ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ATF安全启动实战:BL2认证框架与信任链交棒全解析

ATF安全启动实战:BL2认证框架与信任链交棒全解析 这篇是这个系列的第二篇标题里那个2意味着我默认你已经对 ATF 的镜像分类和冷启动路线有了基本概念。这篇我们把 SOC-ATF 的安全启动链路收窄到 BL2 这一层从 BL1 交棒那几条汇编指令开始一路跟到 BL2 把 BL31 拉起来。如果你正在做固件安全适配、TBB 移植或者只是想知道 BL2 的认证框架到底怎么工作这篇文章可以当作一张比较完整的地图用。先说明一下范围本文分析以 TF-A 2.6 到 2.8 的经典代码路径为主结尾会补一段新版 Transfer List 的变化。BL2 在安全启动里的角色一句话能概括BL1 信任它它验证 BL31/BL32/BL33然后交棒。这句话背后的执行细节值得用一整篇来拆。1. BL2 在信任链里的定位BL1 交棒后它拿到了什么很多人看 BL2 代码喜欢直接打开 bl2_main.c 往下啃这样容易卡住因为 BL2 的起点其实不在 C 代码里。BL2 是被 BL1 用一条跳转指令送进来的而在那条指令之前BL1 已经把一堆关键数据结构通过寄存器传了下来。不搞懂这层交接后面所有代码都会看得一头雾水。1.1 BL1 交棒时刻的寄存器与数据结构在经典 TF-A 流程里BL1 完成对 BL2 镜像的认证之后会走到bl1_run_bl2()这里会填充一个bl2_ep_infoentry point info然后把几个参数塞进bl2_ep_info-args.arg0到arg3最终通过bl2_entrypoint(x0, x1, x2, x3)跳转。这几个参数里最重要的是一组指向 bl2_params 结构体的指针。bl2_params_t的定义不长但信息密度很高它把 BL2 接下来要填充的下一级镜像信息全部包括了bl31_ep_info指向entry_point_info_t记录 BL31 的入口地址、执行状态、SPSR 设置等。bl32_image_info指向 Trusted OS例如 OP-TEE的镜像信息。bl33_image_info指向非安全世界的引导程序比如 U-Boot 或 UEFI。除此之外还有一组bl2_mem_params_descs内存描述符表描述每个镜像在 FIP 里的位置、加载地址、大小以及内存属性。BL1 把这些地址通过寄存器传下来BL2 的入口汇编不会去改动它们等到 C 代码里再作为参数解析。为什么这套接口这么重要因为 BL1 通常固死在 BootROM 里没法升级。而 BL2 是可以升级的。如果 BL1 和 BL2 之间的参数约定不够稳定那芯片出厂之后 BL2 一升级就可能导致整个启动链断掉。所以 TF-A 把交棒接口设计得极其克制能用结构体指针解决的问题绝不额外定义复杂协议。实际调试时如果你用 JTAG 直接拉 PC 到 BL2 入口会发现这几个寄存器里是空的BL2 后面读参数时就会拿到垃圾值。很多BL2 进来就崩的问题根因其实是调试者跳过了 BL1 交棒过程而不是 BL2 本身的 bug。1.2 入口汇编给 C 代码铺了什么路BL2 的入口在bl2/aarch64/bl2_entrypoint.S函数名就叫bl2_entrypoint。这段汇编做的事情比一般人想象中要多它不仅仅是把栈指针指一下然后调 C 函数。整个入口的逻辑大致是这样的保存 BL1 传入的参数寄存器确保后面 C 代码能用。设置异常向量表地址。设置 BL2 自己的栈指针。如果平台配置了可能还要做一次 cache 清理和无效化避免 BL1 阶段留下的脏数据影响后面执行。调用bl2_early_platform_setup()这一步里面会初始化串口、存储控制器等基础外设。调用bl2_plat_arch_setup()这一步建立 MMU 页表并开启 MMU。跳转到bl2_main()正式进入 C 代码主流程。为什么 BL2 不在一进来就开 MMU因为早平台初始化阶段可能需要操作物理地址下的寄存器开着 MMU 反而要处理地址映射的边界问题。TF-A 的做法是先做最基础的硬件初始化再用bl2_plat_arch_setup把整个 BL2 运行所需的地址空间一次性映射好。这个顺序几乎在所有 ARM 平台是一致的新平台移植的时候不要自己瞎改顺序。还有一点值得注意BL2 的 BSS 段通常在早期链接脚本里就规划好了入口汇编在调 C 函数之前会用它自己生成的 BSS 符号把这块区域清零。如果平台链接脚本里__BSS_START__和__BSS_END__定义错了效果就是全局变量初始值全乱症状会很诡异。1.3 单核执行与 BL2_AT_EL3 的选择BL2 阶段默认只在一个核上跑这由COLD_BOOT_SINGLE_CPU这类平台配置决定。原因很好理解BL2 要做的事情是认证和加载镜像多核同时跑不仅没有收益还会引入核间竞争和一致性问题。毕竟这个时候 MMU 都可能刚开多核同步的复杂度远大于收益。另一个容易混淆的宏是BL2_AT_EL3。在标准 ARM 平台上BL1 负责把 BL2 引导到 EL3 并搭好基本执行环境BL2 只需要在这个环境里继续干活。但有些平台没有 BL1 的概念比如树莓派或者 QEMU它们直接从自己的 bootloader 跳到 BL2此时 BL2 需要自己承担更多初始化工作。BL2_AT_EL3就是告诉 BL2你现在直接跑在 EL3 上没有人帮你把 MMU、异常向量、中断控制器这些准备好你得自己来。这个宏的效果在链接脚本和入口汇编里都有体现。开启后BL2 会被链接到独立的地址空间入口汇编也会多做一些自举动作。刚开始做平台移植的同学如果发现 BL2 一启动就异常先检查这个宏的配置是否符合平台的实际启动场景。2. BL2 的平台初始化与内存布局安全边界不是一句空话安全启动经常被简化成验签两个字但实际工程里BL2 的代码放在哪里、内存怎么布局本身就是安全边界的一部分。如果 BL2 运行在非安全内存里那后面做的任何认证都没有意义因为攻击者可以 DMA 改写它的代码。2.1 跑 BL2 的 TZRAM 是谁划出来的在 ARM FVP 这类标准平台上BL2 运行在 TZRAMTrustZone RAM里BL31 则运行在 TZDRAM。这些区域的地址和大小由平台头文件里的宏定义比如TZRAM_BASE、TZRAM_SIZE最终体现在 BL2 的链接脚本bl2.ld.S里。链接脚本会生成一组符号比如__BL2_START__、__BL2_END__、__BSS_START__、__BSS_END__这些符号不仅决定 BL2 镜像的布局还会被 BL1 用来确认 BL2 镜像的加载范围。如果你修改了 BL2 镜像大小但没有同步调整 TZRAM_SIZEBL1 在拷贝 BL2 时可能把数据写到后面的保留区域运行时会踩到 BL31 的领地。这里有个经常被忽略的点BL2 镜像的加载地址LMA和运行地址VMA可以不一样。BL1 先把 BL2 从存储介质读到一个临时位置再根据平台配置搬运到最终运行地址。两个地址如果映射关系处理不好镜像里的绝对地址引用就会出错。这种问题在开了地址无关编译选项后会缓解但调试阶段我见过太多人栽在这上面症状是 BL2 打印完第一行日志就死。2.2 早平台初始化、架构初始化和外设初始化的先后顺序BL2 的 C 初始化分三个阶段分别对应三个回调bl2_early_platform_setup、bl2_plat_arch_setup、bl2_platform_setup。这三个名字看起来相似职责却完全不同。bl2_early_platform_setup是最早被调用的 C 函数这个阶段 MMU 还没开跑在物理地址上。你要在这里初始化串口否则后面出错没有任何打印。还要初始化存储控制器因为 BL2 马上要访问 FIP 所在介质。这个函数不能碰复杂逻辑它的核心目标就是能打印、能读存储。bl2_plat_arch_setup紧接着被调用负责建立 MMU 映射并开启 MMU。TF-A 对这块的要求是BL2 能访问的安全内存区域、外设区域、FIP 映射区域都要在这时候配置好。很多平台在这里使用mmap_add_region一个块一个块地加映射顺序和权限都需要仔细核对。最后的bl2_platform_setup是在所有镜像加载认证完成之后才调用的。为什么把外设初始化放在那么后面因为 BL2 的安全策略是最小化执行时间窗口越快完成镜像认证和加载就能越早把控制权交给 BL31。前面把不必要的外设都初始化了反而扩大了攻击面。2.3 堆大小不够导致证书解析失败的案例BL2 在进行证书解析和签名验证时需要一块动态内存。在 TF-A 里这块内存由平台堆提供大小通过BL2_HEAP_SIZE或者更细的宏来定义。很多平台的默认值在开发阶段够用一旦你把算法从 RSA2048 换成 RSA4096 或者 ECDSA 和 RSA 混合堆空间可能就不够了。我实际遇到过的问题是安全启动打开后BL2 打印ERROR: BL2: Failed to load image后面跟着一行 mbedTLS 的malloc failed。那时候第一反应是看镜像对不对、证书有没有问题排查了一圈才发现是BL2_HEAP_SIZE设置得太小。mbedTLS 在做 RSA 私钥/公钥解析的时候内存开销和密钥长度强相关证书链越深、算法越复杂堆用量涨得越快。建议做法是给 BL2 堆预留至少 8KB 到 16KB 的余量具体数值用实际业务跑一遍后再收紧。不要一开始就抠到极限省那 2KB 内存换来的是一整晚的定位时间。如果你遇到的是cert_parse相关的错误先别急着怀疑算法实现用 DEBUG 级别打开 TF-A 编译然后看堆剩余空间和哪个分配器停了。安全启动里 80% 的神秘错误都是资源不足导致的不是逻辑问题。3. 认证框架BL2 完成安全启动的核心机制如果说前面的平台初始化和内存布局是安全启动的骨架那认证框架就是安全启动的心脏。BL2 的所有价值几乎都体现在这里它如何验证一个镜像是合法的、如何防止回滚、如何保证信任根不被伪造。3.1 先分清 UEFI Secure Boot 和 ATF TBB 的差异很多同学第一次接触安全启动这个概念是在 PC 上Windows 报 BitLocker 蓝屏、提示安全启动被关闭那个是 UEFI Secure Boot属于固件管理层的启动验证。ATF 里的 TBBTrusted Board Boot是更底层的 SoC 固件信任链它从 BootROM 开始一级一级往下验证。两者名字都带 Secure Boot但层级完全不同。ATF TBB 的信任链大致是这样SoC 内部 BootROM 是隐式的信任根它验证 BL1 或者直接验证 BL2BL2 再验证 BL31、BL32、BL33。每一级只需要信任上一级给它的公钥或者摘要。这个链条只要有一环是干净的就能保证后续所有环节都来自可信的代码。这个区别很重要因为调试思路完全不同。PC 上的 Secure Boot 问题大多数是证书配置或者 BIOS 设置项问题而 ATF TBB 问题往往是密钥、证书、FIP 打包方式三者之间任何一处不匹配导致的。3.2 COT 描述符把信任链用数据结构显式画出来TF-A 的信任链不是写死在代码逻辑里的而是通过一组描述符表Chain of Trust来定义的。这个设计非常优雅你要新增一个平台镜像不用改动认证框架本身只需要在 COT 表里加一个条目。COT 表的元素类型是auth_img_desc_t每个元素描述一个待认证的镜像或者证书。拿 BL31 镜像来举例它的描述符大概长这样static const auth_img_desc_t bl31_image { .img_id BL31_IMAGE_ID, .img_type IMG_RAW, .parent soc_fw_content_cert, .img_auth_methods { [0] { .type AUTH_METHOD_HASH, .param.hash { .data { .type AUTH_PARAM_TYPE_ID, .cookie (void *)SOC_FW_CONTENT_CERT_HASH }, .digest { .type AUTH_PARAM_TYPE_ID, .cookie (void *)BL31_HASH } } } } };这个结构的核心是parent字段它显式指定了当前镜像的上级证书。BL31 的 parent 是 SoC FW Content Certificate而这个证书的 parent 又是 Trusted Key CertificateTrusted Key Certificate 的 parent 是 ROTPK。这么一路挂下去最终挂到信任根。认证框架本身不关心你的信任链长什么样它只负责按照描述符的类型和顺序执行对应操作。这种解耦让同一份 BL2 代码可以适配完全不同的平台安全策略。3.3 证书解析与签名验证一个 BL31 镜像的认证全流程以 BL31 镜像为例BL2 执行认证的完整路径大概是这样的首先BL2 通过 img_parser 模块在 FIP 包中找到 BL31 镜像的位置。这一步不涉及任何密码学操作只是按 UUID 在 FIP 头部索引里查一下。接下来认证框架检查 BL31 的 parent 证书是否已经通过认证。如果没有就递归先去认证 SoC FW Content Certificate。认证这个证书需要用到 Trusted Key Certificate 里的 Trusted World Public Key于是又去认证 Trusted Key Certificate最后用 ROTPK 验证 Trusted Key Certificate 的签名。这个递归过程保证了信任链initiation顺序从根到叶子。等 parent 证书都认证完了BL2 从 SoC FW Content Certificate 的扩展字段里提取 BL31 的摘要值然后对加载到内存里的 BL31 镜像计算 SHA256或者平台配置的摘要算法两个值比对一致镜像才算通过认证。签名验证本身用的是标准 X.509 证书解析流程TF-A 内部通过 crypto_mod 抽象层接入不同实现默认是 mbedTLS也可以换成硬件 CryptoCell。你在代码里看到的auth_mod_verify_signature、crypto_mod_verify_hash这些函数就是这一整套流程的入口。值得一说的是BL31 镜像本身并没有独立的签名文件它的签名信息是放在内容证书里的。为什么这么设计因为镜像可能很大给镜像整体签名再附加签名值对 FIP 体积和存储带宽都是浪费。而证书里已经包含了镜像的哈希这个哈希被证书签名保护所以验签一次证书等价于保护了镜像内容。这个设计在工程上很聪明也是读代码时需要理解的关键点。3.4 ROTPK 的存放fuse 还是编译期绑定ROTPKRoot of Trust Public Key是整条信任链的锚点。它本身不参与验证而是用来验证 Trusted Key Certificate 的签名。如果攻击者能把 ROTPK 改写整个信任链就崩塌了。TF-A 里 ROTPK 有两种落地方式。第一种是把 ROTPK 的哈希烧进 eFuse/OTP平台通过plat_get_rotpk_info返回从 OTP 读到的 ROTPK 哈希。第二种是开发模式通过ARM_ROTPK_LOCATIONdevel把 ROTPK 直接编译进固件里方便调试。量产产品必须走 OTP 方案。fuse 烧错之后基本没有办法恢复最稳妥的做法是先读回验证一遍再烧写下一步。开发阶段如果直接开 OTP一旦密钥轮换或者生成错了芯片就废了大半。实际操作中我习惯在开发环境用 devel 模式验证整个启动流程确认镜像、证书、FIP 打包都没问题之后再切到生产模式做一次完整的 OTP 烧录回归测试。这样既能快速迭代又不会在量产阶段被 fuse 问题卡住。3.5 NV 计数器与回滚保护防回滚是安全启动里容易被忽略但又极其重要的一环。攻击者不需要破解你的证书他只要拿到一个旧版本的合法固件重新刷回去利用已知漏洞就能绕过新版本的安全修复。NV 计数器就是用来堵这个洞的。TF-A 的机制是这样每个内容证书里有一个sw_version字段OTP/fuse 里存着一个 NV 计数器。BL2 验证证书时会比较证书里的sw_version和计数器当前值。如果sw_version小于计数器说明这是个旧版本认证直接失败。如果sw_version大于计数器认证通过后将计数器更新为新的sw_version。这个机制写起来简单落地时坑很多。最常见的是 NV 计数器位数不够用特别是 fuse 位很稀缺的芯片可能只留了 8bit 计数空间版本号超过 255 就直接冲突。所以在设计证书版本规划时要提前考虑好版本递增策略不能随手往上加。还有一类坑是多个镜像共享同一个 NV 计数器导致一个镜像升级后其他旧版本的镜像全部失效。选型阶段就要确认平台支持几组 NV 计数器Secure world 和 Normal world 各用哪个别等产品快量产了再发现计数器不够分。4. 从镜像加载到交棒 BL31BL2 的收尾动作BL2 的使命不是一直跑下去它把 BL31 验证好、安排好内存、准备好参数之后就要把执行权交出去。这个收尾过程本身也有不少细节踩过坑的人都知道交棒这一步如果参数没配对BL31 起来第一件事就是死给你看。4.1 FIP 与 IO 框架BL2 怎么在存储介质里找到镜像BL2 要加载的 BL31、BL32、BL33 以及各种证书全部打包在一个叫 FIPFirmware Image Package的文件里。FIP 的头部是 TOCTable Of Contents每一项记录了一个镜像的 UUID、偏移、大小和标志位。BL2 通过 UUID 来区分要找的是哪个镜像而不是通过文件名因为在这个阶段根本没有文件系统的概念UUID 就是它的文件名。TF-A 的 IO 框架把不同存储介质抽象成了统一的接口。不管 FIP 放在 eMMC、NOR Flash 还是内存映射设备里BL2 都通过io_open、io_read、io_close这一套 API 访问数据。这个抽象层让平台适配变得简单但也带来一个问题有些驱动在io_open阶段就会做大量初始化如果介质本身没准备好错误日志会出现在很靠前的位置容易误导排查方向。一个典型的排查场景是FIP 打包顺序变了或者某个镜像的 UUID 写错了BL2 打印找不到对应镜像但你在 FIP 里又确实能看到文件。这时候用fiptool info xxx.fip查一下实际 UUID再和平台的bl_common.h里定义比对通常一两分钟就能定位。4.2 bl2_main 的调用顺序加载、认证、组装参数bl2_main是 BL2 的 C 入口主函数它的调用顺序就是 BL2 生命周期的缩影。以 TF-A 2.x 的经典实现为例bl2_plat_preload_setup()平台预加载初始化。auth_mod_init()初始化认证模块注册 crypto 驱动和证书解析器。img_parser_mod_init()初始化镜像解析模块。bl2_load_images()按 COT 表依次加载并认证所有需要启动的镜像。bl2_platform_setup()平台外设初始化。bl2_plat_get_next_bl_params()组装 BL31/BL32/BL33 的启动参数。bl2_plat_set_bl31_args()把参数写入约定的寄存器或结构体。bl2_run_next_image()跳转到 BL31。bl2_load_images内部对每个镜像都会执行load_auth_image。这个名字起得很精准既是加载又是认证。内部流程是先通过 IO 框架把镜像读入一块临时缓冲然后调用认证框架做完整性校验校验通过后才把镜像搬运到最终运行地址。为什么要分两步因为镜像不能未经验证就写到运行地址万一认证失败你还能保证目标内存区域是干净的。4.3 bl2_run_next_image 交棒寄存器约定与新版本 Transfer Listbl2_run_next_image是 BL2 的最后一个关键动作。在经典实现里BL2 会把bl2_params结构体的指针放到寄存器里然后通过汇编el3_exit跳转到 BL31 的入口地址。BL31 入口代码会从寄存器里取出这些参数继续后续启动。这里有一个容易踩的坑BL2 和 BL31 之间的参数传递依赖寄存器约定如果平台缓存策略没处理好BL2 写入的结构体数据还在 cache 里BL31 从内存里读到的可能是旧数据。所以交棒之前通常要做 cache clean确保数据落到了内存。这个问题在高优化级别编译下更容易出现手动调试时很难复现一开优化就崩。新版本 TF-A2.7 之后逐渐把参数传递迁移到 Transfer List 机制用链表结构组织各类配置数据相比传统的固定结构体更灵活。BL2 阶段还会传递tb_fw_config这类的 FDT 配置块BL31 启动时从中解析平台配置。如果未来你要做多级镜像的定制建议优先看新版本 Transfer List 的实现不要在新平台上硬套老版本的 bl2_params 结构。4.4 安全启动失败时的日志定位思路安全启动排查最怕的就是一上来就开 DEBUG 然后海量打印里乱翻。我的习惯是先看 BL2 最后一行有效日志判断到底死在哪个阶段。常见日志特征和对应原因大概这样日志特征大致原因BL2: Failed to load imageFIP 缺少对应镜像或 UUID 不匹配Authentication failed证书或签名验证没过检查密钥链Invalid certificate证书解析失败检查证书类型或摘要算法cert_parse相关错误堆内存不足或证书格式异常BL2: Booting BL31之后死掉BL2 交棒成功问题在 BL31 侧实操排查我一般按这个顺序走先用fiptool info核对 FIP 内容和预期是否一致再确认当前固件使用 devel 还是 prod ROTPK然后检查 NV counter 和证书的sw_version是否匹配最后单独编译一个TRUSTED_BOARD_BOOT0的版本确认非安全启动下系统能正常跑起来。如果非安全启动正常、安全启动失败那问题基本锁死在认证链路里。我在实际项目里有个体会安全启动调试花的时间七成不是花在密码学上而是花在证书和镜像不匹配FIP 打包顺序错密钥没对上这类工程问题上。所以先把工具链理清楚比盲目看代码有效率得多。排查安全启动问题多了以后最大的心得其实是把 TBB 当成一条数据流来看而不是一堆密码学公式。BL2 的每个阶段无非是从哪拿数据、用什么验证、验证过了往哪放。搞清了这三个问题的答案就等于给整条信任链画出了完整的执行路径。剩下需要做的就是顺着这条路一步一步核对你的 FIP、密钥、证书和内存布局是否匹配。
返回列表