4、固件镜像结构:常见的固件打包格式(Bin/Hex/ELF)、固件头部、元数据与负载分离
说到固件签名,很多人第一反应就是“把签名数据塞进固件里”。但塞在哪儿?怎么塞?塞完之后启动代码怎么找到它?这些问题,说白了都跟固件的打包结构有关。
我刚开始做嵌入式安全那会儿,接过一个项目——客户说他们的固件已经签名了,结果我一分析,签名数据被硬编码在代码段中间,启动时根本没校验。嗯,这就是典型的“结构没设计好”。
这一章,我们就来拆解固件镜像的底层结构。你搞懂了这些,后面做签名、做校验,才能有的放矢。
4.1 三种常见的固件打包格式
嵌入式世界里,固件通常以三种格式存在:Bin、Hex 和 ELF。它们各有各的用途,也各有各的坑。
| 格式 | 全称 | 特点 | 典型场景 |
|---|---|---|---|
| Bin | Binary | 纯二进制数据,无任何元数据 | 烧录到 Flash、OTA 升级包 |
| Hex | Intel HEX | ASCII 文本,包含地址和校验 | 烧录器、调试器传输 |
| ELF | Executable and Linkable Format | 包含段表、符号表、调试信息 | 编译输出、调试、链接 |
4.2 固件头部:镜像的“身份证”
不管用哪种格式,最终烧录到 Flash 里的固件,都需要一个头部。头部就是固件的身份证,告诉启动代码:你是谁、从哪来、到哪去、有没有被篡改。
一个典型的固件头部长这样:
typedef struct {
uint32_t magic; // 魔数,用于识别固件合法性
uint32_t version; // 固件版本号
uint32_t image_size; // 负载大小(不含头部)
uint32_t load_address; // 加载地址
uint32_t entry_point; // 入口地址
uint32_t checksum; // 头部校验和
uint32_t signature_offset; // 签名数据偏移
uint32_t signature_size; // 签名数据长度
uint8_t reserved[32]; // 保留字段,用于扩展
} firmware_header_t;
这里我特别想强调 magic 字段。我曾经见过一个产品,启动代码只检查了 magic 值,没做任何签名校验。结果攻击者直接替换了固件,magic 值照填,系统就跑了。所以 magic 只是“快速过滤”,不是安全措施。
__attribute__((packed))。
4.3 元数据与负载分离:为什么必须分开?
你想想看,签名校验的时候,启动代码需要先读取头部,找到签名数据,然后对负载做哈希,最后比对签名。如果头部和负载混在一起,启动代码就得先“猜”哪里是头部、哪里是负载——这不靠谱。
所以,成熟的固件结构都遵循一个原则:元数据与负载严格分离。
- 元数据区:存放头部、签名、证书链、版本信息等。这部分通常放在固件的最前面,固定大小。
- 负载区:存放真正的可执行代码、数据。这部分是签名保护的对象。
分离的好处很明显:
- 启动代码可以快速定位元数据,不需要扫描整个固件。
- 签名计算时,只需要对负载区做哈希,元数据可以单独保护。
- 支持增量更新——只替换负载,元数据里的版本和签名跟着更新就行。
4.4 固件镜像结构图
下面这张图展示了一个典型的带签名固件镜像布局。我建议你把它存下来,后面做签名工具和启动校验时,会反复用到这个结构。
4.5 实战:如何从固件中提取元数据和负载
光说不练假把式。我们写一段简单的 C 代码,演示如何解析一个带头部的固件镜像。
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#define FIRMWARE_MAGIC 0xDEADBEEF
typedef struct __attribute__((packed)) {
uint32_t magic;
uint32_t version;
uint32_t image_size;
uint32_t load_address;
uint32_t entry_point;
uint32_t checksum;
uint32_t signature_offset;
uint32_t signature_size;
uint8_t reserved[32];
} firmware_header_t;
int parse_firmware(const uint8_t *image, size_t image_size) {
if (image_size < sizeof(firmware_header_t)) {
printf("镜像太小,无法包含头部\n");
return -1;
}
firmware_header_t *hdr = (firmware_header_t *)image;
if (hdr->magic != FIRMWARE_MAGIC) {
printf("魔数不匹配,非法固件\n");
return -1;
}
printf("固件版本: %u\n", hdr->version);
printf("负载大小: %u 字节\n", hdr->image_size);
printf("加载地址: 0x%08X\n", hdr->load_address);
printf("入口地址: 0x%08X\n", hdr->entry_point);
// 定位签名数据
uint32_t sig_offset = hdr->signature_offset;
uint32_t sig_size = hdr->signature_size;
if (sig_offset + sig_size > image_size) {
printf("签名数据超出镜像范围\n");
return -1;
}
uint8_t *signature = (uint8_t *)image + sig_offset;
printf("签名数据位于偏移 0x%X,长度 %u 字节\n", sig_offset, sig_size);
// 负载就是头部 + 元数据之后的部分
uint8_t *payload = (uint8_t *)image + sig_offset + sig_size;
uint32_t payload_size = hdr->image_size;
printf("负载起始偏移: 0x%lX\n", (unsigned long)(payload - image));
printf("负载大小: %u 字节\n", payload_size);
return 0;
}
4.6 不同格式的签名适配策略
你可能会问:如果原始固件是 ELF 或 Hex 格式,怎么加签名?
我的做法是:
- ELF 格式:先用
objcopy转成 Bin,再打包。ELF 里的段信息在运行时不需要,签名只保护实际执行的二进制数据。 - Hex 格式:解析出地址和数据,重组为连续的 Bin 镜像,然后加头部和签名。注意 Hex 里可能有地址空洞,需要填充。
- Bin 格式:直接在前面拼接头部和元数据,尾部做对齐填充。
4.7 小结
这一章我们拆解了固件镜像的底层结构。Bin、Hex、ELF 各有适用场景,但最终烧录的固件一定要有清晰的头部和元数据区。元数据与负载分离,是签名校验能够正确执行的前提。
你掌握了这些结构,后面做签名工具和启动校验时,就能清楚地知道:签名该放在哪里,校验该从哪里开始。