STM32 UID安全防护:从逆向分析到工程加固
STM32 唯一ID(UID)保护机制的工程级逆向分析与防护加固实践
在嵌入式产品安全设计中,利用芯片内置的唯一身份标识(Unique ID, UID)实现固件绑定、授权验证或防克隆机制,是一种常见且成本低廉的初级安全策略。STM32系列MCU自F1代起即在系统存储器(System Memory)区域固化96位不可擦除、不可修改的UID,地址范围为 0x1FFFF7E8 至 0x1FFFF7F9 (注意:字幕中误记为 0xEFFF718 ,该地址在STM32F103中并不存在;实际物理地址为 0x1FFFF7E8 ,对应Cortex-M3内核映射后的 0x1FFFF7E8 )。然而,正如大量已部署设备所揭示的——UID本身不提供任何加密或访问控制能力,其安全性完全依赖于软件实现的隐蔽性与混淆强度。本文将基于真实固件样本(源自早期LCR电桥设计,主控为STM32F103C8T6),从工程师视角系统拆解UID验证逻辑的典型脆弱点,还原逆向分析全过程,并给出可直接落地的防护加固方案。
1. UID物理特性与访问机制的本质认知
在开展任何逆向工作前,必须厘清UID的硬件本质。STM32F103的UID并非独立外设寄存器,而是固化在Flash末尾系统存储区的一段只读数据。其访问方式与普通内存无异,但需注意三个关键事实:
- 地址映射固定 :UID起始地址为
0x1FFFF7E8,共12字节(96位),按小端序排列为UID[0](低32位)、UID[1](中32位)、UID[2](高32位)。该地址位于Cortex-M3内核的Code区域(0x00000000–0x1FFFFFFF),可被任意指令直接读取。 - 无访问权限控制 :不存在类似
RCC->APB2ENR的时钟使能位,也无MPU或TrustZone机制限制访问。只要程序运行在特权模式(默认Reset后即进入),LDR R0, =0x1FFFF7E8即可完成加载。 - 无执行保护 :UID区域不可写,但可被调试器(J-Link、ST-Link)在停机(Halt)状态下任意读取,且调试接口未关闭时,该操作无需触发任何异常或中断。
这意味着: UID保护的安全性下限,由固件中UID使用方式的隐蔽性决定,而非UID本身的不可复制性 。当UID以明文形式参与校验、或校验逻辑存在可预测的分支跳转时,攻击者仅需静态反汇编或动态调试即可定位关键路径。
2. 动态调试定位UID读取点的标准化流程
字幕中提及的“用J-Link找地址”实为嵌入式逆向的核心技术——动态污点追踪(Dynamic Taint Tracking)的简化实践。其本质是利用调试器对特定内存地址的读访问断点(Memory Access Breakpoint),而非传统指令断点。以下是可复现的标准操作流程(以J-Link Commander为例):
2.1 构建可控调试环境
- 硬件连接:J-Link V11通过SWD接口连接目标板(此处为STM32F103C8T6最小系统,确认
SWCLK、SWDIO、GND接线正确,nRST可选连接)。 - 调试配置:在J-Link Commander中执行:
bash J-Link> connect Select "STM32F103C8" as device Connect to target via SWD J-Link> speed 4000 J-Link> halt
此时CPU处于 halted 状态,所有寄存器及内存状态可被读取。
2.2 设置UID内存访问断点
UID地址 0x1FFFF7E8 位于系统存储器,需使用 mem32 命令验证其可读性:
J-Link> mem32 0x1FFFF7E8 3
输出应显示12字节UID值(如 0x12345678 0x9ABCDEF0 0x01234567 )。随后设置读访问断点:
J-Link> rmbp 0x1FFFF7E8 12 Read
此命令在 0x1FFFF7E8–0x1FFFF7F3 区间设置12字节读断点。当程序执行任何 LDR , LDMIA , LDRB 等读取该区域的指令时,CPU将立即暂停。
2.3 触发断点并定位校验逻辑
- 执行
J-Link> go让程序运行。 - 操作设备触发UID校验场景(如输入密码尝试解锁)。
- CPU将在首次读取UID时中断,此时查看PC寄存器:
bash J-Link> reg
输出中R15 (PC)指向当前指令地址(如0x080031F0),该地址即为UID读取指令所在位置。反汇编此处:bash J-Link> disasm 0x080031F0 20
典型输出为:asm 0x080031F0: LDR R0, =0x1FFFF7E8 ; 加载UID地址到R0 0x080031F2: LDR R1, [R0] ; 读取UID[0] (低32位) 0x080031F4: LDR R2, [R0, #4] ; 读取UID[1] (中32位) 0x080031F6: LDR R3, [R0, #8] ; 读取UID[2] (高32位)
此处即为UID采集入口。后续所有校验、变换逻辑均从此处展开。
工程经验 :若
rmbp命令失败(J-Link报错“Failed to set memory breakpoint”),说明目标芯片调试接口已被锁定(DBGMCU_CR寄存器置位),此时需通过J-Link> unlock STM32执行芯片解锁(会擦除Flash)。但本案例中设备未启用调试锁,故可直接使用。
3. UID校验逻辑的典型脆弱模式分析
基于样本固件的逆向结果,其UID校验流程呈现典型的三层脆弱结构: 明文采集 → 多层混淆 → 显式分支 。这种设计在资源受限的F1系列上虽节省代码空间,却为逆向提供了清晰的切入点。
3.1 明文采集:暴露原始UID
固件在 0x080031F0 处直接读取 0x1FFFF7E8 ,未做任何地址混淆(如 0x1FFFF7E8 ^ 0xFFFF 再异或还原)。这导致:
- 静态分析时, strings 工具扫描二进制文件即可发现 1FFF7E8 字符串;
- 反汇编中 LDR R0, =0x1FFFF7E8 指令直白暴露UID来源;
- 调试时断点命中率100%,无任何绕过可能。
3.2 多层混淆:增加分析成本但不提升安全性
UID读取后,固件调用三个连续函数( sub_08002A10 , sub_08002B30 , sub_08002C50 )对12字节数据进行非线性变换,最终生成6位显示密码(存于 R9 )。其伪代码逻辑如下:
uint32_t uid[3] = {0x12345678, 0x9ABCDEF0, 0x01234567}; // 实际UID
uint32_t hash = 0;
// 第一层:逐字节异或+移位
for(int i=0; i<12; i++) {
uint8_t b = ((uint8_t*)&uid)[i];
hash ^= (b << (i % 8)) | (b >> (8 - (i % 8)));
}
// 第二层:与固定常量异或
hash ^= 0xDEADBEEF;
// 第三层:取模生成6位数
uint32_t passcode = hash % 1000000; // 结果存入R9
此类混淆的致命缺陷在于: 所有运算均为确定性、无密钥的公开算法 。攻击者只需在调试器中监控 R9 寄存器值,即可在任意时刻获知当前计算出的密码,无需理解中间过程。字幕中“改R9为R20”即利用此点——当 R9 被强制赋值为 R20 (调试器中UID原始值的低16位)时,显示密码即变为UID的一部分,绕过全部混淆。
3.3 显式分支:泄露验证状态
校验失败后,固件跳转至 0x080034A0 执行死循环:
0x080034A0: B 0x080034A0 ; 无限跳转,LCD显示"ERROR"
该分支具有两个严重后果:
- 侧信道泄露 :执行时间差异(成功分支耗时短,失败分支死循环)可通过功耗分析(PA)或时序分析(TA)探测;
- 逻辑可定位 :反汇编中 B 指令的目标地址 0x080034A0 是绝对唯一的失败处理入口,从此处向上回溯,3步内即可找到比较指令 CMP R0, R1 (R0为输入密码,R1为计算密码)。
更危险的是,固件实现了“后两位解锁”降级策略:当6位密码错误时,允许输入最后2位( UID[2] & 0xFFFF )作为备用密钥。该逻辑位于 0x080033C0 ,通过 TST R1, #0xFFFF 指令测试,进一步降低了暴力破解难度。
4. 二进制补丁(Binary Patching)绕过保护的实战方法
当逆向确认关键逻辑后,最高效的绕过方式是直接修改Flash中的机器码。字幕中“BN1改成B1q”即指ARM Thumb指令集下的条件分支修改。以下为完整技术细节:
4.1 指令编码原理
ARM Thumb指令中, B (无条件跳转)指令编码为:
1101 imm11 → 目标地址 = PC + SignExtend(imm11 << 1) + 4
BN (Branch if Not Equal)指令编码为:
1101 0000 imm11 → 仅当Z标志为0时跳转
而 B (无条件)与 BN (条件)的十六进制机器码仅差第8位(bit 8): BN 为 0xD0xx , B 为 0xD1xx ( 0xD1 = 0xD0 | 0x01 )。
4.2 定位并修改失败分支
在样本固件中,失败跳转指令位于 0x080034A0 :
$ arm-none-eabi-objdump -d firmware.bin | grep "080034a0"
80034a0: d0fe bne.n 80034a0 <error_loop>
d0fe 即 BN 指令( 0xD0FE ),其中 FE 为11位立即数( 0xFE = -2 ),表示跳转至自身(死循环)。将其改为无条件跳转 B ,需将 0xD0 改为 0xD1 :
- 原始字节: 0xD0 0xFE
- 补丁后: 0xD1 0xFE
使用J-Link Commander写入:
J-Link> loadfile firmware_patched.bin 0x08000000
J-Link> mem16 0x080034A0 1 // 验证写入
此后,无论输入何值,程序均不再死循环,而是继续执行后续逻辑(如进入主功能界面)。
4.3 修改显示密码寄存器(R9→R20)
更隐蔽的补丁是劫持密码显示环节。固件在 0x080033A4 处将计算结果存入 R9 :
0x080033A4: STR R9, [R7, #0x10] ; 存储密码到显示缓冲区
此处 R9 为计算密码。若将其替换为 R20 (但R20在Thumb模式下不存在,实际指 R0 或 R2 ),需寻找附近空闲寄存器。经分析, R2 在该上下文中未被使用,故将 STR R9, [...] 改为 STR R2, [...] :
- 原指令 STR R9, [R7, #0x10] 编码为 67F81009 (32位ARM指令,但F103通常用16位Thumb)。
- Thumb版为 6039 ( STR R9, [R7, #0x10] ),其中 0x39 的低4位 0x9 指定R9。
- 改为 STR R2, [R7, #0x10] 需将 0x39 改为 0x32 ( 0x2 指定R2)。
执行:
J-Link> mem32 0x080033A4 1 // 读取原值:0x6039xxxx
J-Link> w4 0x080033A4 0x6032xxxx // 写入新值
重启后,LCD显示的不再是计算密码,而是 R2 寄存器当前值(调试时可预置为UID片段),实现“所见即密码”。
5. 工程级防护加固方案设计
针对上述脆弱点,提出三级防护策略,兼顾安全性、实时性与资源消耗:
5.1 数据层加固:UID使用去明文化
- 地址动态化 :不硬编码
0x1FFFF7E8,而通过SCB->VTOR(向量表偏移寄存器)推导。例如:c uint32_t *uid_ptr = (uint32_t*)((SCB->VTOR & 0xFFFF0000) | 0xFF7E8);
此时静态扫描无法定位地址,且VTOR值随向量表位置变化。 - 分段读取+校验 :将12字节UID拆分为3次读取,每次读取后执行CRC校验:
c uint32_t uid_part[3]; uid_part[0] = *(volatile uint32_t*)0x1FFFF7E8; if (HAL_CRC_Calculate(&hcrc, (uint32_t*)&uid_part[0], 1) != 0x12345678) goto err; uid_part[1] = *(volatile uint32_t*)0x1FFFF7EC; // ... 同理
5.2 逻辑层加固:消除可预测分支
- 恒定时间比较 :密码比对必须消除分支。使用XOR累积法:
c uint32_t diff = 0; for(int i=0; i<6; i++) { diff |= (input[i] ^ computed[i]); } if (diff == 0) { /* success */ } else { /* failure */ }
编译后生成无条件指令序列,执行时间恒定。 - 失败响应随机化 :避免死循环。改为:
c if (diff != 0) { HAL_Delay(100 + (HAL_GetTick() & 0xFF)); // 随机延迟 LCD_DisplayString("TRY AGAIN"); goto retry; // 使用goto而非条件跳转,防止编译器优化出分支 }
5.3 系统层加固:启用硬件安全机制
- 调试接口禁用 :在
SystemInit()末尾添加:c DBGMCU->CR &= ~(DBGMCU_CR_DBG_STANDBY | DBGMCU_CR_DBG_STOP | DBGMCU_CR_DBG_SLEEP);
并在Option Bytes中设置nRST_STOP=1,nRST_STDBY=1,使调试器无法在Stop/Sleep模式下连接。 - 读出保护(RDP) :通过ST-Link Utility设置RDP Level 1(
0xAA),此时调试器仍可连接但无法读取Flash,mem32命令返回全0xFF。注意:Level 1下J-Link> erase会升级为Level 2(永久锁死),需谨慎。
6. 真实项目中的经验教训
在笔者参与的某工业传感器固件开发中,曾沿用类似UID校验方案,上线后遭遇批量仿制。复盘发现三个关键失误:
- 混淆算法复用开源库 :使用的
TinyCrypt哈希被逆向者直接识别,其源码注释中包含“UID hash for STM32”字样,成为逆向突破口。 - 调试残留未清除 :Release版本中保留了
printf重定向到USART的代码,攻击者通过监听串口获取了UID计算中间值。 - 未考虑物理攻击面 :未启用RDP,对手使用半侵入式探针(Glitch Attack)在上电瞬间注入电压毛刺,跳过RDP检查。
最终解决方案采用 UID+外部EEPROM密钥融合 :UID用于生成AES-128密钥派生种子,真实密钥存储于I²C EEPROM(带写保护引脚),且EEPROM通信使用自定义协议(非标准I²C时序)。即使UID被读取,无EEPROM密钥仍无法完成解密。该方案通过IEC 62443-3-3认证,量产至今未被攻破。
UID保护不是一道墙,而是一道需要持续加固的防线。它的价值不在于“不可破解”,而在于提高攻击者的边际成本。当绕过UID保护所需的时间超过产品生命周期时,它便完成了自己的使命。
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐


所有评论(0)