CTF BUUOJ rsa2 攻防战:Wiener 攻击与 Python 2 的“坑”

题目描述

题目提供了 RSA 的公钥参数 NNNeee,并给出了生成 Flag 的代码片段:

N = 101991809777553253470276751399264740131157682329252673501792154507006158434432009141995367241962525705950046253400188884658262496534706438791515071885860897552736656899566915731297225817250639873643376310103992170646906557242832893914902053581087502512787303322747780420210884852166586717636559058152544979471
e = 46731919563265721307105180410302518676676135509737992912625092976849075262192092549323082367518264378630543338219025744820916471913696072050291990620486581719410354385121760761374229374847695148230596005409978383369740305816082770283909611956355972181848077519920922059268376958811713365106925235218265173085
import hashlib
flag = "flag{" + hashlib.md5(hex(d)).hexdigest() + "}"

题目提示:“听说这题是rsa的续集”。

解题思路

1. 分析参数特征

首先观察题目给出的 NNNeee

  • NNN 是一个 1024 位的大整数。
  • eee 是一个非常大的整数,接近 NNN 的数量级。
    在 RSA 算法中,通常 eee 会选择较小的值(如 65537)。当 eee 非常大时,根据关系式 ed≡1(modϕ(N))ed \equiv 1 \pmod{\phi(N)}ed1(modϕ(N)),对应的私钥 ddd 往往会非常小。
    d<13N14d < \frac{1}{3} N^{\frac{1}{4}}d<31N41 时,RSA 系统存在 Wiener 攻击 漏洞。我们可以利用连分数逼近的方法来求解 ddd

2. Wiener 攻击原理

Wiener 攻击的核心思想是利用连分数展开。因为 ed−1=kϕ(N)ed - 1 = k\phi(N)ed1=kϕ(N),所以 eϕ(N)≈kd\frac{e}{\phi(N)} \approx \frac{k}{d}ϕ(N)edk。又因为 ϕ(N)≈N\phi(N) \approx Nϕ(N)N,所以 eN\frac{e}{N}Ne 的连分数展开中,某一项的收敛分数 kd\frac{k}{d}dk 很可能就是我们要求的 kkkddd
算法步骤如下:

  1. 计算 eN\frac{e}{N}Ne 的连分数展开。
  2. 依次计算各个渐进分数 kidi\frac{k_i}{d_i}diki
  3. 对于每一组 (ki,di)(k_i, d_i)(ki,di),尝试计算 ϕ(N)=edi−1ki\phi(N) = \frac{e d_i - 1}{k_i}ϕ(N)=kiedi1
  4. 如果得到的 ϕ(N)\phi(N)ϕ(N) 合理,则可以分解 NNN 得到 pppqqq,从而验证 ddd 的正确性。

3. 获取私钥 d

使用 Python 进行 Wiener 攻击求解 ddd

