做嵌入式开发这些年,我接过不少烂摊子,但最让人头疼的,永远是那种没人讲得清的固件。文件就躺在那里,名字叫v2.8_release.bin,设备跑起来一切正常,可你要是想知道里面到底有什么、改一个参数会不会炸、想加个功能该动哪里——问谁都是含糊其辞。文档是两年前的,写文档的人已经离职,问了一圈只剩下“反正之前就是这么刷的”这种话。
这种状态下,靠问人没用,靠猜更危险。唯一靠谱的办法,是自己把固件拆开看明白。所以我花了两周时间,基于binwalk的识别思路和 Python 的二进制处理能力,做了一套固件分析小工具,用来完成从识别、解包、分析到重新打包的完整流程。这篇文章就把这套工具的来龙去脉、核心模块实现、以及我在实际操作中踩过的坑,一次讲清楚。
1. 项目背景与创作初衷
1.1 一份"三无"固件到底有多难搞
所谓没人讲得清的固件,通常具备三个特征:无文档、无源码、无人可问。我接手的这份还叠加了一个更恶心的条件——它是某个量产设备的主控固件,由芯片原厂提供基础版本,经第三方方案商二次定制,最后落到我们手里时已经过了至少三四手。每一手都只改了局部参数,没有任何人保有完整记录。
这类固件往往是以单个.bin文件交付的,里面可能包含 Bootloader、内核、设备树、根文件系统、业务配置分区,甚至还有一处用于保存校准参数的独立区域。它们按一定偏移拼接在一起,再加上头部信息、版本号、校验值,形成你最终看到的二进制文件。
常规做法是什么?直接烧录,能用就跑。但这种“能用”经不起推敲:客户要改一个波特率,你要重新发包给上游;设备量产阶段想调整分区大小,没人敢动;固件偶发崩溃,想要一份带符号的日志,得到的回答是“这个版本不开源”。说白了,固件对团队而言是一个黑盒,而所有依赖黑盒的工程实践,迟早要付出代价。
我第一次尝试用手工方式分析这份固件时,光是找文件系统偏移就花了一整天。用十六进制编辑器不断翻页查找特征魔数,比对strings输出里的路径线索,效率极低。最崩溃的是找到疑似根文件系统的位置后,还无法确认它是squashfs还是cramfs,因为两者的解包工具完全不通用。
1.2 为什么我决定不靠问人,靠造工具
促使我做工具的直接原因是一次失败的分区调整。当时我需要把一个只读分区的空间扩大 128KB,用来容纳新的字体资源。按正常流程,这需要重新生成整个烧录镜像。我向原方案商提了需求,得到的回复是“这个功能的固件那边改不了,得加钱,而且排期三周”。
三周,对迭代节奏来说太久了。我当时的判断是:如果我能把固件拆开,把分区表读明白,把文件系统重新打包回去,并修复镜像校验,这件事我自己就能在两天内搞定。于是决定自己动手,目标很明确——做一个内部工具,把固件从“只能整包烧录”变成“可解包、可修改、可回包、可验证”。
这个工具最终被我命名为“固件手术台”,因为它干的事情和外科手术很像:切开、观察、修改、缝合。工具从一开始就没有打算做成一款通用商业产品,而是围绕我手头这批硬件定制的,但它采用的结构化识别方式,对绝大多数嵌入式固件都有参考价值。
2. 工具设计思路与技术选型
2.1 先把"解包"这件事拆成四个能力
动手写代码之前,我先把需求拆了一下。固件处理的本质,是对一个二进制大块做结构识别和变换,它至少包含四种能力:
- 识别:判断这个
.bin里装了什么、有多少个分区、每个分区是什么类型、偏移和长度分别是多少。 - 解包:从完整镜像中提取出内核、文件系统等独立组成部分,并还原成可编辑的目录或文件。
- 修改与打包:对文件系统内容做修改后,重新生成文件系统镜像,并把它按原结构拼接回去。
- 校验修复:重新计算镜像长度、校验和、签名区等字段,确保设备能接受改过的固件。
这四步看起来简单,但每一步都有坑。比如识别阶段,很多固件头部不是标准格式,长度字段是小端还是大端?偏移单位是字节还是扇区(512字节)?文件系统后是否有填充对齐?这些问题如果不通过代码系统化地处理,每次手工分析都要重新踩一遍。
我把工具设计成了“插件式”结构。底层是一个二进制流读取层,负责处理字节序、偏移和对齐;往上分别是魔数匹配器、分区表解析器、文件系统识别器、打包器、校验器。这样当我拿到一个新固件时,只需要把它的头部格式写成一个小插件,其余能力全部复用。
2.2 技术栈选择:Python为主,开源工具为辅
工具的主体用 Python 3 编写,原因很直接:Python 在处理二进制文件时够灵活,struct模块可以精确解析任意字节布局,字符串搜索、正则匹配、十六进制打印都开箱即用。而且开发速度快,一个分析脚本当天写完当天就能跑。
但纯 Python 不适合做文件系统级别的解包和打包,这部分我调用了成熟开源工具的命令行接口:
| 功能 | 使用工具 | 说明 |
|---|---|---|
| 固件结构初步识别 | binwalk | 扫描魔数,标出可能的分区边界 |
| squashfs 解包 | unsquashfs | 常用只读文件系统格式 |
| squashfs 打包 | mksquashfs | 重新生成只读文件系统镜像 |
| ext4/jffs2 处理 | debugfs / mtd-utils | 处理可写文件系统场景 |
| 熵分析 | 自研小脚本 | 判断数据是否加密或压缩 |
| 头部分区解析 | 自研 Python 模块 | 针对项目定制 |
在编排这些工具时,我没有直接使用binwalk的 Python API,而是采用正则解析命令行输出 + 自研魔数匹配双重验证的方式。原因之后会讲,binwalk 在某些固件上有误报,尤其是遇到高熵数据段时,往往会给出好几个可疑偏移,必须二次确认。
还有一个选型考量是跨平台。工具最初在 Windows 上开发,但实际刷机验证环节常在 Linux 环境进行,所以我从一开始就把所有路径操作写成了相对路径,避免硬编码盘符,同时用subprocess调用外部工具时做了平台差异封装。后来这套工具在 Windows 和 Ubuntu 18.04/20.04 上都稳定跑过。
3. 核心模块实现与实战拆解
3.1 固件头信息解析
拿到一个陌生固件,第一步永远是解析头部。绝大多数固件都会在头部写入魔数(Magic Number)、版本号、镜像总长度、分区数量等信息,只是字段偏移各不相同。
我常用的方法,是先用十六进制工具看文件开头 512 字节,寻找 ASCII 可打印字符串和明显的长度字段。比如一份常见路由器固件,头部通常是 256 字节,包含类似HW-ROUTER-1.0的标识,紧接着是 4 字节大端长度值。但有些电视盒子固件会使用自己定义的 64 字节头部,所有字段都是自定义偏移。
我写了一个通用的头部解析脚本,核心思路是:允许通过配置文件指定若干已知格式,代码自动尝试匹配,匹配失败则进入交互式人工标注模式。
import struct def parse_custom_header(data): # 示例:假定头部第0~3字节为魔数,4~7为版本,8~11为镜像长度(小端) magic = data[0:4] version_raw = data[4:8] length_raw = data[8:12] length = struct.unpack("<I", length_raw)[0] # 小端大端自动尝试 if not (0 < length < len(data) * 2): length_be = struct.unpack(">I", length_raw)[0] if 0 < length_be < len(data) * 2: length = length_be return { "magic": magic, "version": version_raw.hex(), "total_length": length, }这个脚本看上去简单,但解决了大问题。头部解析不准确,后面的偏移计算全是错的。我调试过程中发现过好几份固件,头部写的是“总长度”,实际文件长度比头部声明的多了几百字节,原因是在镜像末尾追加了一个平台签名块——这种异常如果不识别,打包时直接按声明长度截断,就会把签名块丢掉,刷机后设备启动到一半自动回滚。
所以我在工具里专门设计了一个“头部声明长度 vs 实际文件长度”对比模块,差异超过 16 字节就会弹警告,并强制要求人工确认差异区域的内容。
还有一个细节值得单独说:版本号字段。很多固件的版本号不是单纯字符串,而是一个整数被拆成主版本、次版本、修订号存放。解析时如果按字符串读,会得到一串乱码;按大端短整数读,反而能对上文档里的版本。这个经验让我后来在看任何头部时,都会同时尝试按字符串、按字节数组、按整数三种方式解读。
3.2 文件系统识别与提取
头部解析完之后,核心任务就是定位文件系统。文件系统的识别主要靠魔数,常见格式的特征如下:
| 文件系统 | 魔数(十六进制) | 说明 |
|---|---|---|
| squashfs | hsqs(0x68737173) | 最常用的嵌入式只读文件系统 |
| cramfs | 0x28cd3d45 | 老式设备常用,结构紧凑 |
| jffs2 | 0x1985(节点起始) | 多见于 NOR Flash |
| ubifs | 0x31, 0x18, 0x10, 0x06 | UBI 上的日志文件系统 |
| ext4 | 0x53EF(超级块偏移 1080) | 较少直接裸露在固件中 |
在识别阶段,我没有直接依赖单一工具。binwalk 输出里经常把hsqs后面的压缩数据也标成“squashfs 文件系统”,这不算错,但实际解包时并不会成功,因为真正的文件系统是从魔数所在偏移开始的连续块。我在工具里加了二次验证:对候选偏移做对齐检查。squashfs 通常按 4 字节或 4096 字节对齐,如果偏移值不是对齐的整数倍,基本可以排除误报。
提取动作本身分两层。第一层是“整段提取”,把从魔数偏移到下一个分区偏移之间的所有字节原样扣出来,保存为.squashfs文件。第二层是“挂载解包”,调用unsquashfs还原成目录树。这一层有个问题:如果文件系统损坏或尾部被截断,unsquashfs会直接报错退出,且不告诉你损坏位置。我的处理方法是加一个预检——先尝试unsquashfs -s打印超级块信息,如果连超级块都读不出来,就说明魔数位置选错了,不是文件系统损坏。
目录树解包完成后,工具会自动扫描常见业务目录(/etc、/usr/share、/opt),并生成一份文件清单和大小分布。这份清单对后续定位配置项和资源文件极其有用。我第一次完整解包后,发现设备的启动脚本里居然硬编码了一个测试服务器的 IP,这就是用文件清单 grep 出来的。
3.3 重新打包与校验修复
解包只是前半场,真正麻烦的是打包回去。打包过程可以拆成三步:
- 用
mksquashfs把修改后的目录重新压成.squashfs文件。 - 把新的文件系统镜像按原偏移插入完整固件相应位置。
- 更新头部中的长度字段和全局校验值。
第三步最坑。很多固件的校验值不是简单对整个文件算 CRC32,而是对特定区间计算,再按某种特定算法迭代。有的厂商会先把文件按 1024 字节分块,每块取 XOR 结果,最后把块结果累加成一个 16 字节校验串。还有的厂商干脆把校验算法放在一个独立的checksum程序里,而那个程序本身就在固件的/usr/bin目录下——所以你解包之后,第一件事应该是找找有没有类似check_sum、mkimage、makefw的可执行文件。
在这个项目里,我遇到的校验逻辑比较典型:固件头部第 16 字节是“镜像总扇区数”,最后 4 字节是“从偏移 256 到文件末尾”的累加和校验。这个累加和不是 CRC,而是每 32 字节做一次异或,把结果写入末尾。当时排查这个逻辑花了不少时间,最终是通过对比“原固件校验结果”和“修改后固件应有的校验结果”反推出来的。
通用做法是:在工具中内置一个校验“回放”功能。先把原固件的每个字节区间按不同算法(CRC32、异或累加、简单求和)计算一遍,看哪个结果能对上头部里已有的校验字段。一旦对上,就找到了校验算法,打包含把这个算法重新作用于修改后的文件,生成新校验值回填。我建议所有做固件工具的人都把这一步做进自动化流程里,因为你不可能每次都靠肉眼去猜算法。
# squashfs 重新打包示例 mksquashfs ./rootfs_new ./new_rootfs.squashfs \ -comp xz -b 131072 -noappend -all-root打包参数里,-comp xz和-b 131072必须和原固件一致,否则设备解压时会出问题。判断原压缩算法的方法,是在解包前用 binwalk 查看 squashfs 超级块中的压缩类型字段。很多新手在这里踩坑,直接默认用gzip压缩,打包出来的镜像原始大小翻倍,烧录后设备启动异常,其实就是解压器不匹配。
3.4 加密与混淆的识别思路
在分析固件时,难免会遇到加密或混淆的情况。这里说的“加密”不是指设备运行时的安全通信,而是指固件镜像本身被整体或分区加密。判断方法很简单:计算文件各段的信息熵。正常未加密的代码和数据,熵值通常在 4.5 到 7.5 之间;加密或强压缩后的数据,熵值接近 8.0。如果一个 .bin 文件的末尾 80% 区域熵值全部超过 7.9,且没有可在其中找到任何普通字符串,大概率是被加密了。
我在这个项目中遇到的固件没有整体加密,但它的配置分区做了一次 XOR 混淆,密钥是一个 8 字节的固定字符串。当时找到密钥的方法并非靠爆破,而是在同目录配套的升级工具中发现的——升级工具为了能在写入前修改配置,必然内置了解混淆的逻辑。这类情况在定制固件里很常见。
我在这件事上有一条明确的边界:所有分析都针对自己合法持有并有权维护的设备,目的是完成厂商不再支持的功能维护。做完分析之后,我也把发现的安全问题(硬编码密钥、弱校验算法)同步给了客户,建议他们在下一代产品里替换为带签名的固件体系。固件安全分析的价值,不只是让人能“改”固件,更是让人发现并修复固件里的漏洞。
4. 完整实操流程:从陌生固件到可用镜像
4.1 摸底:先别急着拆,先看"体检报告"
拿到一份陌生固件,我建议你先克制住拆解的冲动,按这个顺序做一遍摸底:
第一步,查看文件信息。ls -l看大小,file命令看识别结果,strings firmware.bin | head -100看有没有暴露路径或版本信息。很多固件的第一手线索就藏在这里。我记得有一次仅仅通过strings里的/mnt/data路径,就推断出设备存在一个可写的数据分区,而这个分区恰好没有被固件整体覆盖——这意味着升级固件不会丢业务数据。
第二步,熵分析。把固件按 4096 字节分块,每块计算熵值并绘制曲线。通过曲线能直观看到:哪些区域是未压缩代码段,哪些区域是高熵的压缩文件系统,哪些区域是低熵的空闲填充。这个曲线的价值远超想象,它基本决定了后续解包策略。比如一段熵值为 7.8 以上的区域,大概率是 squashfs 中的压缩数据,但也可能是 AES 加密块,这两者的后续路径完全不同。
第三步,binwalk 扫描。把binwalk的结果跑出来,和熵分析曲线对照看。如果 binwalk 报了一个 squashfs 偏移,而这个偏移处熵值只有 5.0,那基本可以断定是误报;如果偏移处熵值接近 8.0,且魔数也匹配,那这个位置就值得深入。
4.2 解包:用工具把固件"摊开"
摸底完成,进入正式解包阶段。我在实操中的做法是,先把固件中所有疑似分区都按偏移提取出来存成独立文件,再逐个验证。
一份典型固件的分区布局可能是这样的:
| 偏移 | 大小 | 内容类型 | 处理方式 |
|---|---|---|---|
| 0x000000 | 0x10000 | Bootloader(U-Boot) | 不修改,备份即可 |
| 0x010000 | 0x300000 | 内核 zImage | 需要时可解开改 cmdline |
| 0x310000 | 0x800000 | squashfs 根文件系统 | 主要操作区域 |
| 0xB10000 | 0x10000 | 配置分区 | 需单独解析格式 |
| 0xB20000 | 0x01000 | 校验签名区 | 打包时需要保留原值或重算 |
提取时有一个操作禁忌:不要只提取“看起来像文件系统”的区域,而是要提取所有连续区域,并按偏移命名。因为固件内分区之间存在设计时未对齐、动态分配等历史遗留问题,某个区域看起来是空闲填充,实际上可能藏着设备生产时的校准数据。提取完整、备份完整,后续操作才敢放开。我在工具里设置了“一键提取所有分区”功能,输出结果中保留偏移和长度信息,方便随时回查。
解包根文件系统用的是前面提到的双重验证流程。如果unsquashfs -s能正常读取超级块,就直接解包到工作目录。解包结束后,工具会自动统计文件数量、符号链接数量、特殊文件数量。特殊文件(设备节点、suid 文件)的存在,往往意味着这个固件有较复杂的权限模型,修改时需格外小心。
4.3 修改与重新打包
解包成功后,修改内容就是常规的文件操作了。这次项目要做的实际修改是:调整系统启动脚本中的串口波特率,从 115200 改为 460800,并替换/usr/share/fonts下的一个中文字体文件。
修改文件本身没有技术难度,难点在重新打包时保持文件系统原始属性。mksquashfs打包时,我需要显式指定文件所有者为 root,并确保-noappend参数被加上,否则在已有镜像文件上重复执行会追加内容,而不是重建。另一个细节是文件系统块大小,原固件如果用了 131072 字节块,而你用默认的 4096 字节块重组,解压性能会下降,而且镜像体积会增大。
打包完成后,把新文件系统按原偏移写回完整镜像,然后运行校验修复模块。此时你会遇到一个经典问题:改动文件系统后,整个镜像的字节分布全变了,原校验值必然失效。如果你事先找到了校验算法,这里直接重算即可;如果没找到,你面对的可能是一台怎么刷都无法启动的设备。
所以我强烈建议:动手修改之前,先把原固件的校验算法研究透。哪怕你只是替换一个字体文件,也要走完“解包→修改→打包→校验重写”的完整流程,而不是手工拼凑。
4.4 验证与测试
重新打包好的固件,不能直接在生产设备上刷。我的验证流程分三层:
第一层,镜像自检。用工具重新解析新固件,对比头部字段、分区偏移、文件系统魔数是否和原固件一致。这一步能拦住 80% 的低级错误,比如偏移没对齐、长度字段写错。
第二层,虚拟机或模拟环境。对于路由器类设备,可以用 QEMU 启动新固件的内核和文件系统,检查系统能否正常引导、关键服务是否启动。虽然外设驱动可能不完整,但验证根文件系统的完整性和内核 cmdline 是否正确足够了。这个项目里的设备是单片机方案,没有专用模拟环境,所以这一层的验证改为在相同主控芯片的评估板上进行,烧录后检查串口日志输出级别和外围传感器读取是否正常。
第三层,真机刷入。这一步要格外小心,确保有完整的恢复手段。刷机前先备份当前可用固件和全部配置分区。刷入后不急于接外部系统,而是先观察上电自检、引导日志、文件系统挂载点。如果 30 秒内没有异常重启或内核 panic,再逐步接入外设。
5. 常见问题与排查技巧实录
5.1 文件系统识别失败
症状:binwalk 扫不出任何有效魔数,或者扫出来了但unsquashfs无法读取。
排查方向:先确认文件尾部是否被截断。很多固件在传输过程中会被邮件系统或网盘转换,导致二进制文件损坏。用sha256sum和官方哈希对比是最快的方法。如果没有官方哈希,可以看文件大小是否等于头部声明的长度,再检查文件末尾是否有大量 0xFF 填充。0xFF 填充在 NOR Flash 设备上是正常的,但如果是 0x00 填充,通常说明固件经过了某种工具转换。
第二个排查方向是字节序。部分厂商的魔数会以字节逆序写入。例如 squashfs 魔数hsqs正常字节序是 68 73 71 73,但如果遇到字节逆序的存储,你在十六进制编辑器里看到的是 73 71 73 68。这种情况通过人工搜索可打印字符串不容易发现,但在工具里加上对常见魔数的字节逆序匹配能有效解决。
第三个方向是固件做过分区加密。如果整个文件的熵值均匀偏高,且没有任何可识别魔数,就要考虑整体加密的可能性。此时先别急着硬刚加密,而是回头翻找配套的升级工具、量产烧录软件、芯片原厂参考代码——这些材料里通常能找到加密算法或至少是密钥线索。
5.2 打包后校验不通过
这是几乎所有固件工具使用者都会遇到的问题。症状很明确:修改并打包后的固件,烧录时设备不认,要么提示“mirror checksum error”,要么恢复出厂默认配置,要么干脆不启动。
排查步骤按顺序来:
- 第一步,确认你修改的是不是只读分区。有些固件的文件系统是只读挂载的,但分区本身放在可读写区域,你在解包时看到可以修改,并不代表设备运行时会加载你的修改。
- 第二步,确认头部长度字段是否更新。很多厂商的升级程序首先校验头部长度与文件实际大小是否一致,不一致会直接拒绝升级。
- 第三步,确认校验算法匹配。这里有一个常见误区:厂商在头部写入的校验值可能只覆盖了文件系统区域,而不是整个文件。如果你对整个文件重算校验,回填后反而导致其他区域的原始校验被误覆盖。
- 第四步,如果设备有 RSA/ECDSA 签名机制,无论你如何重算校验值都没用——除非你有私钥,否则修改过的固件永远不会被接受。这种情况下,只能退回在设备运行时做在线修改,或者通过替换可加载模块的方式实现功能变更。
5.3 刷入后设备无法启动
最让人手心出汗的情况莫过于此。设备刷入新固件后反复重启、指示灯乱闪、串口无输出。
我的处理流程非常固定:
第一,保持冷静,先断电,用编程器备份当前 Flash 全量内容。哪怕设备已经半砖,只要 Flash 还能读,就有救回来的可能。
第二,检查串口输出。大多数嵌入式设备留有 UART 调试接口,如果你在硬件设计阶段就没留,那只能靠编程器了。串口输出能提供关键信息:U-Boot 是否执行、内核是否解压、文件系统是否挂载成功。
第三,定位问题层级。如果在 U-Boot 阶段就卡住,说明 Bootloader 区域被破坏或校验失败,需要用编程器单独刷回原厂 Bootloader。如果 U-Boot 正常运行但内核启动后 panic,问题大概率在内核或设备树区域。如果文件系统挂载失败,问题在你的新 squashfs 镜像。
我在这类事故里总结出一条经验:烧录两份中间版本。第一份是先只修改一个无关紧要的文件(比如/etc/version),重新打包刷入,验证整个工具链是否通顺;确认工具链没问题后,再做真正有风险的修改。这样做可以把“工具问题”和“业务修改问题”分开定位,避免两种故障混在一起难以排查。
5.4 常见问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| binwalk 扫描无结果 | 文件损坏或非标准封装 | 检查哈希,确认传输方式 |
| 熵值整体接近 8.0 | 固件整体加密或强压缩 | 从配套工具中寻找算法线索 |
| unsquashfs 读超级块失败 | 魔数偏移错误或文件系统截断 | 做二次对齐验证,检查尾部填充 |
| 打包后镜像体积异常膨胀 | squashfs 压缩参数与原版不一致 | 对比原镜像压缩算法和块大小 |
| 刷入后 U-Boot 阶段卡死 | Bootloader 被改动或校验失败 | 编程器回写原厂 Bootloader |
| 内核启动后挂载失败 | 文件系统打包错误或分区偏移错位 | 用ls -l /dev确认设备节点,检查分区表 |
| 设备能启动但功能缺失 | 特殊文件丢失或权限位未保留 | 解包后保留完整文件属性,使用-all-root参数 |
排查问题的过程其实是逆向理解厂商工程习惯的过程。同一个工程师写出的固件,往往在多个项目里保持着类似的头部格式、分区划分习惯和校验方式。当你手头工具有了,经验也积累起来之后,处理一份新固件的速度会从最初的几天缩短到半天以内。
我在实际使用中最受益的一点是,这个工具让我把之前需要手工操作、反复试错的工作,变成了可记录、可复现的流程。下次再接到一份没人讲得清的固件时,我不再是从零开始拆解,而是直接套用之前写好的插件和脚本。如果你经常要处理嵌入式设备维护、固件升级或二次定制,我强烈建议你也花时间沉淀一套自己的固件分析工具链——不必追求通用,能解决你手头那类问题,就值回所有投入。