照烟照烟
笔记

RSA

加解密原理

RSA 的安全性完全依赖于逆向分解 n 极难的特性。它利用了三个关键参数:

  1. 模数 n :由两个大素数 p,q 相乘得到。
  2. 公钥指数 e 通常 是一个固定的质数(如 65537)
  3. 私钥指数 d :满足 ed1(modϕ) 的逆元,其中 ϕ=(p1)(q1)

由明文 m 通过 c=me(modn) 得到密文 c 。RSA 是非对称加密,由密文 c 通过 m=cd(modn) 还原明文 m 。公钥是 (n,e) ,私钥是 (n,d) 。明文 m 必须小于模数 n ,否则会导致几乎无法恢复的信息截断。

假设有一个大于模数的明文 mbig ,加密再解密后得到的是 mbigmodn 。但疑问来了:加密算法 c=me(modn) 中的 me 几乎必定大于 n ,就不怕发生信息截断吗?

事实上,只要参数 e,ϕ(n) 互素, m,c 二者必定是双射关系,即通过一方的值一定能寻找到唯一的另一方,下面给出证明。

使用反证法。假设有两个在 (0,n1) 之间的不同明文 m1,m2 经过 e 次方后得到了同一个密文 c

m1ec(modn),m2ec(modn)

对这两个等式同时进行 d 次方,其中 d e 在模 ϕ(n)=(p1)(q1) 下的逆元:

m1edcd(modn),m2edcd(modn)

这里先引入欧拉定理。在模 n 环上,让一个和 n 互素的数 m 不断自乘(即 mi ),其结果具有周期性。每当 i 落入 ϕ(n) 的倍数时,结果就会回到 1 ,即

mkϕ(n)1(modn),k=0,1,2,

当模数为质数 p 时,欧拉函数 ϕ(p)=(p1) 。此时欧拉定理退化为费马小定理:

ap11(modp)

其中 gcd(a,p)=1 ,即 a 不是 p 的倍数。

回到原证明。由 d 的定义 ed1(modϕ(n)) ed=1+kϕ(n) 。代入回 med

med=mmkϕ(n)m1=m(modn)

最终得到的是

m1cd(modn),m2cd(modn)

cdmodn (0,n1) 内是唯一的,因此有

m1m2(modn)

m1,m2(0,n1) ,于是 m1=m2

我们开始假设的是 m1m2 ,矛盾了,因此原命题成立。

由此我们就证明了 f(m)=me(modn) 是一个双射映射,且只要 gcd(e,ϕ(n)) 成立,私钥 d 就必然存在,使得每个 c 都能找到属于自己的,唯一的 m

攻击面

参数不当

如果 RSA 体制的参数(如 p,q,n,e,d )选择不当,就会导致可能的漏洞。

  • 质数 p,q

    • p,q 过小。这是最直接的缺陷,可以通过分解大素数的网站 http://www.factordb.com 来直接分解 n 得到 p,q 。如果 p,q 都是 512 位及以上,那基本就不用想了。
    • p,q 相差过小,精确来说,当 |pq|n1/4 时,费马分解法大概就能生效。
    • p,q 相差过大,不过做题中这种情况几乎没有。
    • p1 p+1 光滑(即质数 p 在减去 1 或加上 1 后,所有质因子都很小),使用 Pollard’s p1 算法Williams’s p+1 算法,能够高效攻击。

    上述的所有漏洞都可以调用 yafu 工具进行攻击。

  • 公钥指数 e

    • e 过小(如 e=3 ),此时若是同一明文 m 被加密多次,就是小加密指数广播攻击。
    • Rabin 加密( e=2 的特例)
    • e ϕ(n) 不互质,结合 AMM 算法实现攻击。
  • 私钥指数 d

    • d 过小( d<13n1/4 ),维纳攻击,以及 Boneh Durfee 攻击。

数据泄露

