GKLBB

当你经历了暴风雨,你也就成为了暴风雨

导航

软件安全 --- CTF加密 之 RSA

RSA 加密解密最小示例

第一步:选择两个小素数 素1,素2

假设 
素1 = 3 素2 = 11
素数是大于 1 的自然数,除了 1 和它本身之外,没有其他正因数。
实际应用中素1,素2都是 1024 位以上的大素数,这里仅为演示原理而取极小值。

第二步:计算 模数 和 欧拉函数值

模数 = 素1 × 素2 = 3 × 11 = 33            
欧拉函数值 = (素1-1) × (素2-1) = 2 × 10 = 20 
公开的"模数"本质就是要被模的一个数字,模就是找余数的动作 
欧拉函数值本质就是在 1 到 n 中,与 n 互素(最大公约数为 1)的整数有多少个。 必须保密,这是 RSA 安全性的核心

第三步:选择公钥指数 e

e 需要满足:1 < e < φ(n) 且 最大公约数 gcd(e, φ(n)) = 1(互素)

选 e = 3
验证:gcd(3, 20) = 1  ✓ 满足条件

公钥 = (e, n) = (3, 33)

第四步:计算私钥指数 d

d 是 e 关于 φ(n) 的模逆元,满足:

e × d ≡ 1 (mod φ(n))
3 × d ≡ 1 (mod 20)    翻译成人话:我想找一个数 d,让它乘以 3 之后,除以 20 的余数恰好是 1。
3 × d = 20 × (某个整数) + 1

尝试 d = 7:3 × 7 = 21 = 20 + 1,满足条件 ✓

私钥 = (d, n) = (7, 33)

第五步:把字符编码成数字

假设我们用最简单的映射:A=1, B=2, C=3, D=4, ...

要加密的字符是 'D',对应数字 m = 4

⚠️ 这里 m 必须小于 n=33,所以这个玩具级 RSA 只能加密很小的数字(真实场景中 n 有上千位,可以装下任意长度的消息分组)。

第六步:加密

加密公式:

密文 c = m^e mod n

代入数字:

c = 4^3 mod 33
  = 64 mod 33
  = 31

密文 c = 31

发送方只需要公开的 (e, n) = (3, 33) 就能完成加密,不需要知道私钥。

第七步:解密

解密公式:

明文 m = c^d mod n

代入数字:

m = 31^7 mod 33

手算过程(用快速幂简化计算):

第一步: 31^1 mod 33 = 31
第二步: 31^2 mod 33 = 961 mod 33 = 4      (因为 33×29=957, 961-957=4)
第三步: 31^4 mod 33 = 4^2 mod 33 = 16
第四步: 31^7 = 31^4 × 31^2 × 31^1
            = 16 × 4 × 31 mod 33
            = 64 × 31 mod 33
            = 31 × 31 mod 33
            = 961 mod 33
            = 4

解密得到 m = 4

反查编码表:4 → 'D' 

成功还原明文字符!

完整流程图

明文字符 'D'
   ↓ (编码)
  m = 4
   ↓ (用公钥加密: c = m^e mod n = 4^3 mod 33)
  c = 31
   ↓ (网络传输,公开可见)
  c = 31
   ↓ (用私钥解密: m = c^d mod n = 31^7 mod 33)
  m = 4
   ↓ (解码)
明文字符 'D'

核心原理小结

要素数值是否公开
p, q(两个素数) 3, 11 ❌ 保密
n = p×q 33 ✅ 公开
φ(n) 20 ❌ 保密
e(公钥指数) 3 ✅ 公开
d(私钥指数) 7 ❌ 保密

为什么安全?

攻击者即使知道公钥 (e, n) = (3, 33),想求出私钥 d,必须先算出 φ(n),而这需要先分解 n = p × q。

  • 这个玩具例子里 n=33 太小,手算就能分解(3×11)
  • 但当 n 是 2048 位(617 位十进制数)的大数时,没有已知的高效算法能在合理时间内分解它,这就是 RSA 的安全基础。

 

 

 

证明:

命题:设 p、q 是两个不同的素数,φ是欧拉函数:φ(n) 表示 1~n  里和 n 互质的数的个数。 则 φ(pq) = (p−1)(q−1)。

引理

对任意整数 x:

gcd⁡(x, pq)=1  ⟺  p∤x 且 q∤xgcd(x,pq)=1⟺p∤x 且 q∤x

x 和 pq 互质,意思就是:x 不能被 p 整除,同时也不能被 q 整除。    

证明

(⇒) 若 p | x,则 p 是 x 和 pq 的公因子,且 p > 1,所以 gcd(x, pq) ≠ 1。q 同理。

(⇐) 用反证法。假设 p∤x 且 q∤x,但 gcd(x, pq) = g > 1。取 g 的一个素因子 r,则 r | x 且 r | pq。

因为 r 是素数,由欧几里得引理,r | p 或 r | q。又因为 p、q 都是素数,所以 r = p 或 r = q。

于是 p | x 或 q | x,与假设矛盾。

证明一:容斥原理

设 集合S = {1, 2, …, pq},集合元素个数 |S| = pq。定义

  • A = S 中 p 的倍数的集合:p, 2p, …, qp,所以 |A| = q
  • B = S 中 q 的倍数的集合:q, 2q, …, pq,所以 |B| = p

由引理,S 中与 pq 不互素的数恰好构成 A ∪ B。

话句话说就是 A ∪ B就是 p 的倍数的集合和S 中 q 的倍数的集合,他们都是与 pq 不互素的数

计算 A ∩ B:设 x ∈ A ∩ B(这个数 x 既是 p 的倍数,又是 q 的倍数),则 x = pk,且 q | pk。因为 q 是素数且 q ∤ p(p、q 是不同的素数),由欧几里得引理得 q | k,所以 pq | x。

在 1~pq 中,pq 的倍数只有 pq 本身,所以 |A ∩ B| = 1。

由容斥原理:

∣A∪B∣=∣A∣+∣B∣−∣A∩B∣=q+p−1

所以

φ(pq)=pq−(p+q−1)=pq−p−q+1=(p−1)(q−1)

 

 

 

怎么找到 d = 7?(不是乱猜的,有方法)

方法一:暴力枚举(适合小数字,直观理解用)

挨个试 d = 1, 2, 3, ...,看哪个满足条件:

d3×d3×d mod 20是否等于1?
1 3 3 ❌
2 6 6 ❌
3 9 9 ❌
4 12 12 ❌
5 15 15 ❌
6 18 18 ❌
7 21 1 ✅ 找到了!

验证:3 × 7 = 21,21 ÷ 20 = 1余1,所以 21 mod 20 = 1 ✓ 正确!

方法二:扩展欧几里得算法(真实场景中用的方法)

实际 RSA 中 φ(n) 非常大(几百位数字),不可能一个个去试,而是用扩展欧几里得算法直接计算出来。

这个算法的核心思想是:既然 gcd(e, φ(n)) = 1,那么一定存在两个整数 d 和 k,使得:

e × d + φ(n) × k = 1

展开到我们的例子:

3 × d + 20 × k = 1

用扩展欧几里得算法(这里简化演示过程):

20 = 6×3 + 2        → 2 = 20 - 6×3
3  = 1×2 + 1        → 1 = 3 - 1×2
                      = 3 - 1×(20 - 6×3)
                      = 3 - 20 + 6×3
                      = 7×3 - 20

所以:3 × 7 + 20 × (-1) = 1
对照:3 × d + 20 × k = 1

得出:d = 7, k = -1

验证一下:3 × 7 - 20 = 21 - 20 = 1 ✓ 正确!

💻 实际编程中直接调用库函数即可,比如 Python 里是 pow(e, -1, phi_n),或者 sympy.mod_inverse(e, phi_n),电脑瞬间就能算出来,不需要人工推导。

扩展欧几里得算法原理详解

第一部分:普通欧几里得算法(先搞懂怎么求 gcd)

核心思想:辗转相除

欧几里得算法的原理很简单:两个数的最大公约数,等于较小数和两数相除余数的最大公约数。

用数学语言表达:

