1. 为什么你的U-Boot环境变量需要“上锁”?
大家好,我是老张,在嵌入式这行摸爬滚打十几年了。今天想和大家聊聊一个看似不起眼,但实际项目中经常让人头疼的问题——U-Boot环境变量的安全。
咱们做嵌入式开发,尤其是涉及工业控制、物联网设备或者消费电子产品的,U-Boot环境变量里经常会放一些“宝贝”。比如,设备的Wi-Fi连接密码、用于远程认证的密钥、产品序列号、甚至是一些启动阶段的配置参数。在默认情况下,这些变量通过mkenvimage工具生成镜像后,会以明文形式直接存储在Flash里。这意味着,任何一个能接触到Flash物理存储的人,或者通过调试接口读取到Flash内容,都能像看小说一样,把这些敏感信息一览无余。我早年就踩过这个坑,一个用于产线测试的密码被轻易获取,导致后续出现了一些麻烦。
所以,给环境变量“上锁”,进行加密存储,不是炫技,而是产品走向成熟、考虑安全的必经之路。这就像你把家门钥匙藏在脚垫下面,和放在保险柜里的区别。今天,我就手把手带你,用一种既安全又实用的方法,为你的U-Boot环境变量加上AES加密这把“锁”,并且探讨如何把开锁的“钥匙”——也就是AES密钥,藏到最安全的硬件保险箱(如CPU的eFuse)里。
2. 动手之前:理清U-Boot环境变量的加载脉络
在动手改造之前,咱们得先搞清楚U-Boot是怎么把环境变量从Flash里请出来的。知其然,更要知其所以然,这样改代码时才不会迷路。
简单来说,U-Boot启动后期,会执行一个初始化序列(init_sequence_r),其中就包括环境初始化(initr_env)。这个函数的核心是env_relocate,它负责决定环境变量从哪里来。如果我们的板子配置了从外部存储(比如SPI NOR Flash)加载环境变量(通过CONFIG_ENV_IS_IN_SPI_FLASH等宏),那么U-Boot就会找到对应的环境驱动(比如env_sf.c),调用它的load函数。
这个load函数会做几件事:分配内存缓冲区、操作Flash驱动、从指定的偏移地址(CONFIG_ENV_OFFSET)读取指定大小(CONFIG_ENV_SIZE)的数据到缓冲区,最后调用env_import函数。env_import这个函数是个关键先生,它会对缓冲区数据进行CRC校验,校验通过后,才会把里面的键值对解析出来,导入到U-Boot运行时维护的环境变量哈希表中。
我们加密改造的黄金插入点,就在load函数读取完Flash数据之后,env_import进行CRC校验之前。在这个环节,我们拿到的是刚从Flash读出来的、加密过的原始数据块。我们需要在这里插入解密逻辑,将解密后的明文数据交给env_import去校验和导入。这样一来,对U-Boot的其他部分和用户来说,整个过程是完全透明的,他们感知不到加解密的存在。
3. 第一步:在PC端制作加密的环境变量镜像
加密是个双向过程,存进去的时候要加密,读出来的时候要解密。所以第一步,我们得先有一个工具,能把我们编辑好的明文环境变量文本文件,加密成U-Boot能识别的镜像。这里我们用AES-128 ECB模式,因为它实现简单,并且U-Boot内置了支持。
注意:ECB模式对于重复的明文块会产生相同的密文块,在某些场景下可能泄露模式信息。但对于环境变量这种通常较小且结构不固定的数据,ECB是简单可行的选择。如果对环境变量内容有更高要求,可以考虑在加密前对数据进行随机填充。
下面是我写的一个简单的加密工具示例(env_encrypt.c)。你需要先准备好一个env.txt文件,里面是你的环境变量,比如bootcmd=run bootlinux、ipaddr=192.168.1.100等等。然后用mkenvimage工具(U-Boot源码tools/目录下)生成标准的U-Boot环境镜像,假设输出为env.bin。最后,用我们这个加密程序处理env.bin。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
// 假设使用U-Boot同源的AES库,这里简化演示核心逻辑
// 实际你需要链接U-Boot的aes.c或使用OpenSSL等库
void aes_encrypt_block(const unsigned char *in, const unsigned char *key, unsigned char *out) {
// 这里应调用具体的AES加密函数,例如:
// aes_expand_key(key, expand_key);
// aes_encrypt(in, expand_key, out);
// 为简化示例,此处省略具体AES实现,假设有可用函数。
}
int main(int argc, char** argv) {
// !!重要!! 这是测试用的密钥,实际产品中绝不能硬编码!
// 后续我们会讨论如何安全管理密钥
unsigned char aes_key[16] = {
0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0,
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88
};
if (argc != 3) {
printf("Usage: %s <plaintext_env.bin> <encrypted_env.bin>\n", argv[0]);
return -1;
}
FILE *fp_in = fopen(argv[1], "rb");
FILE *fp_out = fopen(argv[2], "wb");
if (!fp_in || !fp_out) {
perror("File open failed");
return -1;
}
// 获取文件大小
fseek(fp_in, 0, SEEK_END);
long file_size = ftell(fp_in);
fseek(fp_in, 0, SEEK_SET);
// 环境变量大小通常需要是16字节(AES块大小)的整数倍
// mkenvimage生成的文件通常是CONFIG_ENV_SIZE,已经是块大小的倍数
if (file_size % 16 != 0) {
printf("Error: File size (%ld) not multiple of AES block size (16).\n", file_size);
// 一种处理方式:填充到下一个块边界
file_size = ((file_size / 16) + 1) * 16;
printf("Will encrypt as %ld bytes with padding.\n", file_size);
}
unsigned char *plaintext = malloc(file_size);
unsigned char *ciphertext = malloc(file_size);
if (!plaintext || !ciphertext) {
printf("Memory allocation failed.\n");
return -1;
}
memset(plaintext, 0, file_size); // 填充0
// 读取原始数据
fread(plaintext, 1, file_size, fp_in); // 如果文件小,后面部分是0填充
// 分块进行AES-128 ECB加密
for (long i = 0; i < file_size; i += 16) {
aes_encrypt_block(plaintext + i, aes_key, ciphertext + i);
}
// 写入加密后的数据
fwrite(ciphertext, 1, file_size, fp_out);
printf("Encryption complete. Output written to %s\n", argv[2]);
printf("Remember to use the SAME key in U-Boot for decryption!\n");
free(plaintext);
free(ciphertext);
fclose(fp_in);
fclose(fp_out);
return 0;
}
编译这个工具(需要链接AES库),然后运行./env_encrypt env.bin env_encrypted.bin,你就得到了加密后的环境变量镜像。把这个env_encrypted.bin烧写到Flash中CONFIG_ENV_OFFSET的位置。
4. 第二步:修改U-Boot源码,添加启动时解密逻辑
现在,U-Boot那端需要一把同样的“钥匙”来开锁。我们需要修改环境变量驱动,在加载函数中加入解密步骤。这里以SPI Flash驱动(env_sf.c)为例,其他存储介质(如MMC、NAND)的修改位置类似。
找到env_sf_load函数(或其他存储对应的load函数)。这个函数大概长这样:
static int env_sf_load(void)
{
int ret;
char *buf = NULL;
buf = (char *)memalign(ARCH_DMA_MINALIGN, CONFIG_ENV_SIZE);
if (!buf) {
set_default_env("malloc() failed", 0);
return -EIO;
}
ret = setup_flash_device();
if (ret)
goto out;
// 关键读取操作
ret = spi_flash_read(env_flash, CONFIG_ENV_OFFSET, CONFIG_ENV_SIZE, buf);
if (ret) {
set_default_env("spi_flash_read() failed", 0);
goto err_read;
}
// 这里是CRC校验和环境导入
ret = env_import(buf, 1);
if (!ret)
gd->env_valid = ENV_VALID;
err_read:
spi_flash_free(env_flash);
env_flash = NULL;
out:
free(buf);
return ret;
}
我们要在spi_flash_read之后,env_import之前,插入解密代码。同时,为了灵活性,最好通过一个配置宏(如CONFIG_ENV_AES_DECRYPT)来控制是否启用解密。
static int env_sf_load(void)
{
int ret;
char *buf = NULL;
buf = (char *)memalign(ARCH_DMA_MINALIGN, CONFIG_ENV_SIZE);
if (!buf) {
set_default_env("malloc() failed", 0);
return -EIO;
}
ret = setup_flash_device();
if (ret)
goto out;
ret = spi_flash_read(env_flash, CONFIG_ENV_OFFSET, CONFIG_ENV_SIZE, buf);
if (ret) {
set_default_env("spi_flash_read() failed", 0);
goto err_read;
}
/* 新增的AES解密部分开始 */
#ifdef CONFIG_ENV_AES_DECRYPT
{
int i;
unsigned char tin[16], tout[16];
// !!警告:同样硬编码的密钥,仅用于演示!!
unsigned char aes_key[16] = {
0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0,
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88
};
unsigned char expand_key[256]; // AES-128扩展密钥需要176字节,分配256保证足够
printf("ENV: AES decrypting environment from SPI Flash...\n");
// 初始化并扩展密钥
aes_expand_key(aes_key, expand_key);
// 分块解密
for (i = 0; i < CONFIG_ENV_SIZE; i += 16) {
memcpy(tin, buf + i, 16);
aes_decrypt(tin, expand_key, tout); // 调用U-Boot内部的aes_decrypt函数
memcpy(buf + i, tout, 16);
}
printf("ENV: AES decryption completed.\n");
}
#endif
/* 新增的AES解密部分结束 */
ret = env_import(buf, 1);
if (!ret)
gd->env_valid = ENV_VALID;
err_read:
spi_flash_free(env_flash);
env_flash = NULL;
out:
free(buf);
return ret;
}
修改完后,记得在对应的板级配置头文件(比如include/configs/my_board.h)里定义CONFIG_ENV_AES_DECRYPT宏。然后重新编译U-Boot,烧录进去。上电后,如果看到“AES decrypting environment…”的打印信息,并且环境变量能正常读取,恭喜你,基础版的加密存储就成功了!
5. 密钥硬编码太危险?引入硬件安全单元(HSU)
上面的例子有个致命问题:密钥硬编码在代码里。这和把钥匙挂在门上没什么区别。攻击者反编译你的U-Boot镜像,就能轻松提取密钥。为了解决这个问题,我们需要借助芯片的硬件安全特性。
很多现代嵌入式处理器,比如NXP的i.MX系列、ST的STM32MP1系列、Xilinx Zynq-7000/UltraScale+ MPSoC,都提供了硬件安全模块,例如OTP(One-Time Programmable)存储器或eFuse。这些存储单元一旦写入,就无法被软件直接读取,但可以配置给内部的加解密模块(如CAAM, TrustZone, CSU)使用。我们的目标是把AES密钥烧录到这片受硬件保护的区域。
以我比较熟悉的Xilinx Zynq-7000为例,它支持将AES密钥和HMAC密钥烧写到eFuse中,并由硬件加密模块(CSU)在启动时自动使用。实现流程如下:
- 密钥生成与准备:在安全的环境下(如工厂产线),生成一个唯一的AES-256密钥。使用Xilinx提供的工具(如
bootgen)或直接操作eFuse编程器,将密钥写入芯片的eFuse阵列。这个操作通常只能执行一次,写入后密钥就无法被直接读取,只能由芯片内部的硬件加密引擎使用。 - 加密镜像:使用这个唯一的密钥,在PC端加密你的环境变量镜像(以及可能的内核、设备树、根文件系统镜像)。
bootgen工具可以帮你完成这个流程,并生成一个带认证头的加密镜像包。 - 配置U-Boot:在U-Boot中,你不再需要(也无法)在代码里硬编码密钥。相反,你需要:
- 确保U-Boot编译时包含了对应平台的硬件加解密驱动支持(例如Zynq的CSU驱动)。
- 修改环境变量加载逻辑。从直接调用软件AES解密,改为调用一个硬件解密的API。这个API内部会触发CSU模块,使用eFuse中的密钥对指定内存区域的数据进行解密。
- 通常,硬件解密引擎要求数据在内存中的地址对齐,并且可能只解密特定格式(如由
bootgen生成的)的镜像。你需要根据芯片手册调整数据准备流程。
// 伪代码示例:使用硬件解密引擎
#ifdef CONFIG_ENV_AES_DECRYPT_HW
printf("ENV: Using HW decryptor (eFuse key)...\n");
// 1. 初始化硬件加解密引擎(如Zynq CSU)
zynq_csu_init();
// 2. 配置解密引擎使用eFuse中的密钥,并指定解密模式
// 函数参数通常包括:密钥槽(指向eFuse)、源数据地址、目标地址、数据长度
ret = zynq_csu_aes_decrypt(CSU_KEY_SLOT_EFUSE, // 使用eFuse密钥槽
(u8*)buf, // 密文输入(原地解密)
(u8*)buf, // 明文输出(原地解密)
CONFIG_ENV_SIZE);
if (ret) {
printf("ENV: HW decryption failed! Using default env.\n");
set_default_env("HW decryption failed", 0);
goto err_read;
}
#endif
这样做的好处是巨大的:密钥永远不出现在软件可访问的内存或Flash中,极大地提升了攻击门槛。即使攻击者 dump 了Flash内容和U-Boot镜像,也无法获得密钥来解密敏感数据。
6. 进阶思考:构建完整的可信启动链
环境变量加密只是嵌入式系统安全启动的第一环。一个完整的安全启动方案应该是链式的、环环相扣的:
- ROM Code信任第一级引导程序(FSBL/U-Boot SPL):芯片上电后,不可变的ROM代码使用硬编码在芯片中的公钥或根密钥,验证FSBL或SPL的签名。验证通过才执行。
- 第一级引导程序信任U-Boot:FSBL/SPL使用存储在安全区域(如eFuse)的密钥,验证U-Boot镜像的完整性和真实性。同时,它可以解密U-Boot镜像(如果U-Boot本身被加密了)。
- U-Boot信任内核和设备树:这就是我们前面在做的事情的延伸。U-Boot可以使用硬件解密引擎(密钥来自eFuse)来解密环境变量、内核镜像、设备树。并且,它还可以进一步验证这些组件的签名。
- 内核信任根文件系统:内核启动后,可以挂载加密的根文件系统,同样通过硬件或软件模块使用安全存储的密钥进行解密。
这样,从芯片上电到应用启动,形成了一个完整的可信执行链。任何一环被篡改,都会导致启动失败。环境变量的加密存储,是这个链条中保护配置信息安全的关键一环。
7. 实践中的坑与最佳实践建议
最后,分享几个我在实际项目中踩过的坑和总结的建议:
- 密钥管理是核心:软件硬编码密钥是绝对的下策。优先使用芯片的硬件安全特性(eFuse, HSM, TrustZone)。如果芯片不支持,可以考虑使用一颗独立的安全芯片(SE)来存储和进行加解密运算。
- 处理好数据对齐和填充:AES是块加密算法。确保你的环境变量镜像大小是16字节的整数倍。
mkenvimage生成的文件通常是CONFIG_ENV_SIZE,把它设置为16的倍数(如0x4000, 0x10000)。 - 考虑加密性能:对于环境变量这种小数据量,软件AES解密开销可以忽略。但如果你要加密内核(几MB到几十MB),软件解密会显著增加启动时间。务必使用硬件加速引擎。
- 备份与恢复机制:加密后,环境变量无法直接通过串口工具手动修改了。确保你的产品有安全的恢复模式,例如通过一个受信任的网络服务来更新加密的环境变量镜像,或者在开发阶段保留一个未加密的调试模式(通过宏开关控制)。
- 测试、测试、再测试:任何安全机制的引入都不能影响正常功能。务必对加密/解密流程进行充分测试,包括正常启动、密钥错误、数据损坏、断电重启等异常场景。
安全是一个持续的过程,没有一劳永逸的方案。从给U-Boot环境变量加密开始,逐步构建你产品的安全防线,每一步都算数。希望这篇长文能给你带来实实在在的帮助。如果在实践中遇到具体问题,欢迎一起探讨。

397

被折叠的 条评论
为什么被折叠?



