Allureluoli 二月FEBRUARY

2026-10-31-NESTCTF-permeate

资料说明:原记录中提及的本机脚本及题目附件未随本文附上,相关文件名仅用于说明当时的复现流程。

Premaster(permeate)· 一个“填充对不对”的回答就够了

平台题目名:Premaster(Crypto,743 分,11:19:02 提交)。内部代号 permeate,目标 m3bk608v:58999。

1
flag{ttElEB3NmncnSmw66vkv9vWRXhaqa8DVJjn7Hj5G}

目标:m3bk608v.nestctf.cyberpeace.cn:58999(TLS)
附件:pubkey.json(RSA n / e)
复现:py -3.13 attack.py → 落盘 plaintext.txt

一句话

服务端不告诉你明文,只回答一个比特:你给的密文解出来是不是合法的 PKCS#1 v1.5 填充。这一个比特就是 Bleichenbacher 预言机(1998),够把任意密文逐字节还原 —— 这里要还原的是会话开始时发下来的 c0。

0. 面

TLS 连上去,服务端先吐一行 banner:

1
<tag> <c0-hex> <s0-decimal>

c0 是会话密文(RSA 加密后的 PKCS#1 v1.5 块),s0 是它自带的初始盲化因子 —— 省掉了标准攻击里的“找起始 s”那一步。

之后每次发一行十六进制 c,回一行:

1
2
ok        # c 解出来是 00 02 || 非零填充 || 00 || 消息
fail # 否则

1. 问题结构

RSA 解密一整个块:m = c^d mod n,合法填充形如

1
00 02 [PS: ≥8 字节非零] 00 [message]

也就是 2B ≤ m < 3B(B = 2^(8(k-2)))。服务端只暴露 m ∈ [2B, 3B) 这一个区间判断,其余全黑。经典结论:有了这个判断,就能把 m 的可能范围从一整个模数区间,逐步收缩到单点。

2. 攻击步骤(脚本里的实现)

初始区间集合 M = {(2B, 3B-1)},然后用 banner 给的 s0 做第一次收缩。

之后迭代两类步骤:

  • 多区间时(3a):从 s_prev + 1 起逐个试 s,直到 c0 · s^e mod n 被判 ok;命中即用。
  • 单区间时(3c):由 r = ceil((2(b·s_prev - 2B))/n) 起,对每个 r 算候选
    s = ceil((2B + r·n)/b),批量投喂,取第一个 ok。

每次命中都过一遍区间收紧:

1
2
3
rmin = ceil((a·s - (3B-1)) / n)
rmax = floor((b·s - 2B) / n)
[a', b'] = [max(a, ceil((2B + r·n)/s)), min(b, floor((3B-1 + r·n)/s))]

把所有候选区间排序合并,直到集合里只剩一个点 —— 那就是 m。

3. 工程上真正决定成败的两件事

(1) 批量问,别一问一答。 RSA 一次 2048 位运算在服务端不便宜,一问一答的 RTT 会把总时间拉到不可接受。脚本一次写 256 个 c,再连读 256 行 ok/fail,只取第一个命中:

1
2
3
4
5
6
7
8
9
def batch(self, svals):
buf = ["%x" % ((self.c0 * pow(sv, e, n)) % n) for sv in svals]
self.s.sendall(("\n".join(buf) + "\n").encode())
first = None
for i in range(len(buf)):
r = self.f.readline().strip()
if r == b"ok" and first is None:
first = i
return first

(2) 用 banner 的 s0 少走一轮。 服务端已经把第一个有效 s 给你了,等于白送一次区间收缩。

4. 还原明文

单点解出来后按 00 02 PS 00 msg 剥壳:

1
2
3
4
5
6
em = m.to_bytes(k, "big")
assert em[0] == 0 and em[1] == 2
i = 2
while em[i] != 0:
i += 1
msg = em[i+1:]
1
flag{ttElEB3NmncnSmw66vkv9vWRXhaqa8DVJjn7Hj5G}

5. 踩坑

坑 现象 解决
一问一答 每秒几百次,跑不完 批量 256 个一批,单次 write + 顺序读回
自己找起始 s 从 ceil(n/3B) 起扫,空转 banner 直接给了 s0,用它做第一次收缩
区间不合并 候选指数增长,越跑越慢 每轮排序后合并相邻/重叠区间
只判断 m < 3B 收缩不到单点 必须同时要求 m ≥ 2B(00 02 前缀)
连接被服务端关 ConnectionError: closed 重连后重新取 banner(c0/s0 是新会话的)

6. 防御侧

  • 解密失败时不要区分“填充错”和“结构错”,统一返回同一个错误、同一耗时;
  • 用 RSA-KEM / OAEP 替代 PKCS#1 v1.5 加密;
  • 对同一密文的重复提交做速率限制与会话绑定 —— 这个 oracle 需要几万到几十万次询问,限流能直接掐死。

7. 一句话总结

Bleichenbacher 二十年不过时,因为它的前提太廉价:只要有一个能区分“合法/非法填充”的旁路,RSA 的语义安全性就没了。 这题甚至连“找起点”都替你省了 —— banner 把 c0 和 s0 一起递到手上。

评论COMMENTS