Crypto 方向古典密码与现代密码

面向 CTF 竞赛的密码学地图:从凯撒、维吉尼亚、仿射、Hill 等古典密码的频率分析与重合指数破解,到 AES-ECB 与 CBC 误用、Padding Oracle、CTR nonce 重用、RC4 与 MT19937 状态恢复、MD5 长度扩展攻击,并逐类给出检测手段与工程防御启示。

引言

CTF 的 Crypto 方向和其他方向的气质很不一样。Web 与 Pwn 考的是「怎么把一条漏洞链串起来」,而 Crypto 题通常只丢给你一段密文、一个加密脚本、或者一个持续提供加解密服务的靶机,剩下的全靠你判断「这套密码学用法哪里不标准」。真实世界里密码算法本身极少被数学攻破,绝大多数事故都源于协议设计与 API 误用,CTF 恰好把这些误用放大成了一道可解、有唯一答案的题。

这也是为什么学习 CTF Crypto 的收益并不局限于打比赛。一个工程师如果亲手用 Padding Oracle 从 CBC 密文里逐字节还原出明文,他这辈子都不会再在代码里写出「解密失败返回 null、填充错误抛异常」这种可区分的错误路径;一个亲手做过长度扩展攻击的人,才会真正理解为什么 sha256(secret || msg) 做 MAC 是错的,而 HMAC 是必需的。

本专题走的是竞赛与密码学原理视角,与本站企业防御视角的安全内容互补。如果你还没有整体方向感,可以先看 CTF 竞赛全景与学习路径 建立知识框架;想直接跳到公钥体系,本专题另有一篇专门讲 RSA 的攻击手法,可以对照阅读。

本文按「先建地图、再拆手法」的顺序展开:先给出题型分类与解题心法,然后依次覆盖古典密码、频率分析、编码与棋盘类密码、现代分组密码的误用模式、流密码与伪随机数、哈希结构缺陷,最后落到工具链与工程防御启示。每一类手法都会配一段「真实系统如何避免同类问题」,因为那才是这些技巧真正的价值所在。

目录

  1. CTF Crypto 题型地图与解题心法
  2. 古典密码:移位、仿射与维吉尼亚
  3. 频率分析与重合指数:数学直觉
  4. 棋盘类与编码类:Playfair、Hill 与摩斯
  5. 分组密码:ECB 与 CBC 的经典误用
  6. Padding Oracle 与 IV 翻转
  7. 流密码与伪随机数:CTR 重用、RC4、MT19937
  8. 哈希与长度扩展攻击
  9. 工具链与工程防御启示

1. CTF Crypto 题型地图与解题心法

Crypto 题大致可以分成六类,每类的入手方式完全不同。先做分类再动手,比盲目套工具有效得多。

类别典型题面常用破法工具
古典密码一串纯字母暴力 + 频率分析CyberChef、手写脚本
编码混淆一堆符号或方括号链式解码识别CyberChef、dCode
现代对称给了加密脚本与密文找模式误用(ECB/CBC/CTR)pycryptodome
现代非对称n、e、c 三个大整数参数退化攻击sage、gmpy2、RsaCtfTool
哈希与协议MAC、签名、承诺长度扩展、碰撞、伪造hashpumpy、z3
数学题格、椭圆曲线、LCG数学构造与求解sage、z3

解题心法可以浓缩成三句话。第一,先看「给了什么」:给了源码就逐行读,寻找随机源、填充方式、模式选择;给了服务就把它当黑盒,观察输入输出长度与错误信息;只给密文就先做长度统计与编码识别。第二,找「哪里不标准」:固定 IV、固定 nonce、无填充、自实现分组模式、把随机数种子写死,这些都是出题点。第三,善用「差分」:绝大多数现代密码攻击的本质都是观察两个相关输入的输出差异。

一个很实用的习惯是把题面信息整理成表格:明文长度、密文长度、是否分组对齐、是否可重复加密、服务是否返回错误细节。这四个问题回答完,一半的题已经能确定方向了。

2. 古典密码:移位、仿射与维吉尼亚

古典密码的共同特点是密钥空间极小,几乎都能暴力破解,真正需要技巧的是如何在一堆候选里挑出正确明文。