有些题目会泄露 p,q 的中间值或高低位。

  • CRT 数据泄露,即 dp,dq 泄露。
  • 中间值泄露,如 p+q p2+q2 ap+bq 等等。需要灵活使用数论定理。
  • 高低位泄露,使用 Coppersmith 和格密码处理。

过程失误

加密过程导致加密体制易受攻击。

  • 共模攻击。两段及以上密文使用了同样的 n 但不同的 e ,此时可以使用扩展欧几里得算法。
  • 小明文攻击。明文和 e 都很小,直接对 c 开方就能破解。
  • Oracle 交互,缩小明文范围来破解。

RSA 相关的攻击题目非常非常非常多…

OpenSSL 和公私钥文件

OpenSSL 是一个开源、跨平台的密码学工具包和开源软件库,包括加密算法库、协议库以及终端的命令行工具。在密码学方面,它可以实现对称加密(AES/SM4/DES)和非对称加密(RSA/ECC)等的加解密。简而言之,密码学的算法应用由 OpenSSL 落地。对于其实现的 RSA 加密,我们可以在终端执行以下命令:

  • openssl genrsa -out private.pem 2048 生成一个 2048 位的私钥文件
  • openssl rsa -in private.pem -pubout -out public.pem 从私钥文件提取公钥
  • openssl rsautl -encrypt -pubin -inkey public.pem -in file.txt -out file.enc 用公钥加密文件,在新版本的 OpenSSL 中,用 pkeyutl 替代 rsautl
  • openssl rsautl -decrypt -inkey private.pem -in file.enc -out file.dec 对应的解密

私钥文件

在终端执行命令 openssl rsa -in private.pem -text -noout,假设私钥文件完好,OpenSSL 会输出 modulus ( n ),publicExponent ( e ),privateExponent ( d ),prime1 ( p ),prime2 ( q ),exponent1 ( d(modp1) ),exponent2 ( d(modq1) ),coefficient ( q1(modp) )

后三个参数用于加速解密。有关损坏的 .pem 文件,可以参考 https://blog.csdn.net/mxiaomi/article/details/51672547?sharetype = blog&shareId = 51672547&sharerefer = APP&sharesource = m0_73106878&sharefrom = link 提取部分参数,这属于数据泄露的问题。

公钥文件

执行 openssl rsa -pubin -in public.pem -text -noout,OpenSSL 会输出公钥文件包含的 (n,e) 。如:

1
2
3
4
5
6
7
8
Public-Key: (512 bit)
Modulus:
00:db:14:6d:70:bb:03:de:1f:ac:bc:10:e8:2a:59:
ef:31:4a:3c:9f:13:ad:74:a5:a4:fc:bc:f1:72:42:
ad:05:eb:78:1b:c8:f1:23:08:00:ba:22:97:11:16:
e8:10:84:6e:02:18:fa:07:bd:af:63:09:85:89:3a:
6c:27:d2:45:cd
Exponent: 65537 (0x10001)

剔除冒号和换行:

1
2
3
4
5
6
7
8
9
10
11
raw = """
00:db:14:6d:70:bb:03:de:1f:ac:bc:10:e8:2a:59:
ef:31:4a:3c:9f:13:ad:74:a5:a4:fc:bc:f1:72:42:
ad:05:eb:78:1b:c8:f1:23:08:00:ba:22:97:11:16:
e8:10:84:6e:02:18:fa:07:bd:af:63:09:85:89:3a:
6c:27:d2:45:cd
"""
n = raw.replace("\n", "").replace(":", "")
print(int(n, 16))

# 11474139889515860937094439001627058287359193788837184470918095463493180583872828620539844407240396269998313764108592663301050312411598240277126521860277709

攻击思路

正常解密

已知 p,q,e,c ,最普通的情况:

1
2
3
4
5
6
7
8
9
from Crypto.Util.number import *
p =
q =
e = 65537
c =
n = p*q
d = pow(e, -1, (p-1)*(q-1))
m = pow(c, d, n)
print(long_to_bytes(m))

(矩阵的)快速幂算法

