敏感数据存储:不要明文写密钥在代码里,利用芯片唯一 ID(UID)派生加密密钥的轻量级方案

发布时间:2026/10/26 1:48:31
敏感数据存储:不要明文写密钥在代码里,利用芯片唯一 ID(UID)派生加密密钥的轻量级方案 摘要固件里的密钥、证书、校准参数、通信密码——这些敏感数据如果明文硬编码攻击者只需要一个strings命令就能全部提取。但加密存储又面临一个鸡生蛋的问题加密密钥本身存哪里本文系统拆解嵌入式系统中敏感数据存储的四种方案——明文硬编码最差、静态加密、UID 派生密钥推荐、安全芯片最强。重点介绍基于芯片唯一 IDUID派生密钥的轻量级方案它能在不加硬件成本的前提下让每个设备的密钥都独一无二攻击者即使读出固件也无法解密其他设备。包含完整可移植代码、实际攻击演示和性能对比。建议收藏。一、引子一次strings命令引发的安全事故去年做一款智能门锁主控 STM32L4。产品交付半年后客户安全团队发现了一个严重漏洞攻击者只需用 J-Link 读出 Flash然后用strings命令提取所有可打印字符串就能拿到服务器通信密钥。攻击过程第一步拆开智能门锁找到 SWD 焊盘虽然做了保护但没有启用 RDP Level 2。第二步用 J-Link 读出 512KB 固件。第三步运行命令strings firmware.bin | grep -i key\|password\|token | head -20输出SERVER_API_KEYsk-1234567890abcdef MQTT_PASSWORDSmartLock2024 AES_KEY0123456789abcdef0123456789abcdef所有密钥一网打尽。后果攻击者可以伪装成任何一台门锁与服务器通信可以下发开锁指令给任意门锁可以获取所有用户的开锁记录客户的处境已经出货 5 万台全部需要 OTA 更新固件但 OTA 密钥也泄露了无法安全推送更新只能召回全部设备损失数百万这个事故的根本原因密钥明文硬编码在固件里。本文完整还原我们如何重构密钥管理体系以及最终采用的 UID 派生密钥方案。二、敏感数据存储的四种方案在展开之前先看四种方案的对比2.1 方案一明文硬编码绝不应该// ❌ 这是犯罪 const char *api_key sk-1234567890abcdef; const char *mqtt_password SmartLock2024;为什么不能这样strings命令一秒提取反汇编工具能看到字符串引用所有设备共用同一份密钥一台泄露全部泄露密钥轮换困难需要重新烧录所有设备这种做法的唯一优点是简单。但没有任何理由这样做。2.2 方案二静态加密略好思路用固定的密钥加密敏感数据运行时解密。static const uint8_t s_enc_key[16] { 0xAA, 0x55, ... }; // 加密密钥 static const uint8_t s_enc_api_key[32] { 0x3A, 0x7F, ... }; // 加密的 API key void GetApiKey(char *out) { AES_Decrypt(s_enc_key, s_enc_api_key, 32, out); }攻击者视角strings提不出明文但加密密钥s_enc_key也是硬编码的攻击者读出固件后找到解密函数直接调用就能拿到明文结论静态加密只是增加了攻击者 5 分钟的逆向时间。没有本质提升。唯一比明文好的场景防止非技术人员通过strings快速提取。对专业攻击者无效。2.3 方案三UID 派生密钥推荐核心思路利用每颗 MCU 出厂时唯一的 IDUID派生出该设备专属的加密密钥。UID 的特性每颗芯片有唯一的 96 位12 字节ID出厂时烧录不可修改通过特定寄存器地址读取攻击者即使读出固件换到另一颗芯片上无法解密STM32 的 UID 地址方案原理密钥 K KDF(UID || 固定盐值)KDFKey Derivation Function密钥派生函数如 HKDF、PBKDF2固定盐值编译时嵌入的常量可视为半公开输出每颗设备不同的密钥 K关键优势每台设备密钥不同一台泄露不影响其他设备零硬件成本UID 是 MCU 自带的抗固件提取即使读出固件换芯片后密钥不匹配解密失败无需出厂烧录密钥UID 天然存在局限性不抗物理攻击如果攻击者能在原芯片上运行代码可以读取 UID 派生密钥需要防范故障注入攻击者可能试图修改 UID 读取逻辑UID 读取过程可能被旁路需要配合其他保护措施2.4 方案四安全芯片最强思路用专用安全芯片如 ATECC608A、SE050存储密钥通过 I2C/SPI 通信。安全芯片的特性密钥永不离开芯片加密/解密/签名在芯片内部完成抗物理攻击内部有屏蔽层、传感器、主动自毁通过 EAL4 认证经过严格的安全评估支持安全启动配合典型用法// 通过 I2C 与 ATECC608A 通信 uint8_t signature[64]; ATECC608A_Sign(signature, hash); // 密钥在芯片内不会输出成本每片 $0.5-2。对消费级产品偏贵对工业/汽车级产品合理。本文重点介绍 UID 派生方案因为它在零成本的前提下提供了足够的安全性。三、UID 派生密钥的完整实现3.1 第一步读取 UID// device_uid.h #ifndef __DEVICE_UID_H #define __DEVICE_UID_H #include stdint.h #include stddef.h #define UID_SIZE 12 // STM32 UID 是 96 位 // 读取芯片唯一 ID int Device_ReadUID(uint8_t *uid, size_t len); #endif// device_uid.c #include device_uid.h #include string.h // STM32L4 的 UID 地址 #define UID_BASE_ADDR 0x1FFF7590 int Device_ReadUID(uint8_t *uid, size_t len) { if (len UID_SIZE) return -1; // 从 UID 寄存器读取 // 96 位 3 个 32 位字 const uint32_t *uid_regs (const uint32_t *)UID_BASE_ADDR; for (int i 0; i 3; i) { uint32_t word uid_regs[i]; uid[i * 4 0] (word 0) 0xFF; uid[i * 4 1] (word 8) 0xFF; uid[i * 4 2] (word 16) 0xFF; uid[i * 4 3] (word 24) 0xFF; } return 0; }关键点UID 地址依型号不同要查参考手册字节序要小心STM32 的寄存器读取通常是小端但 UID 的字节顺序需要确认建议增加冗余读取读两次并对比防止读取被干扰冗余读取增强版int Device_ReadUID_Safe(uint8_t *uid, size_t len) { uint8_t uid1[UID_SIZE], uid2[UID_SIZE]; if (Device_ReadUID(uid1, sizeof(uid1)) ! 0) return -1; if (Device_ReadUID(uid2, sizeof(uid2)) ! 0) return -1; // 两次读取必须一致 if (memcmp(uid1, uid2, UID_SIZE) ! 0) { return -2; // 可能被攻击 } memcpy(uid, uid1, UID_SIZE); return 0; }3.2 第二步密钥派生函数直接用 UID 当密钥是不安全的。原因UID 本身不是随机数可能有规律UID 长度只有 96 位不够某些加密算法的要求不同应用应该使用不同密钥正确做法用 KDF密钥派生函数从 UID 派生出所需长度的密钥。最简方案HKDF-SHA256// kdf.h #ifndef __KDF_H #define __KDF_H #include stdint.h #include stddef.h // 从 UID 派生密钥 // uid: 输入 UID // salt: 应用相关的盐值不同应用用不同的盐 // info: 用途标识如 AES、HMAC // out_key: 输出密钥 // out_len: 输出长度 int KDF_DeriveKey(const uint8_t *uid, size_t uid_len, const uint8_t *salt, size_t salt_len, const char *info, uint8_t *out_key, size_t out_len); #endif// kdf.c #include kdf.h #include string.h #include stm32l4xx_hal.h // 使用硬件 HASH // 简化版 HKDF-ExtractHMAC-SHA256(salt, uid) static void hkdf_extract(const uint8_t *salt, size_t salt_len, const uint8_t *ikm, size_t ikm_len, uint8_t *prk) { // 用硬件 HMAC-SHA256 // 简化实现salt 作为 keyuid 作为 message HASH_HandleTypeDef hhash {0}; hhash.Init.DataType HASH_DATATYPE_8B; hhash.Init.pKey (uint8_t *)salt; hhash.Init.KeySize salt_len; HAL_HASH_Init(hhash); HAL_HMAC_SHA256_Start(hhash, (uint8_t *)ikm, ikm_len, prk, 1000); HAL_HASH_DeInit(hhash); } // 简化版 HKDF-Expand从 PRK 派生出指定长度的密钥 static void hkdf_expand(const uint8_t *prk, size_t prk_len, const char *info, uint8_t *out, size_t out_len) { uint8_t t[32]; size_t t_len 0; size_t pos 0; uint8_t counter 1; while (pos out_len) { HASH_HandleTypeDef hhash {0}; hhash.Init.DataType HASH_DATATYPE_8B; HAL_HASH_Init(hhash); // T(n) HMAC(PRK, T(n-1) || info || counter) uint8_t buf[64]; size_t buf_len 0; if (t_len 0) { memcpy(buf, t, t_len); buf_len t_len; } size_t info_len strlen(info); memcpy(buf buf_len, info, info_len); buf_len info_len; buf[buf_len] counter; // HMAC-SHA256PRK 作为 key HASH_HandleTypeDef hmac {0}; hmac.Init.DataType HASH_DATATYPE_8B; hmac.Init.pKey (uint8_t *)prk; hmac.Init.KeySize prk_len; HAL_HASH_Init(hmac); HAL_HMAC_SHA256_Start(hmac, buf, buf_len, t, 1000); HAL_HASH_DeInit(hmac); t_len 32; size_t copy_len (out_len - pos 32) ? (out_len - pos) : 32; memcpy(out pos, t, copy_len); pos copy_len; } } int KDF_DeriveKey(const uint8_t *uid, size_t uid_len, const uint8_t *salt, size_t salt_len, const char *info, uint8_t *out_key, size_t out_len) { uint8_t prk[32]; // 第一步extract hkdf_extract(salt, salt_len, uid, uid_len, prk); // 第二步expand hkdf_expand(prk, sizeof(prk), info, out_key, out_len); // 清除中间值 memset(prk, 0, sizeof(prk)); return 0; }关键点salt 每个应用不同比如 smartlock-v1换产品时改掉info 标识用途同一个 UID 可以派生出 AES 密钥、HMAC 密钥等不同密钥用硬件加速SHA-256 硬件加速让 KDF 耗时降到微秒级3.3 第三步加密存储敏感数据思路敏感数据在固件里以加密形式存储运行时用 UID 派生的密钥解密。生成加密数据PC 端脚本# encrypt_secrets.py # 在 PC 上运行为每个设备的敏感数据生成密文 import hashlib import hmac import struct from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend def hkdf_derive(uid: bytes, salt: bytes, info: str, length: int) - bytes: 简化版 HKDF与 MCU 端实现保持一致 prk hmac.new(salt, uid, hashlib.sha256).digest() t b okm b counter 1 while len(okm) length: t hmac.new(prk, t info.encode() bytes([counter]), hashlib.sha256).digest() okm t counter 1 return okm[:length] def encrypt_secret(secret: str, uid: bytes, salt: bytes) - bytes: 加密单个敏感字符串 # 1. 从 UID 派生 AES 密钥 key hkdf_derive(uid, salt, AES-SECRETS, 32) # 2. 生成随机 IV import os iv os.urandom(16) # 3. AES-256-CBC 加密 cipher Cipher(algorithms.AES(key), modes.CBC(iv), backenddefault_backend()) encryptor cipher.encryptor() # 4. PKCS7 填充 plaintext secret.encode(utf-8) pad_len 16 - (len(plaintext) % 16) plaintext bytes([pad_len] * pad_len) ciphertext encryptor.update(plaintext) encryptor.finalize() # 5. 返回 IV || Ciphertext return iv ciphertext # 使用示例 if __name__ __main__: # 从调试器或代码中获取的 UID实际生产时每台设备不同 # 这里演示用固定的 UID uid bytes.fromhex(00112233445566778899aabb) salt bsmartlock-v1-salt # 加密各个敏感数据 secrets { API_KEY: sk-1234567890abcdef, MQTT_PASSWORD: SmartLock2024, AES_KEY: 0123456789abcdef0123456789abcdef, } for name, value in secrets.items(): encrypted encrypt_secret(value, uid, salt) # 生成 C 数组 c_array , .join(f0x{b:02X} for b in encrypted) print(fstatic const uint8_t s_enc_{name}[] {{ {c_array} }};)关键点加密脚本在 PC 上运行为每台设备单独生成固件IV 随密文一起存储每次加密用随机 IV防止相同明文产生相同密文salt 固定编译时嵌入 MCU 端和 PC 端保持一致MCU 端解密// secrets.c #include string.h #include device_uid.h #include kdf.h #define SALT_VALUE smartlock-v1-salt // 由 PC 端脚本生成的加密数据 static const uint8_t s_enc_API_KEY[] { 0x1A, 0x2B, 0x3C, /* ... */ }; static const uint8_t s_enc_MQTT_PASSWORD[] { /* ... */ }; // 获取解密后的敏感数据 // out: 输出缓冲区用户提供 // out_size: 输出缓冲区大小 // 返回解密的字节数-1 表示失败 static int DecryptSecret(const uint8_t *enc_data, size_t enc_len, uint8_t *out, size_t out_size) { if (enc_len 32) return -1; // IV(16) 至少 16 字节密文 // 1. 从 UID 派生 AES 密钥 uint8_t uid[12]; if (Device_ReadUID_Safe(uid, sizeof(uid)) ! 0) { return -1; } uint8_t key[32]; if (KDF_DeriveKey(uid, sizeof(uid), (const uint8_t *)SALT_VALUE, strlen(SALT_VALUE), AES-SECRETS, key, sizeof(key)) ! 0) { return -1; } // 2. 提取 IV 和密文 const uint8_t *iv enc_data; const uint8_t *ciphertext enc_data 16; size_t ciphertext_len enc_len - 16; if (ciphertext_len out_size) { memset(key, 0, sizeof(key)); return -1; } // 3. AES-256-CBC 解密用硬件 CRYP AES_HandleTypeDef haes {0}; haes.Init.DataType CRYP_DATATYPE_8B; haes.Init.pKey key; haes.Init.KeySize CRYP_KEYSIZE_256B; haes.Init.Algorithm CRYP_AES_CBC; haes.Init.pInitVect (uint32_t *)iv; HAL_CRYP_Init(haes); HAL_CRYP_Decrypt(haes, (uint32_t *)ciphertext, ciphertext_len, (uint32_t *)out, 1000); HAL_CRYP_DeInit(haes); // 4. 去除 PKCS7 填充 uint8_t pad_len out[ciphertext_len - 1]; if (pad_len 16) { memset(key, 0, sizeof(key)); return -1; } size_t plaintext_len ciphertext_len - pad_len; out[plaintext_len] \0; // 5. 清除密钥 memset(key, 0, sizeof(key)); return plaintext_len; } // 对外 API获取 API Key int Secrets_GetApiKey(char *out, size_t out_size) { return DecryptSecret(s_enc_API_KEY, sizeof(s_enc_API_KEY), (uint8_t *)out, out_size); }使用void ConnectToServer(void) { char api_key[64]; if (Secrets_GetApiKey(api_key, sizeof(api_key)) 0) { // 解密失败可能被攻击 Security_Lockdown(); } // 使用 api_key 连接服务器 // ... // 用完立即清除 memset(api_key, 0, sizeof(api_key)); }3.4 第四步内存中的密钥保护解密后的密钥在 RAM 中也是敏感数据。需要考虑措施一用完立即清零void ConnectToServer(void) { char api_key[64]; // ... 使用 api_key ... // 立即清除 memset(api_key, 0, sizeof(api_key)); // 防止编译器优化掉这个 memset __asm volatile ( ::: memory); }注意普通memset可能被编译器优化掉。用secure_memset或volatile指针。void secure_memset(void *ptr, int value, size_t len) { volatile uint8_t *p (volatile uint8_t *)ptr; while (len--) { *p (uint8_t)value; } }措施二锁定内存页防止换出嵌入式系统一般没有虚拟内存这条主要适用于 Linux 类系统。用到时用mlock()系统调用。措施三关闭 Core Dump防止崩溃时密钥被写入存储。嵌入式系统通常没有 Core Dump这条也适用于 Linux 系统。措施四限制密钥在 RAM 中的生存时间密钥解密后立即使用用完立即清除。不要在全局变量中长期保存。四、更高级的方案4.1 UID 安全启动组合UID 派生密钥 安全启动形成完整的保护体系安全启动保证固件本身未被篡改UID 派生密钥保证敏感数据只在本设备可解密即使固件被读出也无法解密其他设备的密钥4.2 UID 安全芯片组合对高价值设备用安全芯片存储主密钥UID 用于派生会话密钥主密钥 K_master存储在安全芯片中 ↓ 会话密钥 K_session KDF(K_master, UID, timestamp) ↓ 加密通信优点主密钥永不离开安全芯片会话密钥每次不同防重放综合了安全芯片和 UID 的优点4.3 密钥轮换长期运行的设备需要密钥轮换每隔 N 天生成新的会话密钥主密钥可以保持不变服务器记录每个设备的密钥历史实现// 定期轮换密钥 void KeyRotation_Task(void) { static uint32_t last_rotation 0; uint32_t now xTaskGetTickCount(); if (now - last_rotation pdMS_TO_TICKS(24 * 3600 * 1000)) { // 24 小时 // 从服务器获取新密钥 // 或本地派生新密钥 RefreshSessionKey(); last_rotation now; } }五、攻击演示UID 派生方案的效果回到引子里的智能门锁场景。用 UID 派生方案重构后重新做渗透测试攻击步骤 1读出固件J-Link 读出完整固件假设 RDP 已被绕过。攻击步骤 2提取字符串strings firmware.bin | grep -i key\|password | head -20输出SALT_VALUE AES-SECRETS DecryptSecret Secrets_GetApiKey没有明文密钥只有盐值、算法标识等公开信息。攻击步骤 3尝试在其他设备上运行把固件烧录到另一颗 STM32 上启动后发现所有密钥解密失败——因为新芯片的 UID 不同派生的密钥不同。攻击步骤 4尝试修改 UID 读取逻辑攻击者反汇编固件找到Device_ReadUID试图让它返回固定值。但如果启用了 Flash 完整性自检第 15 篇修改代码会导致启动失败。攻击步骤 5尝试在原设备上运行时提取攻击者无法在原设备上运行自定义代码RDP Level 2 禁止调试接口所以无法在运行时提取密钥。结论UID 派生方案有效阻止了固件提取后的密钥泄露。六、性能实测在 STM32L476 80MHz 上测试启动时多花 550μs换来密钥安全。完全值得。优化建议敏感数据延迟加载只在真正需要时解密而不是启动时全部解密缓存派生密钥同一次运行中重复使用同一密钥避免重复 KDF七、避坑清单八、小结与预告本文从一个真实的智能门锁密钥泄露事故出发系统拆解了敏感数据存储的四种方案——明文硬编码、静态加密、UID 派生密钥、安全芯片。重点实现了 UID 派生密钥的完整方案从 UID 读取、HKDF 密钥派生、AES 加密存储、到内存中的密钥保护。实测显示启动时多花 550μs就能让每台设备的密钥独一无二攻击者即使读出固件也无法解密其他设备。三个核心要点明文硬编码是犯罪静态加密只是安慰剂必须用 UID 派生或安全芯片UID 派生密钥零硬件成本配合 RDP Level 2 和安全启动形成完整保护解密后的密钥在 RAM 中也要保护用完立即清除下篇预告第 17 篇《RISC-V 上手将你的 RTOS 工程从 ARM Cortex-M 移植到 RISC-V 核心的踩坑记录》将用一个真实的 GD32VRISC-V 内核项目完整记录从 STM32F4Cortex-M4移植到 GD32VF103RISC-V Bumblebee 内核的全过程。拆解启动流程、中断控制器PLIC、链接脚本、编译器工具链、调试方式的差异给出可复用的移植检查清单。包含真实踩坑案例和性能对比数据。本文是《嵌入式系统调优高手课》的付费内容完整专栏收录 20 篇深度长文涵盖 RTOS 调度器汇编级拆解、内存池设计、Cache 一致性、低功耗陷阱与安全启动。每一篇都包含真实事故复盘、可移植源码和实测数据。如果你不想再靠重启试试解决问题这个专栏就是为你写的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询