2026软件系统安全赛 · 系统攻击 WP
rsa
第一层
第一层给了这些东西:
generate-plaintexts.pyencrypt.pykey-*.pemciphertext-*.bin
这一层表面上是在考秘密共享 + RSA + AES,实际上核心是:
- 识破假的门限共享
- 找弱 RSA 公钥并恢复私钥
- 解出若干份明文 share
- 用 CRT 直接恢复原始消息
第一步
generate-plaintexts.py 说它用了 Asmuth-Bloom 秘密共享,但实际上并不是正规的门限共享。
它真正干的事是:
1 | ki = S % di |
也就是说,每一条 share 本质上只是:
1 | (d_i, S mod d_i, 原始信息位数) |
所以第一层要做的只是:
- 把
RSA/AES拆掉 - 拿到几个明文
share - 对同一个消息做
CRT
换句话说,它并没有真正实现“满足门限才可恢复”的分享机制,而只是把同一个整数 S 对不同模数做了取模而已。
只要我们拿到足够多组:
1 | S ≡ k_1 (mod d_1) |
就可以用中国剩余定理把 S 还原出来。
第二步
encrypt.py 里面分了两种情况:
1. 当 n.bit_length() >= 2048 时:
- 随机生成 32 字节
AES key - 用
RSA-OAEP加密AES key - 再用
AES-GCM加密明文
2. 当 n.bit_length() < 2048 时:
- 随机生成 16 字节
AES key - 直接做
pow(key, e, n)的“裸 RSA” - 再用
AES-GCM加密明文
所以我们只要能把 RSA 私钥还原出来,就能解出 AES key,再解出 plaintext。
这一层的技术路线非常明确:
- 先破 RSA
- 再破 AES-GCM
- 最后还原 share
第三步:找弱公钥
这一层给了 20 个 key-*.pem,但是只有 10 个 ciphertext-*.bin。
先把所有公钥读出来,提取其中的:
ne
然后检查是否有明显弱点。
弱点 1:共享素因子
把所有 n 两两做 gcd:
1 | from math import gcd |
如果两个模数共用了同一个素数 p,那么:
1 | n1 = p * q1 |
此时直接:
1 | p = gcd(n1, n2) |
就能把两个公钥一起分解。
这一题里可以打出来的有:
key-1.pem和key-2.pemkey-4.pem和key-15.pem
弱点 2:Fermat 分解
对于某些 n = p*q,如果 p 和 q 非常接近,那么可以用 Fermat 分解:
1 | from math import isqrt |
原理是:
1 | n = pq = a^2 - b^2 = (a-b)(a+b) |
如果 p 和 q 接近,那么 a = (p+q)/2 离 sqrt(n) 很近,搜索很快就能命中。
这题里我实际打出来的弱 key 包括:
key-3.pemkey-7.pemkey-18.pem
因此,第一层并不是“硬啃全部 20 把 RSA”,而是只要筛出那些被故意做弱的公钥即可。
第四步:用弱 key 去试所有 ciphertext
这里有一个容易踩的坑:
不能假设
ciphertext-i.bin一定对应key-i.pem。
因为出题人没有承诺它们一一对应,所以正确做法是:
- 把所有已经分解出的弱 key 都准备好
- 用每把弱 key 去尝试解每一个
ciphertext-*.bin - 如果最后
AES-GCM验证通过,就说明匹配成功
因为 AES-GCM 自带 tag 校验,所以只要密钥不对,就会直接解密失败。
对于 n.bit_length() >= 2048 的 key,先做 RSA-OAEP 解出 AES key:
1 | priv = rsa.RSAPrivateNumbers( |
对于 n.bit_length() < 2048 的 key,则是裸 RSA:
1 | m = pow(bytes_to_long(header), d, n) |
然后用 AESGCM(aes_key).decrypt(...) 解正文。
我实际跑出来的匹配关系是:
1 | ciphertext-1.bin <-> key-3.pem |
也就是说,我们一共拿到了 5 份明文 share。
第五步:解析 share 并做 CRT
每一份解出来的明文格式大致是:
- 第一行:固定提示语
- 后面 9 行:分别对应
message2 ~ message10
每一行格式是:
1 | <di_hex>:<ki_hex>:<bits_hex> |
也就是:
1 | (d_i, S mod d_i, 原始消息 bit 长度) |
所以对于同一个消息,比如 message7,我们只要从 5 份 plaintext 里都取出第 7 对应的那一行,就能得到 5 个同余方程:
1 | S ≡ k_1 (mod d_1) |
这时直接 CRT:
1 | def crt(remainders, moduli): |
恢复条件是:
1 | 模数乘积 M 的 bit 长度 > 原始消息 bit 长度 |
一旦满足这个条件,CRT 得到的 x 就是那个消息的真实整数表示。
恢复后把 x 转成字节串再解码即可。
第六步:拿到第一层密码
跑完之后会恢复出多条 message,其中关键的一条是:
1 | Congratulations! next pass is 9Zr4M1ThwVCHe4nHnmOcilJ8。 |
这里注意最后有一个中文句号 。,它不是密码的一部分。
所以:
1 | level2.zip 密码 = 9Zr4M1ThwVCHe4nHnmOcilJ8 |
第一层完整解题脚本
1 | from pathlib import Path |
第二层
解开 level2.zip 后可以得到:
task.pylevel3.zip
这一层的关键在于它不是普通 RSA,而是:
1 | d = getPrime(180) |
也就是说:
- 私钥
d很小,只有 180 bit - 但满足的同余关系是:
1 | e*d ≡ 1 (mod λ(n)) |
而不是更常见的:
1 | e*d ≡ 1 (mod φ(n)) |
所以不能直接照抄最普通版本的 Wiener 攻击,而要做一点变形。
第一步:把关系改写成类似 Wiener 的形式
设:
1 | λ(n) = lcm(p-1, q-1) = φ(n) / g |
其中:
1 | g = gcd(p-1, q-1) |
因为:
1 | e*d - k*λ(n) = 1 |
两边同时乘上 g:
1 | e*(d*g) - k*φ(n) = g |
令:
1 | D = d*g |
就得到:
1 | e*D - k*φ(n) = g |
这就很接近 Wiener 的经典形式了。
因此做法是:
- 对
e/n做连分数展开 - 枚举收敛分数
(k, D) - 再枚举一个较小的
g - 计算
1
φ = (eD - g) / k
- 由
φ推p + q = n - φ + 1 - 检查判别式是否是完全平方
如果成立,就成功分解 n。
第二步:连分数枚举
核心代码如下:
1 | def continued_fraction(a, b): |
然后枚举:
1 | cf = continued_fraction(e, n) |
如果:
1 | Δ = s^2 - 4n |
是完全平方,那么:
1 | p = (s + sqrt(Δ)) / 2 |
就能直接恢复出来。
第三步:拿到第二层密码
这题实际会命中一个很小的 g,脚本跑出来是:
1 | g = 4 |
然后恢复出 p + q。
题目里最后写的是:
1 | flag_hash = sha256(str(p + q).encode()).hexdigest() |
所以第二层密码就是:
1 | 2aa9c360df99cbb4209e4dbab5a9f9ffd86d34906e3206fecfdabf0bb7aeb5ac |
即:
1 | level3.zip 密码 = 2aa9c360df99cbb4209e4dbab5a9f9ffd86d34906e3206fecfdabf0bb7aeb5ac |
第二层完整解题脚本
1 | import hashlib |
第三层
解开 level3.zip 后,可以拿到:
ne = 65537cleak
这一层看起来像是一个很复杂的泄露表达式,里面混合了:
- 乘法
- 异或
- 与或操作
- 位移
- 截断
表面上很难直接反推,但关键观察点是:
当我们只看模
2^k的低位时,这个leak的低位只依赖于p和q的低位。
这就意味着可以做 按位提升。
第一步:确定最低位
因为 p 和 q 都是奇素数,所以:
1 | p ≡ 1 (mod 2) |
所以一开始可以把候选初始化为:
1 | cands = [(1, 1)] |
第二步:逐位扩展
假设当前我们已经知道:
1 | p mod 2^k |
那么第 k 位只可能是 0 或 1,因此枚举:
1 | a, b ∈ {0,1} |
构造:
1 | pp = p0 | (a << k) |
然后检查两个条件:
- 它们乘起来的低
k+1位必须和n一致 - 代入泄露函数后的低
k+1位必须和leak一致
对应代码是:
1 | if (pp * qq) % mod != need_n: |
只要同时满足,就说明这一位的猜测是对的,把候选留下来。
然后进入下一位继续扩展。
第三步:为什么这样能行
因为:
- 模
2^(k+1)只关心低k+1位 - 所有这些按位运算、截断、异或,在低位模数下都是局部可验证的
- 所以每往上猜一位,都可以立即用
n和leak的低位把错误分支剪掉
这种方法本质上就是一个低位逐步恢复的搜索过程。
在这题里,剪枝效果非常好,候选几乎不会爆炸,最后能稳定恢复出完整的 p 和 q。
第四步:恢复 RSA 私钥并解密
得到 p 和 q 后,就是普通 RSA 收尾:
1 | phi = (p - 1) * (q - 1) |
最后得到:
1 | dart{379c9308-e9a8-45a1-bd55-45bbd822e86d} |
第三层完整解题脚本
1 | from math import isqrt |
