RSA——熟悉又陌生的朋友
提到RSA,大多数人的第一反应是“经典”。作为早在1977年就被提出的公钥加密算法,RSA一直坚持到现在:时至今日仍然有许多领域采用RSA加密算法(如支付系统、电子门禁等)。
虽然RSA的大名家喻户晓,但是你真正了解RSA吗?如果对目前的RSA算法中稍作改动,使其安全性急剧降低,你能够敏锐地发现漏洞并利用它攻破RSA吗?让我们今天来试试吧!
RSA简介
RSA加密算法基于数论中的大质数和模运算。
生成密钥时,首先选择两个大质数 和 ,计算它们的乘积 。然后,计算 ,这是 的欧拉函数。
接下来,选择一个整数 ,使其与 互质,通常选择小的质数如 65537。然后,计算 使得 。最后得到公钥 和私钥 。
加密时,用公钥将明文 转换为密文 :
解密时,用私钥将密文 转换回明文 :
这样,只有持有私钥的人才能解密信息。
注
这里的大质数非常大,为了保证安全性,一般选择2048位的 和 。
这个数量级大约是 ,要知道可观测宇宙中所有原子的数量只有大约 。
以上给出的 Plain RSA 算法的定义非常简单,也足够保护明文不被只拥有窃听能力的敌手破译。
针对具有更强能力的敌手,有大量针对 Plain RSA 算法的改良,但本质都相同。因此在本篇博客中不介绍各种强大的改良RSA算法,仅通过 Plain RSA 算法了解基本原理即可。并且以下关于RSA的题目使用的也都是 Plain RSA
相关题目
博主在上海交大CTF的在线练习平台——0ops OJ上挑选了9道名为 easyrsa 的题目,想在实战中加深对RSA的理解。以下是对这9道题的回顾和解析,读者有兴趣也欢迎一起思考。
相关信息
CTF(Capture The Flag,夺旗赛)起源于 1996 年 DEFCON 全球黑客大会,是网络安全爱好者之间的竞技游戏。
目前的CTF题目主要依据常见的 Web-网络攻防、RE-逆向工程、Pwn-二进制漏洞利用、Crypto-密码攻击、Mobile-移动安全 以及 Misc-安全杂项 来进行分类。
本篇博客中介绍的 easyrsa 组题属于Crypto。
在以下题目的回顾解析中,博主将统一按原题、思路、解答三个部分顺序书写。其中
- 原题:仅摘录必要的源代码,如输入输出之类的无关代码会省略。
- 思路:或多或少,如果需要额外知识会附加在思路部分。
- 解答:完整的获取flag的解答函数(Python),默认折叠状态。
为了方便,以下内容中若无特别说明,下列字符均表示相同含义(如有下标,则同下标的字符相互对应):
| 字符 | 含义 | 
|---|---|
| 、 | 随机选取的大质数 | 
| 与 互质的随机数 | |
| 满足 的随机数 | |
| 密文 | |
| 明文 | 
easyrsa0
# Known: n、e、c
# Goal: plain
plain = int(FLAG[5:-1])
p = getPrime(24)
q = getPrime(24)
n = p*q
e = 65537
c = pow(plain,e,n)这个RSA算法毫无安全性可言。 当看到这可怜的只有24位的质数 和 的时候,我就知道,直接因式分解 即可解出答案。
def easyrsa0(n, e, c):
    from sympy import factorint, mod_inverse
    # 因式分解 n
    factors = factorint(n)
    p, q = factors.keys()
    # 计算 φ(n)
    phi_n = (p - 1) * (q - 1)
    # 计算 d
    d = mod_inverse(e, phi_n)
    # 解密密文
    plain_text = pow(c, d, n)
    print(f"私钥 d: {d}")
    print(f"明文: {plain_text}")
    byte_array = plain_text.to_bytes((plain_text.bit_length() + 7) // 8, byteorder='big')
    # 将字节串解码为字符串
    ascii_string = byte_array.decode('ascii', errors='ignore')
    print(ascii_string)easyrsa1
# Known: n、e、c
# Goal: plain
plain = bytes_to_long(FLAG)
p = getPrime(64)
q = getPrime(64)
n = p*q
e = 65537
c = pow(plain,e,n)64位?你是在小看当今CPU的运算能力?方法同上,直接秒杀。
解答略,直接调用 easyrsa0 的函数即可
easyrsa2
# Known: n1、n2、e、c1、c2
# Goal: plain
plain = bytes_to_long(FLAG)
p = getPrime(1024)
q1 = getPrime(1024)
q2 = getPrime(1024)
n1 = p*q1
n2 = p*q2
e = 65537
c1 = pow(plain,e,n1)
c2 = pow(plain,e,n2)1024位可不能暴力因式分解了,这难度可是指数级增长,我们必须另想它法。
仔细一看,发现这人竟然用同一个 生成了不同的 ,还用同一个 加密了同一个 。 考虑到 和 已知,且都是质数之积,我们直接找 、 的最大公因数, 不就出来了吗。 一出来,所有问题都迎刃而解了。
def easyrsa2(n1, n2, e, c1, c2):
    import math
    from sympy import mod_inverse
    # 找 n1、n2 的最大公因数,解出 p
    p = math.gcd(n1, n2)
    q1 = n1//p
    # 计算 φ(n)
    phi_n1 = (p - 1) * (q1 - 1)
    # 计算 d
    d1 = mod_inverse(e, phi_n1)
    # 解密密文
    plain_text1 = pow(c1, d1, n1)
    print(f"私钥 d1: {d1}")
    print(f"明文: {plain_text1}")
    byte_array = plain_text1.to_bytes((plain_text1.bit_length() + 7) // 8, byteorder='big')
    # 将字节串解码为字符串
    ascii_string = byte_array.decode('ascii', errors='ignore')
    print(ascii_string)easyrsa3
# Known: n、e、c
# Goal: plain
plain = bytes_to_long(FLAG*2)
p = getPrime(1024)
q = getPrime(1024)
n = p*q
e = 3
c = pow(plain,e,n)好家伙,这下咋办?1024位的大质数挡在面前,也没有用同一质数生成 的漏洞可钻,还有什么奇怪的地方呢……
诶, 是一个很小很小的质数:3——这表示我们可以不用管 、 和 ,直接反过来对 开立方根。 由于最后的 是模 后的结果,所以可以不断加 尝试,直至结果是一个整数,这就解出了 。
def easyrsa3(n, e, c):
    import gmpy2
    plain_text = 0
    # 从 c 开始,逐步增加 n,直到找到明文
    while True:
        plain = gmpy2.iroot(c, e)
        if plain[1]:
            # plain 刚好是 c 的 e 次方根
            print(f"明文:{plain[0]}")
            plain_text = plain[0]
            break
        c += n
    byte_array = plain_text.to_bytes((plain_text.bit_length() + 7) // 8, byteorder='big')
    # 将字节串解码为字符串
    ascii_string = byte_array.decode('ascii', errors='ignore')
    # flag重复了两次,故只输出一半
    print(ascii_string[:len(ascii_string)//2])easyrsa4
# Known: n、e、c
# Goal: plain
plain = bytes_to_long(FLAG)
p = getPrime(1024)
q = gmpy2.next_prime(p+0xdddd)
n = p*q
e = 65537
c = pow(plain,e,n)这次似乎一切正常:位数足够大、只生成了一个 , 也是一个合适大小的质数。 但是 和 不再无关, 只比 大一点点。这有什么用呢?
最直观的想法是,减少了一层循环。 相比于双层循环遍历寻找 和 ,这种情况下只需要一层循环即可:知道了 则可以确定 。
由于 还只比 大一点点,因此两者之积更接近于某个数的平方,而且 一定比这某个数更小。 这启发我们,可以从 的平方根开始,逐渐往下找满足条件的质数 :是质数,且 。
def easyrsa4(n, e, c):
    import gmpy2
    from sympy import mod_inverse
    # 因为 p 和 q 的位数相差不大,所以遍历可能的质数寻找 p 和 q,从 n 的平方根开始
    p_guess = int(gmpy2.isqrt(n))
    while True:
        # 按原题中的方法定位 q
        q_guess = gmpy2.next_prime(p_guess + 0xdddd)
        if n == p_guess * q_guess:
            break
        # 继续遍历下一个更小的质数
        p_guess = gmpy2.prev_prime(p_guess)
    phi_n = (p_guess - 1) * (q_guess - 1)
    d = mod_inverse(e, phi_n)
    plain_text = pow(c, d, n)
    print(f"私钥 d: {d}")
    print(f"明文: {plain_text}")
    byte_array = plain_text.to_bytes((plain_text.bit_length() + 7) // 8, byteorder='big')
    # 将字节串解码为字符串
    ascii_string = byte_array.decode('ascii', errors='ignore')
    print(ascii_string)easyrsa5
# Known: (n, c)*3、e
# Goal: plain
plain = bytes_to_long("\xdd"*128+FLAG)
def encrypt(plain):
    p = getPrime(1024)
    q = getPrime(1024)
    n = p*q
    assert(plain<n)
    e = 3
    c = pow(plain,e,n)
    assert(c!=plain**e)
    return (c,n)
with open("enc.txt","wb") as f:
    for _ in range(3):
        (c,n) = encrypt(plain)
        f.write("n = "+str(n)+"\n")
        f.write("c = "+str(c)+"\n")这次的题目有些不同:总共给了我们3组不同的 数据,分别由不同的 和 生成。
尽管 很小,但是 easyrsa3 中的 比本题的要大得多,遍历的次数自然要少很多。因此本题不能使用类似 easyrsa3 的解法,只能从多组数据着手。
于是,一个定理呼之欲出:中国剩余定理。
相关信息
中国剩余定理(Chinese Remainder Theorem,CRT)是一个关于整数同余的定理,主要用于解决以下类型的问题:给定几个不同模数的同余方程,求一个整数使得它在各个模数下的余数分别等于给定的数。
假设有 个互质的正整数 ,以及对应的整数 ,如果我们有以下同余方程:
那么存在一个唯一的整数 使得:
其中 。
由于我们知道多组 ,且都是同一个 的加密结果,用数学式表达出来就是:
即
据此就可以轻松解出 了。
# group_of_nc: [(c1, n1), (c2, n2), (c3, n3)]
def easyrsa5(group_of_nc, e):
    from sympy.ntheory.modular import solve_congruence
    import gmpy2
    # 利用中国剩余定理解密
    # result: (x, mod)
    result = solve_congruence(*group_of_nc)
    plain_text = gmpy2.iroot(result[0], e)
    # 再次确保开 e 次方根之后是整数
    assert plain_text[1]
    print(f"明文:{plain_text[0]}")
    byte_array = plain_text[0].to_bytes((plain_text[0].bit_length() + 7) // 8, byteorder='big')
    # 将字节串解码为字符串
    ascii_string = byte_array.decode('ascii', errors='ignore')
    print(ascii_string)easyrsa6
# Known: n、e1、e2、c1、c2
# Goal: plain
plain = bytes_to_long(FLAG)
p = getPrime(1024)
q = getPrime(1024)
n = p*q
e1 = 65537
e2 = 54049
c1 = pow(plain,e1,n)
c2 = pow(plain,e2,n)乍一看,又是多组数据,能不能用CRT求解呢?很可惜,不能——我们把这次的条件用数学式描述出来就是:
这明显不符合CRT的形式。那么有什么办法呢?
注意到,上述两式的模数都是 ,那么自然想到对式子进行相加;如果左边是 就好了……有了,扩展欧几里得算法!
相关信息
扩展欧几里得算法(Extended Euclidean Algorithm)是一种用于求解整数线性方程 的方法,其中 和 是给定的整数, 是 和 的最大公因数(GCD)。这个算法不仅可以计算 GCD,还可以找到 和 的值。
我们利用扩展欧几里得算法可以求得 、,使 。而上述两式可以变形为:
两式相加,得到
即
这样就成功解出 了。
def easyrsa6(n, c1, c2, e1, e2):
    # 扩展欧几里得算法
    def extended_gcd(a, b):
        if a == 0:
            return (b, 0, 1)
        else:
            g, x, y = extended_gcd(b % a, a)
            return (g, y - (b // a) * x, x)
    # e1 * d1 + e2 * d2 = 1
    _, d1, d2 = extended_gcd(e1, e2)
    plain_text = pow(c1, d1, n) * pow(c2, d2, n) % n
    print(f"明文:{plain_text}")
    byte_array = plain_text.to_bytes((plain_text.bit_length() + 7) // 8, byteorder='big')
    # 将字节串解码为字符串
    ascii_string = byte_array.decode('ascii', errors='ignore')
    print(ascii_string)easyrsa7
# Known: n、e、d
# Goal: p
p = getPrime(1024)
q = getPrime(1024)
if q < p:
    p, q = q, p
n = p*q
e = 65537
phi = (p-1)*(q-1)
d = gmpy2.invert(e,phi)
flag = hashlib.sha1(long_to_bytes(p)).hexdigest()这次的题目大不相同:没有明文和密文,只要求在已知 、、 的情况下求出 。
仔细一想,似乎没那么难嘛。由于 ,并且 是 的倍数(记为 倍), 而 ,这样就知道了 、 的和与积,解方程即可。
但是实际这么操作之后就会发现完全解不出来——可能是因为性能有限,无法解决如此大数的一元二次方程;况且还需要通过遍历 来找到精确的 ,这样一来时间复杂度会高到完全无法接受。
根据题目本身的限制,有效的思路应该仅有以上一个,那么只能在算法上寻求突破了。好在博主上网查询发现,寻求有效求解该问题的算法确实是一个有价值的问题,而NIST官方也在标准文件中收录了其中一个优秀的解答(详见Recommendation for Pair-Wise Key-Establishment Using Integer Factorization Cryptography附录C),那么我就抄过来实现一下好啦~
def easyrsa7(n, e, d):
    # 用了《Recommendation for Pair-Wise Key-Establishment Using Integer Factorization Cryptography》中的算法
    ########## -------------------------------
    ### 1. Let k = de – 1. If k is odd, then go to Step 4.
    ### 2. Write k as k = 2^t*r, where r is the largest odd integer dividing k, and t ≥ 1. Or in simpler terms, divide k repeatedly by 2 until you reach an odd number.
    ### 3. For i = 1 to 100 do:
    ###     1. Generate a random integer g in the range [0, n−1].
    ###     2. Let y = g^r mod n
    ###     3. If y = 1 or y = n – 1, then go to Step 3.1 (i.e. repeat this loop).
    ###     4. For j = 1 to t – 1 do:
    ###         1. Let x = y^2 mod n
    ###         2. If x = 1, go to (outer) Step 5.
    ###         3. If x = n – 1, go to Step 3.1.
    ###         4. Let y = x.
    ###     5. Let x = y^2 mod n
    ###     6. If x = 1, go to (outer) Step 5.
    ###     7. Continue
    ### 4. Output “prime factors not found” and stop.
    ### 5. Let p = GCD(y – 1, n) and let q = n/p
    ### 6. Output (p, q) as the prime factors.
    ########## -------------------------------
    import random
    import math
    import hashlib
    from Crypto.Util.number import long_to_bytes
    # 1
    k = d * e - 1
    if k % 2 != 0:
        print("prime factors not found")
    # 2
    t, r = 0, k
    while r % 2 == 0:
        r //= 2
        t += 1
    success = False
    continue_flag = False
    # 3
    for i in range(101):
        # 3.1
        g = random.randint(0, n - 1)
        # 3.2
        y = pow(g, r, n)
        # 3.3
        if y == 1 or y == n - 1:
            continue
        # 3.4
        for j in range(1, t):
            # 3.4.1
            x = pow(y, 2, n)
            # 3.4.2
            if x == 1:
                success = True
                break
            # 3.4.3
            if x == n - 1:
                continue_flag = True
                break
            # 3.4.4
            y = x
        # 3.4.2
        if success:
            break
        # 3.4.3
        if continue_flag:
            continue_flag = False
            continue
        # 3.5
        x = pow(y, 2, n)
        # 3.6
        if x == 1:
            success = True
            break
        # 3.7 auto continue
    # 4
    if not success:
        print("prime factors not found")
    # 5
    p = math.gcd(y - 1, n)
    # 6
    print(f"p: {p}")
    # 找到 flag
    flag = hashlib.sha1(long_to_bytes(p)).hexdigest()
    print(f"0ops{{{flag}}}")easyrsa8
# Known: n、e、c
# Goal: plain
p = getPrime(80)
q = getPrime(432)
n = p*q
plain = bytes_to_long(FLAG)
e = 65537
c = pow(plain,e,n)这次没有花里胡哨,回归到正常生成 、,正常生成 、,最后要求找回明文。和 plain RSA 唯一的不同是 和 的大小不够大。
第一反应是利用 easyrsa0 的暴力求解法,但是位数的上升会使因式分解难度指数级上涨:即便64位的 和 能够被暴力分解出来,80位和432位的 和 却让我这台小小的计算机无能为力。必须另寻他法。
就在这时,一个叫做椭圆曲线因式分解法的词映入了我的眼帘。
相关信息
椭圆曲线因式分解法(Elliptic Curve Factorization Method,ECM)是一种用于整数因式分解的算法,特别适合于中等大小的数。该方法利用了椭圆曲线的数学性质,通过寻找特定的椭圆曲线与数之间的关系来找到非平凡因子。
只要能够因式分解,一切将水到渠成。我既不愿自己实现这复杂的算法,也没能在Python库中找到便利执行ECM的函数,于是我选择了在线做ECM的网站。在网站上提前分解大质数,再将得到的 和 抄下来就好了。
def easyrsa8(n, e, c):
    from sympy import mod_inverse
    # 因式分解 n, from: https://www.alpertron.com.ar/ECM.HTM
    p = A_NUMBER # 80位
    q = ANOTHER_NUMBER # 432位
    # 计算 φ(n)
    phi_n = (p - 1) * (q - 1)
    # 计算 d
    d = mod_inverse(e, phi_n)
    # 解密密文
    plain_text = pow(c, d, n)
    print(f"私钥 d: {d}")
    print(f"明文: {plain_text}")
    byte_array = plain_text.to_bytes((plain_text.bit_length() + 7) // 8, byteorder='big')
    # 将字节串解码为字符串
    ascii_string = byte_array.decode('ascii', errors='ignore')
    print(ascii_string)