4412 字
22 分钟
现代密码学实验四:RSA 加密体制破解

这次现代密码学实验源自 2016 年全国密码学竞赛赛题三:RSA 加密体制的破解。实验提供了 21 个 RSA 加密的密文帧(Frame),每帧使用不同的 RSA 密钥对一个 8 字节的消息片段加密,总共编码了 16 个唯一的消息片段。目标是通过分析 RSA 密钥生成过程中的多种弱点,综合运用密码分析技术,破解全部 21 帧密文并恢复完整的明文消息。

跟前三次实验最大的不同是:这是一个综合性的密码分析任务。21 个密文帧背后是不同的密钥生成错误,每一帧可能需要不同的攻击方法。面对这样的题目,应该先系统地梳理数据,找出可以利用的结构信息。

实验环境#

实验使用 SageMath 完成。SageMath 在 Python 的基础上集成了数论、代数、多项式等数学工具,特别是 Coppersmith 方法(small_roots)和扩展 GCD(xgcd),是 RSA 密码分析的理想环境。部分涉及大整数快速运算的代码也使用了 gmpy2 库。

目录中的主要文件如下:

  • lab4_solution.sage:完整的 SageMath 求解脚本
  • prng_break.py:弱 PRNG 种子恢复攻击的独立 Python 实现
  • 附件3-1(加密案例)/:4 个已知参数的 Frame(用于理解加密格式)
  • 附件3-2(发布截获数据)/:21 个待破解的 Frame

数据格式分析#

在开始攻击之前,必须先充分理解数据格式。附件 3-1(加密案例)给出了已知密钥的样本,从中可以提取完整的加密结构和消息填充格式。

Frame 格式#

每个 Frame 文件是一个 ASCII 十六进制字符串,包含三段连续数据:

def parse_frame(filepath):
with open(filepath, 'rb') as f:
data = f.read().decode('ascii')
n = int(data[0:256], 16) # 模数 N(256 个 hex = 1024 位)
e = int(data[256:512], 16) # 公钥指数 e
c = int(data[512:768], 16) # 密文 c
return n, e, c

消息填充结构#

从附件 3-1 的已知参数可以还原出 128 字节的消息填充格式:

字节 0-63: 全零
字节 64-71: 标记 0x9876543210ABCDEF
字节 72-75: 消息序号(4 字节大端,范围 0-15)
字节 76-119: 全零
字节 120-127: 8 字节消息片段(ASCII 编码)

加密前的填充消息 m 即为这 128 字节的大整数表示。这意味着对于任何帧,只有最后 8 字节(64 位)是真正未知的,其余 120 字节(包括标记和序号位置)是已知的填充结构。这个结构是后续 Coppersmith 攻击的关键前提。

附件 3-1 的信息#

附件 3-1 使用两对密钥加密了测试明文 "This is a test of my RSA system."(32 字节,分割为 4 个 8 字节片段):

  • Frame 0-1:共享一对 1024 位密钥 (p₁, q₁, n₁),e = 0x10001
  • Frame 2-3:共享另一对密钥 (p₂, q₂, n₂),其中 q₂ 仅 40 位0xE00258CB6F

第二个密钥对中 q₂ 仅 40 位本身就暗示了脆弱性的存在——实际应用中素数至少应有 512 位以上,但实验环境中这种”故意留下的弱点”恰好是理解攻击的入口。

攻击方法总览#

对 21 帧数据做初步分析,可以整理出以下攻击路线:

攻击类型适用帧核心弱点
GCD 共模因子1, 18两个模数共享一个素数因子
Pollard p-1 分解2, 6, 19p-1 是光滑数
共模攻击0, 4相同模数,不同 e,加密同一消息
Coppersmith 小指数3, 7, 8, 11, 12, 15, 16, 20e=3 或 5,高比特位已知
Fermat 分解10p 和 q 过于接近
弱 PRNG 种子恢复5, 9, 13, 14, 17素数生成使用 16 位种子的 LCG
(附注)e>N 时的指数处理共模攻击中 Frame 4 的 e 超过 N 的数学处理

