3. 启动镜像格式与签名
在 Qualcomm Snapdragon 的安全启动链中,Boot ROM(PBL)并不会直接读取一个“裸的”可执行文件。它需要一种既能描述加载地址、又能携带完整性证明、还能被硬件解析器快速校验的容器格式。这个容器就是 MBN(Micro Bootloader Image / Multi-Image Binary)。理解 MBN 的内部结构,是理解整条信任链如何从 PBL 传递到 XBL、再到 TZ、Hypervisor 和 Android Boot 的关键。
要真正读懂 MBN,需要先厘清它与 ELF 的关系。开发阶段我们通常产出 ELF 文件,因为 ELF 拥有完整的节区(section)、符号表、重定位信息,便于链接和调试。但 ELF 的头部结构复杂、节区表位置不固定,不适合固化在只读 ROM 中的解析器逐字节校验。因此构建流程会先用工具把 ELF 转换为 MBN:提取可加载段、按目标内存布局重排、附加哈希表和签名,最终生成一个头部自描述、长度明确、可被 PBL 顺序解析的二进制镜像。可以说,ELF 是开发态格式,MBN 是启动态格式。
MBN 的核心由三部分组成:头部(Header)、哈希段(Hash Segment)和签名段(Signature Segment)。头部描述镜像类型、目标芯片、加载地址、各段偏移与长度;哈希段保存对镜像各可加载段计算出的摘要;签名段则保存对这些哈希值整体签名后的结果。PBL 的校验逻辑是:先按头部定位各段,逐段计算哈希并与哈希段中的记录比对,再用内置的根公钥验证签名段,从而确认“镜像未被篡改且确实由持有私钥的一方签发”。
下面这张图展示了 MBN 从 ELF 转换到被 PBL 校验的完整数据流与段结构关系,重点标出了哈希段与签名段在验证链中的位置。
从图中可以看到,哈希段和签名段是 MBN 区别于普通可执行文件的关键。哈希段并不对自身计算哈希,而是对头部和所有可加载段分别计算摘要;签名段则对哈希段整体签名。这种“先哈希、再签名”的两级结构,使得 PBL 只需验证一次签名,就能间接保护整个镜像的所有段。
实际解析 MBN 时,头部中的字段需要重点关注。以常见的 XBL 镜像为例,头部会包含 image_id(标识这是 XBL、TZ 还是 HYP)、header_vsn(头部版本)、load_addr(加载到内存的物理地址)、image_size(镜像总大小)以及各段的 offset 和 size。这些字段在构建时由工具根据链接脚本和签名配置自动填充,但在排障时经常需要手动核对。例如,如果 load_addr 与目标芯片的 SRAM 映射不一致,PBL 会在搬运阶段就失败,表现为设备无法进入 XBL 而直接落入 EDL。
哈希段的组织方式也值得展开。它通常包含一个哈希表头,记录哈希算法类型、哈希条目数量和每个条目的长度,随后是若干条 (segment_id, hash_value) 记录。PBL 在计算完某个段的实际哈希后,会按 segment_id 查找对应记录并做常量时间比对。如果镜像包含多个可加载段,哈希段中就会有多个条目。签名段则通常包含签名算法标识、证书链长度、证书链内容以及签名值本身。证书链的根必须能追溯到 PBL 中固化的根公钥哈希,否则验签直接失败。
在工程实践中,解析 MBN 最直接的工具是 Qualcomm 提供的 mbn_tools 或开源社区维护的解析脚本。下面是一段示例性的 Python 解析逻辑,用于读取 MBN 头部并打印各段信息。注意,这里的字段偏移和结构体定义属于教学示例,实际使用时必须以对应芯片的文档为准。
import struct
def parse_mbn_header(data):
# 示例:假设头部前 40 字节为固定字段
image_id, header_vsn, image_size, load_addr = struct.unpack_from("<IIII", data, 0)
entry_point, seg_count = struct.unpack_from("<II", data, 16)
print(f"image_id=0x{image_id:08X} header_vsn={header_vsn}")
print(f"image_size={image_size} load_addr=0x{load_addr:08X}")
print(f"entry_point=0x{entry_point:08X} seg_count={seg_count}")
# 段偏移表通常紧随头部之后
offset_table = struct.unpack_from(f"<{seg_count}I", data, 24)
for i, off in enumerate(offset_table):
seg_size = struct.unpack_from("<I", data, off)[0]
print(f" segment[{i}] offset=0x{off:X} size={seg_size}")
with open("xbl.mbn", "rb") as f:
parse_mbn_header(f.read())
运行这段示例代码后,你会得到类似 image_id=0x0000000A、load_addr=0x14800000、seg_count=3 的输出。这些数值本身没有绝对意义,但它们的一致性至关重要:image_size 必须等于文件实际大小,各段偏移必须落在文件范围内且互不重叠,load_addr 必须与芯片内存映射匹配。任何一项不满足,PBL 都会在早期校验阶段拒绝加载。
最后需要强调元数据解析的边界。MBN 头部中的字段是自描述的,但 PBL 并不会盲目信任这些字段。它会先做范围检查,再按检查后的偏移去读取哈希段和签名段。因此,即使攻击者篡改了头部中的 load_addr 或段长度,也会因为哈希不匹配或签名验证失败而被拦截。这正是“哈希段保护内容、签名段保护哈希段”这一设计的意义所在。理解这一点,就能理解为什么 MBN 不是简单的 ELF 重打包,而是一套为安全启动量身定制的容器格式。