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校验方案,上线后遭遇批量仿制。复盘发现三个关键失误:

  1. 混淆算法复用开源库 :使用的 TinyCrypt 哈希被逆向者直接识别,其源码注释中包含“UID hash for STM32”字样,成为逆向突破口。
  2. 调试残留未清除 :Release版本中保留了 printf 重定向到USART的代码,攻击者通过监听串口获取了UID计算中间值。
  3. 未考虑物理攻击面 :未启用RDP,对手使用半侵入式探针(Glitch Attack)在上电瞬间注入电压毛刺,跳过RDP检查。

最终解决方案采用 UID+外部EEPROM密钥融合 :UID用于生成AES-128密钥派生种子,真实密钥存储于I²C EEPROM(带写保护引脚),且EEPROM通信使用自定义协议(非标准I²C时序)。即使UID被读取,无EEPROM密钥仍无法完成解密。该方案通过IEC 62443-3-3认证,量产至今未被攻破。

UID保护不是一道墙,而是一道需要持续加固的防线。它的价值不在于“不可破解”,而在于提高攻击者的边际成本。当绕过UID保护所需的时间超过产品生命周期时,它便完成了自己的使命。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