4. 固件动态分析:QEMU模拟运行、GDB调试固件、网络服务模拟与漏洞触发

静态分析能看到代码结构,但看不到代码的「脾气」。我见过太多人把固件反编译出来,盯着汇编看了三天,愣是没发现一个缓冲区溢出——结果一跑起来,当场崩溃。动态分析就是让固件真正「活过来」,看它怎么分配内存、怎么处理网络包、怎么在异常路径上翻车。

这一章我们聚焦三件事:用QEMU把固件跑起来、用GDB在关键函数下断点、模拟网络服务触发漏洞。说白了,就是给固件搭个手术台,边运行边解剖。

4.1 QEMU模拟运行:让固件在PC上「复活」

QEMU是个好东西。它不仅能模拟CPU指令集,还能模拟外设、中断控制器、串口。我最早用QEMU跑固件是在2016年,当时一个路由器固件死活跑不起来,后来发现是NAND Flash的ECC校验没模拟对——折腾了两天。

常见的固件运行场景分两种:

  • 系统级模拟:模拟整个板子,包括CPU、内存、外设,适合跑完整的Linux内核+文件系统。
  • 用户态模拟:只模拟CPU指令集,直接跑单个二进制文件,适合分析某个网络服务或命令行工具。

我个人习惯先用用户态模拟快速验证,确认目标二进制能正常执行后,再切到系统级模拟做完整测试。

4.1.1 用户态模拟:快速上手

假设你从固件里提取出一个MIPS架构的Web服务程序 httpd,想看看它启动后监听哪个端口。用QEMU用户态模拟,一条命令就够了:

# 安装MIPS用户态模拟器
sudo apt install qemu-user-static

# 模拟运行,指定CPU架构和库路径
qemu-mips-static -L ./squashfs-root ./squashfs-root/usr/sbin/httpd

这里 -L 参数指定根文件系统路径,QEMU会从那里加载动态链接库。如果缺库,它会报错——嗯,这时候你就得去固件里翻对应的 .so 文件了。

小技巧:如果目标二进制是静态编译的,连 -L 都不用加。直接 qemu-mips-static ./binary 就能跑。静态编译的固件程序在IoT设备里很常见,省去了很多库依赖的麻烦。

4.1.2 系统级模拟:完整板级环境

用户态模拟有个局限:它模拟不了硬件中断、DMA、网络控制器。如果你的固件需要完整的Linux内核启动流程,或者要跟硬件寄存器交互,就得用系统级模拟。

系统级模拟需要三个东西:

  • 内核镜像(vmlinux或zImage)
  • 根文件系统(squashfs、jffs2等)
  • 设备树文件(.dtb)

我一般这么启动:

qemu-system-mips -M malta \
  -kernel vmlinux \
  -drive file=rootfs.squashfs,format=raw \
  -append "root=/dev/sda console=ttyS0" \
  -nographic \
  -netdev user,id=net0,hostfwd=tcp::8080-:80 \
  -device e1000,netdev=net0

注意最后两行:hostfwd=tcp::8080-:80 把宿主机的8080端口映射到QEMU虚拟机的80端口。这样你在浏览器里访问 http://localhost:8080,就能直接打到固件里的Web服务了。

踩坑提醒:我曾经遇到一个固件,启动后串口没输出,以为是内核崩溃了。折腾了半天才发现是内核启动参数里 console=ttyS0 写成了 ttyS1——QEMU默认的串口是ttyS0。先确认串口号,再怀疑内核。

4.2 GDB调试固件:在二进制世界里下断点

固件跑起来了,但怎么知道它执行到哪一步了?GDB配合QEMU的GDB stub,能让你像调试普通程序一样调试固件。

QEMU内置了GDB远程调试功能,启动时加个 -s 参数就行:

qemu-system-mips -M malta -kernel vmlinux -nographic -s

-s 是 -gdb tcp::1234 的简写,意思是QEMU在1234端口等待GDB连接。

然后在另一个终端启动GDB:

mips-linux-gnu-gdb vmlinux
(gdb) target remote :1234
(gdb) break *0x80010000
(gdb) continue

这里 break *0x80010000 是在绝对地址0x80010000下断点——这个地址通常是MIPS内核的入口点。如果你想调试某个具体的函数,比如 httpd 里的 process_request,得先找到它的地址。

4.2.1 找函数地址的两种方法

方法 命令/工具 适用场景
符号表 nm binary | grep process_request 固件未strip,保留符号
IDA/Ghidra 反编译后查看函数起始地址 固件被strip,无符号表
动态获取 GDB中 info functions 已加载符号,运行时查询

我遇到过最坑的情况是:固件里有个函数叫 auth_check,静态分析时觉得它逻辑没问题,结果GDB一跑,发现它根本没被调用——原来代码里有个条件编译的宏把调用路径给切掉了。动态调试就是用来揪这种「你以为有,实际没有」的问题。

4.2.2 调试网络服务的常见断点

调试网络服务时,我通常会在以下几个位置下断点:

  • recv() / read():看接收到的网络数据
  • strcpy() / sprintf():检查是否存在缓冲区溢出
  • system() / popen():看是否有命令注入
  • 自定义的 parse_request():看解析逻辑是否安全

