ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式系统安全启动实战:从原理到实现安全引导加载程序

嵌入式系统安全启动实战:从原理到实现安全引导加载程序 1. 项目概述为什么嵌入式系统的安全启动如此关键最近在调试一个基于Cortex-M的物联网设备时遇到了一个让我印象深刻的现场问题。设备在远程升级后部分节点“变砖”了无法启动。经过紧急排查发现是固件在传输过程中被意外篡改了几个字节而设备在启动时毫无防备地加载并执行了这段错误的代码直接导致内核崩溃。这次事件让我重新审视了嵌入式系统安全中最基础、也最容易被忽视的一环——安全启动Secure Boot。它不仅仅是高端芯片才需要的功能而是任何联网的、可远程更新的嵌入式设备都必须构筑的第一道防线。安全启动特别是安全引导加载程序Secure Bootloader其核心使命非常简单确保设备每次上电后加载并执行的第一段代码是可信的、未经篡改的。这听起来像是常识但在资源受限的嵌入式环境中实现它却需要精心的设计和权衡。它不仅仅是验证一个数字签名那么简单而是一套从硬件信任根Root of Trust开始贯穿启动链Boot Chain的完整信任体系。无论是防止恶意软件在启动阶段植入还是确保固件升级的可靠性一个健壮的安全引导加载程序都是嵌入式系统安全的基石。接下来我将结合实战经验拆解构建一个安全引导加载程序必须考虑的五个核心要素。2. 安全引导加载程序的整体设计与核心思路设计一个安全引导加载程序不能把它看作一个孤立的软件模块。它本质上是一个“信任的守门人”其设计思路必须与整个系统的安全架构、硬件能力以及生命周期管理紧密结合。我的设计思路通常遵循一个从硬件到软件、从静态到动态的层次化模型。2.1 信任链的建立从不可变的根开始一切安全的基础在于一个绝对可信的起点即硬件信任根RoT。对于许多微控制器MCU这个根通常是一段出厂时烧录在一次性可编程OTP存储器或受保护闪存区域的引导ROM代码。它的任务是初始化最基础的硬件然后定位并验证下一级引导加载程序。作为开发者我们的安全引导加载程序往往是这“下一级”。因此我们的第一个设计决策就是如何继承并延续这份信任常见的做法是利用芯片提供的硬件安全模块如AES、SHA、PKC加速器和唯一的设备密钥如芯片序列号衍生的密钥。引导加载程序自身在编译后会使用一个受保护的、离线存储的私钥进行签名。这个签名通常是RSA或ECC签名会和引导加载程序镜像一起存储在闪存的特定位置。设备上电后ROM代码或最初的硬件逻辑会使用预置在芯片中的公钥或公钥哈希来验证我们引导加载程序的签名。只有验证通过控制权才会移交。这里的关键在于用于验证的公钥或它的哈希值必须通过不可逆的方式如OTP固化在芯片中确保攻击者无法替换。这是整个信任链得以成立的物理基础。2.2 最小化可信计算基TCB原则引导加载程序自身必须是尽可能简单和安全的。这就是“最小化可信计算基”原则。TCB指的是系统中所有安全策略所依赖的软件和硬件的集合。如果TCB过于庞大复杂其本身存在漏洞的可能性就越大一旦被攻破整个安全体系就崩塌了。因此一个安全引导加载程序应该只做最少、最必要的事情初始化必要的硬件时钟、闪存控制器、用于验证的加密外设。从预定地址加载应用程序固件。使用密码学方法验证应用程序固件的完整性和真实性。验证通过则跳转执行否则进入安全故障处理流程。它不应该包含复杂的网络协议栈、文件系统或高级调度器。任何额外的功能都意味着更多的代码也就带来了更大的攻击面。在我的项目中我曾坚持将引导加载程序控制在16KB以内只包含验证逻辑和基础的串口调试输出复杂的升级协议由已验证后的应用程序来实现。2.3 防御性编程与安全故障处理一个安全的系统必须能够优雅地处理失败。对于安全引导加载程序验证失败不是异常而是一种需要安全处理的常态。设计时必须明确回答如果签名无效怎么办如果固件被擦除了怎么办如果验证过程本身被干扰怎么办我的策略是实施“安全故障默认”原则。一旦验证失败引导加载程序绝不能简单地重启或尝试加载一个备用的不安全镜像这会导致拒绝服务攻击或回滚到脆弱版本。正确的做法是立即锁定系统进入一个不可跳出的安全恢复模式如等待通过特定的、带身份验证的调试接口上传新固件。在安全存储区如备份寄存器或受保护的EEPROM中记录失败事件和原因例如secure boot violation invalid signature detected这对于后续的取证分析至关重要。可能的话触发一个不可屏蔽的硬件看门狗在记录日志后强制硬件复位防止系统停留在不确定状态。3. 核心细节解析与实操要点理解了整体思路我们深入到实现层面。安全引导加载程序有几个魔鬼细节处理不好所有安全设计都会功亏一篑。3.1 密钥管理与存储策略密钥是安全的核心但如何存储密钥却是一大挑战。对于资源受限的嵌入式设备没有TPM可信平台模块我们需要创造性地利用现有硬件。1. 硬件唯一密钥的利用许多现代MCU都提供基于芯片唯一IDUID的密钥派生功能。你可以将UID与一个烧录在OTP中的“盐值”Salt一起通过一个密码学哈希函数如SHA-256或硬件加密引擎导出一个设备唯一的密钥。这个密钥可以用来加密存储在外部闪存中的主验证公钥或者直接用于对称签名如HMAC。优点是密钥从不以明文形式存在且每个设备都不同。缺点是如果UID或盐值泄露该设备密钥即失效。2. OTP存储与生命周期一次性可编程存储器是存储公钥哈希或对称密钥的理想场所。操作时必须极其谨慎编程验证写入OTP后必须立即回读并校验确保数据正确。OTP位一旦从0变为1就无法逆转错误的写入可能导致密钥无效整颗芯片报废。分阶段烧录在生产线上建议分两步。先烧录一个测试用的公钥哈希完成引导加载程序和应用程序的测试。测试通过后再擦除实际上是“烧录”为1测试密钥区域并烧录最终正式的公钥哈希。这能避免因软件错误导致批量设备锁死。预留撤销位在设计OTP布局时可以为每个密钥预留一个“撤销位”。当发现某个密钥泄露需要撤销时只需烧录该撤销位引导加载程序在启动时检查此位如果被置位则视该密钥为无效。3. 镜像签名与格式应用程序固件镜像的格式需要精心设计。一个典型的签名镜像结构如下[镜像头部含版本号、大小、加载地址等] [应用程序固件二进制数据] [签名数据如RSA-PSS签名] [证书链可选用于公钥传递]引导加载程序需要知道如何解析这个结构。务必注意在计算哈希值进行签名验证时签名字段本身必须被排除在外或者填充为0。一个常见的错误是将整个存储块包括签名进行哈希计算这显然永远无法验证通过。3.2 安全升级流程设计安全引导加载程序不仅要保护静态的固件更要保护动态的升级过程。一个完整的安全升级流程我称之为“双验三确认”流程至关重要。1. 升级指令的认证触发升级的指令本身必须经过认证。不能仅仅因为收到一个“进入升级模式”的串口命令或网络包就执行。通常这需要应用程序在已验证的安全环境中通过一个预共享密钥或非对称加密的方式对升级指令进行签名或MAC消息认证码计算。引导加载程序在收到指令后先验证其真实性。2. 新固件的下载与暂存新固件应下载到一个专用的、与当前运行固件隔离的存储区域例如Flash的另一个Bank。在下载过程中可以实时计算其哈希值。下载完成后引导加载程序需要对新固件进行完整的签名验证。这里有一个关键点必须在跳转或擦除旧固件前完成验证。我见过有人设计为先擦除旧固件再验证新固件这是极其危险的一旦新固件无效设备直接变砖。3. 版本回滚防护防止攻击者用旧版本可能存在已知漏洞的固件替换新版本是安全升级必须考虑的一环。必须在镜像头部嵌入一个单调递增的版本号或时间戳。引导加载程序在验证签名之外还需检查新固件的版本号必须大于等于当前固件的版本号。这个版本信息同样需要被签名保护防止被篡改。版本号应存储在非易失性存储器中且一旦更新就无法回退。3.3 与硬件安全特性的联动现代MCU提供了越来越多的硬件安全特性引导加载程序应充分利用它们将部分安全关键逻辑下放到硬件从而简化软件复杂度并提高可靠性。1. 内存保护单元MPU的运用在引导加载程序运行期间可以使用MPU严格限制其可访问的内存区域。例如只允许访问自身的代码区、栈区、用于验证的密钥存储区以及固件存储区。禁止访问应用程序的数据区或其他外设。这可以防止潜在的缓冲区溢出等漏洞被利用来劫持引导流程。2. 写保护WRP与读保护RDPFlash的写保护可以防止引导加载程序自身的代码被意外或恶意修改。通常在引导加载程序烧录并验证成功后应立即通过编程其选项字节Option Bytes来锁定自身所在的Flash扇区。同样读保护RDP可以防止通过调试接口如JTAG/SWD读取内存内容保护密钥和核心算法。设置RDP等级需要非常小心因为一旦提升到最高级别Level 2可能永久关闭调试功能给后续开发带来困难。3. 硬件加密加速器的使用务必使用硬件模块进行AES、SHA和公钥运算。软件实现的加密算法不仅速度慢更容易受到侧信道攻击如计时攻击。在引导加载程序中调用硬件加速器通常只需要配置几个寄存器效率和安全性能得到双重提升。4. 实操过程与核心环节实现让我们以一个基于STM32H5系列集成TrustZone和硬件加密的假设项目为例勾勒一个安全引导加载程序的核心实现步骤。这里聚焦于验证主流程省略底层硬件驱动细节。4.1 开发环境与镜像准备首先你需要两个密钥对一级密钥生产密钥用于签名引导加载程序本身。私钥由研发安全保管公钥哈希将烧录到芯片的OTP中。二级密钥产品密钥用于签名应用程序固件。私钥可用于CI/CD流水线自动签名公钥则编译进引导加载程序或通过一级密钥签名保护后存储。使用工具如imgtoolfrom MCUboot, 或openssl 自定义脚本生成签名镜像。假设应用程序固件为app.bin。# 生成一个包含头部、固件、签名和TLV类型-长度-值格式 trailer 的镜像 python imgtool.py sign \ --key product-private.pem \ --header-size 0x200 \ --align 8 \ --version 1.2.3 \ --slot-size 0x40000 \ app.bin \ app-signed.bin这个命令会生成一个结构清晰的app-signed.bin包含了版本、哈希、签名等所有元数据。4.2 引导加载程序验证流程代码框架以下是引导加载程序main函数中核心验证流程的简化伪代码体现了防御性编程思想int main(void) { // 1. 最小化硬件初始化 clock_init(); debug_uart_init(); // 仅用于错误输出发布版可关闭 flash_init(); crypto_hw_init(); // 2. 检查并记录启动原因上电、复位、看门狗等 boot_reason_t reason get_boot_reason(); log_boot_event(reason); // 3. 验证应用程序镜像 if (verify_application() ! VERIFY_OK) { // 4. 验证失败安全处理 handle_boot_failure(FAILURE_INVALID_SIGNATURE); // handle_boot_failure 函数会记录错误、点亮故障灯、并进入无限循环或安全恢复模式 // 它绝不会返回 NVIC_SystemReset(); // 或在循环后强制复位 } // 5. 验证成功配置MPU限制应用程序权限可选 setup_mpu_for_app(); // 6. 跳转到应用程序 jump_to_application(); } int verify_application(void) { image_header_t hdr; uint8_t calculated_hash[SHA256_LEN]; uint8_t stored_signature[SIG_LEN]; // 6.1 从固定地址读取镜像头部 if (flash_read(APP_BASE_ADDR, hdr, sizeof(hdr)) ! FLASH_OK) { return VERIFY_FLASH_ERROR; } // 6.2 检查魔数、版本号防回滚 if (hdr.magic ! IMAGE_MAGIC) { return VERIFY_BAD_MAGIC; } if (hdr.version get_stored_version()) { return VERIFY_ROLLBACK; } // 6.3 计算固件数据的哈希注意排除签名区 crypto_hash_start(); crypto_hash_update((uint8_t*)APP_BASE_ADDR, hdr.image_size); crypto_hash_final(calculated_hash); // 6.4 从镜像尾部读取存储的签名 flash_read(APP_BASE_ADDR hdr.image_size, stored_signature, SIG_LEN); // 6.5 使用硬件加速器验证签名 // 使用存储在引导加载程序中的产品公钥 if (crypto_verify_signature(product_pub_key, calculated_hash, stored_signature) ! CRYPTO_SUCCESS) { log_error(Secure boot violation: invalid signature detected); return VERIFY_SIGNATURE_FAIL; } // 6.6 可选验证哈希是否与头部声明的哈希一致双重校验 if (memcmp(calculated_hash, hdr.image_hash, SHA256_LEN) ! 0) { return VERIFY_HASH_MISMATCH; } return VERIFY_OK; }4.3 安全升级模式实现当通过验证的应用程序请求升级时引导加载程序会进入升级模式其流程独立且受保护void bootloader_upgrade_mode(void) { // 1. 验证升级请求的MAC或签名 if (!authenticate_upgrade_command()) { return; } // 2. 擦除备用存储区Slot 1 flash_erase(UPGRADE_SLOT_ADDR, UPGRADE_SLOT_SIZE); // 3. 接收新镜像可边收边计算哈希 uint8_t rx_buffer[256]; size_t total_received 0; crypto_hash_start(); while (total_received expected_image_size) { receive_chunk(rx_buffer, sizeof(rx_buffer)); flash_write(UPGRADE_SLOT_ADDR total_received, rx_buffer, chunk_len); crypto_hash_update(rx_buffer, chunk_len); total_received chunk_len; } crypto_hash_final(received_hash); // 4. 在跳转前验证备用区中的完整镜像 if (verify_image_at_address(UPGRADE_SLOT_ADDR) ! VERIFY_OK) { flash_erase(UPGRADE_SLOT_ADDR, UPGRADE_SLOT_SIZE); // 清理无效镜像 send_error(UPGRADE_VERIFY_FAIL); return; } // 5. 验证通过设置标志位指示下次启动应从备用区加载 write_boot_flag(BOOT_FROM_UPGRADE_SLOT); // 6. 重启系统 NVIC_SystemReset(); }系统重启后引导加载程序检查到BOOT_FROM_UPGRADE_SLOT标志便会去验证备用区的镜像验证成功则更新版本号、交换主备区或直接跳转完成升级。5. 常见问题与排查技巧实录在实际开发和调试安全引导加载程序的过程中我踩过不少坑。下面是一些典型问题及其排查思路希望能帮你节省时间。5.1 签名验证失败invalid signature detected这是最常见的问题。不要慌按照以下步骤系统性排查问题现象可能原因排查步骤与解决方案一直报告签名无效1.公钥/私钥不匹配引导加载程序使用的公钥与签名镜像使用的私钥不是一对。2.哈希计算范围错误计算签名的数据范围与验证时计算哈希的范围不一致。3.镜像结构误解签名附加的位置不对或头部信息未被包含在哈希计算中。1.核对密钥对使用openssl rsa -in priv.pem -pubout导出公钥与引导加载程序中硬编码的逐字节比较。2.打印并比对哈希值在签名工具和引导加载程序中分别打印出计算哈希的原始数据十六进制。必须确保两者完全一致包括字节序。3.检查镜像布局用十六进制查看工具分析生成的签名镜像确认签名块、头部、固件体的位置和长度是否符合代码预期。升级后签名无效1.升级过程数据损坏通信链路有误码或Flash写入出错。2.版本回滚检查触发新镜像版本号不高于当前版本。3.存储区地址错误升级镜像写错了位置。1.增加传输校验在升级协议中加入每包CRC或整个镜像的CRC校验在写入Flash前检查。2.检查版本号确认签名工具中指定的版本号是递增的。3.确认地址映射调试时在升级完成后立即读取升级区的数据计算其哈希与发送前的哈希对比。仅在部分芯片上失败1.芯片唯一性导致使用了基于UID的密钥派生但派生算法在某些芯片上有差异。2.硬件加速器差异不同批次的芯片加密硬件行为有细微差别。3.OTP编程不一致某些芯片的OTP公钥哈希烧录错误。1.统一测试向量在所有芯片上用相同的输入测试密钥派生函数输出必须一致。2.检查勘误表查阅芯片勘误表看是否有加密模块的相关问题。3.验证OTP内容编写一个测试程序读取并打印OTP中的公钥哈希与预期值对比。实操心得遇到签名问题最有效的调试方法是“分而治之”。在PC端用Python脚本模拟引导加载程序的验证逻辑使用相同的密钥和镜像文件进行验证。如果PC端成功而设备端失败问题一定出在设备端的代码哈希计算、数据读取、硬件加速器调用或存储的数据上。如果PC端也失败则问题出在镜像生成或密钥本身。5.2 启动时间过长或内存不足安全验证特别是非对称加密验证可能非常耗时。在资源受限的设备上需要优化。问题RSA-2048验证在100MHz的Cortex-M4上可能需要上百毫秒导致启动太慢。解决方案使用ECC算法例如ECDSA with NIST P-256其验证速度比RSA-2048快很多且密钥更短。采用哈希链表对于大型固件可以将其分成多个块每个块计算一个哈希最终只对最后一个哈希值或一个哈希树的根进行签名。引导加载程序可以逐块验证实现边启动边验证减少感知延迟。启用硬件加速确保加密外设的时钟已正确开启并处于最高性能模式。精心管理内存签名验证需要缓冲区。如果RAM紧张可以考虑使用Flash作为临时缓冲区或者使用芯片提供的“就地执行”XIP功能直接从Flash计算哈希避免将整个固件加载到RAM。5.3 调试与生产之间的平衡开发时需要调试生产时需要安全这之间存在矛盾。问题开启了Flash读保护RDP后调试器无法连接无法排查问题。解决方案建立分阶段的安全启动策略。开发阶段使用存储在Flash中的测试公钥不烧录OTP关闭RDP。方便调试。小批量试产阶段烧录测试公钥哈希到OTP但使用一个“软开关”如一个特定的GPIO电平或Flash中的标志位允许跳过验证。这样可以在产线测试功能但交付前关闭开关。量产阶段烧录最终正式的公钥哈希到OTP移除所有调试后门和软开关并将RDP级别设置为所需等级。务必在烧录OTP前用最终版本的引导加载程序和应用程序进行全功能测试。避坑技巧永远保留一个“黄金样本”。即一批不烧录最终OTP和RDP的芯片作为工厂返修或极端情况恢复的救命稻草。这些芯片可以通过特殊的工程模式通常需要物理探针触发恢复。5.4 应对硬件故障与异常掉电安全引导加载程序还要考虑非恶意场景下的可靠性。问题固件升级过程中突然断电导致系统“变砖”。解决方案实现原子性的升级交换机制。常用的是“双Bank双槽交换”法。设备总是从Bank A启动。升级时将新固件完整下载并验证到Bank B。验证通过后将一个“交换标志”写入一个在掉电时能保持的存储区如备份寄存器或EEPROM的特定位。系统重启后引导加载程序检查“交换标志”。如果置位则不是直接跳转到Bank B而是将Bank B的内容复制到Bank A然后清除标志位再从Bank A启动。这样即使复制过程中掉电因为标志位未清除下次启动会重试复制直到Bank A拥有完整可用的固件。这种方法保证了主Bank始终有一份完整可用的镜像。构建一个可靠的安全引导加载程序是一个细致入微的系统工程它没有太多的炫技空间更多的是对细节的执着和对各种边界情况的周全考虑。每一次严谨的设计和测试都是在为设备的整个生命周期筑牢最底层的安全防线。当你看到设备在无数次上电和升级中都能稳定、安全地启动时你会觉得这些复杂的工作都是值得的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进