顺便说一下快速幂算法。对于 pow(c, d, n),如果我们先去算 cd ,再模 n ,那么计算机在第一步就数据溢出了。快速幂算法是高效且必要的,具体逻辑:

根据模运算的性质, a×b(modm)=[a(modm)×b(modm)](modm) 。也就是说,模运算可以动态参与乘法运算。因此,先了解基本快速幂。

为了更好地理解,我们可以把指数用二进制的眼光看待。就比如 211 ,把指数写成二进制是 21011 ,也即 282221 。我们创建一个变量 res 用来记录结果,并初始化其为 1 。二进制的权值为 2 ,也就是每进一位(exp // 2),这一位对应的幂相较于前一位的幂是翻倍的(base = base * base),且增长与指数位无关。因为就算该位(exp & 1)的幂不能被 res 记录(二进制位为 0 ),也要适配下一位的权值。在这个基础上每一步都加上模运算,就得到了快速模取幂算法:

1
2
3
4
5
6
7
8
9
10
def fast_pow(base, exp, mod):
res = 1
# 防止底数太大
base = base % mod
while exp > 0:
if exp & 1 == 1:
res = (res * base) % mod
base = (base * base) % mod
exp = exp // 2
return res

还有矩阵版的快速幂,它和实数版差不多,常用于计算递推数列的指定项,可把时间复杂度压缩到 O(logn) 。以斐波那契数列 Fn=Fn1+Fn2 为例,它可以写成矩阵形式:

(Fn+1Fn)=(1110)(FnFn1)

消项,得到矩阵版的通项公式:

(FnFn1)=(1110)n1(F1F0)

核心思想是一致的,只不过带模版的矩阵乘法需要自己写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def matrix_multiply_mod(A, B, mod):
C = [[0, 0], [0, 0]]
for i in range(2):
for j in range(2):
for k in range(2):
C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % mod
return C

def matrix_fast_mod(base, exp, mod):
# 初始化为单位矩阵,用于存储结果
res = [[1, 0], [0, 1]]
# 接下来几乎和实数版一致
while exp > 0:
if exp & 1 == 1:
# 矩阵乘法
res = matrix_multiply_mod(res, base, mod)
# 更新 base,适配权重
base = matrix_multiply_mod(base, base, mod)
exp = exp // 2
return res

# 求斐波那契数列第 n 项模 mod 后的值
def find_val(n, mod):
if n == 1 or n == 2:
return 1
A = [[1, 1], [1, 0]] # 通项公式里的矩阵
# 计算 A 的 n-1 次方
A_final = matrix_fast_mod(A, n-1, mod)
return A_final[0][0]

最终调用 find_val(n, mod),得到斐波那契数列第 n 项模 mod 后的值。

dp 泄露

已知 n,e,dp,c ,写一下数学推导:

dp=d(modp1) ,展开后有 d=dp+k1(p1)

e,d 满足 ed1(modϕ(n)) ,展开代入后有 ed=k2(p1)(q1)+1

d=dp+k1(p1) 代入,整理一下,得到 ek1(p1)+edp=k2(p1)(q1)+1

两边同模 (p1) ek1(p1) k2(p1)(q1) (p1) 的倍数,化为 0 ,得到:

edp1(modp1)

化为普通代数式: edp1=k(p1)

等号左边都是已知的。关于 k 的范围,由于 dp=d(modp1) ,即 dp<p1 ,有以下不等式:

k=edp1p1<edpp1<e(p1)p1=e

k<e ,而 e=65537 ,是可以爆破的。所以有以下脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from Crypto.Util.number import *
n =
e = 65537
dp =
c =
def find_p(n, e, dp):
for k in range(1, e):
if (e*dp - 1) % k == 0:
p = (e*dp - 1) // k + 1
if n % p == 0:
return p
p = find_p(n, e, dp)
q = n // p
d = pow(e, -1, (p-1)*(q-1))
m = pow(c, d, n)
print(long_to_bytes(m))

dp, dq 泄露 & CRT 加速

p,q,dp,dq,c 的情况。没有公钥指数 e ,私钥指数 d 也不易得。这个时候可以先尝试常见的 e 值(如 3、5、17、257、65537 等),不行再去正经写。数学推导:

根据 dp 的定义 dp=d(modp1) ,得到 dp=d+k(p1)

于是 cdpcd+k(p1)cd(cp1)k(modp)

提出 cp1(modp) ,由费马小定理,有 cp11(modp)

代入, cdpcdm(modp) ,同理可证 cdqm(modq)

使用 CRT 合并求解 m 即可。sage 脚本:

1
2
3
4
5
6
7
8
9
10
11
# sage
from Crypto.Util.number import *
p =
q =
dp =
dq =
c =
m1 = pow(c, dp, p)
m2 = pow(c, dq, q)
m = crt([m1, m2], [p, q])
print(long_to_bytes(m))

事实上这就是 CRT 加速 RSA 的原理。

(ex)CRT & (ex)GCD

接下来讲一下普通 CRT。

假设我们有 k 个同余方程,所有模数 mi 两两互质:

xa1(modm1),xa2(modm2),,xak(modmk)

我们的目标是构造出一个 x ,使得其满足以上的所有条件。假设 x=X1+X2++Xk ,那么如果每个 Xi 满足对 mi 的模为 ai ,对除 mi 以外的 m 模为 0,那么就有:

xX1+X2++Xkai(modmi)

接下来如何寻找 Xi 呢?首先要满足 Xi 对除 mi 以外的 m 模为 0。对于这个特性,记 M=i=1nmi ,只需让 Mi=Mmi 成为 Xi 的因数。因为 Mi 是除 mi 外所有 m 的乘积,所以带有 Mi Xi 模上其它 m ,结果一定是 0

因此 Xi 的结构初步定为 CMi C 是未知量。此时看第二个特性,要让 Xi mi 的结果是 ai 。回想模逆元的定义,此时 Xi=CMi 。由于 mi 两两互质,所以由 Mi=Mmi 得到的 Mi 一定与 mi 互质。故一定存在 ti ,满足 tiMi1(modmi) 。要想让等式右边变成 ai ,只需两边同乘 ai 即可。故未知数 C=tiai ,即

Xi=tiaiMi

其中 ti = pow(Mi, -1, mi)。最后对 M 取模,得到:

x=i=1ktiaiMi(modM)

对于 mi 两两不互素的情况,就需要使用 exCRT 来处理。提取出同余方程组的前两个方程

xa1(modm1),xa2(modm2)

为例,改写成普通的代数式:

x=a1+k1m1,x=a2+k2m2

消去 x ,移项得到

m1k1m2k2=a2a1

k 视为未知数,就得到了二元一次方程 Ax+By=C 。其中 A=m1,B=m2,C=a2a1,x=k1.y=k2 。由贝祖定理(我更喜欢它的另一个名字,裴蜀定理),令 d=gcd(A,B) 。如果 d 整除 C ,那么方程 Ax+By=C 整数 解。这里引出第一个判定条件,只有 (a2-a1) % gcd(m1,m2) == 0,方程才有整数解。在开始下一步之前,先讲一下扩展欧几里得算法。

d=gcd(A,B) 通过 exGCD,可以求出满足 Ax+By=d 的一组特解 (x,y)

先由 GCD 求出它们的最小公倍数。 gcd(a,b)=gcd(b,amodb) ,直到后者为 0 时,返回 gcd(x,0)=x 。假设 A=18,B=7 ,演示一下 exGCD 的过程:

  1. 18÷7=24 ,余数 4=187×2 。记 R1=4 R1=A2B
  2. 7÷4=13 ,余数 3=74×1 。记 R2=3 R2=BR1
  3. 4÷3=11 ,余数 1=43×1 。得到 1=R1R2
  4. 上一步余数为 1 ,导致此式的余数一定为 0 。exGCD 从余数为 0 的上一式(即 3 式)开始递归代入。

R2=BR1 代入 1=R1R2 ,得到 1=2R1B 。再将 R1=A2B 代入,得到 1=2A5B 。由此我们便可以得到一组特解 (2,5) 。因此,exGCD 可以用递归思想来写。当 b=0 时,说明等式变为了 ax+0y=a ,此时取 x=1,y=0 ,并且公约数就是 a 。假设递归调用的 exGCD 已经为我们返回了下一级方程

bx+(amodb)y=gcd

的已知特解 (xnext,ynext) ,根据除式规则, amodb=ab(a//b) 。做代换,得到

bxnext+[ab(a//b)]ynext=gcd

拆括号后化简合并,有

a(ynext)+b[xnext(a//b)ynext]=gcd

对比目标等式 ax+by=gcd ,容易发现:

x=ynext,y=xnext(a//b)ynext

于是得到递归返回值。通用代码如下:

1
2
3
4
5
6
7
8
def exGCD(a, b):
if b == 0:
return a, 1, 0
else:
gcd, x_next, y_next = exGCD(b, a % b)
x = y_next
y = x_next - (a//b) * y_next
return gcd, x, y

继续说之前的 exCRT 问题。对于 m1k1m2k2=a2a1 ,我们现在可以求出一组满足 m1u+m2v=d 的特解 (u,v) 。为了把右边凑成 (a2a1) ,左右两边同乘 a2a1d ,即

m1(ua2a1d)+m2(va2a1d)=a2a1

得到 k1 的特解:

k1=ua2a1d

要同时满足两个同余方程,新模数需要是两个原模数的最小公倍数。即 M=lcm(m1,m2) ,这个等式也等于 m1m2d 。由此,我们就完美地把两个同余方程合并成了一个新方程:

x(a1+k1m1)(modM)

设定 anew=a1+k1m1 mnew=M ,不断使用 exGCD,就能合并到只剩一个方程,这就是 exCRT。

共模攻击

两段及以上 相同 明文加密使用了同样的 n 但不同的 e ,得到的密文可以被轻易破解,要求 gcd(e1,e2)=1

由裴蜀定理,存在整数 s1,s2 ,使 s1e1+s2e2=1 。考虑这样一个特殊的式子:

c1s1c2s2(modn)

代入 RSA 加密式 c=me(modn)

c1s1c2s2=me1s1me2s2=me1s1+e2s2=m1=m(modn)

所以那个特殊的式子就等于 m 。但是 s1,s2 必然一正一负,假设 s2<0 ,那么 c2s2=(c21)s2(modn)

c21 就是 c2 关于 n 的模逆元,c2_inverse = pow(c2, -1, n)。最终得到 m=c1s1(c21)s2(modn) 。听起来有点反直觉的是,如果连 e 也相同,攻击者反而什么也解不出来了,因为得到的是多个完全相同的方程,退化成了最基本的 RSA。

脚本:

1
2
3
4
5
6
7
8
9
10
11
n = 
e1 =
e2 =
c1 =
c2 =
# 调用之前的 exGCD
gcd, s1, s2 = exGCD(e1, e2)
if s1 < 0:
s1, s2 = s2, s1
c2_inverse = pow(c2, -1, n)
m = (pow(c1, s1, n) * pow(c2_inverse, -s2, n)) % n

低加密指数广播攻击

和共模攻击很像,这里是 相同 明文使用同样的 e 但不同的 n ,且公钥指数非常小。

正式的 RSA 加密要求 m<n ,否则在 k 不定的情况下,几乎 无法由 m=m0+kn 恢复原始信息。

假设 e=3 ,攻击者截获了三份密文 c 和模数 n ,就有:

c1m3(modn1),c2m3(modn2),c3m3(modn3)

能够得到特解 X 满足 X=m3(modn1n2n3) ,最终得到 m=X3 。脚本:

1
2
3
4
5
6
7
8
9
10
11
# sage
e = 3
c1 =
c2 =
c3 =
n1 =
n2 =
n3 =
x = crt([c1, n1], [c2, n2], [c3, n3]) % (n1*n2*n3)
# 这里不要直接用 pow 函数开方
m = x.nth_root(3)

为什么叫 低加密指数攻击?因为当 e=65537 时,要想获取完整信息,就要使 m65537<nk ,其中 k 是截获消息的条数。此时就只能寄希望于有一组 n 共享了大素数,即 共模质数攻击,但实际上概率也极低。

多项式的 CRT

假设上述被加密的不再是明文 m ,而是明文的线性组合 Mi=aim+bi ,那么我们有以下的同余方程:

(a1m+b1)3c10(modn1)(a2m+b2)3c20(modn2)(a3m+b3)3c30(modn3)

由于各 Mi 不相同了,所以不能简单地 CRT 后线性还原。

展开,等号左边的多项式可以化作标准形式 Am3+Bm2+Cm+D 的形式。化作首一多项式,把未知量替换为 x ,就有

f1(x)=x3+A1x2+B1x+C1(modn1)f2(x)=x3+A2x2+B2x+C2(modn2)f3(x)=x3+A3x2+B3x+C3(modn3)

x2 的全局系数 A AA1(modn1) , AA2(modn2) , AA3(modn3)

x 的全局系数 B BB1(modn1) , BB2(modn2) , BB3(modn3)

解常数项的全局系数 C CC1(modn1) , CC2(modn2) , CC3(modn3)

把求出的 A,B,C 重新拼装,就得到了在全局模数 N=n1n2n3 下的新多项式 F(x)=x3+Ax2+Bx+C0(modN)

由 Coppersmith 定理,能找到的小根上界 XN1/3n ,从而可以还原 m 。参考脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# sage
from Crypto.Util.number import *

n = []
a = []
b = []
c = []
e = 3
assert len(a + b + c + n) == 12

R1.<x> = PolynomialRing(Zmod(n[0]))
R2.<x> = PolynomialRing(Zmod(n[1]))
R3.<x> = PolynomialRing(Zmod(n[2]))

f1 = ((a[0]*R1.gen() + b[0])^e - c[0]).monic()
f2 = ((a[1]*R2.gen() + b[1])^e - c[1]).monic()
f3 = ((a[2]*R3.gen() + b[2])^e - c[2]).monic()

coeffs = []
# 合并 x^0, x^1, x^2, x^3 的系数
for i in range(e + 1):
coeff = crt([int(f1[i]), int(f2[i]), int(f3[i])], [n1, n2, n3])
coeffs.append(coeff)

prod = lambda ls: 1 if not ls else ls[0] * prod(ls[1:])
R.<x> = PolynomialRing(Zmod(prod(n)))
f = sum(x^i*coeff for i, coeff in enumerate(coeffs))
roots = f.small_roots()

if roots:
print(long_to_bytes(int(roots[0])))

Franklin-Reiter 相关消息攻击

当两个明文之间存在已知的线性关系,并且被相同的公钥 (n,e) 加密时,攻击者就能直接还原出明文。

举个例子。假如 m1 = b"0123456789abcd",有 14 字节。为了填充到 16 字节,使用 pad() 函数之后,m2 = b"0123456789abcd\x02\x02"。那么此时二者之间就具有线性关系:

m2=216×m1+(28×2+20×2)(modn)

虽然这个攻击理论上对任何 e 都成立,但算法的复杂度为 O(elog2n) 。因此在实战中,通常针对较小的 e 。对于有线性关系的明文 m2=a×m1+b ,我们先在未知数 x 上构造两个定义在环 ZN[x] 上的多项式:

f1(x)=xec1(modn) f2(x)=(ax+b)ec2(modn)

易知 x=m1 是两个多项式的一个公共根,那么 (xm1) 必定是 f1(x),f2(x) 的公因式,也即 gcd(f1(x),f2(x))=xm1(modn) ,求出这个最大公因式就能得到明文 m1

多项式的 GCD

思想基本和普通 GCD 一致,只不过在多项式环上进行:

1
2
3
4
5
def poly_gcd(f1, f2):
while f2:
f2 = f2.monic()
f1, f2 = f2, f1 % f2
return f1.monic()

Franklin-Reiter 相关消息攻击的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# sage
from Crypto.Util.number import *

n =
e =
a =
b =
c1 =
c2 =

R.<x> = PolynomialRing(Zmod(n))
f1 = x^e - c1
f2 = (a*x + b)^e - c2

def poly_gcd(f1, f2):
while f2:
f2 = f2.monic()
f1, f2 = f2, f1 % f2
return f1.monic()

g = poly_gcd(f1, f2)
if g.degree() == 1:
# m1 就是(x - m1)的常数项取负
m1 = int(-g[0])
m2 = (a*m1 + b) % n
print(f"m1 = {long_to_bytes(m1)}")
print(f"m2 = {long_to_bytes(m2)}")

维纳攻击

维纳攻击的主要利用点是私钥指数 d 过小。这个攻击成功的前提条件是:

  1. p<q<2p ,即 p,q 大小相近,这一点是默认满足的。
  2. 私钥 d 满足 d<13N1/4

前面提到当 p,q 差距过小时,费马分解法能直接分解 n ,具体的范围是 |pq|n1/4 。在 RSA-2048 中, p,q 的大小又都是 1024 位,看起来有点反直觉,好像费马分解法的应用范围很广。事实上,对于 p,q ,取值区间的大小是 21023 ,而危险区是 2512 ,落入危险区的概率是 251221023=12511 ,非常小,在标准 RSA 中可以认为是不可能的。

说回维纳攻击。公钥 e 和私钥 d 的关系是 ed1(modϕ(n)) 。化作标准代数式并且两边同除以 dϕ(n) ,就可以得到:

eϕ(n)=kd+1dϕ(n)

ϕ(n)n ,于是 enkd1dϕ(n) ,且等号右边极小。这里引入勒让德定理:如果一个已知数 x 与一个未知分数 ab 间的距离 |xab| 满足 |xab|<12b2 ,那么 ab 必定是 x 的连分数展开中的一个渐进分数。Wiener 发现当私钥 d<13N1/4 时, en kd 满足勒让德定理。

推导:

|enkd|=|edknnd|

ed=1+kϕ(n) ,有

|edknnd|=|1+kϕ(n)knnd|=|1k(nϕ(n))nd|

ϕ(n)=pqpq+1 展开,绝对值内变号,得到

|k(p+q1)1nd|<|k(p+q1)nd|

根据维纳攻击的适用条件 p<q<2p ,有 p+q1<3p1<3p<3n

带入这个放缩,同时也不要忘了原始式子。得到

|enkd|<3knnd=3nkd

RSA 公钥指数 e 的基本要求就是 1<e<ϕ(n) ,且 gcd(e,ϕ(n))=1 。于是

k=ed1ϕ(n)<edϕ(n)<ϕ(n)dϕ(n)=d

k<d kd<1 。于是 3nkd<3n 。这样,我们就把式子放缩到了极致:

|enkd|<3n

代入勒让德定理的要求:

3n<12d2

得到 d 能被攻击的范围是 d<16n1/4 (好像不是很准?)

连分数展开

实现连分数展开,通常指把一个实数 x 表示成以下形式:

x=a0+1a1+1a2+1a3+

它是欧几里得算法的变体。对于任何实数 x

  1. ai=x ,即 x 的整数部分。
  2. f=xai x 的小数部分。
  3. 如果 f0 (即此时 x 不是整数),则令 x=1/f ,回到第一步,直到 f=0

对于 kd 这样的有理数,展开是有限的。使用上面的算法,得到的是一个连分数序列 [a0,a1,a2,,ai] 。想通过这样的序列还原渐进分数 NiDi ,有以下递推规则:

  1. 第 0 项:分子 N0=a0 ,分母 D0=1
  2. 第 1 项:分子 N1=a0a1+1 ,分母 D1=a1
  3. 第 2 项及以后:分子 Ni=aiNi1+Ni2 ,分母 Di=aiDi1+Di2

由于寻找 d 的过程需要遍历,使用之前提到的快速幂也没有必要了,迭代就可以。需要注意的是,开头提到的标准展开方法受限于浮点数误差,需要改用辗转相除法来避免偏移。下面是脚本:

1
2
3
4
5
6
7
8
9
10
11
def continued_fraction(a, b):
res = []
while b != 0:
# 求商和余数
quotient = a // b
reminder = a % b
res.append(quotient)
# 辗转相除法的换位
a = b
b = reminder
return res

在得到连分数序列后,要想方便地得到各渐进分数,可以假设出 i=1,2 两项。这样对于任意 i0 ,都有上面的递推方程。这两项辅助项的值分别是

N1=1,N2=0D1=0,D2=1

再根据递推方程写脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def traverse_list(ls):
# 初始定义两个辅助项
n_last1, n_last2 = 1, 0
d_last1, d_last2 = 0, 1
res = []
for a in ls:
# 按递推公式计算
n = a * n_last1 + n_last2
d = a * d_last1 + d_last2
res.append((n, d))
# 迭代
n_last2 = n_last1
n_last1 = n
d_last2 = d_last1
d_last1 = d
return res

初代的脚本没有检验步骤。对于得到的每一组 (k,d) ,都要经过以下检验步骤:

  1. 检测分子是否为 0,也即是否是第一层展开。是的话就筛除。
  2. ed=1+kϕ(n) ,有 ϕ(n)=ed1k ,这个值必须是整数。
  3. ϕguess(n) 为整数的前提下,对于质数 p,q ,有方程 pq=n,p+q=nϕ(n)+1 。根据韦达定理, p,q 必定是方程 x2(p+q)x+pq=0 的两个根,也即 x2(nϕ(n)+1)x+n=0 的根。计算方程的判别式 Δ=b24ac ,验证 Δ 是否为完全平方数。若是,则解出 p,q ,得到结果。

由上面的分析,得到完整的维纳攻击脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import math

e =
n =

def wiener_attack(e, n):
k_last1, k_last2 = 1, 0
d_last1, d_last2 = 0, 1
r0, r1 = e, n
while r1 != 0:
q = r0 // r1
r0, r1 = r1, r0 % r1
k = q * k_last1 + k_last2
d = q * d_last1 + d_last2
k_last2, k_last1 = k_last1, k
d_last2, d_last1 = d_last1, d
if k == 0:
continue
if (e*d - 1) % k != 0:
continue
phi = (e*d - 1) // k
a = 1
b = n - phi + 1
c = n
delta = b*b - 4*a*c
if delta >= 0:
s = math.isqrt(delta)
if s*s == delta:
if (b + s) % 2 == 0:
p = (b + s) // 2
q = (b - s) // 2
if p * q == n:
return d, p, q
return None, None, None

res = wiener_attack(e, n)
if res[0] is not None:
d, p, q = res
print(f"p = {p}")
print(f"q = {q}")
else:
print("没有找到解")

当公钥指数 e 很大时, d 就很小。这时候可以试试维纳攻击能不能生效。

Boneh-Durfee 攻击

维纳攻击还有一个使用格密码的版本,称为 Boneh-Durfee 攻击。当加密者使用同一模数 n ,不同公钥指数 ei 加密时,若 d 落入攻击范围,则可以通过 Boneh-Durfee 攻击直接得到 p,q 。其中 d 的受攻击范围随着 ei 的数目上升。当只有一个低加密指数时, d<n0.292 。加密指数的数目提升到两个和三个时,指数位提升至 5/14(0.357) 2/5(0.4) 。理论上当加密指数的数量足够多,攻击范围可以提升到 d<n

这种攻击依赖于 coppersmith 算法,脚本放在 coppersmith 那一篇 blog 里了。