☰
RSA参数复用攻击详解:共模与共享素数的CTF实战破解
2026/10/17 9:58:34 网站建设 项目流程

前几天整理解题笔记,翻到某平台上一道挂着 Crypto 标签的 Xman-RSA,当时为了把它彻底吃透,我把 RSA 常见的攻击姿势重新捋了一遍。今天就把这道题的完整 writeup 写出来,重点不是让你背脚本,而是看懂每一步为什么这么做。如果你刚入门 CTF 的密码学,卡在各种 RSA 题上,或者刷题时见过这种给两个公钥、两个密文但不知道从哪下手的题,这篇应该能帮你省不少时间。先说结论:这道题表面上是让你解 RSA,实际上考的是参数之间的复用关系——同一个模数被反复使用,或者两个模数共享同一个素数因子。只要发现这层关系,根本不需要暴力分解几百位的 n。整个过程我会从附件分析、工具使用、数学原理,到最后的 Python 脚本一一拆开,代码可以直接拿去跑,换到同类型的题上也能用。

1. 题目初探与附件分析

1.1 拿到题目的第一眼:文件清单与信息收集

压缩包解压以后,通常是一组看着很普通的文件:两个.pem公钥文件,两个.enc密文文件。没有私钥,没有加密脚本,连提示都没有。这种配置在 RSA 题里非常典型,基本就是在告诉你:破解点不在大数分解的硬功夫,而在参数之间的关系上。

我第一次拿到附件时,先是例行公事地看了一下文件大小。公钥 PEM 文件一般就几百字节,密文文件长度往往和模数的字节长度一致。如果密文文件长度是 128 字节左右,对应的就是 1024 bit 的 RSA;如果 256 字节,那就是 2048 bit。这个细节可以帮你先确认 RSA 的规模,也能在后续解析公钥时做个对照,防止文件读取姿势不对导致密文数值错位。

文件清单大致如下:

文件类型我的猜测
pubkey1.pem / pubkey2.pem公钥文件内含 RSA 的 n 和 e
flag1.enc / flag2.enc密文文件待解密的二进制数据

文件名如果不一样也没关系,比如有些版本叫 public1.pem、cipher1.txt,核心思路完全一致。重点是先搞清楚公钥里到底写了什么。这里要提醒一句:PEM 文件本质是 Base64 编码后的 DER 结构,不要用文本编辑器看一眼就完事,必须用工具解析成具体的 n、e 数值,否则后面什么都做不了。

1.2 用命令行和 Python 提取公钥参数

最直接的方式是 OpenSSL,命令如下:

openssl rsa -pubin -in pubkey1.pem -text -noout

输出大致长这样:

RSA Public-Key: (1024 bit, 2 num) Modulus: 00:aa:bb:cc:dd:... Exponent: 65537 (0x10001)

其中 Modulus 冒号后面那一大串十六进制就是模数 n,Exponent 就是公钥指数 e。把两个公钥都跑一遍,重点看 Modulus 是否完全一致。

不过比赛中不一定有 OpenSSL 环境,用 Python 反而更省事。只要有pycryptodome库,解析公钥只需要三行:

from Crypto.PublicKey import RSA key = RSA.import_key(open("pubkey1.pem", "rb").read()) print(hex(key.n), key.e)

这样我们能拿到两个公钥的(n1, e1)和(n2, e2)。这一步是整个题的核心情报收集阶段,信息就藏在 n 和 n 的关系里。

2. 突破口:两个公钥之间的“复用关系”

2.1 为什么单个公钥看起来完全正常

只看 pubkey1.pem 时,它就是一把正常的 RSA 公钥:一个几百位的大数 n,加上常见的指数 e=65537。单独看没有任何毛病。如果这时候想的是“1024 bit 好像可以尝试分解”,那就掉进最费时间的坑里了。现代 RSA 分解 1024 bit 不是完全不可能,但绝对不应该作为第一选择,尤其是在题目刻意给了两把公钥的情况下。

把两个公钥的 n 放在一起比对,问题就暴露了。常见结果有两种:要么n1 == n2,要么n1 != n2但gcd(n1, n2) > 1。这两种情况分别对应 RSA 密钥管理里最经典的失误:模数复用和素数复用。

我猜当年出题人就是想让选手意识到:RSA 参数不是孤立存在的,多个公钥之间的数学关联可以直接宣判 RSA 的死刑。

2.2 共模复用与素数复用:两种典型情况

先给一个简单的对照表:

复用类型判断条件攻击方法
共模复用n1 == n2扩展欧几里得求 s1、s2,用共模攻击恢复明文
素数复用gcd(n1, n2) > 1求出共享素数 p,分解两个 n,再求私钥解密

生活化一点理解:共模复用就像两个人共用一个保险箱,但各自配了一把齿形不同的钥匙。虽然你单独看某一把钥匙很正常,但只要同时拿到两把钥匙,就能通过齿形之间的数学关系把保险箱打开。素数复用更像两个保险箱的锁芯里都有一根完全一样的销钉,你只要把这根销钉抽出来,两个保险箱同时垮掉。

2.3 为什么这种关系是设计好的考点

CTF 题来源于真实世界。现实中因为随机数生成器缺陷、系统迁移、固件模板复制等原因,确实出现过大量密钥之间存在共享素数的事件。出题人把这种事故包装成一道题目,本质上是在训练选手用“参数审计”的眼光看待公钥,而不是一上来就无脑跑分解工具。

所以拿到这种附件时,先写三行代码:打印 n1 的 hex、打印 n2 的 hex、打印gmpy2.gcd(n1, n2)。这一套组合能过滤掉一多半参数复用类的 RSA 题。当年我做这道题时,最开始的半小时浪费在硬解一个看起来普通的 n 上,后来把两个 n 一对比,才发现真相就这么摆在眼前。

3. 核心攻击原理与数学推导

3.1 共模攻击的完整推导

共模攻击的适用条件非常清晰:同一个模数 n,两个不同的加密指数 e1、e2,并且gcd(e1, e2) = 1。如果两个密文是同一个明文 m 分别用 e1、e2 加密得到的:

  • c1 = m^e1 mod n
  • c2 = m^e2 mod n

因为 e1 和 e2 互素,根据扩展欧几里得算法,一定存在整数 s1、s2 满足:

e1 * s1 + e2 * s2 = 1

然后计算:

c1^s1 * c2^s2 ≡ m^(e1s1) * m^(e2s2) ≡ m^(e1s1 + e2s2) ≡ m^1 ≡ m (mod n)

一个很小的例子帮你确认思路。比如 n=35,e1=5,e2=11,m=3,那么 c1=3^5 mod 35 = 33,c2=3^11 mod 35 = 12。扩展欧几里得算出 5*(-2) + 11*1 = 1,也就是 s1=-2,s2=1。这里 s1 是负数,需要先对 c1 求模逆:33 mod 35 的逆是 17,所以 c1^s1 等价于 17^2 mod 35 = 9,再乘上 c2=12,得到 108 mod 35 = 3。明文 m=3 就恢复了。

核心代码就是这么来的。要注意负数指数的处理,不能直接pow(c, -2, n),必须先取模逆,再对指数取绝对值。

3.2 共享素数分解的推导

如果两个模数不同,但它们的最大公约数大于 1,情况就更直接了。设:

  • n1 = p * q1
  • n2 = p * q2

这里的 p 就是两个模数共享的那个素数。gcd(n1, n2)可以直接算出来,因为它就是 p。得到 p 之后,q1 = n1 // p,q2 = n2 // p,两个 n 都完成了分解。

有了 p 和 q,欧拉函数就是phi = (p - 1) * (q - 1)。公钥指数 e 已知,就可以求私钥:

d = inverse(e, phi)

然后用:

m = pow(c, d, n)

解密。如果两个密文分别来自两个不同的明文,那就对 n1、n2 各自解一次,再把两个解出的字节拼接起来。通常题目会把 flag 分成两段,分别加密,所以拼接顺序需要留意,一般就是明文顺序。

3.3 为什么参数复用会“秒杀”大数分解

RSA 的安全性建立在“由 n 分解出 p、q 很难”这个假设上。可一旦两个 n 共享素数 p,求 gcd 是非常快的多项式时间运算,几百位的大数立刻被撕开一个口子。而共模攻击更离谱,它甚至不需要分解 n,只要知道两个密文和两个指数的线性关系,就能把明文直接从密文里“提”出来。

这两个攻击原理都不复杂,但它们很有代表性:RSA 的安全性不只是取决于单把密钥的参数强度,还取决于整个密钥集合里是否存在可被利用的数学关联。这也是为什么在密码学的实际工作里,密钥审计和复用检测比单纯追求大位长更重要。

4. 完整 WriteUp:脚本实现与逐步解读

4.1 环境准备与依赖库

建议直接用 Python 3,需要安装两个库:

pip install pycryptodome gmpy2

