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 ); }  
大家可以自行编译运行查看结果。