攻击一:GCD 共模因子攻击(Frame 1, 18)#

原理#

如果两个 RSA 模数 N₁ 和 N₂ 共享一个素数因子 p,那么直接计算最大公约数即可分解两者:

p = gcd(N₁, N₂)
q₁ = N₁ / p
q₂ = N₂ / p

这种情况在现实中出现的原因通常是:多个设备或证书使用了同一个有缺陷的随机数生成器,在生成素数时状态碰撞,导致”不同”的模数实际上共享了因子。

实现#

直接计算所有 21 帧模数之间的 GCD,发现 Frame 1 和 18 的模数共享 512 位公共因子。通过扩展欧几里得算法求模逆得到私钥 d,解密获得:

Frame 1: ". Imagin"(序号 11)
Frame 18: "m A to B"(序号 10)

两个帧快速被破解。GCD 攻击的一个变体是批处理 GCD:如果有几百个模数,可以用乘积树在 O(n log² n) 时间内找出所有共享因子对。2012 年就有研究者用这种方法在大规模 HTTPS 证书扫描中发现了数万个可分解的证书。

攻击二:Pollard p-1 分解(Frame 2, 6, 19)#

原理#

Pollard p-1 方法在素数 p 满足”p-1 是 B-smooth”(即 p-1 的所有素因子都不超过 B)时有效。核心思想基于费马小定理:

如果 p-1 的所有素因子都 ≤ B,那么 p-1 整除 B!。因此对于任意与 p 互素的整数 a:

aB!1(modp)a^{B!} \equiv 1 \pmod{p}

那么 pgcd(aB!1,N)p \mid \gcd(a^{B!} - 1, N)。算法实现从 a=2 开始,迭代计算 aajmodna \leftarrow a^j \bmod n(j 从 2 到 B),最后取 gcd(a1,n)\gcd(a-1, n)

def pollard_pm1(n, B=100000):
a = 2
for j in range(2, B + 1):
a = pow(a, j, n)
g = gcd(a - 1, n)
return g if 1 < g < n else None

实验结果#

逐步增加边界 B 进行搜索:

Frame 2: B=50000 时找到 510 位因子 → " That is"(序号 6)
Frame 19: B=50000 时找到 40 位因子 → "instein."(序号 5)
Frame 6: B=200000 时找到 40 位因子 → "Logic " (序号 7)

Frame 2 的 510 位因子说明 p-1 虽然数值很大,但其素因子分解全部 ≤ 50000,是一个高度光滑的”弱素数”。Frame 6 和 19 的素因子仅 40 位,直接可分解。安全实现中应该选择 p-1 含有一个大素因子的”强素数”来抵抗此攻击。

攻击三:共模攻击(Frame 0, 4)#

原理#

Frame 0 和 Frame 4 使用相同的模数 N,不同的公钥指数 e₀ 和 e₄,加密了相同的消息 m。若 gcd(e0,e4)=1\gcd(e₀, e₄) = 1,利用扩展欧几里得算法找到整数 a、b 使得:

ae0+be4=1a \cdot e_0 + b \cdot e_4 = 1

那么:

c0ac4bmae0mbe4mae0+be4m(modN)c_0^a \cdot c_4^b \equiv m^{a \cdot e_0} \cdot m^{b \cdot e_4} \equiv m^{a \cdot e_0 + b \cdot e_4} \equiv m \pmod{N}

如果 a 为负数(通常如此),需要计算对应密文在模 N 下的逆元再幂次。

实现与附注#

n0 = ZZ(intercepted[0][0])
e0, e4 = ZZ(intercepted[0][1]), ZZ(intercepted[4][1])
c0, c4 = ZZ(intercepted[0][2]), ZZ(intercepted[4][2])
_, a, b = xgcd(e0, e4)
if a < 0:
m = (pow(c0.inverse_mod(n0), int(-a), n0) * pow(c4, int(b), n0)) % n0
else:
m = (pow(c0, int(a), n0) * pow(c4.inverse_mod(n0), int(-b), n0)) % n0

