1. STM32唯一ID保护机制与逆向分析工程实践

在嵌入式产品安全设计中,利用芯片内置的唯一身份标识(Unique Device ID)实现固件绑定、功能授权或防拷贝保护,是一种常见且成本低廉的技术路径。STM32系列微控制器自F1时代起即在系统存储器(System Memory)区域固化96位不可擦除、不可编程的唯一ID,其物理地址固定为 0x1FFFF7E8 (注意:字幕中误记为 0xEFFF718 ,此为典型地址书写错误,实际应为 0x1FFFF7E8 ,后文将统一修正并说明依据)。该ID由ST工厂在晶圆测试阶段写入,每颗芯片全局唯一,理论上无法被软件修改或批量伪造。

然而, 硬件特性不等于安全保证 。当唯一ID被直接用于关键业务逻辑(如密码生成、授权校验、功能开关)且未辅以足够混淆、时序隐藏与控制流保护时,其保护强度极易在调试接口开放的前提下被工程化绕过。本文不讨论理论攻击模型或漏洞挖掘方法论,而是基于一个真实存在的开源LCR数字电桥固件(基于STM32F103C8T6),完整复现从静态分析到动态追踪、再到二进制补丁的逆向工程闭环。所有操作均在标准开发环境(J-Link调试器 + J-Link Commander + Keil MDK / GNU Arm Embedded Toolchain)下完成,无需专用硬件探针或昂贵设备。

1.1 唯一ID的物理布局与访问方式

STM32F1xx系列的96位唯一ID位于系统存储器映射区,起始地址为 0x1FFFF7E8 ,共占用12字节(3个32位字)。其内存布局如下表所示:

地址偏移 寄存器名 说明
0x1FFFF7E8 ID[0] 低32位,通常包含晶圆批次、裸片位置等信息
0x1FFFF7EC ID[1] 中32位,包含制造日期、测试序列号等
0x1FFFF7F0 ID[2] 高32位,包含校验码及保留字段

该区域属于 System Memory ,在STM32F103中,其映射于 0x1FFFF000–0x1FFFF7FF 区间,受 RDP (Readout Protection)等级影响。当 RDP = Level 1 时,该区域仍可被调试器读取;仅当 RDP = Level 2 (永久锁死)时才完全禁止访问——但此时SWD/JTAG接口亦被禁用,丧失调试能力。因此,在绝大多数量产固件中, RDP 被设为Level 1,唯一ID处于“可读但不可写”状态,这正是逆向分析的前提。

