自研固件分析工具:破解私有头与加密分区,实现嵌入式镜像解包 干了十来年嵌入式自认见过的烂摊子不算少。但两个月前接手的那份固件还是让我坐在工位上沉默了十几分钟。文档没有。注释前任留下的代码里只有零星的“// fix bug”和一堆拼音缩写。问了一圈团队里没人能讲清这份固件的完整结构只知道“某年某月的版本能跑后来某次改动好像改过分区”。更折磨人的是这玩意儿的镜像还不是标准Linux固件那种干干净净的uImage加rootfs。它是厂家深度定制过的包了一层私有头内部还塞了块加密分区。我一开始拿binwalk、file、hexdump三板斧来回试能识别出一些东西但离“读懂”差得远。折腾了两三天后我有点烦了既然没人讲得清那就自己写个工具把它扒清楚。于是就有了这篇文章里要聊的东西——一个我自己管它叫fwkit的小工具用来做固件的识别、分区定位、解包和打包。这篇文章我会把整个过程拆开讲为什么固件会“没人讲得清”、我是怎么在分析工具之外自己动手补上这块短板的、工具的核心模块怎么设计、以及实际用它拆解一个镜像时踩过了哪些坑。如果你也接过历史包袱很重的固件或者正在做固件安全分析、OTA升级包解析相关的工作这篇应该能帮你省下不少无头苍蝇式的摸索时间。1. 先说清楚为什么一份固件会“没人讲得清”1.1 真实原因不是“技术水平差”而是“知识断层”很多人以为固件没人讲得清是因为做的人水平不行。干得久了你会发现大部分情况是知识的传承彻底断了。我做过的项目里最常见的剧本是这样第一固件本身不是一个人写的而是好几拨人接力。OEM厂家调过bootloader方案商加过加密逻辑中间某个外包团队又塞了个私有打包脚本。每拨人只清楚自己动过的那一小块等他们都走了后面接手的人拿着一个完整镜像自然只能靠猜。第二文档不是完全没有而是散落在邮件、聊天记录和某个已离职同事的移动硬盘里。你找到的往往是一份写于三年前的“固件烧录说明”里面描述的根文件系统布局和当前版本已经完全对不上。第三历史原因导致的“不说人话”。很多老工程师的习惯是把关键信息写在自己桌面上一张便签纸上人走了纸也没了。代码里的注释往往写着“这里不要动”但不写为什么不要动。我见过一个加密偏移项整整三年没人动过直到后来发现是因为当年改了头部长度后没人同步更新这个偏移值。1.2 这种固件的典型特征加密、私有头和版本灾难“没人讲得清”的固件往往有几个肉眼可见的特征。首先是私有头。标准固件通常直接用uImage或ELF头部很容易解析定制过的固件会在最前面塞一段自定义结构魔数、版本号、分区表全部用自己的格式工具链不认识人也不一定认识。其次是加密分区。现在的盒子、路由器、IPCAM一类设备出于安全考虑或者厂家为了防止你刷机会把rootfs或者配置分区做加密处理。加密算法未必复杂可能就是一个固定key的AES也可能是简单的xor混淆。但如果没人告诉你key在哪、算法是什么单看镜像就是一片熵值接近1.0的随机数据根本下不了手。再就是版本灾难。同一个硬件型号往往有三四个不同方案商的板子固件镜像长得完全不一样。你在网上搜到的所谓“通用刷机包”拆开一看分区布局完全不同。做固件分析的人最怕的就是拿着一堆相似但不相同的镜像来回对齐眼睛都看花了。1.3 接手后的第一件事放弃“等人来教我”的念头这是我踩过最深的一个坑。刚接手那两天的思路是到处找人问问前同事、问领导、问方案商。结果呢前同事说“我看看邮件里有没有”方案商回复一个“该版本已停止支持”。折腾一圈下来唯一确定的事情是谁都说不清。于是我把思路改了没人讲得清那我就把固件自己吃透整理成结构化的档案。这个档案不需要一步到位但每打开一个未知项就打一个勾。慢慢把所有“未知”变成“已知”变成一张表、一份文档。后来我做的事都是围绕这张表展开的我做的工具本质上就是把人工读二进制的过程自动化。提示接手未知固件先建一个你自己的固件档案表。字段包括镜像名、大小、魔数、偏移、分区类型、是否加密、压缩格式、版本号。这个表就是你的“知识资产”比等别人给你一篇文档现实多了。2. 信息考古先摸清镜像的脾气再决定怎么动手2.1 裸镜像的初次见面file、binwalk、hexdump三板斧拿到一个没人讲得清的固件我推荐的第一步永远是先看“指纹”而不是莽上去解包。所谓指纹就是把镜像当成一个普通二进制文件看看它开头写了什么、中间有什么规律、结尾是个什么状态。最基础的是file命令。它能识别出文件头部的魔数告诉你这是个Linux内核镜像、还是一个gzip压缩包、或者squashfs文件系统。实测中file的识别率大概能到六成剩下的三四成往往就是加了私有头的固件。接着是binwalk。这工具的原理是扫描整个镜像中的已知特征字节序列比如gzip的头1F 8B、squashfs的hsqs、JFFS2的85 19之类的。binwalk跑一遍能列出所有“疑似文件系统/压缩包”的偏移位置相当于给你一张地图。然后是hexdump和strings。前者用来看头部原始字节后者用于在镜像里提取可打印字符串比如文件路径、内核命令行、版本字符串。strings的输出往往会直接告诉你文件系统里有什么应用、内核用了什么配置、甚至有没有藏着密钥和调试接口路径。2.2 真正的分水岭熵值分析判断“是不是加密过了”三板斧能应付明文的固件但遇到加密分区就会失灵。这时候熵值分析就该出场了。我也是后来自己写工具时才系统去算熵值这个习惯帮了我大忙。熵值简单说就是衡量一个数据块里信息杂乱程度的指标。全是FF的空白区域熵是0英文文本的熵大概在3到5之间压缩数据gzip、lzma的熵接近7.9到8.0而真正被加密过的数据熵值算下来几乎都是8.0。所以当binwalk提示某个偏移开始好像是文件系统但我用脚本一算熵值从那个位置起直接飙到接近8.0而且读出来的文件头全是乱码我基本就能判断这一块是加密的。反过来如果熵值稳定在7.5左右且头部正好是hsqs那就是标准的squashfs压缩文件系统可以直接提取。网上很多朋友喜欢到处找“固件解密工具”我个人的经验是先算熵值再判断要不要找解密工具。很多时候固件不是整包加密而是分区加密。先定位哪些区域是明文哪些区域是密文比盲目解密有效率得多。2.3 建立自己的“固件档案表”把每次尝试记录下来信息考古阶段最重要的产出不是某个具体的解包结果而是一张你自己维护的固件档案表。我在做原厂问题固件时建过这样一张表镜像头部魔数偏移大小分区角色加密情况压缩格式备注whole_image.bin0x4D4147450x016MB全量镜像部分加密-私有头bootloader0x270519560x0512KBU-Boot明文-可跳转kernel0x7F454C460x800004MBLinux内核明文gzipELF解包后可读rootfs0x737173680x5000008MB根文件系统明文squashfslzma压缩config-0xD000001MB配置分区AES加密-密钥待找这张表前期可能一大半都是空的但每找到一项就填一项。等表填得差不多了你才会真正意识到哪些零件还缺才能决定接下来是自己破解还是写个工具去自动解析。3. 现成工具的上限与自研工具的理由3.1 为什么binwalk拆不开你的固件自定义格式与固定特征失效binwalk好用是好用但一旦碰到深度定制的固件它的短板就很明显。binwalk靠的是“特征库匹配”——它扫描的是已知魔数和压缩包头。厂家只要对头部做一点私有的变换或者对某个标准文件系统前加了一段自定义描述块binwalk的扫描结果就会开始错乱。我遇到的真实情况是binwalk扫出来一大堆“Possible SquashFS filesystem”提示但我按照偏移去提取很多都是误报。原因是镜像里某些明文字符串恰好包含了hsqs这四个字节。而且binwalk对“从某个自定义偏移之后才是真正的squashfs”这种情况是无能为力的它只会报告偏移处有个疑似文件系统但不会告诉你前面有没有私有的头部描述。还有更麻烦的有些固件的文件系统偏移是动态计算的启动时由bootloader在内存里拼装出来静态镜像里根本没有一个固定的起始位置。3.2 手工strings加hexdump的极限一次要对着几百兆字节发呆我见过有同事用最原始的方式啃固件hexdump加strings一个分区一个分区地看。几百兆的镜像单纯把字符串提取出来你还能对付但要对齐分区、确认偏移、判断加密靠肉眼在十六进制窗口里翻那效率真的低到让人怀疑人生。你需要的是一套能自动化完成这些重复行为的流程而且是要可以反复跑的。比如我上面说的熵值计算手工做一次可能要写临时脚本但你实际上需要把每一个可能的偏移都算一遍还要把结果画成曲线来看哪一段变化剧烈。这种东西根本不适合手工做工具就是必然选择。3.3 自研工具不等于重复造轮子真正缺的是“连接器”有人要问现成开源工具那么多你非要自己写一个是不是重复造轮子我的看法是不是。我们需要的是一个能把file、binwalk、熵值分析、偏移计算、解包打包这些散装能力串起来的“连接器”。单个能力开源社区都有但针对我们自己手里这份固件的特殊私有头以及之后快速解析同类镜像的需求现成工具做不到开箱即用。所以写fwkit的时候我的目标不是封装一套万能解包器而是围绕这份固件做一套可扩展的分析框架先识别再解析再解包最后还能打包还原。这个思路做下来以后再接手类似的固件我只用更新一下特征库就能快速复用整套流程。这才是自研工具最大的价值。4. 工具落地fwkit的设计与关键实现4.1 语言选型与整体模块划分工具我选了Python。原因很简单二进制操作方便有struct库处理字节解析有硬编码的纯Python脚本就能跑而且后续做熵值计算、哈希校验都很顺手。虽然性能上不如C或Rust但对于一次分析一份几百兆固件的场景完全够用。跑一遍全镜像熵值也只要几分钟而这几分钟换来的是自动化的确认很值得。fwkit拆成了四个核心模块各管一件事识别模块读取镜像头部字节匹配已知魔数计算指定区间熵值输出一个初步的结构判断。解析模块根据识别结果结合分区表规则把镜像切成多个逻辑分区并把每个分区的偏移、大小、类型算出来。解包模块对明文分区做内容提取支持常见文件系统squashfs、cramfs、jffs2和压缩格式gzip、lzma、xz。打包模块把解包后的文件按原分区布局重新组织补上校验字段生成新的可用镜像。这四个模块共用一套配置表配置表就是我从信息考古阶段整理的固件档案表。也就是说工具本身没有固定的固件格式逻辑而是由配置文件来驱动。4.2 识别模块魔数匹配加熵值双重确认识别模块的核心是一张魔数表我用字典结构存了常见格式的特征MAGIC_SIGNS { gzip: b\x1f\x8b, lzma: b\x5d\x00\x00, xz: b\xfd7zXZ\x00, squashfs: bhsqs, squashfs_alt: bsqsh, cramfs: b\x45\x3d\xcd\x28, jffs2: b\x85\x19, uboot_image: b\x27\x05\x19\x56, }识别时先读文件头部若干字节逐个匹配同时打开文件后分段计算熵值并记录。我实际写的时候把熵值计算设计成可以指定偏移和长度这样在做全量扫描时可以逐块算出熵值曲线import math def entropy(data: bytes) - float: if not data: return 0.0 freq [0] * 256 for b in data: freq[b] 1 ent 0.0 length len(data) for count in freq: if count: p count / length ent - p * math.log2(p) return ent把魔数匹配和熵值计算结合起来你才能分辨“这个区域很像squashfs但熵值接近8”这种关键情况。如果是前者直接让解包模块着手提取如果熵值过高那基本是加密过的分区识别模块会将它标记为“待解密”。4.3 解析模块分区表、偏移对齐和私有头处理接下来是解析模块。这个模块要做的事简单概括就是把整个镜像“切”成块。需要读分区表信息。标准的分区表在U-Boot环境或者镜像头的偏移字段里字段可能是一个偏移量加上一个大小也可能是一个魔数和下一个魔数的分界。私有头的处理是解析模块的重点。前面说过很多定制固件会在标准格式前加私有描述块。比如我遇到的那份固件真正的squashfs其实是从偏移0x80开始的而文件头部0x80字节全是一个自定义的“设备信息结构体”里面用字符数组存了设备型号、版本号和日期最后还有CRC校验。解析模块就必须绕过这个私有头把真正的文件系统起始位置算出来。实际操作中我用了“双指针扫描”的方式来辅助定位一个指针尝试按标准魔数匹配另一个指针按常见的对齐边界比如512字节、4KB、64KB去试探性计算偏移。两者交叉验证后再确认分区的起始位置。4.4 解包和打包给非标准文件系统写提取器squashfs这类文件系统解包可以借助squashfs-tools但前提是你得先把自定义偏移处理好。所以fwkit的解包流程是先用解析模块算好偏移再在该偏移处把文件系统“切”出来存成临时文件最后调用外部的unsquashfs或者自己解析。如果遇到的是完全没有现成解析器可用的私有文件系统就得自己做提取器。这种情况我建议先不要追求把所有文件全部还原而是先继续strings和十六进制搜索把关键配置文件和启动脚本找到再针对性地写提取规则。fwkit的设计里预留了“自定义提取器”的注册接口你把自己写的提取逻辑挂上去后续再遇到同一类固件就能直接复用。打包模块相对解包更麻烦一些因为你不能只把文件放回去还得重新生成校验字段。比如有的固件头部带CRC32有的在分区末尾附加了SHA256。打包时必须实现同样的算法重新计算并回填。fwkit的回填操作都是在配置文件里声明“偏移地址校验算法”的方式来做避免硬编码。5. 实操记录用一个真实案例走完整个流程5.1 第一阶段全量镜像的初步梳理我拿一个代表性的案例来说明fwkit实际是怎么用的。这是一个来自某运营商电视盒子的全量升级包解压后得到一个64MB的镜像文件。文件名就叫update.img没有任何配套说明文档。第一步我先用fwkit的识别模块跑一遍。头部魔数是0x4D414745ASCII码是“MAGE”没有匹配到已知格式。熵值扫描结果显示文件前1MB熵值在4.5左右说明这个区块可能有文字描述或配置属于明文而从1MB偏移开始熵值骤升到7.9以上疑似压缩或加密区域。第二步我用strings在明文区域搜索。结果发现了“kernel”和“rootfs”字样还有一串“boardnamexxx”的字段。这说明前1MB很可能是版本的描述头也就是厂家自定义的头部结构。这个结构在文件里不是可执行代码而是装配信息。第三步我顺着字符串所在位置往前看十六进制直接在hexdump里看到了该结构的完整布局开头是固定四字节魔数接着是设备型号字符串然后是32字节的保留字段最后是8字节的CRC值。这个头部结构我给fwkit写了一条解析规则后续升级包均能自动识别。5.2 第二阶段用熵值曲线锁定rootfs的真实边界头部区域分析完之后最核心的问题就是rootfs在哪里、多大、是什么格式。binwalk之前报过几个疑似squashfs的偏移但都比较含糊。我决定用fwkit的“熵值分箱扫描”功能把整个镜像按4KB一箱进行熵值统计。扫描结果很快就有了戏剧性的表现从偏移0x01000000开始熵值突然长时间稳定在7.9附近长度约16MB。这个区域具有典型高压缩数据特征。同时我在这块地址附近用十六进制检索发现偏移0x01000010处出现了hsqs魔数。这就明确了真正的squashfs文件系统起始偏移就是0x01000000只不过前面有一个0x10字节的小头部对齐信息。这个进度的意义很大因为它意味着rootfs没有被加密。我直接用unsquashfs工具对这个区域提取很快拿到了整个根文件系统目录树。启动脚本、应用程序二进制、配置文件全部出现在眼前。接下来就简单多了我能在里面找到设备的WiFi配置、默认口令、启动参数等所有关键信息。5.3 第三阶段反编译和交叉验证有了rootfs的完整文件我针对关键二进制文件进行了检查。首先是/etc/init.d/下的启动脚本里面会告诉你这个固件开机时拉起哪些服务有没有调试接口。然后是可执行文件用file查出架构再用反汇编工具做静态分析。我在这里发现了一个telnetd的启动参数明显是给产线调试用的这其实属于固件安全审计里非常典型的“后门接口”隐患。同时我用fwkit的打包模块做了个实验修改rootfs里一个配置文件重新打包生成镜像再刷机到同型号设备验证。一开始启动起不来排查后发现是我打包时漏掉了头部CRC字段的回填导致bootloader校验通不过。修正打包脚本后第二次刷机成功系统正常启动配置文件变更也生效了。这一步证明了fwkit不只能解包分析还能用于二次定制。5.4 一些可以优化的方向这次做工具的过程我也复盘过哪些地方还可以做得更好。如果当时有更多时间我会给fwkit加上“差分对比”的功能把两个不同版本的镜像放到一起自动比较分区布局、版本号、配置差异这对追踪固件历史变更极有帮助。另外就是OTA差分包的支持B站上有人专门做各类升级包的分析差分包的解析本质上是先找相似块再做差异合并跟我现在做的事属于同一个技术脉络。6. 避坑清单这些错误我踩过一次就记住了6.1 偏移计算陷阱头部长度到底算不算进去这是我在做私有头解析时踩的第一坑。当时我认为squashfs的起始偏移就是魔数出现的位置就直接把魔数所在偏移当作文件系统起点去解包。结果unsquashfs报错提示“expected on-disk superblock”。后来才搞清楚那个私有描述头虽然只有0x10字节但后面还跟着一个“对齐填充区域”。魔数出现在0x01000010而真正的superblock其实从0x01000020才开始中间那16字节是厂商自己加的额外字段。打包时这边多算、那边少算一个字节的偏差就会让整个启动流程崩溃。对于解析逻辑而言偏移的起点必须明确是“文件内偏移”解包工具期望的起点是“文件系统自身的数据起点”两者之间可能有私有头空隙这个空隙必须剥离干净再交付给解包器。6.2 大小端和压缩算法版本的联合陷阱不同的平台字节序不同。ARM通常小端部分网络处理器却用大端。squashfs自身的superblock里有字节序指示字段但很多解析器默认按主机字节序去读。我在处理一个非标准squashfs时就遇到了数据全乱的情况后来一查是字节序问题。判断方法很简单看hsqs后面四个字节的状态如果04 00 00 00表示小端00 00 00 04那就是大端。同时注意squashfs有多个版本不同版本支持的压缩算法不一样。老版本用gzip后来引入lzma和xz。如果解包时使用的unsquashfs版本过旧可能不认识高版本的压缩算法会报“unsupported compression type”。这里不是坏了的镜像是工具太老了。换一个新版unsquashfs或者装squashfs-tools-ng问题就解决了。6.3 熵值判断的“假阳性”和“假阴性”熵值是个很好的参考但它不是银弹。我遇到过明文数据熵值偏高的特例如果某个分区存的是已经压缩过的数据比如JFFS2里又包了一层zlib文件那它的熵值也会很高但同时它不是加密的提取后依然可用。反过来加密数据里如果厂商用了很弱的同态混淆比如每个字节固定xor 0x5A熵值反而不会接近8.0因为这种变换没有改变字节分布strings还是能扫出大量内容。所以我会在做“是否加密”的判断时同时看两个指标熵值高不高、魔数在不在。如果熵值极高又没有已知魔数那大概率是加密。如果熵值中等但魔数缺失可能是私有文件系统不一定是加密。先确认再动手可以省掉很多无用功。6.4 打包校验字段一个字节的错误要在启动三秒后才爆发这个坑印象太深刻了。我改完rootfs重新打包后设备开机黑屏串口一点输出都没有像死了一样。我当时以为是bootloader被我刷坏了后来才想到可能是CRC问题。类似的产品固件在头部会存一份CRC32计算范围通常是整个镜像或者头部关键字段。bootloader启动时先校验CRC不对就停止启动。但因为没有输出你根本不知道是卡在这一步。解决办法很简单打包时先跑一遍原厂的校验逻辑把CRC字段重新计算并写回去。fwkit里我专门抽象了“CRC回填器”每次打包完自动重新计算。注意任何自定义头部只要包含校验字段修改镜像后一定要重新计算。否则轻则启动失败重则设备直接变砖。打包工具里务必内置校验字段的重算流程并做“打包后回读校验”的一体化检查。6.5 快速参考常见问题速查表问题现象可能原因排查与解决binwalk报了很多squashfs但解包失败魔数误报或私有头前缀用熵值曲线定位真实偏移切割后单独解包解包工具报unsupported compressionsquashfs版本过新/过旧升级squashfs-tools或使用squashfs-tools-ng分区读出来全是乱码字节序颠倒或分区实际已加密检查superblock字节序再算熵值确认密文属性strings能搜到配置路径但提取失败文件系统是私有格式定位关键配置文件的偏移手动提取修改后重新打包启动卡死无输出头部CRC未更新或分区大小对齐错误重算CRC、补齐填充对齐、回读比对头部相同硬件但镜像结构完全不一样不同方案商底板分区表完全不同以实际镜像头部和魔数为准不照搬旧档案我的几点体会工具做出来之后再回头看我反而觉得最困难的部分并不是写代码而是形成一种“主动把未知变成已知”的思路。固件是个很诚实的东西它不会主动告诉你任何信息但只要愿意花时间把它从头到尾一次次剖开、记录、验证它所有的秘密都会暴露在档案表里。fwkit本质上就是把这种剖开和记录的过程沉淀下来让下一次面对类似项目的时候能更快进入状态。最后分享一个小技巧接手任何未知固件先别急着解包先花半天时间做熵值扫描和字符串检索并把结果列成一张像样的档案表。这张表会是你接下来所有操作的地图。等你把地图画完你就成了团队里那个“讲得清固件”的人。