Cryptanalysis of RSA Variants with Primes Sharing Most Significant Bits
概论
当RSA的公钥e和私钥d满足公式\(ed-k(p^2-1)(q^2-1)=1\),如果模数m的两个因子p,q有相同的MSB,也就是说,如果p,q的差值\(|p-q|\)比较小,那么就可以计算出上式中的d,并且分解模数m。
RSA变式
RSA-LUC
1993年,Smith 和 Lennon 发表了一个RSA变种密码系统,叫做LUC,基于 Lucas sequences。模数\(N=pq\),公钥e和私钥d满足 \(ed\equiv1 \pmod{(p^2-1)(q^2-1)}\)。
还有很多其他的变式可以看原文,这些变式都满足 \(ed\equiv1 \pmod{(p^2-1)(q^2-1)}\) 这条式子。
后面的研究中,把这条式子改写为等式 \(ed-k(p^2-1)(q^2-1)=1\) ,并得出 \(\frac{k}{d}\) 可以用 \(e/(N^2-\frac{9}{4}N+1)\)的 连分数形式的逼近来表示,如果 \(d < \sqrt{(2N^3-18N^2)/e}\),这个方法将非常有效。
在2017年,Bunder发现当公钥e满足 \(ex-(p^2-1)(q^2-1)y=z\),可以将Coppersmith方法和连分数方法结合起来,如果 \(xy < 2N-4\sqrt{2}N^{\frac{3}{4}}\) 且 \(|z| < |p-q|N^{\frac{1}{4}}y\) ,那么可以很有效的分解N,当z=1时,上式变为 \(ed-k(p^2-1)(q^2-1)=1\) ,d 的上界为 \(d < \sqrt{2N-4\sqrt{2}N^{\frac{3}{4}}}\) 。d 和 e 的界也被考虑成 \(e = N^{\alpha}\) 和 \(d = N^{\delta}\) 。再到后来密码学家们发现 \(ed\equiv1 \pmod{(p^2-1)(q^2-1)}\) 可以被解决,如果 \(\delta < \frac{7}{3} - \frac{2}{3}\sqrt{1+3\alpha}\) 。
这篇论文主要论述的是RSA模数N=pq,且 \(q < p < 2q\),\(p-q=N^{\beta}\)。如果p和q满足 \(q<p<2q\),那么 \(\beta\) 总是小于0.5的,而且当 \(\beta\) 小于0.25时就可以费马分解的方法直接分解N了。
连分数方法攻击
定理 令 \(N=pq\) 是 RSA 的模数,\(q<p<2q\),\(|p-q|=N^{\beta}\) 。令加密指数 \(e=N^{\alpha}\) 满足 \(ed-k(p^2-1)(q^2-1)=1\) 。如果
可以再多项式时间内找到p,q 。
证明
由此推出
由e, d 的关系,得到 \(k(p^2-1)(q^2-1)=ed-1<ed\) ,所以
和
因为 \(q<p<2q\) 所以
然后得到
这个不等式要求 \(N\ge8\) 。因此用 \(e=N^{\alpha}\) ,\(|p-q|=N^{\beta}\) 以及 \(d=N^{\delta}\) ,我们得到
如果 \(2N^{\alpha+2\beta-4}<\frac{1}{2}N^{-2\delta}\) ,由于指数的作用比乘积大,也就是说前面的不等式等价于 \(\delta<2-\beta-\frac{1}{2} \alpha\) 。
那么就有
由于 d 是一个很大的数,所以 \(1/2d^2\) 非常小,就能知道 \(\frac{k}{d}\) 和 \(\frac{e}{(N-1)^2}\) 非常接近,于是就可以用\(\frac{e}{(N-1)^2}\) 的连分数去逼近 \(\frac{k}{d}\) ,然后用\(ed-k(p^2-1)(q^2-1)=1\) 和 \(N=pq\) 去解 p 和 q 。
总的来看和 Wiener's Theorem 还是很相似的,e, d 的关系变成 \(ed-k(p^2-1)(q^2-1)=1\) ,在公式的推导中利用的很多不等式和近似。
简单总结一下:
- 连分数攻击适用于 \(\delta<2-\beta-\frac{1}{2} \alpha\) ,其中 \(d=N^{\delta}\) , \(e=N^{\alpha}\) ,\(|p-q|=N^{\beta}\) 。也就是 \(d^2 < N^4-|p-q|^2-e\) 如果粗糙一点说也可以用这个不等式来判断 \(d < \sqrt{(2N^3-18N^2)/e}\) 。
- 找 \(\frac{e}{(N-1)^2}\) 的连分数逼近
- 迭代 \(\frac{k_i}{d_i}\) 利用 e, d 关系 \(ed-k(p^2-1)(q^2-1)=1\) 和 \(N=pq\) 去分解N。
demo
attack function by sagemath
# contiune fraction method
def attack(N, e):
"""
Recovers the prime factors of a modulus and the private exponent if two prime factors share most significant bits
:param N: the modulus
:param e: the public exponent
:return: a tuple containing the prime factors and the private exponent, or None if the private exponent was not found
"""
PR = PolynomialRing(ZZ, 'x')
x = PR.gen()
convergents = continued_fraction(ZZ(e) / ZZ((N-1)**2)).convergents()
for c in convergents:
k = c.numerator()
d = c.denominator()
try:
f = x**2 - x*(N**2 + 1 - int((e*d-1)/k)) + N**2
if f.discriminant() > 0:
root = f.roots()
p2 = root[0][0]; q2 = root[1][0]
if is_square(p2) and is_square(q2):
p = isqrt(p2); q = isqrt(q2)
if p*q == N:
return p, q, d
except:
continue
return None
测试代码
from sage.all import *
from math import isqrt
# genetare parmater
n_bits = 1024
p = random_prime(2**512, lbound=2**511)
beta = 0.46
pqdiff_bound = 1 << int(n_bits * beta)
pqdiff_lower_bound = 1 << int(n_bits * beta - 1)
q = next_prime(p - random_prime(pqdiff_bound, lbound=pqdiff_lower_bound))
assert p > q and p < 2*q
N = p*q
phi = (p**2-1)*(q**2-1)
delta = 0.54
d_bound = 1 << int(n_bits*delta)
d_lower_bound = 1 << int(n_bits * delta - 1)
while True:
d = random_prime(d_bound, lbound=d_lower_bound)
if gcd(d, phi) == 1:
e = inverse_mod(d, phi)
if gcd(e, phi) == 1:
alpha = int(e).bit_length() / n_bits
break
# paramters
print(f"alpha = {alpha}")
print(f"beta = {beta}")
print(f"delta = {delta}")
print(f"p = {p}")
print(f"q = {q}")
print(f"e = {e}")
print(f"d = {d}")
# check
attack_bound = ZZ(isqrt((2*N**3 - 18*N**2)//ZZ(e)))
print(f"check step 1: {d < attack_bound}")
print(f"check step 2: {delta < 2-beta-0.5*alpha}")
# contiune fraction method
def attack(N, e):
"""
Recovers the prime factors of a modulus and the private exponent if two prime factors share most significant bits
:param N: the modulus
:param e: the public exponent
:return: a tuple containing the prime factors and the private exponent, or None if the private exponent was not found
"""
PR = PolynomialRing(ZZ, 'x')
x = PR.gen()
convergents = continued_fraction(ZZ(e) / ZZ((N-1)**2)).convergents()
for c in convergents:
k = c.numerator()
d = c.denominator()
try:
f = x**2 - x*(N**2 + 1 - int((e*d-1)/k)) + N**2
if f.discriminant() > 0:
root = f.roots()
p2 = root[0][0]; q2 = root[1][0]
if is_square(p2) and is_square(q2):
p = isqrt(p2); q = isqrt(q2)
if p*q == N:
return p, q, d
except:
continue
return None
result = attack(N, e)
assert result != None, "no result"
p, q, d = result
print("attack finish, get")
print(f"p = {p}")
print(f"q = {q}")
print(f"d = {d}")
输出
alpha = 1.99609375
beta = 0.46
delta = 0.54
p = 8406643377981629626811646133771198403255255522467207444991622697680850682941538832110654789948650168347547487883409896215504739533322889924095611950833997
q = 8406643377977179979671041238653918211315952818096881317941557663045423915326379167410018138334335185395189383254205899373386216155386760699272210407922759
e = 1110800340083303328409168504484065292940668235722045412659413083991459540716589704385849056470951835369439790243586123952050852123679997511675601108424970995223757885717140402024028525018368572164542875329515620447453397899075007131099258157788275439784354313980338033207127998650481283926225741172740750647416454068540786594711130891562928160432618419846698928632055429541344493485659153805653186418897286561907086377175044409847334727768680508645477651906431561168347733901347223084949905765613688938377277010995416972766098844482178898448679408222490011796397878275175763710131617180329057114830701572732576610783
d = 13874613842901198703840384827215457417464863664524770654955846214097908890393956737749090737131189322336619851723197432511838668534730694642961386947340470001867225887
check step 1: False
check step 2: True
attack finish, get
p = 8406643377981629626811646133771198403255255522467207444991622697680850682941538832110654789948650168347547487883409896215504739533322889924095611950833997
q = 8406643377977179979671041238653918211315952818096881317941557663045423915326379167410018138334335185395189383254205899373386216155386760699272210407922759
d = 13874613842901198703840384827215457417464863664524770654955846214097908890393956737749090737131189322336619851723197432511838668534730694642961386947340470001867225887
测试过来上面的参数已经挺极限的了,再往上调解不出来的可能性很大。
Coppersmith 方法攻击
定理 令 \(N=pq\) 是 RSA 的模数,\(q<p<2q\),\(|p-q|=N^{\beta}\) 。令加密指数 \(e=N^{\alpha}\) 满足 \(ed-k(p^2-1)(q^2-1)=1\) , \(d=N^{\delta}\) 。如果
对于小的正数 \(\epsilon\) ,可以在多项式时间内找到p,q 。
与连分数攻击相比较
所以Coppersmith 方法的界比连分数攻击的界要大很多。如果能用Coppersmith方法那么连分数方法应该也能用。
证明
对于N>5
那么
这个不等式给出了k的一个上界。同时密钥关系的式子可以写成
考虑这个多项式 \(f(x, y)=xy+Ax+1\) ,\(A=-(N-1)^2\) 。\((x, y)=(-k,(p-q)^2)\) 是 \(f(x, y)\equiv 0 \pmod{e}\) 的一个解。对这个多项式 \(F(x, u)=u+Ax, u=xy+1\) 可以用Coppersmith方法求出小根。
令 m 和 t 是正整数。考虑下面的多项式
其中把 \(H_{k, i_{1}, i_{2}, i_{3}}(x, y, u)\) 进行展开,把每一项 \(xy\) 用 \(u-1\) 来替代,这样 \(G_{k, i_{1}, i_{2}, i_{3}}(x, y, u)\) 和 \(H_{k, i_{1}, i_{2}, i_{3}}(x, y, u)\) 都会变成单项式。
-
单项式 \(G_{k, i_{1}, i_{2}, i_{3}}(x, y, u)\) 依照下面这个output排列
\(for\ k=0, \ldots m , for\ i_{1}=0, \ldots, m-k , for\ i_{2}=0 , for\ i_{3}=k , output\ x^{i_{1}} y^{i_{2}} u^{i_{3}}\)
-
单项式 \(H_{k, i_{1}, i_{2}, i_{3}}(x, y, u)\) 依照下面这个output排列
\(for\ i_{1}=0 , for\ i_{2}=1, \ldots t , for\ k=\left\lfloor\frac{m}{t}\right\rfloor i_{2}, \ldots, m , for\ i_{3}=k , output\ x^{i_{1}} y^{i_{2}} u^{i_{3}}\)
然后我们令
然后多项式 \(G_{k,i_1,i_2,i_3}(Xx,Yy,Uu)\) 和 \(H_{k,i_1,i_2,i_3}(Xx,Yy,Uu)\) 可以构成一个格 \(\mathcal{L}\) 。以 \(m=t=2\) 为例
由于是下三角矩阵,这个格基的行列式为下面的形式
其中 \(n_X,n_Y,n_U\) 和 格基的维 \(\omega\) 等于
用 \(1/\tau\) 来代替 \(\left\lfloor\frac{m}{t}\right\rfloor\) ,并且使用一些近似得到
对上面的格基进行LLL规约,新的格基有三个多项式 \(h_1(x,y,z)\) , \(h_2(x,y,z)\) 和 \(h_3(x,y,z)\) 共有一个根 \((x, y, z)=(-k,(p-q)^2,-k(p-q)^2+1)\) ,然后用Grobner basis,可以得到 \(p-q=\sqrt{y}\) 再结合 \(N=pq\) 就可以分解N。
简单总结一下,在一开始看这一节的时候很多地方没看懂,看到对 \(F(x, u)=u+Ax\) 用 Coppersmith 方法可以解出根我以为直接用 sage 的 small_root 就可以解,然后去试了一下发现解不出来。然后硬啃下了后面的部分,感觉应该是使用了Coppersmith 的原理,自己构造单项式和多项式吧。
- Coppersmith method 攻击适用于 \(\delta < 2-\sqrt{2\alpha\beta}-\epsilon\) ,但实际上用 \(\delta < 2-\sqrt{2\alpha\beta}\) 粗略的判断一下也可以。
- 选取参数 m 和 t 构造格基,LLL规约后利用Grobner basis解出一个根(x, y, z),其中 \(\sqrt{y}=p-q\) 再结合 \(N=pq\) 分解 N。
dome
实现起来比连分数要复杂很多,参考了一些RCTF 2022 Clearlove 的题解,东拼西凑写出来的。
from sage.all import *
beta = 0.44
n_bits = 1024
delta = 0.63
p = random_prime(1 << (n_bits//2), lbound=1<<(n_bits//2 - 1))
pqdiff_bound = 1 << int(n_bits * beta)
pqdiff_lower_bound = 1 << int(n_bits * beta - 1)
q = next_prime(p - random_prime(pqdiff_bound, lbound=pqdiff_lower_bound))
assert p > q and p < 2*q
N = p*q
phi = (p**2-1)*(q**2-1)
d_bound = 1 << int(n_bits*delta)
d_lower_bound = 1 << int(n_bits * delta - 1)
while True:
d = random_prime(d_bound, lbound=d_lower_bound)
if gcd(d, phi) == 1:
e = inverse_mod(d, phi)
if gcd(e, phi) == 1:
break
alpha = int(e).bit_length() / n_bits
print(f"alpha={alpha}, beta={beta}, delta={delta}")
print(f"is delta < sqrt(2*alpha*beta)? {delta < sqrt(2*alpha*beta)}")
import logging
logging.basicConfig(level=logging.DEBUG)
def find_roots_univariate(x, polynomial):
"""
Returns a generator generating all roots of a univariate polynomial in an unknown.
:param x: the unknown
:param polynomial: the polynomial
:return: a generator generating dicts of (x: root) entries
"""
if polynomial.is_constant():
return
for root in polynomial.roots(multiplicities=False):
if root != 0:
yield {x: int(root)}
def find_roots_groebner(pr, polynomials):
"""
Returns a generator generating all roots of a polynomial in some unknowns.
Uses Groebner bases to find the roots.
:param pr: the polynomial ring
:param polynomials: the reconstructed polynomials
:return: a generator generating dicts of (x0: x0root, x1: x1root, ...) entries
"""
# We need to change the ring to QQ because groebner_basis is much faster over a field.
# We also need to change the term order to lexicographic to allow for elimination.
gens = pr.gens()
s = Sequence(polynomials, pr.change_ring(QQ, order="lex"))
while len(s) > 0:
G = s.groebner_basis()
logging.debug(f"Sequence length: {len(s)}, Groebner basis length: {len(G)}")
if len(G) == len(gens):
logging.debug(f"Found Groebner basis with length {len(gens)}, trying to find roots...")
roots = {}
for polynomial in G:
vars = polynomial.variables()
if len(vars) == 1:
for root in find_roots_univariate(vars[0], polynomial.univariate_polynomial()):
roots |= root
if len(roots) == pr.ngens():
yield roots
return
return
else:
# Remove last element (the biggest vector) and try again.
s.pop()
def factorit(N, y):
pqdiff = ZZ(sqrt(y))
assert pqdiff^2 == y
x = var("x")
roots = (x*(x + pqdiff) - N).roots()
p = ZZ(max(r for r, _ in roots))
assert ZZ(N) % p == 0
q = ZZ(N) // p
return p, q
m=8
tau = (alpha - delta - 2. * beta) / (alpha * beta)
# t = ceil(tau * m)
t=12
print(f"m={m}, t={t}")
A = -(N - 1)**2
R = PolynomialRing(QQ, 'x, y, u')
x, y, u = R.gens()
Rquo = R.quo(x*y - (u - 1))
F = u + A*x
X = ZZ(ceil(2*N**(alpha+beta-2)))
Y = ZZ(ceil(N**(2*beta)))
U = ZZ(ceil(2*N**(beta+beta+2*beta-2)))
def G(k, i1, i2, i3):
return x**i1 * F**k * e**(m - k)
def H(k, i1, i2, i3):
return Rquo(y**i2 * F**k * e**(m - k)).lift()
polynomials = []
monomials = []
for k in range(m + 1):
for i1 in range(m - k + 1):
polynomials.append(G(k, i1, 0, k))
monomials.append(x**i1*u**k)
for i2 in range(1, t + 1):
for k in range(m//t * i2, m + 1):
polynomials.append(H(k, 0, i2, k))
monomials.append(y**i2*u**k)
L = Matrix(ZZ, nrows=len(polynomials), ncols=len(monomials))
for r, g in enumerate(polynomials):
for v, M in g(x=X*x, y=Y*y, u=U*u):
L[r,monomials.index(M)] = v
def mprint(M):
print("\n".join(''.join("0X"[bool(x)] for x in r) for r in M))
# mprint(L)
B = L.LLL()
polys = list(B * vector([m / m.monomials()[0](x=X, y=Y, u=U) for m in monomials]))
print(len(polys))
RR = PolynomialRing(QQ, 'xx, yy')
xx, yy = RR.gens()
def inj(f): return RR(f(u=xx*yy + 1, x=xx, y=yy))
for roots in find_roots_groebner(RR, list(map(inj, polys))):
print(f"{A = }")
print(roots)
if (roots[xx] * roots[yy] + A * roots[xx] + 1) % e != 0:
print("Not an actual root for f")
_y = roots[yy]
if not is_square(_y):
print("Not a square")
continue
if _y > 0:
print(factorit(N, ZZ(_y)))
exit()
在测试这段代码的时候发现表现的并不理想,用上面这个参数跑了10分钟左右也没有跑出来。也有可能是我哪里写错了?
后来找到了HFCTF2022出题人的github上有类似的exp,看了一下,他方法也是Coppersmith,也是从
这个方程下手,构造这样的多项式
发现仅仅是把这篇论文中的 \((p-q)^2=y\) 替换成 \(p-q=y\) ,也就是说 \((x,y)=(-k,p-q)\) 是这个多项式的根。
以及单项式变成
发现这个样子构造出来的格的维度要比这篇论文中的小一点点,可能就是这一点点,让这种方法的LLL格基规约速度更快吧。下面贴一下代码。
from gmpy2 import next_prime, iroot
from Crypto.Util.number import getPrime, inverse, GCD, bytes_to_long, long_to_bytes
from sage.all import *
import time
def attack2(N, e, m, t, X, Y):
ti=time.time()
PR = PolynomialRing(QQ, 'x,y', 2, order='lex')
x, y = PR.gens()
A = -(N-1)**2
F = x * y**2 + A * x + 1
G_polys = []
# G_{k,i_1,i_2}(x,y) = x^{i_1-k}y_{i_2-2k}f(x,y)^{k}e^{m-k}
for k in range(m + 1):
for i_1 in range(k, m+1):
for i_2 in [2*k, 2*k + 1]:
G_polys.append(x**(i_1-k) * y**(i_2-2*k) * F**k * e**(m-k))
H_polys = []
# y_shift H_{k,i_1,i_2}(x,y) = y^{i_2-2k} f(x,y)^k e^{m-k}
for k in range(m + 1):
for i_2 in range(2*k+2, 2*k+t+1):
H_polys.append(y**(i_2-2*k) * F**k * e**(m-k))
polys = G_polys + H_polys
monomials = []
for poly in polys:
monomials.append(poly.lm())
dims1 = len(polys)
dims2 = len(monomials)
MM = matrix(QQ, dims1, dims2)
for idx, poly in enumerate(polys):
for idx_, monomial in enumerate(monomials):
if monomial in poly.monomials():
MM[idx, idx_] = poly.monomial_coefficient(monomial) * monomial(X, Y)
print(f"LLL dimension: {MM.nrows()}x{MM.ncols()}")
B = MM.LLL()
found_polynomials = False
for pol1_idx in range(B.nrows()):
for pol2_idx in range(pol1_idx + 1, B.nrows()):
P = PolynomialRing(QQ, 'a,b', 2)
a, b = P.gens()
pol1 = pol2 = 0
for idx_, monomial in enumerate(monomials):
pol1 += monomial(a,b) * B[pol1_idx, idx_] / monomial(X, Y)
pol2 += monomial(a,b) * B[pol2_idx, idx_] / monomial(X, Y)
# resultant
rr = pol1.resultant(pol2)
# are these good polynomials?
if rr.is_zero() or rr.monomials() == [1]:
continue
else:
print(f"found them, using vectors {pol1_idx}, {pol2_idx}")
found_polynomials = True
break
if found_polynomials:
break
if not found_polynomials:
print("no independant vectors could be found. This should very rarely happen...")
PRq = PolynomialRing(QQ, 'z')
z = PRq.gen()
rr = rr(z, z)
soly = rr.roots()[0][0]
ppol = pol1(z, soly)
solx = ppol.roots()[0][0]
return solx, soly
beta = 0.44
n_bits = 1024
delta = 0.63
p = random_prime(1 << (n_bits//2), lbound=1<<(n_bits//2 - 1))
pqdiff_bound = 1 << int(n_bits * beta)
pqdiff_lower_bound = 1 << int(n_bits * beta - 1)
q = next_prime(p - random_prime(pqdiff_bound, lbound=pqdiff_lower_bound))
assert p > q and p < 2*q
n = p*q
phi = (p**2-1)*(q**2-1)
d_bound = 1 << int(n_bits*delta)
d_lower_bound = 1 << int(n_bits * delta - 1)
while True:
d = random_prime(d_bound, lbound=d_lower_bound)
if gcd(d, phi) == 1:
e = inverse_mod(d, phi)
if gcd(e, phi) == 1:
break
nbits = n_bits
alpha = ZZ(e).nbits() / ZZ(n).nbits()
print(f"alpha={alpha}, beta={beta}, delta={delta}")
print(f"is delta < sqrt(2*alpha*beta)? {delta < sqrt(2*alpha*beta)}")
X = 2 ** int(nbits*(alpha+delta-2)+3)
Y = 2 ** int(nbits*beta+3)
t = time.time()
x, y = map(int, attack2(n, e, 8, 12, X, Y))
p_minus_q = y
p_plus_q = iroot(p_minus_q**2 + 4 * n, 2)[0]
p = (p_minus_q + p_plus_q) // 2
q = n // p
print(f"p = {p}")
print(f"q = {q}")
print(f"if attack successfully? {p*q == n}")
跑了几次,大概5,6分钟左右就出了。