pycryptodome用来解析 PEM 公钥和做字节转换,gmpy2提供快速的大整数扩展欧几里得和模逆运算。后者在处理几百位的大数时比 Python 自带的pow辅助函数更顺手,而且很多 CTF 选手的环境里已经默认装好了。

如果遇到ImportError,大概率是 Python 环境里同时存在旧版Crypto和pycryptodome冲突,可以先把旧的pycryptodome卸载再装,或者用pip install --upgrade pycryptodome处理。

4.2 自动判断攻击路径的完整脚本

下面这个脚本把两种攻击路径都写了。你只需要把文件名替换成实际附件里的文件名,运行后它会自动判断是共模复用还是素数复用,并输出解密结果。

import gmpy2 from Crypto.PublicKey import RSA from Crypto.Util.number import long_to_bytes def load_pubkey(path): with open(path, "rb") as f: key = RSA.import_key(f.read()) return key.n, key.e def load_cipher(path): data = open(path, "rb").read().strip() # 如果密文文件是十六进制文本,先转成整数 try: text = data.decode() if all(c in "0123456789abcdefABCDEF" for c in text) and len(text) % 2 == 0: return int(text, 16) except UnicodeDecodeError: pass # 大多数 .enc 文件直接是二进制,按大端读取即可 return int.from_bytes(data, "big") def common_modulus_attack(n, e1, e2, c1, c2): # 扩展欧几里得:e1 * s1 + e2 * s2 = 1 _, s1, s2 = gmpy2.gcdext(e1, e2) # 负数指数需要先求模逆,再取绝对值 if s1 < 0: c1 = gmpy2.invert(c1, n) s1 = -s1 if s2 < 0: c2 = gmpy2.invert(c2, n) s2 = -s2 m = (pow(c1, s1, n) * pow(c2, s2, n)) % n return m def solve(n1, e1, c1, n2, e2, c2): # 情况一:共模复用 if n1 == n2: print("[*] common modulus detected") m = common_modulus_attack(n1, e1, e2, c1, c2) return long_to_bytes(m) # 情况二:素数复用 p = gmpy2.gcd(n1, n2) if p > 1: print("[*] shared prime detected:", p) res = b"" for n, e, c in [(n1, e1, c1), (n2, e2, c2)]: q = n // p phi = (p - 1) * (q - 1) d = gmpy2.invert(e, phi) m = pow(c, d, n) res += long_to_bytes(m) return res raise ValueError("No common modulus or shared prime found") n1, e1 = load_pubkey("pubkey1.pem") n2, e2 = load_pubkey("pubkey2.pem") c1 = load_cipher("flag1.enc") c2 = load_cipher("flag2.enc") flag = solve(n1, e1, c1, n2, e2, c2) print(flag)

脚本逻辑并不复杂,但有几个细节值得展开。第一,load_cipher同时兼容了二进制和十六进制文本,实战里.enc文件有相当概率是直接用int.from_bytes保存的二进制,但也有人会把密文打印成 hex 存成文本,写一个自适应读取函数能省很多事。第二,common_modulus_attack里必须先判断s1和s2的符号,再决定是否取模逆,否则一运行就会抛ValueError: pow() 2nd argument cannot be negative。第三,两种路径都覆盖之后,不管题目实际是哪种复用,脚本都能正确处理。

4.3 跑通脚本后的输出解读

脚本运行后,如果走的是共模攻击,打印结果通常直接就是可见的 flag,比如flag{...}或RCTF{...}。如果走的是素数分解路径,会把两个 n 分别解密后的字节拼在一起,同样输出一个完整的 flag 格式字符串。

如果输出里出现大量不可见字符,先不要慌。把结果转成 hex 看一眼:

print(flag.hex())

有时候只是long_to_bytes的长度不对,或者明文的字节序是大端、小端搞反了。RSA 密文转整数时,常见的是int.from_bytes(data, "big"),如果文件是从某个脚本生成且用了小端,你就需要改成"little"。这个细节虽然低级,但非常容易在复现的时候让人怀疑人生。

5. 踩坑记录与常见问题速查

5.1 密文文件读了半天读不对

这是最常见的问题。int(open("flag1.enc").read())这种写法在密文是二进制时直接报错,在密文是十六进制文本时又可能因为末尾换行而解析失败。正确做法是用二进制模式读取,并且先判断文件内容形态。如果文件头是30开头且整体是乱码,几乎可以确定是二进制;如果全是可见的十六进制字符,就走int(text, 16)。上面脚本里的load_cipher已经处理了这两种情况。

