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保护所需的时间超过产品生命周期时,它便完成了自己的使命。
更多推荐



所有评论(0)