$ cat ~ / posts /reverse /lecture11 5.1k Words ~ 19 Mins
cover.png
逆向工程基本原理11

#逆向工程基本原理11

exdoubled Lv5

固件模拟

模拟(Emulation)是用计算机程序模仿另一个程序或设备的功能。

常见例子:

  • 在 PC 上运行游戏机程序
  • 在 x86 主机上运行 ARM/MIPS 架构程序
  • 在没有真实路由器的情况下运行路由器固件中的服务

什么时候需要模拟:

  • 想执行目标软件或硬件逻辑
  • 但是缺少目标运行环境
  • 想在可观测、可调试的环境中验证漏洞

固件漏洞分析中,静态审计只能说明“这里可能有漏洞”。真正确认漏洞,需要构造输入让程序崩溃、执行命令,或者产生其他可观测副作用。因此需要先把固件服务跑起来,再用 PoC 验证漏洞是否可触发。

Host 与 Guest

模拟的目标是在 Host 上运行 Guest。

  • Guest:被模拟对象,即目标固件或固件中的程序
  • Host:执行模拟器的机器,一般是普通 PC 或虚拟机

固件模拟的主要困难来自 Host 和 Guest 的差异:

  • 指令集不同:例如 Host 是 x86_64,Guest 是 ARM
  • 系统环境不同:内核、系统调用、动态库、目录结构可能不同
  • 外设环境不同:网卡、NVRAM、传感器、专用设备节点可能不存在

所以固件模拟不是简单运行一个二进制文件,而是补齐它依赖的运行环境。

模拟对象

固件可以按层次拆开看:

1
2
3
4
服务
程序
系统
硬件

不同层次对应不同的模拟目标:

  • 硬件:模拟网卡、存储、GPIO、传感器等外设
  • 系统:模拟完整操作系统和内核状态
  • 程序:模拟单个二进制或脚本的执行
  • 服务:模拟多个程序协作提供的功能,如 HTTP、UPnP、VPN

服务和程序不是一一对应关系。一个 HTTP 服务可能依赖 httpd、CGI 程序、配置管理进程和 NVRAM 程序;一个程序也可能同时参与多个服务。

固件模拟技术体系

固件模拟一般分为三个层次:

  • 指令级模拟:解决不同 CPU 架构指令如何执行
  • 应用级模拟:解决单个程序如何加载、链接和调用系统接口
  • 系统级模拟:解决完整 Guest 系统和外设如何运行

指令级模拟是基础。应用级模拟和系统级模拟都要先能执行 Guest 指令。

指令级模拟

指令级模拟的目标是在 Host 中模拟不同 CPU 架构指令的执行。

基本想法:

  • 根据目标 CPU 手册模拟寄存器状态
  • 按每条指令的语义修改寄存器、内存和标志位
  • 运行时将 Guest 指令转换为 Host 指令执行

例如 ARM 指令:

1
0xE0802001    add r2, r0, r1

模拟器需要做的事情:

  • 读取 r0r1
  • 计算 r0 + r1
  • 将结果写入 r2
  • 按指令语义更新 CPSR 中的标志位

直接做静态指令翻译有明显问题:二进制中无法 100% 区分指令和数据,容易漏翻指令或错把数据当指令。因此主流方案是动态指令集翻译。

1
2
3
Guest 指令  ->  中间表示 IR  ->  Host 指令
ARM -> IR -> x86_64
MIPS -> IR -> x86_64

QEMU 中的 TCG(Tiny Code Generator)就是这类动态翻译机制。Unicorn 则提供了轻量级 CPU 模拟能力,适合模拟代码片段、做动态插桩和验证局部语义。

指令级模拟的局限:

  • 不能完整处理系统调用
  • 不能自动加载外部动态库
  • 不能正确模拟外设访问
  • 只适合代码片段或底层指令行为验证

应用级模拟

应用级模拟的目标是运行固件中的程序和服务,常用于只关心用户态二进制的场景。

相比指令级模拟,应用级模拟需要额外支持:

  • 加载与链接
  • 系统调用
  • 应用内存管理

加载是根据 ELF 等文件格式将程序映射到内存;链接是在运行时加载依赖库,并重定向函数调用地址。跨架构模拟时,模拟器需要能理解 Guest 架构的程序格式和动态链接规则。

系统调用则通常由应用模拟器拦截,再转换为 Host 的系统调用。例如 Guest 程序执行 ARM Linux 的 read(fd, buf, size),模拟器需要把 ARM 寄存器中的参数转换成 Host ABI 需要的参数形式。

