#逆向工程基本原理10
固件与嵌入式系统
固件(Firmware)是嵌入式系统中的软件。它看起来和硬件关系很近,但逆向时主要分析的对象仍然是程序、文件系统、配置和运行逻辑。
嵌入式系统是一类嵌入机械或电气系统内部、面向专一功能的计算机系统。例如:
- 智能家居设备:路由器、摄像头、智能电视、智能插座等
- 工业控制设备:温度控制器、湿度感应设备、机械臂控制设备等
- 实时控制设备:无人机、机器人、车载控制单元等
固件安全重要的原因:
- 应用面广,IoT 设备数量大,暴露面也大
- 固件中安全保护通常较少,很多程序仍使用 C/C++ 编写
- 固件更新困难,设备买回后经常长期不升级
- 固件与物理世界连接更紧密,一旦被攻击可能影响设备控制、监控和生产过程
典型安全事件包括 2010 年的 Stuxnet 和 2016 年的 Mirai。前者针对工业控制系统,后者大规模感染路由器、摄像头等设备并发起 DDoS 攻击。
固件类型
固件类型和硬件能力、实时性要求、成本约束有关。按照系统结构,可以分为三类。
Type-1 固件
Type-1 固件基于常规操作系统,例如 Linux、Windows CE。
这类固件一般包含:
- 定制化或裁剪后的操作系统内核
- 文件系统
- 轻量级用户空间环境
- 厂商自定义服务
- 厂商自定义内核模块
常见设备包括路由器、摄像头、智能手表等。逆向 Type-1 固件时,文件系统是最重要的入口,因为大部分服务程序、配置文件和 Web 静态资源都在文件系统中。
Type-2 固件
Type-2 固件基于 RTOS(Real-Time Operating System,实时操作系统)。
这类固件一般包含:
- RTOS 内核:负责实时调度和硬件资源管理
- 组件库:例如网络协议栈、裁剪后的 libc
- Task:RTOS 中的调度单元,厂商功能通常写成任务函数
- HAL:硬件抽象层,用于适配不同芯片和外设
Type-2 固件常见于温度控制器、无人机、工业控制设备等场景。它往往没有完整的 Linux 用户态环境,逆向时不能只依赖文件系统结构。
Type-3 固件
Type-3 固件是裸机固件(Bare Metal),没有专门的操作系统概念。
典型组成:
- 设备初始化代码:初始化内存、中断、外设
- 主控制循环:很多情况下是一个无限循环
- 中断处理逻辑:响应时钟、串口、传感器等外设输入
这类固件通常是一个高度定制化的二进制程序,常见于功能较简单、成本敏感的设备,例如智能门锁、智能插座等。
Type-1 固件文件系统
Type-1 固件的文件系统用于存放运行所需的数据文件和可执行文件,常见格式包括 SquashFS、JFFS2 等。
固件包常见形式是 .bin、.img、.zip。厂商也可能在外层加入自定义头、压缩、加密或校验信息。
解包后常见目录:
1 | bin/ 固件中的可执行程序和服务程序 |
分析时通常优先关注:
bin:寻找httpd、boa、lighttpd、telnetd、upnpd等服务lib:确认共享库和厂商封装函数cfg/etc:寻找启动项、Web 路由配置、服务参数www/webroot_ro:寻找接口路径、表单字段、前端调用的 API
固件解包
固件文件经常是多个结构拼接在一起,例如启动头、压缩数据、内核镜像、文件系统等。解包的基本思路是线性扫描文件,通过 magic number 识别常见文件格式和文件系统。
常用工具是 binwalk:
1 | binwalk firmware.bin |
其中:
-e表示 extract,提取可识别的内容-M表示递归处理,继续分析提取出的文件
binwalk 的扫描输出通常包含三列:
1 | DECIMAL HEXADECIMAL DESCRIPTION |
例如看到 uImage、LZMA compressed data、Squashfs filesystem 时,就说明固件中可能包含内核镜像和 SquashFS 文件系统。
解包后的常见检查命令:
1 | find . -maxdepth 3 -type f -name "httpd" |
如果无法正常解包,常见原因是:
- 外层格式是厂商自定义格式
- 文件系统经过加密
- 压缩算法或参数比较特殊
binwalk版本或依赖工具不完整
架构识别与加载基地址
Type-1 固件中,每个可执行文件通常带有 ELF 文件头,因此可以直接从文件头识别架构、端序和 ABI。
1 | file ./bin/httpd |
需要关注的信息:
- CPU 架构:ARM、MIPS、PowerPC、x86 等
- 位数:32 位或 64 位
- 端序:little endian 或 big endian
- ABI 和解释器路径
- 是否 stripped,是否保留符号
对 Type-2/Type-3 固件而言,二进制代码可能被直接刷写到 Flash 区域,文件中不一定包含 ELF 头,也不一定包含加载基地址。此时 IDA、Ghidra 等工具需要手动设置架构和基地址。
加载基地址可以通过字符串和指针关系推测。
1 | 文件偏移 0x7327 处发现字符串 "Error at line:" |
实际分析时会把文件中的 4 字节值当作潜在指针,统计它们和 ASCII 字符串偏移之间能匹配出的候选基地址,选择匹配最多的结果。相关工具包括 rbasefind 和 basefind2。
Type-2/Type-3 固件常没有完整 ELF 头和符号表,除基地址外还要恢复函数边界和关键函数语义。可以利用 RTOS 任务创建函数、常见 libc 函数、第三方库函数、字符串引用和控制流图相似性做符号恢复;一旦识别出任务创建函数,就可以优先跟踪它创建的任务入口。
固件服务逆向
Type-1 固件中的主要业务逻辑一般在用户态服务中,而不是内核中。路由器和摄像头常见的攻击面包括:
- HTTP Web 管理界面
- SOAP、UPnP、FTP、SSH、Telnet 等网络服务
- 面向内部的配置管理服务或数据库服务
服务程序常见编码模式:
- NVRAM:用键值对保存配置,并在多个进程之间传递数据
- 进程间通信:环境变量、文件、套接字、管道
- CGI:Web 服务器把特定 URL 请求交给外部程序或处理函数
NVRAM
NVRAM 常用于保存用户名、密码、网络配置等属性。
1 | int get_config() |
漏洞分析时要注意:用户输入可能先被写入 NVRAM,再由另一个进程读取并进入危险函数。只分析单个进程内的数据流可能会漏掉这类路径。
CGI 与 Web 入口
CGI(Common Gateway Interface)用于让 Web 服务器通过外部程序处理特定请求。
以 lighttp 类框架为例:
1 | /cgi-bin/cstecgi.cgi?action=login |
CGI 程序常通过环境变量获取请求信息,通过标准输出构造响应。
1 | void cstecgi() |
这里的 getenv 是 source,sprintf 是 sink。如果没有检查 host_str 长度,就可能存在缓冲区溢出风险。
GoAhead 与 GoForms
实践引导中的固件使用 GoAhead。GoAhead 是嵌入式 Web 应用中常见的开源库,它实现了一套 GoForms 风格的 CGI。
GoForms 和 lighttp 的区别是:GoForms 通常在进程内把 URL 路由到处理函数,不一定需要跨进程分析。
常见函数:
websGetVar:读取请求参数websWrite:写 HTTP 响应内容websDone:设置 HTTP 响应码websFormDefine:注册 GoForms 处理函数
源码层面的形式大致如下:
1 | void formSetNameAndAge(webs_t wp) |
对应请求:
1 | http://0.0.0.0:80/goForm/FormSetNameAndAge?name=Tom&age=20 |
逆向时可以先从 URL 字符串入手,也可以从目标处理函数入手,通过交叉引用找到 websFormDefine 的注册点,进而确定该函数对应的 URL。
字符串与 Web 入口分析
固件 Web 服务分析一般从静态资源和字符串开始。
1 | grep -R "goForm" ./webroot_ro ./www ./etc 2>/dev/null |
需要建立三类映射:
- URL 到处理函数:哪个接口由哪个函数处理
- 参数名到变量:用户可控字段进入了哪个局部变量或全局变量
- 变量到危险函数:用户输入是否进入
strcpy、sprintf、system等 sink
以实践引导中的 DDNS 示例为例,分析流程可以概括为:
1 | 1. 找到 formSetSysToolDDNS 函数 |
其中 CommitCfm 表示把配置修改提交到管理固件属性的进程。它本身不一定是漏洞点,但说明数据可能跨进程继续传播。
固件中的常见漏洞
固件漏洞仍然包含传统二进制安全问题,但输入来源和运行环境有自己的特点。
Type-1 固件的攻击输入主要来自网络服务,例如 HTTP 请求参数、上传文件、UPnP 报文等。
Type-2/Type-3 固件的攻击输入更多来自外设,例如串口、传感器、MMIO、DMA、中断等物理输入信道。
内存破坏漏洞
内存破坏漏洞通常存在于 C/C++ 程序中,主要由内存操作错误导致。
常见类型:
- 缓冲区溢出
- 未初始化内存使用
- 释放后使用
- 数组越界
- 空指针解引用
缓冲区溢出的基本形式:
1 | void login(char *user) |
strcpy 不接收目标缓冲区长度,只要 user 长度超过 buf,就可能覆盖栈上数据。
一些自定义函数也可能是危险函数。例如函数参数只有源指针和目标指针,却没有传入目标缓冲区长度:
1 | void PPPoE_Adjust(char *src, char *dst) |
该函数的循环终止条件只依赖源字符串结尾,没有检查 dst 的大小,因此调用者传入较小缓冲区时可能发生溢出。
命令注入漏洞
命令注入漏洞是指程序没有正确过滤用户输入,就用该输入拼接并执行系统命令。
1 | void backup(char *filename) |
如果 filename 为:
1 | aaa;telnetd; |
最终执行的命令就可能被扩展为额外启动 telnetd。
常见命令执行 sink:
systempopenevalexecve、execlp、execv- 厂商封装函数,例如
doSystem、do_system、xmldb_ecmd
注意:真正调用 system 的位置可能在共享库或包装函数中。分析时不能只搜索 system,还要关注包含命令字符串、%s 格式化字符串、shell 元字符处理的函数。
固件中经常存在厂商自定义的命令执行封装函数。定位这类函数时,可以先搜索像 shell 命令的常量字符串,尤其是同时包含命令名、格式化占位符或配置工具名的字符串,再从字符串交叉引用回溯到拼接函数和最终执行点。
访问控制漏洞
访问控制用于限制未授权用户访问敏感资源,例如 Web 管理页面、配置修改接口、固件升级接口。
常见问题:
- 敏感页面没有要求登录
- 接口只在前端做校验,后端没有校验
- 登录逻辑比较条件错误
- 空口令、特殊用户名或特殊参数绕过认证
一个典型认证绕过模式:
1 | int check_password(char *real_password, char *post_password) |
如果用户输入空字符串,len 为 0,strncmp 可能直接返回 0,导致认证成功。
固件漏洞定位方法
人工审计、污点分析和动态测试都围绕同一个核心问题:用户可控输入能否到达危险操作。
人工代码审计
人工审计的关键步骤:
- 识别数据接收函数
- 识别危险函数
- 跟踪从数据接收函数到危险函数的完整路径
- 检查路径上是否有长度检查、合法性检查、权限检查
数据接收函数也叫 source,危险函数也叫 sink。中间的传播路径需要结合进程内变量传递和进程间通信一起看。
常见 source:
recv、recvfromgetenvscanfwebsGetVarcgiGetValue- 厂商自定义的请求参数读取函数
常见 sink:
- 缓冲区溢出:
memcpy、strcpy、strcat、sprintf、sscanf - 命令注入:
system、popen、eval、exec家族 - 文件访问:
fopen、unlink、rename - 配置修改:
nvram_set、SetValue、CommitCfm
审计时可以用一个简单的检查表:
1 | source: 用户可控数据从哪里进入? |
污点分析
污点分析是一种自动化数据流跟踪技术。
基本步骤:
- 标记污点源:程序外部输入
- 标记出口点:危险函数
- 让分析引擎自动追踪污点传播
- 当污点到达 sink 时,报告潜在漏洞
污点分析和人工审计的相同点是都要识别 source 和 sink。不同点是数据流追踪由工具自动完成。
局限:
- 复杂条件、循环、间接调用可能处理不好
- 可能出现过污染,导致误报
- 需要较多计算资源和人工确认
动态测试
动态测试是运行待测程序并提供输入,观察程序是否崩溃或出现异常行为。
典型方式是模糊测试:
1 | POST /goForm/Login HTTP/1.1 |
如果用户输入覆盖了返回地址、触发非法访问或导致进程崩溃,就说明可能存在内存破坏漏洞。
动态测试优点是误报少;缺点是需要真实设备或准确的固件模拟环境,而且一般更擅长发现内存相关漏洞。
实践中的固件漏洞定位流程
针对实践引导中的 Type-1 Linux 固件,可以按照下面的顺序做:
第一步:解包固件
1 | binwalk -Me ./demo_firmware.bin |
确认是否提取到 SquashFS 或其他文件系统,进入解包目录后查看 bin、lib、cfg、webroot_ro 等目录。
第二步:识别目标服务
1 | find . -type f | grep -E "/bin/|/sbin/|/usr/bin/|/www/|/webroot" |
如果发现 GoAhead 相关字符串,可以优先分析 httpd 这类 Web 服务二进制。
第三步:导入 Ghidra 或 IDA
导入时确认架构、端序和加载地址。对 ELF 文件通常可以自动识别;对裸二进制需要手动设置。
如果二进制被 strip,需要通过以下信息恢复语义:
- 字符串引用
- 函数调用模式
- 交叉引用
- 库函数签名
- 配置文件和前端 JS 中的接口名
第四步:定位 URL 与处理函数
对于 GoForms:
1 | websFormDefine("SetDDNSCfg", formSetSysToolDDNS) |
可以通过两种方向定位:
- 从 URL 字符串出发,找字符串交叉引用
- 从处理函数出发,找函数指针被注册的位置
最终建立:
1 | /goForm/SetDDNSCfg -> formSetSysToolDDNS |
第五步:分析用户可控参数
在处理函数中识别 websGetVar 或类似函数。
1 | char *ddnsEn = websGetVar(wp, "ddnsEn", "0"); |
这里 ddnsEn 是用户可控参数,默认值是 "0"。随后需要追踪它是否进入配置修改、字符串复制、命令拼接或响应构造。
第六步:检查 source-to-sink
漏洞成立通常需要同时满足三个条件:
- 存在 source:用户可控输入
- 存在 sink:危险函数或敏感操作
- 缺少 check:没有充分的长度、白名单、权限或格式检查
对缓冲区溢出,重点看:
- 目标缓冲区大小
- 拷贝长度是否可控
- 是否使用
strcpy、sprintf等无长度限制函数 - 是否存在自定义字符串拷贝函数
对命令注入,重点看:
- 用户输入是否进入命令字符串
- 是否经过
sprintf、snprintf、字符串拼接 - 是否最终调用
system、popen、doSystem - 检查是否使用白名单,而不是只过滤少数字符
第七步:记录证据
分析报告中至少应记录:
- 固件解包路径和目标二进制
- 目标函数对应的 URL
- source 参数名
- 数据流传播过程
- sink 函数
- 缺失的检查
- 可能的触发请求或触发条件
一个简化记录模板:
1 | URL: /goForm/Example |
三类漏洞挖掘方法比较
| 方法 | 优点 | 缺点 |
|---|---|---|
| 人工代码审计 | 准确性较高,可以发现逻辑漏洞,不依赖运行环境 | 成本高,难以覆盖大规模代码 |
| 污点分析 | 可以自动追踪大范围数据流,不一定需要模拟固件 | 可能误报,复杂程序结构处理困难 |
| 动态测试 | 真实崩溃证据明确,误报少 | 需要真实设备或模拟环境,主要发现内存类漏洞 |
本讲的重点不是写利用,而是建立固件漏洞分析的基本路径:先解包和理解文件系统,再定位服务和 Web 入口,最后围绕 source、sink、check 判断漏洞是否存在。