格与小根
题目:Bivariate copper(模逆高位泄漏与 4×4 格攻击)(来自HKCERT CTF2025)
这题把小因子分解、模逆的高位泄漏和格攻击串在了一起。前面的 RSA 只是拿到模数和提示,真正要恢复的是后面两条模逆关系里的 flag。格这部分有点绕,把从题面到构造矩阵的过程拆开记一下,方便之后复习。
题面
先看题目,核心代码如下。公开的大整数放在后面的完整 EXP 里,方便直接复制运行。
from Crypto.Util.number import *from secret import *
with open("message", "r") as f: message = f.read().strip().encode()
m = bytes_to_long(flag)message = bytes_to_long(message)
p = getPrime(1024)q = getPrime(25)N = p * qe = 65537
c = pow(message, e, N)
r1, r2 = getPrime(512), getPrime(512)k = getPrime(64)
t1 = k * inverse(m + r1, p) % pt2 = k * inverse(m + r2, p) % p
leak1 = t1 >> 244leak2 = t2 >> 244公开数据包括:
e, N, c, k, r1, r2, leak1, leak2这里有一个很容易看错的地方:
c = pow(message, e, N)加密的是文件 message 的内容,不是 flag。
flag 对应的整数 m 进入的是后面两条模逆关系。因此,攻击分成两部分:
- 通过不平衡的 RSA 模数恢复素数 ,顺便解出提示语。
- 利用两次模逆的高位泄漏,恢复真正的 flag。
解题思路
观察题目,可以分成两部分。第一部分的 RSA 看起来模数很大,但 q 只有 25 位,先用 Pollard rho 分解 N,得到后面需要的 p。第二部分才是重点:t1、t2 只给了高位,要把未知低位写成两个小变量,再通过消元构造双变量小根方程。
接下来利用格和 LLL,把模方程转成可以直接求解的整数方程,补齐 t1、t2 后再恢复 flag。这里最绕的地方就是“为什么短向量能变成整数等式”,所以把用到的基础和推导一起记一下,免得下次又只剩一份看不懂的模板(恼)。
必要基础:字节、取模、最大公约数和模逆
字符串为什么能变成一个整数
字符串编码后是一串字节。每个字节是 到 的整数。
例如:
"A" → 65"B" → 66把字节串 b"AB" 按大端顺序当成一个整数:
题目中的:
m = bytes_to_long(flag)就是做这样的转换,只是 flag 更长,得到的整数更大。
恢复整数后,可以反向转换:
m.to_bytes((m.bit_length() + 7) // 8, "big")其中,一个字节占 8 位,因此 (m.bit_length() + 7) // 8 是容纳这个整数所需的字节数。这里 flag 没有需要额外保留的前导零字节。
取模与同余
计算:
23 % 7结果为 2,因为:
写成:
一般地:
表示 除以 的余数相同,等价于:
符号 的意思是“ 整除 ”。
必须区分:
- :两个整数完全相等。
- :两者可以不同,只要差是 的整数倍。
例如:
模 7 都相同。
最大公约数 gcd
是同时整除 的最大正整数。
例如:
Python 中:
from math import gcdassert gcd(21, 91) == 7gcd 可以通过欧几里得算法高效计算,不需要先把两个数完全分解。
模逆:模运算中的“除法”
普通运算中,除以 5 等价于乘以 。模运算里,我们寻找一个整数 ,使:
因为:
所以:
Python 中:
assert pow(5, -1, 17) == 7模逆存在的条件是:
当模数 是素数时,所有模 非零的数都有逆元。
注意:模逆不是浮点数倒数,也不是写成 Python 的 1 / a。它是满足乘积模 为 1 的整数。
如果完整的 t1 已知,flag 很容易恢复
原题关系为:
两边乘以 :
再乘以 :
因此:
对应代码:
m = (k * pow(t1, -1, p) - r1) % p所以最后恢复 flag 的式子其实已经有了,问题就卡在这里:
题目只给了 的高位,怎样补齐它们未知的低位?
不过先别急着补低位,模数 还没拿到,先处理前面的 RSA。
先分解不平衡的 RSA 模数
题目使用:
p = getPrime(1024)q = getPrime(25)N = p * q这里的“位”是二进制位数:
非常大,但 只有几千万。整数分解不能只看 的总位数:一个很小的素因子就可能成为突破口。
本题使用 Pollard rho 找到:
q = 23520857于是:
p = N // q恢复出的 为:
141728564220215689105745310666761712438508810180329898864435481120368603802944433535439870033233448775648620641750653586896162186017744701345428383181658483867762748650405587002562099473480910200825070136353056453764908432415631471548522147066421239360596574292417840898610873956701534267841521331362184363411知道 后,正常计算 RSA 的欧拉函数和私钥:
再解密:
代码是:
d = pow(e, -1, (p - 1) * (q - 1))message = pow(c, d, N)结果转回字节得到:
Hurry up and go smelt copper!这句话在提示 Coppersmith 相关方法。真正对后续攻击必需的是恢复出的 ;提示语本身不需要代入后面的方程。
Pollard rho:为什么不用知道因子也能制造碰撞
Pollard rho 的原始思想来自 J. M. Pollard 的论文 A Monte Carlo Method for Factorization。这里用一组小数字把过程走一遍。
一旦两个数模某个因子相同,就可能提取因子
假设:
若找到两个数 ,满足:
则:
而 也整除 ,因此:
就可能等于 。
如果差值同时包含 ,gcd 会变成 ;如果两个因子都没碰上,gcd 就是 1。
只用 N 生成数列
选择:
从 开始,反复计算:
以 为例:
2 → 5 → 26 → 677 → 7474 → 2839 → 871 → …实际分解是:
算法不知道这个分解。为了理解原理,我们暂时把数列对 97 取模:
| 下标 | 模 8051 计算得到的数 | 再模 97 |
|---|---|---|
| 0 | 2 | 2 |
| 1 | 5 | 5 |
| 2 | 26 | 26 |
| 3 | 677 | 95 |
| 4 | 7474 | 5 |
| 5 | 2839 | 26 |
| 6 | 871 | 95 |
右边出现循环:
2 → 5 → 26 → 95 ↑ ↓ └─────────┘“尾巴加环”的形状类似希腊字母 ,这就是 rho 名字的来源。
为什么模 计算的数列,还能在模 97 下保持递推规律?因为 97 整除 。计算中减去的任何 的倍数,模 97 都是零。
一般来说,对任意因子 :
因此,我们只在模 下计算,却同时在“暗中”生成模各个因子的数列。
快慢指针与 gcd
不用保存整个数列,只保留两个变量:
- 慢指针每轮走一步。
- 快指针每轮走两步。
x = f(x)y = f(f(y))d = gcd(abs(x - y), N)对 ,从 开始:
| 轮次 | 慢指针 x | 快指针 y | 差值绝对值 | gcd |
|---|---|---|---|---|
| 1 | 5 | 26 | 21 | 1 |
| 2 | 26 | 7474 | 7448 | 1 |
| 3 | 677 | 871 | 194 | 97 |
第三轮:
两个整数并不相等,但它们模 97 相等,这已经足够暴露因子。
三种结果与重试
| gcd 结果 | 含义 | 处理 |
|---|---|---|
| 暂时没有发现公共因子 | 继续 | |
| 找到非平凡因子 | 成功 | |
| 差值被整个 N 整除 | 换起点和常数重试 |
实际通常使用:
改变起点和 ,避免不利的循环。
为什么 25 位小因子容易找
随机映射模型下,碰撞常出现在约 个状态的尺度。这与生日悖论类似:有 个样本时,可比较的配对约有 个。
本题:
因此通常只需要几千次量级的迭代,而不是枚举全部候选。
这是启发式的预期分析,不保证每一次运行都恰好按这个次数成功;真实耗时也取决于大整数模乘和 gcd 的成本。如果最小素因子有 1024 位, 约为 ,这个方法就不再实用了。
本题用的实现如下,输入应为已经确定的合数:
from math import gcdfrom random import randrange
def pollard_rho(n): if n % 2 == 0: return 2
while True: x = y = randrange(2, n) c = randrange(1, n) d = 1
while d == 1: x = (x * x + c) % n y = (y * y + c) % n y = (y * y + c) % n d = gcd(abs(x - y), n)
if 1 < d < n: return d # d == n:换一组起点和 c。返回的因子不保证为素数。通用整数分解程序需要素性检测和递归;本题已知 是两个素数的乘积,得到一个非平凡因子就足够。
把高位泄漏写成两个小未知量
先看一个小例子:
123 >> 4 # 7对非负整数,右移 4 位等于整除 :
如果只知道右移结果为 7,就可以写:
同样,由:
leak1 = t1 >> 244leak2 = t2 >> 244令:
得到:
其中:
就是被丢弃的低位。244 位仍然无法暴力枚举,“小”是相对 1024 位的 而言。
消掉 m:从三个未知量变成两个
由原始模逆关系:
第一式乘 :
第二式乘 :
第二式减第一式,含 的项抵消:
令:
则:
代入 :
这里 已经完全消失,只剩两个范围已知的小未知量。
归一化:得到双变量小根方程
本题 ,且两者都远小于 ,因此 ,存在模逆。
定义:
方程变为:
逐项展开:
合并同类项:
取各系数模 的整数代表元:
最终:
所谓 Bivariate 就是“双变量”:这里的两个变量是 。
注意,当我们把 选成普通整数后, 就是一个确定的整数多项式。它在真实根处的值可能是 的非零倍数,而不是整数零。这一点正是后续攻击要解决的问题。
小例子一:用小数字核对消元过程
暂时设:
于是:
模 101 下:
所以:
假设丢弃低 2 位:
因此:
继续计算:
得到:
对应多项式:
代入真实低位 :
确实模 101 为零。
这组小数字先用来核对消元和正负号。后面再换一组参数,把格攻击也走一遍。
接下来要把模方程变成整数等式
现在我们知道真实低位 满足:
但这只表示:
这里 是某个未知整数,表示它究竟是 的多少倍。
直接求解这个模方程并不容易。我们希望找到几个新多项式:
使它们在真实根处满足:
作为普通整数等式成立。
格和 LLL 的作用,就是帮助找到这样的多项式。
格和基到底是什么
向量:一排有顺序的数
暂时把向量理解成有顺序的一组数即可:
是二维向量,而:
是四维向量。不必先想象四维空间,可以理解成四个位置依次存放四个数。
向量可以相加:
也可以乘整数:
因此:
这种“乘整数再相加”的操作叫整数线性组合。
格:所有整数线性组合构成的集合
给定:
所有整数线性组合为:
这里 表示整数集合。
可得到:
这些离散点构成一个格。可以想象成横向间隔 2、纵向间隔 3 的网点:
(-2,6) (0,6) (2,6) (4,6) • • • •
(-2,3) (0,3) (2,3) (4,3) • • • •
(-2,0) (0,0) (2,0) (4,0) • • • •是这个格的一组基,也就是生成格点的基本向量。
并非所有整数坐标都在这个格里。例如 不属于它,因为不能用整数 满足 。
关键限制是:组合系数必须是整数,而不是任意实数。
同一个格可以有不同的基
考虑:
两者都很长,但相减:
再计算:
我们找到了更短的一组基:
为什么没有改变格?
- 新向量是旧向量的整数线性组合,所以不会引入原格之外的点。
- 旧向量也能由新向量用整数系数表示,所以没有丢掉原格的点。
这种“换基但保持格不变”的操作,是格基约化的基础。
LLL 在这道题里做了什么
LLL 的名字来自 Lenstra、Lenstra、Lovász。其原始论文是 Factoring Polynomials with Rational Coefficients。
这里先把 LLL 的作用记成:
输入:一组整数基向量输出:生成同一个格的一组较短的基向量它主要通过减去其他基向量的整数倍、交换向量等操作,改善基的长度和方向关系。内部使用 Gram–Schmidt 正交化来衡量这些关系。
暂时先不展开完整的 LLL 算法证明,解这道题需要抓住下面几点:
- 输出基生成的格与输入相同。
- 输出向量仍是输入基的整数线性组合。
- 寻找的是较短的非零向量,不保证每次找到真正的最短向量。
- LLL 不认识 flag、RSA 或模逆;我们必须先设计好“短向量为什么有用”。
LLL 通常以欧氏长度衡量向量。例如:
后文还会用另一个容易计算的量——L1 范数:
两种范数不要混淆。我们使用 LLL 找较短向量,再对输出显式检查 L1 范数条件。
多项式为什么可以变成格向量
本题只需要四种单项式:
固定这个顺序,多项式:
对应系数向量:
反过来:
对应:
因为顺序固定,所以可以准确来回转换。
更重要的是,多项式的加法和整数倍运算,与系数向量完全对应。例如:
则:
对应:
因此:
对向量做整数线性组合,等价于对对应的多项式做同样的整数线性组合。
这就是把代数方程放进格里的入口。
为什么选择 p、px、py、f 作为构造材料
考虑四个整数多项式:
在真实根 处,它们的值都能被 整除:
- 本身当然可以。
- 、 显然可以。
- 可以,因为我们已经建立了模方程。
因此,任意整数线性组合:
都满足:
这说明我们拥有一个很大的“候选多项式集合”,而且不管怎么做整数线性组合,整除性质都不会丢失。
四个多项式还恰好只涉及 四种单项式,使得后面能够用四维格表示,并用三条独立方程做线性化求解。
但仅有整除性质还不够。接下来要让某些组合在真实根处的值足够小。
为什么必须缩放:把未知量范围变成权重
设:
系数小,不代表代入后的值小。即使 的系数只有 2,当 时,结果仍很大。
已知:
因此:
所以应该关注的不是未缩放向量:
而是:
| 单项式 | 在根范围内的大小上界 | 对应列权重 |
|---|---|---|
这等价于记录 关于辅助变量 的系数。真实根对应的 都位于 ,所以各项的大小容易控制。
辅助变量不需要实际求出来。这个说法只是解释缩放为何合理。
缩放把“根的范围”转化成“向量的权重”,让短向量真正对应代入值较小的多项式。
一行一行构造 4×4 格基
按照单项式顺序 :
| 多项式 | 未缩放系数向量 |
|---|---|
给四列分别乘:
得到格基矩阵:
这里每一行是一条基向量。这里把基向量按行放进矩阵。
这个格中的每个向量,都是四行的整数线性组合,因此对应某个:
所以它在真实根处仍然满足:
调用 LLL,就是希望找到加权系数较小的这类 。
一个容易混淆的细节:矩阵里的 是多项式 的 系数 再乘权重 ,并不是把某个未知的 代入了矩阵。构造矩阵时不需要知道真实根。
最关键的证明:短向量如何产生整数等式
设 LLL 输出一行:
除去列权重,对应整数多项式:
这些除法都是整除。因为输入格的第二列全部能被 整除,第三列全部能被 整除,第四列全部能被 整除;整数行组合保留这些性质。
由根的范围:
得到:
如果显式检查发现:
那么同时有:
区间 内唯一的 的整数倍是 0,因此:
例如,一个整数能被 101 整除,同时绝对值小于 101,那么它不可能是 ,只能是 0。
这里就能看出,短向量为什么有用:它给出了一个足够小、同时又能被 整除的整数,这个数只能是 0。把这几步连起来就是:
构造格:保留“在真实根处能被 p 整除”的性质 ↓LLL:寻找较短的加权系数向量 ↓检查 L1 范数 < p ↓整除性质 + 大小限制 ↓得到精确的整数等式LLL 不保证每一行都满足范数条件。代码必须检查,不符合的行不能直接当成整数零方程使用。
小例子二:完整跑通 4×4 格攻击
第一组小例子用来检查模逆消元;现在换一组参数,完整演示格攻击。
已知:
以及:
这些范围当然可以直接枚举。这里刻意使用格,是为了把真实题目的每一步缩小到能看清的数字。
构造矩阵
,所以:
最后一行来自加权系数:
LLL 找到第一条短向量
实际约化结果的一行是:
除去权重 ,得到:
这个多项式确实是:
逐项检查:
而 的系数为 。
因此, 在真实根处能被 整除。
再检查向量 L1 范数:
所以:
注意:检查的是加权向量的 ,不是把权重除掉后再计算系数和。否则无法控制未知根代入后的值。
另外两条有效方程
另外两行是:
除去权重后:
对应 L1 范数:
因此得到三条整数方程:
暂时把 xy 当成第三个未知量
令:
方程变成:
这是三个未知量的三条线性方程,可以用普通消元法求解。
下面给出具体消元过程。第一式加上第三式的 10 倍:
和 同时抵消,得到:
因此:
将 代入第一式并除以 10:
代入第二式:
前一式乘 97,后一式乘 10,再相加消去 :
于是:
代回 :
得到:
所以线性系统的解是:
线性化以后必须回查 z=xy
临时把 视为独立变量,会扩大候选解集合。因此,不能只看到线性系统有解就停止。
需要检查:
这里:
成立,而且:
回代最初的模方程:
确实有:
这个例子中,原始 在真实根处等于 10007,而三个新多项式在真实根处严格等于 0。这正是格攻击完成的转换。
可以直接运行的小例子代码
from fpylll import IntegerMatrix, LLL
p = 10007X = Y = 4alpha, beta, gamma = 100, 3000, 801
B = IntegerMatrix.from_matrix([ [p, 0, 0, 0], [0, p * X, 0, 0], [0, 0, p * Y, 0], [gamma, alpha * X, beta * Y, X * Y],])
LLL.reduction(B, delta=0.99)weights = [1, X, Y, X * Y]
for i in range(B.nrows): vector = [int(B[i, j]) for j in range(B.ncols)] l1 = sum(abs(value) for value in vector)
if l1 >= p: continue
assert all(value % weight == 0 for value, weight in zip(vector, weights)) coefficients = [value // weight for value, weight in zip(vector, weights)]
print("加权向量:", vector) print("L1 范数:", l1) print("[常数项, x 系数, y 系数, xy 系数]:", coefficients)不同版本或参数可能使约化结果的符号、顺序或具体基不同。应检查范数和方程,而不是要求输出逐字一致。
为什么低 244 位算“小”:理解参数余量
这一节是参数直觉,不是前面严格范数证明的替代品。
如果未知位数更多, 更大,矩阵里的 都会变大,就更难找到满足 L1 范数小于 的行。
对于三角矩阵,本题格基的行列式就是对角线乘积:
行列式的绝对值也可理解为一组基张成的基本区域的体积。二维时对应面积,高维是类似概念。它不直接等于最短向量长度,但能提供一个长度尺度。
对四维格,取四次方根:
希望这个体积尺度低于 ,启发式上得到:
即:
若 ,则约为:
本题:
而:
每个变量范围比这个尺度小约 12 位,因此小规模格就有成功空间。
但必须区分两种结论:
- 严格成立的部分:若某行加权 L1 范数小于 ,对应多项式在真实根处严格等于零。
- 需要实际检查的部分:LLL 是否找到足够多这样的行,以及它们是否给出足够独立的方程。
不能仅根据行列式估计,就宣布任意同类双变量模方程都必然可解。单变量模小根、双变量整数小根和多变量模小根不是可以随意互换的定理场景。相关区别可参阅 Coron 的原始论文 Finding Small Roots of Bivariate Integer Polynomial Equations。
回到真实题目:得到三条独立整数方程
真实参数虽然大,格的维度仍然只有 4。
按同样流程构造并约化后,得到三行满足严格条件的向量。加权 L1 范数的二进制位数为:
1008, 1009, 1011为 1024 位。这里不仅比较位数,代码还直接计算并检查了:
sum(abs(value) for value in vector) < p因此对应三条多项式:
临时设 ,得到:
本题这个矩阵可逆,即三条线性方程足够独立,因此有唯一解。
代码使用 fractions.Fraction 做精确有理数消元。不能将这些几百、上千位的整数转为普通浮点数后直接求解,否则会丢失精度。
实际恢复的低位:
x = 26935065816404425348087810146545330304118198184338416823761954915055210704
y = 3508779780900061165273789436142261523358634325843505345455544824287405353并验证:
- 都是整数。
- 。
- 。
- 原始 。
这组参数下,无需计算 resultant 或 Gröbner basis,普通精确线性消元即可。
补齐模逆输出,恢复 flag
既然低位已知:
代码是:
t1 = (leak1 << 244) + xt2 = (leak2 << 244) + y利用前面推导的公式:
恢复:
m = (k * pow(t1, -1, p) - r1) % p另一个泄漏独立计算:
m2 = (k * pow(t2, -1, p) - r2) % passert m == m2转换回字节:
flag = m.to_bytes((m.bit_length() + 7) // 8, "big")结果为:
flag{H4hAHhhHh4_c0pP3r_N07_v1OI3n7_3n0uGh}再回代原题:
assert t1 == k * pow(m + r1, -1, p) % passert t2 == k * pow(m + r2, -1, p) % passert t1 >> 244 == leak1assert t2 >> 244 == leak2这同时检查了两次完整模逆输出与原始高位泄漏。
严格说,泄漏只能恢复 。本题得到的是正常长度的 flag,其整数值小于 ,因此可以直接还原。如果允许任意长的消息, 与 会产生相同泄漏,不能仅凭这些关系无条件区分。
从题目到答案的完整路线
N 中含 25 位小素因子 ↓ Pollard rho恢复 q,再计算 p=N/q ↓解密 RSA 提示语(辅助理解题意) ↓t1=A+x,t2=B+y,x、y 的范围已知 ↓ 两条模逆关系交叉相减消掉 m ↓ 乘 delta 的模逆f(x,y)=xy+alpha*x+beta*y+gamma ≡ 0 mod p ↓选择 p、px、py、f 四个多项式 ↓ 按 1、X、Y、XY 加权构造 4×4 整数格基 ↓ LLL得到较短的加权系数向量 ↓ 检查 L1 范数 < p产生三条严格的整数方程 ↓ 暂时令 z=xy,精确消元恢复 x、y,并检查 z=xy 与根范围 ↓补齐 t1、t2,逆运算得到 m ↓转换为 flag,并回代全部泄漏关系可以把各对象的意义对应起来:
| 对象 | 在本题中的含义 |
|---|---|
| 格向量 | 一个多项式的加权系数 |
| 四行基向量 | 四个在真实根处能被 整除的多项式 |
| 整数行组合 | 对多项式做同样的整数线性组合 |
| LLL 约化 | 寻找加权系数较小的组合 |
| L1 范数小于 | 使代入后的值小于 |
| 整除性质与大小限制 | 联合推出值严格等于零 |
| 三条独立整数方程 | 用于恢复两个低位未知量 |
EXP
完整求解脚本
下面的脚本内置了题目全部公开参数,不依赖原题的 secret.py、message 文件,也不需要 SageMath 或 SymPy。
使用 fpylll 的 IntegerMatrix.from_matrix 和 LLL.reduction,接口可参考 fpylll 官方教程。
建议在虚拟环境中安装依赖:
python3 -m venv .venvsource .venv/bin/activatepython -m pip install fpylll==0.6.4 cysignals==1.13.1将下面代码保存为 solve_bivariate.py,执行:
python solve_bivariate.py需要保存验证记录时:
python solve_bivariate.py --report verification.json脚本最后保留了因子乘积、RSA 重新加密、两条模逆关系和高位泄漏的检查,得到结果后一起回代。
# 公开参数已内置,无需原题附件;仅需 fpylll 和 cysignals。#!/usr/bin/env python3"""Bivariate copper: 4x4 lattice, followed by exact linear algebra."""import argparseimport itertoolsimport jsonimport mathimport randomimport timefrom fractions import Fractionfrom pathlib import Pathfrom fpylll import IntegerMatrix, LLL
def factor_small(n): """Pollard rho; suitable here because one factor has only 25 bits.""" if n % 2 == 0: return 2 rng = random.Random(0xC0FFEE) while True: x = y = rng.randrange(2, n - 1) c = rng.randrange(1, n - 1) d = 1 while d == 1: x = (x*x + c) % n y = (y*y + c) % n y = (y*y + c) % n d = math.gcd(x-y, n) if 1 < d < n: return d
def solve_linear(rows): """Solve h0+h1*x+h2*y+h3*z=0 with z representing x*y.""" mat = [[Fraction(v) for v in row[1:]] + [Fraction(-row[0])] for row in rows] for col in range(3): pivot = next((i for i in range(col, 3) if mat[i][col]), None) if pivot is None: return None mat[col], mat[pivot] = mat[pivot], mat[col] pivot_value = mat[col][col] mat[col] = [v / pivot_value for v in mat[col]] for i in range(3): if i != col: multiplier = mat[i][col] mat[i] = [a - multiplier*b for a, b in zip(mat[i], mat[col])] return [row[-1] for row in mat]
def as_bytes(value): return value.to_bytes((value.bit_length()+7)//8, 'big')
PARAMS = { "e": 65537, "N": 3333577291839009732612693330613476891341287017491683764014849337158389717338712200133085615150269196268856288361865352673921704626130772582853528604556994221890454520933132803888321775335519781063447756692130742361931522856942232406992357982482263472763363458621836220024977864980600979194500121897419553619426163227, "c": 1277272201928931051067525742142583320131498687502905469530557519241347169899260720694873154669476372724906606385788056536109971768256973988460766527896895880291037980646963981472637862512247195798266373251524526460097881602691641026093728861572872156172787168597410496150253340538386296663073088345799201197096884740, "k": 9352039867057736323, "r1": 10421792656200324147964684790160875926436411483496860422433732508593789212449544620816674407170998779863336939494663076247759140488927744939619406024905901, "r2": 8806088830734144089522276896226392806947836111998696180055727048752624989402057411311728398322297424598954586424896296000606209022432442660527640463521679, "leak1": 4266222222502644630611545246271868348722888987303187402827005454059765428769160822475080050046035916876078546634293907218937483241284454918367519709206766322037148585465519188582916280829212776096606923824120883699251868362915920299645, "leak2": 1176921186497191878459783787148403806360469809421921990427675048480656171919274113895695842508460760829511824635106692634456334400022597605585661597793889066395539405395254174368285751236344600489419240628821864912762242188289636510706}
def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument('--report', type=Path) args = ap.parse_args() started = time.monotonic() values = PARAMS n, e, c, k, r1, r2, l1, l2 = [values[name] for name in ('N', 'e', 'c', 'k', 'r1', 'r2', 'leak1', 'leak2')] factor = factor_small(n) q, p = sorted((factor, n//factor)) assert p*q == n and q.bit_length() == 25 and p.bit_length() == 1024 print(f'[+] q = {q}') d = pow(e, -1, (p-1)*(q-1)) message_int = pow(c, d, n) assert pow(message_int, e, n) == c hint = as_bytes(message_int).decode() print(f'[+] RSA message = {hint}')
bound = 1 << 244 a, b = l1*bound, l2*bound delta = (r2-r1) % p h = k * pow(delta, -1, p) % p # f(x,y) = xy + alpha*x + beta*y + gamma (mod p) alpha, beta, gamma = (b-h) % p, (a+h) % p, (a*b+h*(b-a)) % p weights = [1, bound, bound, bound*bound] lattice = IntegerMatrix.from_matrix([ [p, 0, 0, 0], [0, p*bound, 0, 0], [0, 0, p*bound, 0], [gamma, alpha*bound, beta*bound, bound*bound], ]) LLL.reduction(lattice, delta=0.99) short_rows, norm_bits = [], [] for i in range(4): row = [int(lattice[i,j]) for j in range(4)] norm = sum(abs(v) for v in row) # A sufficient exact bound: |g(x0,y0)| <= ||g(Xx,Yy)||_1 < p. if norm < p: assert all(v % w == 0 for v, w in zip(row, weights)) short_rows.append([v//w for v, w in zip(row, weights)]) norm_bits.append(norm.bit_length()) print(f'[+] short integer equations: {len(short_rows)}; weighted L1 norm bits: {norm_bits}') root = None for rows in itertools.combinations(short_rows, 3): solution = solve_linear(rows) if solution is None or any(v.denominator != 1 for v in solution): continue x, y, z = map(int, solution) if not (0 <= x < bound and 0 <= y < bound and z == x*y): continue if (x*y + alpha*x + beta*y + gamma) % p: continue root = (x, y) break if root is None: raise ValueError('This 4x4 construction did not provide three independent equations') x, y = root t1, t2 = a+x, b+y m = (k*pow(t1, -1, p)-r1) % p assert m == (k*pow(t2, -1, p)-r2) % p assert t1 == k*pow(m+r1, -1, p) % p assert t2 == k*pow(m+r2, -1, p) % p assert t1 >> 244 == l1 and t2 >> 244 == l2 flag = as_bytes(m).decode() print(f'[+] x = {x}') print(f'[+] y = {y}') print(f'[+] flag = {flag}') print('[+] verified both inverse equations and both high-bit leaks') elapsed = time.monotonic()-started print(f'[+] elapsed: {elapsed:.2f}s') if args.report: args.report.write_text(json.dumps({ 'flag': flag, 'rsa_message': hint, 'q': q, 'p': p, 'x': x, 'y': y, 'm_mod_p': m, 'weighted_l1_norm_bits': norm_bits, 'short_polynomials': short_rows, 'elapsed_seconds': elapsed, 'verified': ['p*q=N', 'RSA re-encryption', 'two inverse equations', 'two high-bit leaks'], }, indent=2)+'\n')
if __name__ == '__main__': main()对应的结果如下,耗时会因机器和环境不同而变化:
[+] q = 23520857[+] RSA message = Hurry up and go smelt copper![+] short integer equations: 3; weighted L1 norm bits: [1008, 1009, 1011][+] x = 26935065816404425348087810146545330304118198184338416823761954915055210704[+] y = 3508779780900061165273789436142261523358634325843505345455544824287405353[+] flag = flag{H4hAHhhHh4_c0pP3r_N07_v1OI3n7_3n0uGh}[+] verified both inverse equations and both high-bit leaks[+] elapsed: 0.03s对照代码理解各阶段
| 代码位置 | 对应数学步骤 |
|---|---|
factor_small | Pollard rho 提取小因子 |
q, p = sorted(...) | 确认较小因子为 q,较大因子为 p |
pow(e, -1, ...) | 计算 RSA 私钥,解密提示语 |
bound = 1 << 244 | 定义 |
a, b = l1*bound, l2*bound | 定义已知高位对应的 |
delta、h | 消元后将 系数归一化 |
alpha, beta, gamma | 构造整数多项式 的系数 |
IntegerMatrix.from_matrix | 构造四行加权格基 |
LLL.reduction | 寻找较短的新基 |
norm < p | 判断是否能得到严格的整数零方程 |
v // w | 去除各列权重,恢复多项式系数 |
solve_linear | 将 当成 ,精确解 3×3 线性方程 |
z == x*y | 检查线性化解确实对应原变量 |
后面的 assert | 验证根范围、模方程和完整泄漏 |
为什么脚本枚举三条短方程的组合
约化基的行顺序不等于“前几行一定能用”。即使多行通过范数检查,也要确认选出的三条能构成可逆的线性系统。
脚本使用:
itertools.combinations(short_rows, 3)逐个尝试三行组合。若某组消元过程中没有主元,说明系数矩阵不可逆,就跳过它。
四维基只有四行,因此组合数量很小,不构成性能问题。
为什么固定了随机种子
完整脚本中的:
rng = random.Random(0xC0FFEE)只用于固定 Pollard rho 的起点与常数序列,方便复现。它不是密钥生成逻辑,也不是安全随机数的示范。
更换种子仍然可以运行,只是因子出现的迭代次数和耗时可能不同。
易错点
回头看一下容易绕进去的地方
解密 RSA 得到的不是 flag
c 加密的是 message。真正的 flag 通过模逆泄漏恢复。
已知高位不等于“直接忽略低位”
不能把 直接近似成 后求模逆,并期待得到正确 flag。模逆对输入变化并不具有普通实数运算中那种连续的近似关系。低 244 位必须恢复。
小根的“小”不是能够暴力枚举
仍然极大。这里利用的是它相对于模数和格构造尺度足够小。
格的四个坐标不是 x、y、z 和 flag
它们对应固定单项式 的加权系数。LLL 输出也不是直接给出根,而是给出新的多项式。
LLL 不会引入根的额外信息
所有输出仍是输入多项式的整数线性组合。攻击利用的是题目已泄漏的信息与根范围,而不是凭空增加信息。
为什么一条模关系能变成多条整数关系
模 时, 都是零,多条组合关系看起来没有新增约束。但我们同时知道根位于很小的范围内。
利用这个范围和短系数,能把某些“模 为零”的组合提升为“整数严格为零”。这个强化依赖小根范围,不能对任意大小的根这样做。
不能只检查去权重后的系数和
必须检查:
也就是加权向量的 L1 范数。单独检查 没有考虑根的大小。
LLL 找到的每一行不一定都有效
没有通过范数界的行,不能仅凭它属于同一个格就宣布对应多项式等于零。
三条方程不一定独立
如果只是同一方程的倍数,数量再多也未必足以求解。要检查线性化系数矩阵的可逆性。
z=xy 不是可以省略的检查
引入独立变量 是临时放宽条件。线性系统解出的 必须等于 ,才能回到原来的双变量问题。
不要用普通浮点数处理这些消元
Python 整数可以精确表示大整数,但 float 有有限精度。这里使用 Fraction,保留每一步有理数运算的准确结果。
4×4 格并非通用模板保证
这组参数已经实际求解成功,不意味着所有双变量小根题都能用四维格。不同题目可能需要更高次幂、额外单项式、更大格和其他消元方法。
留几个小例子复习
练习一:检查模逆
验证:
再用它们重新计算第一组例子的 。
练习二:检查右移与低位分解
对整数 123:
high = 123 >> 4low = 123 % (1 << 4)assert 123 == (high << 4) + low把 4 改成其他位数,观察高位与低位如何变化。
练习三:手工恢复多项式
给定加权向量:
和权重:
求它对应的多项式,并检查在 处的值。
答案:
代入后等于 0。
练习四:不运行 LLL,也能理解短向量的来历
手工展开:
其中:
确认结果正是练习三的多项式。这证明 LLL 给出的向量确实可以由原始四行做整数线性组合得到。
练习五:检查真正的范数界
分别计算:
和:
理解为什么第二个才是控制根范围内代入值的上界。
练习六:从三条方程恢复小例子的答案
先不看前面的结论,解一下:
然后检查 、根范围与原始模方程。
练习七:观察范围变大后会发生什么
在小例子代码中,将 改成 8、16 等值,重新约化。
记录有多少行满足:
不要预设结果必须单调变化,但可以观察到:放宽未知量范围会改变加权格,也会影响短方程能否被找到。
练习八:自己解释关键证明
复习时试着不看前面的推导,自己解释一下:
复习时能说清楚这一步,再说明格怎么保留整除性质、LLL 怎么帮助找到满足大小限制的向量,这道题的主线就顺下来了。
这道题要记住什么
这道题将几个常见的密码分析动作串在一起:
- 从参数不平衡处入手:大模数里有小因子,先分解它。
- 把泄漏变成方程:高位已知就写成“已知部分 + 有界未知量”。
- 利用共享未知量消元:两条模逆里同一个 可以交叉相减消掉。
- 用单项式组织多项式:固定系数顺序,把代数对象写成向量。
- 把范围编码进缩放权重:让向量长度反映代入值的大小。
- 通过整除与大小得到精确等式:这是从模信息走向整数方程的关键。
- 利用多项式结构简化求解:只有 时,三条独立方程可通过线性化求解。
- 对最终结果回代验证:不要仅凭能解码出字符串就认为推导必然正确。
回头看,LLL 这一步调用起来其实很短,难的是前面怎么消元、怎么选多项式、怎么把未知量范围放进格里。尤其要记住:LLL 输出的是多项式的加权系数,不会直接把 flag 吐出来。下次遇到类似的高位泄漏,先把这条思路顺一遍,再考虑套代码。
参考资料
最后把相关资料放在这里,之后复习 Pollard rho、LLL 和双变量小根时可以接着看。
- J. M. Pollard, A Monte Carlo Method for Factorization, BIT 15 (1975), 331–334。原论文 PDF。
- A. K. Lenstra, H. W. Lenstra Jr., L. Lovász, Factoring Polynomials with Rational Coefficients (1982)。CWI 机构文献页。
- Jean-Sébastien Coron, Finding Small Roots of Bivariate Integer Polynomial Equations, EUROCRYPT 2004。IACR 原论文。注意标题中的 integer,不能直接把该情形的结论当成本题模双变量方程的无条件保证。
- FPLLL 开发团队,fpylll 项目与官方教程。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时







