☰
RSA离线解题工具包实战:从公钥解析到维纳攻击全流程
2026/10/20 11:53:35 网站建设 项目流程

简介:面向CTF竞赛选手与密码学学习者的RSA专项工具包,聚焦常用攻击手法与解题流程,覆盖维纳攻击、低加密指数攻击、共模攻击、dp泄露、模不互素、rabin算法、特殊题型AMM等典型场景。资源共32个文件,以Python脚本为主,兼有sagemath辅助脚本、yafu可执行程序、配置与日志文件、素数库等,压缩包约26.83MB,工具链较完整,适合入门与进阶选手在本地直接部署、按需调用。已有2077人学习下载,兼具实战参考与速查价值。包内含多类RSA攻击的现成脚本,并附带yafu的Windows版本及说明,可直接用于大整数分解、素数检测等操作;目录按常见题型组织,便于快速定位对应攻击方案。

1. CTF 加密方向最刚需的工具捆:RSA 离线解题器的真实边界

如果你打 CTF 遇到过那种“公钥 n、e 都给了,密文也给了,但就是解不出来”的 RSA 题,应该能理解这种工具包的价值。这套轩禹CTF_RSA工具 3.6.1 不是给你一个在线接口,而是把 RSA 类题目常见的攻击路径打包成本地脚本集:公钥解析、小因子分解、费马分解、维纳攻击、共模攻击、低加密指数广播、Rabin、DP 泄露,全部离线跑完,最后还能把整个解算过程固化成一份可验证的论证报告。适合三类人:正在备赛的 CTF 选手、需要批量验证 RSA 参数弱点的安全从业者,以及刚学完 RSA 原理但苦于没有完整实现参考的学生。我拆包跑了一遍,把模块结构、参数边界和几个“翻车现场”记录下来,这篇笔记值得你下载前先读一遍。

2. RSA 工具包解压后到底有什么:核心模块与输入的三种姿势

2.1 公钥加载:PEM、DER、Hex 三路输入自动识别

工具包在拿到题目后,第一件事是把公钥转成内部统一的“参数对象”。常见 CTF 题目给公钥的姿势有三种:PEM 文本(-----BEGIN PUBLIC KEY-----开头)、DER 二进制文件、十六进制字符串。这套工具包对三种格式都做了检测,优先尝试文本解析,失败后自动降级成二进制读取。

我一般会先用命令行工具确认公钥格式,再决定走哪条加载路径:

# 查看 PEM 文件详细信息 openssl rsa -pubin -in public.pem -text -noout # 输出中 modulu 即 n,publicExponent 即 e # 如果题目给的是 DER 二进制文件,先转成 PEM 再解析 openssl rsa -pubin -inform DER -in pub.der -out pub.pem

代码里对应的加载函数是这个逻辑:

def load_pubkey(data: bytes) -> tuple[int, int]: # 优先尝试 PEM 文本解析 try: from cryptography.hazmat.primitives.serialization import load_pem_public_key pub = load_pem_public_key(data) nums = pub.public_numbers() return nums.n, nums.e except Exception: pass # PEM 失败则尝试 DER 二进制 try: from cryptography.hazmat.primitives.serialization import load_der_public_key pub = load_der_public_key(data) nums = pub.public_numbers() return nums.n, nums.e except Exception: pass # 最后按 hex 字符串处理 hex_str = data.decode().strip() raw = bytes.fromhex(hex_str) # DER 结构里最后的三个整数分别是 n、e、d 的 TLV # 这里只取 n 和 e,省略 TLV 解析细节 return parse_der_public(raw)

这段代码的核心价值在于“自动降级”。很多入门选手拿到二进制公钥会卡在第一步,实际上工具包把三种格式的解析路径全包进去了。参数说明里有一个关键点:load_pem_public_key在解析到 RSA 私钥时也会成功,但返回对象的public_numbers()依然可取,所以即使题目误给了私钥文件,也能提取出公钥参数。注意 hex 输入里如果混入了不可见字符,bytes.fromhex会直接抛异常,工具包在这里做了异常兜底,但如果你自己改脚本,建议先strip()再判断奇偶长度。

2.2 数学小工具集:小因子、费马分解、Pollard 方法按序调度

