ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

固件逆向实战:从Hex文件到反编译的完整流程

固件逆向实战:从Hex文件到反编译的完整流程 1. 拿到一个Hex文件之后先搞清楚它到底是什么很多人第一次接触固件逆向都是从手里捏着一个.hex文件开始的。可能是从旧设备上读出来的可能是别人丢过来让你看看里面写了啥的也可能是你自己用编程器从Flash里dump出来的。不管来源是什么第一步永远不是急着找反编译工具而是先弄明白这个文件到底属于哪一类。.hex这个后缀其实是个万金油它至少对应三种完全不同的东西第一种是Intel HEX 格式这是嵌入式领域最常见的固件烧录文件本质上是纯文本每一行都是ASCII字符第二种是原始二进制文件被错误命名有些工具导出时不管三七二十一都叫.hex打开一看全是乱码第三种是某些厂商自定义的加密容器格式头部有自己的magic number。这三种东西的处理路径完全不同搞错了方向会浪费大量时间。1.1 Intel HEX 的行结构拆解Intel HEX 格式的每一行都遵循同一个骨架:LLAAAATT[DD...]CC:是行起始符固定不变LL是数据长度表示这一行携带多少个字节的有效数据AAAA是16位地址偏移注意这只是偏移不是完整地址TT是记录类型决定了这一行的语义DD...是实际数据字节CC是校验和计算方式是把前面所有字节求和后取低8位再用0x100减去这个值记录类型有六种但实际固件里常见的就是四种00数据记录、01文件结束、02扩展段地址、04扩展线性地址。03和05是起始地址记录在ARM Cortex-M的固件里偶尔能见到但多数情况下可以忽略。我拿一段真实的STM32固件开头举例:020000040800F2 :1000000000200020CD010008D1010008D3010008B4 :1000100000000000000000000000000000000000E0第一行:020000040800F2长度是02地址是0000类型是04数据是0800意思是把基地址设为0x08000000。这就是STM32 Flash的起始地址。第二行:10000000...长度是1016字节地址偏移0000类型00实际数据从00200020开始。把基地址加上偏移这条数据就落在0x08000000处。这里有个容易踩的坑扩展线性地址记录一旦出现会一直生效直到下一条类型04的记录出现。所以你不能只看当前行的地址字段必须维护一个当前基地址的状态变量逐行累加解析。我见过有人写解析脚本时只取AAAA字段当绝对地址结果解析出来的固件地址全乱套了。1.2 判断文件类型的快速方法在Linux或者macOS下直接file命令有时候能给出提示但对Intel HEX不一定准确。更可靠的办法是看文件头几个字节head -c 64 firmware.hex | xxd如果开头是3A也就是ASCII的冒号后面跟着的都是可打印的十六进制字符那基本可以确定是Intel HEX。如果开头是0x7F 0x45 0x4C 0x46那是ELF文件被改了后缀。如果开头是一堆看起来随机的字节那可能是原始二进制或者加密容器。Windows下可以用PowerShell快速看一眼Get-Content firmware.hex -TotalCount 3如果输出的是以冒号开头的可读文本行那就是Intel HEX无疑。提示有些厂商会把Intel HEX再做一层压缩或者异或加密文件头看起来还是冒号开头但数据段全是规律性的重复字节。这种情况需要先找到解密逻辑通常在Bootloader里。1.3 从Hex到原始二进制的转换确认是Intel HEX之后下一步就是把它还原成连续的二进制镜像。这一步是后续所有分析的基础因为反编译工具、反汇编器、十六进制编辑器它们吃的都是原始二进制不是文本格式的Hex。最常用的工具是objcopy如果你装了GNU工具链比如arm-none-eabi-系列直接arm-none-eabi-objcopy -I ihex -O binary firmware.hex firmware.bin这条命令的意思是输入格式是ihex输出格式是binary把文本Hex转成纯二进制。转换完之后用ls -l看一下大小再用xxd看开头几个字节确认一下是不是符合预期。如果没有objcopy也可以用Python自己写一个转换脚本逻辑不复杂import struct def hex_to_bin(hex_path, bin_path): base 0 data {} with open(hex_path, r) as f: for line in f: line line.strip() if not line.startswith(:): continue raw bytes.fromhex(line[1:]) length raw[0] addr (raw[1] 8) | raw[2] rtype raw[3] payload raw[4:4length] if rtype 0x00: for i, b in enumerate(payload): data[base addr i] b elif rtype 0x04: base ((payload[0] 8) | payload[1]) 16 elif rtype 0x02: base ((payload[0] 8) | payload[1]) 4 elif rtype 0x01: break if not data: return min_addr min(data.keys()) max_addr max(data.keys()) buf bytearray(max_addr - min_addr 1) for a, b in data.items(): buf[a - min_addr] b with open(bin_path, wb) as f: f.write(buf) hex_to_bin(firmware.hex, firmware.bin)这个脚本的核心思路是用字典记录每个地址上的字节最后按最小地址到最大地址铺平。注意这里有个细节如果固件中间有大段空白比如Bootloader和App之间有间隔铺平后空白处会填0x00或者0xFF具体填什么取决于你的实现。我一般填0xFF因为Flash擦除后的状态就是0xFF这样更接近真实的内存布局。2. 反编译之前先搞清楚目标架构和工具链拿到firmware.bin之后最忌讳的就是随便找个反编译工具往里一丢。嵌入式固件和PC上的可执行文件有本质区别它没有操作系统提供的标准入口没有符号表没有动态链接信息甚至可能连指令集都不是你熟悉的那个。所以在动手之前必须先确定三件事CPU架构是什么、字节序是大端还是小端、代码的加载地址在哪里。2.1 从向量表推断架构和加载地址对于ARM Cortex-M系列的固件最开头四个字节就是初始栈指针MSP紧接着四个字节是复位向量Reset Handler的地址。这两个值能告诉你大量信息。假设firmware.bin开头是00 20 00 20 CD 01 00 08小端解析第一个字是0x20002000第二个字是0x080001CD。0x20000000是STM32的SRAM起始地址0x08000000是Flash起始地址。这就说明这是一个STM32的固件加载地址是0x08000000复位向量指向0x080001CCThumb模式下最低位是1实际地址要清掉最低位。如果是ARM Cortex-A系列或者MIPS、RISC-V向量表的结构不一样但思路相同找那些看起来像地址的值看它们落在哪个地址区间。比如一个值频繁出现在0x80000000附近那可能是MIPS的加载地址如果出现在0x00000000附近且固件是从0地址映射的那可能是RISC-V或者某些ARM7TDMI的配置。我整理了一个快速判断表开头特征可能架构常见加载地址00 20 00 20或类似SRAM地址ARM Cortex-M0x0800000018 F0 9F E5ARM (ARM模式)0x000000000x7F 0x45 0x4C 0x46ELF容器看Program Header大量0x3C 0x1C类模式MIPS0x80000000开头就是可执行代码无向量表RISC-V / Xtensa看链接脚本2.2 工具选型IDA、Ghidra、radare2 怎么选确定架构之后就要选反汇编/反编译工具了。这三个工具我都深度用过各有适用场景。IDA Pro是行业标杆对ARM、MIPS、RISC-V的支持都很成熟反编译插件Hex-Rays的质量目前还是最好的。缺点是贵而且对某些冷门架构需要额外买授权。如果你只是偶尔分析一两个固件用IDA Free版也能凑合但Free版不支持反编译只能看汇编。Ghidra是NSA开源的免费反编译质量这几年进步很大对ARM Cortex-M的支持已经相当可用。它的优势是脚本能力强你可以用Python或者Java写扩展批量处理固件。缺点是启动慢界面操作逻辑和IDA差异较大新手需要适应。radare2是命令行工具适合自动化分析和脚本化处理。它的反汇编能力不错但反编译通过r2dec或r2ghidra插件的质量不如前两者。如果你需要在CI流程里自动分析固件radare2是最合适的选择。我的建议是日常分析用Ghidra遇到复杂逻辑需要交叉验证时开IDA批量自动化用radare2。这三个工具不冲突可以同时装。2.3 在Ghidra里正确导入原始固件Ghidra导入原始二进制时最关键的一步是设置正确的加载地址和架构。很多人导入后看到一堆乱码指令就是因为加载地址填错了。操作路径File - Import File - 选择firmware.bin- 在Format里选 Raw Binary - 点击Options - 设置 Base Address。Base Address填什么就是前面推断出来的加载地址。STM32填0x08000000ESP32填0x40000000如果是IRAM段或者0x3F400000如果是Flash映射段AVR填0x00000000。Language设置也很关键。ARM Cortex-M选 ARM:LE:32:CortexMIPS选对应的MIPS变体RISC-V选 RISCV:LE:32:RV32I 之类的。选错了语言反汇编出来的指令完全不对。导入完成后Ghidra会自动做一轮分析。这时候你可能会看到大量未定义函数需要手动创建。一个技巧是先找到复位向量指向的地址在那里按F12创建函数然后让Ghidra从那里开始递归分析。这样能自动识别出大部分函数边界。3. 没有符号表的情况下怎么定位关键逻辑固件反编译最痛苦的地方在于没有函数名没有字符串引用没有导入表。你面对的是几千个FUN_08001234这样的匿名函数。怎么从里面找到你关心的逻辑这需要一套系统的方法而不是靠运气乱翻。3.1 从字符串交叉引用切入字符串是最容易定位的锚点。在Ghidra里Window - Defined Strings 可以列出所有识别到的字符串。即使固件做了简单的混淆字符串往往还是以明文存在因为加密字符串会增加代码体积和运行开销很多厂商不愿意做。找到感兴趣的字符串后右键 - References - Show References to Address就能看到哪些代码引用了这个字符串。顺着引用往上追通常能定位到处理该逻辑的函数。举个例子如果你在一个路由器固件里搜到http://%s/%s这样的格式化字符串那引用它的函数大概率是HTTP请求构造逻辑。再往上追调用者就能找到触发请求的上层业务逻辑。但这里有个坑编译器优化会把字符串合并到只读数据段多个函数可能引用同一个字符串。所以不能只看一个引用就下结论要把所有引用都过一遍结合上下文判断。3.2 通过中断向量表定位外设驱动嵌入式固件和PC软件最大的区别是它大量依赖中断。中断向量表通常在固件的最开头对于Cortex-M偏移0x00开始就是向量表每个表项4字节指向对应的中断服务函数。以STM32为例向量表的布局是固定的偏移0x00是MSP0x04是Reset0x08是NMI0x0C是HardFault然后依次是MemManage、BusFault、UsageFault、保留、保留、保留、保留、SVC、DebugMon、保留、PendSV、SysTick之后是外设中断。如果你知道目标芯片的型号就能对照参考手册把每个中断号对应的外设搞清楚。比如第18个外部中断是USART1那这个位置的函数就是USART1的中断服务程序。从USART1的ISR里你能看到它怎么处理接收到的数据进而找到协议解析的入口。这个方法特别适合分析通信协议。我分析过一个Modbus RTU从站固件就是先从USART中断入手找到接收缓冲区再顺着缓冲区找到解析函数最后定位到寄存器映射表。3.3 利用常量池和立即数搜索很多关键逻辑会用到特定的常量。比如CRC校验的多项式0xEDB88320CRC32、AES的S盒、特定协议的magic number、加密算法的初始向量等。在Ghidra里Search - For Scalars 可以搜索立即数。搜0xEDB88320就能找到CRC32的实现搜0x67452301能找到MD5的初始化常量搜0x9E3779B9能找到TEA/XTEA加密。这个方法的前提是你对常见算法的常量比较熟悉。我建议平时积累一个常量速查表把常见哈希、加密、压缩算法的特征常量记下来分析时直接搜效率极高。另一个技巧是搜内存地址常量。外设寄存器的地址是固定的比如STM32的GPIOA基地址是0x40020000USART1是0x40011000。搜这些地址就能找到操作对应外设的代码。4. 从汇编到C反编译结果怎么读、怎么改、怎么验证反编译工具输出的C代码和原始源码有本质区别。它是从汇编反推出来的伪C变量名是uVar1、iVar2这种类型可能不准确控制流可能被优化得面目全非。直接读这种代码会很痛苦但掌握方法之后效率能提升好几倍。4.1 识别编译器优化留下的痕迹嵌入式固件通常用-O2或-Os编译编译器会做大量优化。常见的优化模式有函数内联小函数被直接展开到调用处导致反编译出来的代码看起来是一个巨大的函数里面重复出现相似的代码块。识别方法是看代码块的结构是否高度相似如果多个地方出现几乎一样的指令序列那很可能是同一个函数被内联了多次。尾调用优化函数的最后一条指令是跳转到另一个函数而不是返回。反编译工具有时会把它识别成普通跳转导致控制流看起来很奇怪。识别方法是看跳转目标是否是一个已知函数的入口。循环展开编译器会把小循环展开成直线代码。比如一个4次迭代的循环可能被展开成4段相似的代码。识别方法是看代码块是否有规律地重复且重复次数是2的幂或者小常数。寄存器复用编译器会尽量把变量放在寄存器里导致反编译出来的变量生命周期看起来很混乱。这时候需要结合汇编视图对照看才能理清数据流。我在Ghidra里读反编译代码时习惯同时开着反汇编窗口。当C代码里出现看不懂的变量关系时切到汇编看一眼往往就明白了。4.2 重建结构体和函数签名反编译出来的代码里指针运算通常表现为*(int *)(param_1 0x10)这种形式。这实际上是在访问某个结构体的字段。如果能确定结构体的布局就可以在Ghidra里定义结构体然后把param_1的类型改成该结构体指针代码可读性会大幅提升。重建结构体的步骤收集所有对同一个基址指针的偏移访问比如0x00、0x04、0x08、0x10根据访问的指令类型推断字段大小比如LDRB是1字节LDRH是2字节LDR是4字节在Ghidra的Data Type Manager里新建Structure按偏移添加字段回到反编译视图右键参数 - Retype - 选择新建的结构体这个过程可能需要反复迭代因为一开始你对结构体的理解不完整随着分析深入会不断补充字段。函数签名的重建同理。反编译工具推断的参数个数和类型经常不准需要根据调用处的寄存器使用情况手动修正。ARM架构下前四个参数通过R0-R3传递返回值在R0。如果看到调用前有对R0-R3的赋值那参数个数基本就确定了。4.3 动态验证用QEMU跑固件静态分析得出的结论最好能动态验证。对于ARM Cortex-M固件QEMU可以模拟运行。虽然QEMU不能模拟所有外设但对于纯算法逻辑的验证已经足够。基本流程是qemu-system-arm -M stm32vldiscovery -kernel firmware.bin -nographic -S -g 1234-S表示启动时暂停-g 1234表示监听1234端口等待GDB连接。然后用arm-none-eabi-gdb连上去设置断点单步执行观察寄存器和内存变化。但这里有个现实问题大多数固件在启动时会初始化外设如果QEMU不支持该外设固件会卡在初始化阶段。解决办法是跳过初始化代码直接把PC设置到你想分析的函数入口手动构造好参数再执行。我通常的做法是在GDB里用set $pc 0x08001234直接跳转到目标函数然后用set $r0 ...设置参数再stepi单步执行。这样可以绕过外设初始化专注于算法逻辑。对于更复杂的场景可以用Unicorn Engine写Python脚本做指令级模拟。Unicorn的好处是你可以精确控制内存映射和寄存器初始状态适合做算法逆向。from unicorn import * from unicorn.arm_const import * mu Uc(UC_ARCH_ARM, UC_MODE_THUMB) mu.mem_map(0x08000000, 0x10000) mu.mem_write(0x08000000, open(firmware.bin, rb).read()) mu.reg_write(UC_ARM_REG_SP, 0x20002000) mu.reg_write(UC_ARM_REG_PC, 0x08001234) mu.emu_start(0x08001234, 0x08001234 0x100)这段代码把固件映射到0x08000000设置好栈指针和PC然后从目标函数开始执行。执行完后可以读寄存器和内存看算法的输出是否符合预期。5. 实战中那些文档不会告诉你的坑前面讲的都是方法论但真正干活的时候坑往往出在细节上。我挑几个印象最深的分享出来。5.1 Thumb指令集的地址对齐陷阱ARM Cortex-M只支持Thumb指令集而Thumb指令的地址最低位必须是1。这意味着函数指针的值实际上是真实地址加1。比如复位向量是0x080001CD真实函数入口是0x080001CC。这个细节在静态分析时影响不大因为反汇编器会自动处理。但在动态调试时如果你用GDB设置断点必须用真实地址最低位清0否则断点永远命中不了。我在这上面浪费过整整一个下午最后才发现是地址对齐的问题。同样在Unicorn里设置PC时也要注意这个问题。emu_start的地址参数应该是真实地址但如果你从向量表里读出来的值直接传进去就会出错。正确做法是addr ~1。5.2 编译器对switch语句的优化C语言的switch语句编译器会根据case的数量和分布生成不同的汇编代码。case少的时候用比较跳转case多且连续的时候用跳转表case多但不连续的时候用二分查找。跳转表在反编译时特别容易出问题。反编译工具可能把跳转表识别成数据而不是代码导致控制流断裂。识别方法是看汇编里是否有LDR PC, [PC, R0, LSL #2]这样的指令如果有那附近一定有一个跳转表。在Ghidra里可以手动把跳转表标记为代码然后重新分析。具体操作是选中跳转表所在的内存区域右键 - DisassembleGhidra会尝试把每个表项解析成代码地址。5.3 固件加密和混淆的应对思路有些固件会做加密常见的有整体加密整个固件用AES加密Bootloader负责解密后跳转。这种情况下你从Flash里dump出来的固件是密文直接反编译没有意义。需要先找到Bootloader里的解密逻辑提取密钥解密后再分析。部分加密只有关键函数被加密运行时解密到RAM执行。这种相对少见但分析难度更大因为你需要跟踪解密过程在内存里抓取解密后的代码。控制流平坦化用一个大循环和状态变量来混淆控制流让反编译出来的代码变成一堆switch-case。这种混淆在商业固件里越来越常见。应对方法是写脚本做反混淆识别状态变量的取值规律重建原始控制流。字符串加密字符串在运行时动态解密静态分析看不到明文。应对方法是在解密函数上下断点动态抓取解密后的字符串。我遇到过一个比较极端的案例固件把关键算法拆成多个片段分散在不同的函数里通过函数指针表动态调用。静态分析时看起来是一堆无关的函数只有动态跟踪才能理清调用关系。这种时候QEMU加GDB的动态分析就是唯一的选择。5.4 不同工具之间的结果交叉验证没有任何一个反编译工具是完美的。Ghidra可能把某个函数识别错了IDA可能对某段代码的类型推断有误。我的习惯是关键逻辑至少用两个工具交叉验证。具体做法是在Ghidra里分析出伪C代码后把对应的地址范围记下来然后在IDA里打开同一个固件跳到相同地址对比两边的反编译结果。如果两边一致那结论基本可靠如果两边有差异就切到汇编视图人工判断哪个更合理。这个习惯帮我避免过好几次误判。有一次Ghidra把一个除法运算识别成了乘法导致我算出来的校验和一直不对换IDA一看才发现问题。6. 把分析结果落地从伪C到可编译代码分析到最后你可能需要把反编译出来的逻辑还原成可编译的C代码。这个过程不是简单的复制粘贴因为伪C代码里有大量工具生成的类型转换和临时变量直接编译是过不了的。6.1 清理伪C代码的实用步骤第一步是替换类型。把undefined4替换成uint32_tundefined2替换成uint16_tundefined替换成uint8_t。把uint替换成unsigned int。这些替换可以用编辑器的批量替换功能完成。第二步是重命名变量。把uVar1、iVar2这种改成有意义的名称。这一步最耗时但也是最有价值的因为好的变量名能让代码逻辑一目了然。我的习惯是结合上下文推断变量的用途比如循环计数器叫i缓冲区指针叫buf长度叫len。第三步是处理指针运算。伪C代码里的*(int *)(param_1 0x10)要改成结构体访问ctx-field_0x10。这需要先定义好结构体然后把所有类似的访问都替换掉。第四步是修复控制流。伪C代码里可能有goto语句或者while(true)里嵌套复杂的if-else。需要根据逻辑重新组织成清晰的循环和条件判断。第五步是补充缺失的定义。伪C代码里引用的外部函数和全局变量需要根据分析结果补充声明。如果某个函数是标准库函数比如memcpy、strlen直接包含对应的头文件即可。6.2 验证还原代码的正确性还原出来的代码必须验证正确性。最直接的方法是用Unicorn或者QEMU把原始固件里的目标函数和还原后的C代码分别跑一遍对比输入输出。具体做法是用Unicorn执行原始函数记录输入和输出把还原后的C代码编译成PC上的可执行文件用相同的输入跑一遍对比输出。如果一致说明还原正确如果不一致就逐步缩小范围找到差异点。对于有副作用的函数比如操作硬件寄存器验证会麻烦一些。这时候可以用桩函数替换硬件访问只验证纯计算逻辑。我一般会写一个测试框架把多个函数的验证用例组织起来每次修改还原代码后自动跑一遍。这样能快速发现回归问题。6.3 分析成果的合规使用边界这里必须说清楚固件反编译技术的使用必须严格遵守法律法规和道德规范。分析自己拥有的设备固件、做安全研究、学习技术原理这些是正当的。但未经授权分析他人设备、绕过技术保护措施、复制他人代码用于商业产品这些行为是有法律风险的。我的原则是只分析自己合法拥有的设备分析结果仅用于学习和技术研究不传播固件文件本身不帮助他人绕过授权验证。技术本身是中性的关键在于怎么用。在实际操作中我建议保留完整的分析记录包括固件来源、分析目的、使用范围。这样既能保护自己也能让技术分享更有底气。7. 一些提升效率的工具和脚本最后分享几个我日常用的工具和脚本都是实战中打磨出来的。hex2bin.py前面给过的转换脚本我把它封装成了命令行工具支持自动识别Intel HEX和原始二进制。vector_parser.py解析ARM Cortex-M向量表输出每个中断向量的地址和对应的外设名称。需要配合芯片的SVD文件使用。string_xref.pyGhidra脚本批量导出所有字符串及其交叉引用输出成CSV格式方便在外部工具里筛选。unicorn_runner.py封装了Unicorn的常用操作支持从JSON配置文件加载内存映射和寄存器初始值一行命令就能跑一个函数的模拟。diff_decompiler.py对比两个反编译工具对同一段代码的输出高亮差异部分帮助快速定位分析分歧。这些脚本都不复杂但能省下大量重复劳动。我建议每个做固件逆向的人都花时间搭建自己的工具链把常用操作自动化。前期投入的时间后期会成倍地赚回来。固件反编译这个领域工具在变架构在变但核心方法论是稳定的先搞清楚文件格式和架构再选对工具然后从字符串和中断向量切入定位关键逻辑最后通过静态分析和动态验证交叉确认。掌握这套流程之后面对任何陌生的固件你都能有条不紊地推进而不是对着几千个匿名函数发愁。
返回列表