Skip to content

加密失败:明文传输、弱哈希与硬编码密钥

这一项在 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 形同虚设

从「能碰到密文」到「能还原明文」

一个"加密失败"要转化为实际危害,通常需要:

  1. 攻击者能接触到密文/哈希值 —— 拖库、抓包、日志泄露、备份文件外泄。
  2. 加密方案存在可逆/可碰撞/可预测的弱点 —— 弱算法、已知密钥、可预测随机。
  3. 缺少有效的爆破门槛 —— 无盐、无迭代、无限速、无失败锁定。

💡 踩坑提示:很多人觉得"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% 的用户明文密码。其中相当一部分跟他们的邮箱密码相同 —— 这就是典型的"单点加密失败,跨域连锁爆破"。


二、怎么查出来

代码里的危险特征(按语言速查)

审计加密问题,本质是找**"用了什么""怎么用"**。前者看算法选型,后者看使用方式。

危险函数速查表(按语言)

语言危险特征说明
JavaMessageDigest.getInstance("MD5") / ("SHA-1")弱哈希
JavaCipher.getInstance("AES") / ("AES/ECB/PKCS5Padding")ECB 模式(默认就是 ECB!)
Javanew SecureRandom() 未设种子 vs new Random()Random 可预测
Pythonhashlib.md5() / hashlib.sha1()弱哈希
Pythonrandom.random() / random.randint() 用于安全场景弱随机
PythonCrypto.Cipher.AES.new(key, AES.MODE_ECB)ECB
PHPmd5($pass) / sha1($pass)弱哈希
PHPrand() / mt_rand() 生成 token弱随机(mt_rand 可被逆向种子)
JSMath.random() 生成验证码/token弱随机
通用verify=False / trustAllCerts / CURLOPT_SSL_VERIFYPEER, false关闭证书校验

💡 踩坑提示(Java 特有)Cipher.getInstance("AES") 在 SunJCE 里的默认模式就是 ECB。这是 Java 密码学 API 最臭名昭著的一个坑 —— 开发者以为自己"用了 AES",实际用的是最不安全的模式。审计时凡看到只写 "AES" 的,一律按 ECB 处理。

审计的正则起点

bash
# 在代码库中搜索加密相关的可疑写法
# 弱哈希
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、看哈希形态

观察点一:传输是否加密

bash
# 检查是否强制 HTTPS,以及是否有 HSTS
curl -sI http://target.com/login | head -20
# 关注:
#   - 301/302 是否跳转到 https
#   - 是否有 Strict-Transport-Security 响应头
#   - 登录表单的 action 是否指向 http://

观察点二:Cookie 安全属性

bash
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。

bash
# 实践方法:用相同明文注册/提交两次,对比返回的密文/token
# 若两次密文完全一致,且长度是 16 字节的整数倍 → ECB 可能性极大

观察点四:响应里的敏感信息

bash
# 抓登录/注册/找回密码的响应,看是否泄露加密细节
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 最经典的验证手段 —— 拿到哈希,证明能在可接受时间内还原明文。

bash
# 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 字节块独立加密,块与块之间没有关联。所以攻击者可以像搭积木一样重排、替换密文块。

python
#!/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 用了不安全的随机源。

python
#!/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)")

安全写法对照

python
# ❌ 不安全:可预测
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
# ✅ 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
// ✅ 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
// ✅ 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 里最容易在代码审计中一击致命的点。

yaml
# ❌ 绝对禁止:硬编码在配置文件里
# application.yml
crypto:
  key: "5f4dcc3b5aa765d61d8327deb882cf99"   # 这个密钥已经跟着代码进了 Git 历史

# ✅ 正确:从环境变量或 KMS 读取
# application.yml
crypto:
  key: ${CRYPTO_KEY}        # 运行时注入,代码里只有占位符

推荐架构

方案适用场景说明
云 KMS(腾讯云 KMS / AWS KMS)生产环境首选密钥永不离开 KMS,应用只调用加解密 API
Vault混合云/自建动态密钥、自动轮转、详细审计
环境变量 + 配置中心中小项目最低限度,密钥与代码分离
密钥轮转所有场景定期轮换;泄露时立即使旧密钥失效
bash
# 检测密钥是否已泄露进 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
# 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;
}

代码里绝对禁止关闭证书校验

python
# ❌ 绝对禁止
requests.get("https://api.example.com", verify=False)

# ✅ 正确(默认就是校验的,显式写出来更清晰)
requests.get("https://api.example.com", verify="/path/to/ca-bundle.crt")

📌 排查技巧:CI 里加一条静态检查,禁止 verify=False 合入:

bash
if grep -rnE "verify\s*=\s*False|VERIFYPEER.*false" --include=*.py --include=*.php --include=*.java .; then
  echo "❌ 检测到关闭 SSL 证书校验的代码,禁止合入"
  exit 1
fi

检测:加密失败靠行为发现,不靠拦截

加密失败主要靠检测数据泄露行为,而非拦截请求:

yaml
# 检测规则:异常数据下载行为(批量拉取敏感字段)
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

数据库层面

sql
-- 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');      -- 明文存储类型
-- 对结果逐项确认:是否加密存储?密钥在哪?

回归验证清单

bash
#!/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)

最后:密码学最危险的用法,是「自己觉得够用了」

加密失败这一类问题有个很反直觉的特点:它几乎不会在功能测试里暴露。系统该登录登录、该加密加密、该返回返回,一切正常 —— 直到数据被拖走的那一刻。

所以对付它只能靠规范 + 自动检查

  1. 不用通用哈希存密码 —— Argon2id / bcrypt,让框架替你加盐和调参。
  2. 只用 AEAD 模式 —— AES-GCM,别再手写 CBC/ECB。
  3. 密钥进 KMS —— 代码里出现密钥字符串,就是一个待爆的雷。
  4. CI 里卡死反模式 —— verify=False、硬编码密钥、弱随机,一律不准合入。

密码学最危险的用法,就是"自己觉得够用了"。


⚠️ 声明:本文所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行破解或测试,违反者需自行承担法律责任。

SecLab 网络安全博客 · 内容仅供安全研究与学习,请勿用于未授权活动