这周跟 Project SEKAI 一起打了 IERAE CTF 2025,比赛的时候基本全花在做 Cubic Math 上了,但没做出来。赛后看了作者的解释,发现自己只差一步就能做出来了,很气。
Cubic Math
secret = randrange(2**256)
primes = []p = secretwhile len(primes) < 10: p = next_prime(p) primes.append(p)
for _ in range(50): a, b = map(int, input("a, b = ").split(",")) if a == secret: print(FLAG) exit() if not (-2**31 <= a < 2**31 and -2**31 <= b < 2**31): exit(1)
res = [] for p in primes: R.<x> = PolynomialRing(GF(p)) if (x^3+a*x+b).is_irreducible(): res.append(p-secret) print(res)
这题的逻辑很简单,给十个连续的素数,每次询问一个三次多项式 模 下是否可约,如果不可约就输出到 secret
的距离。
首先这题看起来跟椭圆曲线很像,实则毫无关系,因为椭圆曲线这个是否可约没啥性质。所以我们直接来考察三次多项式的可约性。
我们可以从两个角度考察三次多项式。首先,可约等价于它在模下有一个根,这可以用gcd(x**p-x, ?)
来判断。但我们不知道secret,所以行不通。所以我们只能从三次方程求根公式想办法。
对于方程 ,它的解长这样
对称的 和 也都是解。
首先,模下的三次剩余现在仍然没做完,所以能不惹就先不惹。那我们就先考虑 这个二次剩余的部分。三次方程有一个判别式 ,当 时,方程有三个实根;当 时,方程有一个实根和两个共轭复根。这个式子在模 下也有意义,我们可以认为 相当于模 下是二次剩余。而 相当于模 下不是二次剩余,会产生一个扩域。所以 不是二次剩余时,在扩域上有一对共轭的根,这个二次项是不可约的,然后剩下的一个根就不得不回退到 ,故而可约。
所以判别式相当于告诉了我们这样一件事,如果这个多项式不可约,那么 是二次剩余,但反之则不成立。这已经提供了足够的信息,如果可以询问无限多次的话,我们可以这样做:
- 选取小素数,枚举出若干个 ,因为可约不代表 不是二次剩余,所以需要多次询问,确定每个模下的二次剩余。
- 通过二次互反律,得知 模这个小素数的二次剩余,根据这些素数的offset来查表得知secret模的值。
- CRT解出secret。
但是这道题只有50次询问,平均每一次询问需要获取 bit 的信息。但因为判别式只在不可约的时候可以获取信息,而不可约的概率只有 ,所以是不够 bit 的。
在判别式寄了之后,剩下的方法只剩下用三次剩余了。好消息是,三次剩余并不是完全没有研究,至少三次互反律是存在的,这给了我们用三次互反律来CRT的可能。
三次剩余需要使用 的高斯整数环来构造。这个环也是唯一分解整环,如果素数 ,那么它在 中也是不可约的。如果素数 ,那么它可以分解为共轭的两个素数 。然后我们称一个素数是 primary 的如果 的 是 的倍数。然后 是模长且一定模 余 。跟二次剩余类似,三次剩余也可以定义
取值一定是三次单位根的一个。对任意两个 primary 的素数 ,有三次互反律 成立。
如果我们想要在三次方程中使用三次互反律,首先要处理的就是在开立方根之前的那个二次剩余。如果我们随意地选数,不同模 的开根会乱成一团,所以我们要直接构造 使开根的数是平方数。这也意味着判别式跟一个平方数只差一个 ,无法给信息了。一个不错的选择是把根设置为 的所有共轭。这样我们只需要研究 的三次剩余,而且 模 余 ,所以它在 中也是素数。
看着十分美好,但实操却发现了很大的问题。因为 模 余 时所有数都有立方根,所以我们需要再一开始赌有很多素数模 余 。而且就算对模 余 的素数分析,也会遇到它自己又不是 上的素数的问题。如果我们要对这个强行算类似 Jacobi 符号的东西,会直接永远是 寄掉。
以上是我在比赛时的思路,一看不太行就就转头开始继续对判别式做概率分析然后一头撞死。
比赛结束后,作者也发了他的解法,其实没太看懂为什么,但第一步是在单位根域上找一个三次的子域,比如 次单位根对应的就是 ,然后转到我的做法是算 的三次剩余。这里就发现问题了,他实际在开根的时候保留了一个 然后直接转换到了整环上。这样就算对模 余 的素数分析也能得到一个非平凡的 Jacobi 符号。然后就可以直接用三次互反律来做了。
随便写了一个脚本来验证这个思路的正确性,就不完整做了。
from sage.all import *
w = polygen(ZZ, 'w')K = NumberField(w**2+w+1, 'w')w = K.gen()
p = random_prime(2**8)while p % 3 != 1: p = random_prime(2**8)a = list(K(p).factor())[-1][0]
def is_primary(a): # stupid primary ideal check u, v = a.parts() v = ZZ(2*v) return v % 3 == 0
a = next(a for a in [a, a*w, a*w**2] if is_primary(a))print(a, a.norm())norm = ZZ(a.norm())ret = {}
for i in range(norm): if i == 0: continue b = random_prime(2**20) while b % norm != i: b = random_prime(2**20) b = K(b) assert b.residue_symbol(K.ideal(a), 3) == a.residue_symbol(K.ideal(b), 3) ret[ZZ(b)%norm] = a.residue_symbol(K.ideal(b), 3)
print(ret)while True: b = random_prime(2**40) b = K(b) print(b, ZZ(b)%norm, ret[ZZ(b)%norm], b.residue_symbol(K.ideal(a), 3), a.residue_symbol(K.ideal(b), 3)) assert ret[ZZ(b)%norm] == b.residue_symbol(K.ideal(a), 3) and ret[ZZ(b)%norm] == a.residue_symbol(K.ideal(b), 3)