别自己造加密:一次错误使用 AES 的代码审计
上个月帮人看一个项目的数据加密模块。开发者的原话是「我们用了 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)
还没有评论,来说两句吧~
发表评论