凯撒密码就是移位密码 c = (p + k) mod 26,k 只有 26 种可能,直接全量爆破再肉眼挑可读文本即可:

import string

def caesar_bruteforce(ct):
    for k in range(26):
        pt = ''.join(
            chr((ord(ch) - 65 - k) % 26 + 65) if ch.isupper() else
            chr((ord(ch) - 97 - k) % 26 + 97) if ch.islower() else ch
            for ch in ct)
        print(k, pt)  # 26 行输出里挑可读的那一行

caesar_bruteforce("WKH TXLFN EURZQ IRA MXPSV RYHU WKH ODCB GRJ")

仿射密码是 c = (a*p + b) mod 26,要求 gcd(a, 26) = 1,因此 a 只有 12 个合法取值,b 有 26 个,总计 312 组密钥,暴力同样够用。破解时先枚举 a,再用模逆还原:p = a_inv * (c - b) mod 26,其中 a_inv = pow(a, -1, 26)(Python 3.8 起 pow 支持负指数求模逆)。

维吉尼亚密码是「多表凯撒」,密钥循环使用,密钥空间变成 26^len(key),暴力不可行。破解分两步:先用 Kasiski 测试或重合指数估计密钥长度,再把密文按 i mod keylen 拆成若干列,每列退化成一个凯撒密码,逐列做频率分析即可。这三类密码构成了古典部分的主干,其余变体大多是它们的组合或包装。

维吉尼亚的密钥长度估计

密钥长度一旦确定,剩下就是逐列卡方检验。把每一列的字母频数与该列对应的凯撒偏移做匹配,取卡方距离最小者作为该列密钥字符:

import string
ENGLISH = [8.17, 1.49, 2.78, 4.25, 12.70, 2.23, 2.02, 6.09, 6.97, 0.15,
           0.77, 4.03, 2.41, 6.75, 7.51, 1.93, 0.10, 5.99, 6.33, 9.06,
           2.76, 0.98, 2.36, 0.15, 1.97, 0.07]

def chi_square(column):
    n = len(column)
    score = 0.0
    for i, ch in enumerate(string.ascii_uppercase):
        observed = column.count(ch)
        expected = n * ENGLISH[i] / 100
        score += (observed - expected) ** 2 / expected
    return score

def recover_key(ct, keylen):
    key = ""
    for i in range(keylen):
        col = [c for c in ct[i::keylen].upper() if c.isalpha()]
        best = min(range(26), key=lambda s: chi_square(
            [chr((ord(c) - 65 - s) % 26 + 65) for c in col]))
        key += string.ascii_uppercase[best]
    return key

注意一个容易被忽略的细节:卡方检验对每列的长度敏感,列长低于 30 个字符时统计噪声会主导结果,此时应优先信任 Kasiski 给出的候选长度,或者用更长的人工构造样本来校准。

3. 频率分析与重合指数:数学直觉

频率分析的依据是自然语言里字母分布极不均匀:英语中 E、T、A、O、I、N 五个字母合计占比约 40%,而 Z、Q、X 各不到 0.2%。单表替换密码不改变这个分布,只是把字母重命名了一遍,所以密文中出现最频繁的字符大概率对应 E。

重合指数(Index of Coincidence,IC)把「分布是否像自然语言」量化成一个数。设密文长度 N,字母 i 出现 f_i 次,则:

IC = sum(f_i * (f_i - 1)) / (N * (N - 1))

它的含义是「随机抽两个字符,它们相同的概率」。英语长文本 IC 约 0.065,而完全均匀的随机文本约 0.038。这个差值就是维吉尼亚破解的抓手:如果把密文按错误长度分组,每组内是混合了多个凯撒的密文,IC 会趋近 0.038;按正确密钥长度分组,每组是纯单表替换,IC 会升到 0.06 以上。

参考用的英文字母频率(百分比)如下表,做卡方检验或频率分析时直接查表即可:

字母频率字母频率字母频率
E12.70L4.03Z0.07
T9.06D4.25Q0.10
A8.17C2.78J0.15
O7.51U2.76X0.15
I6.97M2.41K0.77
N6.75W2.36V0.98
S6.33F2.23B1.49
H6.09G2.02P1.93
R5.99Y1.97

实操代码非常短:

