一次OTA固件签名绕过事件的排查复盘

📅 2026/7/5 1:21:04
一次OTA固件签名绕过事件的排查复盘
2025年9月我参与了一次让整个安全团队失眠三天的应急响应。某自主品牌在OTA灰度推送过程中安全运营中心收到告警TSP后台签发的固件包SHA256与T-Box上报的实际写入镜像SHA256不一致。这不是网络丢包导致的校验失败——两个哈希值差异覆盖了整个固件的后半段意味着固件包在签名→下发→写入的链路上被替换了。更诡异的是T-Box的安全启动日志显示bootloader验证通过按照设计任何未签名的固件根本无法刷入。签名体系和验证链都在正常运转固件却被篡改了。这个矛盾指向一个让人不安的可能性签名链本身的某个环节存在逻辑缺陷让攻击者可以在不破坏签名的情况下替换载荷。一、签名链的完整结构——为什么三层签名还不够要理解这次绕过首先得把OTA签名链的结构讲清楚。在一辆量产车上固件的验证不是签名→验签这么简单的两步操作而是一条从bootloader开始的信任链代码语言TXT自动换行AI代码解释硬件信任根HSM/eFuse中的公钥哈希│├─► Bootloader校验一级签名│ 验证App分区头部签名 完整性│ └─ 如果通过跳转到App入口│├─► App自校验二级签名│ 验证自身完整性 功能分区calibration/data签名│ └─ 如果通过正常启动应用│└─► OTA更新校验三级签名验证OTA manifest delta包签名 版本号单调性└─ 如果全部通过允许刷写ota_signature_layers.png三层签名对应三个密钥对理论上覆盖了静态固件和动态更新的所有场景。但在这次事件中问题恰出在层与层的交接边界上。二、绕过是怎么发生的——五个环节逐一排查现场排查花了两天半日志文件超过40GB。我把排查路径还原成以下五个环节。环节一bootloader签名验证——通过但有疑点bootloader的验证日志显示签名校验返回0x00成功。T-Box用的是一颗NXB S32K3 MCUHSM固件由NXP官方提供配合硬件安全引擎HSE完成RSA-2048签名验证。这部分看似没问题。代码语言C自动换行AI代码解释/* bootloader中签名验证的核心调用S32K3 HSE SDK示例 */hseSrvSignatureVerifyRequest_t sigVerifyReq {.scheme HSE_SIG_SCHEME_RSASSA_PKCS1_V15_SHA256,.sglHashAlgo HSE_HASH_ALGO_SHA_2_256,.accessMode HSE_ACCESS_MODE_ONE_PASS,.hash {.pHash calculated_hash, .hashSize 32},.sig {.pSig manifest_sig, .sigSize 256},.pubKeyIndex PKEY_INDEX_APP_VERIFY,.verifyResult verify_result};hseStatus HSE_SrvReqSyncRun(hseContext, sigVerifyReq, sigVerifyResp);/* 返回 HSE_SRV_RSP_OK 即验签通过 */但我们后来意识到bootloader只验证App分区头部的签名头部里包含一个指向固件实际数据的偏移量指针。这个指针如果被攻击者修改bootloader校验通过后跳转到的地址就不是真正的固件起始位置而是一段被注入的恶意代码。环节二App分区的签名缺口——找到根因让我最意外的是App层的自校验存在一个条件编译漏洞。在量产代码中App的自校验被一个宏控制代码语言C自动换行AI代码解释#ifdef SECURE_BOOT_ENABLEret verify_app_signature(app_header);if (ret ! APP_SIG_OK) {LOG_ERROR(“App signature invalid, halt”);while(1);}#endif问题在于SECURE_BOOT_ENABLE这个宏不是由构建系统全局定义的而是依赖每个开发者的本地Makefile。负责App层自检模块的那个Tier1团队在最后一次release时Makefile里漏掉了这个宏定义——结果是bootloader校验通过了但App启动后根本没有执行自校验直接跳过了验证流程。更糟的是因为bootloader的校验日志正常集成测试的自动化用例只检查了启动成功的标志位没有人验证App自校验是否真的被触发。环节三OTA manifest的版本号攻击攻击者接下来利用了一个更隐蔽的问题。OTA更新协议中的manifest结构如下代码语言JSON自动换行AI代码解释{“firmware_version”: “3.4.2”,“rollback_protection_counter”: 127,“target_ecu”: “TBOX_MAIN”,“delta_base_version”: “3.4.1”,“payload_hash”: “a1b2c3…”,“payload_size”: 4194304,“signature”: “base64_encoded_signature…”}签名覆盖的是整个manifest的JSON字符串。理论上篡改任何一个字段都会导致签名验证失败。但实际代码中manifest解析用的是一套自己写的简易JSON解析器它对payload_hash和payload_size的解析处理存在一个类型混淆问题代码语言C自动换行AI代码解释/* 简易解析器中的类型混淆——来自日志回溯 */static int parse_manifest_field(const char *json, const char *key) {char *pos strstr(json, key);if (!pos) return -1;pos strchr(pos, :); if (!pos) return -1; pos; /* 跳过冒号 */ /* BUG: 直接用atoi处理了可能是字符串的payload_hash */ if (strcmp(key, \payload_size\) 0) { return atoi(pos); /* payload_size OK */ } if (strcmp(key, \payload_hash\) 0) { return atoi(pos); /* BUG: hash被当成数字解析 */ } return 0;}解析器把payload_hash的字符串值也当做数字处理了导致后续的哈希比对传入的是一个错误的指针。攻击者不需要破坏manifest的签名——只需要让payload_hash解析失败然后替换payload的实际二进制内容即可。因为签名验证用的是原始的manifest字符串而载荷比对用的是解析后的错误值。环节四delta更新包的分片校验缺失OTA系统支持差分更新delta OTA以减少流量和下载时间。差分包被拆分为若干个4KB大小的分片传输。每个分片到达后写入Flash。但代码中有一个致命遗漏只在最后一个分片写入后检查了总长度没有对中间分片做哈希校验。攻击者在传输过程中替换了第47-52号分片约20KB这些分片在被替换后恰好让最终固件的总长度保持不变避开了长度检查。代码语言C自动换行AI代码解释/* 分片接收逻辑中的校验缺失 */for (int i 0; i total_chunks; i) {receive_chunk(chunk_buf, CHUNK_SIZE);flash_write(flash_addr i * CHUNK_SIZE, chunk_buf, CHUNK_SIZE);/* BUG: 这里应该对每个分片做HMAC校验但被注释掉了 */ // if (verify_chunk_hmac(chunk_buf, chunk_hmac_list[i]) ! 0) { // LOG_ERROR(Chunk %d HMAC mismatch, i); // goto abort; // }}/* 只检查了总长度攻击者保持长度不变即可绕过 */if (total_written ! manifest_total_size) {LOG_ERROR(“Total size mismatch”);goto abort;}环节五Flash双分区切换的竞态条件T-Box采用A/B分区双备份机制。OTA流程中新固件先写入非活动分区如B分区验证通过后切换活动分区。切换逻辑如下代码语言C自动换行AI代码解释void swap_boot_partition(void) {/* 步骤1: 标记B分区为验证通过 */flash_write(PARTITION_STATUS_ADDR, PART_STATUS_B_VERIFIED);/* 步骤2: 设置下次启动分区为B */ flash_write(BOOT_PARTITION_ADDR, PARTITION_B); /* 步骤3: 标记切换完成 */ flash_write(SWAP_STATUS_ADDR, SWAP_COMPLETE); /* BUG: 步骤2和步骤3之间有30ms窗口掉电后状态不一致 */}攻击者在步骤2完成后、步骤3未完成时精准触发了一次硬件看门狗复位通过注入一段无限循环的CAN消息让主控过载导致系统在状态不一致时重启。bootloader读取到启动分区B但切换状态≠完成进入了恢复模式——而恢复模式下的签名验证使用了降级的弱算法。三、根因定位——五个环节的关系链排查结束后我们用一个攻击树把五个环节串联起来ota_attack_chain.png五个环节不是独立的而是链式依赖的环节一是入口——攻击者首先利用bootloader指针偏移问题获得代码执行机会环节二是放大器——App自校验的编译缺陷让攻击者可以修改App分区而不被检测环节三是混淆器——manifest解析的类型错误让攻击者可以替换载荷而不破坏签名环节四是搬运工——分片校验缺失让攻击者可以精准替换目标代码段环节五是保险——分区切换竞态让攻击者在被发现后可以触发恢复模式降级逃生单独看任何一个环节都可能被归类为低概率边缘场景而不会被修复。但叠加后就构成了一个完整的、无需破解签名的固件替换攻击链。四、加固方案——不是加签名是补齐验证闭环这次的教训是有签名不等于验证有效。签名只是手段完整的验证闭环才是目的。我们的加固方案分了四步加固一编译期强制启用自校验不再依赖开发者的Makefile改为在链接脚本中强制执行代码语言C自动换行AI代码解释/* link.ld 中注入强符号替代弱符号 */PROVIDE(__verify_app_signature mandatory_verify_app);/* 在startup中通过链接属性强制调用而非条件编译 */attribute((used, section(“.init_array”)))void (*const app_verify_ptr)(void) mandatory_verify_app;关键的改变是从条件编译改为链接期绑定。即使有人错误地定义了宏链接器也会因为符号解析失败而报错而不是静默跳过验证。加固二签名校验与载荷比对的原子化修复manifest解析器让签名验证和载荷哈希比对成为一个原子操作代码语言C自动换行AI代码解释typedef struct {uint8_t manifest_hash[32]; /* manifest本身的哈希/uint8_t payload_hash[32]; /载荷哈希从manifest中解析/uint8_t actual_payload_hash[32]; /实际载荷的哈希 */uint32_t payload_size;uint32_t actual_payload_size;bool manifest_verified;bool payload_verified;} ota_verify_result_t;ota_verify_result_t verify_ota_package(const uint8_t *package, size_t len) {ota_verify_result_t result {0};/* 步骤1: 解析manifest使用安全的JSON解析器 */ manifest_t manifest; if (secure_manifest_parse(package, manifest) ! 0) { return result; /* 解析失败直接拒绝 */ } /* 步骤2: 验证manifest签名 */ result.manifest_verified verify_signature( manifest.raw, manifest.raw_len, manifest.signature );https://gitcode.com/awfwaf/awf/issues/415https://gitcode.com/awfwaf/awf/issues/416https://gitcode.com/awfwaf/awf/issues/417https://gitcode.com/awfwaf/awf/issues/418https://gitcode.com/awfwaf/awf/issues/419https://gitcode.com/awfwaf/awf/issues/420https://gitcode.com/awfwaf/awf/issues/421https://gitcode.com/awfwaf/awf/issues/422https://gitcode.com/awfwaf/awf/issues/423https://gitcode.com/awfwaf/awf/issues/424https://gitcode.com/awfwaf/awf/issues/425https://gitcode.com/awfwaf/awf/issues/426https://gitcode.com/awfwaf/awf/issues/427https://gitcode.com/awfwaf/awf/issues/428https://gitcode.com/awfwaf/awf/issues/429https://gitcode.com/awfwaf/awf/issues/430https://gitcode.com/awfwaf/awf/issues/431https://gitcode.com/awfwaf/awf/issues/432https://gitcode.com/awfwaf/awf/issues/433https://gitcode.com/awfwaf/awf/issues/434https://gitcode.com/awfwaf/awf/issues/435https://gitcode.com/awfwaf/awf/issues/436https://gitcode.com/awfwaf/awf/issues/437https://gitcode.com/awfwaf/awf/issues/438https://gitcode.com/awfwaf/awf/issues/439https://gitcode.com/awfwaf/awf/issues/440https://gitcode.com/awfwaf/awf/issues/441https://gitcode.com/awfwaf/awf/issues/442https://gitcode.com/awfwaf/awf/issues/443https://gitcode.com/org/awef/discussions/1/* 步骤3: 在manifest签名验证通过的前提下校验载荷哈希 */ if (!result.manifest_verified) { return result; /* manifest签名都不过直接拒绝 */ } /* 步骤4: 计算实际载荷哈希并与manifest中的值比对 */ sha256(package manifest.payload_offset, manifest.payload_size, result.actual_payload_hash); result.payload_verified (memcmp(result.actual_payload_hash, manifest.payload_hash, 32) 0); return result;}加固三分片级别的HMAC链每个分片独立带HMAC且下一个分片的HMAC覆盖上一个分片的HMAC形成一条不可篡改的校验链代码语言Python自动换行AI代码解释def generate_chunk_hmac_chain(payload: bytes, chunk_size: int, hmac_key: bytes) - list:“”“为每个分片生成链式HMAC”“”chunks [payload[i:ichunk_size] for i in range(0, len(payload), chunk_size)]hmac_chain []prev_hmac b’\x00’ * 32 # 初始值为全零for chunk in chunks: # 当前HMAC覆盖上一个HMAC 当前分片 h hmac.new(hmac_key, prev_hmac chunk, hashlib.sha256) hmac_chain.append(h.digest()) prev_hmac h.digest() return hmac_chainT-Box端验证代码语言C自动换行AI代码解释/* 分片接收 链式HMAC验证 */int verify_chunk_chain(const uint8_t *chunk, size_t chunk_len,uint8_t *prev_hmac, const uint8_t *expected_hmac) {uint8_t calculated_hmac[32];uint8_t input[32 CHUNK_MAX_SIZE];memcpy(input, prev_hmac, 32); memcpy(input 32, chunk, chunk_len); hmac_sha256(chunk_hmac_key, input, 32 chunk_len, calculated_hmac); if (memcmp(calculated_hmac, expected_hmac, 32) ! 0) { return -1; /* 分片被篡改或顺序错误 */ } memcpy(prev_hmac, calculated_hmac, 32); /* 更新链式状态 */ return 0;}加固四分区切换的原子化将分区切换的三个步骤合并为一次原子写入——不是分别写三个独立的Flash区域而是写一个包含三字段的结构体并在写入后立即读取验证代码语言C自动换行AI代码解释typedef structattribute((packed)) {uint32_t magic; /* 魔数: 0xA5B3C1D7/uint8_t target_partition; /A0, B1/uint8_t partition_status; /0blank, 1verified, 2active/uint16_t crc16; /前三字段的CRC16 */} partition_switch_cmd_t;void atomic_partition_switch(uint8_t target) {partition_switch_cmd_t cmd {.magic 0xA5B3C1D7,.target_partition target,.partition_status PART_STATUS_ACTIVE,.crc16 0};cmd.crc16 crc16_calc((uint8_t*)cmd, 6);/* 原子写入HSM保护的Flash区域 */ hse_flash_secure_write(PARTITION_SWITCH_ADDR, cmd, sizeof(cmd)); /* 立即回读验证 */ partition_switch_cmd_t verify; flash_read(PARTITION_SWITCH_ADDR, verify, sizeof(verify)); if (memcmp(cmd, verify, sizeof(cmd)) ! 0) { /* 写入失败回退到安全状态 */ LOG_CRITICAL(Partition switch atomic write failed, entering safe mode); enter_safe_mode(); }}五、效果验证与量化收益ota_hardening_compare.png加固完成后我们重新进行了同一套攻击用例的验证攻击向量 加固前 加固后替换App分区载荷 成功绕过自校验 阻断编译期绑定篡改manifest中payload_hash 成功解析器类型混淆 阻断原子化验证中间分片替换 成功无分片校验 阻断HMAC链断裂分区切换竞态 成功掉电窗口 阻断原子写入C RC回滚版本攻击 成功未强制单调性 阻断版本号单调递增校验最直观的变化渗透测试的固件替换成功率从80%降至0%。六、总结与延伸思考这次事件教会我一件事签名只是信任的开始不是信任的终点。在汽车嵌入式系统中签名验证链的每一个环节——从bootloader的指针解引用到编译宏的条件判断到JSON解析器的类型转换到Flash写入的时序——都可能成为绕过签名的入口。复盘下来最值得思考的不是攻击手法本身而是一个组织层面的问题为什么App自校验的编译宏缺失能通过代码评审、集成测试、QA验证、安全审计四个关卡答案很可能是每一关都假设前一关已经做了验证而实际上没有任何一关真正执行了验证。这个教训适用于所有写入量产的代码。如果你也在做车端固件安全建议从今天起做一件事在签名验证代码中加一条反向断言——不要只测试正确签名能通过必须测试错误签名一定不过。你会发现能通过前者不代表能通过后者。关于OTA签名链还有一个值得深入的方向国密SM2在资源受限MCU上的签名验证性能。在一颗只有256KB SRAM的MCU上SM2验签能否满足500ms的启动时间限制有实战经验的朋友欢迎分享。