工具包内置的数学工具集解决的是 RSA 最基础的问题:n 能不能被分解。这套工具不是上来就丢给 yafu 去跑,而是按“成本从低到高”的顺序自动调度,这个顺序在真实刷题里非常关键。

常见调度顺序是这样的:

1. 检查前 100000 个小素数(用预生成素数表,一秒内出结果) 2. 费马分解(适合 p、q 距离很近的情况) 3. Pollard p-1 分解(适合 p-1 是光滑数的情况) 4. Pollard rho 分解(通用兜底) 5. 提示用户转外部工具

费马分解的实现很短,但用对了场景效率极高:

import math def fermat_factor(n: int) -> tuple[int, int] | None: 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 相差过大时,循环次数会爆炸 # 工具包默认循环上限 100000 次,超过即放弃

这个函数用了一个关键优化:每次循环只做一次平方根整数运算,配合isqrt的精确性,只要 p、q 的实际距离在几千以内,基本秒出结果。参数说明上有一个我实际踩过的坑:a的初始值必须是ceil(sqrt(n))而不是floor(sqrt(n)),否则第一轮就可能漏掉解。工具包在源码里用了math.isqrt后自增补正,这个细节如果你自己抄代码很容易写错。

Pollard p-1 分解在工具包里也有完整实现,它的思路是构造a^k ≡ 1 mod p的机会:

def pollard_p1(n: int, bound: int = 10000) -> int | None: a = 2 for j in range(2, bound): a = pow(a, j, n) d = math.gcd(a - 1, n) if 1 < d < n: return d if d == n: return None # 说明因子已退化,换个 a 重试 return None

参数含义:bound控制枚举光滑因子的最大素数范围,默认 10000 是速度和安全性的折中。如果a-1与 n 直接互素,说明这个a选得不够好,工具包会换a=3,5,7重试。这里要注意:gcd结果为 n 本身时不是成功,而是失败信号,很多初学实现只判断d != 1,结果把 n 本身当因子返回。

2.3 masquerade 调度器:一次调用跑完分解、解密、验签全流程

这是这套工具包比较有辨识度的设计:masquerade 模块承担“代理调度”职责。你输入密文、公钥参数,它自动把上述数学工具按序串联,输出结果的同时生成一份“解题路径日志”。

我用一个具体例子说明工具包内部的串联逻辑:

# 工具包根目录下执行,假设题目三个文件在同级目录 python main.py -c cipher.bin -n n.txt -e e.txt # 输出示例: # [*] 格式检测:cipher.bin 为原始二进制密文 # [*] n 位数:1024 bit # [*] 尝试小因子表:未命中 # [*] 尝试费马分解:未命中 # [*] 尝试维纳攻击:命中,d 已恢复 # [+] 明文已写入 output/plaintext.bin # [+] 验证报告已生成:output/report.txt

调度器本身用了一个按攻击成本排序的优先级表,核心逻辑如下:

PLAN = [ ("small_factor", small_factor_attack), ("fermat", fermat_attack), ("pollard_p1", pollard_p1_attack), ("wiener", wiener_attack), ("common_modulus", common_modulus_attack), ("broadcast", broadcast_attack), ("rabin", rabin_attack), ]

调度逻辑绑定两个前置条件:一是输入里有没有多个密文(共模/广播攻击需要多组数据),二是 e 是否等于 2(Rabin 攻击前提)。工具包会先做静态检查,再进入循环。这个设计在实际刷题里很有用,因为很多选手容易凭直觉跳过某些攻击路径,而调度器按成本排序保证了每个可能性都被低成本的方案“试一遍再说”。

3. 经典攻击模块实战:维纳、共模、广播三条主线的完整代码

3.1 维纳攻击:连分数逼近与 d 恢复的完整实现

维纳攻击成立的前提是私钥指数 d 很小,具体阈值是d < n^0.25 / 3。真实刷题环境里,题目一般不会直接告诉你 d 的范围,所以工具包的做法是:只要公钥的 e 异常大(通常接近 n),就先跑一遍维纳攻击,因为它的计算成本极低。

维纳攻击的本质是用 e/n 的连分数展开去逼近 k/d:

from fractions import Fraction import math def wiener_attack(n: int, e: int) -> tuple[int, int, int] | None: # 1. 计算 e/n 的连分数展开 cf = [] a, b = e, n while b: cf.append(a // b) a, b = b, a % b # 2. 生成所有收敛子 convergents = [] for i in range(len(cf)): if i == 0: k, d = cf[0], 1 else: k = cf[i] * prev_k + prev_prev_k d = cf[i] * prev_d + prev_prev_d convergents.append((k, d)) prev_prev_k, prev_prev_d = prev_k, prev_d prev_k, prev_d = k, d # 3. 逐一验证候选 k/d for k, d in convergents: if k == 0 or d == 0: continue # 验证 e*d ≡ 1 mod k 是必要条件 if (e * d - 1) % k != 0: continue phi = (e * d - 1) // k # 利用 n - phi + 1 = p + q 构造二次方程 s = n - phi + 1 delta = s * s - 4 * n if delta >= 0: root = math.isqrt(delta) if root * root == delta and (s + root) % 2 == 0: p = (s + root) // 2 q = (s - root) // 2 if p * q == n: return d, p, q return None

这段代码有两个细节必须说明。第一,连分数展开到收敛子时,前面的几项 k==0 或者 d==0 必须跳过,不然二次方程直接无意义;第二,验证条件除了(e*d-1)%k==0,还要求n - phi + 1的判别式必须是完全平方数,两个条件都通过才算真正命中。很多网上流传的版本只做了第一个验证,会在部分题目里抛出错误解。

参数上需要知道:维纳攻击几乎不消耗内存,循环次数与连分数长度成正比,而连分数长度通常是几百以内,所以完全可以把维纳攻击当作默认首选项跑一遍。工具包在调度器里把它的优先级排在费马之后、Pollard 之前,这是因为运行成本低,不值得排在后面。

3.2 共模攻击:扩展欧几里得恢复明文的边界处理

共模攻击的场景是:两组公钥共享同一个 n,但 e1、e2 不同,且明文相同。数学依据是扩展欧几里得能找到整数 s1、s2 使得s1*e1 + s2*e2 = gcd(e1, e2),然后m = c1^s1 * c2^s2 mod n。

工具包的实现把负指数的处理单独拎出来了,这是最容易写错的地方:

def egcd(a: int, b: int) -> tuple[int, int, int]: if b == 0: return a, 1, 0 g, x, y = egcd(b, a % b) return g, y, x - (a // b) * y def common_modulus_attack(n: int, e1: int, c1: int, e2: int, c2: int) -> bytes: g, s1, s2 = egcd(e1, e2) if g != 1: # 如果公因子不为 1,说明两个指数不互素,攻击不成立 raise ValueError("e1 and e2 are not coprime") # 处理负指数:先求模逆,再取正指数幂 if s1 < 0: c1_inv = pow(c1, -1, n) m = pow(c1_inv, -s1, n) else: m = pow(c1, s1, n) if s2 < 0: c2_inv = pow(c2, -1, n) m = m * pow(c2_inv, -s2, n) % n else: m = m * pow(c2, s2, n) % n return m.to_bytes((m.bit_length() + 7) // 8, 'big')

负指数处理是共模攻击的“坑中之坑”。Python 3.8 以上pow(c, -1, n)支持直接求模逆,但如果你的运行环境是 Python 3.6 或者更老,就得换成pow(c, phi-1, n)或者显式调用扩展欧几里得。工具包源码里兼容了两个版本,用version_info做了判断。实际刷题时,明文一般是 ASCII 字符串,to_bytes后要尝试直接打印;如果结果乱码,大概率是字节序问题,可以试试'little'端序,这个我在后面避坑章节会详细讲。

注意共模攻击要求“两个密文是同一明文的两次加密”,如果题目故意改了填充,攻击会得到填充后的结果,需要再剥掉标准 padding。工具包在解密后会追加一个自动探测:前两位为\x00\x02时按 PKCS1 规则剥离。

3.3 低加密指数广播:中国剩余定理与开立方补正

广播攻击的出现场景是:同一个明文 m,用同一个指数 e(通常是 3),发送给了多个不同模数的接收方。解题思路是先中国剩余定理合并密文,再对结果开 e 次方。

工具包的实现如下:

import math from functools import reduce def crt(remainders: list[int], moduli: list[int]) -> int: total = 0 M = 1 for mod in moduli: M *= mod for r, mod in zip(remainders, moduli): Mi = M // mod inv = pow(Mi, -1, mod) total += r * Mi * inv return total % M def broadcast_attack(data: list[tuple[int, int]], e: int = 3) -> bytes: # data 是 [(c1, n1), (c2, n2), ...] 至少 e 组 c_list = [c for c, _ in data] n_list = [n for _, n in data] C = crt(c_list, n_list) # 先近似取整 m = int(round(C ** (1 / e))) for candidate in range(m - 10**6, m + 10**6): if pow(candidate, e, n_list[0]) == c_list[0]: return candidate.to_bytes((candidate.bit_length() + 7) // 8, 'big') # 如果直接开立方不成立,尝试 C + k*N 再开 N = 1 for n in n_list: N *= n for k in range(1, 1024): m_candidate = int(round((C + k * N) ** (1 / e))) if pow(m_candidate, e, n_list[0]) == c_list[0]: return m_candidate.to_bytes((m_candidate.bit_length() + 7) // 8, 'big') return b""

这里有个初学者很难发现的细节:C ** (1 / e)是浮点运算,得到的结果天然有精度误差,所以代码先取整再用m-10^6到m+10^6的范围暴力验证。1e6 这个参数是工具包作者根据 N 的位数动态算出来的,位数越大浮动范围越大,你可以直接沿用,也可以改成pow(C, 1, N)的方式配合整数开方。第二种情况C + k*N的处理非常重要,因为如果 m^3 实际上稍大于 C(由 CRT 进位造成),直接开方会失败,遍历 k 才能命中真实值。

广播攻击的最后一个关键点:至少需要 e 组密文。e=3 就至少需要三组,e=65537 时这个方案就没意义了,工具包调度器会在len(data) < e时直接跳过。

4. 高概率验证跑法:Rabin、DP 泄露与参数推导链的自动化

4.1 Rabin 盲区与四根处理

Rabin 攻击的触发条件是 e=2,这是 RSA 的退化情形。解密过程是解二次同余方程,工具包针对 p 和 q 都满足p % 4 == 3的情况做了快速实现,这是绝大多数 CTF 题的出题方式。

核心思路是先分别在模 p 和模 q 域里开根,再用 CRT 合成四个候选明文:

def rabin_attack(n: int, c: int, p: int, q: int) -> list[bytes]: # 前提:p % 4 == 3 且 q % 4 == 3 root_p = pow(c, (p + 1) // 4, p) root_q = pow(c, (q + 1) // 4, q) # 四个候选根组合:(r_p, r_q), (r_p, q - r_q), (p - r_p, r_q), (p - r_p, q - r_q) candidates = [] for rp in (root_p, p - root_p): for rq in (root_q, q - root_q): # CRT 合成 m = (rp * q * pow(q, -1, p) + rq * p * pow(p, -1, q)) % n candidates.append(m) results = [] for m in candidates: try: plain = m.to_bytes((m.bit_length() + 7) // 8, 'big') results.append(plain) except OverflowError: continue return results

四个根里只有一个是真的 flag,判断依据是看 ASCII 可打印性。工具包会遍历四个候选并打印所有能解码为可读文本的结果,如果四个都不像明文,检查是否有 padding。这里有一个通用方案:把四个候选按“包含字母数字比例”打分,返回分数最高的一个。

4.2 DP 泄露:本地方程恢复 p 的完整推导链

DP 泄露是指题目额外给了dp = d mod (p-1)的值,通常伴随 n、e、c。利用e * dp ≡ 1 mod (p-1)的变形可以恢复 p 因子。工具的暴力枚举逻辑如下:

def dp_attack(n: int, e: int, dp: int, c: int) -> bytes: # 枚举 k,k 的范围是 [1, e) for k in range(1, e): # 核心方程:e*dp - 1 = k*(p-1) p_candidate = (e * dp - 1) // k + 1 if n % p_candidate == 0: p = p_candidate q = n // p break else: raise ValueError("No p found, dp may be incorrect") # 已知 p 后,先求 m mod p m_p = pow(c, dp, p) # 再用 CRT 合成完整明文 m mod n phi = (p - 1) * (q - 1) d = pow(e, -1, phi) # 由 e 求完整 d m = pow(c, d, n) return m.to_bytes((m.bit_length() + 7) // 8, 'big')

这段代码里for k in range(1, e)在 e=65537 时会跑满 6 万多次,看起来吓人,但每次循环都是 O(log n) 的整数取余,实际执行时间不到一秒。如果 e 是 65537 以上更大的数,工具包会自动加一个优化:只遍历(e*dp-1)的约数,而不是逐一遍历。

注意一个容易翻车的点:代码先求m_p = pow(c, dp, p)其实是为了更快验证解的正确性,但真正的明文还是要靠完整私钥 d 来解。因为 dp 只在模 p-1 下有效,用它直接解出的结果只在 p 域成立,直接输出会得到半个明文。

4.3 从公钥到明文的自动化推导链:识别与选路

工具包把“给定一个题目文件,自动识别攻击类型”这件事做成了独立函数,核心逻辑是根据参数特征选择攻击路径:

def route_attack(n: int, e: int, c_list: list[int], n_list: list[int], dp: int = 0) -> str: bit_len = n.bit_length() # 1. 最便宜的小因子检测 for s in SMALL_PRIMES_TABLE: if n % s == 0: return "small_factor" # 2. Rabin 攻击识别条件 if e == 2: return "rabin" # 3. 维纳攻击的高概率区间 if bit_len <= 4096 and e > bit_len // 2: return "wiener" # 4. 多模数优先级 if len(c_list) > 1 and len(set(n_list)) > 1: if len(c_list) >= e and e <= 65537: return "broadcast" return "common_modulus" # 5. DP 泄露检测 if dp > 0: return "dp_leak" # 6. 兜底 return "unknown"

这段代码的价值在于“无脑试错”升级为“有先验判断”。比如 e 特别大时要优先跑维纳,而不是先进费马;多个密文要在广播和共模之间做区分——如果 n 都相同就是共模,n 都不同才是广播。实际使用时,如果 route 结果返回 unknown,工具包会退回去跑一轮 yafu 引导命令,把命令直接打到终端让你复制执行。

5. RSA 工具包翻车现场:四条常见问题与排查顺序

5.1 维纳攻击报告命中但 d 解不出正确明文

现象:工具包显示[+] wiener attack hit,输出的 d 也符合e*d ≡ 1 mod k的验证条件,但用 d 解密密文得到的是乱码。

原因:维纳收敛子里存在“假收敛”情况,判据只验证了(e*d-1)%k==0和二次方程判别式,没有验证 d 是否真正满足e*d ≡ 1 mod φ(n)。在某些边界题目中,收敛子给出的 d 能通过前两个验证,但对应的 φ 与 n 不匹配。

解决:检查输出的 p、q 乘积是否严格等于 n。工具包在wiener_attack返回值里已经做了p*q==n的终验证,如果你在复写脚本时漏掉这一步,就会踩这个坑。强制在验证逻辑里保留最后一行的assert p*q == n,没有它就不要信任 d。

5.2 e=3 广播攻击开立方精度溢出

现象:CRT 合并出的 C 值非常大,C ** (1/3)得到的结果与真实 m 相差几千,工具包第一轮近邻搜索未能命中,直接返回空结果。

原因:浮点开立方天生有精度损失,C 的位数越大,相对误差越大。而且当 m^3 大量进位后,C 并不是 m^3 的精确值,需要调整倍数 k*N。

解决:扩大近邻搜索范围,或者直接改成整数二分开立方。工具包默认近邻范围是正负 1e6,对 1024 位模数够用,但遇到 2048 位模数时,我一般会改成int(round(C ** (1/3)))后做 ±1e6 的验证以外,再额外检查C+k*N从 0 到 512 的遍历。实测中第二种情况命中概率不低。

5.3 共模攻击负指数导致的模逆失败

现象:common_modulus_attack抛出ValueError: base is not invertible,或者返回的明文前缀不是\x00\x02。

原因:扩展欧几里得返回的 s1、s2 经常一个是负数,另一个是正数,直接pow(c1, s1, n)在 Python 3.7 及以下会直接报错,在 3.8 以上会计算模逆,但题目如果密文与 n 不互素,模逆依然不存在。

解决:先做互素检查:math.gcd(c1, n) == 1,不互素时共模攻击天然不成立。互素后再处理负指数,用pow(c1, -1, n)求逆,再取正幂。如果使用老版本 Python,用egcd手动求逆,不要直接依赖内置。

5.4 公钥 hex 字符串字节序不一致

现象:题目给的十六进制字符串解析出的 n 位数远小于文件体积暗示的位数,或者维纳攻击跑通了但 p、q 复原后乘积与 n 不一致。

原因:部分题目把 DER 编码的十六进制做字符串输出时,前导\x00被丢弃,或者字节序为大端小端混用,解析得到错误的 n 值。

解决:在解析函数里对所有 16 进制串先判断长度奇偶,奇数时在前面补0;再检查第一个字节是否为0x30(DER 序列化标志),如果不是,尝试反转字节序。工具包实现里专门有一个normalize_hex_key函数,我建议你不管从哪个来源拿 hex,都先过一遍这个函数再进入后续解析。

5.5 长明文超过单次加密区块导致密文被截断

现象:明文是超过 117 字节的长文本,但题目给的单条密文长度明显较短,解密后只能得到部分明文。

原因:RSA 单次加密的明文上限由 n 的位数决定,例如 1024 位 n 最多加密 117 字节(PKCS1 v1.5 会占用 11 字节 padding)。题目出题时如果用了长明文,通常会拆成多段分别加密,但题目描述里经常不说明。

解决:工具包在调度器解析出明文后,检查输出结果是否以常见文本开头;如果疑似拆包,会自动尝试把cipher.bin按 128 字节(1024 位 n)分块分别解密再拼接。这个功能默认关闭,需要在命令行加--multi-block参数开启,我建议拿到长密文时直接开启。

6. 把一次解题过程固化成可复现验证:Masquerade 生成论证报告的进阶用法

这套工具包区别于普通脚本集的地方在于,它自带的 masquerade 模块能把一次成功的攻击过程转成一份独立、可重新执行、可提交的解题论证,而不仅仅是输出一个 flag。我通常在成功破解一道题之后,会执行一遍这个流程,把“攻破过程”转成“经验资产”。

masquerade 生成一份论证报告的核心逻辑是这样的:

def build_report(result: dict) -> str: attack = result["attack"] vp = result["audit_points"] # 验证点字典 lines = [f"Attack: {attack}"] for name, value in vp.items(): lines.append(f" {name}: {value}") # 最终验证式,保证任何人都能离线复核 lines.append(f" verify: pow(m, e, n) == {result['c']}") lines.append(f" prime_check: p * q == n") return "\n".join(lines)

audit_points里记录的不是中间变量本身,而是它们的哈希值,这样报告不会被篡改,同时保持可读性。对于一个维纳攻击的结果,报告内容大致长这样:

Attack: wiener n_bit_length: 1024 e: 0x10001 d_estimate_bit_length: 486 convergents_scanned: 237 delta_square: 0x1a2b3c... p_q_product_check: 0x8f3d... verify: pow(m, e, n) == 0x7c2e...

有了这份报告,你下次遇到同类型题目时,不用重新推导数学原理,直接把d_estimate_bit_length和convergents_scanned这两个数值作为参考指标,就能快速判断新题的维纳攻击难度。这相当于把黑匣子的解题过程变成了白盒验证。

我自己的习惯是:无论题目是秒解还是卡了很久才解出来,最后都会强制走一遍 masquerade 的离线生成流程,把报告文件和 flag 一起存档。这个习惯帮我建立了一个本地题库,每次碰到新题目先在报告库中模糊搜索一次,偶尔还能直接命中之前解过的变形题。从那以后,我每次把某道 RSA 题解完,都会强制走一遍 masquerade 的离线验证闭环,哪怕多花三十秒,也坚持补完这个过程。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询