def index_of_coincidence(text):
    text = [c for c in text.upper() if c.isalpha()]
    n = len(text)
    if n < 2:
        return 0.0
    freqs = {}
    for c in text:
        freqs[c] = freqs.get(c, 0) + 1
    return sum(f * (f - 1) for f in freqs.values()) / (n * (n - 1))

for keylen in range(1, 21):
    avg = sum(index_of_coincidence(ct[i::keylen]) for i in range(keylen)) / keylen
    print(keylen, round(avg, 4))  # 平均 IC 明显抬升的那个 keylen 就是答案

为什么它可靠

IC 的可靠性来自大数定律:文本越长,统计量越接近期望值。密文短于 50 个字符时,IC 波动会掩盖真实密钥长度,这时应该换用 Kasiski 测试,寻找重复三元组的间距并求其最大公约数。两种方法结合使用,命中率最高。

4. 棋盘类与编码类:Playfair、Hill 与摩斯

Playfair 使用 5x5 方阵(合并 I/J),把明文两两分组后按三条规则加密:同行的两个字母各右移一位;同列的两个字母各下移一位;不同行列的取对角。破解要点是它保留了双字母频率特征,且无法加密重复字母对(如 LL 需插入填充字母 X 拆开),因此密文长度恒为偶数。遇到「字母表只有 25 个字符、长度偶数」的题面,先试 Playfair。