举个例子,你想看Web服务收到HTTP请求后怎么处理:

(gdb) break recv
(gdb) continue
# 在宿主机上发送HTTP请求
curl http://localhost:8080/cgi-bin/test
# GDB命中断点,查看接收缓冲区
(gdb) x/32bx $a0

这里 $a0 是MIPS架构的第一个参数寄存器,存放 recv() 的缓冲区地址。用 x/32bx 打印32个字节,就能看到原始HTTP请求内容。

核心思路:动态调试不是漫无目的地单步执行。先通过静态分析找到可疑函数,再在GDB里验证。我习惯「静态定位、动态验证」——静态分析圈定范围,动态调试确认漏洞是否真实存在。

4.3 网络服务模拟与漏洞触发

固件里的网络服务通常依赖硬件外设——比如交换机芯片、GPIO、LED控制器。QEMU模拟不了所有外设,所以网络服务启动时可能会卡住或崩溃。这时候就需要「部分模拟」:把硬件相关的调用hook掉,让服务以为硬件正常工作。

4.3.1 用LD_PRELOAD hook硬件调用

Linux的动态链接器允许你预加载一个共享库,覆盖目标程序的函数。比如固件里的Web服务调用了 ioctl() 操作GPIO,你可以写一个假的 ioctl() 让它返回成功:

// fake_gpio.c
#include <stdio.h>
#include <stdarg.h>

int ioctl(int fd, unsigned long request, ...) {
    // 假装所有ioctl都成功
    return 0;
}

编译成共享库,然后预加载:

gcc -shared -fPIC -o fake_gpio.so fake_gpio.c
qemu-mips-static -E LD_PRELOAD=/path/to/fake_gpio.so ./httpd

这样Web服务调用 ioctl() 时,实际执行的是你的假函数。它不会报错,会继续往下走,直到进入网络监听循环。

经验之谈:我见过最离谱的hook是有人把 rand() 也给hook了——因为固件里的TLS库初始化时调用了 rand(),而QEMU用户态模拟下 rand() 返回的值导致TLS握手失败。hook掉之后,服务正常启动。

4.3.2 构造触发Payload

服务跑起来了,接下来就是触发漏洞。假设你在静态分析时发现 parse_request() 函数里有个栈缓冲区溢出:

void parse_request(char *data) {
    char buf[64];
    strcpy(buf, data);  // 没有长度检查!
}

用Python构造一个超长请求:

import socket

payload = b"A" * 200 + b"\x00"
s = socket.socket()
s.connect(("localhost", 8080))
s.send(b"GET /" + payload + b" HTTP/1.1\r\n\r\n")
s.close()

然后在GDB里观察:

(gdb) break parse_request
(gdb) continue
# 发送payload后,GDB命中断点
(gdb) stepi  # 单步执行,观察strcpy后的栈变化
(gdb) info registers $sp  # 查看栈指针
(gdb) x/20wx $sp         # 查看栈内容

如果 $sp 附近出现了大量的 0x41414141('A'的十六进制),说明溢出成功覆盖了栈空间。接下来就是计算偏移量、覆盖返回地址、构造ROP链——这些我们在后面的章节会详细讲。

4.3.3 网络服务模拟的完整流程

我把整个流程总结成一张图,方便你对照操作:

固件动态分析流程图 固件动态分析:从模拟到漏洞触发 提取固件文件系统 选择模拟方式:用户态 / 系统级 用户态:qemu-xxx-static 系统级:qemu-system-xxx Hook硬件调用(LD_PRELOAD) GDB远程调试 + 下断点 构造Payload → 触发漏洞 关键:静态定位 + 动态验证 先分析,后调试,再触发

这张图从左到右展示了完整的分析链路。你不需要每一步都走完——如果用户态模拟就能让服务跑起来,就别折腾系统级。我个人的原则是:用最少的模拟成本,达到最大的调试收益。

4.4 常见问题与避坑指南

动态分析看着简单,实际坑不少。我把自己踩过的坑列出来,你遇到了可以直接翻:

问题 现象 解决方案
QEMU启动后无输出 黑屏或卡住 检查内核启动参数 console= 是否正确;确认串口号
网络服务启动失败 提示 bind() failed 检查端口映射是否冲突;确认QEMU的 -netdev 参数
GDB连不上QEMU Connection refused 确认QEMU是否加了 -s 参数;检查防火墙
LD_PRELOAD不生效 程序仍然调用原始函数 确认hook函数签名完全一致;检查是否使用了 -E 传递环境变量
固件崩溃在未知地址 QEMU报 Illegal instruction 可能是CPU架构不匹配(如MIPS32 vs MIPS64);检查QEMU的 -cpu 参数
重要提醒:有些固件会检测是否运行在模拟器中——比如检查 /proc/cpuinfo 或尝试执行特殊指令。遇到这种情况,要么patch掉检测代码,要么用更高级的模拟器(如Unicorn)。不过90%的IoT固件没有这种反模拟机制,放心跑。

动态分析的核心就一句话:让固件动起来,然后看它怎么死。QEMU给了你运行环境,GDB给了你手术刀,剩下的就是耐心和细心。我见过有人一晚上就挖出三个漏洞,也见过有人卡在环境搭建上整整一周——别急,环境搭好了,后面的事就顺了。