当前位置: 首页 > news >正文

一次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 MCU,HSM固件由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_ENABLE
ret = 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/415
https://gitcode.com/awfwaf/awf/issues/416
https://gitcode.com/awfwaf/awf/issues/417
https://gitcode.com/awfwaf/awf/issues/418
https://gitcode.com/awfwaf/awf/issues/419
https://gitcode.com/awfwaf/awf/issues/420
https://gitcode.com/awfwaf/awf/issues/421
https://gitcode.com/awfwaf/awf/issues/422
https://gitcode.com/awfwaf/awf/issues/423
https://gitcode.com/awfwaf/awf/issues/424
https://gitcode.com/awfwaf/awf/issues/425
https://gitcode.com/awfwaf/awf/issues/426
https://gitcode.com/awfwaf/awf/issues/427
https://gitcode.com/awfwaf/awf/issues/428
https://gitcode.com/awfwaf/awf/issues/429
https://gitcode.com/awfwaf/awf/issues/430
https://gitcode.com/awfwaf/awf/issues/431
https://gitcode.com/awfwaf/awf/issues/432
https://gitcode.com/awfwaf/awf/issues/433
https://gitcode.com/awfwaf/awf/issues/434
https://gitcode.com/awfwaf/awf/issues/435
https://gitcode.com/awfwaf/awf/issues/436
https://gitcode.com/awfwaf/awf/issues/437
https://gitcode.com/awfwaf/awf/issues/438
https://gitcode.com/awfwaf/awf/issues/439
https://gitcode.com/awfwaf/awf/issues/440
https://gitcode.com/awfwaf/awf/issues/441
https://gitcode.com/awfwaf/awf/issues/442
https://gitcode.com/awfwaf/awf/issues/443
https://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:i+chunk_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_chain

T-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; /
A=0, B=1/
uint8_t partition_status; /
0=blank, 1=verified, 2=active/
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的启动时间限制?有实战经验的朋友欢迎分享。

http://www.jsqmd.com/news/1125217/

相关文章:

  • 电脑错误dll修复工具 运行库工具修复dll 缺失找不到dll丢失问题
  • 3D医学影像分割:基于TotalSegmentator等5个公开数据集的模型训练实战
  • 当“遇见小面”商标遇见“渝见小面”!
  • 图数据库与知识图谱构建实战
  • Linux /etc/fstab 配置详解:5个关键参数避免重启后挂载回退只读
  • 3个关键步骤让AirPods在Windows上重获完整功能:AirPodsDesktop终极解决方案
  • TwinCAT3实战:台达A2伺服扭矩读取与参数优化指南
  • 高清图像数据集 DIV2K 与 Flickr2K 超分实战:1900张图像预处理与数据增强3种策略
  • 软件测试面试总结分享
  • Rmarkdown动态文档创作与数据科学报告实战指南
  • 大疆 M3508 电机速度 PID 调参实战:从振荡到稳定,3 组参数对比分析
  • 【全网大测评】有没有降AI率的靠谱软件推荐?2026年亲测15款降AI率工具,帮你避坑省钱!
  • Go 微服务限流:别把所有请求都堵在入口
  • 多接地配电系统的基于PMU的系统状态估计附Matlab代码
  • AI 反馈聚类:独立产品别让用户意见散成一地碎片
  • 3个痛点+5大功能:这款桌面待办工具如何让你的效率提升300%?
  • 计算机网络知识点总结(四)Linux C++ Socket实现“伪”半双工聊天室程序
  • A2A 在 Eino 框架中的完整应用解析
  • AI绘画不翻车的3个关键步骤与技巧
  • 89个公共Tracker如何让BT下载告别“孤岛困境“?
  • 30秒一镜到底的AI视频模型重磅来袭|Seedance2.5在哪体验一篇讲透
  • AI 创业假设验证:先证明有人痛,再证明模型强
  • ExplorerPatcher深度解析:重塑Windows界面体验的高效工具
  • 2026年最新:一行代码实现 One-API / New-API 聚合渠道国内无代理极速直连
  • 椭偏仪—介质膜的首选方法
  • 2026年暑假学习规划排名:这样安排让孩子高效又充实
  • 储能电站 BMS 与车载动力电池 BMS 核心差异:工况、保护策略、控制逻辑对比
  • 2026建筑合同管理系统怎么选才不踩坑:房建企业合同、签证、产值与付款闭环指南
  • AI 压测数据回放:让模型读报告之前先校准口径
  • 5分钟搞定米游社自动签到:手把手教你配置MihoyoBBSTools