1
2
Guest: X8 = read 系统调用号, X0 = fd, X1 = buf, X2 = size
Host : rax = read 系统调用号, rdi = fd, rsi = buf, rdx = size

内存管理方面,应用模拟器需要维护 Guest 地址和 Host 地址之间的映射。程序加载、缺页、mmapbrk 等操作都可能触发新的内存映射。

应用级模拟的常见问题

文件缺失

固件启动时可能动态生成文件,但解包后的文件系统里没有这些文件。如果直接模拟某个应用,应用读取不到文件就会退出。

1
2
3
4
5
stream = fopen("/dynamic_gen/webui", "r");
if (!stream) {
fprintf(stderr, "Can't open webui!");
exit(0);
}

解决思路:

  • 逆向程序,找出缺失文件名
  • 如果文件由启动脚本生成,尝试运行对应脚本
  • 如果只用于通过存在性检查,可以先创建空文件或空目录

多应用交互缺失

固件应用经常通过 Unix domain socket、共享文件、环境变量、NVRAM 等方式交互。应用级模拟只跑一个进程时,另一个进程不存在,交互就会失败。

1
2
3
4
if (connect(sock_fd, (struct sockaddr *)&un, sizeof(un)) < 0) {
printf("connect socket failed\n");
exit(-1);
}

解决思路:

  • 同时模拟被依赖的应用
  • 写一个轻量脚本模拟 socket 服务
  • 在确认不影响漏洞触发路径时 patch 掉失败退出逻辑

Quiz 中的例子要求应用成功启动,需要同时满足三件事:

  • 创建 /tmp/xmldb_version
  • 创建 /var/xmldb_sock
  • 让 socket 返回以 XMLDB 开头、且 <version> 后数字在 (5, 10) 之间的数据

启动参数和环境变量缺失

有些信息来自固件启动脚本或 Web 服务器环境,单独运行程序时不会自动存在。

1
2
3
4
5
remote_ipaddr = getenv("REMOTE_ADDR");
if (!remote_ipaddr) {
fprintf(stderr, "No remote ip address!");
exit(0);
}

解决思路:

  • 逆向启动脚本,恢复参数和环境变量
  • 手动设置环境变量,如 REMOTE_ADDR=127.0.0.1
  • 如果依赖过多,考虑系统级模拟

系统级模拟

系统级模拟的目标是模拟完整固件系统,包括 Guest 内核、设备驱动和外设交互。

相比应用级模拟,系统级模拟增加了:

  • 对外设访问的支持
  • 更完整的系统调用和内核行为
  • Guest 内核自己的内存管理机制

固件访问外设一般经过如下路径:

1
应用程序  ->  系统调用/API  ->  驱动/HAL  ->  MMIO/PMIO/DMA/中断  ->  外设

常见外设交互方式:

  • MMIO:将设备寄存器映射到内存地址,CPU 通过读写内存访问设备
  • PMIO:通过 I/O 端口访问设备
  • 中断:外设事件发生后通知 CPU
  • DMA:外设直接读写内存,完成后再中断 CPU

系统级模拟器需要截获这些访问,并把它们转发给真实 Host 设备,或者用软件模拟一个虚拟设备。

QEMU 提供了许多常见设备模拟器,如网卡、网桥、USB、PCI、磁盘和显示设备。但嵌入式设备中的专用外设经常无法直接支持,这时需要人工补设备模型,或者用“空设备”骗过应用检查。

系统级模拟的常见问题

不支持的外设访问

固件可能访问 /dev/special/dev/rcc 这类真实设备上才有的节点。

1
2
3
4
5
6
7
8
fd = open("/dev/rcc", O_RDWR);
if (fd == -1) {
exit(-1);
}
ioctl(fd, MY_DEVICE_READ_VALUE, &value);
if (value != 0xfffffc48) {
exit(-1);
}

解决思路:

  • 先尝试创建空设备节点或普通文件,绕过简单存在性检查
  • 如果程序会执行 ioctl 并检查返回值,就需要实现对应设备逻辑
  • 对 QEMU 做设备扩展,注册 MMIO/PMIO 读写函数

缺乏特定网络环境

很多路由器固件会检查固定网卡或网桥名,例如 br0

