$ cat ~ / posts /reverse /lecture10 4.9k Words ~ 18 Mins
cover.png
逆向工程基本原理10

#逆向工程基本原理10

exdoubled Lv5

固件与嵌入式系统

固件(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
2
3
4
5
6
bin/        固件中的可执行程序和服务程序
lib/ 固件使用的共享库
cfg/ 固件配置文件
etc/ 启动脚本、服务配置、系统配置
www/ Web 静态资源或 CGI 程序
webroot_ro/ Web 管理页面的 HTML、CSS、JS 等静态资源

分析时通常优先关注:

  • bin:寻找 httpdboalighttpdtelnetdupnpd 等服务
  • lib:确认共享库和厂商封装函数
  • cfg/etc:寻找启动项、Web 路由配置、服务参数
  • www/webroot_ro:寻找接口路径、表单字段、前端调用的 API

固件解包

固件文件经常是多个结构拼接在一起,例如启动头、压缩数据、内核镜像、文件系统等。解包的基本思路是线性扫描文件,通过 magic number 识别常见文件格式和文件系统。

常用工具是 binwalk

1
2
binwalk firmware.bin
binwalk -Me ./demo_firmware.bin

其中:

  • -e 表示 extract,提取可识别的内容
  • -M 表示递归处理,继续分析提取出的文件

binwalk 的扫描输出通常包含三列:

1
2
DECIMAL       HEXADECIMAL     DESCRIPTION
十进制偏移 十六进制偏移 识别到的文件类型、压缩算法、文件系统等

例如看到 uImageLZMA compressed dataSquashfs filesystem 时,就说明固件中可能包含内核镜像和 SquashFS 文件系统。

解包后的常见检查命令:

1
2
3
4
find . -maxdepth 3 -type f -name "httpd"
find . -maxdepth 4 -type f | grep -E "bin/|www/|webroot|cgi|conf"
file ./bin/httpd
strings ./bin/httpd | grep -E "goForm|cgi-bin|system|password"

如果无法正常解包,常见原因是:

  • 外层格式是厂商自定义格式
  • 文件系统经过加密
  • 压缩算法或参数比较特殊
  • binwalk 版本或依赖工具不完整

架构识别与加载基地址

Type-1 固件中,每个可执行文件通常带有 ELF 文件头,因此可以直接从文件头识别架构、端序和 ABI。

1
2
file ./bin/httpd
readelf -h ./bin/httpd

需要关注的信息:

  • CPU 架构:ARM、MIPS、PowerPC、x86 等
  • 位数:32 位或 64 位
  • 端序:little endian 或 big endian
  • ABI 和解释器路径
  • 是否 stripped,是否保留符号

对 Type-2/Type-3 固件而言,二进制代码可能被直接刷写到 Flash 区域,文件中不一定包含 ELF 头,也不一定包含加载基地址。此时 IDA、Ghidra 等工具需要手动设置架构和基地址。

加载基地址可以通过字符串和指针关系推测。

1
2
3
4
5
文件偏移 0x7327 处发现字符串 "Error at line:"
文件偏移 0x44ec 处发现 4 字节值 0x08007327

如果 0x08007327 是该字符串运行时地址:
base = 0x08007327 - 0x7327 = 0x08000000

实际分析时会把文件中的 4 字节值当作潜在指针,统计它们和 ASCII 字符串偏移之间能匹配出的候选基地址,选择匹配最多的结果。相关工具包括 rbasefindbasefind2

Type-2/Type-3 固件常没有完整 ELF 头和符号表,除基地址外还要恢复函数边界和关键函数语义。可以利用 RTOS 任务创建函数、常见 libc 函数、第三方库函数、字符串引用和控制流图相似性做符号恢复;一旦识别出任务创建函数,就可以优先跟踪它创建的任务入口。

固件服务逆向

Type-1 固件中的主要业务逻辑一般在用户态服务中,而不是内核中。路由器和摄像头常见的攻击面包括:

  • HTTP Web 管理界面
  • SOAP、UPnP、FTP、SSH、Telnet 等网络服务
  • 面向内部的配置管理服务或数据库服务

服务程序常见编码模式:

  • NVRAM:用键值对保存配置,并在多个进程之间传递数据
  • 进程间通信:环境变量、文件、套接字、管道
  • CGI:Web 服务器把特定 URL 请求交给外部程序或处理函数

NVRAM

NVRAM 常用于保存用户名、密码、网络配置等属性。

1
2
3
4
5
6
7
8
9
10
int get_config()
{
char *name = nvram_get("http_username");
return name != 0;
}

void set_config(char *name)
{
nvram_set("http_username", name);
}

漏洞分析时要注意:用户输入可能先被写入 NVRAM,再由另一个进程读取并进入危险函数。只分析单个进程内的数据流可能会漏掉这类路径。

CGI 与 Web 入口

CGI(Common Gateway Interface)用于让 Web 服务器通过外部程序处理特定请求。

以 lighttp 类框架为例:

1
2
3
4
5
/cgi-bin/cstecgi.cgi?action=login

/cgi-bin/ 表示该请求由 CGI 处理
cstecgi.cgi 表示对应的 CGI 可执行文件
action=login 表示传给 CGI 程序的请求参数

CGI 程序常通过环境变量获取请求信息,通过标准输出构造响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void cstecgi()
{
char buffer[1024];
char *query_str = getenv("QUERY_STRING");
char *host_str = getenv("HTTP_HOST");

if (strstr(query_str, "action=login")) {
sprintf(buffer, "{\"host\":\"%s\"}", host_str);
}

printf("HTTP/1.1 200 OK\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("%s", buffer);
}

这里的 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
2
3
4
5
6
7
8
9
10
11
12
13
void formSetNameAndAge(webs_t wp)
{
char *name = websGetVar(wp, "name", "");
char *age = websGetVar(wp, "age", "0");

strcpy(g_test_name, name);
g_test_age = atoi(age);

websWrite(wp, "{\"errCode\":0}");
websDone(wp, 200);
}

websFormDefine("FormSetNameAndAge", formSetNameAndAge);

对应请求:

1
http://0.0.0.0:80/goForm/FormSetNameAndAge?name=Tom&age=20

逆向时可以先从 URL 字符串入手,也可以从目标处理函数入手,通过交叉引用找到 websFormDefine 的注册点,进而确定该函数对应的 URL。

字符串与 Web 入口分析

固件 Web 服务分析一般从静态资源和字符串开始。

1
2
3
4
grep -R "goForm" ./webroot_ro ./www ./etc 2>/dev/null
grep -R "cgi-bin" ./webroot_ro ./www ./etc 2>/dev/null
strings ./bin/httpd | grep -E "goForm|websGetVar|websFormDefine|Set[A-Za-z]+"
strings ./bin/httpd | grep -E "system|popen|doSystem|sprintf|strcpy"

需要建立三类映射:

  • URL 到处理函数:哪个接口由哪个函数处理
  • 参数名到变量:用户可控字段进入了哪个局部变量或全局变量
  • 变量到危险函数:用户输入是否进入 strcpysprintfsystem 等 sink

以实践引导中的 DDNS 示例为例,分析流程可以概括为:

1
2
3
4
5
6
1. 找到 formSetSysToolDDNS 函数
2. 查找该函数的交叉引用
3. 在注册点识别 websFormDefine
4. 得到 URL: /goForm/SetDDNSCfg
5. 在处理函数中识别 websGetVar,确认 ddnsEn 等用户参数
6. 跟踪参数进入 SetValue、CommitCfm 或响应构造函数

其中 CommitCfm 表示把配置修改提交到管理固件属性的进程。它本身不一定是漏洞点,但说明数据可能跨进程继续传播。

固件中的常见漏洞

固件漏洞仍然包含传统二进制安全问题,但输入来源和运行环境有自己的特点。

Type-1 固件的攻击输入主要来自网络服务,例如 HTTP 请求参数、上传文件、UPnP 报文等。

Type-2/Type-3 固件的攻击输入更多来自外设,例如串口、传感器、MMIO、DMA、中断等物理输入信道。

内存破坏漏洞

内存破坏漏洞通常存在于 C/C++ 程序中,主要由内存操作错误导致。

常见类型:

  • 缓冲区溢出
  • 未初始化内存使用
  • 释放后使用
  • 数组越界
  • 空指针解引用

缓冲区溢出的基本形式:

1
2
3
4
5
void login(char *user)
{
char buf[0x20];
strcpy(buf, user);
}

strcpy 不接收目标缓冲区长度,只要 user 长度超过 buf,就可能覆盖栈上数据。

一些自定义函数也可能是危险函数。例如函数参数只有源指针和目标指针,却没有传入目标缓冲区长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
void PPPoE_Adjust(char *src, char *dst)
{
if (src != 0 && dst != 0) {
for (char *sp = src, *dp = dst; *sp != '\0'; sp++) {
if (*sp == '\\') {
sp++;
}
*dp = *sp;
dp++;
}
*dp = '\0';
}
}

该函数的循环终止条件只依赖源字符串结尾,没有检查 dst 的大小,因此调用者传入较小缓冲区时可能发生溢出。

命令注入漏洞

命令注入漏洞是指程序没有正确过滤用户输入,就用该输入拼接并执行系统命令。

1
2
3
4
5
6
void backup(char *filename)
{
char cmd[256];
sprintf(cmd, "tar -cf archive.tar %s", filename);
system(cmd);
}

如果 filename 为:

1
aaa;telnetd;

最终执行的命令就可能被扩展为额外启动 telnetd

常见命令执行 sink:

  • system
  • popen
  • eval
  • execveexeclpexecv
  • 厂商封装函数,例如 doSystemdo_systemxmldb_ecmd

注意:真正调用 system 的位置可能在共享库或包装函数中。分析时不能只搜索 system,还要关注包含命令字符串、%s 格式化字符串、shell 元字符处理的函数。

固件中经常存在厂商自定义的命令执行封装函数。定位这类函数时,可以先搜索像 shell 命令的常量字符串,尤其是同时包含命令名、格式化占位符或配置工具名的字符串,再从字符串交叉引用回溯到拼接函数和最终执行点。

访问控制漏洞

访问控制用于限制未授权用户访问敏感资源,例如 Web 管理页面、配置修改接口、固件升级接口。

常见问题:

  • 敏感页面没有要求登录
  • 接口只在前端做校验,后端没有校验
  • 登录逻辑比较条件错误
  • 空口令、特殊用户名或特殊参数绕过认证

一个典型认证绕过模式:

1
2
3
4
5
6
7
8
9
int check_password(char *real_password, char *post_password)
{
int len = strlen(post_password);

if (!strncmp(real_password, post_password, len)) {
return 0;
}
return 1;
}

如果用户输入空字符串,len 为 0,strncmp 可能直接返回 0,导致认证成功。

固件漏洞定位方法

人工审计、污点分析和动态测试都围绕同一个核心问题:用户可控输入能否到达危险操作。

人工代码审计

人工审计的关键步骤:

  1. 识别数据接收函数
  2. 识别危险函数
  3. 跟踪从数据接收函数到危险函数的完整路径
  4. 检查路径上是否有长度检查、合法性检查、权限检查

数据接收函数也叫 source,危险函数也叫 sink。中间的传播路径需要结合进程内变量传递和进程间通信一起看。

常见 source:

  • recvrecvfrom
  • getenv
  • scanf
  • websGetVar
  • cgiGetValue
  • 厂商自定义的请求参数读取函数

常见 sink:

  • 缓冲区溢出:memcpystrcpystrcatsprintfsscanf
  • 命令注入:systempopenevalexec 家族
  • 文件访问:fopenunlinkrename
  • 配置修改:nvram_setSetValueCommitCfm

审计时可以用一个简单的检查表:

1
2
3
4
5
source: 用户可控数据从哪里进入?
sink: 数据最终进入了哪个危险操作?
check: 中间是否检查长度、字符集、白名单、权限?
scope: 数据是否通过环境变量、NVRAM、文件、socket 传给其他进程?
effect: 成功触发后是崩溃、命令执行、配置篡改还是认证绕过?

污点分析

污点分析是一种自动化数据流跟踪技术。

基本步骤:

  • 标记污点源:程序外部输入
  • 标记出口点:危险函数
  • 让分析引擎自动追踪污点传播
  • 当污点到达 sink 时,报告潜在漏洞

污点分析和人工审计的相同点是都要识别 source 和 sink。不同点是数据流追踪由工具自动完成。

局限:

  • 复杂条件、循环、间接调用可能处理不好
  • 可能出现过污染,导致误报
  • 需要较多计算资源和人工确认

动态测试

动态测试是运行待测程序并提供输入,观察程序是否崩溃或出现异常行为。

典型方式是模糊测试:

1
2
3
4
5
POST /goForm/Login HTTP/1.1
Host: 192.168.0.1
Content-Length: 1024

username=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...

如果用户输入覆盖了返回地址、触发非法访问或导致进程崩溃,就说明可能存在内存破坏漏洞。

动态测试优点是误报少;缺点是需要真实设备或准确的固件模拟环境,而且一般更擅长发现内存相关漏洞。

实践中的固件漏洞定位流程

针对实践引导中的 Type-1 Linux 固件,可以按照下面的顺序做:

第一步:解包固件

1
binwalk -Me ./demo_firmware.bin

确认是否提取到 SquashFS 或其他文件系统,进入解包目录后查看 binlibcfgwebroot_ro 等目录。

第二步:识别目标服务

1
2
3
find . -type f | grep -E "/bin/|/sbin/|/usr/bin/|/www/|/webroot"
file ./bin/httpd
strings ./bin/httpd | grep -E "goForm|websGetVar|websFormDefine"

如果发现 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:没有充分的长度、白名单、权限或格式检查

对缓冲区溢出,重点看:

  • 目标缓冲区大小
  • 拷贝长度是否可控
  • 是否使用 strcpysprintf 等无长度限制函数
  • 是否存在自定义字符串拷贝函数

对命令注入,重点看:

  • 用户输入是否进入命令字符串
  • 是否经过 sprintfsnprintf、字符串拼接
  • 是否最终调用 systempopendoSystem
  • 检查是否使用白名单,而不是只过滤少数字符

第七步:记录证据

分析报告中至少应记录:

  • 固件解包路径和目标二进制
  • 目标函数对应的 URL
  • source 参数名
  • 数据流传播过程
  • sink 函数
  • 缺失的检查
  • 可能的触发请求或触发条件

一个简化记录模板:

1
2
3
4
5
6
7
URL: /goForm/Example
handler: formExample
source: websGetVar(wp, "name", "")
flow: name -> local_20 -> sprintf(cmd, "...%s...", local_20)
sink: system(cmd)
check: 未看到白名单或 shell 元字符过滤
impact: 可能造成命令注入

三类漏洞挖掘方法比较

方法优点缺点
人工代码审计准确性较高,可以发现逻辑漏洞,不依赖运行环境成本高,难以覆盖大规模代码
污点分析可以自动追踪大范围数据流,不一定需要模拟固件可能误报,复杂程序结构处理困难
动态测试真实崩溃证据明确,误报少需要真实设备或模拟环境,主要发现内存类漏洞

本讲的重点不是写利用,而是建立固件漏洞分析的基本路径:先解包和理解文件系统,再定位服务和 Web 入口,最后围绕 source、sink、check 判断漏洞是否存在。

$ discussion
# Comments
waline