Hill 密码是线性代数型密码,C = K * P mod 26,K 是 n×n 矩阵且 det(K) 与 26 互素。它的致命弱点是完全线性:只要拿到 n 组已知明文密文对,就能直接解出密钥矩阵 K = C * P_inv mod 26。如果只给了密文,也可以做已知明文假设(比如开头是 flag{),凑出 n 组即可。

import numpy as np
from sympy import Matrix

P = Matrix([[7, 4], [11, 11]])   # 已知明文 "HELL" 分块
C = Matrix([[7, 8], [11, 11]])   # 对应密文
K = C * P.inv_mod(26) % 26       # 解出密钥矩阵
print(K)

需要明确区分的是「加密」与「编码」。摩斯电码、AAencode、JSFuck、Brainfuck、Base 系列都只是编码或混淆,不含密钥,理论上总能还原。它们的价值在于隐藏信息,属于 Misc 与 Crypto 的交界地带,遇到时优先用 CyberChef 的 Magic 模式做链式识别,逐层剥离直到出现可读结构。

5. 分组密码:ECB 与 CBC 的经典误用

现代分组密码算法本身(AES、SM4、ChaCha20)在 CTF 里几乎无法攻破,出题点全在「模式与参数怎么用错了」。

ECB 的最大特征是确定性:相同明文块加密后得到相同密文块。这既让密文呈现「结构可见」的图案(经典的 ECB 企鹅图),也让攻击者能检测出明文块的重复。检测方法一行就能写:

def has_ecb_pattern(ct: bytes, bs: int = 16) -> bool:
    blocks = [ct[i:i+bs] for i in range(0, len(ct), bs)]
    return len(set(blocks)) < len(blocks)  # 有重复块则高度怀疑 ECB

更进一步的 ECB Oracle 攻击可以逐字节还原未知明文:攻击者控制明文前缀,通过调整前缀长度把目标字节「挤」到块边界,然后枚举该字节的 256 种取值,观察哪个候选产生的密文块与目标块一致。这是分组密码选择明文攻击最经典的教科书示例,也直接说明了「ECB 不该用于结构化数据」。

def ecb_byte_at_a_time(oracle, bs=16):
    # oracle(attacker_input) -> AES-ECB(secret_prefix + attacker_input + secret)
    known = b""
    for i in range(1, 64):
        pad = b"A" * (bs - i % bs) if i % bs else b""
        # 用填充把目标字节推到块尾,再枚举候选
        block_index = (len(pad) + len(known)) // bs - 1
        target = oracle(pad)[block_index * bs:(block_index + 1) * bs]
        for guess in range(256):
            probe = pad + known + bytes([guess])
            if oracle(probe)[block_index * bs:(block_index + 1) * bs] == target:
                known += bytes([guess])
                break
    return known

这段代码的核心不是实现细节,而是它揭示的事实:ECB 把「加密」变成了「带字典的查表」,任何可控制前缀的接口都能被逐字节剥开。工程上的结论是,ECB 只适用于加密完全随机、无结构、且不重复的定长数据(例如单块密钥包装),一旦明文有结构或可被攻击者影响,就必须换成带随机 IV 的 CBC 或直接上 AEAD。

CBC 模式引入了前一块密文的异或,隐藏了重复结构,但如果 IV 固定、可预测,或者服务对填充错误与解密错误给出不同响应,就会退化成可攻击的形态。下面两节分别讲这两条路径。

6. Padding Oracle 与 IV 翻转

CBC 解密时,最后一组需要按 PKCS#7 规则去除填充:填充值 n 表示末尾有 n 个字节、每个字节的值都是 n。合法填充只有 1 到 16 这 16 种形态,其余全是非法。如果服务在填充非法时返回 500、合法但内容错误时返回 200,这个差异就是一个 Padding Oracle,攻击者可以逐字节解出整个明文,而不需要任何密钥。

原理是:解密的中间值 I = D(C_i) 与 P_i = I xor C_{i-1}。攻击者控制 C_{i-1} 的最后一个字节,尝试 256 个取值,当服务报告「填充合法」时,说明该字节让 P_i 的末字节变成了 0x01,于是 I 的末字节等于 guess xor 0x01。拿到末字节后,再构造末两字节为 0x02 0x02,依次递推,直到整块解出。平均每字节 128 次请求,一块 16 字节约 2000 次,完全可行。

def padding_oracle_block(oracle, prev, cur, bs=16):
    inter = bytearray(bs)
    plain = bytearray(bs)
    for pad in range(1, bs + 1):
        for g in range(256):
            forged = bytearray(prev)
            forged[bs - pad] = g
            for j in range(1, pad):        # 已解出的字节改造成合法填充
                forged[bs - j] = inter[bs - j] ^ pad
            if oracle(bytes(forged), cur):
                inter[bs - pad] = g ^ pad
                plain[bs - pad] = inter[bs - pad] ^ prev[bs - pad]
                break
    return bytes(plain)

IV 翻转(bit-flipping)是同一原理的轻量版:如果明文结构是 user=xxx;admin=0,攻击者只需翻转 IV 中对应位置的比特,就能把 0 改成 1,代价是那一块的前一个字节会被破坏,通常用注释符或空格填充来吃掉这个副作用。

工程启示

这两条攻击在真实系统里的修复方式非常明确:其一,不要用 CBC 加 MAC 的拼装方式,直接上 AEAD(AES-GCM、ChaCha20-Poly1305),认证失败就整体拒绝;其二,所有错误路径必须对外不可区分,填充错误、MAC 错误、解密错误统一返回同一个响应码与同一耗时;其三,比较 MAC 时使用常量时间函数(如 hmac.compare_digest),避免时序侧信道。认证与会话层的完整设计可以对照 认证与会话安全 一起看。

7. 流密码与伪随机数:CTR 重用、RC4、MT19937

流密码的安全前提是「密钥流永不重复」。CTR 模式把分组密码当成密钥流生成器,nonce 与计数器拼接后加密得到的每个块都是密钥流。一旦 nonce 重用,两条密文异或就消掉了密钥流:

C1 xor C2 = (P1 xor KS) xor (P2 xor KS) = P1 xor P2

此时只要知道其中一条明文(比如已知 flag{ 开头或一段已知格式的报文),另一条立刻还原。CTF 里常见形态是「服务每次加密都用固定的 nonce」或者「用时间戳当 nonce,而你可以控制时间」。

RC4 的问题在于密钥调度算法(KSA)在密钥弱相关时会泄漏偏差,且前若干字节的输出分布明显偏离均匀。它的结构只有二十来行:

def rc4(key: bytes, data: bytes) -> bytes:
    S = list(range(256))
    j = 0
    for i in range(256):                       # KSA:用密钥打乱状态表
        j = (j + S[i] + key[i % len(key)]) % 256
        S[i], S[j] = S[j], S[i]
    out, i, j = bytearray(), 0, 0
    for byte in data:                          # PRGA:生成密钥流并异或
        i = (i + 1) % 256
        j = (j + S[i]) % 256
        S[i], S[j] = S[j], S[i]
        out.append(byte ^ S[(S[i] + S[j]) % 256])
    return bytes(out)

CTF 里 RC4 常见的考点是「丢弃前 N 字节」的伪加固:丢弃前 256 或 768 字节确实能消除最明显的偏差,但并不能修复算法本身的设计缺陷。它的现代工程结论很简单:任何新系统都不应再使用 RC4,TLS 早在 RFC 7465 中将其列为禁止套件。

MT19937 是 Python random 模块的底层生成器,状态空间为 624 个 32 位字。这意味着只要拿到连续 624 个 32 位输出,就能通过线性反演完整恢复内部状态,此后所有过去与未来的输出都可预测。CTF 里常见的题目形态是「用 random 生成密钥或 OTP」,解法就是收集输出后做状态恢复:

from randcrack import RandCrack

rc = RandCrack()
for i in range(624):
    rc.submit(leaked_outputs[i])   # 必须是完整的 32 位 getrandbits 输出
next_secret = rc.predict_getrandbits(32)

工程启示

密码学用途的随机数必须来自操作系统 CSPRNG:Linux 上是 getrandom(2) 或 /dev/urandom,Python 里是 secrets 模块,Go 里是 crypto/rand。random、Math.random、rand() 这类统计随机数生成器可以用于抽奖、模拟、采样,绝不能用于密钥、IV、nonce、令牌或验证码。

8. 哈希与长度扩展攻击

MD5、SHA-1、SHA-256 都采用 Merkle-Damgard 结构:消息先按块填充,每个块的压缩函数以上一块的输出为初始向量。这带来一个结构性后果:给定 H(m),攻击者不需要知道 m,就能算出 H(m || padding || extra)。只要 MAC 的构造是 H(secret || message),攻击者就能在不知道 secret 的情况下追加内容并伪造出合法 MAC。

结构上可以这样理解它的链式传递:

IV ---> [ F ] ---> h1 ---> [ F ] ---> h2 ---> [ F ] ---> digest
          ^                  ^                  ^
         m1                 m2                 m3

由于摘要本身就是最后一个压缩函数的输出,而压缩函数是公开的,攻击者只要把 digest 当作新的 IV,把原始消息连同它自带的填充一起视为「已压缩的部分」,就能从任意位置继续往后压缩。这就是长度扩展不需要密钥的全部原因:状态是可移植的。

攻击步骤是:把已知的 H(secret || message) 作为压缩函数的初始状态,把 padding 当成消息的一部分,然后继续压缩攻击者想追加的数据。工具上可以用 hashpumpy 或 hash_extender 复现:

hashpumpy -m 8 -a "&admin=1" -s "abcdefgh" \
  -d "user=guest" 5f4dcc3b5aa765d61d8327deb882cf99

需要记住的是,SHA-3 与 BLAKE2 采用海绵结构,不受长度扩展影响;而 HMAC 通过内外两层哈希、以及对密钥做 ipad/opad 异或,从设计上规避了这个问题。所以结论不是「别用 SHA-256」,而是「别用裸哈希当 MAC」。

构造是否受长度扩展影响建议
H(secret || msg)是禁用
H(msg || secret)否但存在碰撞风险不推荐
HMAC-SHA256否推荐
KMAC / BLAKE2 keyed否现代替代方案

9. 工具链与工程防御启示

CTF Crypto 的效率很大程度上取决于工具熟练度。下面这张表是日常解题的核心工具箱。

工具用途典型场景
CyberChef编码识别与链式转换Base/摩斯/ROT/压缩包套娃
pycryptodome对称加解密原语复现题目加密脚本
z3-solver约束求解线性方程组、位运算约束
sage数论与格运算非对称、格攻击、Coppersmith
gmpy2大整数快速运算开方、模逆、连分数
hashpumpy长度扩展MAC 伪造

本地靶场怎么搭

复现这些手法不需要任何真实目标,一个几十行的 Flask 服务就够了。关键是把「误用」显式地写出来:

from flask import Flask, request
from Crypto.Cipher import AES

app = Flask(__name__)
KEY = b"0123456789abcdef"

@app.route("/oracle")
def oracle():
    ct = bytes.fromhex(request.args["ct"])
    pt = AES.new(KEY, AES.MODE_CBC, ct[:16]).decrypt(ct[16:])
    pad = pt[-1]
    if not 1 <= pad <= 16 or pt[-pad:] != bytes([pad]) * pad:
        return "padding error", 500          # 这个分支就是漏洞本身
    return "ok", 200

把它跑在本地,用前面那段 padding_oracle_block 就能逐字节还原明文,全程不涉及任何外部系统。这类靶场也是理解「为什么错误信息必须统一」最快的途径。

工程侧的防御清单可以归纳成五条:用 AEAD 而不是裸模式;IV 与 nonce 必须每次随机或严格递增且不可预测;MAC 用 HMAC 或 AEAD 自带认证,不要自造拼装方案;错误信息对外统一,避免成为 Oracle;不要自己实现密码学,用经过审计的库(libsodium、Google Tink、语言标准库的 crypto 包)。这套原则与纵深防御的整体思路一致:密码学是最后一道防线,它的正确性靠规范而不是靠聪明。

权衡取舍

手法适用前提复杂度是否需已知明文
频率分析单表替换、密文足够长低否
Kasiski + IC多表替换、密钥循环中否
ECB Oracle可控前缀 + 可重复加密中否
Padding Oracle可区分填充错误中高否
IV 翻转已知明文结构低部分
CTR 重用nonce 重复低一条已知明文
长度扩展H(secret || msg) 形态中已知消息与 MAC
MT19937 恢复泄漏 624 个 32 位输出中否

古典密码靠统计,现代密码靠「误用识别」。前者是数学,后者是工程直觉,训练方式完全不同:古典部分建议手写脚本而不依赖工具,现代部分建议自己搭一个本地靶场把每种误用都复现一遍。

常见坑清单

  1. 把编码当加密:Base64、摩斯、JSFuck 无密钥,先解码再判断是否还有一层密码。
  2. 忽略密文长度:长度是否整除 16 直接决定是分组密码还是流密码,是分组又是否补齐。
  3. 只试一次就放弃:凯撒、仿射这类小密钥空间必须全量枚举后人工筛选。
  4. 混淆 IC 与 Kasiski 的适用条件:短密文用 IC 会误判密钥长度,应改用重复序列间距。
  5. 忘记 ECB 检测:拿到密文先做重复块检测,能省掉大量猜测。
  6. 把「解密失败」当成单点问题:只要错误信息可区分,CBC 就退化成了 Oracle。
  7. 误以为 SHA-256 免疫长度扩展:结构问题而非强度问题,裸哈希做 MAC 一律不安全。
  8. 用 random 生成密钥:MT19937 线性可逆,泄露 624 个输出即全盘失守。
  9. 内网/本地靶场之外的代码照抄:CTF 脚本用于授权环境,真实系统只做检测与防御验证。
  10. 跳过源码阅读:给了加密脚本却不读,等于放弃了出题人留下的全部线索。

小结

Crypto 方向的题面千变万化,但底层只有两条主线:一是统计规律,古典密码、频率分析、重合指数都在这条线上;二是实现误用,ECB 的确定性、CBC 的错误可区分性、CTR 的 nonce 唯一性、Merkle-Damgard 的结构性,全都属于「标准算法用错」的范畴。抓住这两条线,绝大多数题都能归位。

从学习路径上看,建议先用手写脚本把凯撒、仿射、维吉尼亚、Hill 全部实现一遍,建立对密钥空间与统计量的直觉;然后用 pycryptodome 在本地搭一套 AES 靶场,把 ECB Oracle、Padding Oracle、IV 翻转三种攻击各复现一次;最后补上哈希与 PRNG 的结构性缺陷。这套练习做完,再去看 Crypto 方向 RSA 常见攻击手法 里的公钥体系,会发现攻击思路完全同构:都是找参数退化与结构泄漏。

最后提醒一点:本文所有技术都以 CTF 题目与授权靶场为前提。密码学攻击的价值在于理解「为什么标准做法是那样设计的」,把结论带回工程实践去写更安全的代码,而不是去攻击没有授权的系统。工具与流程的完整梳理可以继续阅读 CTF 工具链与攻击视角下的防御 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「网络安全攻防」更多文章

  1. 流量分析与协议逆向
  2. 椭圆曲线与格攻击
  3. Windows 提权与 AD 内网渗透