ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

固件拆解与回封实战:用fwkit将无人维护的固件变成可复现工程

固件拆解与回封实战:用fwkit将无人维护的固件变成可复现工程 接手一份没人讲得清的固件几乎是每个做嵌入式的人都会撞上的事。半年多前我就撞上了交接材料里只有一行字“这是上一版的完整固件烧进去能跑”再加一个没人愿意打开的旧工程压缩包。问了一圈没有任何人能说清楚这个固件从哪来、分区怎么摆、改了文件之后该怎么封回去。没办法我只能一条路走到黑把这块二进制当考古现场来挖。挖到后来我干脆攒了一套自己的固件拆解与回封工具链名字很朴素叫 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 或 65536inode 数量一般不会为 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 - 0x100000uboot0x100000 - 0x300000内核0x300000 - 0x800000squashfs 根文件系统0x800000 - 0x1000000jffs2 用户数据区这一步不追求精确只求建立骨架。fwkit 的scan命令做的就是这件事区别在于它会把以上 offset 和判断理由写进一个 JSON 报告而不是只打印在终端里。3.2 第二步按 offset 抽取容器层画完草图后用dd把怀疑是内核的区域整个抽出来再做二次验证。这一步我犯过的最大错误是盲信第一次扫描。后来定的规矩是抽出来的子镜像必须能独立被解析否则就说明切割不对。比如 I 抽出了从 0x100000 开始、长度 0x200000 的固件段然后执行dd iffirmware_whole_v3.bin ofkernel_part.bin bs1 skip$((0x100000)) count$((0x200000)) file kernel_part.bin如果file输出显示它是“u-boot legacy uImage”或“kernel Image”那基本可以确认这段 cut 正确。如果它输出的是乱七八糟的数据那我就要回头检查是不是前面还有 dtb或者根文件系统其实从这里就开始了。3.3 第三步文件系统层的抽取与挂载验证文件系统层是大多数人的最终目标。squashfs 在固件里出现频率极高抽取思路很直接找到hsqs魔数所在位置从那里开始抽取一直到分区尾部。dd iffirmware_whole_v3.bin ofrootfs.squashfs bs1 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.jsonfwkit extract file --report json根据扫描报告抽取所有分区与文件系统parts/ 与 fs/ 目录fwkit mount fs-file自动尝试按 squashfs/jffs2/ubifs 等类型挂载或解包挂载目录或解包结果fwkit repack workspace按原平台规则回封强制多字段一致性校验新固件镜像 SHA256fwkit 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 过的参数沉淀为可复用配置攒久了就是团队自己的“固件知识库”。说到底做这个工具的过程比工具本身更值钱。它逼着我把“没人讲得清”的固件拆成了一个个能讲清的结构、参数和步骤。以后再遇到类似的历史包袱我已经不会再焦虑了因为产品里每一块看不明白的东西最后都会在拆解、验证和回封的过程中露出真面目。这就是我在这件事里最大的收获。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进