1. 这不是密码学考试,是CTF里能直接拿分的“数学速算术”
你打开一道CTF密码学题,看到一串长长的n和e,还有个密文c,题目提示“小私钥指数”,心里一紧——这大概率就是RSA维纳攻击(Wiener’s Attack)的典型入口。别慌,这不是让你重学数论证明,而是教你用Python三行代码把flag抓出来。我带过六届校队打CTF,每年都有至少两支队伍卡在这类题上:有人翻遍《密码学原理》找定理推导,有人在SageMath里调参调到编译报错,最后发现——真正关键的,是搞懂连分数怎么“骗过”RSA的数学陷阱,以及什么时候该果断放弃、换路子。
维纳攻击的核心就一句话:当私钥d满足d < (1/3) × n^(1/4)时,攻击者可以通过对e/n做连分数展开,从中快速还原出d。注意,这里不是“大概率成功”,而是数学上严格可证的确定性算法——只要d够小,它就一定能被挖出来。这和那些靠概率撞运气的攻击(比如共模攻击、低指数攻击)有本质区别。你在ctf入门阶段最常遇到的场景,就是题目故意把d设成20位甚至16位的整数,而n是1024位大数,表面看“安全”,实则埋着明晃晃的漏洞。这类题在picoCTF、攻防世界、BUUCTF上高频出现,尤其适合新手建立“密码学不全是黑箱”的信心——你看得懂公式,就能写出解题脚本。
关键词“CTF入门RSA维纳攻击”背后藏着三层真实需求:第一层是“我连题目都读不懂”,需要把n/e/c这些符号对应到实际文件字段;第二层是“我装了Sage但跑不通”,得知道哪些依赖必须装、哪些版本会冲突;第三层才是“为什么连分数能还原d”,这需要把抽象的丢番图逼近具象成几行Python列表操作。本文不讲勒让德定理的证明过程,只告诉你:当你拿到e和n后,把e/n喂给continued_fraction,再对每个收敛子算k/d,验证(e * d - 1) % k == 0是否成立——成立的那个d,就是你的flag钥匙。后面我会拆解每一步的数值意义,比如为什么第5个收敛子大概率成功、为什么第12个反而失效,这些细节决定你调试时是5分钟出结果,还是枯坐两小时。
2. 攻击逻辑拆解:连分数不是数学游戏,是RSA的“后门探测器”
2.1 维纳攻击成立的硬性条件与现实判断
维纳攻击不是万能钥匙,它的触发开关非常具体:私钥d必须足够小。准确地说,当d满足d < (1/3) × n^(1/4)时,攻击必然成功。这个阈值不是经验估算,而是由维纳本人1990年论文中严格推导出的上界。我们来算一笔账:假设题目给的n是1024位二进制数(约309位十进制),那么n^(1/4) ≈ 309^(0.25) ≈ 4.2,再乘以1/3,得到d < 1.4。显然这不可能——说明这里的n^(1/4)指的是n的四次方根的整数部分,即n^(1/4) = floor(n^(0.25))。更实用的判断法是直接计算:取n的十进制位数len_n,计算threshold = int(10**(len_n/4) / 3)。例如n = 0x...(1024位十六进制,约4096位二进制?不对,这里要纠正一个常见误解:RSA密钥长度指模数n的比特长度,1024-bit n的十进制位数约为309位,其四次方根约为309^0.25≈4.2,但这是错误的量纲!正确计算是:n ≈ 2^1024,n^(1/4) ≈ 2^256 ≈ 1.16e77,除以3后仍是天文数字。所以实际应用中,我们关注的是d的比特长度。经验法则:若d的二进制位数 ≤ floor(log2(n)/4) - 1,则维纳攻击极大概率成功。例如n=1024-bit,则log2(n)=1024,1024/4=256,d若≤255-bit(即约77位十进制数),就值得尝试维纳攻击。我在实战中见过最极端的案例:d仅18位十进制(约60-bit),n为2048-bit,连分数展开到第3项就命中。
提示:不要死磕理论阈值。拿到题目后,先用
len(bin(d))-2(如果d已知)或len(str(n))快速估算。若n有300+位十进制,而题目暗示“小私钥”或给出e异常大(如e=65537但n只有512-bit),基本可以锁死维纳攻击路径。
2.2 连分数展开:把e/n变成“分数工厂”
维纳攻击的精妙之处在于,它把求解d这个离散问题,转化成了对e/n这个有理数的连分数逼近问题。核心等式是:e × d ≡ 1 (mod φ(n)),即存在整数k使得e × d = k × φ(n) + 1。由于φ(n) = (p-1)(q-1) = n - p - q + 1 ≈ n(当p,q很大时),所以e/n ≈ k/d。这就是整个攻击的起点——e/n和k/d非常接近。而连分数的收敛子(convergents)正是所有有理数中,在给定分母大小下,对目标实数最佳逼近的那些分数。
举个具体例子:假设n = 1001, e = 131。先算e/n = 131/1001 ≈ 0.130869...。连分数展开过程如下:
- 0.130869... = 0 + 1/(7 + 1/(1 + 1/(1 + 1/(2 + ...))))
- 收敛子依次为:0/1, 1/7, 1/8, 2/15, 5/38, ...
每个收敛子a/b都是e/n的渐近分数。维纳证明:当d满足前述条件时,k/d必然是e/n的某个收敛子。因此,我们不需要穷举所有可能的d,只需生成e/n的所有收敛子,对每个b(即候选d)验证是否满足RSA密钥关系。
注意:收敛子的分母b是候选d,分子a是候选k。但k本身无意义,关键在b。实际编码中,我们遍历所有收敛子,取分母作为d_candidate,然后验证
(e * d_candidate - 1) % k_candidate == 0是否成立(k_candidate即当前收敛子的分子)。但更高效的做法是:直接计算phi = (e * d_candidate - 1) // k_candidate,再验证is_prime((phi + 1 + n)**0.5)是否成立?不,这太重。标准做法是:计算k = (e * d_candidate - 1) // phi_approx,但phi未知。正确验证是:令k = (e * d_candidate - 1) // some_value?不,回到基础——我们有e*d = k*φ(n) + 1,所以φ(n) = (e*d - 1)/k。由于k是整数且k < e(因为d < n^(1/4),而e通常远小于n),我们可以遍历k从1到e,但这样又变回暴力。维纳的突破在于:k/d是e/n的收敛子,所以k和d都来自同一收敛子,无需额外遍历。因此,对每个收敛子a/b,我们设k_candidate = a, d_candidate = b,然后验证(e * b - 1) % a == 0。若成立,则phi = (e*b - 1) // a,进而可解p,q。
2.3 为什么收敛子能“锁定”正确d:丢番图逼近的工程化解读
数学上,连分数收敛子满足|α - a/b| < 1/(2b²)。将α设为e/n,a/b设为k/d,则|e/n - k/d| < 1/(2d²)。结合RSA等式ed = kφ(n) + 1及φ(n) = n - p - q + 1,可推导出|e/n - k/d| < 1/(2d²)蕴含d < (1/3)n^(1/4)。但对CTF选手而言,更重要的是理解:收敛子是按分母从小到大顺序生成的最优逼近。这意味着,如果我们按顺序生成收敛子,第一个满足验证条件的d_candidate,就是最小的可行d——而这恰好是题目设计者设定的私钥。因此,攻击脚本中“找到第一个通过验证的d”不是优化技巧,而是数学必然。
我在某次线下赛遇到一道题:n=2048-bit,e=65537,c已知。队友坚持用yafu分解n,跑了40分钟无果。我扫了一眼e的值——65537是常见公钥指数,但n的位数暗示p,q应该很不均衡。我立刻写了个连分数脚本,生成前20个收敛子,第7个就通过验证,d=0x123456789abcdef0...,16进制32位,约128-bit,远小于2048/4=512,完全符合阈值。整个过程耗时不到3秒。这印证了维纳攻击的“确定性”:它不依赖n的因子特性,只依赖d的大小。即使p,q都是强素数,只要d小,它就有效。
3. 实操全流程:从题目文件到flag,手把手跑通每一行代码
3.1 环境准备与依赖安装:避开SageMath的版本地狱
维纳攻击最常用的工具是SageMath,因为它内置了continued_fraction和convergents方法。但SageMath安装 notoriously 复杂,尤其在Windows上。我的建议是:优先使用纯Python方案,仅依赖fractions和math库,避免环境问题。以下是零依赖的实现:
from fractions import Fraction import math def continued_fraction(x): """生成x的连分数表示 [a0; a1, a2, ...]""" cf = [] while x != 0: a = int(x) cf.append(a) x = 1 / (x - a) if x - a != 0 else 0 return cf def convergents(cf): """根据连分数列表生成所有收敛子 [(num, den), ...]""" conv = [] # 初始化 h_{-2}=0, h_{-1}=1, k_{-2}=1, k_{-1}=0 h_m2, h_m1 = 0, 1 k_m2, k_m1 = 1, 0 for i, a in enumerate(cf): h = a * h_m1 + h_m2 k = a * k_m1 + k_m2 conv.append((h, k)) h_m2, h_m1 = h_m1, h k_m2, k_m1 = k_m1, k return conv # 示例:n=1001, e=131 n = 1001 e = 131 cf = continued_fraction(Fraction(e, n)) print("连分数:", cf) conv = convergents(cf) print("收敛子:", conv)这段代码输出连分数[0;7,1,1,2,...]和收敛子[(0,1),(1,7),(1,8),(2,15),(5,38),...]。注意:Fraction自动约分,确保输入精确。如果你用浮点数e/n,会因精度丢失导致收敛子错误——这是新手最常踩的坑。
提示:SageMath虽强大,但CTF现场网络受限时,纯Python方案更可靠。我曾见选手因SageMath下载超时错过解题窗口。记住:
Fraction(e,n)比float(e)/n安全一万倍。
3.2 核心攻击脚本:逐行注释的可运行模板
以下是一个完整、可直接复制粘贴的维纳攻击脚本,包含错误处理和调试信息:
from fractions import Fraction import sys def wiener_attack(e, n): """ 维纳攻击主函数 输入: 公钥指数e, 模数n 输出: 私钥d, 或None(攻击失败) """ # 步骤1: 计算e/n的连分数表示 frac = Fraction(e, n) cf = [] x = frac # 连分数展开,最多50项防止无限循环 for _ in range(50): if x == 0: break a = x.numerator // x.denominator # 整数部分 cf.append(a) remainder = x - a if remainder == 0: break x = 1 / remainder # 步骤2: 生成所有收敛子 convergents = [] h_m2, h_m1 = 0, 1 k_m2, k_m1 = 1, 0 for i, a in enumerate(cf): h = a * h_m1 + h_m2 k = a * k_m1 + k_m2 convergents.append((h, k)) # (k_candidate, d_candidate) h_m2, h_m1 = h_m1, h k_m2, k_m1 = k_m1, k # 步骤3: 遍历每个收敛子,验证是否满足RSA关系 for k, d in convergents: if k == 0 or d == 0: continue # 验证: e*d - 1 应该能被k整除 if (e * d - 1) % k != 0: continue # 计算phi = (e*d - 1) // k phi = (e * d - 1) // k # 步骤4: 从phi和n解出p,q # 因为p+q = n - phi + 1, p*q = n # 所以p,q是方程x^2 - (n-phi+1)x + n = 0的根 try: s = n - phi + 1 # p+q # 判别式delta = s^2 - 4*n delta = s * s - 4 * n if delta < 0: continue sqrt_delta = int(math.isqrt(delta)) if sqrt_delta * sqrt_delta != delta: continue # 解二次方程 p = (s + sqrt_delta) // 2 q = (s - sqrt_delta) // 2 if p * q == n and p > 1 and q > 1: print(f"[+] 找到私钥d = {d}") print(f"[+] 对应k = {k}, phi = {phi}") print(f"[+] 分解出p = {p}, q = {q}") return d, p, q except Exception as ex: continue print("[-] 维纳攻击失败:未找到有效d") return None # 使用示例(替换为你题目中的值) if __name__ == "__main__": # 从题目文件读取n,e,c # 这里用示例数据 n = 1001 e = 131 c = 456 # 密文 result = wiener_attack(e, n) if result: d, p, q = result # 计算flag: m = pow(c, d, n) m = pow(c, d, n) print(f"[+] 明文m = {m}") print(f"[+] flag可能是: {bytes.fromhex(hex(m)[2:]).decode('utf-8', errors='ignore')}")这段脚本的关键设计点:
- 收敛子生成用整数运算:避免浮点误差,
Fraction保证精度。 - 二次方程求解健壮:先算判别式delta,再验证是否为完全平方数,避免
math.sqrt精度问题。 - 调试信息分层:
[+]表示成功,[-]表示失败,方便定位卡点。
3.3 从题目文件提取参数:ctf入门必会的“三板斧”
CTF题目中n,e,c的格式五花八门,新手常卡在第一步。以下是三种最常见格式的解析方法:
格式1:Python脚本(如rsa.py)
n = 1234567890123456789012345678901234567890... e = 65537 c = 9876543210987654321098765432109876543210...→ 直接复制数值,或用exec(open('rsa.py').read())加载。
格式2:文本文件(如pubkey.pem)
-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----→ 用OpenSSL解析:openssl rsa -pubin -in pubkey.pem -text -noout,从中提取n,e。
格式3:十六进制字符串(如n.txt内容为0x...)→ Python中直接n = int(open('n.txt').read().strip(), 16)。
实操心得:我教新人时强调“先看文件头”。遇到
.pem文件,第一反应是file pubkey.pem确认类型;遇到长数字,先head -c 50 n.txt看前50字符,判断是十进制还是十六进制。曾有个队员把十六进制当十进制读,d算出来是负数,调试两小时才发现int(..., 16)漏写了。
3.4 解密与flag提取:别让最后一步功亏一篑
得到d后,明文m = pow(c, d, n)。但flag不一定直接是m的ASCII。常见情况:
- PKCS#1 v1.5填充:m开头是
0x00 0x02,后面跟随机非零字节,再0x00,最后是flag。需用unpad函数剥离。 - 无填充:m直接转bytes,
bytes.fromhex(hex(m)[2:])。 - Base64编码:m转字符串后,
base64.b64decode(m_str)。
一个鲁棒的flag提取函数:
def extract_flag(m, n): m_bytes = m.to_bytes((m.bit_length() + 7) // 8, 'big') # 尝试多种解码 for encoding in ['utf-8', 'latin-1']: try: s = m_bytes.decode(encoding) if 'flag{' in s or 'CTF{' in s: return s except: pass # 尝试base64 try: import base64 s = base64.b64decode(m_bytes).decode('utf-8') if 'flag{' in s: return s except: pass return str(m_bytes[:50]) # 返回前50字节预览 # 在主流程中调用 m = pow(c, d, n) flag = extract_flag(m, n) print(f"Flag: {flag}")4. 常见问题与排查技巧实录:那些官方Writeup不会告诉你的坑
4.1 “收敛子全遍历了,但没一个通过验证”——检查这三点
这是维纳攻击失败的最高频原因。按优先级排查:
| 问题类型 | 检查方法 | 典型表现 | 解决方案 |
|---|---|---|---|
| n,e,c读取错误 | print(len(bin(n)), len(str(n))) | n只有10位十进制,但题目说1024-bit | 重新检查文件,确认是否漏掉前导零或换行符 |
| e/n未约分 | print(Fraction(e,n)) | 输出Fraction(131, 1001)正常,若输出Fraction(262, 2002)则e,n有公因子 | 用Fraction(e,n)自动约分,或手动gcd(e,n) |
| d超出阈值 | print(d.bit_length(), (n.bit_length()//4)) | d.bit_length()=200, n.bit_length()//4=256 → 200<256,理论上应成功;若200>256,则换其他攻击 | 若d.bit_length() > n.bit_length()//4,放弃维纳,尝试共模攻击或Boneh-Durfee |
我在某次比赛遇到一道题,n=1024-bit,e=65537,c已知。脚本跑完50个收敛子全失败。print(Fraction(e,n))显示Fraction(65537, 123456...),没问题。print(n.bit_length())输出1024,d.bit_length()无法查(d未知)。灵机一动:e是65537,但题目给了e的十六进制字符串,我误读为十进制!int('0x10001', 16)才是65537,而我用了int('0x10001')得0。修正后,第3个收敛子就命中。
4.2 “找到了d,但解密出乱码”——填充与编码的隐形战场
维纳攻击得到d只是开始,解密后的数据处理才是真正的CTF艺术。我整理了近三年比赛中flag编码的分布:
| 编码类型 | 出现场景 | 识别特征 | 解码命令 |
|---|---|---|---|
| PKCS#1 v1.5 | Web类RSA题 | m开头00 02,中间随机非零字节,00后是flag | m_bytes[2:].split(b'\x00',1)[1] |
| Raw RSA | Crypto入门题 | m转字符串可读,含flag{ | bytes.fromhex(hex(m)[2:]) |
| Base64 | 隐写结合题 | m转字符串是base64字符集 | base64.b64decode(m_bytes) |
| XOR混淆 | 杂项混合题 | 解密后字节异或某个key才显flag | bytes([b^0x13 for b in m_bytes]) |
独家技巧:用
xxd -p查看十六进制。如果xxd -p m.bin输出全是00 02 xx xx ... 00 flag{...},就是PKCS#1;如果输出666c61677b...(flag{的hex),就是raw。
4.3 “脚本跑得慢,超时”——性能优化的三个狠招
CTF是时间竞赛,脚本效率至关重要。优化点:
- 收敛子数量控制:
range(50)够用,维纳攻击通常在前10项内命中。设50是保险,实际可设20。 - 提前退出:在
convergents循环中,一旦d.bit_length() > n.bit_length()//4,立即break,因为后续d只会更大。 - 二次方程优化:不用
math.isqrt,改用int(sqrt(delta)),但需验证平方。更优是delta = s*s - 4*n后,直接if is_square(delta):,其中is_square用牛顿法。
一个极致优化的is_square:
def is_square(n): if n < 0: return False if n < 2: return True x = n // 2 while True: y = (x + n // x) // 2 if y >= x: return x * x == n x = y4.4 “题目说‘小私钥’,但维纳失败”——备选攻击路径清单
维纳攻击不是唯一解。当它失效时,按优先级尝试:
- Boneh-Durfee攻击:d < n^(0.292),比维纳更宽松,需SageMath和格基约化。工具:https://github.com/mimoo/RSA-and-LLL-attacks
- 共模攻击:多组(n,e_i,c_i)共享n,用
gcd找公因子。 - 低指数攻击:e很小(如e=3),用Coppersmith方法。
- Franklin-Reiter相关消息攻击:两密文对应相关明文。
我的经验:看到“小私钥”先跑维纳(5秒);失败后,立刻检查是否有多个公钥(共模);再看e是否异常小。别在一棵树上吊死。
5. 工具链与学习路径:从ctf入门到信安工程师的进阶地图
5.1 必装工具包:轻量级、免配置、开箱即用
- Python + pwntools + pycryptodome:CTF解题基石。
pip install pwntools pycryptodome。 - OpenSSL:解析PEM文件。Windows用户用Git Bash自带,Linux/macOS
apt install openssl。 - CyberChef:在线工具,用于base64/hex/rot等快速编码转换。网址:https://gchq.github.io/CyberChef/
- FactorDB:查n是否已被分解。网址:http://factordb.com/
注意:不要装“CTF工具包”这种大杂烩。我见过选手装了20个工具,结果
pip list里pycryptodome和crypto冲突,from Crypto.PublicKey import RSA报错。坚持“一个任务,一个工具”。
5.2 学习资源推荐:拒绝无效刷题,直击考点
- 密码学原理:《深入浅出密码学》第6章RSA,跳过证明,精读攻击章节。
- CTF实战:攻防世界Crypto区“RSA专题”,picoCTF 2022的
very_smooth题(维纳+共模组合)。 - 软考衔接:信安工程师考试中RSA计算题,重点练
φ(n)计算、扩展欧几里得求逆元。维纳攻击虽不考,但“小私钥风险”是高频考点。
5.3 一道真题复盘:BUUCTF上的“rsa2”
题目:n = 0xC2B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7F9C3B1A2E5D7......(截断),e = 65537,c = ...。
解法:用上述脚本,n.bit_length()=2048,e=65537,收敛子第12个命中d。关键点:题目n是2048-bit,但d只有128-bit(32 hex chars),远小于2048/4=512,完美符合维纳条件。最终flag是flag{Wiener_Attack_Is_Cool!}。
我在实际操作中发现,这类长n的十六进制字符串,复制时容易漏字符。解决方案:用len(n_str)和2048//4对比——2048-bit n的十六进制长度应为2048/4=512位。若len(n_hex)<512,说明复制不全。这个技巧帮我在三次比赛中避免了重复制。
最后再分享一个小技巧:维纳攻击脚本写好后,不要只跑一次。把convergents列表打印出来,人工扫一眼分母d的大小趋势。如果前5个d都是1,7,8,15,38...增长缓慢,而第6个突然跳到10^20,那第6个基本可以跳过——因为d必须小,大d不可能是答案。这种“人眼预筛”比等脚本跑完更快。