CNVD-2018-01084 是 Dlink 系列路由器的一个漏洞,影响设备包括 DIR 615/645/815 路由器。
漏洞存在于 /htdocs/cgibin 二进制文件中,漏洞成因是 service.cgi 中未经任何过滤检查就将用户的输入拼接到原始命令中,导致任意代码执行。
环境搭建 首先下载存在漏洞的固件,地址 ftp://ftp2.dlink.com/PRODUCTS/DIR-645/REVA/
我下载的是 1.02 版本,这个漏洞在 1.03 以上版本被修复了,注意不要下错。
固件为 MIPS 架构,所以需要利用 QEMU 来模拟执行,关于 QEMU 和环境的具体搭建流程可以参考我上一篇文章 https://wzt.ac.cn/2019/03/19/CVE-2018-5767/
这个固件属于 CGI(通用网关接口),所以不需要搭建网络环境即可运行。
装好必要的软件之后使用命令
1 sudo chroot . ./qemu -g 10000 ./htdocs/cgibin
即可运行程序,如果添加了 -g 选项,那么在外部使用 gdb 链接到 10000 端口就能调试程序了。
运行起来会提示
1 CGI.BIN, unknown command cgibin
这是正常现象,因为我们还没有访问任何 CGI 服务。
逆向分析 由于是 MIPS 程序,IDA 的反编译插件不能解析伪代码,在之前我们只能硬着头皮看汇编,或者利用 RETDEC 大概看看函数调用关系,但是今年上半年 NSA 发布了 Ghidra,相信大家都有所耳闻,到现在已经迭代了 5 个版本,在 GITHUB 上可以下载到最新的 9.0.4 版本源代码。
如果不想手动编译的可以直接去官方网站下载 release 版本。
Ghidra 虽然速度和交互性弱于 IDA,但是它能反编译 mips 等 IDA 暂不支持的架构,这次我们就利用它来完成逆向分析。
打开 Ghidra 新建项目,并导入 cgibin 文件,让 ghidra 自动分析:
在左边的函数窗口中有一个搜索栏,搜索 main 即可找到 main 函数,点击汇编窗口,右侧的反编译窗口就自动出现的 main 函数的伪代码:
反编译的效果感觉和 IDA 的不相上下,只是交互性和细节上差一些。
根据披露信息,漏洞位于 service.cgi 中,在 main 函数找一找就能找到这一项:
1 2 3 4 5 6 7 8 9 10 else { iVar2 = strcmp (__s,"pigwidgeon.cgi" ); if (iVar2 == 0 ) { UNRECOVERED_JUMPTABLE = pigwidgeoncgi_main; } else { iVar2 = strcmp (__s,"service.cgi" ); if (iVar2 == 0 ) { UNRECOVERED_JUMPTABLE = servicecgi_main; }
观察代码发现 cgibin 从 web 端接受了请求,然后取出 URL 中的访问参数,筛选出对应的 cgi 服务,并调用相关函数。
Service.cgi 对应的是 servicecgi_main 函数,伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 int servicecgi_main (void ) { undefined *__ptr; char *__s1; int *piVar1; int iVar2; void *__ptr_00; uint uVar3; char *__format; int iVar4; char acStack280 [260 ]; memset (acStack280,0 ,0x100 ); __s1 = getenv("REQUEST_METHOD" ); if (__s1 == (char *)0x0 ) { __s1 = "No HTTP request" ; goto LAB_0040ab48; } iVar4 = strcasecmp(__s1,"POST" ); if (iVar4 == 0 ) { uVar3 = 0x400 ; LAB_0040aad0: iVar4 = cgibin_parse_request(FUN_0040adcc,0 ,uVar3); if (iVar4 < 0 ) { __s1 = "Unable to parse HTTP request" ; } else { iVar4 = sess_ispoweruser(); if (iVar4 != 0 ) { iVar2 = FUN_0040a950("EVENT" ); __s1 = (char *)FUN_0040a950("ACTION" ); iVar4 = FUN_0040a950("SERVICE" ); if (iVar2 == 0 ) { if ((iVar4 != 0 ) && (__s1 != (char *)0x0 )) { iVar2 = strcasecmp(__s1,"START" ); if (iVar2 == 0 ) { __s1 = "service %s start > /dev/null" ; } else { iVar2 = strcasecmp(__s1,"STOP" ); if (iVar2 == 0 ) { __s1 = "service %s stop > /dev/null" ; } else { iVar2 = strcasecmp(__s1,"RESTART" ); if (iVar2 != 0 ) { __format = "Unknown action - \'%s\'" ; goto LAB_0040ab00; } __s1 = "service %s restart > /dev/null" ; } } goto LAB_0040ac38; } } else { __s1 = "event %s > /dev/null" ; iVar4 = iVar2; LAB_0040ac38: lxmldbc_system(__s1,iVar4); } memset (acStack280,0 ,0x100 ); iVar4 = 0 ; goto LAB_0040ac7c; } __s1 = "Not authorized" ; } LAB_0040ab48: snprintf (acStack280,0x100 ,__s1); } else { iVar4 = strcasecmp(__s1,"GET" ); if (iVar4 == 0 ) { uVar3 = 0x40 ; goto LAB_0040aad0; } __format = "Unsupport HTTP request - %s" ; LAB_0040ab00: snprintf (acStack280,0x100 ,__format,__s1); } iVar4 = -1 ; LAB_0040ac7c: puts ("HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\n\r" ); puts ("<?xml version=\"1.0\" encoding=\"utf-8\"?>" ); puts ("<report>" ); if (iVar4 == 0 ) { __s1 = "OK" ; } else { __s1 = "FAILED" ; } printf ("\t<result>%s</result>\n" ,__s1); printf ("\t<message>%s</message>\n" ,acStack280); puts ("</report>" ); cgibin_clean_tempfiles(); while (__ptr = PTR_LOOP_00420120, (undefined **)PTR_LOOP_00420120 != &PTR_LOOP_00420120) { piVar1 = *(int **)(PTR_LOOP_00420120 + 4 ); iVar2 = *(int *)PTR_LOOP_00420120; __ptr_00 = *(void **)(PTR_LOOP_00420120 + 8 ); *piVar1 = iVar2; *(int **)(iVar2 + 4 ) = piVar1; *(undefined4 *)__ptr = 0 ; *(undefined4 *)(__ptr + 4 ) = 0 ; if (__ptr_00 != (void *)0x0 ) { free (__ptr_00); } if (*(void **)(__ptr + 0xc ) != (void *)0x0 ) { free (*(void **)(__ptr + 0xc )); } free (__ptr); } return iVar4; }
稍微观察一下发现函数内部先处理传递进来的 HTTP 请求,但是这里的请求处理流程很奇怪,利用 getenv 函数从系统环境变量中获取请求字段。
查找文件内容之后发现了调用 cgi 的文件,使用命令在 htdocs 目录查找所有包含 cgi 字符串的文件:
得到输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 匹配到二进制文件 cgibin web/js/comm.js:393: ajaxObj.sendRequest("hedwig.cgi", xml.XDoc); web/js/comm.js:396:/* submit the action type to pigwidgeon.cgi for caching(saving) config or restarting service */ web/js/comm.js:409: ajaxObj.sendRequest("pigwidgeon.cgi", payload); web/js/postxml.js:44: AJAX.sendRequest("captcha.cgi", "DUMMY=YES"); web/js/postxml.js:73: AJAX.sendRequest("session.cgi", payload); web/js/postxml.js:89: AJAX.sendRequest("session.cgi", payload); webinc/js/wiz_wan_fresetv6.php:859: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/js/bsc_sms_inbox.php:123: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/js/adv_qos.php:627: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/js/tools_system.php:67: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/js/tools_time.php:318: ajaxObj.sendRequest("service.cgi", "SERVICE=DEVICE.TIME&ACTION=RESTART"); webinc/js/tools_time.php:360: ajaxObj.sendRequest("service.cgi", "SERVICE=DEVICE.TIME&ACTION=RESTART"); webinc/js/adv_wps.php:230: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/js/tools_sys_ulcfg.php:67: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/js/st_ipv6.php:17: ajaxObj.sendRequest("service.cgi", "EVENT="+EventName); webinc/js/tools_firmware.php:39: ajaxObj.sendRequest("service.cgi", "EVENT=CHECKFW"); webinc/js/tools_email.php:153: ajaxObj.sendRequest("service.cgi", "EVENT=SENDMAIL"); webinc/js/st_device.php:17: ajaxObj.sendRequest("service.cgi", "EVENT="+EventName); webinc/js/wiz_freset.php:1103: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/js/adv_dlna.php:179: ajaxObj.sendRequest("service.cgi", "EVENT="+EventName); webinc/js/adv_routingv6.php:173: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/js/bsc_lan.php:1027: ajaxObj.sendRequest("service.cgi", "EVENT="+svc); webinc/body/tools_system.php:8: <form id="dlcfgbin" action="dlcfg.cgi" method="post"> webinc/body/tools_system.php:18: <form id="ulcfgbin" action="seama.cgi" method="post" enctype="multipart/form-data"> webinc/body/tools_firmware.php:63:<form id="fwup" action="fwup.cgi" method="post" enctype="multipart/form-data">
基本都是在 js 中调用的 service.cgi,例如 tools_system.php:
1 2 3 4 5 6 7 8 9 10 11 ajaxObj.createRequest (); ajaxObj.onCallback = function (xml ) { ajaxObj.release (); if (xml.Get ("/report/result" )!="OK" ) BODY.ShowAlert ("Internal ERROR!\nEVENT " +svc+": " +xml.Get ("/report/message" )); else BODY.ShowCountdown (banner, msgArray, sec, url); } ajaxObj.setHeader ("Content-Type" , "application/x-www-form-urlencoded" ); ajaxObj.sendRequest ("service.cgi" , "EVENT=" +svc);
类似的还有很多,web 端通过 js 构造请求,并将请求发送到 cgibin 进行进一步处理。
回到代码上,首先获取了 REQUEST_METHOD 字段,判断是否为 POST,然后进入 cgibin_parse_request 函数进一步解析 HTTP 请求。
cgibin_parse_request 函数中构造了 sobj 结构体,函数大致功能是取出 CONTENT_TYPE、CONTENT_LENGTH、REQUEST_URI 这几个请求字段,进行进一步操作。
关键点在于 REQUEST_URI,它表示了当前请求的访问参数,关键代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 __nptr_00 = getenv("REQUEST_URI" ); if (__nptr_00 == (char *)0x0 ) { iVar4 = -1 ; } else { __nptr_00 = strchr (__nptr_00,0x3f ); iVar4 = 0 ; if (__nptr_00 != (char *)0x0 ) { local_38 = 0 ; sVar2 = strlen (__nptr_00 + 1 ); FUN_00402dc0(&local_38,(int )(__nptr_00 + 1 ),sVar2); FUN_00402dc0(&local_38,0 ,0 ); iVar4 = 0 ; } }
从 REQUEST_URI 中定位 ? ,然后取出问号之后的内容,计算长度。
函数 FUN_00402dc0 的主要功能是对取出的参数进行 URLdecode,并写入某块内存区域。
当 cgibin_parse_request 函数完成了 HTTP 请求解析动作,并且正确的返回,servicecgi_main 函数会继续下一步操作。
首先检查当前用户 session 是否为管理员,对应函数是 sess_ispoweruser,由于在模拟环境中,这个函数无法获取到 session,我们可以直接 patch 掉这个函数。身份验证成功之后继续解析 REQUEST_URI 的内容,涉及的指令有三个:EVENT、ACTION、SERVICE。
观察代码发现 EVENT 的限制最少,当解析到 EVENT 的时候跳出 if 判断,进入 else 流程;
1 2 3 4 5 6 else { __s1 = "event %s > /dev/null" ; iVar4 = iVar2; LAB_0040ac38: lxmldbc_system(__s1,iVar4); }
关键函数为 lxmldbc_system:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void lxmldbc_system (char *pcParm1,undefined4 uParm2,undefined4 uParm3,undefined4 uParm4) { undefined4 local_res4; undefined4 local_res8; undefined4 local_resc; char acStack1036 [1028 ]; local_res4 = uParm2; local_res8 = uParm3; local_resc = uParm4; vsnprintf(acStack1036,0x400 ,pcParm1,&local_res4); system(acStack1036); return ; }
根据传入的参数,这个函数直接使用 vsnprintf 将 “event %s > /dev/null” 和和用户传入的 EVENT 拼接,并利用 system 执行。
这里显然存在命令注入漏洞,注入的格式大概是;
运行上面的命令可以同时得到三条指令的结果。
如果我们构造 EVENT=&ls&,那么拼接出来的结果就是 event &ls& > /dev/null,从而完成任意命令执行。
调试 1 Update(2021-02-24):当 IDA 使用原生 gdb 调试的时候,会弹出警告信息,提示我们 IDA 无法读取某些内存区域的数据,通常来说这是正常情况,想要读取目标内存数据的话可以手动设置地址范围,具体做法大家可自行搜索。
根据逆向分析的结果,我们需要手动传入一些参数以防止程序自动退出。按照网上的教程构造如下命令:
1 sudo chroot . ./qemu -0 "service.cgi" -g 10000 -E REQUEST_METHOD="POST" -E CONTENT_LENGTH=10 -E REQUEST_URI="service.cgi?EVENT=%26ls%26" -E CONTENT_TYPE="application/x-www-form-urlencoded" -E HTTP_COOKIE="uid=aaaaa" ./htdocs/cgibin
qemu 利用 -0 传入第一个参数,满足 main 函数的需求,进入 servicecgi_main 函数。
利用 -E 选项传入自定义的环境变量,满足判断,REQUEST_URI 中包含待注入的命令(URL 编码)。
在终端运行后打开 gdb-multiarch
1 2 file ./htdocs/cgibin target remote 127.0.0.1:10000
两条命令连接到 cgibin 进程上面:
按照程序逻辑我们在 0x0040aad8 下断点,然后运行程序
当返回 gdb 界面的时候 v0 可能是 -1,代表 cgibin_parse_request 的逻辑判断失败。不要慌,我们手动 patch 程序逻辑到正确的分支即:0x0040AB20。这里和网上的文档不同,我猜测是 qemu 的环境问题。
接下来执行到 sess_ispoweruser 函数,程序会进入 20 秒左右的假死状态.
此处我们可以打开 -strace 寻找原因,修改启动命令,添加 -strace 选项再执行,到了 sess_ispoweruser 函数窗口会打印一堆信息:
问题在于 qemu 模拟环境中找不到 /var/session 文件,但是函数一直尝试去获取 session,导致程序卡在这里循环。
去除 strace 选项继续调试,patch 掉 session 验证函数,转移到正确分支:0x0040AB58,接着就可以直接在存在漏洞的函数头部下断点了,地址是:00410f14
继续执行 gdb 断在 lxmldbc_system 函数头部,单步调试看到 vsnprintf 函数的几个参数:
显然是把字符串 “&ls&” 拼接到了 “event %s > /dev/null” 中,来到 system 函数内部,参数为:
命令注入成功。
但是不要高兴的太早,如果继续执行程序有很大可能只会打印出如下信息:
1 2 3 4 5 6 7 8 HTTP/1.1 200 OK Content-Type: text/xml <?xml version="1.0" encoding="utf-8" ?> <report > <result > OK</result > <message > </message > </report >
我们已经注入了 ls 命令,为什么没有效果呢?
再次打开 strace(注意跳过 sess_ispoweruser 函数)
这里找到了答案,execve 系统调用错误,显示找不到文件。后来在大佬的文章上看到这是 qemu 的锅,我的 qemu 是用 apt 安装的,版本在 2.5.0,这个版本的 qemu user 模式没有实现 execve 函数。需要下载 qemu 2.9 版本并且加上 -execve 参数才行(这么说我上一篇漏洞也是由于 qemu 的问题才起不来 shell QAQ)。
关于 2.9 qemu 加 patch 的问题,没有太多的教程,不过我发现 QEMU 3.0.0 版本存在一个 -sandbox 选项,内部有如下描述
1 2 use 'elevateprivileges' to allow or deny QEMU process to elevate its privileges by blacklisting all set*uid|gid system calls. The value 'children' will deny set*uid|gid system calls for main QEMU process but will allow forks and execves to run unprivileged
不知道是不是官方添加的 execve 系统调用?
最后看一下官方的修复手段:
下载比较新的固件(1.03 以上),用 Ghidra 打开,直接定位到存在漏洞的函数附近:
发现函数的参数传递变成了 文件路径 + 参数形式。进入函数内部:
修复手段简单粗暴,直接 fork 出一个新的进程,然后关闭 stdout,利用 execl 执行命令。
由于 execl 的特性,我们再注入的命令就无效了。例如下面两个程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main () { printf ("execl test\n" ); char * var1 = "/bin/ls" ; char * var2 = "ls" ; char * var3 = "-la" ; char * var4 = "/etc/passwd" ; int ret = execl(var1, var2, var3, var4, (char *)0 ); }
1 2 3 4 5 6 7 8 9 10 11 12 13 #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main () { printf ("execl test\n" ); char * var1 = "/bin/ls" ; char * var2 = "ls" ; char * var3 = "-la" ; char * var4 = "/etc/passwd&ls&" ; int ret = execl(var1, var2, var3, var4, (char *)0 ); }
大家可以自行编译运行查看结果。