哈希算法基础:SHA-256原理、哈希碰撞与安全性、固件完整性校验

聊固件签名,绕不开哈希算法。说白了,哈希就是给数据算一个「指纹」。指纹是唯一的,数据变了,指纹就得变。我刚开始做固件时,总觉得哈希就是个校验和,后来踩了坑才明白——选错哈希,签名等于白做。

SHA-256 到底怎么工作的?

SHA-256 属于 SHA-2 家族,输出固定 256 位(32 字节)的摘要。你给它 1KB 的固件,它算 32 字节;给它 1MB 的固件,还是 32 字节。这个特性在签名里特别重要——我们不会去签名整个固件,而是签名它的哈希值。

它的内部流程大致分三步:

  1. 填充:把数据补成 512 位的整数倍,末尾还要加上原始长度信息。
  2. 分块:每 512 位(64 字节)为一个消息块,依次处理。
  3. 压缩:每个块经过 64 轮迭代运算,更新 8 个 32 位工作变量。

嗯,这里要注意——SHA-256 的每一轮都用到了位运算、模加和逻辑函数。它不是简单的异或,而是精心设计的非线性变换。我个人习惯用下面这张图来理解它的整体流程:

SHA-256 核心流程 原始固件数据 步骤1:填充 + 长度编码 步骤2:分割为 512-bit 块 步骤3:64 轮压缩函数 每轮:Σ₀ + Σ₁ + Maj + Ch + 模加 更新 8 个工作变量 (A~H) 256-bit 摘要输出 关键点:任何输入变化都会导致输出完全不同的摘要(雪崩效应)

我在项目中遇到过有人用 MD5 做固件校验,结果被篡改了都没发现。SHA-256 的雪崩效应保证了——哪怕只改一个比特,输出的 256 位里大约一半的位都会翻转。这才是真正的「指纹」。

哈希碰撞与安全性

哈希碰撞,就是两个不同的输入算出了相同的摘要。理论上,任何哈希函数都有碰撞,因为输入空间无限,输出空间有限。但关键是——找到碰撞的难度有多大。

哈希算法 输出长度 碰撞攻击复杂度 当前状态
MD5 128 bit 2^18(秒级) 已攻破,禁止使用
SHA-1 160 bit 2^63(理论可行) 已攻破,禁止使用
SHA-256 256 bit 2^128(生日攻击) 安全,推荐使用
SHA-3-256 256 bit 2^128 安全,备选方案

你想想看,2^128 是什么概念?就算你用全球所有的算力去跑,也要跑几十亿年。所以 SHA-256 的碰撞安全性,在可预见的未来是牢不可破的。

⚠️ 避坑指南

我曾经在一个遗留项目里看到固件校验还在用 SHA-1。当时客户要求升级,我查了一下——SHA-1 在 2017 年就被 Google 和 CWI 联合攻破了,他们演示了 SHAttered 攻击,生成了两个不同的 PDF 文件,SHA-1 摘要完全相同。如果你还在用 SHA-1,请立刻迁移到 SHA-256。

固件完整性校验实战

固件完整性校验,说白了就是回答一个问题:「我拿到的固件,是不是原始作者写的那一份?」

校验流程通常分两步:

  1. 生成参考值:在构建阶段,对固件二进制计算 SHA-256,得到参考摘要。
  2. 现场校验:设备启动时,对存储的固件重新计算 SHA-256,对比参考值。

代码实现其实不复杂。下面是我在嵌入式 Linux 环境下常用的校验片段:

#include <stdio.h>
#include <string.h>
#include <openssl/sha.h>

int verify_firmware(const char *fw_path, const unsigned char *expected_hash) {
    FILE *fp = fopen(fw_path, "rb");
    if (!fp) return -1;

    SHA256_CTX ctx;
    SHA256_Init(&ctx);

    unsigned char buf[4096];
    int bytes;
    while ((bytes = fread(buf, 1, sizeof(buf), fp)) > 0) {
        SHA256_Update(&ctx, buf, bytes);
    }
    fclose(fp);

    unsigned char actual_hash[SHA256_DIGEST_LENGTH];
    SHA256_Final(actual_hash, &ctx);

    // 恒定时间比较,防止时序攻击
    int result = 0;
    for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) {
        result |= actual_hash[i] ^ expected_hash[i];
    }
    return (result == 0) ? 0 : -1;
}

💡 个人经验

注意上面代码里的「恒定时间比较」。我见过有人用 memcmp 做哈希对比,结果被侧信道攻击利用——攻击者通过测量比较时间,逐字节猜出了哈希值。虽然嵌入式场景下这种攻击门槛高,但养成好习惯总没错。

哈希在签名流程中的位置

固件签名不是直接对固件签名,而是「先哈希,再签名」。为什么?

  • 性能:非对称签名算法(如 RSA-2048)处理大块数据很慢。哈希把任意大小的固件压缩成 32 字节,签名效率高得多。
  • 安全性:哈希的雪崩效应保证了签名无法被重用或伪造。
  • 灵活性:你可以先校验哈希,再决定是否验证签名。在资源受限的 MCU 上,有时只做哈希校验就够了。

我习惯把整个流程画成一条链:

签名流程中的哈希位置 固件二进制 SHA-256 哈希 256-bit 摘要 私钥签名 签名对象是 32 字节的哈希值,不是整个固件。这样既快又安全。 验证时:固件 → SHA-256 → 摘要 → 公钥验签 → 比对

嗯,这里有个细节很多人忽略——哈希算法本身不提供防篡改能力。攻击者可以同时篡改固件和它附带的哈希值。所以哈希必须和签名配合使用,或者存储在安全区域(如 OTP、安全元件)。

核心要点总结

  • SHA-256 输出固定 256 位,雪崩效应保证输入微小变化导致输出剧烈变化。
  • MD5 和 SHA-1 已不安全,固件场景下严禁使用。
  • 哈希碰撞的理论风险存在,但 SHA-256 的 2^128 复杂度在工程上不可行。
  • 固件完整性校验必须使用恒定时间比较,防止时序侧信道攻击。
  • 哈希是签名的前置步骤,不是替代方案——两者缺一不可。