一道看似简单实则暗藏杀机的 RSA 题。我从"n 不是两个素数乘积"开始掉坑,一路挣扎到"gcd(e, φ(n)) ≠ 1"才发现标准解密路被堵死,最后又在 flag 前缀上踩了最后一个坑……本文记录全过程,力求让纯小白也能看懂。

00 前言:我为什么被坑了

拿到这道题的时候,我看了一眼提示:

Tip: openssl rsautl -encrypt -in FLAG -inkey public.pem -pubin -out flag.enc

心里暗想:RSA 嘛,提取公钥 → 分解 n → 求 d → 解密。常规操作,稳了。

结果一路踩了三个大坑:

  1. n 不是两个素数的乘积,是三个 — 标准 RSA 的模数 n = p × q。但这题的 n 居然分解出了 三个素数。
  2. e = 3 与 φ(n) 不互质,标准解密路被彻底堵死 — 求私钥 d 需要 e⁻¹ mod φ(n),但 gcd(3, φ(n)) = 3,逆元根本不存在。
  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 种组合,筛选出包含 flagctf 字样的明文。

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 → 解密。写了一个脚本,核心逻辑是:

  1. 从 public.pem 读取公钥,获取 n 和 e — 这一步成功了,n = 314 bit,e = 3。
  2. 尝试 FactorDB 在线分解 — 脚本调用 factordb.com/api,但返回了 JSON 解析错误:
    [!] Factordb 查询失败: Expecting value: line 1 column 1 (char 0)
    [!] Factordb 未找到分解结果。
    原因:FactorDB 的 API 偶尔会返回非 JSON 内容(可能是被拦截或限流)。
  3. 手动输入 p 和 q — 脚本让我手动输入 p 和 q。我随手试了 2 和 3:
    请输入 p (十进制): 2
    请输入 q (十进制): 3
    [!] p*q != N,请检查。
    这时我还没意识到 n 不是两个素数的乘积,脚本里写的也是 if len(factors) == 2,根本没有处理三素数的情况。

第二次尝试:solve2.py(增加本地分解)

FactorDB 失败了,那我就在本地分解。安装了 primefacsympy,写了一个自动尝试多种方法的脚本:

  • 先试 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~}。

flowchart TD A[创建 FLAG = 0ctf{...}] --> B[openssl rsautl -encrypt] B --> C[flag_new.enc与原始密文不同PKCS#1 v1.5 随机填充] C --> D[立方根 + CRT 解密] D --> E[明文 = 0ctf{...}与原始 FLAG 一致] F[原始 flag.enc] --> G[立方根 + CRT 解密] G --> H[明文 = 0ctf{...}] H --> I[m³ mod n = flag.enc数学验证通过]

图 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 参考资料

  1. 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/
  2. FactorDB — 在线整数因子分解数据库
    https://factordb.com
  3. YAFU — Yet Another Factoring Utility
    支持 SIQS、ECM 等多种分解算法,对 90-100 位十进制数非常有效。
    https://github.com/bbuhrow/yafu
  4. SymPy Documentation — nthroot_mod
    Python 中求模素数域 n 次根的实用函数。
    https://docs.sympy.org/latest/modules/ntheory.html