#逆向工程基本原理11
固件模拟
模拟(Emulation)是用计算机程序模仿另一个程序或设备的功能。
常见例子:
- 在 PC 上运行游戏机程序
- 在 x86 主机上运行 ARM/MIPS 架构程序
- 在没有真实路由器的情况下运行路由器固件中的服务
什么时候需要模拟:
- 想执行目标软件或硬件逻辑
- 但是缺少目标运行环境
- 想在可观测、可调试的环境中验证漏洞
固件漏洞分析中,静态审计只能说明“这里可能有漏洞”。真正确认漏洞,需要构造输入让程序崩溃、执行命令,或者产生其他可观测副作用。因此需要先把固件服务跑起来,再用 PoC 验证漏洞是否可触发。
Host 与 Guest
模拟的目标是在 Host 上运行 Guest。
- Guest:被模拟对象,即目标固件或固件中的程序
- Host:执行模拟器的机器,一般是普通 PC 或虚拟机
固件模拟的主要困难来自 Host 和 Guest 的差异:
- 指令集不同:例如 Host 是 x86_64,Guest 是 ARM
- 系统环境不同:内核、系统调用、动态库、目录结构可能不同
- 外设环境不同:网卡、NVRAM、传感器、专用设备节点可能不存在
所以固件模拟不是简单运行一个二进制文件,而是补齐它依赖的运行环境。
模拟对象
固件可以按层次拆开看:
1 | 服务 |
不同层次对应不同的模拟目标:
- 硬件:模拟网卡、存储、GPIO、传感器等外设
- 系统:模拟完整操作系统和内核状态
- 程序:模拟单个二进制或脚本的执行
- 服务:模拟多个程序协作提供的功能,如 HTTP、UPnP、VPN
服务和程序不是一一对应关系。一个 HTTP 服务可能依赖 httpd、CGI 程序、配置管理进程和 NVRAM 程序;一个程序也可能同时参与多个服务。
固件模拟技术体系
固件模拟一般分为三个层次:
- 指令级模拟:解决不同 CPU 架构指令如何执行
- 应用级模拟:解决单个程序如何加载、链接和调用系统接口
- 系统级模拟:解决完整 Guest 系统和外设如何运行
指令级模拟是基础。应用级模拟和系统级模拟都要先能执行 Guest 指令。
指令级模拟
指令级模拟的目标是在 Host 中模拟不同 CPU 架构指令的执行。
基本想法:
- 根据目标 CPU 手册模拟寄存器状态
- 按每条指令的语义修改寄存器、内存和标志位
- 运行时将 Guest 指令转换为 Host 指令执行
例如 ARM 指令:
1 | 0xE0802001 add r2, r0, r1 |
模拟器需要做的事情:
- 读取
r0、r1 - 计算
r0 + r1 - 将结果写入
r2 - 按指令语义更新 CPSR 中的标志位
直接做静态指令翻译有明显问题:二进制中无法 100% 区分指令和数据,容易漏翻指令或错把数据当指令。因此主流方案是动态指令集翻译。
1 | Guest 指令 -> 中间表示 IR -> Host 指令 |
QEMU 中的 TCG(Tiny Code Generator)就是这类动态翻译机制。Unicorn 则提供了轻量级 CPU 模拟能力,适合模拟代码片段、做动态插桩和验证局部语义。
指令级模拟的局限:
- 不能完整处理系统调用
- 不能自动加载外部动态库
- 不能正确模拟外设访问
- 只适合代码片段或底层指令行为验证
应用级模拟
应用级模拟的目标是运行固件中的程序和服务,常用于只关心用户态二进制的场景。
相比指令级模拟,应用级模拟需要额外支持:
- 加载与链接
- 系统调用
- 应用内存管理
加载是根据 ELF 等文件格式将程序映射到内存;链接是在运行时加载依赖库,并重定向函数调用地址。跨架构模拟时,模拟器需要能理解 Guest 架构的程序格式和动态链接规则。
系统调用则通常由应用模拟器拦截,再转换为 Host 的系统调用。例如 Guest 程序执行 ARM Linux 的 read(fd, buf, size),模拟器需要把 ARM 寄存器中的参数转换成 Host ABI 需要的参数形式。
1 | Guest: X8 = read 系统调用号, X0 = fd, X1 = buf, X2 = size |
内存管理方面,应用模拟器需要维护 Guest 地址和 Host 地址之间的映射。程序加载、缺页、mmap、brk 等操作都可能触发新的内存映射。
应用级模拟的常见问题
文件缺失
固件启动时可能动态生成文件,但解包后的文件系统里没有这些文件。如果直接模拟某个应用,应用读取不到文件就会退出。
1 | stream = fopen("/dynamic_gen/webui", "r"); |
解决思路:
- 逆向程序,找出缺失文件名
- 如果文件由启动脚本生成,尝试运行对应脚本
- 如果只用于通过存在性检查,可以先创建空文件或空目录
多应用交互缺失
固件应用经常通过 Unix domain socket、共享文件、环境变量、NVRAM 等方式交互。应用级模拟只跑一个进程时,另一个进程不存在,交互就会失败。
1 | if (connect(sock_fd, (struct sockaddr *)&un, sizeof(un)) < 0) { |
解决思路:
- 同时模拟被依赖的应用
- 写一个轻量脚本模拟 socket 服务
- 在确认不影响漏洞触发路径时 patch 掉失败退出逻辑
Quiz 中的例子要求应用成功启动,需要同时满足三件事:
- 创建
/tmp/xmldb_version - 创建
/var/xmldb_sock - 让 socket 返回以
XMLDB开头、且<version>后数字在(5, 10)之间的数据
启动参数和环境变量缺失
有些信息来自固件启动脚本或 Web 服务器环境,单独运行程序时不会自动存在。
1 | remote_ipaddr = getenv("REMOTE_ADDR"); |
解决思路:
- 逆向启动脚本,恢复参数和环境变量
- 手动设置环境变量,如
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 | fd = open("/dev/rcc", O_RDWR); |
解决思路:
- 先尝试创建空设备节点或普通文件,绕过简单存在性检查
- 如果程序会执行
ioctl并检查返回值,就需要实现对应设备逻辑 - 对 QEMU 做设备扩展,注册 MMIO/PMIO 读写函数
缺乏特定网络环境
很多路由器固件会检查固定网卡或网桥名,例如 br0。
1 | strcpy(ifr.ifr_name, "br0"); |
解决思路:
- 根据固件脚本和反编译结果推测网卡名、IP 和网桥结构
- 手动创建对应网桥或网卡
- 确认服务监听的地址和端口没有被 Host 上其他进程占用
未知初始配置
固件常依赖 NVRAM 或配置文件保存初始设置。如果配置缺失,应用会走到错误路径或直接退出。
1 | setting = nvram_get("WLAN_initial"); |
解决思路:
- 在固件中搜索
.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 | mkdir -p workspace |
这里得到的 squashfs-root 就是固件根文件系统。后续操作都在这个目录下完成。
挂载虚拟文件系统
应用虽然是用户态程序,但运行时可能读取 /proc、访问 /dev、查询 /sys。因此需要把 Host 的虚拟文件系统挂进固件根目录。
1 | sudo mount -t proc /proc ./proc |
含义:
/proc:进程、内存、网络状态等运行时信息/dev:常见设备文件,如/dev/null/sys:内核设备和驱动相关信息
还需要根据固件启动脚本恢复目录结构。本实验中参考 etc/init.d/rcS 的逻辑,把只读目录复制成运行时目录:
1 | rm -rf ./etc |
这一步的核心不是机械复制,而是从启动脚本中找出真实设备启动时会准备哪些文件。
准备网络环境
httpd 会检查并监听 br0 网桥,因此需要在 Host 上创建对应网桥并配置 IP。
1 | sudo ip link add br0 type bridge |
如果缺少 br0,服务可能启动失败,或者启动后监听地址不符合预期。
二进制 patch
模拟环境不是真实设备,程序中的部分环境检查可能无法满足。实验中需要对 bin/httpd 做三处 patch:
1 | printf '\x02\x00\x00\xea' | dd of=./bin/httpd bs=1 count=4 seek=156952 conv=notrunc |
注意点:
seek是文件偏移,不是反编译器中的虚拟地址conv=notrunc表示只覆盖指定字节,不截断文件- 需要在反编译器中定位修改位置,说明 patch 前后控制流或指令语义发生了什么变化
- patch 的目的通常是跳过无法还原的环境检查,而不是修改漏洞逻辑本身
启动 HTTP 服务
先检查 80 端口,避免与 Host 上已有服务冲突:
1 | sudo netstat -ptuln | grep :80 |
复制 QEMU 到固件根目录,并用 chroot 启动:
1 | cp `which qemu-arm-static` qemu |
参数含义:
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 | This document has moved to a new location: |
继续观察响应头:
1 | curl -I http://192.168.0.4/main.html |
服务会设置 Cookie:
1 | HTTP/1.0 302 Redirect |
后续访问管理页面或 GoForm 接口时,通常需要携带这个 Cookie:
1 | curl -H "Cookie: password=nqq5gk" http://192.168.0.4/main.html |
如果浏览器或 curl 访问异常,还要检查代理环境变量、浏览器代理配置、br0 是否存在、服务监听地址是否正确。
结束模拟
实验结束后需要解除挂载,并删除创建的网桥:
1 | sudo umount ./proc |
固件漏洞验证
漏洞验证要回答两个问题:
- 漏洞是否真的可以触发
- 漏洞触发后是否可以产生可观测影响
主流方式是编写 PoC(Proof of Concept)。PoC 是用于验证漏洞存在的输入或脚本,重点是证明漏洞触发路径存在,而不是追求完整利用。
本次实验的目标是用 PoC 和调试证据证明漏洞路径可触发,不要求构造完整 EXP 或拿到 shell。报告中更重要的是说明请求参数如何进入目标函数、危险函数或栈缓冲区,而不是只给出 HTTP 返回结果。
PoC 一般包含两部分:
- payload:触发漏洞的数据
- 攻击流程模拟:模拟攻击者与目标服务交互的脚本
编写 PoC 的流程
一般流程:
- 环境准备:获取固件、解包文件系统、启动目标服务
- 漏洞分析:确认漏洞成因、输入入口、数据流和校验条件
- payload 构造:构造能进入危险函数的字段
- 攻击流程模拟:用浏览器、BurpSuite 或脚本发送请求
- 运行验证:观察崩溃、日志、命令输出或调试器状态
Web 固件服务中特别要关注:
- 请求 URL:目标函数注册到了哪个 GoForm 或 CGI 路径
- 参数名称:危险数据来自哪个字段
- 数据格式:GET、POST、JSON、XML 或表单
- Cookie 和登录状态:请求是否必须携带认证信息
- 非用户输入字段:配置项、NVRAM 值可能影响路径选择
requests 编写 PoC
Python 的 requests 可以快速构造 HTTP 请求。对于 GoForm,实验中 GET 和 POST 都可能到达相同处理逻辑,关键是 URL、参数名和 Cookie 要正确。
1 | data = { "filePath": "a" * 2000 } |
如果服务需要 Cookie,使用 Session 更方便:
1 | s = requests.Session() |
可以先请求页面拿到 password Cookie,再发送携带 payload 的 GoForm 请求。若 Host 配了代理,建议在脚本中关闭继承代理,避免请求被代理环境变量影响。
缓冲区溢出验证
缓冲区溢出 PoC 的目标是让超长输入进入固定长度栈缓冲区或危险拷贝函数。
本实验中需要关注 saveParentControlInfo 相关路径。验证思路:
- 找到对应 GoForm URL
- 找到会进入栈缓冲区的参数
- 构造明显超长的字符串
- 在危险函数调用前后下断点,确认 payload 到达栈上
示例请求形态:
1 | 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 |
验证时不要只看 HTTP 返回值。更可靠的证据是 GDB 中能看到:
- 参数寄存器指向 payload
strcpy、sprintf、sscanf等危险函数被调用- 栈上出现长字符串或返回地址附近被覆盖
命令注入验证
命令注入 PoC 的目标是让用户输入被拼接进系统命令。
典型漏洞逻辑:
1 | snprintf(cmd, sizeof(cmd), "cfm post netctrl 51?op=3,string_info=%s", deviceName); |
如果 deviceName 没有过滤命令分隔符,可以构造:
1 | deviceName=aaa; ls |
最终命令变成:
1 | cfm post netctrl 51?op=3,string_info=aaa; ls |
在 PoC 阶段,建议使用无害命令验证,例如:
lsecho pocping -c 1 127.0.0.1
本实验中命令注入漏洞与 formSetSambaConf、/goform/SetSambaCfg 相关。验证时可以在 doSystemCmd 或 system 包装函数附近下断点,观察寄存器或内存中的命令字符串是否包含注入内容。
QEMU 与 GDB 调试
模拟的优势之一是调试能力强。真实设备上只能看到请求和响应;模拟环境中可以观察 payload 在程序内部如何流动。
启动 GDB Server
QEMU user-mode 支持用 -g 参数启动内置 GDB Server,并等待调试器连接。
1 | sudo chroot . ./qemu -L . -g 1234 ./bin/httpd |
这里 1234 是 GDB 连接端口,需要确认没有被占用。
另开终端连接:
1 | cd squashfs-root |
常用命令:
1 | b *0x803B0 |
含义:
b *addr:在指定地址下断点c:继续执行n:单步执行info registers:查看寄存器x/s:按字符串查看内存x/16wx:按 4 字节十六进制查看内存
观察参数传递
ARM 32 位调用约定中,前几个参数通常通过 r0、r1、r2、r3 传递,返回值通常在 r0 中。
调试 GoForm 参数读取函数时,可以这样验证:
- 断在参数读取函数调用前,查看
r0、r1、r2 - 单步到调用后,查看
r0返回值 - 用
x/s $r0确认返回字符串是否为 PoC 中的字段值 - 如果返回值随后写入栈上,用
x/16wx $sp或 pwndbg 的栈视图观察栈内容
例如对 /goform/saveAutoQos 发送 enable 参数后,在读取函数前后下断点,如果返回值指向由多个 A 构成的 payload,就说明 PoC 输入确实进入了目标函数。
调试注意点
调试时容易踩的坑:
- 地址要区分文件偏移和加载后的虚拟地址
- QEMU 等待 GDB 连接时服务不会继续启动,需要连接并
c - 浏览器或脚本需要携带 Cookie,否则可能到不了漏洞函数
- Host 代理可能影响
curl或 requests,必要时关闭代理 - 应用级模拟报错不一定代表失败,关键看目标服务和目标路径能否执行
- 如果服务崩溃,要区分是漏洞触发、环境缺失,还是 patch 错误
总结
三种模拟方式的使用场景:
1 | 指令级模拟:适合指令翻译、插桩、验证代码片段语义 |
本次实验采用的是典型应用级模拟流程:
- 用
binwalk解包固件 - 在
squashfs-root中补齐/proc、/dev、/sys - 根据启动脚本恢复
etc、webroot - 创建
br0网络环境 - patch
httpd中无法满足的环境检查 - 用
chroot + qemu-arm-static启动/bin/httpd - 用
curl、浏览器或脚本验证服务 - 编写 PoC 并通过
gdb-multiarch验证漏洞触发路径
核心思路是:先让目标服务在模拟环境中尽量接近真实运行状态,再让输入沿着预期数据流进入危险函数,最后用调试证据证明漏洞确实被触发。