1
2
3
4
5
6
7
strcpy(ifr.ifr_name, "br0");
ioctl(skfd, SIOCGIFFLAGS, &ifr);
if (ifr.ifr_flags & IFF_RUNNING) {
return "br0 UP";
} else {
exit(0);
}

解决思路:

  • 根据固件脚本和反编译结果推测网卡名、IP 和网桥结构
  • 手动创建对应网桥或网卡
  • 确认服务监听的地址和端口没有被 Host 上其他进程占用

未知初始配置

固件常依赖 NVRAM 或配置文件保存初始设置。如果配置缺失,应用会走到错误路径或直接退出。

1
2
3
4
5
setting = nvram_get("WLAN_initial");
if (!setting) {
printf("Environment Error!\n");
exit(-1);
}

解决思路:

  • 在固件中搜索 .nvram.config 或默认配置文件
  • 从同系列固件中提取配置
  • 对关键键值做最小补齐

系统级模拟的优势是保真度高,适合完整测试固件系统;缺点是外设模拟困难、运行速度慢、启动和调试配置更复杂。

QEMU、chroot 与固件服务模拟

本次实验主要使用应用级模拟方式:在解包出的固件文件系统中,用 QEMU user-mode 运行 ARM 架构的 /bin/httpd

需要的基础工具:

  • binwalk:解包固件
  • qemu-arm-static:在 x86 环境中执行 ARM 二进制
  • chroot:切换根目录,让程序看到固件自己的目录结构
  • gdb-multiarch:跨架构调试 ARM 程序
  • pwndbg:可选,用于增强 GDB 调试界面

解包固件

实验固件为 Tenda V15.03.05.19,HTTP 服务程序位于解包后的 bin/httpd

1
2
3
4
5
6
7
8
9
mkdir -p workspace
cp attachment/Tenda_V15.03.05.19_fs.bin workspace/Tenda_V15.03.05.19_fs.bin
cd workspace

binwalk -Me ./Tenda_V15.03.05.19_fs.bin
mv ./extractions/Tenda_V15.03.05.19_fs.bin.extracted/0/squashfs-root/ .
rm -rf ./extractions

cd squashfs-root

这里得到的 squashfs-root 就是固件根文件系统。后续操作都在这个目录下完成。

挂载虚拟文件系统

应用虽然是用户态程序,但运行时可能读取 /proc、访问 /dev、查询 /sys。因此需要把 Host 的虚拟文件系统挂进固件根目录。

1
2
3
sudo mount -t proc /proc ./proc
sudo mount -o bind /dev ./dev
sudo mount -t sysfs /sys ./sys

含义:

  • /proc:进程、内存、网络状态等运行时信息
  • /dev:常见设备文件,如 /dev/null
  • /sys:内核设备和驱动相关信息

还需要根据固件启动脚本恢复目录结构。本实验中参考 etc/init.d/rcS 的逻辑,把只读目录复制成运行时目录:

1
2
3
4
rm -rf ./etc
rm -rf ./webroot
cp -r ./etc_ro ./etc
cp -r ./webroot_ro ./webroot

这一步的核心不是机械复制,而是从启动脚本中找出真实设备启动时会准备哪些文件。

准备网络环境

httpd 会检查并监听 br0 网桥,因此需要在 Host 上创建对应网桥并配置 IP。

1
2
3
4
5
sudo ip link add br0 type bridge
sudo ip addr add 192.168.0.4/24 dev br0
sudo ip link set br0 up

ifconfig br0

如果缺少 br0,服务可能启动失败,或者启动后监听地址不符合预期。

二进制 patch

模拟环境不是真实设备,程序中的部分环境检查可能无法满足。实验中需要对 bin/httpd 做三处 patch:

1
2
3
printf '\x02\x00\x00\xea' | dd of=./bin/httpd bs=1 count=4 seek=156952 conv=notrunc
printf '\x05\x00\x00\xea' | dd of=./bin/httpd bs=1 count=4 seek=156988 conv=notrunc
printf '\x00\x00\xa0\xe1' | dd of=./bin/httpd bs=1 count=4 seek=157872 conv=notrunc

注意点:

  • seek 是文件偏移,不是反编译器中的虚拟地址
  • conv=notrunc 表示只覆盖指定字节,不截断文件
  • 需要在反编译器中定位修改位置,说明 patch 前后控制流或指令语义发生了什么变化
  • patch 的目的通常是跳过无法还原的环境检查,而不是修改漏洞逻辑本身

启动 HTTP 服务

先检查 80 端口,避免与 Host 上已有服务冲突:

1
sudo netstat -ptuln | grep :80

复制 QEMU 到固件根目录,并用 chroot 启动:

1
2
cp `which qemu-arm-static` qemu
sudo chroot . ./qemu -L . ./bin/httpd

参数含义:

  • chroot .:把当前 squashfs-root 作为程序看到的根目录
  • ./qemu:在 chroot 后运行 QEMU user-mode
  • -L .:让 QEMU 在当前根目录中查找动态链接库
  • ./bin/httpd:被模拟执行的 ARM 程序

应用级模拟不是完整系统模拟,启动时出现一些非关键报错是正常的。判断成功的关键是服务是否可访问、目标功能是否进入预期路径。

验证服务

通过 curl 访问根路径:

1
curl http://192.168.0.4

如果返回跳转到 main.html,说明 HTTP 服务已经启动。

1
2
This document has moved to a new location:
http://192.168.0.4/main.html

继续观察响应头:

1
curl -I http://192.168.0.4/main.html

服务会设置 Cookie:

1
2
3
4
HTTP/1.0 302 Redirect
Server: Http Server
Set-Cookie: password=nqq5gk; path=/
Location: http://192.168.0.4/main.html

后续访问管理页面或 GoForm 接口时,通常需要携带这个 Cookie:

1
curl -H "Cookie: password=nqq5gk" http://192.168.0.4/main.html

如果浏览器或 curl 访问异常,还要检查代理环境变量、浏览器代理配置、br0 是否存在、服务监听地址是否正确。

结束模拟

实验结束后需要解除挂载,并删除创建的网桥:

1
2
3
4
5
sudo umount ./proc
sudo umount ./dev
sudo umount ./sys

sudo ip link delete br0

固件漏洞验证

漏洞验证要回答两个问题:

  • 漏洞是否真的可以触发
  • 漏洞触发后是否可以产生可观测影响

主流方式是编写 PoC(Proof of Concept)。PoC 是用于验证漏洞存在的输入或脚本,重点是证明漏洞触发路径存在,而不是追求完整利用。

本次实验的目标是用 PoC 和调试证据证明漏洞路径可触发,不要求构造完整 EXP 或拿到 shell。报告中更重要的是说明请求参数如何进入目标函数、危险函数或栈缓冲区,而不是只给出 HTTP 返回结果。

PoC 一般包含两部分:

  • payload:触发漏洞的数据
  • 攻击流程模拟:模拟攻击者与目标服务交互的脚本

编写 PoC 的流程

一般流程:

  1. 环境准备:获取固件、解包文件系统、启动目标服务
  2. 漏洞分析:确认漏洞成因、输入入口、数据流和校验条件
  3. payload 构造:构造能进入危险函数的字段
  4. 攻击流程模拟:用浏览器、BurpSuite 或脚本发送请求
  5. 运行验证:观察崩溃、日志、命令输出或调试器状态

Web 固件服务中特别要关注:

  • 请求 URL:目标函数注册到了哪个 GoForm 或 CGI 路径
  • 参数名称:危险数据来自哪个字段
  • 数据格式:GET、POST、JSON、XML 或表单
  • Cookie 和登录状态:请求是否必须携带认证信息
  • 非用户输入字段:配置项、NVRAM 值可能影响路径选择

requests 编写 PoC

Python 的 requests 可以快速构造 HTTP 请求。对于 GoForm,实验中 GET 和 POST 都可能到达相同处理逻辑,关键是 URL、参数名和 Cookie 要正确。

1
2
data = { "filePath": "a" * 2000 }
response = requests.post(url, data=data)

如果服务需要 Cookie,使用 Session 更方便:

1
2
3
s = requests.Session()
s.get("http://192.168.0.4/main.html")
s.post(url, data=data)

可以先请求页面拿到 password Cookie,再发送携带 payload 的 GoForm 请求。若 Host 配了代理,建议在脚本中关闭继承代理,避免请求被代理环境变量影响。

缓冲区溢出验证

缓冲区溢出 PoC 的目标是让超长输入进入固定长度栈缓冲区或危险拷贝函数。

本实验中需要关注 saveParentControlInfo 相关路径。验证思路:

  • 找到对应 GoForm URL
  • 找到会进入栈缓冲区的参数
  • 构造明显超长的字符串
  • 在危险函数调用前后下断点,确认 payload 到达栈上

示例请求形态:

1
2
GET /goform/saveParentControlInfo?deviceId=1&enable=1&time=AAAA...&url_enable=0&urls=&day=1&block=1&connectType=1&limit_type=1&deviceName=test HTTP/1.1
Cookie: password=<cookie>

验证时不要只看 HTTP 返回值。更可靠的证据是 GDB 中能看到:

  • 参数寄存器指向 payload
  • strcpysprintfsscanf 等危险函数被调用
  • 栈上出现长字符串或返回地址附近被覆盖

命令注入验证

命令注入 PoC 的目标是让用户输入被拼接进系统命令。

典型漏洞逻辑:

1
2
snprintf(cmd, sizeof(cmd), "cfm post netctrl 51?op=3,string_info=%s", deviceName);
system(cmd);

如果 deviceName 没有过滤命令分隔符,可以构造:

1
deviceName=aaa; ls

最终命令变成:

1
cfm post netctrl 51?op=3,string_info=aaa; ls

在 PoC 阶段,建议使用无害命令验证,例如:

  • ls
  • echo poc
  • ping -c 1 127.0.0.1

本实验中命令注入漏洞与 formSetSambaConf/goform/SetSambaCfg 相关。验证时可以在 doSystemCmdsystem 包装函数附近下断点,观察寄存器或内存中的命令字符串是否包含注入内容。

QEMU 与 GDB 调试

模拟的优势之一是调试能力强。真实设备上只能看到请求和响应;模拟环境中可以观察 payload 在程序内部如何流动。

启动 GDB Server

QEMU user-mode 支持用 -g 参数启动内置 GDB Server,并等待调试器连接。

1
sudo chroot . ./qemu -L . -g 1234 ./bin/httpd

这里 1234 是 GDB 连接端口,需要确认没有被占用。

另开终端连接:

1
2
3
4
5
6
cd squashfs-root
gdb-multiarch

set architecture arm
target remote :1234
set sysroot .

常用命令:

1
2
3
4
5
6
7
8
b *0x803B0
b *0x803B4
c
n
info registers
x/s $r0
x/s $r1
x/16wx $sp

含义:

  • b *addr:在指定地址下断点
  • c:继续执行
  • n:单步执行
  • info registers:查看寄存器
  • x/s:按字符串查看内存
  • x/16wx:按 4 字节十六进制查看内存

观察参数传递

ARM 32 位调用约定中,前几个参数通常通过 r0r1r2r3 传递,返回值通常在 r0 中。

调试 GoForm 参数读取函数时,可以这样验证:

  • 断在参数读取函数调用前,查看 r0r1r2
  • 单步到调用后,查看 r0 返回值
  • x/s $r0 确认返回字符串是否为 PoC 中的字段值
  • 如果返回值随后写入栈上,用 x/16wx $sp 或 pwndbg 的栈视图观察栈内容

例如对 /goform/saveAutoQos 发送 enable 参数后,在读取函数前后下断点,如果返回值指向由多个 A 构成的 payload,就说明 PoC 输入确实进入了目标函数。

调试注意点

调试时容易踩的坑:

  • 地址要区分文件偏移和加载后的虚拟地址
  • QEMU 等待 GDB 连接时服务不会继续启动,需要连接并 c
  • 浏览器或脚本需要携带 Cookie,否则可能到不了漏洞函数
  • Host 代理可能影响 curl 或 requests,必要时关闭代理
  • 应用级模拟报错不一定代表失败,关键看目标服务和目标路径能否执行
  • 如果服务崩溃,要区分是漏洞触发、环境缺失,还是 patch 错误

总结

三种模拟方式的使用场景:

1
2
3
指令级模拟:适合指令翻译、插桩、验证代码片段语义
应用级模拟:适合测试固件中的单个用户态程序或服务
系统级模拟:适合完整测试固件系统和外设相关逻辑

本次实验采用的是典型应用级模拟流程:

  1. binwalk 解包固件
  2. squashfs-root 中补齐 /proc/dev/sys
  3. 根据启动脚本恢复 etcwebroot
  4. 创建 br0 网络环境
  5. patch httpd 中无法满足的环境检查
  6. chroot + qemu-arm-static 启动 /bin/httpd
  7. curl、浏览器或脚本验证服务
  8. 编写 PoC 并通过 gdb-multiarch 验证漏洞触发路径

核心思路是:先让目标服务在模拟环境中尽量接近真实运行状态,再让输入沿着预期数据流进入危险函数,最后用调试证据证明漏洞确实被触发。

$ discussion
# Comments
waline