一个值得注意的细节是 Frame 4 的 e 值超过了 N。在标准 RSA 中 e 通常远小于 φ(N),但这里的异常取值并不影响共模攻击的数学关系——扩展欧几里得算法和模逆运算在 e > N 时仍然有效。

Frame 0/4: "My secre"(序号 0)

共模攻击的教训很明确:绝不应对不同接收者使用相同的模数。即使加密指数不同,只要同一条消息被加密两次,攻击者就能直接恢复明文。这就是为什么每个 RSA 密钥对必须独立生成。

攻击四:Coppersmith 小指数攻击(Frame 3, 7, 8, 11, 12, 15, 16, 20)#

原理#

这是整个实验中解决帧数最多的攻击方法。当公钥指数 e 很小(如 e = 3 或 5)且消息的大部分比特已知时,Coppersmith 方法可以在多项式时间内恢复未知的低比特位。

本实验中,128 字节的消息中只有最后 8 字节消息文本(64 位)和 4 字节序号是未知的,其余 120 字节是已知的填充结构。由于序号只有 21 种可能(0-20),可以对每种假设分别构造多项式求解。

加密方程为:

me=(known_high+x)ec(modN)m^e = (\text{known\_high} + x)^e \equiv c \pmod{N}

定义多项式 f(x)=(known_high+x)ecf(x) = (\text{known\_high} + x)^e - c 在模 N 下。如果未知量 x 足够小( < N^{1/e}),Coppersmith 定理保证可以在多项式时间内找到小根。

实现与关键调试#

for seq_guess in range(21):
known_high = (INTERCEPT_MARKER << 32) + seq_guess
known_high = known_high << (52 * 8) # 移到字节 64-75
R = PolynomialRing(Zmod(n), 'x')
x = R.gen()
f = (known_high + x)**e - c
try:
roots = f.small_roots(X=2**64, beta=1.0, epsilon=0.03)
if roots:
m_candidate = known_high + int(roots[0])
marker, seq, msg = unpack_message(m_candidate)
if marker == INTERCEPT_MARKER:
# 攻击成功
break
except:
pass

构造已知部分时有一个容易出错的关键细节:标记(8 字节)和序号(4 字节)组成 12 字节块,应向左移位 52 × 8 位(即字节 64-75 拼接到 76-127 前面),而不是错误地移 (128-76) × 8 位。如果位偏移计算错误,将导致根搜索空间扩大到 416 位(远超 Coppersmith 可处理范围),从而使攻击失败。

实验结果#

e=3 的帧(3 帧,全部独立消息):
Frame 7: "amous sa"(序号 2)
Frame 11: "ying of "(序号 3)
Frame 15: "Albert E"(序号 4)
e=5 的帧(5 帧,全部为重复加密):
Frame 3: "t is a f"(序号 1)
Frame 8: "t is a f"(序号 1,重复)
Frame 12: "t is a f"(序号 1,重复)
Frame 16: "t is a f"(序号 1,重复)
Frame 20: "t is a f"(序号 1,重复)

e=3 的三个帧分别对应不同的消息片段(序号 2-4),而 e=5 的五个帧全部加密了同一个消息片段(序号 1)。这说明 e=5 的帧使用了不同的密钥对来加密同一条消息——实现上没有问题(不同密钥就是不同密钥),但消息内容的大量重复从侧面印证了附加 3-2 数据集的构造特征。

Coppersmith 方法的核心教训是:即使使用安全的模数长度,如果公钥指数太小且消息的高比特位可预测,RSA 依然不安全。这也解释了为什么现实中几乎总是使用 e = 65537——它足够大以抵抗 Coppersmith 攻击,同时作为 216+12^{16}+1(二进制只有两位是 1)模幂运算仍然高效。此外,使用 OAEP 等引入随机性的填充方案可以消除”已知高位”的假设,从根本上阻止此类攻击。

