3、哈希函数基础:从“数字指纹”说起
聊固件校验,绕不开哈希函数。这东西说白了,就是给任意一段数据算出一个固定长度的“摘要”。你想想看,固件动不动几兆甚至几十兆,我们总不能每次都拿整个文件去比对吧?哈希就是那个“数字指纹”。
我记得刚入行那会儿,带我的老工程师跟我说过一句话:“哈希函数是嵌入式安全的基石,你把它搞透了,后面校验、签名、升级,一通百通。”当时没太在意,后来踩了几个坑才明白——这话一点不夸张。
3.1 什么是哈希函数?
哈希函数(Hash Function),也叫散列函数。它接收任意长度的输入,经过计算后输出一个固定长度的值,这个值就叫哈希值(Hash Value)或消息摘要(Message Digest)。
用公式表示就是:
H = Hash(M)
其中:
M —— 任意长度的输入消息
H —— 固定长度的哈希值(比如 256 位)
Hash() —— 哈希函数本身
我习惯把哈希函数想象成一个“碎纸机+印章”的组合。你把一份文件塞进去,出来的是一个固定大小的印章图案。这个图案能唯一代表那份文件,但你没办法从印章反推出文件内容。
核心要点:哈希不是加密。加密是为了隐藏信息,哈希是为了验证完整性。这两个概念千万别搞混了——我在面试新人时发现这是最常见的误区。
3.2 哈希函数的三大特性
一个合格的哈希函数,必须同时满足三个特性。缺一个,它就不适合用在固件校验里。
3.2.1 单向性(One-way)
单向性,也叫抗原像性。意思是:给定哈希值 H,你几乎不可能反推出原始输入 M。
为什么会这样?因为哈希函数在计算过程中做了大量的“信息压缩”和“非线性变换”。输入空间是无限的,输出空间是有限的,必然存在多对一的映射关系。但好的哈希函数让这个逆向过程在计算上不可行。
我曾经做过一个实验:用 SHA256 对一个 1KB 的固件片段算哈希,然后尝试用暴力方式反推。嗯,算到宇宙热寂也出不来结果。
实战经验:在固件升级场景中,单向性保证了即使攻击者拿到了服务器下发的哈希值,也无法反推出固件的二进制内容。这对保护固件知识产权很有用。
3.2.2 抗碰撞性(Collision Resistance)
抗碰撞性分两个层次:
- 弱抗碰撞性:给定 M1,你找不到另一个 M2(M2 ≠ M1),使得 Hash(M1) = Hash(M2)。
- 强抗碰撞性:你找不到任意两个不同的 M1 和 M2,使得它们的哈希值相等。
说白了,就是“撞车”的概率要低到可以忽略不计。SHA256 的输出空间是 2^256,这个数字有多大?比宇宙中的原子总数还多。理论上存在碰撞,但实际中你碰不上。
注意:MD5 和 SHA1 已经被证明存在实际的碰撞攻击。我在 2017 年参与过一个项目,客户坚持用 MD5 做固件校验。我花了整整一周时间,用实际碰撞案例说服他们改用 SHA256。从那以后,我个人的项目里再也没用过 MD5。
3.2.3 确定性(Deterministic)
同一个输入,无论你算多少次,得到的哈希值必须完全一样。这个特性看起来简单,但实际工程中容易出问题。
比如,有些哈希库在处理不同字节序的输入时,结果会不一样。我遇到过一例:PC 端用大端序算哈希,嵌入式端用小端序算,两边结果对不上。排查了整整两天才发现是字节序的问题。
3.3 常见哈希算法对比
下面这张表,是我在实际项目中常用的哈希算法对比。建议你收藏一下,选型时直接参考。
| 算法 | 输出长度 | 安全性 | 速度(相对) | 推荐场景 |
|---|---|---|---|---|
| MD5 | 128 位(16 字节) | 已破解,不推荐 | 最快 | 仅用于非安全场景(如文件去重) |
| SHA1 | 160 位(20 字节) | 已破解,不推荐 | 较快 | 遗留系统兼容,新项目不要用 |
| SHA256 | 256 位(32 字节) | 安全(截至 2025 年) | 中等 | 固件校验、数字签名、证书验证 |
| SHA384 | 384 位(48 字节) | 安全 | 较慢 | 高安全等级场景 |
| SHA512 | 512 位(64 字节) | 安全 | 最慢 | 对性能不敏感的高安全场景 |
我的建议:固件校验场景,直接上 SHA256。它在安全性和性能之间取得了最好的平衡。在 Cortex-M4 上,SHA256 的吞吐量可以达到几十 MB/s,完全够用。
3.4 哈希函数在固件校验中的典型流程
下面这张 SVG 图,展示了哈希函数在固件校验中的核心位置。我画这张图的时候,特意把“哈希计算”放在中间——因为它是连接“固件分发”和“固件验证”的桥梁。
流程其实很简单:
- 对原始固件计算哈希值,得到“数字指纹”
- 把固件和哈希值一起分发(哈希值可以通过安全通道传输)
- 接收端收到固件后,重新计算哈希值
- 对比两个哈希值:一致则固件完整,不一致则说明被篡改
3.5 嵌入式环境下的哈希实现注意事项
在实际的嵌入式项目中,哈希函数的实现有几个坑需要避开:
- 内存管理:哈希计算需要缓冲区。我习惯用静态分配,避免动态内存分配带来的不确定性。
- 分块计算:固件可能很大,不能一次性加载到内存。用哈希库的“初始化-更新-最终”三步接口,分块计算。
- 硬件加速:很多 MCU 内置了哈希硬件加速器(比如 STM32 的 HASH 外设)。能用硬件就别用软件,速度和功耗都有优势。
- 字节序:前面提过,确保发送端和接收端的字节序一致。我一般在协议里固定使用大端序。
代码片段:下面是一个典型的 SHA256 分块计算伪代码,我在多个项目里用过这个模式。
// 伪代码:SHA256 分块计算
SHA256_CTX ctx;
uint8_t hash[32];
uint8_t buffer[1024];
uint32_t offset = 0;
uint32_t remaining = firmware_size;
SHA256_Init(&ctx);
while (remaining > 0) {
uint32_t chunk = (remaining > 1024) ? 1024 : remaining;
read_firmware(buffer, offset, chunk);
SHA256_Update(&ctx, buffer, chunk);
offset += chunk;
remaining -= chunk;
}
SHA256_Final(&ctx, hash);
// 此时 hash 数组中就是 32 字节的 SHA256 值
// 与接收到的哈希值逐字节对比即可
嗯,哈希函数的基础就这些。记住一句话:在固件校验的世界里,哈希是你的第一道防线,也是最后一道防线。选对算法、用对方式,后面的事情就顺了。