加密失败:明文传输、弱哈希与硬编码密钥
这一项在 2017 年叫 Sensitive Data Exposure(敏感数据暴露),2021 改名叫 Cryptographic Failures(加密失败)。改名改得很准 —— 问题的根源不是"数据被暴露了"这个结果,而是加密没做对这个原因。
一、加密失败长什么样
明信片、透明保险箱,和自以为安全的拼音
你给朋友寄一张明信片,上面写着银行卡密码。邮递员、分拣员、任何一个经手的人都能看到。
- 完全不加密 = 明信片
- 加密了但密钥写在明信片上 = 用一个透明保险箱装现金,还把钥匙挂在箱子上
- 用了自己发明的加密算法 = 用拼音写密码,觉得别人看不懂
加密失败 就是指:该加密的没加密、加密了但算法/模式/密钥管理有问题、或者加密强度早已跟不上算力。
六大坑位:从明文传输到关掉证书校验
| # | 坑位 | 典型表现 | 后果 |
|---|---|---|---|
| 1 | 明文传输 | HTTP 而非 HTTPS;内网接口走 HTTP | 中间人可嗅探 |
| 2 | 弱哈希存密码 | MD5 / SHA1 直接存,甚至加盐都没有 | 撞库即破 |
| 3 | 硬编码密钥 | 密钥写在代码、配置文件、前端 JS 里 | 一次泄露全盘皆输 |
| 4 | 错误的工作模式 | AES 用 ECB 模式;IV 固定或复用 | 密文可被重排/分析 |
| 5 | 弱随机数 | 用 rand() / Math.random() 生成 token、验证码、盐 | 可被预测 |
| 6 | 证书校验被关闭 | 代码里 verify=False、信任所有证书 | HTTPS 形同虚设 |
从「能碰到密文」到「能还原明文」
一个"加密失败"要转化为实际危害,通常需要:
- 攻击者能接触到密文/哈希值 —— 拖库、抓包、日志泄露、备份文件外泄。
- 加密方案存在可逆/可碰撞/可预测的弱点 —— 弱算法、已知密钥、可预测随机。
- 缺少有效的爆破门槛 —— 无盐、无迭代、无限速、无失败锁定。
💡 踩坑提示:很多人觉得"MD5 不可逆,所以存 MD5 是安全的"。错在两个地方:一是彩虹表早就覆盖了常见密码的 MD5;二是现在一张消费级显卡跑 MD5 的速率是 每秒数百亿次,6 位纯数字密码几秒就能穷举完。"不可逆"不等于"不可破"。
影响分为什么这么高
OWASP 数据:CWE 集合 29 个,平均加权利用率 6.54%,平均加权影响 7.07% —— 影响分是 A01 之外最高的,因为一旦爆了就是数据本身泄露,往往直接触发合规处罚。
典型场景:
- 用户密码库泄露 —— 弱哈希导致拖库后明文可还原,进而撞库其他站点(用户习惯复用密码)。
- 敏感字段明文入库 —— 身份证、手机号、银行卡明文存储,DBA 或任何拿到只读权限的人都能看。
- Cookie / Token 可预测 —— 用时间戳或自增数做 session id,可直接伪造他人身份。
- 短信/邮箱验证码可爆破 —— 4 位纯数字 + 无限速 = 万分之一概率,脚本几十次就中。
- 内网 HTTP 传输 —— 开发觉得"内网是安全的",但内网横向移动正是攻击者的常规动作。
- 云上 OSS/S3 桶公开 —— 加密做得再好,桶权限开了 public-read 一切归零。
📌 真实场景:某次内部排查,发现一个老系统的密码字段是
MD5(password)无盐。我们用自己的密码字典跑了一遍,8 小时内还原了约 73% 的用户明文密码。其中相当一部分跟他们的邮箱密码相同 —— 这就是典型的"单点加密失败,跨域连锁爆破"。
二、怎么查出来
代码里的危险特征(按语言速查)
审计加密问题,本质是找**"用了什么"和"怎么用"**。前者看算法选型,后者看使用方式。
危险函数速查表(按语言)
| 语言 | 危险特征 | 说明 |
|---|---|---|
| Java | MessageDigest.getInstance("MD5") / ("SHA-1") | 弱哈希 |
| Java | Cipher.getInstance("AES") / ("AES/ECB/PKCS5Padding") | ECB 模式(默认就是 ECB!) |
| Java | new SecureRandom() 未设种子 vs new Random() | Random 可预测 |
| Python | hashlib.md5() / hashlib.sha1() | 弱哈希 |
| Python | random.random() / random.randint() 用于安全场景 | 弱随机 |
| Python | Crypto.Cipher.AES.new(key, AES.MODE_ECB) | ECB |
| PHP | md5($pass) / sha1($pass) | 弱哈希 |
| PHP | rand() / mt_rand() 生成 token | 弱随机(mt_rand 可被逆向种子) |
| JS | Math.random() 生成验证码/token | 弱随机 |
| 通用 | verify=False / trustAllCerts / CURLOPT_SSL_VERIFYPEER, false | 关闭证书校验 |
💡 踩坑提示(Java 特有):
Cipher.getInstance("AES")在 SunJCE 里的默认模式就是 ECB。这是 Java 密码学 API 最臭名昭著的一个坑 —— 开发者以为自己"用了 AES",实际用的是最不安全的模式。审计时凡看到只写"AES"的,一律按 ECB 处理。
审计的正则起点:
# 在代码库中搜索加密相关的可疑写法
# 弱哈希
grep -rnE "MessageDigest\.getInstance\(\"(MD5|SHA-1|SHA1)\"\)|hashlib\.(md5|sha1)|md5\(|sha1\(" --include=*.{java,py,php,js}
# ECB 模式
grep -rnE "MODE_ECB|\"AES\"|\"DES\"|\"RC4\"|\"3DES\"" --include=*.{java,py,php}
# 硬编码密钥(重点看长度像密钥的字符串常量)
grep -rnE "(key|secret|password|passwd|pwd|token)\s*=\s*[\"'][A-Za-z0-9+/=]{16,}[\"']" --include=*.{java,py,php,js,yml,yaml,properties}
# 关闭证书校验
grep -rnE "verify\s*=\s*False|VERIFYPEER.*false|trustAllCerts|setHostnameVerifier" --include=*
# 弱随机
grep -rnE "Math\.random\(\)|new Random\(\)|rand\(|mt_rand\(|random\.random\(\)" --include=*.{java,py,php,js}📌 审计思路:这些 grep 会有一堆误报(比如
md5用于文件校验是没问题的)。过滤原则是看上下文 —— 如果这个哈希/随机的结果参与了身份认证、会话、令牌、密码、加密密钥,那就是问题;如果只是做文件完整性校验、缓存 key,那可以放过。
黑盒:看传输、看 Cookie、看哈希形态
观察点一:传输是否加密
# 检查是否强制 HTTPS,以及是否有 HSTS
curl -sI http://target.com/login | head -20
# 关注:
# - 301/302 是否跳转到 https
# - 是否有 Strict-Transport-Security 响应头
# - 登录表单的 action 是否指向 http://观察点二:Cookie 安全属性
curl -sI https://target.com/login | grep -i "set-cookie"
# 正确示例应包含:
# Set-Cookie: sessionid=xxx; Secure; HttpOnly; SameSite=Lax
# 缺 Secure → 可通过 HTTP 明文传输(可被中间人劫持)
# 缺 HttpOnly → 可被 XSS 窃取
# 缺 SameSite → 存在 CSRF 风险观察点三:密码/令牌的形态分析
拿到一个哈希或密文,先判断它是什么算法:
| 特征 | 判断 |
|---|---|
| 32 位十六进制 | MD5 |
| 40 位十六进制 | SHA-1(或 SHA-1 加盐) |
| 64 位十六进制 | SHA-256 |
$2a$ / $2b$ / $2y$ 开头 | bcrypt ✅ 安全 |
$argon2i$ / $argon2id$ | Argon2 ✅ 安全 |
$1$ 开头 | MD5crypt(弱) |
$6$ 开头 | SHA-512crypt |
以 = 结尾的 Base64 | 可能是对称加密结果 |
| 长度固定且重复片段多 | 高度怀疑 ECB 模式 |
ECB 模式的快速识别法:ECB 的特点是相同的明文块产生相同的密文块。所以如果你能看到同一明文(比如同一个密码)对应的密文,密文完全一致 → 高度怀疑 ECB。
# 实践方法:用相同明文注册/提交两次,对比返回的密文/token
# 若两次密文完全一致,且长度是 16 字节的整数倍 → ECB 可能性极大观察点四:响应里的敏感信息
# 抓登录/注册/找回密码的响应,看是否泄露加密细节
curl -s -X POST https://target.com/api/login \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"test123"}' | jq .
# 关注:是否返回了 password_hash、salt、token 生成规则、详细错误堆栈怎么判断一个哈希是什么算法
| 检测项 | 请求特征 | 判断依据 |
|---|---|---|
| 明文传输 | http:// 而非 https:// | 抓包可直接看到账号密码 |
| 混合内容 | HTTPS 页面里加载 http:// 资源 | 浏览器控制台报 Mixed Content |
| 弱哈希 | 拖库/日志/接口返回哈希值 | 用 hashcat 能在合理时间内还原 |
| 硬编码密钥 | 前端 JS 里能看到密钥/盐 | 翻 .js 文件,搜 key/secret/salt |
| 可预测 token | 连续取两次 token,对比差异 | 有规律(时间戳、自增)即可预测 |
| 验证码可爆破 | 验证码端无限速 | 重放 20 次无锁定 |
| ECB | 相同明文 → 相同密文 | 密文块可重排替换 |
💡 踩坑提示:验证码/密码重置令牌的长度和字符集直接决定爆破难度。4 位纯数字 = 1 万种;6 位数字 = 100 万种;但如果没有速率限制且没有过期时间,100 万次对脚本来说也不是问题(分布式+并发,几小时)。关键永远是"是否限频"和"是否一次性失效",而不是位数。
三、怎么打穿它
⚠️ 以下内容仅限授权渗透测试 / 自有系统 / 安全研究环境中使用。
hashcat:从字典到掩码
这是 A02 最经典的验证手段 —— 拿到哈希,证明能在可接受时间内还原明文。
# hashcat 基本用法
# 格式: hashcat -m <哈希类型码> -a <攻击模式> <哈希文件> <字典/掩码>
# ===== 常用哈希类型码 =====
# 0 = MD5
# 100 = SHA1
# 1400= SHA256
# 3200= bcrypt
# 1800= sha512crypt (Unix $6$)
# 1000= NTLM (Windows)
# ===== 攻击模式 =====
# 0 = 字典攻击(Straight)
# 3 = 掩码暴力破解(Brute-force)
# 6 = 字典 + 掩码组合
# 9 = 组合攻击
# 场景 1:字典攻击 MD5(最快,先跑这个)
hashcat -m 0 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt
# -m 0 → MD5 算法
# -a 0 → 字典模式,逐行尝试字典中的密码
# hashes.txt → 每行一个哈希值
# rockyou.txt → 经典密码字典(约 1400 万条)
# 场景 2:掩码暴力破解(已知密码是 8 位数字,如手机号后 8 位)
hashcat -m 0 -a 3 hashes.txt ?d?d?d?d?d?d?d?d
# -a 3 → 掩码模式
# ?d → 数字占位符 (0-9)
# 其他占位符:?l=小写字母 ?u=大写字母 ?s=特殊字符 ?a=全部可打印字符
# 场景 3:字典 + 后缀(模拟 "password123" 这类常见拼接)
hashcat -m 0 -a 6 hashes.txt /usr/share/wordlists/rockyou.txt ?d?d?d
# -a 6 → 字典在前,掩码追加在后
# 场景 4:带盐的哈希(salt 在哈希文件里以 hash:salt 形式给出)
hashcat -m 10 hashes_with_salt.txt rockyou.txt
# -m 10 = md5($pass.$salt)
# ===== 实用参数 =====
# --show → 显示已破解的结果
# --force → 强制运行(虚拟机/无 GPU 环境需要)
# -O → 优化内核,提速但限制密码长度
# -w 3 → 工作负载级别(1-4,越高越吃资源)
# --status → 运行时按 s 查看进度
# 查看破解结果
hashcat -m 0 -a 0 hashes.txt rockyou.txt --show预期回显:
$ hashcat -m 0 hashes.txt rockyou.txt --show
5f4dcc3b5aa765d61d8327deb882cf99:password
e10adc3949ba59abbe56e057f20f883e:123456
25d55ad283aa400af464c76d713c07ad:12345678
Session..........: hashcat
Status...........: Cracked ← 表示已破解如果能还原出明文,漏洞即成立。报告中要注明破解耗时和使用的字典规模,这决定了定级(几分钟破解 = 严重)。
💡 踩坑提示:无 GPU 的环境(比如普通虚拟机)跑 hashcat 会非常慢,加
-O和-w 3能改善一些,但 MD5 大字典仍可能要几小时。先小范围验证(取 100 个哈希试试),确认可行性再全量跑,别一上来就卡住几小时。
ECB 块重排:不用密钥也能改数据
ECB 的致命弱点:每个 16 字节块独立加密,块与块之间没有关联。所以攻击者可以像搭积木一样重排、替换密文块。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ECB 块重排演示 —— 仅用于授权测试
原理:AES-ECB 下相同明文块产生相同密文块,攻击者无需知道密钥,
仅通过观察和重排密文块即可篡改明文语义。
"""
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import os
KEY = os.urandom(32) # 攻击者并不知道这个密钥
def encrypt(plaintext: bytes) -> bytes:
"""服务端加密函数(攻击者只能调用它,看不到里面)"""
cipher = AES.new(KEY, AES.MODE_ECB)
return cipher.encrypt(pad(plaintext, 16))
def decrypt(ciphertext: bytes) -> bytes:
"""服务端解密函数"""
cipher = AES.new(KEY, AES.MODE_ECB)
return unpad(cipher.decrypt(ciphertext), 16)
# ---------- 场景:一个转账/支付接口,明文格式固定 ----------
# 假设明文结构为: user=<用户名>&amount=<金额>&to=<收款方>
# 攻击者能控制 user 字段,想提权成 admin 或篡改 amount
BS = 16 # AES 块大小
def pkcs7_pad(data: bytes) -> bytes:
pad_len = BS - (len(data) % BS)
return data + bytes([pad_len]) * pad_len
# 技巧:通过填充对齐,让目标内容独占一个完整的块
# 比如构造 user 名,使 "amount=999999" 恰好对齐到块边界
crafted_user = b"A" * (BS - len(b"user=")) # 把 "user=" 填满第一个块
# 现在后续内容从块边界开始,便于整块替换
# 构造一个"探针":加密一个我们控制内容且对齐的明文
probe_plain = pkcs7_pad(crafted_user + b"&amount=999999&to=attacker")
probe_ct = encrypt(probe_plain)
# 提取 "amount=999999" 所在的密文块(第 2 块,索引 1)
target_block = probe_ct[BS:BS*2]
print(f"[*] 提取到的目标密文块: {target_block.hex()}")
# 用这个块替换掉真实请求中对应的块
# 攻击者重放真实请求时,把对应块换成 target_block
# 服务端解密后,amount 就变成了 999999
print("[!] ECB 下无需密钥即可完成密文替换 —— 这就是 ECB 的危害")
print("[!] 修复方案:改用 AES-GCM 或 AES-CBC+HMAC(带随机 IV 与完整性校验)")关键点:
- 攻击者全程不知道密钥,只利用了 ECB 的确定性。
- 更著名的演示是 ECB Penguin(ECB 企鹅):加密一张图片后仍能看到企鹅轮廓,因为相同颜色的像素块加密后仍相同。
- 定级参考:ECB 用于加密结构化数据(如 token、订单、权限字段)时可导致权限提升或数据篡改,属于高危。
弱随机:可预测的验证码与重置令牌
很多系统的"密码重置令牌"、验证码、session id 用了不安全的随机源。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
弱随机数预测演示 —— 仅用于授权测试
原理:Python 的 random 模块基于 Mersenne Twister,
观察到足够多的输出后可逆向推算出内部状态,进而预测后续所有输出。
"""
import random
import time
# ---------- 场景 1:基于时间戳种子的可预测 token ----------
def weak_token_v1():
"""典型错误:用时间戳做种子"""
random.seed(int(time.time())) # 种子 = 当前秒级时间戳
return ''.join(random.choice('0123456789abcdef') for _ in range(16))
# 攻击:攻击者知道大致的请求时间,暴力枚举时间戳即可复现 token
def crack_timestamp_seed(target_token: str, time_window: int = 3600):
"""在目标时间前后 1 小时窗口内枚举种子"""
now = int(time.time())
for ts in range(now - time_window, now + time_window):
random.seed(ts)
candidate = ''.join(random.choice('0123456789abcdef') for _ in range(16))
if candidate == target_token:
return ts
return None
# ---------- 场景 2:Mersenne Twister 状态逆向 ----------
# Python random 是 MT19937:收集 624 个 32-bit 输出即可完全还原内部状态
# 之后所有"随机"输出都可预测
MT_N = 624
def untemper(y: int) -> int:
"""逆 MT19937 的 tempering 变换,还原状态字"""
y ^= y >> 18
y ^= (y << 15) & 0xefc60000
# 逆向 y ^= (y << 7) & 0x9d2c5680
for _ in range(5):
y = (y ^ ((y << 7) & 0x9d2c5680)) & 0xffffffff
return y
def clone_mt(observed: list):
"""用 624 个观测值克隆 MT19937 状态"""
if len(observed) < MT_N:
raise ValueError(f"至少需要 {MT_N} 个观测值,当前 {len(observed)}")
state = [untemper(x) for x in observed[:MT_N]]
rng = random.Random()
rng.setstate((3, tuple(state) + (MT_N,), None))
return rng
print("[*] 收集 624 个连续随机输出后,即可完全预测该生成器的后续输出")
print("[*] 修复方案:改用 secrets 模块或 os.urandom(基于操作系统 CSPRNG)")安全写法对照:
# ❌ 不安全:可预测
import random
token = ''.join(random.choice('0123456789abcdef') for _ in range(32))
# ✅ 安全:密码学安全随机
import secrets
token = secrets.token_hex(16) # 32 位十六进制字符串
token = secrets.token_urlsafe(32) # URL 安全的 base64 字符串放大器:那些让破解变便宜的错误
加密类问题的"绕过"更多是降低破解成本:
| 场景 | 手法 | 说明 |
|---|---|---|
| 加盐但盐固定 | 一次彩虹表覆盖全库 | 盐必须每用户随机 |
| 加盐但盐太短 | 盐空间小,仍可预计算 | 盐至少 16 字节 |
| 哈希迭代次数少 | 单轮 SHA256 加盐仍可高速爆破 | 必须用 bcrypt/Argon2/PBKDF2 这类慢哈希 |
| 客户端加密后传输 | 看 JS 拿到密钥/算法,等于明文 | 客户端加密不能替代 TLS |
| 自研算法 | 逆向 APP/JS 即可还原 | 永远用标准算法 |
| 密码找回令牌不失效 | 可无限次复用 | 必须一次性 + 短有效期 |
📌 真实场景:某系统"密码找回"用
MD5(用户名 + 时间戳)生成重置链接,且链接永不失效。攻击者只要知道用户名(通常公开),枚举时间戳就能生成任意用户的重置链接。这是把"弱哈希 + 可预测输入 + 无过期"三个错误叠在一起了 —— 加密设计里,错误是会相乘的。
四、怎么修对
存密码:别用通用哈希
唯一正确答案:用专门的密码哈希算法,不要用通用哈希加密密码。
# ✅ Python 推荐:Argon2id(OWASP 首选)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# 配置参数(OWASP 推荐基线)
ph = PasswordHasher(
time_cost=3, # 迭代次数,越大越慢越安全
memory_cost=65536, # 内存开销 64MB —— 这是 Argon2 抗 GPU 的关键
parallelism=4, # 并行度
hash_len=32, # 输出长度
salt_len=16 # 盐长度(Argon2 自动生成随机盐,无需自己管)
)
# 注册时
hashed = ph.hash("user_password_123")
# 存储形如: $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHRzb21lc2FsdA$xxxx
# 登录时
try:
ph.verify(hashed, "user_password_123")
# 检查是否需要重新哈希(参数升级后自动迁移)
if ph.check_needs_rehash(hashed):
new_hash = ph.hash("user_password_123")
# 更新数据库中的哈希值
except VerifyMismatchError:
print("密码错误")// ✅ Java 推荐:bcrypt(Spring Security 内置,成熟稳定)
@Bean
public PasswordEncoder passwordEncoder() {
// strength=12 是当前推荐基线;每增加 1,耗时翻倍
// 建议以"单次校验耗时 250-500ms"为标准调参
return new BCryptPasswordEncoder(12);
}
// 使用
@Autowired private PasswordEncoder encoder;
public void register(String rawPassword) {
String encoded = encoder.encode(rawPassword); // 内部自动生成随机盐
userRepo.save(new User(encoded));
}
public boolean login(String raw, String stored) {
return encoder.matches(raw, stored); // 恒定时间比较,防时序攻击
}算法选型对照表:
| 算法 | 是否推荐 | 说明 |
|---|---|---|
| Argon2id | ✅ 首选 | 抗 GPU/ASIC,OWASP 第一推荐 |
| bcrypt | ✅ 推荐 | 成熟、广泛支持,注意 72 字节截断 |
| scrypt | ✅ 可用 | 抗 ASIC,内存硬 |
| PBKDF2-HMAC-SHA256 | ⚠️ 可用 | NIST 认可,迭代 ≥ 600000 |
| SHA256/512 加盐 | ❌ 禁止 | 太快,可被 GPU 高速爆破 |
| MD5 / SHA1 | ❌ 禁止 | 已破解 |
💡 踩坑提示:bcrypt 会截断超过 72 字节的密码。如果用户用了超长密码或超长 passphrase,后面的字符会被静默丢弃。要么在前端限制长度,要么先做一次 SHA-256 再送进 bcrypt(即
bcrypt(sha256(password)))。
对称加密:为什么必须是 GCM 而不是 CBC
唯一正确答案:AES-256-GCM(认证加密),永远不要手写加密模式。
// ✅ Java:AES-GCM 正确用法
public class CryptoUtil {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int IV_LEN = 12; // GCM 推荐 12 字节 IV
private static final int TAG_LEN = 128; // 认证标签长度(位)
public static String encrypt(String plaintext, SecretKey key) throws Exception {
// 关键 1:每次加密都生成全新的随机 IV(绝不能复用!)
byte[] iv = new byte[IV_LEN];
SecureRandom.getInstanceStrong().nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LEN, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// 关键 2:IV 不需要保密,但要和密文一起存储/传输
// 常见格式:IV(12字节) + 密文
byte[] result = new byte[IV_LEN + ciphertext.length];
System.arraycopy(iv, 0, result, 0, IV_LEN);
System.arraycopy(ciphertext, 0, result, IV_LEN, ciphertext.length);
return Base64.getEncoder().encodeToString(result);
}
public static String decrypt(String encrypted, SecretKey key) throws Exception {
byte[] data = Base64.getDecoder().decode(encrypted);
// 拆分 IV 和密文
byte[] iv = Arrays.copyOfRange(data, 0, IV_LEN);
byte[] ciphertext = Arrays.copyOfRange(data, IV_LEN, data.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LEN, iv));
// 关键 3:GCM 自带完整性校验,密文被篡改会抛 AEADBadTagException
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
}
}为什么必须是 GCM 而不是 CBC:
- CBC 只加密不认证 —— 攻击者可以翻转密文位来修改明文(Padding Oracle 攻击的基础)。
- GCM 是 AEAD(认证加密) —— 密文被改一个 bit,解密时直接报错,从根上杜绝篡改。
- 如果必须用 CBC(老系统兼容),必须额外做 HMAC,且要用
MessageDigest.isEqual做恒定时间比较(防时序攻击)。
密钥管理:最容易被一击致命的地方
密钥永远不要写进代码。 这是 A02 里最容易在代码审计中一击致命的点。
# ❌ 绝对禁止:硬编码在配置文件里
# application.yml
crypto:
key: "5f4dcc3b5aa765d61d8327deb882cf99" # 这个密钥已经跟着代码进了 Git 历史
# ✅ 正确:从环境变量或 KMS 读取
# application.yml
crypto:
key: ${CRYPTO_KEY} # 运行时注入,代码里只有占位符推荐架构:
| 方案 | 适用场景 | 说明 |
|---|---|---|
| 云 KMS(腾讯云 KMS / AWS KMS) | 生产环境首选 | 密钥永不离开 KMS,应用只调用加解密 API |
| Vault | 混合云/自建 | 动态密钥、自动轮转、详细审计 |
| 环境变量 + 配置中心 | 中小项目 | 最低限度,密钥与代码分离 |
| 密钥轮转 | 所有场景 | 定期轮换;泄露时立即使旧密钥失效 |
# 检测密钥是否已泄露进 Git 历史(这步一定要做)
# 用 gitleaks 扫描完整历史
gitleaks detect --source . --log-opts="--all" -v
# 或用 trufflehog
trufflehog git file://. --since-commit HEAD~100
# 若发现密钥已提交,仅删除当前文件无效 —— 必须清理 Git 历史
# git filter-repo(推荐,比 filter-branch 快且安全)
git filter-repo --path path/to/secret-file --invert-paths💡 踩坑提示:发现密钥进了 Git,第一反应应该是吊销/轮转密钥,而不是删文件。因为在你删之前,它可能已经被镜像、被 fork、被 CI 缓存,或者已经被自动化爬虫抓走了(GitHub 上 secrets 的平均存活时间是几分钟)。
传输层:HTTPS 与安全头
强制 HTTPS + HSTS:
# Nginx 全站 HTTPS + 安全头配置
server {
listen 80;
server_name example.com;
# 所有 HTTP 请求永久跳转到 HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
# TLS 配置:只启用 TLS 1.2+,禁用已知不安全协议
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# 优先使用支持前向安全(ECDHE)的套件
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_timeout 1d;
ssl_session_tickets off; # 关闭 session ticket 以支持前向安全
# HSTS:强制浏览器后续只走 HTTPS(max-age 建议 1 年起)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# 其他安全头
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
# Cookie 安全属性由应用层设置,这里可做兜底
proxy_cookie_flags ~ secure samesite=lax;
}代码里绝对禁止关闭证书校验:
# ❌ 绝对禁止
requests.get("https://api.example.com", verify=False)
# ✅ 正确(默认就是校验的,显式写出来更清晰)
requests.get("https://api.example.com", verify="/path/to/ca-bundle.crt")📌 排查技巧:CI 里加一条静态检查,禁止
verify=False合入:bashif grep -rnE "verify\s*=\s*False|VERIFYPEER.*false" --include=*.py --include=*.php --include=*.java .; then echo "❌ 检测到关闭 SSL 证书校验的代码,禁止合入" exit 1 fi
检测:加密失败靠行为发现,不靠拦截
加密失败主要靠检测数据泄露行为,而非拦截请求:
# 检测规则:异常数据下载行为(批量拉取敏感字段)
id: bulk-sensitive-data-access
detection:
condition: threshold
rules:
- endpoint_matches: /api/(user|customer|order)/(list|export|search)
- response_contains_fields: [id_card, phone, bank_card, password]
threshold:
count: 100 # 单次会话内请求 100 次以上
window: 300s # 5 分钟内
action: alert
severity: critical
note: "可能是拖库行为,需人工确认"
# 检测规则:认证爆破(配合 A07)
id: credential-stuffing-detect
detection:
condition: threshold
rules:
- endpoint: /api/login
- status: 401
threshold:
count: 20
window: 60s
distinct_source: true
action: block_and_alert数据库层面:
-- MySQL:审计敏感表的访问(企业版审计插件或通用日志)
-- 定期检查是否有异常的全表扫描/导出
-- 检查是否存在明文存储敏感字段(自查 SQL)
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
AND (
COLUMN_NAME REGEXP 'pass(word|wd)?|pwd' -- 疑似密码字段
OR COLUMN_NAME REGEXP 'id_card|idcard|idno' -- 身份证
OR COLUMN_NAME REGEXP 'bank|card_no' -- 银行卡
OR COLUMN_NAME REGEXP 'phone|mobile|tel' -- 手机号
)
AND DATA_TYPE IN ('varchar','char','text'); -- 明文存储类型
-- 对结果逐项确认:是否加密存储?密钥在哪?回归验证清单
#!/usr/bin/env bash
# 加密修复回归验证脚本
echo "=========== 1. TLS 配置检查 ==========="
# 检查协议版本(应只有 TLS1.2/1.3)
echo | openssl s_client -connect target.com:443 -tls1_1 2>&1 | grep -q "Protocol" \
&& echo "[FAIL] TLS 1.1 仍可用" || echo "[PASS] TLS 1.1 已禁用"
# 检查证书有效期
echo | openssl s_client -connect target.com:443 -servername target.com 2>/dev/null \
| openssl x509 -noout -dates -subject
echo "=========== 2. HSTS 检查 ==========="
curl -sI https://target.com | grep -i "strict-transport-security" \
&& echo "[PASS] HSTS 已启用" || echo "[FAIL] 缺少 HSTS 头"
echo "=========== 3. Cookie 安全属性检查 ==========="
COOKIE=$(curl -sI https://target.com/login | grep -i "set-cookie" | head -1)
echo "$COOKIE"
echo "$COOKIE" | grep -qi "secure" && echo "[PASS] Secure" || echo "[FAIL] 缺 Secure"
echo "$COOKIE" | grep -qi "httponly" && echo "[PASS] HttpOnly" || echo "[FAIL] 缺 HttpOnly"
echo "$COOKIE" | grep -qi "samesite" && echo "[PASS] SameSite" || echo "[FAIL] 缺 SameSite"
echo "=========== 4. 密码哈希算法验证 ==========="
# 从数据库取一条哈希,确认算法已升级(应由 DBA 在授权环境执行)
# 期望看到 $2a$/$2b$ (bcrypt) 或 $argon2id$,而非 32/40 位十六进制
# mysql> SELECT LEFT(password, 20) FROM users LIMIT 1;
# 期望: $2a$12$xxxxxxxxxxxx 或 $argon2id$v=19$
echo "=========== 5. 硬编码密钥扫描 ==========="
if command -v gitleaks &> /dev/null; then
gitleaks detect --source . --no-git -v \
&& echo "[PASS] 未发现硬编码密钥" || echo "[FAIL] 发现疑似密钥,需处理"
else
echo "[SKIP] gitleaks 未安装,建议: brew install gitleaks / 下载二进制"
fi
echo "=========== 6. 关闭证书校验检查 ==========="
if grep -rnE "verify\s*=\s*False|VERIFYPEER.*false" --include=*.py --include=*.php . 2>/dev/null; then
echo "[FAIL] 存在关闭证书校验的代码"
else
echo "[PASS] 未发现关闭证书校验"
fi
echo "=========== 完成 ==========="验证清单(Checklist):
- [ ] 全站 HTTPS,HTTP 全部 301 跳转,HSTS 已启用
- [ ] TLS 仅 1.2+,禁用弱套件,证书未过期
- [ ] 密码使用 Argon2id / bcrypt / PBKDF2,参数达标
- [ ] 对称加密使用 AES-GCM,每次随机 IV
- [ ] 密钥存储于 KMS/Vault,代码中无硬编码
- [ ] Git 历史已扫描,无泄露密钥
- [ ] 随机令牌使用 CSPRNG(
secrets/SecureRandom) - [ ] Cookie 具备 Secure + HttpOnly + SameSite
- [ ] 验证码/令牌有限频、有过期、一次性失效
- [ ] 敏感日志已脱敏(配合 A09)
最后:密码学最危险的用法,是「自己觉得够用了」
加密失败这一类问题有个很反直觉的特点:它几乎不会在功能测试里暴露。系统该登录登录、该加密加密、该返回返回,一切正常 —— 直到数据被拖走的那一刻。
所以对付它只能靠规范 + 自动检查:
- 不用通用哈希存密码 —— Argon2id / bcrypt,让框架替你加盐和调参。
- 只用 AEAD 模式 —— AES-GCM,别再手写 CBC/ECB。
- 密钥进 KMS —— 代码里出现密钥字符串,就是一个待爆的雷。
- CI 里卡死反模式 ——
verify=False、硬编码密钥、弱随机,一律不准合入。
密码学最危险的用法,就是"自己觉得够用了"。
⚠️ 声明:本文所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行破解或测试,违反者需自行承担法律责任。