攻击五:Fermat 分解(Frame 10)#

原理#

如果 N = pq 的两个素因子 p 和 q 过于接近,Fermat 分解可以快速找到它们。设 a=Na = \lceil \sqrt{N} \rceil,逐渐增加 a,检查 b2=a2Nb^2 = a^2 - N 是否为完全平方数。如果是,则:

N=a2b2=(a+b)(ab)=pqN = a^2 - b^2 = (a+b)(a-b) = pq

def fermat_factor(n):
a = math.isqrt(n)
if a * a < n:
a += 1
while True:
b2 = a * a - n
b = math.isqrt(b2)
if b * b == b2:
return a + b, a - b
a += 1

算法的时间复杂度取决于 p 和 q 的差距。当 pq<N1/4|p - q| < N^{1/4} 时,Fermat 分解在多项式时间内可完成。这意味着安全地生成 RSA 素数时,p 和 q 不仅需要随机独立选择,位长通常也应略有差异。

实验结果#

Frame 10 的 p 和 q 非常接近,在 1000 次迭代内成功分解:

Frame 10: "will get"(序号 8)

攻击六:弱 PRNG 种子恢复(Frame 5, 9, 13, 14, 17)#

原理#

这些帧的 RSA 素因子由一个 16 位种子的线性同余生成器(LCG)生成:

X_{n+1} = (365 × X_n - 1) mod 2¹⁶

每个 16 位 PRNG 输出被拼接成一个比特串,当这个比特串表示的整数被证明为素数时,就用作 RSA 的素因子。由于种子空间仅 16 位(1 到 65536),攻击者可以枚举所有可能的种子并重演 PRNG 序列来尝试分解 N。

实现与关键调试#

def prng_factor(n, max_bits=520):
for seed in range(1, 65537):
xn_int = seed
xn = format(seed, '016b')
while len(xn) < max_bits:
xn_int = (365 * xn_int - 1) % (1 << 16)
xn += format(xn_int, '016b')
candidate = int(xn, 2)
if candidate < n and n % candidate == 0:
return candidate, seed
return None

这个地方有一个非常重要的调试经历。初始实现错误地从左侧截断比特(即检查序列的后缀),导致 Frame 13 和 17 未能恢复。正确的做法是从右侧截断(检查序列的前缀),因为 PRNG 的首个输出对应的是素数的高位比特。修正后全部 5 帧成功恢复:

Frame 5 (seed=41714): "ation wi"(序号 12)
Frame 9 (seed=46818): "ll take "(序号 13)
Frame 14 (seed=53546): " you fro"(序号 9)
Frame 13 (seed=34410): "you ever"(序号 14)
Frame 17 (seed=40530): 'ywhere."'(序号 15)

这个 bug 的本质是对位序方向的理解错误。PRNG 序列的第一个 16 位输出拼接在比特串的最左边(最高位),随着序列增长,新的输出追加在右边(低位)。因此应该检查序列的前缀(从左截断),而不是后缀(从右截断)。这是一个非常典型的 “bit ordering” 类错误,值得铭记。

教训#

16 位的种子空间意味着最多 65536 次尝试即可穷举全部可能,在现代计算机上几秒内就能完成。更微妙的是,即使 PRNG 使用了更大的状态空间,如果它从系统时间等低熵源获取种子,攻击者同样可以通过猜测时间窗口来缩小搜索空间。

密码学中,随机数生成器是系统的命门。本实验中至少四条攻击路线(GCD 共享因子、Pollard p-1、Fermat 分解、弱 PRNG)都直接或间接源于密钥生成阶段使用了不可靠的随机性。

解密结果汇总与完整明文恢复#

全部 21 帧成功解密,共 16 个唯一消息片段(5 帧为重复加密)。按序号 0-15 排序拼接,得到完整明文:

"My secret is a famous saying of Albert Einstein.
That is "Logic will get you from A to B.
Imagination will take you everywhere.""

按序号整理的消息片段对照表:

序号消息片段来源帧攻击方法
0”My secre”0, 4共模攻击
1”t is a f”3, 8, 12, 16, 20Coppersmith (e=5, 重复 5 次)
2”amous sa”7Coppersmith (e=3)
3”ying of “11Coppersmith (e=3)
4”Albert E”15Coppersmith (e=3)
5”instein.”19Pollard p-1
6” That is”2Pollard p-1
7”Logic “6Pollard p-1
8”will get”10Fermat 分解
9” you fro”14PRNG 种子恢复
10”m A to B”18GCD 共模因子
11”. Imagin”1GCD 共模因子
12”ation wi”5PRNG 种子恢复
13”ll take “9PRNG 种子恢复
14”you ever”13PRNG 种子恢复
15’ywhere.“‘17PRNG 种子恢复

总结与反思#

本次实验通过 2016 年全国密码学竞赛赛题三的 21 个密文帧,实践了 RSA 密码体制的五类数学攻击和一种密钥生成层面的种子穷举。几个最重要的体会是:

  • RSA 的理论安全性依赖大整数分解,但实现中的任何弱点都可能绕过这个假设。无需直接分解 1024 位模数——通过共享素因子(GCD)、光滑 p-1(Pollard)、p 和 q 过近(Fermat)、弱 PRNG(种子恢复),每一种都绕过了”硬分解”的需要。安全实现必须系统性地避免这些弱点,任何一个环节的疏忽都可能导致全局崩溃。

  • Coppersmith 方法是现代 RSA 密码分析的核心工具,本实验中 8 个帧(近 40%)由这种方法破解。它的威力在于:只要知道足够多的高位比特,就能在多项式时间内恢复低位。但在实现时必须精确处理位偏移——报告中记录的错误(标记偏移写成双倍导致搜索空间 416 位)如果没被修正,整个攻击就会失败。理论正确不等于实现正确。

  • 弱 PRNG 攻击中的前缀/后缀问题是一个值得铭记的调试经验。初始实现误从左侧截断比特(检查后缀),导致 Frame 13 和 17 未能恢复。修正为从右侧截断(检查前缀)后全部成功。这个 bug 的本质是对比特串拼接方向的理解偏差——PRNG 的第一个输出拼接在最高位,检查前缀才是正确的。它提醒我们:在位级别的密码分析中,“左边”和”右边”的区别可能直接决定攻击成败。

  • 21 帧中 5 帧为重复加密(同一消息在不同密钥下多次加密)。看似”浪费”,但从安全性角度看是有意义的——它可以防止单帧丢失导致消息片段不可恢复。但同时,重复加密也为共模攻击和消息匹配提供了条件。数据冗余是一把双刃剑。

  • 攻击前系统性地梳理数据结构是分析能力的第一步。面对 21 个帧,第一件事不是写代码,而是统计每个帧的模数位长、e 的取值分布、模数之间的 GCD 关系。找出”哪些帧共享素因子”、“哪些帧的 e 特别小”、“哪些帧的模数接近”,这种系统性的数据梳理比盲目逐个尝试高效得多。

从实战角度看,这六种攻击覆盖了 RSA 密钥生成和加密实现中最常见的脆弱点。安全实现 RSA 的”底线清单”是清晰的:使用加密安全的随机数生成器(≥128 位熵源)、生成独立的大素数(≥512 位,验证 p-1 不具有小素因子)、确保 |p-q| 足够大、使用 e = 65537 而非 e=3 或 5、使用 OAEP 等引入随机性的安全填充方案、绝不在不同用户间共享模数 N。

现代密码学实验四:RSA 加密体制破解
https://sandt3a.github.io/posts/modern-cryptography-lab-4/
作者
Dorabit
发布于
2026-06-02
许可协议
CC BY-NC-SA 4.0