副题魔术字、香农熵、结构体——拆开一个固件镜像的三把钥匙摘要吾爱破解软件调试区里有一篇《IoT 漏洞分析之模拟环境搭建》6037 回复同区还有《记一次手机付费充电设备研究》7196。这类内容的共同起点都是先拿到一个固件镜像然后把它拆开。本文只讲这第一步如何在一个不认识的二进制里判断“哪一段是什么”。文中用三把钥匙——魔术字、香农熵、结构体——配合实机代码演示构造一个嵌套镜像能精准扫出 uImageg16、gzip52、squashfs102而熵值实测给出非常清楚的判读线——纯文本 5.533 bits/byte、全零约 0、随机数据 7.956、压缩后数据 7.849。标签IoT固件分析嵌入式Python逆向工程实践目录目录目录一、选题为什么从“拆固件”这一步写起二、固件不是“一个文件”而是一串嵌套的包三、第一把钥匙魔术字四、第二把钥匙香农熵4.1 滑窗实测让边界自己浮出来五、第三把钥匙结构体六、剥开之后rootfs 里值得看什么七、把它沉淀成一份固件分析清单八、三个反直觉的结论九、资料来源与取舍说明一、选题为什么从“拆固件”这一步写起站点本次仍可访问数据来自实时抓取。软件调试区fid59里与嵌入式/设备相关的两条线索是回复数标题7196记一次手机付费充电设备研究6037IoT 漏洞分析之模拟环境搭建同区还有 ACE 反作弊绕过、还原软件穿透、内核提权等条目均属本文不涉及的方向移动安全区则整体偏“破解 / 去加固 / 会员绕过”同样不在本文取材范围。我选 IoT 这一条理由有三条。第一它是一个完整的、自洽的开局。做 IoT 安全几乎所有工作都从同一个动作开始从一个不认识的二进制里把文件系统挖出来。这一步做不成后面什么都谈不上。它像第 10 篇里那个“把安全翻译成可测的值”一样是把一个模糊目标变成一个可执行动作。第二它可实测而且实测结论非常直观。判断“这一段是不是压缩/加密”靠的不是经验而是一个可以算出来的数字——香农熵。本文的所有数字都是本机真跑出来的可以被任何人复核。第三它的产出物天然是防御性的。拆固件能得到的是信息厂商用了什么组件、默认配置是什么、有没有硬编码凭据。这类发现的正确去处是反馈给厂商修而不是拿去攻击设备——本文严格遵守这条边界详见文末声明。四维筛选口径四者同时成立聚集度——同区多条设备/嵌入式线索互相印证可验证——squashfs、UBI、uImage、gzip/xz/zstd 的格式定义全部公开可迁移——结论能写成一份通用固件分析清单合规——产出物是“把镜像看懂”不涉及任何漏洞利用。二、固件不是“一个文件”而是一串嵌套的包先建立正确的心理模型。固件镜像几乎从来不是单一格式而是好几层容器套在一起。从里到外一台上网设备路由器、摄像头、充电桩主控、智能音箱的固件通常包含引导程序U-Boot 之类负责把内核拉起来内核Linux或 RTOS镜像常见容器是 U-Boot 的uImage格式根文件系统rootfs真正装应用的地方通常是只读压缩文件系统最常见的是squashfs可写分区用于保存配置常见JFFS2或UBI/UBIFS配合 MTD 闪存厂商自己的封装把上面这些再套一层例如 OpenWrt 系常见的TRX魔术字HDR0。而每一层压缩都可能是不同的算法gzip、xz、lzma、lzo、lz4、zstd。squashfs 自己就支持这几套用哪个由超级块里的字段指明。于是“分析固件”这件事立刻变成了一句很具体的话在一个字节流里找到各层的起点判断它是什么再决定要不要继续往下拆。而“找到起点、判断是什么”恰好可以由三把互相配合的钥匙完成一个不认识的固件镜像钥匙二 香农熵先切出边界钥匙一 魔术字再确认身份熵 ≈ 8 → 压缩或加密熵 ≈ 0 → 空洞 / 未写入熵 4~6 → 文本或代码钥匙三 结构体读出地图压缩算法编号块大小 与 block_log 自洽各张表的起始偏移按偏移继续解析 inode / 目录 / 数据块rootfs 内容审计启动脚本与默认服务默认凭据 / 全设备共用私钥第三方组件与版本升级包是否验签三、第一把钥匙魔术字最直接的办法是魔术字magic扫描——用一组已知的固定字节去文件里逐位置匹配。这是 binwalk 一类工具的基本做法。实测构造一个“uImage 套 gzip 套 squashfs”的嵌套字节流然后扫描MAGICS[(b\x1f\x8b,gzip),(b\xfd7zXZ\x00,xz),(b\x28\xb5\x2f\xfd,zstd),(b\x04\x22\x4d\x18,lz4 frame),(b\x5d\x00\x00,lzma alone),(bhsqs,squashfs (little-endian)),(bsqsh,squashfs (big-endian)),(b\x27\x05\x19\x56,uImage (U-Boot)),(bUBI#,UBI),(b\x85\x19,jffs2 node),(b\x45\x3d\xcd\x28,cramfs),(bHDR0,TRX / HDR0),(b\x7fELF,ELF),]defscan_magics(blob):hits[]formag,nameinMAGICS:start0whileTrue:iblob.find(mag,start)ifi0:breakhits.append((i,name))starti1hits.sort()returnhits本机实测输出偏移 16 uImage (U-Boot) 偏移 52 gzip 偏移 102 squashfs (little-endian)三个偏移分毫不差。这张魔术字表本身值得记住它覆盖了嵌入式固件里绝大多数“门”魔术字十六进制 / ASCII含义1f 8bgzipfd 37 7a 58 5a 00xz28 b5 2f fdzstd04 22 4d 18lz4 帧格式hsqs/sqshsquashfs小端 / 大端27 05 19 56U-Boot uImageUBI#UBI85 19/19 85JFFS2 节点45 3d cd 28cramfsHDR0TRXOpenWrt 系7f 45 4c 46ELF可执行文件魔术字扫描的强项是“确认”弱项是“发现”。它能精确告诉你“这里是什么”但一旦某个区块被加密了魔术字就全部消失——加密后的数据看起来就是随机噪声扫描结果为空。这时候必须换第二把钥匙。四、第二把钥匙香农熵熵是一个信息论概念用在这里非常实用对一段字节统计各字节取值出现的频率算出它的“混乱程度”单位是 bits/byte上限是 8。判读规则只有三条接近 8→ 这段数据几乎肯定是压缩或加密的因为这两种处理都会把数据变得“不可预测”接近 0→ 全零或大面积未写入区域比如擦除后的闪存空洞46→ 典型文本、代码、配置文件的量级。实测对真实数据计算本脚本文本源码 : 5.533 全零填充区 : 0.000 随机字节(伪加密): 7.956 zlib 压缩后数据: 7.849这四行把熵的用法说清楚了文本 5.5、全零 0、随机 7.956、压缩 7.849。importmath,collectionsdefentropy(b):香农熵单位 bits/byte上限 8ifnotb:return0.0ccollections.Counter(b)nlen(b)return-sum((v/n)*math.log2(v/n)forvinc.values())这里有一条必须讲清楚的边界否则会得出错误结论高熵只说明“被压缩或加密了”并不能区分是哪一个。上表里随机数据的 7.956 和压缩数据的 7.849 几乎一样——单看熵值是分不出“加密载荷”和“压缩区块”的。要区分它们得回到第一把钥匙如果这段数据前面紧跟着一个 gzip/xz 魔术字它就是压缩如果前面什么都没有、且长度是整齐的块对齐值那更可能是加密。熵最实用的用法不是“算一个数”而是“滑窗画一条曲线”。用一个小窗口比如 256 字节逐段移动、逐段算熵就能画出一条熵值随偏移变化的曲线曲线在哪个位置突然升高那个位置就是一段压缩/加密数据的起点在哪里落回来就是它的终点。这一步的作用是把“一大片不知道是什么的字节”切成“若干段有边界的东西”——边界一旦确定魔术字扫描和结构体解析就都有了落点。所以两把钥匙是配合使用的熵负责找边界、分割区域魔术字负责确认身份。单独用任何一个都会卡住。4.1 滑窗实测让边界自己浮出来把熵按窗口滑动计算就能把“一片不知道是什么的字节”切成有边界的区块。实测构造四段不同性质的数据拼在一起defprofile(blob,win1024,step1024):滑窗熵窗口越大越平滑越小越能定位边界return[(off,entropy(blob[off:offwin]))foroffinrange(0,len(blob)-win1,step)]切片构成: [0,4096) 全零 | [4096,8192) 随机 | [8192,12288) 文本 | [12288,~) 压缩 | 尾部全零 偏移 熵值 判读 ---------------------------------------------- 0 -0.000 空洞/未写入 1024 -0.000 空洞/未写入 2048 -0.000 空洞/未写入 3072 -0.000 空洞/未写入 4096 7.792 压缩或加密 ← 边界空洞 → 高熵 5120 7.814 压缩或加密 6144 7.826 压缩或加密 7168 7.810 压缩或加密 8192 4.156 文本/代码 ← 边界高熵 → 文本 9216 4.263 文本/代码 10240 -0.000 空洞/未写入两条边界4096 与 8192被准确定位。这正是滑窗熵的价值它不给你“这是什么”但准确告诉你“从这里开始换了一种东西”。顺带注意最后一行偏移 10240 的窗口跨到了压缩段与尾部空洞的交界熵值被“平均”成了一个不具代表性的低值。这是滑窗法的固有性质——窗口跨边界时会互相污染。实践上的对策是窗口选得比你预期的最小区块更小发现可疑边界后再用更小的步长在附近细扫一次把边界收紧到字节级。现在第二把钥匙交出的成果很具体三段有边界的区块以及哪一段需要重点看。接下来要判断它们各自是什么——回到第一把钥匙。五、第三把钥匙结构体有了边界和身份第三步是读结构——因为一个文件系统或容器格式的“头”里写着接下来该去哪。以最常见的squashfs为例。它的超级块固定96 字节布局是这样偏移字段说明0s_magichsqs小端/sqsh大端4inodes文件数8mkfs_time制作时间戳12block_size数据块大小16fragments片段数20compression压缩算法编号22block_log块大小的 2 的幂24–31flags/no_ids/s_major/s_minor标志、ID 数、版本32–95八张表的起始偏移root inode、bytes_used、inode 表、目录表、片段表…用二十行代码就能把它读出来importstruct COMP{1:gzip,2:lzma,3:lzo,4:xz,5:lz4,6:zstd}FMT_HEAD4sIIIIHHHHHH# 4 16 12 32 字节FMT_TABLES8Q# 64 字节合计 96 字节defparse_squashfs_superblock(b):ifb[:4]notin(bhsqs,bsqsh):returnNoneleifb[:4]bhsqselse(magic,inodes,mtime,block_size,frags,comp,block_log,flags,no_ids,vmaj,vmin)struct.unpack(leFMT_HEAD,b[:32])(root_ino,bytes_used,id_start,xattr_start,inode_start,dir_start,frag_start,lookup_start)struct.unpack(leFMT_TABLES,b[32:96])returndict(inodesinodes,block_sizeblock_size,compressionCOMP.get(comp,comp),version%d.%d%(vmaj,vmin),bytes_usedbytes_used,root_inoderoot_ino,inode_table_startinode_start,directory_table_startdir_start)本机实测对一个构造的超级块解析超级块长度 96 字节 inodes 1234 block_size 131072 compression zstd version 4.0 bytes_used 8000000 root_inode 64 inode_table_start 1000000 directory_table_start 2000000这几行信息量很大压缩算法是 zstd块大小 131072 字节即 128 KiB格式版本 4.0inode 表在偏移 1000000、目录表在 2000000。拿到这些下一步就明确了——去 inode 表所在的偏移挖目录树去数据块按 zstd 解压。这里还有一个很值得记住的自检点超级块同时给了block_size和block_log两个字段它们应当满足block_size 1 block_log。上例中2¹⁷ 131072完全吻合。结构体内部的这种自洽关系是判断“我有没有在正确位置解析”的最好证据——如果你在错误的偏移上强行按这个结构去读几乎不可能同时满足 magic、版本、块大小与 block_log 四个约束。在二进制分析里“能读出合理值”本身就是一种验证。六、剥开之后rootfs 里值得看什么拿到 rootfs或它的解压结果之后工作性质就变了——从“解析格式”变成“读内容”。从防御与厂商卫生的角度有五类东西值得系统性地过一遍第一启动脚本与默认服务。系统启动时自动拉起什么有没有调试用的服务默认开着比如 telnet 服务、调试 Web 端口这些通常在/etc/init.d/、rcS一类位置。第二配置文件里的默认凭据。这是 IoT 领域最经典的一类问题固件里写死了某个账号密码或者给每台设备用同一套出厂凭据。它之所以常见是因为批量生产时“设备能连上”比“每台都不同”省事得多。第三私钥与证书。如果每台设备烧录的是同一份私钥那这套信任机制在出厂那一刻就已经失效了。这是一类“看起来加密了、其实等于没加密”的问题。第四第三方组件与版本号。固件里通常挂着几十个开源库每个都有自己的版本。把版本号和公开漏洞库对一遍是固件分析里投入产出比最高的一项工作。第五升级与校验机制。固件升级包验不验签如果只校验 CRC 而不验签那么升级通道本身就是一个可以被替换内容的入口。需要强调这五类发现的正确出口是“反馈给设备厂商修复”而不是“拿去打设备”。本文讨论的全部是“把镜像读懂”不涉及任何利用手法实际做固件分析也应限定在自己拥有、或获得明确书面授权的设备上——这一点在文末声明里再重复一次。七、把它沉淀成一份固件分析清单第一步认门先分类再动手记录镜像总大小与首 64 字节很多格式的魔术字就在开头做一次全量魔术字扫描列出所有候选起点与类型注意扫描结果里重叠的候选——多层嵌套时外层起点会先出现第二步找边界熵值滑窗用固定窗口如 256/1024 字节逐段算熵画出熵—偏移曲线熵 ≈ 8 的区域标为“压缩或加密”作为重点区块熵 ≈ 0 的区域标为“空洞/未写入”通常可直接跳过记住熵只给边界不给身份身份仍要靠魔术字与结构体第三步读结构拿到地图命中文件系统时先解析它的超级块拿到压缩算法、块大小、各表偏移用结构体内部的自洽关系验证解析位置是否正确如block_size 1 block_log根据超级块给出的偏移按顺序解析 inode 表 → 目录表 → 数据块第四步解压与重建按超级块声明的算法解压不要靠猜算法编号与实现的对应关系要核对版本大端/小端要跟着魔术字走hsqs是小端、sqsh是大端解出来的目录树先只读挂载/浏览不要在原始镜像上原地修改第五步内容审计防御视角启动脚本与默认开启的服务配置文件中的默认/硬编码凭据、以及是否为全设备共用私钥、证书是否全设备共用第三方组件版本清单 → 比对公开漏洞库固件升级是否验签所有发现整理成可复现的报告提交厂商或内部负责人贯穿纪律保留原始镜像副本每步操作产物分开存放可回溯记录每一步的工具与版本——不同版本的 squashfs 工具对同一镜像的结果可能有差异只在自有或书面授权的设备/镜像上操作八、三个反直觉的结论第一熵值高不等于加密——压缩同样会把熵推到接近 8。实测里随机数据是 7.956而压缩数据是 7.849两者几乎无法区分。这意味着“看到一个高熵块就断定它是加密载荷”是一种误判习惯。正确做法是两把钥匙配合熵负责切边界魔术字负责定身份——只有当高熵区的起点没有任何已知魔术字时“加密”才是合理推测。第二最难的部分不是“拆开”而是“确认自己拆对了”。固件是多层嵌套的同一段字节可能被不止一种格式“解释得通”。结构体里那些自洽约束magic 对不对、版本号合理不合理、block_size与block_log是否互为幂次才是真正的锚点。在二进制分析里“能读出一个看起来合理的值”是一项证据而不是理所当然。第三拆固件最大的产出往往不是“漏洞”而是“出厂默认”。写死的凭据、全设备共用的私钥、默认开启的调试服务、不验签的升级通道——这些东西不是被攻击者植入的是生产流程“为了省事”留下的。它们比精心构造的漏洞更普遍也更容易被忽略因为它们看起来一切正常。这和上一篇安全基线核查的结论是同一个最需要处理的问题常常来自“没人管”而不是“有人打”。九、资料来源与取舍说明本文证据分三层请读者注意区分。第一层站点实时抓取社区侧证据。站点本次可达数据来自对52pojie.cn软件调试区fid59与移动安全区fid65列表页的实时抓取本地urllib直连、GBK 解码、间隔 2 秒抓取时间为 2026-10-14。选题线索为《IoT 漏洞分析之模拟环境搭建》回复数 6037与《记一次手机付费充电设备研究》7196。能力边界与口径声明抓取范围是板块列表页标题、回复数未抓取帖子正文。因此对这些帖子的引用严格限于标题级——只作为选题线索说明“设备固件分析是社区持续关注的方向”并未复述其任何实现或结论。文中全部技术与数据来自下面的第二、三层。合规取舍声明重要本文只讨论镜像格式的识别与解析不涉及任何漏洞利用、绕过认证或攻击手法。文中提到的所有发现类型默认凭据、共用私钥、未验签升级等其正确出口是反馈给设备厂商或产品负责人修复。做固件分析应严格限定在自己拥有、或已获得明确书面授权的设备与镜像上对本不属于自己的设备进行固件提取与分析不在本文讨论范围也不被本文所建议。另外本文未取材于该站移动安全区该区内容整体偏向应用破解与会员绕过与脱壳破解区。第二层公开格式规范与文档技术侧锚点squashfsLinux 内核原生支持的只读压缩文件系统默认块大小 128 KB可扩至 1 MB支持gzip / lzma / lzo / xz / lz4 / zstd多种压缩超级块固定 96 字节压缩算法以编号表示本实测映射1gzip、2lzma、3lzo、4xz、5lz4、6zstd闪存文件系统JFFS2、UBI / UBIFS面向 MTD 闪存引导与封装U-Boot uImage魔术字27 05 19 56、TRX / HDR0OpenWrt 系固件封装压缩格式规范gzipRFC 1952、xz 文件格式规范、zstdRFC 8878、lz4 帧格式工具参照binwalk 一类“魔术字扫描 熵值分析”的开源实现思路。第三层本文实机实验。全部数字来自本次在本机运行的 Python 代码WindowsPython 3.14魔术字扫描构造的嵌套镜像偏移16命中 uImage、52命中 gzip、102命中 squashfs小端香农熵bits/byte本脚本文本源码5.533、全零填充区0.000、随机字节7.956、zlib 压缩后数据7.849squashfs 超级块解析构造的 96 字节超级块block_size 131072、compression zstd、version 4.0、inode_table_start 1000000、directory_table_start 2000000并验证2¹⁷ 131072与block_log 17自洽。示例数据声明上述镜像与超级块均为为演示构造的字节序列不代表任何真实设备熵值会随数据内容变化请以自己样本的实测为准。文中“压缩算法编号 → 算法名”的映射以本实测所用版本为准不同工具链版本的编号可能与实际实现存在差异解析时应以格式规范为准并做交叉验证。一句话收束拆固件这件事说到底是在一个不认识的字节流里回答三个问题——“这一段从哪开始、它是什么、它的头里写了什么”。魔术字回答“是什么”熵值回答“从哪开始”结构体回答“接下来去哪”。三把钥匙缺一不可而验证自己没拿错钥匙的方法是让结构体内部的自洽关系替你做证。