第 18 课:密码学工具 — 哈希函数 & 安全随机数

对应源文件:

  • trantor/utils/Utilities.h — 公开 API(Hash128/160/256、所有哈希函数、secureRandomBytes
  • trantor/utils/Utilities.cc — 无 TLS 后端时的纯 C 实现
  • trantor/utils/crypto/openssl.cc — OpenSSL 后端实现
  • trantor/utils/crypto/botan.cc — Botan 后端实现
  • trantor/utils/crypto/md5.h/cc — 内置 MD5(纯 C)
  • trantor/utils/crypto/sha1.h/cc — 内置 SHA1(纯 C,公有域)
  • trantor/utils/crypto/sha256.h/cc — 内置 SHA256(纯 C)
  • trantor/utils/crypto/sha3.h/cc — 内置 SHA3-256(Keccak,纯 C)
  • trantor/utils/crypto/blake2.h/cc — 内置 BLAKE2b-256(纯 C)

一、整体架构:三层后端选择

trantor 的密码学工具采用编译期后端切换设计,同一套 API 在三种环境下对应不同实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
用户代码
trantor::utils::md5(data, len)      ← 统一 API(Utilities.h)
    ├─ USE_OPENSSL 定义时 ──→ crypto/openssl.cc  (OpenSSL EVP API)
    ├─ USE_BOTAN  定义时 ──→ crypto/botan.cc     (Botan HashFunction)
    └─ 两者均无时 ──────────→ Utilities.cc        (内置纯 C 实现)
                              ├─ crypto/md5.cc
                              ├─ crypto/sha1.cc
                              ├─ crypto/sha256.cc
                              ├─ crypto/sha3.cc
                              └─ crypto/blake2.cc

设计原理

  1. 当项目已链接 TLS 库(OpenSSL/Botan)时,复用它们的哈希实现,避免二进制体积膨胀
  2. 无 TLS 依赖的轻量部署(如嵌入式环境),依然有完整的哈希功能
  3. 用户代码无需感知后端,#include <trantor/utils/Utilities.h> 一次包含即可

二、Hash 结构体设计

1
2
3
4
// Utilities.h
struct Hash128 { unsigned char bytes[16]; };  // MD5 输出:128 位 = 16 字节
struct Hash160 { unsigned char bytes[20]; };  // SHA1 输出:160 位 = 20 字节
struct Hash256 { unsigned char bytes[32]; };  // SHA256/SHA3/BLAKE2b 输出:256 位 = 32 字节

为什么用结构体而不是 std::stringstd::array

  1. 类型安全Hash128Hash256 不能混用,编译器会报错;std::string 则无法区分
  2. 零开销:结构体可以直接在栈上分配,无堆分配,函数返回时编译器通常 RVO 优化
  3. 方便 memcmp:原始字节数组比较比 std::string 快(不需要处理 null 终止)

使用时通过 toHexString() 转为可读字符串:

1
2
3
auto hash = trantor::utils::sha256("hello");
std::string hex = trantor::utils::toHexString(hash);
// → "2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"

toHexString 的实现极其简洁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
std::string toHexString(const void *data, size_t len) {
    std::string str;
    str.resize(len * 2);
    for (size_t i = 0; i < len; i++) {
        unsigned char c = ((const unsigned char *)data)[i];
        str[i * 2]     = "0123456789ABCDEF"[c >> 4];    // 高4位
        str[i * 2 + 1] = "0123456789ABCDEF"[c & 0xf];   // 低4位
    }
    return str;
}

字符查表法——用字符数组下标代替条件分支,编译器友好,性能优于 sprintf


三、五种哈希函数

3.1 API 统一形式

每个哈希函数都提供两个重载:

1
2
3
4
5
6
7
// 原始内存版本(底层实现)
TRANTOR_EXPORT HashXxx func(const void *data, size_t len);

// std::string 便捷版本(内联包装)
inline HashXxx func(const std::string &str) {
    return func(str.data(), str.size());
}

3.2 各算法对比

算法返回类型摘要大小安全性trantor 用途
MD5Hash128128 位❌ 已破解(碰撞攻击)文件完整性校验(非安全用途)
SHA1Hash160160 位⚠️ 弱(碰撞攻击存在)WebSocket 握手(RFC 6455 强制)
SHA256Hash256256 位✅ 安全TLS 证书指纹、HMAC
SHA3Hash256256 位✅ 安全(Keccak)一般安全哈希(新代码推荐)
BLAKE2bHash256256 位✅ 安全性能敏感场景(软件最快)

注释原文的推荐:

1
2
3
// @note When in doubt, use SHA3 or BLAKE2b. Both are safe.
// SHA3 is faster if you are using OpenSSL with hardware mode.
// Otherwise BLAKE2b is faster in software.

3.3 SHA1 在 WebSocket 握手中的强制使用

WebSocket 握手协议(RFC 6455)规定:

1
Sec-WebSocket-Accept = Base64(SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

即使 SHA1 存在安全问题,协议规范强制使用,无法替换。这是 trantor 保留 SHA1 实现的核心原因。

3.4 SHA3 的降级兼容

SHA3(Keccak)在 OpenSSL 版本 < 3.0 或 LibreSSL 上可能不可用。代码做了优雅降级:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// openssl.cc
Hash256 sha3(const void* data, size_t len)
{
    Hash256 hash;
#if OPENSSL_VERSION_MAJOR >= 3
    auto sha3 = EVP_MD_fetch(nullptr, "SHA3-256", nullptr);
    if (sha3 != nullptr) {
        // 使用 OpenSSL 3.x 的 SHA3
        ...
        return hash;
    }
#elif !defined(LIBRESSL_VERSION_NUMBER)
    auto sha3 = EVP_sha3_256();   // OpenSSL 1.x
    if (sha3 != nullptr) { ... return hash; }
#endif
    // 降级:使用内置的纯 C 实现
    trantor_sha3((const unsigned char*)data, len, &hash, sizeof(hash));
    return hash;
}

降级链:OpenSSL 3.x → OpenSSL 1.x → 内置 Keccak 实现。


四、内置哈希算法的 C 接口

当没有 TLS 后端时,直接使用内置纯 C 实现,接口为经典的三段式:

1
Init → Update(可多次)→ Final
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// MD5
MD5_CTX ctx;
trantor_md5_init(&ctx);
trantor_md5_update(&ctx, data1, len1);  // 可流式输入
trantor_md5_update(&ctx, data2, len2);
trantor_md5_final(&ctx, hash_bytes);

// SHA1
SHA1_CTX ctx;
trantor_sha1_init(&ctx);
trantor_sha1_update(&ctx, data, len);
trantor_sha1_final(digest_20bytes, &ctx);   // 注意:SHA1 参数顺序与 MD5 相反!

// SHA256
SHA256_CTX ctx;
trantor_sha256_init(&ctx);
trantor_sha256_update(&ctx, data, len);
trantor_sha256_final(&ctx, hash_bytes);

MD5_CTX 结构

1
2
3
4
5
6
typedef struct {
    uint8_t  data[64];     // 当前 64 字节分组缓冲
    uint32_t datalen;      // 缓冲中已有字节数
    uint64_t bitlen;       // 总处理位数(用于 padding)
    uint32_t state[4];     // 4 个 32 位状态变量(A, B, C, D)
} MD5_CTX;

SHA256_CTX 结构(与 MD5 类似,state 扩展为 8 个字):

1
2
3
4
5
6
typedef struct {
    uint8_t  data[64];     // 64 字节分组
    uint32_t datalen;
    uint64_t bitlen;
    uint32_t state[8];     // 8 个 32 位状态变量
} SHA256_CTX;

SHA3 的 Keccak 使用 200 字节(1600 位)的状态矩阵,结构不同:

1
2
3
4
5
6
7
typedef struct {
    union {
        uint8_t  b[200];   // 字节视图
        uint64_t q[25];    // 64位字视图(5×5 矩阵)
    } st;
    int pt, rsiz, mdlen;   // 位置指针、速率、摘要长度
} sha3_ctx_t;

五、secureRandomBytes — 密码学安全随机数

5.1 三层实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
bool secureRandomBytes(void *data, size_t len)
{
#if defined(USE_OPENSSL)
    RAND_bytes((unsigned char*)data, len);    // OpenSSL CSPRNG
    return true;

#elif defined(USE_BOTAN)
    thread_local Botan::AutoSeeded_RNG rng;   // Botan 线程本地 RNG
    rng.randomize((unsigned char*)data, len);
    return true;

#else
    // 无 TLS 后端:自实现 CSPRNG(Dan Kaminsky DEFCON 22)
    ...
#endif
}

5.2 无 TLS 后端的 CSPRNG 设计

当既没有 OpenSSL 也没有 Botan 时,trantor 自己实现了一个基于 BLAKE2b 的 CSPRNG(Cryptographically Secure Pseudo-Random Number Generator):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
系统熵源(/dev/urandom / getentropy / RtlGenRandom)
    │  每 1024 次调用更新一次 secret
RngState {
    Hash256 secret;   ← 系统熵
    Hash256 prev;     ← 上一次输出的哈希
    int64_t time;     ← 时间戳(+ shiftAmount 防时间预测)
    uint64_t counter; ← 单调递增计数器
}
    ▼ BLAKE2b(state)  ← 混合所有输入
随机输出

设计思路来自 Dan Kaminsky DEFCON 22 演讲:

  • 时间戳:两次调用恰好同一时刻极难,提供额外熵
  • 计数器:确保即使时间戳相同,输出也不同(单调增)
  • BLAKE2b 混合:不可逆压缩函数,输入微小差异导致输出完全不同
  • 周期刷新 secret:每 1024 次从系统获取新鲜熵,防止长时间预测

平台适配

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
static bool systemRandomBytes(void *ptr, size_t size)
{
#if BSD || APPLE
    arc4random_buf(ptr, size);       // BSD/macOS:内核级 CSPRNG
#elif Linux(glibc >= 2.25)
    getentropy(ptr, size);           // Linux 3.17+:直接读内核熵池
#elif Windows
    RtlGenRandom(ptr, size);         // Windows:CryptGenRandom 的轻量版
#else
    fread("/dev/urandom", ...);      // POSIX 兜底
#endif
}

5.3 trantor 中的实际使用

1
2
3
// jsonstore/main.cc — 生成随机 session token
std::array<uint8_t, 16> random;
utils::secureRandomBytes(random.data(), random.size());

六、三种后端的 API 对比

OpenSSL 后端(EVP 统一 API)

1
2
3
4
5
6
7
8
// OpenSSL 3.x 推荐方式
auto md = EVP_MD_fetch(nullptr, "SHA256", nullptr);   // 按名称获取算法
auto ctx = EVP_MD_CTX_new();
EVP_DigestInit_ex(ctx, md, nullptr);
EVP_DigestUpdate(ctx, data, len);
EVP_DigestFinal_ex(ctx, (unsigned char*)&hash, nullptr);
EVP_MD_CTX_free(ctx);
EVP_MD_free(md);

EVP_MD_fetch 是 OpenSSL 3.x 的新接口(provider 机制),可加载引擎(硬件加速)。OpenSSL < 3 用旧接口 EVP_sha256()

Botan 后端(OOP 接口)

1
2
3
4
auto hasher = Botan::HashFunction::create("SHA-256");   // 工厂模式
hasher->update((const unsigned char*)data, len);
hasher->final((unsigned char*)&hash);
// hasher 析构时自动清理

Botan 的接口更符合 C++ 面向对象风格,RAII 自动资源管理。

内置实现(纯 C)

1
2
3
4
SHA256_CTX ctx;
trantor_sha256_init(&ctx);
trantor_sha256_update(&ctx, data, len);
trantor_sha256_final(&ctx, hash.bytes);

三种后端最终都通过 Utilities.h 暴露相同的 Hash256 sha256(data, len) 接口,对用户完全透明。


七、游戏服务器实践

7.1 玩家密码存储(SHA256 + Salt)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 注册时生成密码哈希(不要直接存明文!)
std::string hashPassword(const std::string &password, const std::string &salt) {
    // salt 防彩虹表攻击:即使两个用户密码相同,哈希也不同
    std::string salted = salt + password;
    auto hash = trantor::utils::sha256(salted);
    return trantor::utils::toHexString(hash);
}

// 生成随机 salt
std::string generateSalt() {
    std::array<uint8_t, 16> random;
    trantor::utils::secureRandomBytes(random.data(), random.size());
    return trantor::utils::toHexString(random.data(), random.size());
}

// 使用示例
std::string salt = generateSalt();
std::string pwdHash = hashPassword("player_password", salt);
// 存入数据库:{ salt: salt, password_hash: pwdHash }

7.2 游戏道具验签(防篡改)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 服务器生成道具数据时附带签名
std::string signItemData(const ItemData &item, const std::string &secretKey) {
    std::string payload = item.serialize() + secretKey;
    auto hash = trantor::utils::sha256(payload);
    return trantor::utils::toHexString(hash);
}

// 收到客户端请求时验证
bool verifyItemData(const ItemData &item, const std::string &signature,
                    const std::string &secretKey) {
    std::string expected = signItemData(item, secretKey);
    // 使用常量时间比较防时序攻击
    return expected == signature;
}

7.3 文件完整性校验(MD5 用途)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 客户端更新时校验下载文件(MD5 足够用于完整性,非安全用途)
bool verifyDownloadedPatch(const std::string &filePath,
                           const std::string &expectedMd5) {
    // 读取文件
    std::ifstream file(filePath, std::ios::binary);
    std::string content((std::istreambuf_iterator<char>(file)),
                         std::istreambuf_iterator<char>());

    auto hash = trantor::utils::md5(content);
    std::string actual = trantor::utils::toHexString(hash);

    // 大小写不敏感比较(MD5 通常小写,服务端可能大写)
    std::transform(actual.begin(), actual.end(), actual.begin(), ::toupper);
    return actual == expectedMd5;
}

7.4 Session Token 生成(secureRandomBytes)

1
2
3
4
5
6
7
// 玩家登录后生成不可预测的 session token
std::string generateSessionToken() {
    std::array<uint8_t, 32> random;
    trantor::utils::secureRandomBytes(random.data(), random.size());
    return trantor::utils::toHexString(random.data(), random.size());
    // 返回 64 字符的十六进制字符串
}

八、安全使用指南

场景推荐算法原因
新代码哈希需求SHA3 或 BLAKE2b安全且无历史包袱
密码存储SHA256 + salt(或 bcrypt/argon2)抗碰撞 + 抗彩虹表
文件完整性(非安全)MD5够用,速度快
WebSocket 握手SHA1(RFC 6455 强制)无法选择
TLS 证书指纹SHA256RFC 标准
随机 token/noncesecureRandomBytes密码学安全
不安全场景MD5 / SHA1存在碰撞攻击,不用于安全场景

核心收获

  • 编译期三路后端:USE_OPENSSL → EVP API / USE_BOTAN → Botan / 无后端 → 内置纯 C,用户 API 完全不变
  • Hash128/160/256 结构体设计:类型安全(不同长度不可互换)+ 零堆分配 + 方便 memcmp
  • SHA1 保留原因:WebSocket 握手(RFC 6455)强制使用,无法替换
  • SHA3 优雅降级:OpenSSL 3.x → OpenSSL 1.x → 内置 Keccak,任何环境都能跑
  • 无 TLS 后端的 CSPRNG:BLAKE2b 混合(时间戳 + 计数器 + 周期系统熵),来自 Dan Kaminsky DEFCON 22 设计
  • toHexString 字符查表法 "0123456789ABCDEF"[c>>4]:无条件分支,输出大写十六进制

九、思考题

  1. trantor 的哈希 API 是一次性计算(一次 md5(data, len) 返回结果),而不暴露流式的 Init/Update/Final 接口。对于大文件(如游戏客户端 patch 包,可能 GB 级),必须先把整个文件读入内存才能计算 MD5,这显然不现实。如果你来设计,会在 Utilities.h 增加什么 API 支持流式哈希?

  2. secureRandomBytes 在无 TLS 后端时,每 1024 次调用才从系统获取一次新鲜熵(刷新 secret)。假设攻击者通过旁信道(如时序分析)知道了某次调用的输出,他能预测后续 1024 次内的输出吗?BLAKE2b 混合是否能阻止这种攻击?

  3. 代码注释说"MD5 don’t use for new applications",但游戏行业仍普遍用 MD5 作为文件完整性校验(配置文件、资源包)。从攻击模型分析:对于防止传输损坏,MD5 是否足够?对于防止恶意篡改,MD5 是否足够?

  4. toHexString 用大写字母(“0123456789ABCDEF”)。但 HTTP 标准(如 ETag)和 Linux 工具(如 md5sum)通常输出小写。如果 trantor 的哈希值要与外部系统对比,大小写不一致会导致什么问题?trantor 是否应该提供小写版本?


十、思考题参考答案

1. 如何设计支持大文件的流式哈希 API

问题分析

当前 API Hash256 sha256(const void *data, size_t len) 要求一次性传入所有数据。对于 GB 级的游戏 patch 包,必须先将整个文件读入内存,这对内存是灾难性的。

内置的纯 C 实现已经支持流式操作(Init/Update/Final 三段式),只是 Utilities.h 没有暴露这个接口。

设计方案

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Utilities.h 新增

/// 流式哈希上下文(类型擦除,隐藏后端实现)
class TRANTOR_EXPORT HashContext : public NonCopyable
{
  public:
    /// 创建指定算法的上下文
    enum Algorithm { MD5, SHA1, SHA256, SHA3_256, BLAKE2b_256 };
    static std::unique_ptr<HashContext> create(Algorithm algo);

    /// 追加数据(可多次调用)
    virtual void update(const void *data, size_t len) = 0;

    /// 便捷重载
    void update(const std::string &str) { update(str.data(), str.size()); }

    /// 完成计算并返回结果(调用后不能再 update)
    /// 返回原始字节数组,大小取决于算法(16/20/32字节)
    virtual std::string final() = 0;

    virtual ~HashContext() = default;
};

// 便捷包装:流式计算文件哈希
inline Hash256 sha256File(const std::string &filePath) {
    auto ctx = HashContext::create(HashContext::SHA256);
    std::ifstream file(filePath, std::ios::binary);
    char buf[8192];
    while (file.read(buf, sizeof(buf)) || file.gcount() > 0) {
        ctx->update(buf, static_cast<size_t>(file.gcount()));
    }
    Hash256 hash;
    auto result = ctx->final();
    memcpy(hash.bytes, result.data(), 32);
    return hash;
}

内部实现(以 OpenSSL 后端为例):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class OpenSSLHashContext : public HashContext {
    EVP_MD_CTX *ctx_;
    const EVP_MD *md_;
    bool finalized_ = false;
public:
    OpenSSLHashContext(Algorithm algo) {
        ctx_ = EVP_MD_CTX_new();
        switch (algo) {
            case SHA256: md_ = EVP_sha256(); break;
            case SHA3_256: md_ = EVP_sha3_256(); break;
            // ...
        }
        EVP_DigestInit_ex(ctx_, md_, nullptr);
    }
    void update(const void *data, size_t len) override {
        assert(!finalized_);
        EVP_DigestUpdate(ctx_, data, len);
    }
    std::string final() override {
        assert(!finalized_);
        finalized_ = true;
        unsigned char buf[EVP_MAX_MD_SIZE];
        unsigned int len;
        EVP_DigestFinal_ex(ctx_, buf, &len);
        return std::string(reinterpret_cast<char*>(buf), len);
    }
    ~OpenSSLHashContext() { EVP_MD_CTX_free(ctx_); }
};

设计要点

  1. 类型擦除HashContext 是纯虚基类,不同后端(OpenSSL/Botan/内置 C)各自实现,用户不感知后端差异。
  2. RAIIunique_ptr<HashContext> 保证上下文资源自动释放,不会忘记调用 final
  3. 内存友好sha256File 每次只读 8KB 到栈上缓冲区,GB 级文件也只需 8KB 内存。
  4. 保持向后兼容:原有的一次性 API sha256(data, len) 不变,新 API 是增量扩展。

2. 攻击者知道某次 secureRandomBytes 输出后能否预测后续输出

CSPRNG 的状态结构

1
2
3
4
5
6
struct RngState {
    Hash256 secret;    // 系统熵(每 1024 次刷新)
    Hash256 prev;      // 上一次输出的哈希
    int64_t time;      // 时间戳(+ 随机偏移量 shiftAmount)
    uint64_t counter;  // 单调递增计数器
};

输出 = BLAKE2b(RngState全部字段)

攻击者知道某次输出(output_N),能否预测 output_N+1?

要预测 output_N+1,攻击者需要知道 RngState 在第 N+1 次调用时的完整状态:

  1. secret(32 字节):来自系统熵源(/dev/urandomgetentropyRtlGenRandom),攻击者无法获得。即使知道 output_N 也无法反推 secret,因为 BLAKE2b 是不可逆的单向函数——这是哈希函数的基本安全属性(抗原像攻击)。

  2. prev(32 字节):等于 output_N 本身。如果攻击者知道了 output_N,那他确实知道 prev

  3. time(8 字节):CPU 时间戳(__rdtsc()cntvct_el0)加上一个随机偏移量 shiftAmountshiftAmount 是进程启动时从系统熵源获取的随机值,只计算一次,之后不变。攻击者不知道 shiftAmount,因此即使能大致估计 rdtsc 的范围,也无法精确知道 time 的值。

  4. counter(8 字节):单调递增。如果攻击者知道调用次数(如通过旁信道观察),他可以推断 counter。

综合分析

攻击者已知:prev(= output_N)、大致的 counter 范围。 攻击者未知:secret(32 字节 = 256 位熵)、shiftAmount(8 字节 = 64 位熵)。

即使攻击者枚举所有可能的 time 值,他仍然需要枚举 secret 的 2^256 种可能。这在计算上是不可行的(宇宙中所有原子数约 2^266)。

BLAKE2b 混合能否阻止攻击?

关键不在于 BLAKE2b 的"混合"能力,而在于 BLAKE2b 的单向性(one-wayness)

  • 给定 BLAKE2b(state) = output_N,无法反推 state(抗原像攻击)
  • 因此无法从 output_N 中提取 secret
  • 没有 secret 就无法计算 output_N+1

结论:攻击者知道某次输出后,不能预测后续输出。这正是 CSPRNG(密码学安全伪随机数生成器)的核心安全保证。每 1024 次刷新 secret 是额外的安全措施——即使 secret 通过某种理论攻击泄漏,影响范围也被限制在 1024 次输出以内。

3. MD5 用于防传输损坏 vs 防恶意篡改

两种攻击模型的本质区别

攻击模型攻击者能力目标
传输损坏随机比特翻转、丢包、截断(无智能对手)检测数据是否被意外修改
恶意篡改可精确控制修改内容、了解算法(智能对手)修改数据使其通过哈希校验,欺骗接收方

MD5 用于防止传输损坏:完全足够

传输错误是随机的——网络噪声、磁盘坏道等导致的比特翻转没有"意图"。随机错误恰好产生与原始数据相同 MD5 的概率是 2^(-128),约 3.4 * 10^(-39)。这个概率低到可以忽略。

MD5 的 128 位输出对于完整性校验来说位数足够,计算速度也比 SHA256 快(约 2~3 倍),这正是游戏行业仍然广泛使用 MD5 做资源文件校验的原因。

MD5 用于防止恶意篡改:不够

MD5 已被证明存在碰撞攻击——可以在合理的计算资源内(几秒到几分钟)找到两个不同的输入 A 和 B,使 MD5(A) == MD5(B)

具体威胁场景:

  1. Chosen-prefix attack:攻击者可以构造两个不同的文件(如一个正常的 patch 包和一个含恶意代码的 patch 包),使它们具有相同的 MD5。玩家下载了恶意版本,MD5 校验通过。

  2. 实际案例:2012 年 Flame 恶意软件利用 MD5 碰撞伪造了 Windows Update 的证书签名。

游戏行业的实际考量

虽然 MD5 理论上不安全,但在游戏资源文件分发场景中:

  • 攻击者需要同时控制分发服务器校验值发布渠道才能利用碰撞攻击
  • 如果攻击者已经能控制分发服务器,他可以直接替换文件和对应的 MD5 值,无论用什么哈希算法都无法防御
  • 真正的安全需要数字签名(如 RSA/ECDSA 签名),而不仅仅是哈希校验

所以实践中 MD5 用于资源完整性校验仍然是合理的"够用"选择。但对于安全敏感的场景(如验证服务端下发的配置文件、防止外挂注入),应使用 SHA256 + HMAC 或数字签名。

4. toHexString 大写字母与外部系统对比的问题

问题所在

trantor 的 toHexString 输出大写十六进制:

1
2
3
str[i * 2]     = "0123456789ABCDEF"[c >> 4];
str[i * 2 + 1] = "0123456789ABCDEF"[c & 0xf];
// 输出示例:"2CF24DBA5FB0A30E..."

而外部系统通常使用小写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Linux md5sum
echo -n "hello" | md5sum
5d41402abc4b2a76b9719d911017c592    # 全小写

# HTTP ETag
ETag: "5d41402abc4b2a76b9719d911017c592"    # 全小写

# OpenSSL
openssl dgst -sha256 file.txt
# SHA256(file.txt)= 2cf24dba5fb0a30e...    # 全小写

直接比较会失败

1
2
3
4
5
6
7
8
9
auto hash = trantor::utils::md5("hello");
std::string trantor_hex = trantor::utils::toHexString(hash);
// "5D41402ABC4B2A76B9719D911017C592"  ← 大写

std::string external_hex = "5d41402abc4b2a76b9719d911017c592";  // 从 md5sum 获取

if (trantor_hex == external_hex) {  // false!大小写不同
    // 永远不会进入
}

受影响的场景

  1. HTTP ETag 比较:HTTP 规范中 ETag 是大小写敏感的字符串比较。trantor 生成大写 ETag,CDN/代理可能生成小写 ETag,导致缓存验证失败。
  2. 数据库存储与查询:如果数据库中已有小写格式的哈希值,用 trantor 生成的大写值去查询,在大小写敏感的数据库中会找不到。
  3. 与第三方 API 对接:很多 API 文档要求小写十六进制(如 AWS S3 的 Content-MD5、GitHub API 的 SHA 等)。
  4. 日志分析:运维人员用 grep 搜索哈希值时,大小写不一致会导致搜不到。

解决方案

  1. 用户侧转换(当前的 workaround):

    1
    2
    
    std::string hex = trantor::utils::toHexString(hash);
    std::transform(hex.begin(), hex.end(), hex.begin(), ::tolower);
    
  2. trantor 应该提供的改进:增加大小写选项:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    // 方案 A:增加参数
    std::string toHexString(const void *data, size_t len, bool upperCase = true);
    
    // 方案 B:增加独立函数
    std::string toHexStringLower(const void *data, size_t len);
    
    // 方案 B 的实现:
    std::string toHexStringLower(const void *data, size_t len) {
        std::string str;
        str.resize(len * 2);
        for (size_t i = 0; i < len; i++) {
            unsigned char c = ((const unsigned char *)data)[i];
            str[i * 2]     = "0123456789abcdef"[c >> 4];     // 小写查表
            str[i * 2 + 1] = "0123456789abcdef"[c & 0xf];
        }
        return str;
    }
    
  3. 大小写不敏感比较(最稳妥的做法):在所有哈希比较场景中使用不区分大小写的比较:

    1
    2
    3
    4
    5
    
    #ifdef _WIN32
    bool hashEqual = _stricmp(a.c_str(), b.c_str()) == 0;
    #else
    bool hashEqual = strcasecmp(a.c_str(), b.c_str()) == 0;
    #endif
    

RFC 的规定:RFC 4648(Base16 编码)规定解码器必须能处理大写和小写,但推荐编码时使用大写。所以 trantor 用大写在 RFC 层面是合规的,但与业界惯例(大多数工具输出小写)不一致,容易踩坑。建议至少提供小写选项。


学习日期:2025-04-12 | 上一课:第17课_并发工具与对象池 | 课程完结