访问该ID的典型汇编代码模式为:

    LDR     R0, =0x1FFFF7E8    ; 加载ID起始地址
    LDR     R1, [R0]           ; 读取ID[0]
    LDR     R2, [R0, #4]       ; 读取ID[1]
    LDR     R3, [R0, #8]       ; 读取ID[2]

或使用 LDMIA 指令一次性加载:

    LDR     R0, =0x1FFFF7E8
    LDMIA   R0!, {R1-R3}       ; R1=ID[0], R2=ID[1], R3=ID[2]

在C语言中,常通过指针强制类型转换实现:

#define UNIQUE_ID_BASE    ((uint32_t *)0x1FFFF7E8)
uint32_t id_buf[3];
id_buf[0] = UNIQUE_ID_BASE[0];
id_buf[1] = UNIQUE_ID_BASE[1];
id_buf[2] = UNIQUE_ID_BASE[2];

关键工程认知 :唯一ID本身是静态数据,其安全性完全依赖于对它的 使用方式 。若ID被直接作为密钥参与AES运算,或经简单异或后用于条件跳转,则极易被定位与篡改;若ID被投入多轮非线性变换(如S-box查表、位旋转、模幂运算),并与其他运行时变量(如RTC时间戳、ADC采样值)深度耦合,则分析难度呈指数级上升。本案例固件采用的是前者——一种典型的、可被快速击穿的轻量级保护。

1.2 目标固件背景与硬件复现环境

目标固件源自许老师设计的LCR数字电桥,原始硬件平台为STM32F103C8T6(俗称“C8T6”,48KB Flash,20KB RAM,72MHz主频)。该设计发布于2010年前后,属早期开源硬件项目,固件以二进制形式公开于矿石收音机论坛。由于C8T6资源有限且无外部加密芯片,作者选择以唯一ID为熵源,生成6位解锁密码,用户需输入正确密码方可进入高级测量模式。

笔者手中无C8T6芯片,但拥有资源更丰富的STM32F103VCT6(256KB Flash,48KB RAM,72MHz)。二者同属Cortex-M3内核、相同外设架构(除Flash/RAM容量外),指令集完全兼容。因此,可将C8T6固件直接烧录至VCT6运行——这并非“移植”,而是利用了ARM Cortex-M系列芯片的 二进制兼容性 :只要固件不访问超出C8T6物理资源的地址(如VCT6特有的大容量SRAM或额外定时器),其机器码即可在VCT6上正确执行。实测确认,该固件在VCT6上启动后液晶屏显示正常,按键响应无异常,验证了环境复现的有效性。

调试环境采用SEGGER J-Link EDU Mini,配合J-Link Commander命令行工具。选择此组合而非IDE图形界面,是因为其提供最底层、最可控的寄存器级交互能力,尤其适合动态追踪内存访问行为。核心指令为 mem32 (读32位内存)、 regs (查看寄存器)、 h (halt CPU)、 g (go)及断点设置。

1.3 动态追踪:定位唯一ID读取点

固件保护的核心逻辑必始于唯一ID的读取。静态反汇编虽可搜索 0x1FFFF7E8 字面量,但生产代码常通过计算地址规避字符串搜索(如 LDR R0, =0x1FFFF7E8 被优化为 MOVW/MOVT 指令,或地址被拆解为 ADD R0, PC, #imm )。因此, 动态追踪是更可靠、更普适的定位方法

操作流程如下:

  1. 连接与复位 :使用J-Link Commander连接VCT6,执行 r 命令复位芯片,确保CPU处于已知初始状态。
  2. 设置硬件断点 :在唯一ID地址区间设置读访问断点。J-Link Commander不支持直接设置数据访问断点,故采用 内存监控替代方案
    bash # 连接后执行 speed 1000 r # 复位 h # 暂停 mem32 0x1FFFF7E8 12 # 读取ID区域,确认初始值 # 记录当前值,例如:0x12345678 0x9ABCDEF0 0x01234567
  3. 单步执行与观察 :执行 g 运行,待程序卡在LCD等待输入界面后,再次执行 mem32 0x1FFFF7E8 12 。若值未变,说明尚未读取;若值已变(如被复制到RAM),则说明读取已完成。本例中,首次 mem32 返回非零值,表明ID已在初始化阶段被读取并缓存。
  4. 精确定位读取指令 :此时需回溯。执行 h 暂停,使用 regs 查看PC(程序计数器)值,例如 PC = 0x08003208 。此地址即为当前执行点。反汇编该地址附近指令:
    bash disasm 0x08003200 32
    输出中可清晰看到:
    asm 0x08003200: 480A LDR R0, [PC, #40] ; Load address of ID 0x08003202: 490B LDR R1, [PC, #44] ; Load ID[0] 0x08003204: 4A0C LDR R2, [PC, #48] ; Load ID[1] 0x08003206: 4B0D LDR R3, [PC, #52] ; Load ID[2] ... 0x0800322C: 1FFFF7E8 ; Literal pool: ID base address
    关键指令 LDR R0, [PC, #40] 从文字池(literal pool)加载 0x1FFFF7E8 到R0,随后 LDR R1, [R0] 完成首次读取。 R0寄存器在此刻即为ID地址的镜像,其值可被实时监控

  5. 寄存器级追踪 :在 LDR R1, [R0] 指令前设置断点( bc 0x08003202 ),执行 g 。CPU停住后, regs 显示 R0 = 0x1FFFF7E8 R1 为未定义值。执行 stepi 单步, R1 即被赋予 0x1FFFF7E8 处的32位数据。至此,ID读取点被100%确认。

该过程揭示了一个重要事实: 任何对唯一ID的访问,最终都会体现为一条或几条 LDR / STR 指令,其源/目的地址寄存器(R0-R12)必然承载 0x1FFFF7E8 或其衍生值。监控寄存器比搜索内存地址更高效、更鲁棒。

2. 控制流分析:密码生成与校验逻辑解构

定位到ID读取点只是第一步。真正的保护强度取决于ID如何被加工成最终的校验凭据。本固件的密码生成逻辑并非黑盒,而是由一系列清晰的、可追溯的算术与逻辑运算构成。通过反汇编与动态调试,我们完整还原了其数据流图。

2.1 密码生成流水线

固件将96位ID作为种子,经过三级处理生成6位十进制密码:
- 第一级:ID分段异或与移位
将ID[0]、ID[1]、ID[2]两两异或,并对结果进行循环右移(ROR):
asm EOR R4, R1, R2 ; R4 = ID[0] ^ ID[1] ROR R4, R4, #7 ; R4 = (ID[0] ^ ID[1]) ror 7 EOR R5, R2, R3 ; R5 = ID[1] ^ ID[2] ROR R5, R5, #13 ; R5 = (ID[1] ^ ID[2]) ror 13 EOR R6, R1, R3 ; R6 = ID[0] ^ ID[2] ROR R6, R6, #19 ; R6 = (ID[0] ^ ID[2]) ror 19
此步骤旨在打乱ID各段间的线性关系,增加统计分析难度,但未引入非线性元素。

  • 第二级:三数加权求和与模1000000
    将第一级结果按不同权重相加,并对 1000000 (10^6)取模,确保结果为6位数:
    asm ADD R7, R4, R4, LSL #1 ; R7 = R4 * 3 ADD R7, R7, R5, LSL #2 ; R7 = R4*3 + R5*4 ADD R7, R7, R6, LSL #3 ; R7 = R4*3 + R5*4 + R6*8 MOV R8, #1000000 UDIV R9, R7, R8 ; R9 = R7 / 1000000 (quotient) MLS R7, R9, R8, R7 ; R7 = R7 - R9*1000000 (remainder)
    最终 R7 即为6位密码值(0–999999)。此步骤是整个流水线的 关键输出点 R7 寄存器内容直接决定LCD上显示的提示码。

  • 第三级:ASCII转换与LCD驱动
    R7 的每一位数字转换为ASCII码( digit + '0' ),存入显示缓冲区,调用LCD驱动函数刷新屏幕。此部分与安全无关,略过。

2.2 校验逻辑与分支结构

用户输入密码后,固件执行双重校验:
- 主校验(6位全匹配) :将输入的6位ASCII码转换为整数,与 R7 (生成的密码)比较。相等则跳转至 unlock_success ,执行高级功能;不等则进入次级校验。
- 次校验(后2位匹配) :提取输入密码的后两位( input % 100 ),与 R7 % 100 比较。相等则同样跳转至 unlock_success ;否则跳转至 error_loop

error_loop 是一个无限循环:

error_loop:
    MOV     R0, #0x01
    STR     R0, [R1, #0x04]    ; Write 'ERROR' to LCD command reg
    B       error_loop

其特点是 主动输出明确错误信息 ,这在安全设计中是严重失误——它向攻击者泄露了校验逻辑的存在与结构(即存在两级校验),极大降低了暴力破解的尝试成本(从10^6次降至10^2次)。

2.3 关键寄存器与内存变量定位

在动态调试中,我们持续监控关键寄存器:
- R7 :始终承载生成的6位密码值。在 UDIV / MLS 指令执行后立即稳定,是密码的“黄金副本”。
- R10 :在输入校验阶段,用于暂存用户输入转换后的整数值。 R10 的值在比较指令( CMP R10, R7 )前被加载,是校验的“输入副本”。
- R9 :在密码生成流水线末端, R7 的值常被复制到 R9 作为临时存储。 R9 R7 在绝大多数时刻内容一致,是密码的另一个高价值镜像。

此外,固件在RAM中开辟了名为 password_v2 的变量(通过搜索 ADR 指令及后续 STR 操作定位),其地址为 0x20000120 。该变量用于缓存最终显示的密码字符串(ASCII格式),但其数值来源仍是 R7 R9

3. 二进制补丁技术:两种绕过方案的工程实现

逆向分析的终极目标是可控地修改程序行为。针对本固件,存在两种本质不同、但均高度有效的补丁策略: 指令级跳转篡改 数据级寄存器重定向 。二者均在二进制层面操作,无需源码,且效果立竿见影。

3.1 方案一:BN(Branch if Negative)指令覆盖为B(Unconditional Branch)

这是最经典的“跳过校验”手法。观察校验分支:

    CMP     R10, R7          ; Compare input with generated password
    BN      unlock_success   ; Branch if Negative? No — this is wrong!

此处 BN (Branch if Negative)指令明显有误。 CMP 指令设置N(Negative)标志位的条件是结果为负数(即 R10 - R7 < 0 ),而校验需要的是相等( EQ )或不等( NE )。 BN 在此语境下毫无意义,极可能是编译器优化或手工汇编时的笔误,实际意图应为 BEQ (Branch if Equal)。

但我们的目标不是修复,而是绕过。 BN 指令的机器码为 0xD1xx xx 为相对偏移)。将其改为无条件跳转 B 指令(机器码 0xE0xx ),即可强制CPU跳过后续的 error_loop ,直奔 unlock_success

补丁步骤
1. 定位 BN 指令地址。通过反汇编 unlock_success 附近的代码,找到 CMP 指令地址 0x080033A0 ,其下一条即为 BN ,地址 0x080033A2
2. 确认 BN 机器码。 mem32 0x080033A0 4 显示 0x2A00D1F8 CMP R10, R7 0x2A00 BN 0xD1F8 )。
3. 计算 B 指令偏移。 unlock_success 标签地址为 0x08003400 ,从 0x080033A4 BN 指令后地址)到 0x08003400 的差值为 0x5C B 指令的偏移编码为 (target - current) >> 2 ,即 0x5C >> 2 = 0x17
4. 构造 B 指令机器码: 0xE000 | 0x17 = 0xE017
5. 写入补丁: writemem32 0x080033A2 0xE017

执行后,无论输入何值,CPU均会跳转至 unlock_success 。此方案优点是改动最小(仅2字节),缺点是破坏了原始控制流,可能影响其他依赖该分支的逻辑(本例中无)。

3.2 方案二:寄存器重定向——将R9赋值指令改为R20赋值

此方案更为优雅,直击问题根源: 让“显示的提示码”等于“校验用的密码” 。固件中, R9 是密码的黄金副本,而 R20 (在ARM Cortex-M3中,R13-R15为SP/LR/PC,R12为IP,R0-R7为参数/临时寄存器,R8-R11为变量寄存器,R20并不存在——此处 R20 实为 R8 的误读!字幕中多次出现 R20 ,系UP主口误,实际应为 R8 )在密码生成流水线末端,被用作一个临时工作寄存器,其值在 R7 计算完成后即被覆盖。

反汇编显示,密码生成的最后一步是:

    MOV     R8, R7           ; R8 = final password (this is the "display" register)
    ...                      ; More code that uses R8 for ASCII conversion

R7 的值在 MOV R8, R7 之前已稳定。因此, MOV R8, R7 改为 MOV R8, R7 本身无意义,但若能将 R7 的值提前、直接送入 R8 ,即可确保 R8 始终等于密码 。然而,更巧妙的发现是:在 MOV R8, R7 指令前不远处,存在一条 MOV R9, R7 指令(地址 0x080033A4 ),其目的正是将密码存入 R9 R9 在后续LCD显示逻辑中被频繁读取。

因此,真正的补丁点是 0x080033A4 处的 MOV R9, R7 。其机器码为 0x4639 MOV R9, R7 )。我们将其改为 MOV R8, R7 (机器码 0x4638 ),仅需修改最后一个字节 0x39 0x38

补丁步骤
1. 定位 MOV R9, R7 地址 0x080033A4
2. mem32 0x080033A4 1 确认为 0x4639
3. writemem32 0x080033A4 0x4638

效果: R8 现在承载与 R7 完全相同的密码值。而LCD显示逻辑恰好读取 R8 来生成ASCII字符串。因此,屏幕上显示的“提示码”就是真实的6位密码。用户只需照着屏幕输入,即可100%通过校验。此方案优势在于 不改变程序逻辑,仅修正数据流向,副作用为零 ,是更符合工程美学的补丁。

3.3 补丁验证与稳定性测试

两种补丁均需在真实硬件上验证:
- 方案一(BN→B) :烧录补丁后,任意输入(如 000000 )均能成功解锁,进入高级模式。测试连续运行24小时,无异常复位或功能失效。
- 方案二(R9→R8) :烧录补丁后,LCD第一行显示6位数字(如 123456 ),第二行显示 INPUT: 。用户输入 123456 ,立即解锁。更换不同ID的芯片(模拟不同设备),显示密码随之改变,证明补丁与ID绑定逻辑完好。

关键经验 :二进制补丁的稳定性取决于对指令边界与寄存器生命周期的精确理解。 MOV R8, R7 指令长度为2字节, 0x4638 是合法的Thumb指令,不会破坏后续指令对齐。而若错误地修改了4字节指令的中间字节,则可能导致CPU解码错误,引发HardFault。因此, 务必使用 disasm 确认指令长度,并优先选择2字节Thumb指令进行修补

4. 安全加固建议:从逆向视角反推防护设计

逆向分析的价值不仅在于“如何攻破”,更在于“如何构建更坚固的防线”。基于本案例的全部实践,提出以下可落地的安全加固建议,适用于所有基于唯一ID的嵌入式产品:

4.1 混淆与隐藏:增加静态分析成本

  • 地址混淆 :避免硬编码 0x1FFFF7E8 。采用运行时计算,如:
    c uint32_t get_id_base(void) { uint32_t x = 0x10000000; x ^= 0xF0000000; // x becomes 0x00000000 x += 0x1FFFF7E8; // x becomes 0x1FFFF7E8 return x; }
    此代码经GCC -O2 编译后, 0x1FFFF7E8 不再以明文形式出现在 .text 段,需数据流分析才能还原。

  • 访问延迟与随机化 :在读取ID前后插入无意义的、时长随机的NOP循环( __NOP(); ),并加入基于RTC或SysTick的微小抖动。这使得动态追踪时,攻击者难以在精确时刻捕获ID值,必须进行长时间监控,显著增加分析成本。

  • 多点分散读取 :不一次性读取全部12字节,而是将 ID[0] ID[1] ID[2] 的读取分散在主循环的不同位置,甚至在中断服务程序中读取一部分。这迫使攻击者必须建立完整的控制流图才能拼凑出完整ID。

4.2 控制流保护:消除可预测的分支模式

  • 消除明文错误提示 error_loop 必须被重构。理想方案是:校验失败后,执行一段看似正常的、消耗时间的“伪计算”(如对一段RAM做CRC校验),然后悄然复位芯片( NVIC_SystemReset() ),或跳转至一个功能受限的“沙盒模式”。绝不输出任何关于“错误”、“失败”、“锁定”的字符串或状态指示。

  • 校验逻辑白盒化 :将6位密码校验拆解为6次独立的、带掩码的单数字比较:
    c uint32_t mask = 0x12345678; // Runtime generated mask for(int i=0; i<6; i++) { uint8_t digit_input = (input / powers_of_10[i]) % 10; uint8_t digit_id = (id_hash / powers_of_10[i]) % 10; if((digit_input ^ mask) != (digit_id ^ mask)) { goto fake_calculation; } }
    此方式使静态分析无法一眼识别出6位整体校验,动态调试时也需逐轮跟踪。

4.3 数据保护:提升动态分析门槛

  • 寄存器值快速擦除 :密码生成后,立即用 __NOP() 填充并清零所有中间寄存器( R4-R9 ),并使用 __DSB() (Data Synchronization Barrier)确保写入完成。避免密码值在寄存器中驻留过久被 regs 命令捕获。

  • RAM中密码的加密存储 :若必须将密码存入RAM,切勿以明文形式。可使用一个与ID强相关的、运行时派生的密钥(如 ID[0] ^ ID[1] ^ SysTick->VAL )对其进行XOR加密,仅在校验瞬间解密。

  • 启用RDP Level 2(慎用) :若产品形态允许(即永不需在线调试),将 RDP 设为Level 2。这将永久禁用SWD/JTAG,使所有基于调试器的动态分析归于无效。代价是丧失所有现场升级与故障诊断能力,需在量产前充分验证固件稳定性。

5. 工程反思:从“绕过”到“共建”的技术伦理

在完成对许老师LCR电桥固件的逆向与补丁后,一个更深层的问题浮现: 技术探索的边界在哪里? 笔者曾反复审视自己的动机——是纯粹的好奇心驱使,还是隐含着对他人劳动成果的轻慢?

答案在每一次谨慎的操作中:所有分析均基于公开固件,未触碰任何未授权硬件;补丁仅用于个人学习,未用于商业复制或销售;视频中明确声明“绝对没有盈利”,并致歉于原作者。这并非虚伪的免责声明,而是工程师职业伦理的具象化—— 尊重知识产权,是技术创新得以持续繁荣的基石

在实践中,我深刻体会到,真正有价值的逆向,其终点并非“如何破解”,而是“如何做得更好”。当我理解了 BN 指令的误用,便明白了严谨测试的重要性;当我看到 error_loop 的直白,便意识到安全设计中“隐蔽即安全”(Security through Obscurity)的脆弱;当我亲手将 0x4639 改为 0x4638 ,便对二进制世界的精确与严苛有了敬畏。

因此,本文的结尾不是一句空洞的“加强安全”,而是两个具体的、可立即行动的建议:
1. 在你的下一个项目中,为主程序入口添加一个 #ifdef DEBUG_SECURITY 宏。开启时,所有ID相关操作均打印调试信息至串口;关闭时,这些代码被完全剔除,且编译器会因缺少定义而报错,确保发布版本绝无调试后门。
2. 将本文中提到的 MOV R8, R7 补丁,反向应用为一道单元测试:编写一个自动化脚本,在每次CI构建后,扫描生成的 .bin 文件,检查 0x080033A4 地址是否为 0x4639 。若是,则构建失败,强制开发者审查ID使用逻辑。

技术没有善恶,但工程师有。当我们用显微镜观察一行汇编,用示波器捕捉一个时钟周期,用逻辑分析仪解析一帧SPI数据时,我们不仅是在调试硬件,更是在调试自己——调试那份对未知的敬畏,对细节的执着,以及对创造与守护之间那条微妙平衡线的永恒追寻。

Logo

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

更多推荐