别自己造加密:一次错误使用 AES 的代码审计

密码学 · 8 分钟阅读· 7 次阅读

上个月帮人看一个项目的数据加密模块。开发者的原话是「我们用了 AES-256,很安全」。

看完代码,我一晚上没睡好。

一、原始代码

简化后大概是这样:

function encrypt($plaintext, $key) {
    $cipher = 'aes-256-ecb';
    return base64_encode(openssl_encrypt($plaintext, $cipher, $key, OPENSSL_RAW_DATA));
}

function decrypt($ciphertext, $key) {
    $cipher = 'aes-256-ecb';
    return openssl_decrypt(base64_decode($ciphertext), $cipher, $key, OPENSSL_RAW_DATA);
}

密钥在 config.php:

define('AES_KEY', 'MySecretKey2023!');

三个问题,每一个都足以致命。

二、问题一:ECB 模式

ECB(Electronic Codebook)是最简单的分组模式:每个 16 字节的块独立加密,相同的明文块产生相同的密文块。

后果:它保留了明文的结构。

最著名的例子是「ECB 企鹅」——把一张图片用 ECB 加密,图案的轮廓依然清晰可见,因为相同颜色的区域产生相同的密文。

对数据库字段来说,后果是:

  • 两个用户的密码相同 → 密文相同
  • 如果加密的是 JSON,字段的位置和长度都能被推断
  • 如果加密的是身份证号,前 6 位是地区码,可以做成字典暴力比对

ECB 不应该被用于任何真实场景。 它只应该出现在教学里。

正确的选择是 AEAD 模式,比如 AES-GCM 或 ChaCha20-Poly1305,它们同时提供加密和完整性校验。

三、问题二:没有 IV

CBC 模式需要随机 IV,而这段代码用的是 ECB,压根没有 IV 的概念。

即使换成 CBC 但 IV 固定(比如全零),问题依然存在:相同的明文产生相同的密文。

IV 必须是随机的,而且每次加密都不同,并且和密文一起存储(IV 不需要保密)。

用 GCM 时叫 nonce,要求更严格:同一个密钥下 nonce 绝对不能重复,否则会泄露密钥流,攻击者可以恢复明文。

GCM 的 nonce 通常 12 字节,用 random_bytes(12) 生成。

四、问题三:密钥硬编码且太弱

MySecretKey2023! 只有 16 个字符,而 aes-256-ecb 要求 32 字节密钥。

PHP 的 openssl_encrypt 在这种情况下的行为是:用 0 填充到 32 字节(或者截断)。也就是说实际密钥是 MySecretKey2023! + 16 个 \0。

更糟的是密钥写在代码里,代码在 git 仓库里,仓库权限是「所有开发者可读」。

任何一个离职员工都能解密所有数据。

五、正确的写法

用 GCM,密钥从环境变量读,每次加密生成随机 nonce:

function encrypt(string $plaintext, string $key): string {
    $nonce = random_bytes(12);
    $tag = '';
    $ciphertext = openssl_encrypt(
        $plaintext,
        'aes-256-gcm',
        $key,
        OPENSSL_RAW_DATA,
        $nonce,
        $tag,
        '',
        16
    );
    if ($ciphertext === false) {
        throw new RuntimeException('encrypt failed');
    }
    // 存储格式:nonce(12) + tag(16) + ciphertext
    return base64_encode($nonce . $tag . $ciphertext);
}

function decrypt(string $encoded, string $key): string {
    $raw = base64_decode($encoded, true);
    if ($raw === false || strlen($raw) < 28) {
        throw new RuntimeException('invalid payload');
    }
    $nonce = substr($raw, 0, 12);
    $tag = substr($raw, 12, 16);
    $ciphertext = substr($raw, 28);

    $plain = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
    if ($plain === false) {
        throw new RuntimeException('decrypt failed: 数据被篡改或密钥错误');
    }
    return $plain;
}

密钥:

# 生成 32 字节密钥
php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'

放在 .env 里,权限 600,不进 git。

六、还要注意的三件事

1. 密钥轮换

密钥用过两年就该换。换的时候要能做「渐进式迁移」:新数据用新密钥加密,老数据在读取时用旧密钥解密后重新加密。

数据结构里要记版本号:v1:base64...,这样才知道用哪个密钥。

2. 不要加密「需要查询的字段」

GCM 的密文每次都不一样(因为 nonce 随机),所以不能用来做 WHERE 条件查询。

如果业务需要按加密字段查询,要么用确定性加密(有泄露风险),要么加一个哈希索引列(HMAC + 固定 salt)。

3. 密钥不要和数据放一起

数据库备份里包含密文,如果密钥也在同一个备份里,加密就没有意义。

七、审计时怎么快速发现问题

# 找所有加密调用
grep -rn "openssl_encrypt\|mcrypt\|AES\|DES" --include="*.php" .

# 危险信号
grep -rn "ecb\|ECB" --include="*.php" .
grep -rn "openssl_encrypt" -A 1 --include="*.php" . | grep -i "iv\s*=\s*['\"]"

看到 ecb 三个字母,基本可以判定不合格。

看到 iv 是硬编码的字符串,也不合格。

看到 md5($password) 或者 sha1($password) 做密码存储,也不合格(应该用 password_hash)。

八、最后

那个项目的开发者后来问我:「这些加密库不是应该帮我处理好吗?」

这是个好问题。答案是:加密库只保证算法正确,不保证用法正确。

OpenSSL 会老老实实按你说的用 ECB 加密,它不会提醒你「这样不安全」。密码学的坑在于:错误的用法不会报错,只会悄悄地不安全。

所以第一条原则永远是:不要自己设计加密方案,用成熟的、被审过的库和模式(libsodium 的 crypto_secretbox 就是很好的选择,PHP 有 sodium 扩展)。

第二条:不要在业务代码里直接调 openssl_encrypt,包一层,把 nonce、tag、格式这些都封好,业务层只调 encrypt() / decrypt()。

评论 (0)

还没有评论,来说两句吧~

发表评论

验证码

提交后需站长审核,通过后才会显示。