RSA已知高比特位p的分解攻击:Coppersmith与LLL实战
发布时间:2026/10/15 1:24:52 锦皓数字建站

1. 从“已知高比特位p”说起这题到底在考什么如果你碰到一个RSA公钥 n又知道它的一个大素数因子 p 的高比特位目标是顺藤摸瓜把 n 完整分解出来这就是典型的“已知高比特位p分解n”问题。我第一次看到这个题的时候第一反应是“这也能分解n 不就是 p 乘 q 吗我只知道 p 的前半截后半截还是黑的怎么算”后来真正去跑了一遍才发现这套东西不仅可行而且背后的数学工具相当优雅属于典型的“部分私钥泄露”攻击场景。这里说的“高比特位”通常指的是 p 的二进制表示里靠前的一段连续比特。比如 p 一共 512 bit假设你已经拿到了它的高 256 bit低 256 bit 完全未知。题目可能会直接给你一个 p_high也可能给你一个被抹掉低位的十六进制串又或者是在某个日志里打印出来的不完整 p。不管形式怎么变本质都一样n 已知p 的高位已知低位未知目标是把 n 拆成 p 和 q。这题适合谁看做 CTF Crypto 的应该很熟RSA 系列里基本是必刷题型做密码学实现和硬件安全的同学也值得了解因为侧信道攻击、随机数预测、内存泄露都可能产生“部分因子泄露”的场景。就算你只是好奇数学怎么在这种看似无解的条件下破开 RSA也建议往下看因为核心思路并不复杂难的是理解它背后的“为什么”。1.1 场景那些高比特位到底从哪里泄露出来的很多人觉得“p 只泄露一半怎么可能发生”但现实里还真可能发生。RSA 私钥在生成时如果底层随机数生成器不是真随机某些高位可能相对固定调试日志里如果把 p 的部分值打印出来又没做截断或者某次侧信道攻击能稳定读出寄存器里 p 的高位数据。这些都不算天方夜谭。更常见的是 CTF 题目直接设计成这种条件出题人给你 n再给你一个 p_highp_high 通常是p kbits kbits也就是说把 p 的低 kbits 位全部清零后的值。你要找回的就是被清掉的那个 x满足p p_high x这里 x 的绝对值不超过 2^kbits。于是问题从“分解一个 1024 bit 的数”变成了“求一个小整数 x”。1.2 为什么不能直接枚举剩下的低位先算一笔账。假设 n 是 1024 bitp 和 q 各 512 bit题目只给了 p 的高 256 bit也就是未知低 256 bit。那未知空间就是 2^256。这个数字有多大大约 10^77把所有已知的恒星全部变成计算机也算不完。所以这条路一开始就堵死了必须找数学捷径。Coppersmith 方法就是这条捷径。它专门处理一类问题知道一个多项式在“某个未知模数”下有一个小根能不能在多项式时间内把这个小根找出来。放在这里就是这个意思我们构造了 f(x) p_high x它有一个根 x0使得 f(x0) p而 p 整除 n。虽然我们不知道 p 本身但知道 n并且知道根 x0 非常小Coppersmith 方法就可以把这个根从格子里捞出来。2. 核心数学小根、格基约化和那条关键边界2.1 把问题写成“同余小根”先看整体逻辑。设真实因子 p 写成p p_high x0其中 p_high 是已知的高位部分x0 是未知的低位部分。因为 p 是 n 的因子所以一定存在整数 q 使得 n p * q。那么对于多项式f(x) p_high x显然有f(x0) p ≡ 0 (mod p)也就是说x0 是 f(x) 在模 p 下的一个根。麻烦在于模数 p 是未知的不能用常规的模 p 求根方法。但是没关系Coppersmith 的方法允许模数未知只需要知道这个未知模数整除一个已知数 n 即可。这里的核心工具是格基约化。怎么把“模未知数求根”转化成“普通整数求根”靠的是一个小引理通常称为 H-G 引理。大意是这样的如果一个多项式 h(x) 同时满足两个条件第一h(x0) 在模某个 M 下等于 0第二h(x) 的系数足够小那么 h(x0) 实际上在整数范围内就等于 0不再有余数干扰。用公式写就是h(x0) ≡ 0 (mod M)且 |h(x0)| M那 M 的倍数就只能是 0于是 h(x0) 0。这样就把一个同余问题变成了精确的方程求根。这个引理是整个攻击的开关只要能把多项式系数压得足够小就可以直接解根。2.2 从“小系数多项式”到“格”现在的问题是怎么找到一个系数又小、又在模 M 下以 x0 为根的多项式 h(x)。我们手上只有一个在模 p 下以 x0 为根的多项式 f(x)其他什么都不知道。Coppersmith 的经典做法是构造一堆“移位多项式”。既然 f(x0) 是 p 的倍数而 n 也是 p 的倍数那么把 f(x) 的幂和 n 的幂乘起来再乘上 x 的若干次方得到的所有多项式在 x0 点都一定被 p 的某个高次幂整除。这些多项式的系数向量放在一起就张成了一个格。接下来交给 LLL 格基约化算法。格的每一行都对应一个多项式LLL 会在格子里找一组“短向量”也就是系数尽量小的多项式组合。当某个组合多项式满足 H-G 引理的系数上界时它就给出一个普通整数方程根 x0 就能直接解出来。这里很多人第一次接触会觉得很绕为什么格里的短向量就对应“系数小”的多项式因为格基约化就是在高维空间里找短向量而每个坐标就是多项式的系数坐标向量越短系数绝对值越小越容易满足 H-G 引理。LLL 是多项式时间算法所以这个过程不是暴力枚举而是一个优雅的数学搜索。2.3 可解边界beta 和 epsilon 到底卡在哪理论结果给了一个非常明确的边界。设 p ≥ n^β也就是说 p 大约占 n 的 β 次方大小。对于标准 RSAp 和 q 差不多大β 约等于 0.5。Coppersmith 定理说如果未知根 x0 满足|x0| n^(β² - ε)就能在多项式时间内找到它。注意这个 ε 是允许损失的一点点指数取得越小理论边界越接近 β²但构造的格会更大跑得也更慢。举个例子。n 是 1024 bitp 大概 512 bitβ 0.5β² 0.25。那 x0 的比特数上限是 1024 * 0.25 256 bit。也就是说p 的未知低位最多只能有 256 bit超过这个数理论上就救不回来了。反过来想p 本身 512 bit未知 256 bit那已知的高位就是 256 bit正好是 p 的一半。所以网上很多题目都强调“p 的高位泄露一半”原因就在这。实际使用 small_roots 时因为格的实际构造存在参数损耗很少能踩满理论边界。通常把 ε 设为 0.01 到 0.05边界会稍微缩小一点所以稳妥的做法是让未知比特数比理论最大值小几十 bit。这也是为什么实战脚本里经常能看到“kbits 240”而不是“kbits 256”的原因不是保守是真的跑不动。3. SageMath 实操三行核心代码分解 n3.1 为什么直接选 SageMath这种攻击要用到格基约化选择 SageMath 几乎是行业默认。它内置了small_roots底层把 Coppersmith 的格构造、LLL 约化、根提取全封装好了。你不需要自己手写 LLL也不需要自己处理那些移位多项式的组合关系。如果用纯 Python手写格基约化不是不行但很容易在细节上出错尤其是格维数高的时候数值稳定性、精度、多项式取值都要自己兜底。SageMath 里一个small_roots就解决了省下的时间去理解参数比什么都值。3.2 完整脚本与每一步的解释下面是一个可以直接抄的脚本。假设你已经有了 n 和 p_high以及一个 kbits 表示未知低位的比特数。from sage.all import * def factor_with_high_p(n, p_high, kbits, beta0.5, epsilon0.02): X 2 ** kbits R.x PolynomialRing(Zmod(n)) # 先尝试构造 p p_high x f p_high x for x0 in f.small_roots(XX, betabeta, epsilonepsilon): pp gcd(int(p_high x0), n) if 1 pp n: return int(pp), int(n // pp) # 如果 p_high 是近似值且比真实 p 大可以尝试 p p_high - x g p_high - x for x0 in g.small_roots(XX, betabeta, epsilonepsilon): pp gcd(int(p_high - x0), n) if 1 pp n: return int(pp), int(n // pp) return None参数说明X 2 ** kbits告诉算法“我允许的根的上界是 2^kbits”。如果真实 x0 正好接近这个上界会很难求实际使用可以稍微留一点余量。beta 0.5表示 p 大约是 n 的 0.5 次方。标准 RSA 的 p、q 等大所以默认 0.5如果是不平衡 RSA这里要改成 pbits / nbits。epsilon 0.02每取一个非零的 ε 都会缩小一点可解范围但能显著降低格维数。对于比较轻松的题ε 可以取 0.05接近理论边界时取 0.01 甚至更小。注意small_roots 返回的根 x0 不一定是唯一解所以拿到根后一定要用 gcd 验证。有些初学者直接p p_high roots[0]结果 p 不是素数也不整除 n最后卡很久。gcd 验证这一步不能省。3.3 造一组测试数据验证脚本为了确认脚本没问题最好自己先生成一组随机数据。下面的代码会生成一个 512 bit 的 np、q 各 256 bit然后故意抹掉 p 的低 100 bit 作为 p_high。from sage.all import * nbits 512 kbits 100 while True: p randint(2**255, 2**256 - 1) if is_prime(p): break while True: q randint(2**255, 2**256 - 1) if q ! p and is_prime(q): break n p * q p_high (p kbits) kbits print(n , n) print(p_high , p_high) print(kbits , kbits) result factor_with_high_p(n, p_high, kbits, beta0.5, epsilon0.02) print(result , result) print(check , result is not None and result[0] * result[1] n)在我实际跑过的环境里这种参数基本秒出。kbits 越小越轻松kbits 越大越接近边界耗时就会从秒级变成分钟级。真实题目如果给的是 1024 bit 的 n未知低位 220 到 240 bit也通常能在可接受的时间内跑完。3.4 small_roots 源码之外的建议不要一开始就追求手写 Coppersmith。先把 small_roots 用熟理解X、beta、epsilon三个参数对结果的影响就已经能解决 90% 的实际问题。等你真的遇到 small_roots 解不动的特殊情况再去啃源码、看格的构造细节会轻松很多。不然一上来就自己搭格很容易被矩阵维度、多项式移位这些细节劝退。4. 实操时最常踩的四个坑4.1 第一大坑忘记把 beta 改成 0.5small_roots的默认参数是beta 1.0表示它默认模数是 n 本身。但这里我们需要的是 p一个约等于 n^0.5 的未知因子。如果不传 beta算法会用 n 当作模数来约束多项式那 f(x) p_high x 在模 n 下根本不会以 x0 为根结果自然是空列表。解决方案很简单要么显式传beta0.5要么根据 pbits/nbits 算一个更精确的值。很多时候你以为“题目跑不出来”其实只是这个参数没写。4.2 第二大坑p_high 的形式没搞清楚题目给 p_high 的方式五花八门。最标准的做法是p_high (p kbits) kbits即低位全部补 0真实 p 就是 p_high 加上一个 0 到 2^kbits 之间的数。这种时候用f(x) p_high x没问题。但有时候题目给的是“p 的近似值”比如某个错误随机数 p_guess可能比真实 p 大一点也可能小一点。如果 p_guess 比真实 p 大真实 p p_guess - delta那f(x) p_guess x找不到正的小根。这时就要换成g(x) p_guess - x这也是我在上面脚本里同时跑两个方向的原因。判断方法很简单跑完看 gcd。如果结果不是 p/q换个方向再跑。不要嫌麻烦这一步顺手就做了。4.3 第三大坑未知 bit 数超过了可解边界有些新手拿到题目发现 p 的高位只泄露了 128 bit而 p 本身 512 bit于是想用同样的方法结果发现怎么调 epsilon 都是空。这不是 bug是数学上无解。回到边界公式未知比特数必须小于 nbits * β²。对于 n 为 1024 bit 的 RSAp、q 等大的情况下这个值是 256 bit。如果你只知道 p 的高 128 bit未知 384 bit已经远超 256 bitCoppersmith 的一元版本救不回来。这时候要么去找更多的泄露比特要么结合 p、q 一起泄露要么换其他攻击思路。如果你只是略微超出边界比如未知 260 bit理论值 256 bit可以把 epsilon 调到 0.001再加大格的维度有时候能硬挤出来但耗时和内存会暴涨。超出太多就别浪费时间了。4.4 快速排查表现象大概率原因处理方法small_roots 返回空列表没设 beta 或设得不对检查 beta标准 RSA 用 0.5返回了根但 gcd 结果不是因子p_high 方向反了用 p_high - x 再跑一次能跑但特别慢epsilon 太小或格维数太大适当加大 epsilon或减少 kbits报错说 polynomial ring 不匹配SageMath 版本太老换新版本 SageMathroot 的值接近 X 上界边界比较极限调小 X 或增加已知比特位5. 不止是高比特位这一招还能怎么变形5.1 已知 p 的低位比特时怎么改高比特位能解低位比特当然也能解只是多项式构造要换一下。假设已知 p 的低 t 位记它为 p_low那么真实 p 可以写成p p_low 2^t * x这里 x 是未知的高位部分。和前面一样构造多项式f(x) p_low 2^t * x这个多项式在 x0 处等于 p所以同样满足“模 p 下为根”的条件。剩下的流程完全一样调用 small_roots 时 X 取 2^(pbits - t) 即可。注意这里 2^t 乘在 x 前面系数可能会很大但 small_roots 能处理。低位泄露在实际攻击中也很常见。如果随机数生成器的低比特被预测或者某些算法对低位比特存在逃逸就可以用这个变体。5.2 中间位泄露问题变成了多变量如果泄露的不是连续高位或低位而是中间一段连续比特那就比单一变量麻烦一点。假设 p low mid * 2^a high * 2^b其中 low 和 high 都未知。未知变量有两个上面的一元 Coppersmith 就不够用了。处理方法有两种思路。第一种是如果低位部分 a 很小比如只有 20 到 30 bit可以直接枚举 low每枚举一个 low就把它转化成“已知低位比特 p_low”的问题再用上一节的构造去解 high。代价是枚举 2^a 次a 超过 40 就不太现实了。第二种是使用二元 Coppersmith。SageMath 里没有封装好的多变量 small_roots需要自己构造二元多项式对应的格。实现起来比一元复杂很多但原理是一样的只是格维数和矩阵会成倍增加。实战里如果没有必要大部分人还是优先把问题化成一元。5.3 双边泄露和不平衡因子有些场景下p 和 q 各泄露一部分比如 p 的高位已知 q 的低位已知。这时候可以利用 n p * q 这个乘法关系把泄露的部分代入得到一个关于 p、q 未知部分的多元方程再尝试用格解。还有一种情况是 p 和 q 长度差很远比如 q 明显大于 p。这个时候 β 不能照搬 0.5而应该设成pbits / nbits。举个例子n 是 2048 bitp 只有 896 bit那 β ≈ 896 / 2048 ≈ 0.4375β² ≈ 0.191理论最大未知比特只有 2048 * 0.191 ≈ 391 bit。p 总共 896 bit意味着至少要知道 p 的高 505 bit也就是约 56% 的 p。所以非平衡 RSA 在部分泄露面前未必更安全反而可能更脆弱。最后再分享一点实操体会做这类题做多了我现在的习惯是拿到题目先不算 2^kbits而是先算未知比特数和 nbits 的比例。如果未知比特数大于 nbits 的四分之一先别急着跑脚本回头找题目的其他条件。很多人卡半天不是脚本问题是数学边界问题。另一个印象很深的教训是small_roots 返回的根未必是唯一解也未必是第一个。所以我在脚本里永远保留 gcd 验证这一步宁可多算两次也不去赌它一定对。这个习惯帮我省了不少时间。最后想说的是Coppersmith 方法不是魔法它的本质是用格基约化把“同余小根”变成“普通小根”。理解了这条主线后面不管遇到高比特位、低比特位还是多变量泄露你都知道往哪个方向构造多项式。这套思路值得花一个下午好好吃透。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。