gcd(a, b) = gcd(b, a mod b)

不断重复这个过程,直到余数为 0,最后一个非零余数就是 gcd。

实际计算过程(求 gcd(20, 3))

第1步:20 = 6×3 + 2     (20 除以 3,商6余2)
第2步:3  = 1×2 + 1     (3 除以 2,商1余1)
第3步:2  = 2×1 + 0     (2 除以 1,商2余0,余数为0,停止)

最后一个非零余数是 1,所以 gcd(20, 3) = 1(验证了它们互素)。

第二部分:为什么要"扩展"?

普通欧几里得算法只能告诉我们 gcd 是多少,但 RSA 需要的是找到满足:

3 × d + 20 × k = 1

的具体数字 d 和 k。这个式子叫做 裴蜀等式(Bézout's Identity),数学上已经证明:只要 gcd(a,b)=1,就一定存在整数 d 和 k 使得 a×d + b×k = 1。

扩展欧几里得算法就是用来反向推导出这两个具体的系数 d 和 k。

第三部分:反向代入的原理(核心部分)

思路:把每一步的余数都表示成"原始两个数的组合"

正向计算(辗转相除):

20 = 6×3 + 2   →   变形:2 = 20 - 6×3        ...(式A)
3  = 1×2 + 1   →   变形:1 = 3 - 1×2         ...(式B)

反向代入(从后往前,把余数替换掉):

目标:把式B中的"2",替换成用 20 和 3 表示的形式(也就是用式A替换)

式B:  1 = 3 - 1×2

把式A 2 = 20 - 6×3 代入进去,替换掉式B里的 "2":

1 = 3 - 1×(20 - 6×3)

展开括号:

1 = 3 - 1×20 + 1×6×3
  = 3 - 20 + 6×3

合并同类项(把两个 "3" 的项加在一起):

1 = (1+6)×3 - 20
1 = 7×3 - 20

对照目标式:

3 × d + 20 × k = 1
7 × 3 + (-1) × 20 = 1

结论:d = 7,k = -1

第四部分:用一个更直观的"搬箱子"比喻理解

想象你在玩一个数字搬运游戏:

  1. 正向过程(辗转相除):你不断用大数减小数的倍数,把大问题拆解成小问题,就像不断把大箱子拆分成小箱子,直到剩下的箱子里东西最少(余数最小,也就是gcd)。

  2. 反向过程(回代):你再把这些拆解过程倒着走一遍,把每一步用过的"小箱子"重新换算回最初的两个"大箱子"(也就是原始的 3 和 20),每次替换都在给 3 和 20 前面积累系数。

走完这个来回的过程,你就能精确知道:要搭配多少个 3,多少个 20(正的或负的),才能刚好凑出 1 这个数字。

第五部分:验证结果

把 d=7, k=-1 代入原式验证:

3 × 7 + 20 × (-1) 
= 21 - 20 
= 1   ✓ 正确!

第六部分:为什么只要 d,不要 k?

回到 RSA 的场景,我们真正需要的只是:

3 × d ≡ 1 (mod 20)

而完整的裴蜀等式是:

3 × d + 20 × k = 1

移项:

3 × d = 1 - 20 × k = 1 + 20 × (-k)

两边同时 mod 20:

3 × d mod 20 = 1   (因为 20×(-k) 这一项正好是 20 的倍数,mod 20 后变成 0)

所以 k 只是计算过程中的一个"副产品",RSA 真正要用的只有 d = 7。

总结:扩展欧几里得算法的本质

普通欧几里得算法扩展欧几里得算法
只求出 gcd(a,b) 的数值 还能求出满足 a×d+b×k=gcd(a,b) 的具体系数 d,k
正向计算(不断取余) 正向计算后,再反向回代
目的:判断两数是否互素 目的:求模逆元(RSA私钥计算的核心工具)

一句话总结:扩展欧几里得算法 = 普通欧几里得算法(正向拆解)+ 反向代入还原系数,这个系数 d 恰好就是我们在 RSA 中要找的私钥指数。

 

裴蜀定理(Bézout's Identity)通俗解释

这个结论有一个正式的数学名字,叫 裴蜀定理(也叫贝祖定理),是数论中一个非常基础但极其重要的定理。

定理的完整表述

裴蜀定理:对于任意两个整数 a 和 b,一定存在整数 x 和 y,使得:

a × x + b × y = gcd(a, b)

特殊情况:当 a 和 b 互素时(即 gcd(a,b)=1),这个式子就变成:

a × x + b × y = 1

这正是我们之前用到的那个等式(把 a 换成 e,b 换成 φ(n),x 换成 d,y 换成 k):

e × d + φ(n) × k = 1

为什么这个定理是对的?直观理解

用"能凑出哪些数字"的角度理解

想象你手里有两种"货币":面值为 a 的硬币和面值为 b 的硬币(硬币可以使用负数个,也就是可以"欠款")。

问题是:用这两种硬币(正着用或者倒着欠),你能凑出哪些数字?

答案:你能凑出的所有数字,恰好就是 gcd(a,b) 的所有倍数,一个不多一个不少。

举例验证

a = 3, b = 20

用 3 和 20 的组合(可以是负数倍),能凑出哪些数?

3×1 + 20×0 = 3
3×0 + 20×1 = 20
3×7 + 20×(-1) = 21-20 = 1
3×(-3) + 20×1 = -9+20 = 11
...

因为 gcd(3,20)=1,所以理论上能凑出任何整数(1的倍数就是所有整数)。特别是能凑出 1 这个最小正数,这正是裴蜀定理保证的。

反例:如果不互素会怎样?

a = 4, b = 20,  gcd(4,20) = 4

用 4 和 20 的组合:
4×1 + 20×0 = 4
4×(-5) + 20×1 = -20+20 = 0
4×3 + 20×(-0.5) ❌ 不行,y必须是整数

无论怎么组合整数倍的 4 和 20,你永远凑不出 1,只能凑出 4 的倍数(4, 8, 12, -4, 0, ...)。

这就是为什么 RSA 要求 gcd(e, φ(n))=1 ——只有互素,才能保证能凑出 "1",也就是能找到满足 e×d ≡ 1 (mod φ(n)) 的那个 d。

另一个直观比喻:钟表齿轮

想象两个啮合的齿轮,齿数分别是 a 和 b:

  • 如果 gcd(a,b)=1(比如齿数为3和20),两个齿轮转动时,会经历"所有可能的相对位置组合"后才会重新对齐回起点。这意味着信息不会丢失,每个状态都唯一对应。

  • 如果 gcd(a,b)=d>1(比如齿数为4和20,gcd=4),齿轮转动会提前重复、进入循环,只能覆盖 1/d 的状态,会丢失信息。

RSA 需要的正是前一种情况——齿数互素,保证了"一一对应、不丢信息",这也是为什么解密能够精确还原加密前内容的根本原因。

这个定理和"扩展欧几里得算法"的关系

裴蜀定理只是从理论上证明了这样的 d 和 k 一定存在,但它没有告诉你具体数值是多少。

扩展欧几里得算法,就是用来具体计算出这组 d 和 k 的算法——也就是我们之前手算的那个"辗转相除再反向代入"的过程。

两者的关系可以这样总结:

 作用
裴蜀定理 从理论上证明满足 e×d + φ(n)×k = 1 的整数 d、k 一定存在(只要互素)
扩展欧几里得算法 提供一个具体可执行的算法,一步步计算出这组 d 和 k 的精确数值

一句话总结

裴蜀定理说的是:只要两个数互素,就一定能找到合适的整数系数,把它们组合"凑"出数字 1 —— 这正是 RSA 中私钥 d 必然存在的数学根基;而扩展欧几里得算法则是把这个"理论上存在"变成"实际上能算出来"的具体工具。

 

 

 

 

 

 

1. Crypto-babe-Lifting(Crypto)

入口/附件:附件/output.txt(pk、cipher、hint)与 附件/task.py;纯附件题。

1.1 分析

n 为 1024 bit,e = 0x1001,泄漏了解密指数 d 的低 400 位。由 e*d ≡ 1 + k*φ(N) (mod 2^400) 且 1 ≤ k ≤ e,可以枚举 k 得到 φ(N) mod 2^400。再由 φ(N) = N - (p+q) + 1 得到 (p+q) mod 2^400,解二次方程即可得到 p mod 2^400。已知 p 的低 400 位后,用 Coppersmith 的 small_roots 分解 N。最后求 d 并解密 cipher。

1.2 解题步骤

  1. 读取参数并确认位数:
python3 - <<'PY'
import re
txt = open('附件/output.txt').read()
n, e = map(int, re.search(r'pk = \((\d+), (\d+)\)', txt).groups())
c = int(re.search(r'cipher = (\d+)', txt).group(1))
d0 = int(re.search(r'hint = (\d+)', txt).group(1))
print('n bits =', n.bit_length(), 'e =', e, 'd0 bits =', d0.bit_length())
PY
  1. 在 Z/2^400Z 上枚举 k,解出 p 的低位候选:
X = var('X')
for k in range(1, int(e) + 1):
    rows = solve_mod([k*X^2 + (e*d0 - (1 + k*(n+1)))*X + k*n == 0], 1 << 400)
    for row in rows:
        p0 = ZZ(row[0])
  1. 对每个 p0 用 Coppersmith 求 p:
PR.<x> = PolynomialRing(Zmod(n))
f = (2^398*x + p0).monic()
roots = f.small_roots(X=2^(512-400+2), beta=0.49)
if roots:
    p = gcd(2^398*int(roots[0]) + p0, n)
  1. 分解 N 后求私钥并解密:
q = n // p
d = inverse_mod(e, (p-1)*(q-1))
m = pow(c, d, n)
print(int(m).to_bytes((int(m).bit_length() + 7) // 8, 'big').decode())
  1. 直接运行完整 Sage 脚本:
sage solve/solve.sage

1.3 完整脚本

import re
import os

BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
txt = open(os.path.join(BASE, "附件", "output.txt")).read()

pk = re.search(r"pk = \((\d+), (\d+)\)", txt)
n = Integer(pk.group(1))
e = Integer(pk.group(2))
c = Integer(re.search(r"cipher = (\d+)", txt).group(1))
d0 = Integer(re.search(r"hint = (\d+)", txt).group(1))

print(f"[*] n bits = {n.nbits()}, e = {e}, d0 bits = {d0.nbits()}")


def recover_p(p0, n):
    """已知 p 的低 400 位,用 Coppersmith 求 p。"""
    PR = PolynomialRing(Zmod(n), 'x')
    x = PR.gen()
    nbits, p0bits = 1024, 400
    f = 2 ** (p0bits - 2) * x + p0
    f = f.monic()
    roots = f.small_roots(X=2 ** (nbits // 2 - p0bits + 2), beta=0.49)
    if roots:
        x0 = roots[0]
        return ZZ(gcd(2 ** (p0bits - 2) * int(x0) + p0, n))
    return None


def find_p(d0, e, n):
    """遍历 k,解出 p 的低位候选并尝试 Coppersmith 分解。"""
    X = var('X')
    for k in range(1, int(e) + 1):
        results = solve_mod([k * X**2 + (e * d0 - (1 + k * (n + 1))) * X + k * n == 0], 1 << 400)
        for x in results:
            p = recover_p(ZZ(x[0]), n)
            if p and p != 1:
                return p
    return None


p = find_p(d0, e, n)
if p is None:
    print("[-] 未能分解 N")
    raise SystemExit(1)

q = n // p
d = inverse_mod(e, (p - 1) * (q - 1))
m = pow(c, d, n)
pt = int(m).to_bytes((int(m).bit_length() + 7) // 8, 'big')
match = re.search(rb"moectf\{[^}]+\}", pt)
print(f"[+] 成功获取 Flag: {match.group(0).decode() if match else pt}")

Flag:moectf{7h3_st4rt_0f_c0pp3rsmith!}(附件题静态 Flag

 

posted on 2026-10-31 21:36  GKLBB  阅读(6)  评论(0)    收藏  举报