一道看似简单实则暗藏杀机的 RSA 题。我从"n 不是两个素数乘积"开始掉坑,一路挣扎到"gcd(e, φ(n)) ≠ 1"才发现标准解密路被堵死,最后又在 flag 前缀上踩了最后一个坑……本文记录全过程,力求让纯小白也能看懂。
00 前言:我为什么被坑了
拿到这道题的时候,我看了一眼提示:
Tip: openssl rsautl -encrypt -in FLAG -inkey public.pem -pubin -out flag.enc
心里暗想:RSA 嘛,提取公钥 → 分解 n → 求 d → 解密。常规操作,稳了。
结果一路踩了三个大坑:
- n 不是两个素数的乘积,是三个 — 标准 RSA 的模数 n = p × q。但这题的 n 居然分解出了 三个素数。
- e = 3 与 φ(n) 不互质,标准解密路被彻底堵死 — 求私钥 d 需要 e⁻¹ mod φ(n),但 gcd(3, φ(n)) = 3,逆元根本不存在。
- 好不容易解出明文,flag 前缀却看错了 — 解密结果明明是 0ctf{...},我却硬是把开头的 0 当成填充残留给扔了,提交了 ctf{...},反复失败。
这三个坑,每一个都值得好好说道说道。下面我就按时间线,把整个折腾过程原原本本地记录下来。
01 题目给了什么
解压后得到两个文件:
| 文件 | 说明 |
|---|---|
| public.pem | RSA 公钥文件 |
| flag.enc | 加密后的 Flag,40 字节 |
提示命令:
openssl rsautl -encrypt -in FLAG -inkey public.pem -pubin -out flag.enc
这说明加密方用的是 openssl rsautl 命令,用公钥对 FLAG 文件进行了加密。我们的目标就是解密 flag.enc。
02 第一步:分析公钥
公钥是公开信息,直接用 OpenSSL 解析:
$ openssl rsa -pubin -in public.pem -text -noout
Public-Key: (314 bit)
Modulus:
02:ca:a9:c0:9d:c1:06:1e:50:7e:5b:7f:39:dd:e3:
45:5f:cf:e1:27:a2:c6:9b:62:1c:83:fd:9d:3d:3e:
aa:3a:ac:42:14:7c:d7:18:8c:53
Exponent: 3 (0x3)
提取出两个关键参数:
| 参数 | 值 | 含义 |
|---|---|---|
| n(模数) | 23292710978670380403641273270002884747060006568046290011918413375473934024039715180540887338067 | RSA 的模数,314 bit |
| e(公钥指数) | 3 | 极小指数 |
危险信号 — n 只有 314 bit,远低于现代 RSA 的安全标准(2048 bit 起步)。e = 3 也是经典低指数。这两个信号说明 n 大概率可以被分解,而且可能存在低指数攻击。
03 第二步:分解模数 n
拿到 n 后,第一个任务就是把它分解成素因子的乘积。我尝试了多条路径:
3.1 在线数据库 FactorDB(最快)
FactorDB 是一个预先计算了大量整数因子分解结果的在线数据库。对于小于 ~100 位的整数,它通常已经有记录。
访问 factordb.com,输入 n,秒出结果:
n = 26440615366395242196516853423447
× 27038194053540661979045656526063
× 32581479300404876772405716877547
三个因子都是 32 位十进制数,验证:
>>> p1 = 26440615366395242196516853423447
>>> p2 = 27038194053540661979045656526063
>>> p3 = 32581479300404876772405716877547
>>> p1 * p2 * p3 == n
True
>>> is_prime(p1), is_prime(p2), is_prime(p3)
(True, True, True)
3.2 不用 FactorDB 怎么办?本地工具也能打
为了回答 "如果不用 FactorDB 呢?" 这个问题,我实际测试了多种本地分解工具:
| 工具 | 方法 | 结果 |
| --- | --- |
| Pollard Rho (Python) | 随机游走找因子 | 20 次尝试全部失败(因子太大且均匀) |
| GMP-ECM | 椭圆曲线方法 | 30 次随机曲线未命中(32 位因子需 ~6000 条曲线) |
| PARI/GP factor | 内置 ECM + MPQS/SIQS | 3 分钟超时未完成 |
| primefac (Python) | 多算法整合 | 300 秒超时 |
| YAFU (SIQS) | 自初始化二次筛 | ETA ~60 分钟(对 95 位十进制数最有效) |
小白贴士 — 你不需要先验地知道 n 有几个素因子。 分解工具的输出会明确列出每个素因子及其重数。FactorDB 查出来是三个,yafu 跑完也会告诉你三个。
3.3 多素数 RSA(Multi-Prime RSA)
你可能会有疑问:标准 RSA 不是 n = p × q 吗?怎么这里变成了三个素数?
答案是:RSA 的数学结构允许多个素因子。使用三个或更多素数的技术叫做 Multi-Prime RSA。它的优势是可以用 CRT(中国剩余定理)并行加速私钥运算;劣势是素数越多、越短,越容易分解。
本题故意使用三个短素数,既让 n 可被分解,又为后续攻击埋下了伏笔。
04 第三步:标准 RSA 为什么走不通
按照标准 RSA 流程,下一步应该是求私钥 d:
$$ d ≡ e⁻¹ (mod φ(n)) $$
其中 φ(n) 是欧拉函数。对于 n = p₁ × p₂ × p₃:
$$ φ(n) = (p₁ - 1)(p₂ - 1)(p₃ - 1) $$
让我们计算一下 gcd(3, φ(n)):
>>> phi = (p1 - 1) * (p2 - 1) * (p3 - 1)
>>> gcd(3, phi)
3
致命问题 — gcd(e, φ(n)) = 3 ≠ 1,这意味着 3 在模 φ(n) 下没有乘法逆元,私钥 d 根本求不出来!标准 RSA 解密路径被彻底堵死。
为什么 gcd 不为 1?
检查每个素因子减 1 后对 3 的余数:
| 因子 | (p - 1) mod 3 | 含义 |
|---|---|---|
| p₁ = 26440615366395242196516853423447 | 0 | 3 整除 (p₁ - 1) |
| p₂ = 27038194053540661979045656526063 | 1 | 3 不整除 (p₂ - 1) |
| p₃ = 32581479300404876772405716877547 | 0 | 3 整除 (p₃ - 1) |
因为 p₁ 和 p₃ 的 (p-1) 都是 3 的倍数,所以 3 整除 φ(n) = (p₁-1)(p₂-1)(p₃-1)。
这是出题人精心设计的陷阱:用三个素数确保 gcd(e, φ(n)) > 1,迫使你放弃标准 RSA 解密路线,回到更底层的数学方法。
05 第四步:多素数 RSA 的解密方法
既然求不出 d,怎么办?
别忘了 RSA 加密的本质是:
$$ c ≡ m³ (mod n) $$
由于 n = p₁ × p₂ × p₃,我们可以分别在每个素数域中求 c 的 立方根,然后用 CRT 组合回模 n 下的候选明文。
5.1 模素数域的立方根
对于素数 p,求 a 的立方根(即解 x³ ≡ a (mod p)),解的数量取决于 p mod 3 的值:
| 条件 | 立方根数量 | 求解方法 |
|---|---|---|
| p ≡ 2 (mod 3) | 1(唯一) | d = 3⁻¹ mod (p-1),然后 x = a^d mod p |
| p ≡ 1 (mod 3) | 0 或 3 | 先检验 a^((p-1)/3) ≡ 1 (mod p),再用 Tonelli-Shanks 变体或 sympy 的 nthroot_mod |
5.2 本题的具体计算
p₂ 域(唯一立方根):
>>> d2 = pow(3, -1, p2 - 1) # 3 在模 (p2-1) 下的逆元
>>> r2 = pow(c, d2, p2) # c^(1/3) mod p2
>>> r2
19616973567618515464515107624812
p₁ 和 p₃ 域(各 3 个立方根):
由于 p₁ ≡ p₃ ≡ 1 (mod 3),立方根不唯一。我用 sympy 的 nthroot_mod 来求解:
>>> from sympy import nthroot_mod
>>> r1_list = nthroot_mod(c % p1, 3, p1, all_roots=True)
>>> r1_list
[13374868592866626517389128266735,
19722834945917837883299946044598,
22982635323400908786226917550586]
>>> r3_list = nthroot_mod(c % p3, 3, p3, all_roots=True)
>>> r3_list
[6149264605288583791069539134541,
13028011585706956936052628027629,
13404203109409336045283549715377]
5.3 中国剩余定理(CRT)组合
现在我们有 3 × 1 × 3 = 9 种组合。对每一种组合,用 CRT 求出模 n 下的候选 m:
def crt(m1, m2, m3):
M1, M2, M3 = p2*p3, p1*p3, p1*p2
inv = lambda a,m: pow(a, -1, m)
return (m1*M1*inv(M1,p1) + m2*M2*inv(M2,p2) + m3*M3*inv(M3,p3)) % n
遍历 9 种组合,筛选出包含 flag 或 ctf 字样的明文。
06 第五步:踩坑——我以为 flag 是 ctf
解密原始密文后,我得到了以下结果:
[原始密文解密] i=0, j=1
完整填充数据 (39 bytes): 02d15ecb84845243f34a00306374667b4861684121546869355f31735f6e30545f7253617e7d0a
明文: b'0ctf{HahA!Thi5_1s_n0T_rSa~}\\n'
这里我犯了一个低级错误:我看到填充数据里 0x00 后面跟着 30 63 74 66 ...,下意识地认为 30(ASCII '0')是填充残留,于是只提取了 ctf{...}。
致命误判 — 我以为 flag 是 ctf{HahA!Thi5_1s_n0T_rSa~},反复提交都失败。实际上,开头的 '0' 是 flag 的一部分,正确的 flag 是 0ctf{HahA!Thi5_1s_n0T_rSa~}。
为什么会误判?因为我先入为主地假设 flag 格式是 ctf{...} 或 flag{...},而看到 '0' 时大脑自动把它归类为"非 flag 字符"。
后来搜索到原题 writeup,才恍然大悟:这道题来自 0CTF 2016 Quals,flag 前缀自然是 0ctf!
- ❌ 错误答案(我最初提交的): ~~ctf{HahA!Thi5_1s_n0T_rSa}~
- ✅ 正确答案:
0ctf{HahA!Thi5_1s_n0T_rSa~}
06.5 真实的踩坑记录:四个脚本与终端操作
下面是我在本地真实尝试的全过程,不是事后补写的。每一个报错、每一次 Ctrl+C、每一个"找不到命令"都是原汁原味的现场记录。
第一次尝试:solve.py(标准 RSA 思路)
我最开始的思路是标准的:提取公钥 → 分解 n → 求 d → 解密。写了一个脚本,核心逻辑是:
- 从 public.pem 读取公钥,获取 n 和 e — 这一步成功了,n = 314 bit,e = 3。
- 尝试 FactorDB 在线分解 — 脚本调用 factordb.com/api,但返回了 JSON 解析错误:
[!] Factordb 查询失败: Expecting value: line 1 column 1 (char 0)
[!] Factordb 未找到分解结果。
原因:FactorDB 的 API 偶尔会返回非 JSON 内容(可能是被拦截或限流)。 - 手动输入 p 和 q — 脚本让我手动输入 p 和 q。我随手试了 2 和 3:
请输入 p (十进制): 2
请输入 q (十进制): 3
[!] p*q != N,请检查。
这时我还没意识到 n 不是两个素数的乘积,脚本里写的也是 if len(factors) == 2,根本没有处理三素数的情况。
第二次尝试:solve2.py(增加本地分解)
FactorDB 失败了,那我就在本地分解。安装了 primefac 和 sympy,写了一个自动尝试多种方法的脚本:
- 先试 FactorDB 在线查询
- 再试 primefac 本地 Pollard Rho
- 最后试 sympy 的 factorint
结果 primefac 的 Pollard Rho 跑 Pollard Rho 时卡住了:
[*] 尝试 primefac 本地分解...
^CTraceback (most recent call last):
File "solve2.py", line 144, in
...
File "primefac/__init__.py", line 317, in pollardrho_brent
y = (y**2 + c) % n
KeyboardInterrupt
我按了 Ctrl+C 中断。对于 95 位十进制数、三个均匀分布的 32 位素因子,Pollard Rho 确实很难在合理时间内找到因子。
关键盲点 — solve.py 和 solve2.py 都假设 n = p × q(两个素数)。它们的代码里检查的是 len(factors) == 2,如果 FactorDB 返回了三个因子,这些脚本会直接忽略。这是导致它们"看似失败"的根本原因。
第三次尝试:s1.py(直接求 d 解密)
我在网上查到了 n 的三个素因子,直接硬编码到脚本里,尝试用标准 RSA 解密:
phi = (p - 1) * (q - 1) * (r - 1)
d = inverse(e, phi)
结果运行时 inverse(e, phi) 抛出异常,因为 gcd(3, φ(n)) = 3,3 在模 φ(n) 下没有逆元。标准 RSA 解密路被彻底堵死。
这个脚本让我真正意识到了问题的严重性:这不是一道"套公式"的 RSA 题。
第四次尝试:solve3.py(立方根 + CRT,终于成功)
换了思路:不求 d,直接在每个素数域上求 c 的立方根,然后用 CRT 组合。
运行结果:
[*] p 的立方根: [5686385026105901867473638678946, 7379361747422713811654086477766, 13374868592866626517389128266735]
[*] q 的立方根: [19616973567618515464515107624812]
[*] r 的立方根: [6149264605288583791069539134541, 13028011585706956936052628027629, 13404203109409336045283549715377]
[-] 未找到有效填充,可能是立方根不全或填充格式不同。
打印所有候选明文(调试):
0: b'\x02\x9d\xb0\xda\xb3...'
1: b'\x02\xd1^\xcb\x84\x84RC\xf3J\x000ctf{HahA!Thi5_1s_n0T_rSa~}\n'
2: b'.\x82\x7fY~U\xff\xaa...'
...
候选 1 直接出现了 flag!但脚本的 PKCS#1 v1.5 剥离逻辑却没有匹配到它。为什么呢?
因为脚本里写的是:
if data[0] != 0x00 or data[1] != 0x02:
return None
它要求填充格式必须以 0x00 0x02 开头,但实际解密出的 m_bytes 是 0x02 ... 开头(前面没有 0x00)。这是因为 m 的 bit_length = 296 bit = 37 字节,而 n 是 314 bit = 40 字节,m 作为整数没有占满 40 字节,所以转换成的 bytes 不以 0x00 开头。
幸好脚本在剥离失败后会打印所有候选,我就是在调试输出中一眼看到了 0ctf{...}。
终端上的其他尝试
尝试 openssl rsautl 解密(没有私钥,当然失败):
$ openssl rsautl -decrypt -in flag.enc -inkey public.pem -out flag.txt
Could not find private key from public.pem
尝试 openssl 重新加密,bash 历史扩展坑了我:
$ openssl rsautl -encrypt -in ctf{HahA!Thi5_1s_n0T_rSa~} -inkey public.pem -pubin -out flag.enc2
bash: !Thi5_1s_n0T_rSa~}: event not found
bash 把 ! 当成历史事件扩展了。后来我用 nano flag1 写入内容,再用文件作为输入,成功加密:
$ openssl rsautl -encrypt -in flag1 -inkey public.pem -pubin -out flag.enc2
比较两次加密的密文:
$ hexdump -C flag.enc | head
00000000 00 4c 41 62 a0 7a 01 11 b8 34 4c 68 b1 18 bd 05 |.LAb.z...4Lh....|
$ hexdump -C flag.enc2 | head
00000000 01 98 c5 27 29 4a c7 79 71 ae af 84 22 f5 90 de |...')J.yq..."...|
两个密文完全不同,验证了 PKCS#1 v1.5 随机填充的特性。
07 第六步:正向验证与最终真相
为了彻底确认,我做了正向验证:
7.1 用 openssl 重新加密
$ printf '0ctf{HahA!Thi5_1s_n0T_rSa~}\n' > FLAG
$ openssl rsautl -encrypt -in FLAG -inkey public.pem -pubin -out flag_new.enc
注意:由于 PKCS#1 v1.5 填充使用了随机非零字节,每次加密生成的密文都不同。所以 flag_new.enc 和原始 flag.enc 的二进制内容不会相同。这是正常的。
7.2 解密新密文验证
用同样的立方根 + CRT 方法解密新密文:
[重加密密文解密] i=1, j=1
完整填充: 024fe3ce50b85c8f840100306374667b4861684121546869355f31735f6e30545f7253617e7d0a
明文: b'0ctf{HahA!Thi5_1s_n0T_rSa~}\n'
解密成功,明文完全一致!
7.3 最直接的数学验证
把原始解密出的完整 39 字节填充数据直接代入 c = m³ mod n:
>>> m_bytes = bytes.fromhex('02d15ecb84845243f34a00306374667b4861684121546869355f31735f6e30545f7253617e7d0a')
>>> m = int.from_bytes(m_bytes, 'big')
>>> c_recalc = pow(m, 3, n)
>>> c_orig = int.from_bytes(open('flag.enc','rb').read(), 'big')
>>> c_recalc == c_orig
True
验证通过 — 原始解密出的 39 字节数据直接做 m³ mod n,结果与原始密文 完全一致。解密没有任何错误,flag 就是 0ctf{HahA!Thi5_1s_n0T_rSa~}。
图 1: 正向验证流程
08 完整可运行代码(纯 Python,零依赖)
把下面这段代码保存为 solve.py,放在与 flag.enc 同目录下,运行 python3 solve.py flag.enc 即可:
#!/usr/bin/env python3
"""
0CTF 2016 Quals rsa-2 解密脚本(纯 Python,无需 gmpy2)
用法: python3 solve.py flag.enc
"""
import sys
from sympy import nthroot_mod
# ========== 1. 参数(从 public.pem 解析 + FactorDB 分解) ==========
n = 23292710978670380403641273270002884747060006568046290011918413375473934024039715180540887338067
p1 = 26440615366395242196516853423447
p2 = 27038194053540661979045656526063
p3 = 32581479300404876772405716877547
# ========== 2. 读取密文 ==========
with open(sys.argv[1], 'rb') as f:
c = int.from_bytes(f.read(), 'big')
# ========== 3. 各素数域的立方根 ==========
# p2: 唯一立方根(因为 p2 % 3 == 2,3 与 p2-1 互质)
d2 = pow(3, -1, p2 - 1)
r2 = pow(c, d2, p2)
# p1, p3: 各有 3 个立方根(因为 p1 % 3 == p3 % 3 == 1)
r1_list = nthroot_mod(c % p1, 3, p1, all_roots=True)
r3_list = nthroot_mod(c % p3, 3, p3, all_roots=True)
# ========== 4. 中国剩余定理(CRT)==========
def crt(m1, m2, m3):
M1, M2, M3 = p2 * p3, p1 * p3, p1 * p2
inv = lambda a, m: pow(a, -1, m)
return (m1 * M1 * inv(M1, p1) +
m2 * M2 * inv(M2, p2) +
m3 * M3 * inv(M3, p3)) % n
# ========== 5. 遍历 9 种组合,筛选明文 ==========
print("正在遍历 3×1×3 = 9 种 CRT 组合...")
for i, m1 in enumerate(r1_list):
for j, m3 in enumerate(r3_list):
m = crt(m1, r2, m3)
length = (m.bit_length() + 7) // 8
m_bytes = m.to_bytes(length, 'big')
# 尝试剥离 PKCS#1 v1.5 填充
if m_bytes.startswith(b'\x02'):
sep = m_bytes.find(b'\x00', 1)
if sep != -1:
plaintext = m_bytes[sep + 1:]
if b'ctf' in plaintext.lower() or b'flag' in plaintext.lower():
print(f"\n[FOUND] i={i}, j={j}")
print(f" 完整填充数据: {m_bytes.hex()}")
print(f" 明文 bytes: {plaintext}")
try:
print(f" 明文 text: {plaintext.decode('ascii')}")
except Exception:
pass
# 验证:重新加密看是否等于原始密文
m_int = int.from_bytes(m_bytes, 'big')
if pow(m_int, 3, n) == c:
print(" [验证通过] m³ mod n == c")
else:
print(" [验证失败] m³ mod n != c")
运行前准备 — 代码使用了 sympy 的 nthroot_mod 来求模素数域的立方根。如果没有 sympy,先安装:
pip install sympy
⚠️ 网络安全免责声明
本文内容仅供技术研究与安全学习之用。文中描述的 RSA 弱点分析、Multi-Prime 攻击链拆解和代码示例均基于公开的 CTF 赛题(0CTF 2016 Quals),旨在帮助开发者理解 RSA 密码学原理、提升密码安全意识。
请勿将本文所述技术用于任何未经授权的系统测试或非法用途。
09 知识点总结
9.1 本题涉及的安全缺陷
| 缺陷 | 描述 | 利用方式 |
|---|---|---|
| 模数过短 | n 仅 314 bit,远低于 2048 bit 安全标准 | FactorDB / SIQS 直接分解 |
| e = 3 极小 | 导致 m³ 较小,且可分域开立方根 | 模素数域立方根 + CRT |
| 三素数乘积 | n = p₁ × p₂ × p₃ 而非 p × q | gcd(e, φ(n)) ≠ 1,阻断标准解密 |
| PKCS#1 v1.5 填充 | 随机填充导致密文不可复现 | 解密验证而非密文比对 |
9.2 核心知识点
- Multi-Prime RSA:RSA 数学上允许任意多个素因子。素数越多、越短,越容易被分解。
- gcd(e, φ(n)) ≠ 1 的后果:当公钥指数 e 与 φ(n) 不互质时,e 在模 φ(n) 下没有逆元,标准 RSA 解密失效。
- 模素数域的立方根:p ≡ 2 (mod 3) 时唯一,p ≡ 1 (mod 3) 时有 0 或 3 个。可用 sympy 的 nthroot_mod 或 PARI/GP 的 sqrtn 求解。
- 中国剩余定理(CRT):将各素数域上的部分解合并为模 n 的全局解。3 × 1 × 3 = 9 种组合中筛选出有效明文。
- PKCS#1 v1.5 填充:格式为 0x00 0x02 [随机非零字节...] 0x00 [明文]。每次加密的随机字节不同,密文也不同。
9.3 踩坑教训
经验之谈 — 不要预设 flag 格式。 解密出的每一个字节都要仔细检查,不要因为 "看起来不像 flag" 就随意丢弃。CTF 题目的 flag 前缀千变万化,这道题的 0ctf 就是一个典型例子。
10 参考资料
- 0CTF 2016 Quals – RSA? (Crypto 2 pts) 官方 Writeup — hellman, MSLC
原题解析,包含 Cipolla-Lehmer 立方根算法和 Sage 代码。
https://mslc.ctf.su/wp/0ctf-2016-quals-rsa-crypto-2-pts/ - FactorDB — 在线整数因子分解数据库
https://factordb.com - YAFU — Yet Another Factoring Utility
支持 SIQS、ECM 等多种分解算法,对 90-100 位十进制数非常有效。
https://github.com/bbuhrow/yafu - SymPy Documentation — nthroot_mod
Python 中求模素数域 n 次根的实用函数。
https://docs.sympy.org/latest/modules/ntheory.html
浙公网安备 33010602011771号