接手一份没人讲得清的固件,几乎是每个做嵌入式的人都会撞上的事。半年多前我就撞上了:交接材料里只有一行字“这是上一版的完整固件,烧进去能跑”,再加一个没人愿意打开的旧工程压缩包。问了一圈,没有任何人能说清楚这个固件从哪来、分区怎么摆、改了文件之后该怎么封回去。没办法,我只能一条路走到黑,把这块二进制当考古现场来挖。挖到后来,我干脆攒了一套自己的固件拆解与回封工具链,名字很朴素,叫 fwkit,但帮我把这类“没人讲得清”的固件,变成了可以反复修改、可复现、可回封的工程产物。这篇文章就是把整个过程和踩过的坑摊开来讲,希望能给同样接手历史固件的朋友省点时间。
1. “没人讲得清”的固件到底缺了什么:交接现场还原与问题清单
先还原一下我当时面对的真实场景。设备是一块老旧的嵌入式板子,跑着定制版 Linux,闪存里烧了一个完整镜像。供应商早就换了三波,内部懂这块系统的人也调走了;现役团队里最接近“懂”的人,只能告诉我“用编程器把整个 flash 读出来,就是一份固件”。那句“烧进去能跑”确实不假,但一进入开发状态,问题就全暴露了。
1.1 文档与代码不可靠带来的连锁问题
最典型的问题是:你根本不知道该相信哪一份资料。仓库里的代码注释和实际编译产物明显对不上,Makefile 里写的目标平台和 uboot 日志里打印的硬件代号也不是一回事。我花了整整一天去核对“到底哪个内核版本在跑”,最后发现唯一可信的真相来源,就是 flash 里那一整个 bin 文件。
这种“代码失踪、文档作废、人员蒸发”的状态,几乎是所有历史固件的通病。它带来的核心困难不是二进制本身有多难读,而是缺少一张“地图”:哪些偏移属于 uboot,哪些属于内核,哪些属于根文件系统,哪一段是用户数据区。没有地图,你连从哪里开始看都不知道。
1.2 我给“没人讲得清”列的五个问题清单
后面我遇到类似固件,都会先按这张表问一遍,能答上的越少,就越需要动用专门工具:
| 问题 | 不知道时会发生什么 |
|---|---|
| 固件采用什么平台和字节序 | 连魔数都认不出来,扫描完全无意义 |
| flash 分区布局是什么 | 抽取 payload 时 offset 一步错步步错 |
| 文件系统是 squashfs、jffs2 还是 ubifs | 挂载失败,且不知道是工具问题还是数据问题 |
| uboot 对镜像是否校验 CRC 或签名 | 改完回封后设备不启动,还以为是代码改坏了 |
| 打包时的对齐和填充规则 | 回封出来的镜像要么变砖,要么一启动就崩溃 |
这五个问题,就是我做 fwkit 的第一版需求清单。它的目标不是“把固件破解成裸文件系统”,而是补齐这张地图:告诉我固件里有什么、结构是什么、从哪里切、能不能原样封回去。
1.3 为什么现成工具还不够
很多人会说,有 binwalk、unsquashfs、jefferson 这些现成工具,拆个固件还要自己写工具集?说实话,单点工具确实快,我也离不开它们。但它们的弱点是每条命令只解决一个片段,中间大量的判断靠手记,比如你手动记录 offset、手动比较不同分区的长度、手动在若干个 bin 片段之间来回切换。一旦固件形态复杂,一个人同时盯七八个中间文件,早晚会记岔。
fwkit 核心理念是把散碎的拆解动作串成一条可追溯的工作流。每拆一步,都把结果落进结构化目录,并保留文件 hash 和关键参数,这样下次拿到一个类似牌子但不同版本的固件,就能直接复用上次的拆解思路。
2. 先别急着逆代码:把固件拆成“包装层-容器层-文件系统层”
刚接手这种固件时,我的第一反应是把 bin 丢进反汇编器,想“找到入口点然后开始读”。事实证明这是南辕北辙。除非你要做的事是漏洞分析或代码级逆向,否则解一个固件,正确的顺序是从外往里剥,而不是从入口点往内存里钻。
2.1 固件不是“一个文件”,是三层结构的嵌套容器
我后来把所有固件都抽象成三个层次,这个抽象直接决定了 fwkit 的模块设计:
- 包装层:位于固件最前面的厂商自定义头或标准头部,负责描述镜像版本、硬件型号、目标地址、镜像长度、校验值。它决定了你怎么理解整块数据的边界。
- 容器层:位于包装层之内,通常是 uboot 的 legacy uImage、FIT image,或者干脆是裸分区镜像。它的作用是承载“内核 + 文件系统 + dtb”等不同子组件,并把它们摆到正确位置。
- 文件系统层:最内部的实际数据,常见是 squashfs、jffs2、ubifs、cpio 存档,这一段才是我们真正想改东西的地方。
这个三层模型的价值在于:每一层都可以独立识别、独立切分、独立替换。如果一个新固件只是把包装层换成了另一种厂商头,那我只需要适配包装层模块,容器层和文件系统层的逻辑可以原样复用。
2.2 用特征而不是猜测来识别每一层
识别层的关键不是肉眼猜,而是靠魔数、长度字段、对齐模式三者互相印证。比如 standard squashfs 的 superblock 通常以hsqs开头,而 jffs2 的节点头以0x1985起始,cpio 存档则是070701或070702。可光有魔数不够,因为整个 bin 十分里可能有十几段都含这些魔数。
我惯用的做法是三步交叉确认:
- 把文件丢进
binwalk做熵分析和特征扫描,拿到候选偏移列表; - 再用十六进制查看器去候选位置看 superblock 里的 block size、inode 数、压缩类型等字段是否自洽;
- 最后直接尝试在对应 offset 上挂载或解包,用工具结果来反向验证 offset 是否正确。
譬如 squashfs 的 superblock 有固定的结构,其中 block size 通常是 131072 或 65536,inode 数量一般不会为 0。如果这些字段看起来完全随机、不成逻辑,那八成是扫到了压缩数据里的“伪魔数”,得继续往后找。
2.3 用 strings 反向锁定关键路径
结构识别之外,还有一个特别奏效的笨办法:对整个 bin 跑一遍strings,把/etc、/usr/bin、/lib、/overlay这些路径打出来。只要看到这些路径且有明确的文件结构感,就说明这一段是文件系统层,往回找它的头部和长度就顺理成章。
实数上说,这个方法在“毫无文档”的场景里比任何工具都管用。我第一次从固件里定位根文件系统区域,就是先看到了满满的/etc/init.d路径,再反推出前面的 squashfs 边界。这也是每个拆固件的人都值得练的基本功。
3. fwkit 拆解实战:从一块裸 bin 到可挂载根文件系统的完整链路
有了三层结构模型作为指导思想,就能把“拆固件”定义成一串可复现的步骤。下面用一次真实拆解流程来演示我是怎么操作的。为了通用性,示例中的文件名和偏移做了一定脱敏,但每一步都是原样跑过的。
3.1 第一步:识别整体概况与分区草图
我拿到的是整个 flash 导出的 bin,第一步永远是先看整体:
file firmware_whole_v3.bin binwalk firmware_whole_v3.bin打开输出,重点关注两类信息:头部有没有明显的 uboot 或厂商标志;熵较高的区域是从哪里开始的。熵高通常意味着压缩文件系统或加密数据,而熵低则可能是未压缩的内核或文件系统元数据。结合这两点,我就能画出大致草图:
- 0x000000 - 0x100000:uboot
- 0x100000 - 0x300000:内核
- 0x300000 - 0x800000:squashfs 根文件系统
- 0x800000 - 0x1000000:jffs2 用户数据区
这一步不追求精确,只求建立骨架。fwkit 的scan命令做的就是这件事,区别在于它会把以上 offset 和判断理由写进一个 JSON 报告,而不是只打印在终端里。
3.2 第二步:按 offset 抽取容器层
画完草图后,用dd把怀疑是内核的区域整个抽出来,再做二次验证。这一步我犯过的最大错误是盲信第一次扫描。后来定的规矩是:抽出来的子镜像必须能独立被解析,否则就说明切割不对。
比如 I 抽出了从 0x100000 开始、长度 0x200000 的固件段,然后执行:
dd if=firmware_whole_v3.bin of=kernel_part.bin bs=1 skip=$((0x100000)) count=$((0x200000)) file kernel_part.bin如果file输出显示它是“u-boot legacy uImage”或“kernel Image”,那基本可以确认这段 cut 正确。如果它输出的是乱七八糟的数据,那我就要回头检查:是不是前面还有 dtb,或者根文件系统其实从这里就开始了。
3.3 第三步:文件系统层的抽取与挂载验证
文件系统层是大多数人的最终目标。squashfs 在固件里出现频率极高,抽取思路很直接:找到hsqs魔数所在位置,从那里开始抽取一直到分区尾部。
dd if=firmware_whole_v3.bin of=rootfs.squashfs bs=1 skip=$((0x300000)) count=$((0x500000)) unsquashfs -s rootfs.squashfsunsquashfs -s输出里能看到 superblock 中记录的字节序、压缩算法、block size、inode 数量。这些信息不仅用于确认,也用于后面的回封参数选择。
如果是 jffs2,我会用jefferson去解,或者更直接的方法是整分区做成镜像后用内核 loop 设备挂载,因为 jffs2 是日志型文件系统,它需要完整 erase block 上下文才能正确恢复,纯粹的流式解包经常缺文件。我在实践中比较偏爱先抽取整分区,再用mount -t jffs2去读,必要时配合nandsim。
解完文件系统后,强烈建议立刻做一次挂载验证,而不是直接进目录改东西:
mount -o loop rootfs.squashfs /mnt/fwtest ls /mnt/fwtest/etc能列目录只是第一步,真正需要检查的是/etc/inittab、/etc/fstab、/lib/modules这些关键文件在不在。如果有人把 DTB 藏在/boot下,也要在这里确认。这一步能避免很多“我改了文件但没改对文件”的无效劳动。
3.4 第四步:把中间产物工程化归档
裸手动拆解每一次都能成,但每次的 offset 判断、命令参数、踩坑记录都会丢。fwkit 在这里改进了习惯:它将每次拆解保存为一个 workspace,目录结构类似:
fwkit-out/ raw/ firmware_whole_v3.bin parts/ 0x000000-uboot.bin 0x100000-kernel.bin 0x300000-rootfs.squashfs fs/ rootfs/ meta/ scan-report.json fw-report.md有了meta目录,我再接一个新版本固件时,可以先diff新旧两份报告,快速知道哪些分区变了、哪些没变、文件系统类型是否一致。这种可追溯性在实际项目中比一次解包成功更有价值。
4. 回到 flash 的考验:对齐、填充、校验和与回封顺序
能解包只是及格,能把改动后的固件封回去还能正常启动,才算这门手艺的真正门槛。我在拿到无人固件后的第三天,就把一个自定义脚本塞进了根文件系统,结果刷回去后设备起不来。当时第一反应是“脚本把系统搞坏了”,后来冷静一查,发现完全不是代码问题,而是回封过程违反了 flash 世界的三条规则。
4.1 规则一:分区大小必须遵守对齐,pad 字节不能随便填
整个 flash 的分区边界并不是任意二进制都能扛住的。很多 bootloader 从读取到校验都有固定对齐要求,比如 erase block 是 64KB,那每个分区长度就建议取 64KB 的整数倍。拆包时你会看到许多分区尾部跟着大面积 0xFF 或 0x00,这些不是垃圾,而是为了让分区尺寸对齐而做的填充。
返回去重新打包时,padding 填充字节也有讲究。我遇到的这套 uboot 风格固件喜欢用 0xFF,而某些平台则要求 0x00。如果你的 padding 填错,虽然长度正确,但 bootloader 可能会把 padding 当成文件系统的一部分去解析,轻则启动慢,重则直接校验失败。建议拿到固件后先统计原始镜像里分区尾部的填充模式,并保持原样。
4.2 规则二:CRC 校验的范围往往不是整个文件
很多定制 uboot 对固件有 CRC 校验,但新手最容易踩的坑是:误以为 CRC 覆盖全文件。真实情况要看 bootloader 源码或头部结构,有的只校验头部,有的校验“头部后面 payload”,有的对每个分区分别算 CRC,甚至还有把 CRC 字段自己排除在外的奇葩设计。
我在这类问题上的解决套路是:先看头部有没有 CRC 字段,对比原始镜像和封回镜像时,只改 payload 而不改头部,再算结论;如果设备能启动,说明校验范围大概率不包含头部那部分,或者头部校验条件非常宽松。更稳妥的做法是找到 uboot 源码里的crc32或hash调用点,确定校验区间。
4.3 规则三:签名固件另行处理
如果固件携带数字签名或硬件级安全启动,情况会完全不同。签名一般由“镜像摘要 + 私钥签名”两部分构成,改动文件系统后摘要变化,签名就无法通过验证,设备会拒绝启动。并不是所有固件都适合这种“解包改包”的工作方式。
我在实操中只拿无签名或签名校验被关闭的调试版固件来演示;碰到量产锁定签名的设备,正确的做法是走厂商提供的官方升级机制、拿到对应 SDK 或签名工具,而不是想方设法绕过。这是固件安全的基本边界,行业里大家心里都该有数。
4.4 标准回封顺序
基于上述逻辑,fwkit 的repack子命令强制按如下顺序执行,而不是“把文件拼回去拉倒”:
- 修改文件系统层内容;
- 按原压缩算法、压缩级别重新制作 squashfs 或 jffs2 镜像;
- 根据原分区长度对镜像做对齐,保留原填充模式;
- 更新容器层头部中的长度字段;
- 计算并更新头部或 payload 的 CRC 字段;
- 按分区布局拼合整体镜像;
- 对整体镜像生成 SHA256,作为产物记录。
这套顺序的每一步都有验证点,而不是闷头走到最后一步才测试。我在实际项目里至少省下了三次“回刷变砖再救砖”的冤枉时间。
5. 三个让我改了两版工具的真实 Bug:从错误到修正的完整记录
工具不是一次写成的。fwkit 从最早的一堆 shell 脚本演进到现在,核心推动力就是拆解不同固件时踩到的各种「看似不该出错、最后确实出错」的坑。挑三个最有代表性的说说,估计很多人也碰到过。
5.1 坑一:squashfs 的“伪偏移”骗过了 binwalk
当时拿到一个摄像头固件,binwalk 很自信地告诉我 rootfs.squashfs 在 0x541000 位置开始。我按这个偏移抽取,unsquashfs -s也能读出 superblock 字段,但真正解包时却报错说 inode 读取失败、目录树损坏。
排查了很久,最后把 0x541000 前 64KB 的字节导出来对比,才发现这个偏移指向的是文件系统层之前的一段压缩环境变量数据,里面巧合地包含了和 squashfs 魔数一致的字节。真正的 superblock 还要再往后偏移几十 KB。解决办法是:识别不能只看魔数,还要校验 superblock 里的 block size 与实际文件系统开头是否一致、压缩后的第一个数据块是否能在该偏移后被正确定位。
fwkit 后来在扫描候选偏移时,会做一次“最小解包测试”,只有成功读取出第一个目录项的候选点才被标记为有效偏移。这避免了大量手动反复试错。
5.2 坑二:jffs2 的 erase block 上下文被误伤
第二个坑出在一个老路由器的用户数据分区。我用完整分区装 loop 设备挂载,结果 mount 报错“jffs2: summary marker not found”,折腾了半天都找不到原因。
后来打开uboot的日志,发现这个分区根本不是普通 jffs2,而是叠加了 OOB 校验和坏块表的结构。直接对 raw flash 导出的 jffs2 镜像挂载,自然缺了很多带擦除块元数据的信息。正确做法是先识别该分区是否带 OOB 数据、是否采用“whole erase block”布局,再用专门工具把 OOB 部分剥离或重建 erase block 上下文。
这个坑让 fwkit 增加了一个--nand-mode处理路径,专门处理这类“看起来是 jffs2 但实际是 NAND 原生布局”的固件。对没有 NAND 经验的同事来说,这个模式能直接把“挂载失败”变成“可解包”。
5.3 坑三:回封后 uboot 不启动,问题在于我“太聪明”了
第三个坑完全是我自己造成的。回封镜像时,我觉得 squashfs 压缩后的大小和原始不一样,为了“节省空间”,就把后面的 padding 区域缩短了,让整个分区短了几十个字节。结果设备刷入后 uboot 日志一直停在“wrong image size”反复重启。
排查后发现,uboot 在启动时不是拿整个 flash 分区长度来读的,而是根据内核镜像头部里的 size 字段。换句话说,头部 size 字段、分区表记录的实际长度、以及文件系统末尾 padding 长度,三者必须保持一致。我擅自缩短 padding,就造成分区表里登记的“最大长度”与头部声称的 size 不一致,uboot 直接拒载。
修复方案是:回封时分区长度严格恢复成原分区长度,头部 size 字段也改回原始数值;squashfs 压缩后多出来的空间,继续用相同的填充字节补齐。fwkit 现在每次 repack 都会把这三个数值强制拉齐,否则直接报错,不允许生成镜像。
这三个坑总结在一起,其实就是一条主线:解包容易让人飘飘然,回封才是考验对平台细节理解程度的地方。工具能做的,就是把这些细节固化成默认策略,而不是每次都靠人肉记忆。
6. 工具沉淀下来的样子:模块划分、命令行设计与使用建议
最后聊聊 fwkit 最终长成什么样。它不是一个图形化的大门户,也不追求“一键傻瓜式破解”,而是一组互相独立、可以通过命令行自由组合的小模块。所有产物都落进一个 workspace 目录,方便对比和追溯。
6.1 模块划分和对应命令
fwkit 当前有五个核心子命令,分工如下:
| 子命令 | 作用 | 产出 |
|---|---|---|
fwkit scan <file> | 扫描固件结构,识别各层偏移与文件系统类型 | scan-report.json |
fwkit extract <file> --report <json> | 根据扫描报告抽取所有分区与文件系统 | parts/ 与 fs/ 目录 |
fwkit mount <fs-file> | 自动尝试按 squashfs/jffs2/ubifs 等类型挂载或解包 | 挂载目录或解包结果 |
fwkit repack <workspace> | 按原平台规则回封,强制多字段一致性校验 | 新固件镜像 + SHA256 |
fwkit diff <ws1> <ws2> | 对比两个固件拆解报告,输出差异摘要 | 差异报告 |
这种设计的原因在于,固件格式百花齐放,没有人能预知下一个固件会用什么厂商头或什么文件系统。一键式工具在遇到“未知格式”时只能尴尬报错,而组合式工具能让你手动介入某个环节:扫描不准就手动指定 offset,解包失败就换文件系统插件,回封失败就看校验报告。
6.2 一段实际使用示例
假设你手里有一个unknown-device_v2.bin,典型路线是:
mkdir ws && cd ws fwkit scan ../unknown-device_v2.bin fwkit extract ../unknown-device_v2.bin --report scan-report.json ls parts/ fs/如果fwkit extract自动识别失败,可以手动修正:
fwkit extract ../unknown-device_v2.bin \ --override 0x300000:squashfs \ --override 0x800000:jffs2改完 rootfs 里的文件后,回封:
fwkit repack . ls dist/ sha256sum dist/*.bin整个过程对新手友好,但对老手也不碍事。重要的信息都保留在meta/下,不会因为一次命令结束就丢失上下文。
6.3 几个使用建议
第一,拿到新固件后先跑scan,但不要盲信报告,尤其当报告显示“多个候选偏移”时,必须结合 strings 和文件系统结构再确认。第二,每次拆解和回封前记录原始镜像的 SHA256,防止后来拿错文件。第三,工具尽量常跟项目里常见格式同步迭代,把每次手动 override 过的参数沉淀为可复用配置,攒久了就是团队自己的“固件知识库”。
说到底,做这个工具的过程,比工具本身更值钱。它逼着我把“没人讲得清”的固件,拆成了一个个能讲清的结构、参数和步骤。以后再遇到类似的历史包袱,我已经不会再焦虑了,因为产品里每一块看不明白的东西,最后都会在拆解、验证和回封的过程中露出真面目。这就是我在这件事里最大的收获。