嵌入式固件逆向分析:从零构建固件分析工具实战指南 1. 接手“没人讲得清”的固件第一步不是写代码而是重建上下文接到这个任务的时候我心里其实是有准备的。上一任维护者已经离职交接文档只有两页纸其中一页还是考勤表复印件。仓库里倒是躺着几十个版本的固件 bin 文件文件名从final_v3.img到final_v7_final_真的不改了.bin没有任何 Release Note编译链版本写在某个 readme 的第三行而那个 readme 和实际构建产物明显对不上。这种场景在嵌入式圈子里太常见了。所谓“没人讲得清的固件”通常不是代码真的没法看而是固件作为交付物本身就包含太多无法从表面直接读出的信息——它是一堆编译器产物、链接脚本、分区表配置、启动参数和文件系统镜像的混合体。只要任何一环没了上下文后面的人就只能拿着 bin 文件当黑盒猜。我的第一个决定是先别急着改 bug也别急着“重构”先把整个固件的家底盘清。我花了大概两周时间做了三件事1.1 资料盘点把能找的都找出来再判断可信度在动手读二进制之前我先整理手头所有和这个固件相关的资料。原理图、芯片 datasheet、bootloader 源码、SDK 压缩包、历史提交记录、CI 配置、烧录工具以及现场工程师发的故障截图全都拉出来建了一个独立目录。注意我说的是“独立目录”不是扔在桌面而是像证据链一样按时间、按版本、按来源分好。这个环节最容易被新手跳过去。但我想强调的是资料盘点决定了后续所有分析的方向是否正确。比如我后来发现仓库里有两个 SDK 版本一个带 4.19 内核源码一个带 3.10 内核头文件。上一任维护者实际使用的是后者但新旧 SDK 的差异导致编译出来的 bootloader 分区表偏移量不同而文档里根本没提过这茬。如果没有这一步我后面直接对着新版 SDK 去解析旧固件肯定全盘皆错。判断资料可信度的标准就一条它能不能被当前固件二进制反证。不能反证的一律存疑能反证的记下来。比如文档说“固件入口地址在 0x80000000”但反汇编出来的 Reset 向量跳转地址是 0x81000000那文档就不可信实际芯片的引导配置才是准的。1.2 还原可复现构建环境不能只会“刷别人编好的”我做的第二件事是把这个项目从“只交付固件”变成“交付可复现构建”。听起来很工程管理但它对个人理解固件结构的帮助其实极大。具体做法不复杂根据现有线索猜出编译链版本比如日志里残留了 arm-none-eabi-gcc 5.4.1 的路径在容器里搭一个干净的构建环境然后一步步把 SDK、内核源码、rootfs 工具链装好。头几次编译当然会失败报缺头文件、缺交叉编译宏、架构不匹配这类问题。但这恰恰是最好的学习过程——每一次报错都在告诉我固件内部到底依赖什么。我强烈建议所有接到“孤儿固件”的人做一次完整重建哪怕你不改代码哪怕你最后仍然用官方编译好的版本。因为只有自己编过一遍你才会真正理解链接脚本里.text段被放在哪个地址.data段在哪个地址这两个地址和分区表怎么对齐rootfs 是用什么工具压的是 SquashFS 还是 UBI压缩块大小是多少最终打包时头部那几字节的 magic、版本号、长度字段都是哪里来的怎么算出来的。这些知识不是看 datasheet 能直接学到的但它们是后面拆解固件、做工具的底层逻辑。1.3 项目槽点我从这次经历里品出的两个“必然”多说两句我实际遇到的情况可能比方法论更能说清楚问题。第一“没人讲得清”必然不是因为一个原因。这个项目的问题至少有三层SDK 版本错乱、分区表定义存在多处冗余配置uboot 里一份、打包脚本里一份、终端客户手里还焊死了一份、以及 binwalk 等现成工具在这个平台上大量误报。三条线交叉在一起才让固件看起来“不可破译”。第二“没人讲得清”也不代表固件本身神秘。它只是说明项目的知识没有沉淀成可检索的资料。上一任维护者把很多信息记在脑子里而不是仓库里。一旦人走了知识就变成噪声。我后来做的工具本质上是把固件该有的信息从二进制里重新“提炼”出来变成可查询、可对比、可审计的东西。这才是工具真正的价值。2. 工具选型的底层逻辑为什么现成工具不够用家底盘清之后面临的现实问题就是我需要系统性地分析固件。最顺手的方案当然是 binwalk 全家桶再配合strings、hexdump、objdump手工撸。说实话我第一反应也是拿 binwalk 扫描。但实际使用了几轮之后我发现它在这个项目上有一个致命问题签名库太全误报严重。几百 MB 的固件扫出来几十个“可能是 SquashFS、可能是 ext、可能是加密数据”的标签真正可靠的信息被噪声淹没。而且它不擅长处理“不标准”的固件——比如头部被打过补丁、分区表被手动改过偏移、magic 字符串被混淆过的情况。对于这种“脏”固件我需要的是一个更贴合项目特点、能够定制规则的工具。于是决定自己写一个微型固件镜像分析工具名字随意功能清单很明确自动识别固件文件格式raw bin、Intel HEX、ELF、U-Boot 镜像等扫描镜像里的分区表候选位置并根据已知地址范围做筛选提取字符串并标注虚拟地址/文件偏移方便反汇编时对照CRC 校验和差分对比功能用于确认目标固件是否被修改过支持自定义 magic 字典方便扩展未知文件系统类型。2.1 工具的第一版先解决“能不能看到”的问题第一版我只做了两件事文件结构探测和字符串提取。文件结构探测其实就是在 Python 里读前 64 字节按照常见格式的头部特征做判断。保守起见我用的是一组简单的 magic 表格式类型关键特征判断依据ELF\x7fELF文件头第 0~3 字节Intel HEX首字节为:逐行检查:开头、长度段校验U-BootLegacy0x27051956镜像头 magic 字段SquashFShsqs/sqsh文件偏移 0x28 附近存在 superblockUBIUBI#卷表区域特征自定义打包0xAA55 或厂家魔数手动补充字典这一版能解决“这个 bin 文件到底是什么”的问题属于最基础的一层。字符串提取也用了最朴素的方案读取全部字节按 4~8 字节长度过滤可见字符再把连续 ASCII 序列记下来。遇到 UTF-8 或 UTF-16 的中文字符串时我会额外标注编码因为很多工业设备的 web 管理页面字符串是 UTF-8 的直接strings会丢内容。第一版效果还不错。配合已知分区表我能把 bootloader 和 rootfs 的位置大致框出来也找到了几个错误提示字符串比如“System A corrupt, rollback to System B”这类只有现场崩溃时才会出现的东西。有了这些线索我开始往更深一层走。2.2 工具的进化从“肉眼看”到“能搜、能比、能算”第一版丢给现场同事试了两周反馈是“能用但不够”。具体来说大家希望在固件文件里快速定位某个模块的位置比如“帮我查一下这个固件里到底有没有某个网卡驱动的代码”“这两个版本之间到底改了哪些地方”。于是第二版加了三个能力关键字节序列搜索、分区级差分、CRC 校验。关键字节序列搜索实现起来很简单本质就是一个多模式 KMP 匹配把用户输入的十六进制串或 ASCII 串换算成字节流在文件里做不重叠扫描。但它在实战里相当好用。比如怀疑某个固件被替换过驱动直接搜驱动名对应的字符串看是否有匹配几秒就出结论。分区级差分稍微有点技巧。固件里有很多内容每次编译都会变比如版本号、时间戳、编译路径、CRC 值。直接对整个文件做 diff噪声太大。我的做法是先根据分区表把镜像切成若干块boot、kernel、rootfs、config、app对每个块单独计算一个健壮哈希块和块之间做块级 diff。这样版本升级后哪些分区动了哪些没动一眼就能看出来。CRC 校验则解决了“固件是否被传输损坏/人为修改”的疑虑。很多固件在头部或尾部自带校验和我把它提取出来对数据区重新算一遍两边不一致立刻报警。后来发现现场有不少设备挂死是升级包校验失败导致的靠这个功能能快速区分是传输问题、存储问题还是固件本身损坏。3. 实操实录从拆包到定位问题的完整流程光说功能没多大意思我拿一个真实案例把整个流程走一遍。这个案例讲的是设备升级后不断重启同事拿了两版固件给我看能不能定位原因。3.1 第一步先看格式和分区表确认分析范围两版固件分别是 v1.2.0设备出厂版和 v1.2.1升级包都是 raw bin 格式。我加载第二版之后工具自动识别出它是自解压风格的打包头部有一个厂商自定义的 magcic后面跟着四字节长度字段。解析出分区表之后我得到这样一份摘要[0] uboot offset 0x00000000 size 0x00040000 [1] kernel offset 0x00040000 size 0x00280000 [2] rootfs offset 0x002c0000 size 0x00780000 [3] config offset 0x00a40000 size 0x00080000 [4] appfs offset 0x00ac0000 size 0x00520000光看这份摘要分区偏移和大小都正常没有越界。所以问题大概率不在分区表。3.2 第二步字符串定位快速锁定可疑模块接着我用字符串扫描对 kernel 分区做了一遍关键词过滤搜索“panic”“oops”“rollback”“recovery”“unknown”等等。结果在 rootfs 分区里找到一个很有意思的字符串/customer/start_app.sh: not found正常开机流程应该是 kernel 挂载 rootfs 后执行系统初始化脚本其中一步是执行/customer/start_app.sh。如果它 not found说明 rootfs 里的 customer 目录缺少这个脚本但应用分区还是好的。这个提示让我立刻怀疑升级过程中rootfs 分区被写坏或写错位置导致脚本丢失。3.3 第三步差分对比找出 v1.2.0 和 v1.2.1 的实际差异有了嫌疑方向我再用差分功能对比两版 rootfs 分区。结果差异大得离谱——rootfs 里几乎每个文件都变了。如果真的是普通升级受影响范围应该集中在少数改动文件不可能整个文件系统全部变化。所以我又做了一个更细的检查对比 config 分区的 CRC。工具提取出 v1.2.1 中 config 分区的头部 CRC 字段发现 CRC 对应的数据区长度不是 0x80000而是 0x7fff0。少掉的 16 字节恰好是一个关键配置的长度差。这就解释了为什么升级后设备反复重启升级脚本按照旧的 config 大小去写新固件导致新 config 尾部 16 字节被写入到了下一个分区appfs的头 16 字节。而 appfs 头部被污染影响到开机应用的启动逻辑触发系统看门狗不断重启。3.4 第四步用校验功能反查升级包本身是否完整最后一步我对升级包原文件做了一次全镜像 CRC 校验并且和从设备 flash 里读回来的固件做对比。工具输出[OK] original image CRC32 0x8F3A21C4 [OK] flash readback CRC32 0x8F3A21C4这说明升级包从服务器下发到设备、写入 flash 的全过程没有发生 bit 翻转。问题确实出在升级脚本对新旧分区大小假设不一致上。经过这一轮定位维护团队立刻去查升级平台的分区配置确认是配置表里 config 分区 size 字段写错了。整个过程大概花了两个小时而如果用 binwalk 加人工对比至少要大半天。更重要的是这个过程里我没有真正“读”过一行固件代码——所有结论都是通过工具提供的结构和差分信息推导出来的。这恰恰说明一个数据处理得当的工具能把“没人讲得清”的固件变成“可被提问”的对象。4. 常见问题与排查技巧实录工具做出来之后不光我自己用其他团队也开始拿它分析各自的固件。半年里积累了不少问题我把典型问题、排查思路和处理办法整理成一个速查表分享给大家。现象可能原因排查方法解决方案binwalk 报大量假阳性文件系统固件内嵌大量非结构化数据用字典过滤 检查 magic 是否位于预期对齐位置只信任“magic 长度字段 校验和”三者齐备的候选固件可以启动但字符串提取不到关键信息字符串被压缩或加密先判断分区是否为压缩文件系统用解密器或解压器处理后重新扫描升级包下载正常但设备升级失败分区表写入长度错误计算各分区 CRC 并对比设备 flash 回读值核对升级平台的分区配置表两个版本差分行数巨大无法定位重点编译器版本或时间戳干扰对每个分区先做块级哈希再对关键块做 diff忽略“编译环境无关”差异只关注业务代码块固件头部没有 magic无法自动识别制造商自定义格式未公开用已知指令集入口地址交叉验证手动配置入口地址将头部视为 padding反汇编后地址与文件偏移对不上存在加载地址和运行地址差异借助“文件偏移 虚拟地址映射表”做关联在工具里增加地址映射换算页面4.1 我踩过的几个典型坑以及处理思路第一个坑是“过度相信 magic”。有一版固件工具在偏移 0x00003200 处识别出一个 U-Boot 镜像头magic 完全正确CRC 也对。我当时差点就认定它是一个可供跳转的引导镜像。后来用地址映射一算发现它根本不是镜像头而是一个普通数据结构只是碰巧用了同样的数值。从那以后我给 magic 识别加了一个硬性要求magic 所在的偏移必须落在分区表定义的可执行分区内并且和 CPU 复位向量地址满足映射关系否则只记录可疑不下结论。这个规则的代价是会漏掉一些真实分区偏移未知的固件但换来的是大幅降低误报值得。第二个坑是“忽略了历史版本的二进制内嵌符号信息”。固件里经常有未剥离的符号表尤其在 bootloader 里。第一次分析时我纯粹靠反汇编硬读走了不少弯路。后来我才想到用nm或解析 ELF 符号段来辅助分析。如果你的固件保留了 ELF 格式别急着转 raw bin先看看符号文件有没有留。符号文件价值巨大能直接把地址映射到函数名。第三个坑发生在做差分对比时文件系统内的空洞区域。SquashFS 这类只读文件系统压缩后会有对齐产生的空洞里面的数据通常是零或者历史残留。如果直接对文件系统镜像做 blob 级对比就会看到大量“差异”其实都是空洞填充字节不同。这个问题让初期几个对比结果几乎没法看。后来我在差分模块里加入了“忽略全 0x00 / 0xFF 区域”的选项才真正把有效差异凸显出来。第四个坑和工具本身有关只要你分析的是别人给的固件就要默认它可能被有意隐藏了信息。比如有些设备商会把 rootfs 用 AES 加密密钥烧在 SoC 的 eFuse 里有些会在编译时把字符串表改成 GBK 编码干扰通用的 UTF-8 提取。遇到这些情况工具不可能全自动解决。我的建议是不要把工具当魔法它只是帮你把底层信息暴露出来真正的解读还得靠人结合硬件规格书、启动日志和厂商 SDK 一起判断。4.2 几条独家经验做分析工具时最值得投资的细节现在回过头看这个项目让我最受益的几条经验可能和大多数“工具开发指南”里写的都不一样。第一工具的交互设计比算法更重要。固件分析是一个高度探索性的过程你需要不断调整搜索范围、切换解析规则、对比不同版本。如果工具每次都要重新命令行传参效率会低很多。我把工具做成了带光标定位的文本界面左边是十六进制文件流右边是结构化解析字段按不同颜色标出分区边界、字符串、magic 和校验字段。这个交互设计帮我省了至少一半的时间。第二一定要保留中间过程的可重复性。每当我对某个固件做出“它属于什么格式、有没有加密、分区怎么切”的判断就自动把解析规则和提取结果导出成一个 JSON 配置文件。下次拿到同系列固件直接套用配置几秒钟就能出初步报告不用重新猜。半年下来我的配置库里已经积累了二十多种常见方案对同类设备几乎可以做到“开箱即用”。第三所有结论都要能溯源。我不会让工具给出一个只输出“可疑/正常”的判定。每一项可疑结果必须附带偏移、长度、校验值以及参考规则。这样做最大的好处是当我和同事因为一个问题争执时可以直接打开报告对质互相验证而不是凭感觉争论。第四要主动构建“固件家族图谱”。如果手上有多个固件版本我会把所有版本的哈希值、编译时间、分区表、内嵌驱动指纹都汇总到一张表里。分析新问题时先看这个新版本和哪个历史版本血缘最近直接做定向 diff能大幅缩小排查范围。这一点在设备量产之后尤其好用因为现场出问题的固件往往只比正常版本在某个分区差几个字节。5. 后续还能怎么扩展这套工具做完之后我明显感觉到“没人讲得清的固件”问题不再是不可解的谜团。只要掌握了解析脉络剩下的一切都可以半自动化处理。目前我计划把工具往三个方向扩展。第一个方向是解析规则配置化。现在内核里已经内置了不少常见固件格式的解析器但当遇到全新厂商的封装时仍然需要写一点代码。下一步我会把 magic 字典、分区表格式、校验算法全部做成 JSON 配置让懂业务但不擅长写代码的人也能自己加规则。第二个方向是可视化启动流程。很多固件难分析是因为我们只看到静态字节看不到 CPU 是如何一步步从 bootrom 过渡到 uboot再到 kernel 和 rootfs 的。我想把启动阶段的地址跳转关系提取出来画成一张调用路径图配合分区表信息使用者可以直接看到每个阶段执行的是哪段代码、访问的是哪个分区。这对排查启动类问题会有很大的帮助。第三个方向是自动化安全审计。固件安全的关注点一直在升温——硬编码密钥、弱加密算法、可被利用的越权接口这些都藏在固件里。我希望工具能自动扫描常见危险模式例如硬编码的 root 口令、可反序列化的不安全的协议字段、可写可执行的内存段等输出一个“疑似风险点”清单。这样接手固件的人在看第一眼报告时就能知道优先级。当然工具永远替代不了人对硬件的理解替代不了对业务逻辑的思考。但在绝大部分“固件没人讲得清”的现实场景里一个好用的分析工具足以帮你把可读性从“几乎为零”提升到“能说话”的水平。拿到固件后先别急着猜把你的分析工具建好你会发现那些看似天书一样的东西其实每一字节都有自己的意思。