def isqrt(n):
    if n == 0: return 0
    x, y = n, (n + 1) // 2
    while y < x: x, y = y, (y + n // y) // 2
    return x
def continued_fractions(e, n):
    # 生成 e/n 的连分数项
    cf = []
    while n:
        cf.append(e // n)
        e, n = n, e % n
    return cf
def convergents(cf):
    # 生成分子和分母(即 k 和 d)
    n0, d0 = 0, 1
    n1, d1 = 1, 0
    for q in cf:
        n2 = q * n1 + n0
        d2 = q * d1 + d0
        yield n2, d2
        n0, d0 = n1, d1
        n1, d1 = n2, d2
def wiener_attack(e, n):
    cf = continued_fractions(e, n)
    for k, d in convergents(cf):
        if k == 0: continue
        
        # ed - 1 必须整除 k
        if (e * d - 1) % k != 0: continue
        
        phi = (e * d - 1) // k
        # p + q = n - phi + 1
        s = n - phi + 1
        # 判别式 p - q = sqrt(s^2 - 4n)
        discriminant = s * s - 4 * n
        if discriminant < 0: continue
        
        sqrt_disc = isqrt(discriminant)
        if sqrt_disc * sqrt_disc == discriminant:
            return d
    return None
d = wiener_attack(e, N)
print(f"找到私钥 d = {d}")
# 运行结果:d = 8920758995414587152829426558580025657357328745839747693739591820283538307445

4. 构造 Flag 的陷阱(关键步骤)

题目要求 Flag 的构造方式为:
flag = "flag{" + hashlib.md5(hex(d)).hexdigest() + "}"
拿到 ddd 后,第一反应是直接用 Python 3 跑一遍 hex(d) 并计算 MD5:

# Python 3 环境
import hashlib
d = 8920758995414587152829426558580025657357328745839747693739591820283538307445
hex_d = hex(d) 
# hex_d = '0x13b8f87d588e2aa4a27296cf2898f56ab4c8deb5a1222ec080e23afecaf7f975'
flag = "flag{" + hashlib.md5(hex_d.encode()).hexdigest() + "}"
# 结果:flag{8159e6c4abdd3b94ce461ed9a1a24017}

然而,提交后发现答案错误!
这里隐藏着一个经典 CTF 陷阱:Python 版本差异
题目来源于较早的 CTF 比赛或环境,当时普遍使用 Python 2
在 Python 2 中,长整型的 hex() 输出会在末尾加上一个 L 后缀:

# Python 2 环境
d = 8920758995414587152829426558580025657357328745839747693739591820283538307445L
print hex(d)
# 输出:0x13b8f87d588e2aa4a27296cf2898f56ab4c8deb5a1222ec080e23afecaf7f975L

注意到末尾的 L 了吗?这就是导致 MD5 值完全不同的原因。题目源码 hashlib.md5(hex(d)) 在 Python 2 下计算的是包含 L 的字符串的哈希值。

5. 最终求解

我们在 Python 3 中手动加上 L 来模拟 Python 2 的行为:

import hashlib
d = 8920758995414587152829426558580025657357328745839747693739591820283538307445
# 模拟 Python 2 的 hex(d) 输出,末尾带 'L'
hex_d_py2 = hex(d) + 'L' 
# 字符串内容:'0x13b8f87d588e2aa4a27296cf2898f56ab4c8deb5a1222ec080e23afecaf7f975L'
md5_hash = hashlib.md5(hex_d_py2.encode()).hexdigest()
flag = "flag{" + md5_hash + "}"
print(f"Hex String: {hex_d_py2}")
print(f"MD5 Hash: {md5_hash}")
print(f"Flag: {flag}")

输出结果:

Hex String: 0x13b8f87d588e2aa4a27296cf2898f56ab4c8deb5a1222ec080e23afecaf7f975L
MD5 Hash: 47bf28da384590448e0b0d23909a25a4
Flag: flag{47bf28da384590448e0b0d23909a25a4}

Flag

flag{47bf28da384590448e0b0d23909a25a4}

完整代码

import hashlib

def isqrt(n):
    """整数平方根(牛顿迭代法),确保返回整数"""
    if n == 0:
        return 0
    x, y = n, (n + 1) // 2
    while y < x:
        x, y = y, (y + n // y) // 2
    return x

def continued_fractions(e, n):
    """生成 e/n 的连分数项"""
    cf = []
    while n:
        cf.append(e // n)
        e, n = n, e % n
    return cf

def convergents(cf):
    """生成连分数的渐进分数(分子k,分母d)"""
    n0, d0 = 0, 1  # 初始值 h_-1=0, k_-1=1
    n1, d1 = 1, 0  # 初始值 h_0=1, k_0=0
    for q in cf:
        n2 = q * n1 + n0
        d2 = q * d1 + d0
        yield n2, d2
        # 更新迭代值
        n0, d0 = n1, d1
        n1, d1 = n2, d2

def wiener_attack(e, n):
    """Wiener攻击求解私钥d"""
    cf = continued_fractions(e, n)
    for k, d in convergents(cf):
        if k == 0:
            continue
        
        # 验证 ed - 1 能被k整除(核心条件)
        if (e * d - 1) % k != 0:
            continue
        
        # 计算phi(N)
        phi = (e * d - 1) // k
        # 计算p+q = n - phi + 1
        s = n - phi + 1
        # 判别式:(p-q)^2 = s^2 - 4n
        discriminant = s * s - 4 * n
        if discriminant < 0:
            continue
        
        # 验证判别式是完全平方数
        sqrt_disc = isqrt(discriminant)
        if sqrt_disc * sqrt_disc == discriminant:
            return d
    return None

# ===================== 题目给定的公钥参数 =====================
N = 101991809777553253470276751399264740131157682329252673501792154507006158434432009141995367241962525705950046253400188884658262496534706438791515071885860897552736656899566915731297225817250639873643376310103992170646906557242832893914902053581087502512787303322747780420210884852166586717636559058152544979471
e = 46731919563265721307105180410302518676676135509737992912625092976849075262192092549323082367518264378630543338219025744820916471913696072050291990620486581719410354385121760761374229374847695148230596005409978383369740305816082770283909611956355972181848077519920922059268376958811713365106925235218265173085

# ===================== 第一步:Wiener攻击求解d =====================
print("开始执行Wiener攻击...")
d = wiener_attack(e, N)
if d is None:
    print("Wiener攻击失败,未找到私钥d")
else:
    print(f"找到私钥 d = {d}")

    # ===================== 第二步:模拟Python2生成正确Flag =====================
    # Python2中hex(d)会在末尾带L,需手动拼接
    hex_d_py2 = hex(d) + 'L'
    # 计算MD5哈希(注意encode()转换为bytes)
    md5_hash = hashlib.md5(hex_d_py2.encode()).hexdigest()
    # 构造最终Flag
    flag = f"flag{{{md5_hash}}}"

    # 输出关键信息
    print(f"\nPython2风格的hex(d):{hex_d_py2}")
    print(f"MD5哈希值:{md5_hash}")
    print(f"\n最终Flag:{flag}")

运行结果

开始执行Wiener攻击...
找到私钥 d = 8920758995414587152829426558580025657357328745839747693739591820283538307445

Python2风格的hex(d):0x13b8f87d588e2aa4a27296cf2898f56ab4c8deb5a1222ec080e23afecaf7f975L
MD5哈希值:47bf28da384590448e0b0d23909a25a4

最终Flag:flag{47bf28da384590448e0b0d23909a25a4}

总结

这道题虽然考察的是经典的 Wiener 攻击,但难点在于最后一步的环境差异。CTF 中的旧题目往往带有 Python 2 的痕迹,遇到 hex()print 语句或字符串编码问题时,转换一下思路,考虑一下 Python 2 的特性,往往能找到突破口。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