还有一种坑:同一个题目里,两个密文文件的编码方式可能不一样。我之前遇到过公钥解析正常、但一个密文是十六进制、另一个是二进制的情况。所以load_cipher要对每个文件独立判断,不要想当然认为所有文件格式一致。

5.2 共模攻击脚本报了负数指数错误

很多人第一次写共模攻击时,会直接写:

m = pow(c1, s1, n) * pow(c2, s2, n) % n

当s1或s2为负时,Python 会立刻报错。正确做法是用模逆把负指数“掰正”。具体来说,如果s1 < 0,就计算c1_inv = gmpy2.invert(c1, n),然后用pow(c1_inv, -s1, n)。需要特别注意的是,invert(c1, n)要求c1和n互质,在绝大多数正常 RSA 题目里都满足。如果真遇到不互质的情况,说明密文泄露了 n 的因子,那反而是更好的突破口。

5.3 还有一种情况:两个 n 既不相同也互素

如果你发现n1 != n2,而且gcd(n1, n2) == 1,这时脚本会报错,题目大概率不是参数复用类。那就要看看公钥指数 e 是不是很小,比如 e=3、e=5,以及密文是不是只有一组。如果同一明文被多个不同模数加密,可以考虑低加密指数广播攻击;如果密文之间存在线性关系,可以考虑相关消息攻击。总之路是死的,人是活的,公钥关系检查只是第一步,不是万能药。

5.4 输出结果拼接顺序混乱

素数复用路径里,我默认按n1的解密结果在前、n2的解密结果在后进行拼接。但有些题故意把两段明文反着放,或者明文中夹杂填充字符,导致拼出来不是完整的 flag。遇到这种情况,可以分别解密后把两个十六进制结果先打印出来,肉眼确认哪段像 flag 开头,再调整顺序。这个检查比反复跑脚本快得多。

6. 攻击面扩展:从一道题看 RSA 密钥管理的影响范围

6.1 现实中的参数复用来自哪里

很多人觉得这种题只是 CTF 里的理想化模型,其实现实中 RSA 参数复用的案例并不罕见。老旧的随机数生成器如果初始化不良,可能在短时间内为不同用户生成相同的大素数;系统镜像、虚拟机模板、IoT 设备出厂固件里如果直接打包了同样的密钥文件,也会造成一堆设备共用同一个私钥或共用同一个模数;还有不少公司在做密钥迁移时图省事,直接把旧证书的模数复制到新证书里,只改了指数,这类操作都会埋下参数复用的种子。

所以这道题看似在考数学,实际上是在提醒从业者:密钥生成过程必须要有足够的熵,并且不能为了兼容去复用任何关键参数。

6.2 一旦泄露,影响是“链式”的

如果某个 RSA 公钥池里存在共享素数,攻击者只要拿到两份公钥,就能用 gcd 快速分解出这个素数,进而分解所有共享该素数的模数。换句话说,一个短板会拖垮一整批密钥。共模攻击的影响范围也类似:同一个模数被多个不同指数复用,只要攻击者拿到了同一明文在不同指数下的密文,就能在不分解 n 的情况下恢复明文。

这种“链式”效应在真实环境里非常可怕。你以为只是某个边缘系统的密钥出了问题,结果其他用同一个 n 或共享素数的系统全部跟着遭殃。安全工作的重点之一,就是在密钥未出事之前把这些关联关系扫出来。

6.3 防御建议与自查清单

从这道题延伸出来,我觉得每个涉及 RSA 密钥管理的人都应该把下面几条记下来:

  • 密钥生成必须使用密码学安全的随机源,不要用random.random()这类伪随机数生成大素数。
  • 生成多个 RSA 密钥后,一定要做两两 gcd 检查。这个检查非常快,但能直接发现共享素数问题。
  • 公钥池要定期审计,扫描模数是否重复、是否存在共享因子。
  • 不要试图通过给不同用户分配不同 e 的方式来复用同一个 n。这种做法在数学上已经证明是不安全的。
  • 推荐位长至少 2048 bit,重要系统 3072 bit 或更高,但位长只是底线,参数独立性同样重要。

我个人在实际解题中最大的体会是:RSA 题拿到手的第一件事,永远不是急着分解,而是先检查参数之间的“关系”。打印三条信息——n1、n2、gcd(n1, n2)——这几乎成了我的固定动作,能过滤掉一大批题。最后再分享一个小技巧:解密得到的字节先看 hex,再尝试decode(),很多奇怪的编码问题都能这样轻松绕过去。这种参数复用类的题目刷过一次之后,以后再遇到类似的附件结构,基本就能秒判攻击路径了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询