很多开发者和安全工程师第一次听到“RSA低解密指数攻击”时,都会觉得这是离自己很远的学术名词。但我在实际做密钥审计时,已经不止一次用维纳攻击(Wiener Attack)在一眨眼之间就把公钥背后的两个大素数还原出来。这个攻击非常纯粹:它只需要公开的(e, n),不需要任何密文或签名样本,一旦私钥指数d满足条件,d就藏在e/N的连分数展开里。这篇文章会把数学原理到代码实现完整串一遍,最后用一个可以手动验算的实例带大家复现整条攻击链路。无论你是做安全测试、写支付接口的后端,还是正在学RSA的密码学入门者,读完都能拿到一套可以直接运行的脚本,以及一个判断密钥是否“裸奔”的硬标准。
1. 先聊清楚:为什么私钥指数d的大小会决定生死
1.1 RSA参数背后的基本关系
RSA能工作,核心依赖一组简单关系:取两个大素数p和q,计算n = p·q,再算欧拉函数φ(N) = (p-1)(q-1)。随后选一个与φ(N)互素的公开指数e,用扩展欧几里得算法求出私钥指数d,使得:
e·d ≡ 1 (mod φ(N))
公钥是(n, e),私钥是(n, d)。如果攻击者不知道φ(N)或p、q,理论上就无法从公开的公钥反推出d。整个RSA的安全性,就建立在这个“模逆关系里的d不可求解”的前提之上。
但这里有一个极其容易被人忽略的细节:RSA标准只要求e·d ≡ 1 (mod φ(N)),并没有限制d的取值范围。标准实现里,e固定为65537,d是通过模逆运算随机得到的大数,和N几乎同长度——2048位RSA的d通常就有2048位。可这不是数学上的“必须”,你完全可以主动选择一个几十位的小d,反算出一个很大的e,这样构造出的密钥依然能正常加解密,只是安全性已经烂到根了。
1.2 d小到什么时候会出事
直觉上,d的范围越小,私钥信息就越容易被“扫”出来。但维纳攻击比“扫描”更恐怖,它不做穷举,而是用连分数直接把d从公开数据里解出来。数学上的充分条件是:
d < (1/3) · N^(1/4)
这个条件看起来抽象,代入具体尺寸就明白了。对1024位RSA,N^(1/4)约为256位,也就是d只要小于2^256/3这个量级,维纳攻击就保证成功。对512位RSA,N^(1/4)是128位,攻击范围更宽。
需要强调的是,这只是一个充分条件,不是精确边界。实际测试中,d稍微超过这个阈值时,连分数方法仍有相当高的命中概率。所以“疑似小d”的公钥,都值得跑一遍脚本试试。我最早接触这个攻击是在一次模拟应急响应里,当时拿到一张e异常巨大的证书,公钥的e通常都是65537,突然看到一个几千位的e,第一反应就是“这密钥有问题”,跑了脚本后0.01秒内p、q就出来了,队友都愣住了。
1.3 哪些场景最容易产生小d密钥
根据我的审计经验,有三类场景最容易出现小d:
第一类,嵌入式设备或低功耗硬件为了提升签名速度,把模幂运算做了“优化”,有人直接把d缩短到128位甚至64位。签名计算确实变快了,但安全性荡然无存。
第二类,教程里的“极简RSA实现”为了展示方便,在示例代码中写了固定的d,比如d = 3或d = 65537,有的开发者直接CV到生产代码,所有密钥的d都一样。
第三类,主动构造的后门密钥。某些系统签发证书时会用特殊参数,只要d被人为设定得很小,掌握方法的人就能快速分解n,拿到整个证书体系的根权限。这种操作在黑产和灰色产业里并不罕见。
2. 数学原理:连分数是怎么揪出藏起来的d
2.1 从一个同余式到一组近似分数
维纳攻击的起点是前面那个同余式。因为ed - kφ(N) = 1,两边同时除以d·φ(N),就得到:
e/φ(N) - k/d = 1 / (d·φ(N))
右边是一个非常小的正数。这告诉我们一件重要的事:k/d是e/φ(N)的一个极高精度的有理近似。再看φ(N)本身,φ(N) = n - (p+q) + 1,而p+q作为两个大素数的和,在比值上只是n的“零头”,所以φ(N)和n的差距非常小。综合考虑,k/d也就极其接近e/N。
这里的逻辑链是:
- 不知道d,但我们手里有公开的e和n
- 知道k/d应该非常接近e/N
- 问题变成:如何从e/N的有理近似序列里,认出哪一个恰巧是k/d
2.2 俄罗斯套娃一样的连分数
“连分数”可以理解为分数的俄罗斯套娃。任意一个实数x都能展开成这种形式:
x = a0 + 1 / (a1 + 1 / (a2 + 1 / (a3 + ...)))
每次“截断”这个无穷嵌套,就得到一个渐近分数,记作p_i/q_i。例如e/N的连分数前几项是[0; 1, 3, 1, 1, 2, ...],对应的渐近分数依次是0/1、1/1、3/4、4/5、7/9、18/23……它们会一级比一级更贴近原始数值。
连分数理论里有一条关键定理:如果某个有理数a/b非常接近x,满足|x - a/b| < 1/(2b²),那么a/b必然出现在x的连分数渐近展开中。维纳攻击的完整证明,本质上就是证明k/d满足这个精度条件。证明过程需要用到d < N^(1/4)/3这个关键条件来控制误差,细节可以在密码学教材的附录里找到,这里不展开。
我一直用这样的方式来理解连分数:它就是一台“能给出所有最优有理逼近”的机器。你不需要预先知道k/d在哪一层,把机器完整跑一遍,把输出的每个渐近分数都当候选去验证即可。这也是为什么实战中即使d略超理论边界,攻击依然可能成功——因为机器本身给出了大量候选,命中一个就够了。
2.3 从候选分数到分解n的验证设计
假设我们枚举到了一个候选渐近分数,分子记作k',分母记作d',怎么确定它真的就是我们要找的k和d?
第一关,整除性检查。真正的φ(N)是整数,所以(ed - 1)必须能被k整除。对候选值算一下(e·d' - 1) % k',如果除不尽,直接丢掉。
第二关,构造二次方程。通过候选值算出φ'(N) = (e·d' - 1) / k',再利用p+q = n - φ(N) + 1,得到p和q的“和”,而p·q = n是已知的。所以p、q应该是这个方程的两个根:
x² - (n - φ'(N) + 1)x + n = 0
如果候选正确,这个方程的判别式Δ一定是完全平方数,开方后就能直接得到p、q两个整数。
第三关,终极验证。即使判别式是平方数,也要检查p·q是否真的等于n。三道关卡层层过滤,基本不会误报。这也是维纳攻击实现起来特别干净的原因:没有随机扰动,没有概率迭代,每一步都是确定性的数学验证。
3. 三十行Python代码实现维纳攻击
3.1 第一步:连分数展开
先从辗转相除法写连分数展开函数:
def cf_expand(num, den): cf = [] while den: a = num // den cf.append(a) num, den = den, num - a * den return cf这个函数本质就是广义的欧几里得算法,每轮的商a就是连分数的一项。对37213607 / 47564513运行,返回的列表开头是[0, 1, 3, 1, 1, 2, ...],后面会用到这个结果。
3.2 第二步:生成渐近分数序列
有了连分数项,就可以递推生成渐近分数:
def convergents(cf): convs = [] p_minus2, p_minus1 = 0, 1 q_minus2, q_minus1 = 1, 0 for a in cf: p = a * p_minus1 + p_minus2 q = a * q_minus1 + q_minus2 convs.append((p, q)) p_minus2, p_minus1 = p_minus1, p q_minus2, q_minus1 = q_minus1, q return convs这里的初始边界(p_minus2, q_minus2) = (0, 1)和(p_minus1, q_minus1) = (1, 0)是数学递推的两块基石,保证第一项也能套用后面的递推公式。这份代码看起来简单,但边界写错会直接导致渐近分数序列错位,攻击自然失败。我见过好几版网上流传的实现,边界处理是错的,需要特别留意。
3.3 第三步:候选验证与主攻击函数
把前面的函数组合起来,加入三重验证:
from math import isqrt def wiener_attack(e, n): cf = cf_expand(e, n) for idx, (k, d) in enumerate(convergents(cf)): if k == 0: continue if (e * d - 1) % k != 0: continue phi_guess = (e * d - 1) // k b = n - phi_guess + 1 delta = b * b - 4 * n if delta < 0: continue s = isqrt(delta) if s * s != delta: continue if (b - s) % 2 != 0: continue p = (b - s) // 2 q = (b + s) // 2 if p * q == n: return {"d": d, "k": k, "p": p, "q": q, "convergent_index": idx} return None3.4 使用方式和性能分析
调用方式非常直接:
result = wiener_attack(e, n) if result: print(result) else: print("未命中,d可能足够大")性能方面,连分数展开本身是辗转相除级别的复杂度,而每个渐近分数只做几次乘法和开方。实测中,2048位的n,脚本运行时间在毫秒量级;就算不满足攻击条件,把全部渐近分数过一遍也很轻松。所以这个脚本非常适合放进CI或者密钥合规巡检任务里,只要输入公钥参数就能自动排查。
这里特别提醒一点:判别式是否是平方数的判断,一定要用integer版本的isqrt,不要用float的sqrt。大整数用float开方会因为浮点精度丢失,误判平方数,导致攻击脚本漏报或误报。
4. 实战复现:用一组可控弱密钥走完全程
4.1 构造一个满足条件的RSA实例
为了让大家能亲手验证,我特意构造了一组位数不大、但严格满足维纳条件的RSA参数:
- p = 6833
- q = 6961
- n = p·q = 47564513
- φ(N) = (p-1)(q-1) = 47550720
- 选择d = 23,计算得e = d⁻¹ mod φ(N) = 37213607
验证一下攻击条件:N^(1/4)约等于83.2,除以3约等于27.7,d = 23完全落在范围内。
注意到没有?这里的e高达三千多万。这是小d RSA的典型外观特征:d越小,e往往越大。如果你在安全评估里看到一把公钥的e明显大得离谱,第一个念头就应该是跑维纳攻击。
4.2 手工连分数展开与命中
现在对e/N = 37213607 / 47564513做连分数展开,过程如下:
- a0 = 0,因为37213607 < 47564513
- 取倒数,整数部分a1 = 1
- 继续展开,得到a2 = 3,a3 = 1,a4 = 1,a5 = 2
连分数序列为[0; 1, 3, 1, 1, 2, ...]。对应的渐近分数逐步逼近原始值:
- 0/1
- 1/1
- 3/4
- 4/5
- 7/9
- 18/23
第6个渐近分数是18/23,到这里就命中了:k = 18,d = 23。
接下来做验证。先看整除性:
(e·d - 1) / k = (37213607 × 23 - 1) / 18 = 855912960 / 18 = 47550720
结果恰好等于φ(N)。下一步,p + q = n - φ(N) + 1 = 47564513 - 47550720 + 1 = 13794。解方程:
x² - 13794x + 47564513 = 0
判别式Δ = 13794² - 4×47564513 = 190274436 - 190258052 = 16384,开根号等于128。于是:
p = (13794 - 128) / 2 = 6833 q = (13794 + 128) / 2 = 6961
分解成功。
4.3 用脚本验证并扩展到真实尺寸
把参数丢进前面的wiener_attack函数:
e, n = 37213607, 47564513 print(wiener_attack(e, n)) # 输出: {'d': 23, 'k': 18, 'p': 6833, 'q': 6961, 'convergent_index': 5}和手工推算完全一致。拿到p和q之后,整个RSA体系就彻底失守了。你可以重新计算私钥、解密任意密文、伪造任意签名,一切都不再受控制。整个过程连一次模幂都没做,只是对e/N做了一次辗转相除。
如果你想测试更接近真实环境的参数,比如512位或1024位的RSA,只需要用标准库生成一个d很小的密钥对,再把这个脚本喂进去,代码一行都不用改。Python的大整数运算天然支持任意长度,实际审计中直接对接证书解析出来的(e, n)即可。
5. 边界之外与工程防护的完整认知
5.1 别以为过了维纳边界就安全
维纳攻击的理论边界是d < N^0.25/3,但密码学家的攻击没有止步于此。Boneh和Durfee在1999年把边界扩展到了d < N^0.292,用的工具是格子上的Coppersmith方法。这意味着即使d比维纳边界大不少,只要仍然明显小于N的0.292次方,攻击者依然可能通过格基约减恢复私钥指数。
这类格攻击的实现依赖sage数学环境中的LLL算法,代码量比维纳攻击大得多,但它告诉我们一个更重要的原则:私钥指数d的安全边界,不是“大于N的四分之一次方”,而是“和N基本同量级”。任何人为把d做小的尝试,都是在玩火。
5.2 一套可以落地的密钥自检与防护清单
把防护经验整理成一张表,方便直接对照执行:
| 防护措施 | 具体做法 | 说明 |
|---|---|---|
| 标准密钥生成 | 使用系统库或OpenSSL,固定e=65537 | d由模逆自然得到,天然和N同量级 |
| 审计e的形态 | 发现e异常巨大时,立即跑维纳攻击脚本 | e过大往往是d过小的信号 |
| 巡检进CI | 把wiener_attack封装成定时任务,扫描线上公钥 | 速度快,毫秒级完成,适合全量检测 |
| 处理历史密钥 | 对扫描命中的密钥一律下线重签 | 不要尝试修补,直接替换最稳妥 |
| 控制密钥长度 | 当前生产环境至少2048位,推荐4096位 | 长度增加后,攻击条件指数级变宽 |
| 禁用自研RSA | 不在业务代码里手写RSA生成逻辑 | 专业库已经规避了已知攻击面 |
这些不是纸上谈兵。我在做合规审计时,把维纳攻击脚本固化成了巡检工具,遇到过好几次线上密钥因为历史原因采用了短d,全部在1秒内识别出来并推动下线。其中最夸张的一个案例,某系统为了“性能优化”把d压缩到了128位,证书仍然通过正常签发流程发放,要不是这次巡检,这把备钥可能到现在还在生产环境里裸奔。
最后分享一个小小的实操体会:无论你多信任自己的密钥生成流程,都建议把维纳攻击脚本保留在工具箱里。它占不了多少代码量,却能在关键时候帮你发现最容易被忽略的“低级失误”。安全这件事,往往不是被多高深的技术攻破的,而是被最基础的设计死角击穿的。