1. 一张U盘里的16个固件,差点让我把开发板烧坏
运维老周把U盘往我桌上一丢,说了句“都在里面了”,就走了。我打开一看,十六个固件文件,名字一个比一个离谱:update.bin、new_201905.bin、backup_final_v2.img、whole.img、fw_old_ok.bin、不要动这个.bin……旁边没有文档,没有版本记录,没有对应硬件型号。问了一圈,老员工说“好像给某款盒子用过”,但对不上具体型号;新来的同事表示听都没听说过。
我当时的第一个念头是:“直接烧一片试试。”还好忍住了。干这行的都清楚,固件这东西,烧进去容易,烧错了救回来难。尤其是机顶盒、路由器、电视主板这类设备,固件里一旦混入不匹配的引导参数,轻则系统起不来,重则引导分区被覆盖,后面得拆闪存上编程器才能恢复。我手头这片板子价值倒不高,但来回折腾的时间成本实在不划算。
所以我给自己定了个规矩:凡是来源不明的固件,必须先在电脑上完成静态分析,再考虑上机。说白了,就是先让二进制文件自己“开口说话”——它是什么平台的镜像?引导方式是什么?文件系统是哪一种?打包日期是什么时候?有没有私钥、硬编码账号这些不应该出现的东西?
这篇文章就是记录我为了处理这批“没人讲得清”的固件,从零写了个小工具的全过程。它不复杂,核心就是一套自动识别、自动解包、自动扫描的可执行脚本,但做完之后,后续再接手任何新固件,我都可以在几分钟内得到一份相对完整的“固件身份证”,而不是靠猜。
如果你也遇到过类似情况——交接文档丢了、上游厂商不配合、文件名全是test和final——那这篇文章应该能给你省点冤枉路。本文涉及的技术点包括固件魔数识别、镜像头部解析、squashfs/cramfs/jffs2文件系统提取、以及常见的固件陷阱。我会把关键思路和踩坑经历都展开讲。
2. 把“没人讲得清”翻译成四个能落地的工程问题
面对十六个来路不明的文件,最容易犯的错就是一把梭:挨个解包看看。我一开始也是这么干的,结果解到第三个就发现效率极低——有的文件根本不是什么打包镜像,就是一段裸的u-boot二进制;有的解出来了,但完全不知道属于什么芯片平台。后来我把思路理了一下,发现“没人讲得清”这个笼统的抱怨,其实可以拆成四个具体问题:
- 目标平台是什么?是ARM还是MIPS?是高安还是非高安?主控厂商是哪家?这决定了后续用什么工具链、什么解包姿势。
- 整个文件是完整镜像还是部分升级包?有的固件是“全量包”,从bootloader到rootfs全都有;有的只是ota差分升级包,单独拎出来没有意义。
- 文件系统用的是什么?squashfs、cramfs、jffs2、ubifs,处理方式完全不同。用错工具硬解,浪费时间不说,还可能把数据搞坏。
- 版本和隐藏信息是什么?编译时间、设备型号字符串、硬编码的调试账号、私钥、串口开启指令,这些才是维修和二次开发真正要用到的信息。
我把这四个问题列成了一张表,每一条都对应一个可行的排查手段。这张表后来直接变成了工具的功能模块:识别模块负责芯片平台和镜像整体结构,解包模块负责文件系统,扫描模块负责版本字符串和敏感信息。
| 要回答的问题 | 主要判断依据 | 常用手段 |
|---|---|---|
| 芯片平台是什么 | 引导加载器源码特征、CPU架构相关指令串、BSP版本号 | strings、file、反汇编跳板入口地址 |
| 是不是完整镜像 | 是否存在uImage/Android boot头部,是否有多个分区连续存放 | hexdump头部、binwalk粗略扫描 |
| 文件系统类型 | 超级块的魔数、压缩算法特征、块大小字段 | 按偏移验证sqsh/cramfs/JFFS2签名 |
| 版本与隐藏信息 | build.prop、设备树、环境变量、shadow文件 | strings+grep、目录遍历、正则扫描 |
这个拆法很快见效。比如里面有个whole.img,一开始我用file命令看,只显示“data”,完全没头绪。后来按平台识别的思路,先把前256字节拉出来看十六进制,发现27051956这个经典引导魔数,确认是一个标准的uImage头部。顺着头部里的信息,又拿到入口地址和镜像大小,再往后扫描,才定位到了squashfs文件系统。整个过程虽然花了几分钟,但至少方向是对的。
所以我的建议是:接到固件先别急着解,先按这四个问题走一遍流程。磨刀不误砍柴工。当你把“不知道”细化成“不知道哪个平台”,你至少知道该去搜什么、该用什么工具。
3. 工具第一期:靠魔数和头字段,让固件先自报家门
有了问题清单,我就开始写工具了。工作日的时间不能全耗在这上面,所以我要求自己“最快的路子先跑起来”,第一版脚本只有两百多行,目标只有一个:读文件头,自动识别出镜像类型和平台特征。
这一步的核心是魔数(magic number)。每一种文件格式,在头部固定位置都有几个字节的签名,像是它的身份证号。常见的固件相关签名我整理了一份表:
| 类型 | 魔数(十六进制/ASCII) | 说明 |
|---|---|---|
| u-boot uImage | 27 05 19 56 | 老牌bootloader镜像头,内部含CRC、加载地址 |
| Android boot image | 41 4E 44 52 4F 49 44 21(ANDROID!) | 安卓设备常见,内核与ramdisk打包 |
| gzip压缩流 | 1F 8B | 很多包是先压缩后塞进镜像 |
| LZMA | 4C 5A 4D 41(LZMA) | 裸LZMA流,也可能出现在固件中间 |
| squashfs(小端) | 68 73 71 73(hsqs) | 只读文件系统,绝大多数路由/盒子用 |
| squashfs(大端) | 73 68 73 71(shsq) | 少见,但MIPS某些旧平台会碰到 |
| cramfs(大端) | 28 CD 3D 45 | 老平台只读fs,结构紧凑 |
| cramfs(小端) | 45 3D CD 28 | 小端设备上的cramfs |
| JFFS2 | 85 19或19 85 | 闪存友好型日志文件系统,多见于NOR Flash |
| UBIFS | 31 18 10 06 | NAND设备常用,扫描时要配合UBI卷信息 |
第一版脚本做的事很简单:把整份文件读一遍,按顺序找这些魔数出现在哪些偏移位置,然后打印出来。但就这么一步,已经能省掉大量人工hexdump的时间。我举个例子,update.bin这个文件,十个文件名里有八个叫这个。以前我得手动用十六进制查看器翻来翻去,现在一行命令直接告诉我:偏移0x0000是uImage头,偏移0x00A0附近有gzip流,偏移0x10000开始是hsqs。
脚本核心代码大致长这样:
import sys from pathlib import Path MAGICS = [ ("u-boot uImage", bytes.fromhex("27051956")), ("android boot", b"ANDROID!"), ("gzip", bytes.fromhex("1f8b")), ("lzma", b"LZMA"), ("squashfs_le", b"hsqs"), ("squashfs_be", b"shsq"), ("cramfs_be", bytes.fromhex("28cd3d45")), ("cramfs_le", bytes.fromhex("453dcd28")), ("jffs2", bytes.fromhex("8519")), ] def scan_magics(data: bytes): hits = [] for name, magic in MAGICS: start = 0 while True: idx = data.find(magic, start) if idx < 0: break hits.append((idx, name)) start = idx + 1 hits.sort() return hits if __name__ == "__main__": path = Path(sys.argv[1]) data = path.read_bytes() for offset, name in scan_magics(data): print(f"0x{offset:08X} {name}")这个版本已经能提供基本“画像”。但我很快发现,光报“这里有个gzip”不够,因为整个固件可能几MB几十MB,里面藏着一堆压缩段,不全是真正的文件系统。于是第二版加了更聪明的逻辑:对于找到的候选偏移,再往前读一段,如果发现是uImage头部,就尝试解析头部里的数据长度、加载地址、镜像类型和文件系统说明;如果发现是squashfs的hsqs,就往下读超级块,解析出块大小、是否压缩、inode数等字段。
这里有一个关键操作:头部字段的字节序判断。同样一个魔数,大端小端设备读出来的数值是完全反的。比如uImage头部里的ih_load字段,在ARM小端设备上直接按小端读取,在部分MIPS大端设备上却要反过来。判断方法很简单:如果解析出来的加载地址落在内存地址范围内(比如0x80000000以上),那就是标准解读;如果发现一个荒唐的地址,多半要考虑字节序反了。这个细节让我少走了一下午的弯路。
做完这一步,工具已经能自动输出类似这样的信息:
文件名: whole.img 大小: 268435456 bytes 头部: u-boot uImage @0x0 CPU: ARM 加载地址: 0x80008000 入口地址: 0x80008000 镜像类型: Linux内核映像 校验值: CRC32匹配 分区线索: 0x10000 squashfs_le到这时,我才算是真正把“某个固件大概是什么”搞清楚了。但这还只完成了四个问题里的前两个。接下来最麻烦的是文件系统提取。
4. 文件系统那个环节,坑在“你以为找到偏移量就够了”
识别出squashfs位于偏移0x10000后,我第一反应是用unsquashfs直接解:
dd if=whole.img of=rootfs.sqsh bs=1 skip=65536 count=2097152 unsquashfs -s rootfs.sqsh结果它给我报了个“no squashfs filesystem found”?我当时就愣了。明明魔数扫描结果显示hsqs就在那儿,怎么会找不到?后来我单独把那段拷出来,用hexdump一看,发现只有文件头前几个字节是hsqs,后面的超级块字段全不对——厂商在这个位置做了一个假签名,真正的squashfs还要再偏移几十KB。
这条弯路让我意识到:魔数只能作为线索,不能作为结论。所有候选偏移都必须经过“超级块结构校验”才能进入后续提取环节。
所以我给工具加了一个verify_fs函数:对每个候选偏移,尝试把它当squashfs、cramfs、JFFS2分别解析,判断关键字段是否合理。比如squashfs要检查魔数之后紧接着的inode计数是否大于0,块大小是否在1024~131072之间;cramfs要检查root inode的偏移是否落在镜像长度范围内;JFFS2则检查节点长度是否能形成合法的链式结构。
判断逻辑大概是这样:
def verify_squashfs(buf: bytes) -> bool: if buf[0:4] != b"hsqs": return False inode_count = int.from_bytes(buf[4:8], "little") block_size = int.from_bytes(buf[16:20], "little") compression = buf[20] if inode_count == 0 or inode_count > 1_000_000: return False if block_size not in (1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072): return False if compression > 5: return False return True同理,cramfs的校验要更细一些。因为cramfs的超级块总共只有64字节,前面的魔数之后直接就是root inode信息、块大小和分区大小。我会追加判断:“分区大小”字段是否等于当前镜像剩余长度,以及name字段的前几个字符是否看起来像设备名。
验证通过之后,才真正进入提取路径。不同文件系统用的工具不一样,我整理了一下:
| 文件系统 | 校验命令 | 提取命令 | 适用场景 |
|---|---|---|---|
| squashfs | unsquashfs -s | unsquashfs -d out | 绝大多数Linux嵌入式设备 |
| cramfs | fsck.cramfs --verbose | fsck.cramfs --extract | 老平台、小分区设备 |
| JFFS2 | jffs2dump -c -e little | jffs2extract | NOR Flash、部分路由器 |
| ubifs | ubireader_extract_files | 同左 | NAND设备、UBI卷 |
这个过程最大的启发是:扫描魔数只是“发现嫌疑人”,校验超级块才是“证实罪行”。两个步骤缺一不可。后来我把这个流程整合成了工具里的一步extract,传一个文件路径进去,它会自动扫描、验证、提取,并返回最终成功用到的偏移量。这一改,效率翻倍,原先要手工试半小时的活儿,现在几秒钟出结果。
5. 实测下来,最容易翻车的三种固件结构
工具做得差不多,我就拿手头那批“历史遗留固件”一个个试。结果确实帮我解决了不少问题,但也被三个顽固案例教做人。这三个案例非常典型,可以说是所有“来路不明固件”的共性坑,单独拿出来说说。
5.1 gzip包装的squashfs,扫描器先报了hsqs
第一个坑就发生在我最熟悉的机顶盒固件上。这个文件前四个字节是hsqs,我满心欢喜以为找到了rootfs,结果unsquashfs -s直接报错。后来我仔细看超级块里的bytes_used字段,发现它指向的结束位置远远大于当前文件的长度。这说明真正的squashfs并不是从头开始的,而是被某个工具预先压缩了一遍,再在前面拼了一个假头部。
真相比我想的还绕:厂商把整个squashfs镜像用gzip压了,压完之后又在gzip数据段前面拼了squashfs的魔数,用来欺骗老版本的binwalk。而真正的gzip魔数1f8b出现在后面很深的偏移处。处理方法其实很简单:从偏移0x20000找到gzip流,先解压,得到的数据再去验证squashfs。
我现在的做法是:多魔数联合判断。一旦发现hsqs和1f8b同时出现在一个文件里,且hsqs的超级块校验不通过,就自动尝试解压后面的gzip段,再对解压结果重新做文件系统校验。这个逻辑后来帮我识别出另外两个类似结构的文件,证明这不是个别厂商的恶趣味,而是一种常见的防止直接提取的手段。
5.2 头部完全明文,主体却加密了一大半
第二个坑比第一个隐蔽。某个固件从uImage头部看非常正常,加载地址合理、CRC也能对上,我正要继续提取内核,结果strings一跑,发现从偏移0x40000往后全是高熵随机数据,几乎没有可读ASCII字符。用file命令看,也只显示“data”。
这种结构通常代表厂商只加密了部分内核或文件系统,但引导头没动过。我当时差点以为镜像损坏,后来在文件末尾发现了一小段明文:/dev/mtdblock/5和几个shell命令。这些字符串说明文件其实是bootloader和内核的拼接体,后面的“随机数据”其实是加密后的rootfs。没有解密key的情况下,继续硬解是没有意义的。
我的处理思路是:不在解包上死磕,转做“边界标注”。工具扫描到高熵区块时,自动标记为“可能加密分区”,并把前后明文区的字符串全部提取出来。有时候,厂商的加密key就藏在非加密段的环境变量里,或者藏在uboot的参数区。你把边界标清楚了,后面人接手不用再从头摸一遍。
5.3 所谓“全量镜像”,其实是三个分区硬拼在一起
第三个案例来自一个路由设备固件。文件名叫full.bin,我用第一版工具扫描,得到的结果里有bootloader、kernel、squashfs,但它们的偏移非常奇怪:squashfs的超级块校验通过了,可是它声称的bytes_used恰好到文件末尾,没有任何多余空间。刚开始我以为这就是完整的单分区rootfs,直到提取出来的目录里少了一堆应该存在的配置目录,比如/etc/config、/usr/lib都不见踪影。
后来用hexdump确认,这个“full.bin”其实是这样拼的:最前面是Bootloader,紧接着是kernel,然后是squashfs1,但squashfs1只是内核用的最小ramfs,真正存放业务数据的squashfs2被塞在了文件尾部,两段squashfs之间还夹了一个JFFS2分区。扫描器之所以没报第二个squashfs,是因为我把扫描范围限制在了文件前64MB——偷懒导致的误判。
修正方式很简单:扫描全文件,收集所有超级块校验通过的偏移,并把“每个偏移对应的文件系统类型”全部打印出来。现在工具输出分区线索时,不会只给一个结果,而是给一整份清单,比如:
0x00000 uImage (kernel) 0x10000 squashfs_le (rootfs-min) 0x20000 jffs2 0x30000 squashfs_le (rootfs-data)说白了,这类“拼盘固件”在现实中非常常见,不能因为某一个偏移找到了合法文件系统就以为大功告成。一定要把全文件的候选结果都列出来,再按照“bootloader → kernel → 多个文件系统”的顺序判断哪个是真正的业务rootfs。
6. 工具收敛成一条命令:输出“固件身份证”报告
经过前面几轮迭代,工具的功能从“识别魔数”扩展到了“自动解包”“加密区域标注”“敏感信息扫描”“版本字符串提取”。工具的内涵已经不是一两个脚本,而是一套小工作流。我最后把它收敛成一个命令行入口,起名叫fw_card,意思是给固件做一张身份证。
我设计的使用方法很简单:
python fw_card.py analyze firmware.bin -o report.json它会输出一份JSON报告,结构大概是这样的:
{ "file": "firmware.bin", "size": 268435456, "sha256": "a1b2c3...", "platform": "arm", "load_address": "0x80008000", "partitions": [ {"offset": "0x0", "type": "u-boot", "note": "CRC OK"}, {"offset": "0x10000", "type": "squashfs", "verified": true, "extract_dir": "out/rootfs"}, {"offset": "0x30000", "type": "jffs2", "verified": true, "extract_dir": "out/jffs2"} ], "strings": { "build_date": "2019-05-21 12:44:00", "model": "m200_3", "debug_accounts": ["root:123456"], "private_keys": ["/etc/dropbear/dropbear_rsa_host_key"] } }为什么用JSON?因为后续还要脚本化处理,比如批量扫描几十个固件,找出所有包含硬编码telnet密码的文件;或者做版本比对,看两个固件之间改了哪些文件。纯文本报告人读方便,但机器处理麻烦。我用双输出:终端上打印人读摘要,文件里保存完整的JSON,两条腿走路。
功能设计上还有一个小心思:SHA256校验放在最前面。固件分析和文件取证一样,第一件事就是记录完整性哈希。因为固件可能被改动过、被刷过补丁,没有哈希只有文件名,你根本不知道自己拿到的是不是原始版本。哈希一算,放到文件名里,后续拿第二个固件一比就知道差异。
实际用起来,工具现在已经成为我们团队处理“来路不明固件”的默认第一步。新固件到手,第一件事不是找刷机教程,而是跑一遍fw_card,看报告。报告里会明确写出:这是什么平台的、有没有可解包的文件系统、有没有明显的密钥和测试账号、有没有加密分区。有了这份报告,再去查对应平台的手册或固件打包规范,方向感强很多。
另外,在批量场景里,这个工具的价值更大。上次我们一次性收到六个新固件,我写了个for循环全部跑完,直接把所有固件按平台分类,再去做对比,很快就发现有两个固件只是同一个系统的不同版本,一个多了几个界面文案,一个修了几个网络协议栈的BUG。如果这些工作全部手动处理,至少要干一整天。
7. 最后说点个人体会
这批固件折腾了我大概一周,最深的感受不是技术难度,而是文档缺失带来的信息损耗远比我想象的严重。一个固件从一个团队传到另一个团队,名字改了、说明丢了、实际内容被二次封包,到最后谁都不知道它是什么。这不是个别公司的个别现象,而是很多设备生产商和方案商的常态。
所以我特别理解为什么现在再怎么强调“先从二进制本身找答案”都不过分。固件本身不撒谎,头部字段、文件系统超级块、字符串表、编译时间都明明白白写在里面。工具能做的,就是把这些“没人愿意整理、但确实存在”的信息自动挖出来,摊在桌面上。
如果你也遇到了“接手一份没人讲得清的固件”,别急着把板子烧上。先把文件丢给脚本,让它把魔数、分区、文件系统、可读字符串全部跑一遍,哪怕最终没有直接解出完整rootfs,你手里的线索也已经足够支撑下一步排查了。我做的fw_card不是什么了不起的工程,但它证明了:只要肯花时间把磁头字节翻译成结构化的信息,再乱的固件都能理出头绪来。