ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MCU UID正确玩法:从指纹读取到防抄板与MQTT一机一密

MCU UID正确玩法:从指纹读取到防抄板与MQTT一机一密 UID这玩意儿做嵌入式的应该都不陌生。芯片出厂自带一段唯一标识写死在寄存器或者eFuse里理论上每一颗都不同。很多项目拿它来做设备编号、防抄板、设备鉴权方向都没问题但我在调试过程中见过太多把UID当字符串用的写法——要么字符串函数把0x00截断了要么字节序对不上导致上位机和下位机算出来的“指纹”永远不一致要么直接把明文UID塞进MQTT的username里抓一次包就能仿冒。这篇就把MCU“指纹”的正确玩法拆开讲清楚从UID读取到防抄板逻辑再到MQTT一机一密的落地实现一次说透。先说适用范围用过STM32、GD32、ESP32这类主流MCU想在产品上做唯一标识、防抄板或者设备安全接入的开发者和嵌入式爱好者都可以参考。内容不绑定具体厂商但示例代码以ST系列为模板思路可以平移。1. 先认清UID的本质它不是字符串是二进制“指纹”1.1 大多数MCU的UID到底长什么样拿STM32举例它内部有一块出厂烧录的96位唯一标识也就是12个字节分布在3个32位寄存器里。地址因芯片系列略有差异F1系列在0x1FFFF7E8F4系列在0x1FFF7A10有些新系列还能通过HAL库接口直接读。ESP32则没有传统意义的UID概念常用的是eFuse里烧录的MAC地址作用类似。GD32、NXP的部分系列也都有类似设计。关键点是这12个字节是二进制原始数据不是ASCII码。比如你在调试器里看到UID是0x00112233 0x44556677 0x8899AABB它对应的内存字节序列可能是33 22 11 00 77 66 55 44 BB AA 99 88小端模式也可能因为芯片厂的设计是顺序排列。你无法用常识去“猜”必须按照芯片手册确认。这12个字节是芯片的天然“指纹”防抄板靠它做绑定校验一机一密靠它做密钥派生。但正因为它是二进制不是人能直接读的字符串所以第一步就必须建立正确的代码姿势。1.2 把UID当字符串处理的常见坑这里我直接列出我在实际项目中踩过、以及在别人代码里见过的典型问题strlen截断。UID原始数据里极有可能出现0x00字节。你把它丢进char uid[13]再调用strlen函数会在第一个0x00处停下来后面全丢。这算是最经典的坑。字节序混乱。拿STM32的96位UID很多人在代码里把3个32位寄存器分别读出拼成一个十六进制字符串。同一颗芯片在小端模式下手动拼接的字符串和通过内存字节顺序拼出来的字符串可能顺序完全相反。于是出现“下位机算出来的ID和上位机算出来的ID对不上”这种抓狂问题。二进制数据直接进协议。如果直接把UID字节作为字符串拼进MQTT报文、JSON字段、Modbus寄存器遇到0x00、0x0A、0x0D这类不可见字符轻则协议解析出错重则引发缓冲区溢出、状态机错乱。很多“串口数据明明发过去了但就是不对”的问题源头就在这。大小端跨平台不一致。同一套代码STM32小端、某些无线SoC大端生成的UID字符串完全不同。等产品量产后再发现这种问题改固件容易改服务器和存量设备就痛苦了。1.3 防抄板和一机一密需要的不是UID而是指纹明白UID本质后再往下想一层防抄板和一机一密核心需要的并不是“UID本身”而是从UID派生出来的指纹。这个指纹要满足两个要求确定性同一颗芯片任何时候计算结果都一致否则没法做校验。不可逆行不能让别人通过通信报文反推出你的UID。不可伪造性攻击者哪怕抓到了设备发出的指纹也不能轻松批量复制到别的芯片上。基于这三点裸用UID是不合格的。裸UID是唯一且确定但它不具备不可逆和不可伪造抓包就能复制。所以我们要做的是先把UID做一次“加工”生成一个指纹再交给业务逻辑使用。2. 把UID变成真正的MCU“指纹”2.1 原始字节的读取姿势不同平台读取方式不同。这里给一个STM32的标准示例直接操作寄存器地址不依赖HAL版本任何环境都能跑#include stdint.h void read_mcu_uid(uint8_t uid[12]) { // 以STM32F1系列为例0x1FFFF7E8是UID基地址 // 实际地址请按芯片参考手册确认 uint32_t *p (uint32_t *)0x1FFFF7E8; uint32_t uid0 p[0]; uint32_t uid1 p[1]; uint32_t uid2 p[2]; // 按小端模式拆成字节保证在任意平台上结果一致 uid[0] (uint8_t)(uid0 0xFF); uid[1] (uint8_t)((uid0 8) 0xFF); uid[2] (uint8_t)((uid0 16) 0xFF); uid[3] (uint8_t)((uid0 24) 0xFF); uid[4] (uint8_t)(uid1 0xFF); uid[5] (uint8_t)((uid1 8) 0xFF); uid[6] (uint8_t)((uid1 16) 0xFF); uid[7] (uint8_t)((uid1 24) 0xFF); uid[8] (uint8_t)(uid2 0xFF); uid[9] (uint8_t)((uid2 8) 0xFF); uid[10] (uint8_t)((uid2 16) 0xFF); uid[11] (uint8_t)((uid2 24) 0xFF); }这段代码的关键点是显式拆成字节而不是直接memcpy三个寄存器内容也不是用指针强转成字符串。这样任何MCU平台、任何编译器读出来的12字节顺序是一致的这是后续所有指纹计算的基础。如果你用的是ESP32常见做法是读取eFuse MAC#include esp_system.h uint8_t mac[6]; esp_efuse_mac_get_default(mac); // 读取MAC作为设备唯一ID有的项目还会取MAC的后几个字节当序列号但完整MAC也常见。注意MAC是6字节UID通常12字节做派生算法时填充方式略有不同要在这个基础上统一处理。2.2 编码与归一化把二进制转成可用形式拿到12字节原始数据后最常用的归一化方式就是转十六进制字符串。这时有两个要点不要用char[]直接当字符串操作始终记录长度十六进制字符串字符范围在0-9A-F协议传输、日志打印、MQTT消息里都安全不会出现不可见字符。一个简单可靠的转换函数void uid_to_hex_string(const uint8_t uid[12], char out[25]) { static const char hex[] 0123456789ABCDEF; int idx 0; for (int i 0; i 12; i) { out[idx] hex[(uid[i] 4) 0x0F]; out[idx] hex[uid[i] 0x0F]; } out[idx] \0; }输出就是一个24字符的十六进制字符串比如A1B2C3D4E5F6A7B8C9D0E1F2。这个字符串才是你可以在日志、上位机、云端数据库里安全使用的“设备标识”。调试和生产过程中我还建议打印一份UID对照表。下线测试时把每台样机的UID打印出来贴在产品外壳内部将来做RMA、故障排查时能快速定位。2.3 指纹派生裸UID不能直接当密钥用接下来是关键一步。无论做防抄板还是MQTT一机一密都不建议直接把UID字符串发到通信链路上。原因前面说了抓包就能复制。一个实用的做法是对UID做哈希或HMAC派生。MCU资源有限不一定都支持SHA256我根据资源量给三种方案资源紧张几十KB Flash无硬件加速用CRC32或者FNV1a做轻量散列把12字节UID映射成一个32位或64位指纹。强度有限但至少不是明文传输。资源中等主流ARM Cortex-M3/M4软件实现SHA256把UID字符串或原始字节作为输入输出32字节摘要。这个强度已经足够支撑防抄板和MQTT一机一密。资源充足ESP32、带加密引擎的芯片直接用硬件SHA/HMAC还可以把UID和预置密钥组合做HMAC-SHA256生成一个基于密钥种子的设备指纹。我常用的中等方案示例用mbedtls做SHA256STM32 CubeMX自带的中间件一般都有#include mbedtls/sha256.h void derive_fingerprint(const uint8_t uid[12], uint8_t fp[32]) { mbedtls_sha256_context ctx; mbedtls_sha256_init(ctx); mbedtls_sha256_starts(ctx, 0); // 可以混入产品标识、版本号增加指纹语义 const char product_tag[] my_iot_product_v1; mbedtls_sha256_update(ctx, (const uint8_t *)product_tag, sizeof(product_tag) - 1); mbedtls_sha256_update(ctx, uid, 12); mbedtls_sha256_finish(ctx, fp); mbedtls_sha256_free(ctx); }到这一步你已经能从UID派生出一个32字节的“指纹”。接下来防抄板和一机一密就可以在这个指纹基础上做文章了。3. 用指纹做防抄板从固件绑定到运行校验3.1 防抄板的基本逻辑防抄板思路很简单固件在运行时检查当前芯片的UID指纹和自己期望的指纹做比对。如果匹配正常执行不匹配拒绝执行或者进入降级模式。听起来简单但实现细节决定成败。我做过的防抄板方案一般分三层烧录期绑定生产时读取芯片UID指纹连同授权码一起写入Flash的OTP区域或者写到服务器数据库。启动期校验固件启动早期读取UID计算指纹和预设值比对。运行期校验主循环或关键函数里周期性校验防止别人用调试器绕过启动校验。这里要特别提醒启动期校验单独使用几乎无效。因为攻击者可以反汇编找到校验函数直接patch掉跳转指令让“校验不通过”的分支永不执行。所以要在运行中途做几次非固定的校验并且把校验结果间接影响业务逻辑而不仅仅是弹出一个错误码。3.2 一种可落地的绑定实现我在量产项目里用过的方案是这样的上位机在烧录固件后读UID再通过自定义烧录协议把授权码写入OTP。固件里预置一把公钥或密钥种子运行时把UID指纹和OTP里的授权码做关联校验。具体流程烧录器或产测软件读取12字节UID计算指纹SHA256生成授权码可以用私钥对指纹签名也可以直接把指纹截断后异或一个厂商密钥生产工具里保存这个授权码将授权码通过串口/SPI写入OTP区域固件第一次启动时读UID OTP授权码重新计算关联关系匹配则标记“已授权”。如果你的产品有联网能力更稳的做法是改为云端校验。设备上报UID指纹云端比对数据库返回是否合法。这样攻击者要破解就不只是静态改固件还得伪造服务器响应。3.3 防抄板方案的边界哪些情况防不住防抄板不是绝对安全这里劝大家降低预期。我做的防抄板项目里挡得住80%的情况但下面这几种是真的难专业逆向团队。CAN总线、JTAG、逻辑分析仪一上只要你固件里有校验逻辑总能被慢慢抠出来。唯一能延缓的就是多点校验、混淆代码、使用安全启动等。芯片批量复制。有些国产MCU的UID区域被山寨厂直接复制同一批晶圆或者翻新片UID会重复或可伪造。所以防抄板只适用于“常规抄板”场景不适合对抗芯片级别造假。固件提取后重打包。如果攻击者能从Flash读走全部固件再把补丁好的固件烧到新芯片上你的防抄板逻辑就完全失效。这种情形需要硬件级安全SecureBoot、读保护、加密Flash配合。所以我的建议是把防抄板当成“延迟被抄的时间”而不是“杜绝被抄”。在商业上这个时间窗口通常已经足够让产品建立市场壁垒。4. MQTT一机一密从UID到动态设备身份4.1 一机一密解决的核心问题MQTT设备接入IoT平台时最基础的身份认证就是username/password或者clientId。很多项目偷懒所有设备共用一套账号密码这等于大门钥匙配了一把万能钥匙泄露一个设备就全军覆没。一机一密的意思是每台设备一个独立身份凭据。这样单台设备被攻破不会波及其他设备云端后台也能精确追踪每一台设备的行为。UID在这里的价值是作为“种子”让每台设备都能量化出不同的凭据。但要注意一机一密不等于“把UID当密码”。UID是明文可读、静态不变的如果你直接拿UID字符串当密码抓包一次就永久暴露。我们要的是把UID和密钥种子一起派生生成动态或半动态的设备凭据。4.2 设备端凭据生成UID 产品密钥种子我推荐的方案是HMAC-SHA256(UID原始字节, 产品密钥种子)将生成的32字节摘要转成十六进制字符串作为MQTT的password。产品密钥种子是写死在固件里的一段随机数据也可以存OTP区。能不能被逆向提取能。但提取成本比直接抓包高得多而且即使提取了攻击者也只能仿冒已经见过的UID不能凭空生成新设备的凭据。示例代码基于上一节的derive_fingerprint扩展#include mbedtls/md.h void generate_mqtt_password(const uint8_t uid[12], char *out_pass, size_t out_len) { const char *product_key a_very_random_secret_2024; uint8_t hmac[32]; size_t olen 0; mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), (const uint8_t *)product_key, strlen(product_key), uid, 12, hmac, olen); // 转hex字符串输出65字节含结尾0 static const char hex[] 0123456789ABCDEF; size_t i; for (i 0; i 32 i * 2 1 out_len; i) { out_pass[i * 2] hex[(hmac[i] 4) 0x0F]; out_pass[i * 2 1] hex[hmac[i] 0x0F]; } out_pass[i * 2] \0; }MQTT连接时// username用设备序列号或UID的十六进制串 // password用HMAC派生的动态凭据 mqtt_client-username device_serial; // 例如ST-32-00112233... mqtt_client-password mqtt_password; // 64字节hex字符串注意clientId最好也唯一化常见做法是产品名/芯片型号/UID十六进制串这样服务器端能从clientId直接定位设备型号和身份。但是不要放密钥相关的东西进去。4.3 云端校验动态密码表与挑战机制设备侧有了动态密码云端侧怎么验两种常用思路离线同步云端预存一个device - password映射表生产阶段由产测软件把UID和派生密码一起导入数据库。设备连接时MQTT Broker直接用静态密码校验。实现简单但设备密码是固定的一旦泄露可长期仿冒。在线挑战设备先用clientId连接一个临时channel向云平台请求一个随机挑战值nonce。设备用HMAC(UID, nonce)计算应答云端再用相同算法验签。验证成功后云端下发真正的MQTT凭据或者直接放行。这种方式能实现每次会话密码都不同安全性提高一个量级。在线挑战的代价是协议复杂度增加因而不适合所有场景。如果只是追求基本的“每设备不同密码”离线同步就够了。如果做的是高端设备或者金融级业务还是建议上挑战机制。这里给一个折中建议先用离线同步跑量产后续通过OTA升级加入挑战机制两者共用一套UID指纹派生逻辑底层不用改。4.4 一机一密的本地存储要点很多工程忽略一个细节动态密码生成后不要把它明文写死在Flash里。你每次开机用UID现场计算一次就好性能开销在毫秒级别完全可以接受。但如果你把它缓存到文件系统或者外部Flash反而增加了暴露面。如果设备需要离线认证或快速重连可以考虑把指纹摘要存在加密分区。但大多数场景下每次启动时重新计算HMAC是更好的选择。另外还要考虑一点UID不能变但密码可以换。如果产品密钥种子泄露你可以在云端更新种子并通过OTA下发新种子给存量设备。UID保持不变但HMAC输入变了所有设备密码都会随之更新。这就是一机一密体系相对固定密码体系的优势——密钥可轮换、可撤销。5. 常见问题排查与避坑清单5.1 常见问题速查表现象可能原因排查/解决两次读取UID结果不一致访问地址错误、中断中读取被干扰核对芯片手册外设地址读取时关中断或加锁UID字符串长度不对用了strlen截断二进制数据改用固定长度数组hex编码永远不要依赖strlen上位机和下位机指纹不一致字节序、大小端处理不统一统一先拆成字节数组再参与哈希/CRC计算MQTT密码抓包后能直接复现直接用明文UID当密码改用HMAC派生加入产品密钥种子防抄板校验被跳过校验逻辑太集中、条件分支太明显多点校验、影响业务逻辑而非仅输出错误码服务器端无法定位设备clientId不唯一生成clientId时包含UID十六进制串或设备序列号同一芯片密码永远相同怕泄露一机一密静态密码升级为挑战-应答机制或动态口令5.2 关于UID安全性的几句大实话第一UID是公开可读的。只要设备落在别人手里用读取器十几分钟就能拿到UID。所以任何基于UID的安全方案都要假设攻击者知道UID。第二真正的安全边界在密钥种子和算法。密钥种子要分散存储、避免多产品复用算法尽量选择标准库mbedtls、tinycrypt而不是自己发明异或和翻转。第三不要把鸡蛋放在一个篮子里。防抄板、一机一密只是产品安全体系中的一环还有固件保护、通信加密、云端风控等。借助UID做指纹坚定了“一设备一身份”的底座但它不是万能钥匙。5.3 我在实际项目里的几条心得读UID和派生指纹的代码我会建议单独建一个device_identity.c模块固件里所有需要身份鉴定的地方都引它不要在十个文件里各写一份UID读取逻辑。这样目代码维护方便也方便将来升级算法时只改一处。量产阶段我用过一个很笨但实用的办法在产测工装里把每台设备的UID指纹打印成二维码贴在产品包装盒上。售后时扫描二维码就能核对设备身份不需要拆机查芯片省了不少RMA时间。有一次现场设备批量上报异常排查半天发现是服务器数据库里存的UID是十六进制大写设备端生成的是小写导致校验失败。所以字母大小写的统一建议从一开始就规范成大写设备端、产测工具、数据库、后台代码全用大写能省去很多无谓的沟通成本。最后再分享一个后续扩展方向有了UID指纹作为设备唯一身份你还可以在此基础上做设备证书、OTA包加密、云端设备黑名单等能力。指纹体系是底层地基往上可以叠加的安全能力非常多。但第一步请先把UID当作二进制数据处理别再用字符串函数“欺负”它了。
返回列表