简介:AUTOSAR_PRS_SecOcProtocol.pdf 是 AUTOSAR 组织在 R20-11 版本中发布的《Secure Onboard Communication Protocol》规范文档,面向汽车电子架构工程师、车载网络安全开发者与智能驾驶系统设计人员。它针对车载 ECU、传感器、执行器之间通信易被篡改或重放的问题,给出安全车载通信(SecOC)的协议目的与目标、适用范围、约束与假设、局限及非对称方案下的适配方式,并梳理与底层协议、应用层的依赖关系,随后展开 I-PDU 认证、验证判定条件、Freshness 管理等安全机制的定义,还包含用例、需求可追溯性、术语与缩略语章节,可用于指导车载安全通信模块的设计、实现与集成测试。资源压缩包约 322KB,内含 1 个 PDF 文件,共 28 页,篇幅紧凑而结构完整,适合作为案头速查资料。目前已有 459 人学习下载,可作为理解 AUTOSAR 安全车载通信协议栈的入门与参考资料。
1. 一条 CAN 报文被重放就执行:SecOC 要补的到底是什么
台架上拿 CAN 工具把 0x123 这条报文抓下来,改两个字节再原样发回总线,车身控制器照样执行——这不是什么漏洞挖掘,只是 CAN 通信的默认状态。ID 过滤、DLC 检查、周期监控都属于「格式对不对」,而 SecOC(Secure Onboard Communication)要回答的是另外三个问题:这条报文是不是持有密钥的那一方发的、内容有没有被动过、它是不是刚刚生成的而不是三天前录下来重放的。
AUTOSAR PRS_SecOcProtocol 定义的就是这套 PDU 级认证机制:不改 CanTp 的分段规则,不引入会话和握手,只在原有应用 PDU 后面挂一段认证器和新鲜度值,让接收端能独立判断报文可信度。它适合做 AUTOSAR CP 通信栈、CAN/CAN FD 与车载以太网 PDU 集成、Crypto 栈配置的人,也需要你同时看得懂 COM、PduR、CanIf 三层的数据流向,否则配出来的参数对不上位。
2. Secured I-PDU 逐字段拆解与 MAC 计算链路
SecOC 不是一个独立的传输通道,它夹在 PduR 与 CanIf 之间,把上层来的应用 PDU 加工成 Secured I-PDU 再交给下层。理解这条链路,比记住参数名重要得多,因为 90% 的集成问题都出在「发出去的段和收回来按同样规则切的段不一致」。
2.1 Authentic I-PDU、Authenticator、Freshness Value 三段怎么排
Secured I-PDU 的组成顺序是固定的:先放原始应用数据(Authentic I-PDU,长度和上层 PDU 完全一致),接着是截断后的消息认证码(Authenticator),最后是截断后的新鲜度值(Freshness Value)。如果新鲜度值长度在项目里是可变的,还要在末尾追加一个字节表示 FV 的实际长度,配置固定长度时这一段通常省掉。
| 段 | 典型长度 | 是否截断 | 说明 |
|---|---|---|---|
| Authentic I-PDU | 由应用决定 | 不截断 | 原始信号 payload,SecOC 不修改其内容 |
| Authenticator | 3 / 4 / 8 字节 | 截断 MAC | 最长常见 32 bit,短包场景可压到 24 bit |
| Freshness Value | 1 / 2 / 3 / 5 字节 | 截断 FV | 只传低位,高位靠本地重建 |
| FV Length | 0 或 1 字节 | — | 仅在 FV 长度可变时出现 |
这张表的现实约束是 CAN FD 的 64 字节上限。一条 48 字节的应用 PDU,加上 4 字节 MAC 和 3 字节 FV,就已经 55 字节,再算上 SecOC 不做分段这件事,一旦超长就得回头砍信号或者拆分 PDU。我在项目里通常先做一次长度预算,把 MAC 截断长度和 FV 截断长度当作两个可调旋钮,而不是先定死再返工。
截断长度不是越短越好。MAC 截断到 24 bit 意味着随机伪造成功的概率是 2 的负 24 次方,对功能安全等级要求高的报文偏松;32 bit 是当前多数量产项目的默认值。FV 截断同理,截得越短,接收侧的搜索窗口就要开得越大,验证耗时和 CPU 负载都会上涨。
2.2 MAC 输入的构造:完整 FV 与 DataID 为什么不能被截断
真正容易踩的坑在这里:总线上传的是截断 FV,但参与 MAC 计算的必须是完整未截断的新鲜度值。接收端先用本地重建出的完整 FV 算出 MAC,再拿结果的前 N 个字节和报文里带的 Authenticator 比对——如果直接用截断值去算,两边永远对不上,而且报错信息通常只告诉你「验证失败」,不会告诉你原因。
MAC 的输入构造,常见做法是把 Authentic I-PDU 和完整 FV 顺序拼接作为主输入,把 DataID(一般对应 CAN ID 或 PDU ID,2 字节)通过附加认证数据的方式传给 Crypto 栈。DataID 的作用是把认证结果和「这是哪条报文」绑死,防止把 A 报文的合法 MAC 挪到 B 报文上使用。两侧的 DataID 取值、字节序、是否参与计算必须完全一致,这是跨团队联调时最高频的对不齐项。
/* SecOC 发送路径构造 MAC 输入的骨架示意 */ static Std_ReturnType SecOC_BuildMacInput(const uint8 *pduData, uint16 pduLen, const uint8 *fullFv, uint8 fullFvLen, uint8 *macInput, uint16 *macInputLen) { uint16 idx = 0u; /* 1. 先放 Authentic I-PDU 原始数据,顺序不可调换 */ (void)memcpy(&macInput[idx], pduData, pduLen); idx = (uint16)(idx + pduLen); /* 2. 追加完整 FV,注意是未截断的版本 */ (void)memcpy(&macInput[idx], fullFv, fullFvLen); idx = (uint16)(idx + fullFvLen); *macInputLen = idx; /* 3. DataID 走附加认证数据通道,由 Crypto 栈参数传入 */ return E_OK; }这段代码的关键点是拼接顺序和 FV 的完整性。pduData只包含应用信号,不含 MAC 和截断 FV;fullFv从新鲜度值管理器取出,长度可能是 5 字节(Trip 3 + Reset 1 + Message 1 之类的组合),而总线上只传其中低位 3 字节。函数返回的macInputLen要传给Csm_MacGenerate,长度算错一位,MAC 就完全变了。
2.3 用 Python 把抓到的 Secured PDU 拆回三段
联调阶段最实用的动作,是先确认「发出来的字节布局」和「设计文档里写的布局」一致。下面这段脚本读 candump 风格的行格式日志,按配置的长度切出三段并打印,用来快速判断问题出在发送侧布局还是接收侧验证。
#!/usr/bin/env python3 # 从 candump 文本日志中切分 SecOC PDU,校验发送侧字节布局 import sys AUTH_LEN = 4 # 截断 MAC 长度,单位字节,需与 ECUC 中 AuthInfoTxLength/8 一致 FV_LEN = 3 # 截断 FV 长度,单位字节 TARGET_ID = 0x123 def parse_line(line): # 形如: (1700000000.123456) can0 123#AABBCCDDEEFF00112233 try: ts_part, rest = line.split(")", 1) can_id, data = rest.strip().split("#") return int(can_id, 16), bytes.fromhex(data) except ValueError: return None, None def split_secured(payload): if len(payload) <= AUTH_LEN + FV_LEN: return None authentic = payload[:len(payload) - AUTH_LEN - FV_LEN] auth = payload[len(payload) - AUTH_LEN - FV_LEN: len(payload) - FV_LEN] fv = payload[len(payload) - FV_LEN:] return authentic, auth, fv def main(path): with open(path, "r", encoding="utf-8", errors="ignore") as f: for lineno, line in enumerate(f, 1): cid, payload = parse_line(line) if cid != TARGET_ID or payload is None: continue parts = split_secured(payload) if parts is None: print(f"line {lineno}: payload too short [{payload.hex()}]") continue authentic, auth, fv = parts print(f"line {lineno} authentic={authentic.hex()} " f"mac={auth.hex()} fv={fv.hex()}") if __name__ == "__main__": main(sys.argv[1])三个常量AUTH_LEN、FV_LEN、TARGET_ID必须和 ECUC 里配的值一一对应,改配置就要改脚本,不要凭印象。输出里的fv字段建议连续观察几百行,如果它长时间不变化或者出现大幅回退,先别怀疑加密算法,去看新鲜度值的推进逻辑和同步消息是否正常,那才是根因所在。
3. 新鲜度值管理:Trip/Reset/Message 计数器与同步消息设计
Freshness Value 是 SecOC 里唯一带「状态」的东西,也是最容易做错的部分。它由三部分拼装而成,各自的时间尺度和存储要求完全不同,混在一起想就会乱。
3.1 三种计数器各自的时间尺度与存储要求
| 计数器 | 递增时机 | 存储介质 | 典型位宽 | 掉电后行为 |
|---|---|---|---|---|
| Trip Counter | 整车生命周期内极少递增,如维修更换部件 | 非易失(NvM) | 24 bit | 必须保留 |
| Reset Counter | ECU 每次上电复位递增 1 | 非易失(NvM) | 8~20 bit | 必须保留 |
| Message Counter | 每发送一帧递增 1 | RAM | 8~20 bit | 归零,靠前两者兜底 |
三者组合的逻辑是:Message Counter 变化最快,负责同一上电周期内的抗重放;它一旦溢出或 ECU 复位归零,由 Reset Counter 保证整体 FV 仍然单调递增;Reset Counter 累积到上限时由 Trip Counter 接管。只要保证完整的 FV 值在整车生命周期内不重复,重放就失效。
这里有个容易被忽略的工程约束:非易失计数器的写入次数是有限的。如果每次上电都往 NvM 写 Reset Counter,某些 EEPROM 的擦写寿命几年就撑不住。我在项目里一般会把 Reset Counter 做成分块轮转存储,或者按 K 次复位合并写入一次,代价是掉电瞬间可能丢掉少量计数——这部分要在安全分析里说明并给出容忍窗口。
网络管理(NM)和 BSWM 下电流程也会影响这件事。ECU 进入睡眠前,如果 FV 的当前值还没落盘,下次唤醒后的 FV 可能低于上次发送过的值,接收侧就会判为重放。常见做法是把 FV 的持久化放在 BSWM 的下电动作序列里,和 NvM 的写回同步完成。
3.2 同步消息与接收侧的 FV 重建窗口
发送侧的 FV 是完整可知的,接收侧却只能拿到截断值,中间那段高位必须靠同步。SecOC 的解法是引入 FV Master:它周期性广播一条同步 PDU,内容包含完整的三元组,接收侧(FV Slave)收到后用同步值覆盖本地重建基准,再把截断值填进低位,得到候选 FV。
窗口宽度由两个量决定:同步周期 T 和该报文的实际发送速率 R(帧/秒)。最坏情况下,接收侧自上次同步以来可能已经错过了 R×T 个计数步进,所以候选窗口至少要覆盖这个范围,再留一点余量应对抖动。窗口开得太小,正常报文也会被拒;开得太大,接收端每次要对窗口内多个候选值逐一算 MAC,CPU 开销线性上升。
这也是SecOCAuthenticationVerifyAttempts这个参数的意义所在:它限制一次验证最多尝试几个候选 FV。工程师常犯的错是把它设成 1 图省事,结果同步消息稍有延迟就大批量认证失败,然后转头去查密钥,方向完全错了。
3.3 用 Python 验证 FV 重建窗口是否够用
把日志里连续收到的截断 FV 提出来,重放一遍重建逻辑,就能判断当前窗口参数在真实流量下是否收敛。下面脚本模拟按窗口搜索的过程,并统计失败次数。
# 模拟接收侧 FV 重建,评估窗口宽度设置是否合理 WINDOW = 8 # 允许向前搜索的候选个数,对应 VerifyAttempts 上限 FV_MASK = 0xFFFFFF # 截断 FV 为 24 bit def reconstruct(local_full_fv, rx_truncated_fv, window=WINDOW): """返回 (候选FV列表, 是否命中)""" local_high = (local_full_fv >> 24) & 0xFFFFFFFF candidates = [] # 高 32 位保持不变,低位在窗口内枚举 for step in range(window): cand = ((local_high << 24) | ((rx_truncated_fv - step) & FV_MASK)) & 0xFFFFFFFFFFFFFFFF candidates.append(cand) return candidates def replay(fv_sequence, window=WINDOW): local = fv_sequence[0] stat = {"hit": 0, "first_try": 0, "search": 0, "miss": 0} for rx in fv_sequence[1:]: cands = reconstruct(local, rx & FV_MASK, window) idx = next((i for i, c in enumerate(cands) if (c & FV_MASK) == rx), None) if idx is None: stat["miss"] += 1 else: stat["hit"] += 1 stat["first_try" if idx == 0 else "search"] += 1 local = cands[idx] return stat if __name__ == "__main__": seq = [0x0000000001, 0x0000000002, 0x0000000003, 0x0000010004, 0x0000010005, 0x0000010006] print(replay(seq))WINDOW对应接收侧的搜索深度,local是本地维护的完整 FV,rx & FV_MASK是总线上传来的截断部分。统计结果里first_try占比高说明同步健康,search占比高说明本地 FV 落后于实际值、靠窗口兜住,miss出现则说明窗口不够或同步消息丢失。把真实日志的 FV 序列喂进去跑一遍,参数该调到多少就有依据了,不用靠猜。
4. 在 AUTOSAR CP 上配置 SecOC:ECUC 参数与 Crypto Stack 调用链
配置阶段的痛苦主要来自两处:参数分散在 SecOC、Csm、CryIf、Crypto Driver 四个模块,以及工具链生成的代码把调用链藏得很深。理清顺序之后再动手,返工能少很多。
4.1 SecOCPdu 里必须对齐的六个 ECUC 参数
以 Vector 工具链为例,SecOC 相关的容器大致分布在 SecOCGeneral、SecOCConfig、SecOCPdu(收发两侧各有 Tx/Rx 处理容器)之下。不同工具链生成的元素名会略有差异,但语义是通用的。
| 参数 | 含义 | 对齐要求 |
|---|---|---|
| SecOCDataID | 参与认证的报文标识 | 收发两侧必须完全一致,含字节序 |
| SecOCFreshnessValueID | 引用新鲜度值管理器中的 FV 实例 | 该 ID 决定取哪一组计数器 |
| SecOCFreshnessValueLength | 完整 FV 的位宽 | 必须等于参与 MAC 计算的实际长度 |
| SecOCAuthInfoTxLength | 截断 MAC 的位宽(bit) | 收发两侧一致,且与 Python 脚本常量对应 |
| SecOCAuthAlgorithmRef | 引用的 Crypto 算法对象 | 算法、密钥长度、模式要匹配 |
| SecOCAuthKeyRef | 引用的密钥对象 | 密钥更新时要保证两端同步切换 |
第六项密钥引用是批量生产阶段最容易出事的地方。产线刷写、售后更换 ECU、密钥轮换三个场景下,只要有一端更新另一端没更新,表现就是全量认证失败,而且 DEM 里报的事件名往往只写「验证失败」,看不出是密钥版本问题。建议在应用层额外保留一个密钥版本 DID,通过诊断读取,出问题时先比版本号。
4.2 CSM/CryIf/Crypto Driver 的调用顺序与 HSM 边界
SecOC 自己不算 MAC,它调用 CSM 的服务。链路是 CSM → CryIf → Crypto Driver,最终落到软件实现或 HSM 固件里的算法执行单元。发送侧调Csm_MacGenerate,接收侧同样调Csm_MacGenerate再本地比对——注意接收侧不是调用「验证」类服务,因为总线上传的 MAC 已经被截断,无法直接交给算法做校验,只能重算再比。
<!-- SecOCPdu 关键参数配置片段,元素名以实际工具链导出为准 --> <SECOC-PDU> <SHORT-NAME>SecOCPdu_VCU_Status</SHORT-NAME> <SECOC-DATA-ID>4660</SECOC-DATA-ID> <!-- 0x1234 --> <SECOC-FRESHNESS-VALUE-ID>FV_VCU</SECOC-FRESHNESS-VALUE-ID> <SECOC-FRESHNESS-VALUE-LENGTH>40</SECOC-FRESHNESS-VALUE-LENGTH> <SECOC-AUTH-INFO-TX-LENGTH>32</SECOC-AUTH-INFO-TX-LENGTH> <SECOC-AUTH-ALGORITHM-REF DEST="CRYPTO-ALGORITHM"> /Crypto/CmacAes128 </SECOC-AUTH-ALGORITHM-REF> <SECOC-AUTH-KEY-REF DEST="CRYPTO-KEY"> /Crypto/Key_SecOC_VCU </SECOC-AUTH-KEY-REF> </SECOC-PDU>SECOC-FRESHNESS-VALUE-LENGTH写的是 40,表示完整 FV 是 5 字节,而总线上只传 3 字节,这个差值就是接收侧要靠同步补回来的部分。SECOC-AUTH-INFO-TX-LENGTH是 32 bit 截断 MAC,对应前文脚本里的AUTH_LEN = 4。两个数字改一个不改另一个,验证必然失败,而且现象和密钥错误一模一样。
如果 MAC 运算跑在 HSM 上,还要注意 CSM 作业的优先级和超时配置。SecOC 在 Tx 中断或主函数周期里同步等结果,HSM 被其他作业(比如诊断安全访问的随机数生成)占住时,Csm_MacGenerate可能返回CSM_E_BUSY。这个返回值必须处理,直接忽略会出现「偶尔几帧验证失败」这种最难查的现象。
4.3 Tx/Rx 处理链的代码骨架与返回值检查
下面这段是接收侧验证路径的骨架,工具链生成的结构体字段名会有差异,重点是流程和错误处理位置。
/* SecOC 接收侧验证骨架:重建 FV -> 重算 MAC -> 常量时间比对 */ static Std_ReturnType SecOC_VerifyRx(const SecOC_RxPduType *cfg, const uint8 *rxPayload, uint16 rxLen) { uint8 macBuf[16]; uint32 macLen = sizeof(macBuf); uint8 macInput[128]; uint16 macInputLen = 0u; uint8 candFv[5]; uint8 attempt; /* 超出配置的尝试次数直接拒绝,避免窗口过大拖垮主函数 */ for (attempt = 0u; attempt < cfg->verifyAttempts; attempt++) { if (SecOC_FvmGetCandidate(cfg->fvId, attempt, candFv) != E_OK) { break; } if (SecOC_BuildMacInput(rxPayload, (uint16)(rxLen - cfg->macLen - cfg->fvLen), candFv, cfg->fullFvLen, macInput, &macInputLen) != E_OK) { return E_NOT_OK; } if (Csm_MacGenerate(cfg->jobId, CRYPTO_OPERATIONMODE_SINGLECALL, macInput, macInputLen, macBuf, &macLen) != E_OK) { /* HSM 忙或密钥未就绪,上报 DEM 后交给上层决定是否丢弃 */ (void)Dem_SetEventStatus(cfg->demEventId, DEM_EVENT_STATUS_FAILED); return E_NOT_OK; } if (SecOC_ConstTimeEqual(macBuf, &rxPayload[rxLen - cfg->macLen - cfg->fvLen], cfg->macLen)) { (void)SecOC_FvmAccept(cfg->fvId, candFv); /* 命中后推进本地 FV */ return E_OK; } } (void)Dem_SetEventStatus(cfg->demEventId, DEM_EVENT_STATUS_FAILED); return E_NOT_OK; } /* 常量时间比较,避免因提前返回泄露 MAC 匹配进度 */ static boolean SecOC_ConstTimeEqual(const uint8 *a, const uint8 *b, uint8 len) { uint8 diff = 0u; uint8 i; for (i = 0u; i < len; i++) { diff |= (uint8)(a[i] ^ b[i]); } return (diff == 0u) ? TRUE : FALSE; }循环里attempt的上界就是SecOCAuthenticationVerifyAttempts,它直接决定最坏情况下的 MAC 计算次数,也就是主函数被占用的时间。SecOC_FvmAccept只在命中后调用,如果把推进本地 FV 的动作放在循环里,一旦某个候选偶然匹配,后续窗口基准就错了,表现为「认证成功但下一帧就失败」。Csm_MacGenerate的返回值不做分支处理是最常见的偷懒写法,而它恰恰是间歇性故障的来源。
5. SecOC 认证失败的定位顺序与被动模式调试技巧
SecOC 的报错信息粒度很粗,几乎所有原因都表现为「MAC 验证失败」。有效的做法是按成本从低到高排一个固定的检查顺序,而不是一上来就怀疑算法。
| 现象 | 优先怀疑 | 快速确认方式 |
|---|---|---|
| 全量失败,一帧都过不了 | DataID 或密钥不一致 | 对照两侧配置导出,读密钥版本 DID |
| 偶发失败,随时间增多 | FV 同步丢失或窗口过小 | 统计同步消息周期与丢帧率 |
| 上电后前几帧失败,之后正常 | Reset Counter 落盘滞后 | 观察 FV 在唤醒后的跳变 |
| 大批量失败但诊断无异常 | HSM 作业繁忙,Csm 返回 BUSY | 打开 CSM 作业耗时统计 |
| 单条报文一直失败 | PDU 长度与截断参数不匹配 | 用 2.3 的脚本核对三段切分 |
一个很实用的技巧是把发送侧先切到被动模式(SecOCPassiveModeActive之类的开关),此时发送的 Secured PDU 仍然带 FV 和 MAC 字段,但不参与接收侧阻断,链路可以正常跑起来。这样能把「通信本身通不通」和「认证过不过」两个问题分开,避免在一堆认证失败日志里迷失方向。
被动模式下同时抓两端日志,用 2.3 的脚本把发送侧的真实 FV 序列提出来,人工喂给接收侧的重建函数,看窗口内能不能搜到。如果发送侧 FV 序列本身就在跳变或回退,问题在新鲜度值管理器和 NvM 写入时序;如果发送侧完全单调正常,接收侧搜不到,那就是同步消息没收到或者 DataID 对不上。
最后一个容易被忽略的细节:即使比对逻辑完全正确,也建议用常量时间比较(如 4.3 里的SecOC_ConstTimeEqual),而不是memcmp提前返回。截断 MAC 只有 3 到 4 字节,逐字节试探在理论上是可以把搜索空间压下来的,而memcmp的返回时机差异恰好给了这种试探可乘之机。
本文还有配套的精品资源,点击获取