第二届“湾区杯”网络安全大赛初赛writeup

这里整理了一下第二届”湾区杯”网络安全大赛初赛的部分writeup。

Crypto

Cold Forge

FROST 门限签名的 nonce 高位泄漏攻击:从格攻击到解封发布包

结果:flag{bd9b026e-b58e-416c-bf8f-d815573c35a6}

1. 题目概述

题目给出两个文件:

文件内容
frost_telemetry.json一台门限签名设备的公开 FROST transcript 导出,外加采集探针对每次签名会话有效 nonce 的量化观测值(高位泄漏)
sealed_release.json用 ChaCha20-Poly1305 加密的发布内容

目标:恢复正确的阈值私钥重建出确定性的发布签名,从而解封发布包。

攻击链全貌:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
40 个 FROST transcript(每位签名者 20 个)
│ 每个会话泄漏有效 nonce k 的高 152 位(256 位中仅丢低 104 位)

Hidden Number Problem(HNP)
│ 用 FROST 签名方程 z = k + λ·c·s (mod q) 消去未知 k 的低位

CVP-embedding 格 + LLL 约简

恢复分片 s1、s2
│ 拉格朗日插值 sk = λ1·s1 + λ2·s2 (mod q)

门限私钥 sk(并验证 sk·G == 群公钥)
│ 标准确定性 BIP340(aux_rand = 0x00×32)签名

bip340 签名 → key = SHA256("cold-forge/release/v1" || sig)

ChaCha20-Poly1305 解密 → flag

2. 数据格式解析

2.1 frost_telemetry.json 关键字段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"curve": "secp256k1",
"group_order": "ffffffff...4141", // 曲线阶 n
"threshold": 2, // 门限 t=2
"participants": [1, 2], // 参与签名者
"lagrange_coefficients": { // 对集合 {1,2} 的插值系数
"1": "0000...0002", // λ1 = 2
"2": "ffff...4140" // λ2 = -1 (mod n)
},
"group_public": { "x": "...", "y": "..." }, // 群公钥 PK
"message_hex": "436f6c6420...", // 被签名消息
"leak_model": {
"kind": "msb_quantisation_with_bucket_jitter",
"discarded_low_bits": 104, // 丢弃低 104 位
"maximum_bucket_drift": 1 // 桶抖动最多 1
},
"transcripts": [ ... ] // 40 条,每位签名者 20 条
}

message_hex 解码为明文:Cold Forge release manifest v1: approve threshold recovery

2.2 单条 transcript 结构
1
2
3
4
5
6
7
8
9
10
{
"signer": 1,
"session": "e24f7b73194268b9",
"D": { "x": "...", "y": "..." }, // 第一个 nonce 承诺 D = d·G
"E": { "x": "...", "y": "..." }, // 第二个 nonce 承诺 E = e·G
"rho": "...", // binding factor
"challenge": "...", // FROST challenge c
"z": "...", // 签名者响应 z_i
"nonce_msb": "fdfc1129b384779fbb5a64bedac6fca84aeeb8" // ← 泄漏点
}

关键观测nonce_msb 是 38 个十六进制字符 = 152 bit。曲线阶是 256 位,丢弃低 104 位后恰好剩 256 − 104 = 152 位,与 discarded_low_bits: 104 完全吻合。也就是说,每个会话的有效 nonce 的高 152 位已知,只差低 104 位未知(外加 ±1 个桶的抖动)。

所有 40 条 transcript 经校验:

  • 所有 DE 均在 secp256k1 曲线上;
  • rhochallengez 均在 [0, n) 内。

注意:每条 transcript 是独立会话(D、E 各不相同),且 signer1 与 signer2 的 challenge 无一相同,说明它们不构成同一份 FROST 聚合签名,而是被采集设备分别记录的不同会话——用途正是泄露各自私钥分片。

2.3 sealed_release.json
1
2
3
4
5
6
{
"format": "cold-forge/sealed-release-v1",
"nonce": "UdfpASn/7RhHRhS4", // 12 字节 nonce
"aad": "Y29sZC1mb3JnZS9yZWxlYXNlL3Yx", // = "cold-forge/release/v1"
"ciphertext": "SXEKxWHV+BU0GEDMRCHQV8tmpPkx/...wZViYNKKw1Y9x0TZcX+ODIcA==" // 58 字节
}

ciphertext 58 字节 = 42 字节密文 + 16 字节 Poly1305 MAC tag。AAD 字符串与遥测中 release_kdf 的域前缀 "cold-forge/release/v1" 一致,说明 KDF 与 AEAD 共用同一域分隔符。

3. FROST 门限签名背景

FROST(Flexible Round-Optimized Schnorr Threshold Signatures)是 Schnorr 签名的 (t, n) 门限变体。本题 t = n = 2

对每个参与签名者 i

  1. Round 1:随机生成两个 nonce (d_i, e_i),广播承诺
    1
    D_i = d_i·G ,  E_i = e_i·G
  2. Round 2:计算 binding factor(对全部承诺与消息的哈希)
    1
    rho_i = H(commitments, ...)
    每个签名者的有效 nonce(即其贡献的”签名 nonce”)为
    1
    k_i = d_i + e_i·rho_i   (mod n)
    聚合承诺为 R = Σ_i (D_i + rho_i·E_i)
  3. 计算全局 challenge
    1
    c = H(R || PK || m)
  4. 每个签名者用自己的私钥分片 s_i拉格朗日系数 λ_i 计算响应
    1
    2
    z_i = d_i + e_i·rho_i + λ_i·c·s_i
    = k_i + λ_i·c·s_i (mod n) ← 核心方程
  5. 聚合得到最终签名 (R, z = Σ z_i)

对本攻击最重要的单点事实:把 z_i = k_i + λ_i·c·s_i (mod n) 看成关于分片 s_i 的线性方程。每个会话泄漏了 k_i 的高位——这就把恢复分片问题变成了一个经典的可格攻击问题。

本题里每条 transcript 已直接给出 rhochallengez,且拉格朗日系数 λ 已知:

1
λ1 = 2,   λ2 = -1 (mod n)

于是两个参与者用拉格朗日插值可合成群私钥:

1
sk = λ1·s1 + λ2·s2 = 2·s1 − s2   (mod n)

4. 泄漏建模:从观测到 Hidden Number Problem

4.1 单条方程欠定

对 signer i 的第 j 条 transcript:

1
z_j = k_j + λ_i·c_j·s_i   (mod n)

已知:z_jc_jλ_i,以及 k_j 的高 152 位(记为 t_j = nonce_msb_j × 2^104)。

未知:s_i(256 位)、k_j 的低 104 位 u_j

1
k_j = t_j·2^104 + u_j ,   |u_j| ≤ 2^105   (含 ±1 桶抖动,见下)

一条方程有两个未知量(s_i 和 u_j),欠定——单条 transcript 无法解出分片。

4.2 桶抖动的含义

泄漏模型 msb_quantisation_with_bucket_jitter 表示:探针把 nonce 按 2^104 为桶量化上报,且可能发生 ±1 个桶的偏差。因此真实 u_j = k_j − t_j·2^104 的范围不是 [0, 2^104) 而是 [−2^104, 2^105)。取安全上界 |u_j| < 2^105(实现中放宽到 2^106 以保证容错)。

4.3 化成 HNP

把核心方程改写成关于分片 s_i 的线性同余:

1
2
z_j = k_j + λ_i·c_j·s_i  (mod n)
=> λ_i·c_j·s_i ≡ z_j − t_j·2^104 − u_j (mod n)

1
2
a_j = λ_i·c_j  (mod n)
b_j = (z_j − t_j·2^104) (mod n)

1
a_j·s_i − b_j ≡ −u_j  (mod n) ,   |u_j| < 2^105

这正是经典的 Hidden Number Problem(HNP,隐数问题):给定若干条 a_j·x ≡ b_j + e_j (mod n),其中误差 e_j 很小,求未知大数 x

每条 transcript 提供 152 − 104 = 48 位”净信息”?更准确地说:高位已知部分(152 位)越多,越容易恢复 256 位私钥。20 条 transcript 提供的约束远超恢复一个 256 位数的需求(经验规则:总已知位数 20 × 152 ≈ 3040 ≫ 256),格攻击必然可行。

5. 格攻击求解 HNP(CVP-embedding + LLL)

5.1 CVP 视角

把误差项挪到等式右边:

1
a_j·s_i − k'_j·n = b_j + u_j      (k'_j 为商)

写成向量形式(记 u = (u_1,...,u_n)b = (b_1,...,b_n)):

1
s_i·(a_1,...,a_n) − Σ_j k'_j·(n·e_j) = b + u

左边属于由向量 {n·e_j}a = (a_1,...,a_n) 张成的格 L'。因此:

b 距离某个格点 p ∈ L' 很近,且 p − b = u 是一个小向量。

这是 Closest Vector Problem(CVP,最近向量问题)。求出 p 后,任意一分量 p_1 = a_1·s_i − k'_1·n,故

1
s_i = p_1 · a_1^{-1}  (mod n)
5.2 用 Embedding 把 CVP 转成 SVP

直接解 CVP 比较麻烦,标准做法是 embedding(嵌入法):把目标向量 b 连同一个大权重塞进格的一维,把 CVP 变成高一维的 SVP。

构造格 L'' ⊂ Z^{n+1},行向量为:

1
2
3
g_j = (0, ..., n, ..., 0, 0)      // 第 j 个坐标 = n,共 n 个
g_a = (a_1, ..., a_n, 0)
g_b = (b_1, ..., b_n, C) // C 为权重

考察整数组合 s_i·g_a − g_b − Σ_j m_j·g_j,其中 m_j 选成把前 n 个坐标”归约到中心余数”:

1
= (u_1, ..., u_n, −C)

这个向量属于格,且其模长:

1
||v|| ≈ sqrt( n·(2^105)² + C² )

C = 2^104,则 ||v|| ≈ 2^106

为什么一定能被 LLL 找到? 看格的协体积(covolume):

  • 前 n 维子格 {x·a + n·Z^n} 的协体积 ≈ n^(n−1) = 2^(256·(n−1))
  • 最后一维是 C·Z,贡献因子 C

所以 det(L'') ≈ C·n^(n−1) = 2^104·2^(256·19)(n=20 时)。高斯启发式最短向量长度:

1
λ ≈ sqrt((n+1)/2πe) · det(L'')^(1/(n+1)) ≈ 2^238

而我们的目标向量模长只有 ~2^106比高斯界短约 2^130 倍——它是格内压倒性的最短向量,LLL 约简后必然出现在基中。

5.3 从短向量恢复分片(符号坑)

LLL 输出基后,筛选满足下面条件的行:

1
|last| == C  且  前 n 个坐标绝对值 < 2^106

此时前 n 个坐标就是 ±u。由于 LLL 可能找到 (u, −C) 也可能找到 (−u, +C)(即对应 s_i−s_i 的组合),必须同时尝试两种符号

1
2
p_0 = (b_0 ± u_0)  (mod n)
x = p_0 · a_0^{-1} (mod n)

最后用全部 20 条方程验证候选 x

1
对每个 j:  centered(a_j·x − b_j) < 2^106

只有真正满足全部方程(且能对上群公钥)的才是正确分片。这一步验证同时天然排除了 LLL 可能给出的”伪短向量”。

5.4 实现要点(fpylll)
1
2
3
4
5
6
7
8
from fpylll import IntegerMatrix, LLL
M = IntegerMatrix(n + 2, n + 1)
for i in range(n): M[i, i] = q # g_j = (n·e_j, 0)
for i in range(n): M[n, i] = A[i] # g_a = (a, 0)
for i in range(n): M[n+1, i] = B[i] # g_b = (b, C)
M[n+1, n] = C
LLL.reduction(M)
# 然后按 5.3 扫描基

6. 分片恢复结果与三重验证

对 signer 1 和 signer 2 分别跑上面的 HNP 求解,各用 20 条 transcript,得到:

1
2
s1 = 4acb7a4deb36fc64ad41e8a3e373b682f035e18b29aea6336aed739be5a6eeb6
s2 = c192e508f6dc1c6fd2c2950775a564a1a04f90eacf5dde9da3bc56f96c5e9cc2

合成群私钥:

1
2
sk = 2·s1 − s2 (mod n)
= d4040f92df91dc5987c13c4051420862facb0f1233480e04f1f0eecb2f2581eb

三重验证全部通过

  1. 群公钥匹配sk·G == group_public ✓ —— 这是最关键的外部校验,证明恢复的私钥就是签发群公钥背后的阈值密钥;
  2. 插值一致性λ1·s1 + λ2·s2 ≡ sk (mod n) ✓;
  3. 逐 transcript 自洽:对每个 transcript 用分片反推有效 nonce
    1
    k_j = z_j − λ_i·c_j·s_i  (mod n)
    其高 152 位与 nonce_msb 的偏差(桶数)40/40 全部 ≤ 1,正好落在泄漏模型声称的抖动范围内 ✓。

7. 重建确定性 BIP340 签名(含关键坑)

7.1 BIP340 签名流程

恢复出 sk 后,需要重建签名。遥测声明:

1
"release_kdf": "SHA256('cold-forge/release/v1' || bip340_signature)"

即解封密钥由与封存时完全一致的签名派生。签名必须是确定性的(README 中”确定性的恢复签名”),因此采用 BIP340 的标准确定形式:aux_rand = 0x00×32(32 个零字节)。

BIP340 签名(sk 私钥、m 消息、a 辅助随机数):

1
2
3
4
5
6
7
8
9
10
d' = sk
P = d'·G
d = d' 若 P 的 y 为偶,否则 d = n − d'
t = bytes(d) XOR H_BIP0340/aux(a)
rand = H_BIP0340/nonce(t || bytes(P) || m) ← 见下方"坑"
k' = int(rand) mod n
R = k'·G
k = k' 若 R 的 y 为偶,否则 k = n − k'
e = int(H_BIP0340/challenge(bytes(R) || bytes(P) || m)) mod n
sig = bytes(R.x) || bytes((k + e·d) mod n) // 64 字节
7.2 关键坑:nonce preimage 用的是公钥不是私钥

很多自行实现 BIP340 的人会把 nonce 哈希写错。规范原文(BIP-0340 Default Signing):

rand = hash_BIP0340/nonce(t || bytes(P) || m)

nonce preimage 是 t || bytes(P) || m,其中 bytes(P)公钥的 x 坐标(32 字节)不是私钥 bytes(d)

设计动机:把公钥纳入 nonce 哈希,可防止公钥计算被篡改/调用方传错时泄漏私钥。

排错经验:初版实现误用了 bytes(d),导致签名与官方测试向量全部不符;用 BIP340 官方 test-vectors.csv(bip-0340/test-vectors.csv)逐条对拍后定位到该差异,改为 bytes(P) 后 4 条向量的 match=True。这道题如果不修正此处,永远解不开密文。

7.3 验证签名

用 BIP340 验证算法校验(lift R、计算 e、检查 s·G == R + e·P),签名通过:

1
2
sig = d3d4ced5a8b7b334f7a1baaaf71fe68a9b2bb02f218aa39cfa0d978fd5fc108b
6707bfc6914d3b9b341bd606e69f1bd1d1ab0062d59ab39ea014baa8e07450b3

8. KDF 派生与 ChaCha20-Poly1305 解封

8.1 派生密钥
1
key = SHA256( "cold-forge/release/v1" || sig )

域前缀与 AAD 完全一致(都是 "cold-forge/release/v1"),作为域分隔符防止跨用途密钥复用。

1
key = bd8667881a9b9903b8008f693ac80ef43a8bce95c042ad22fbe1ad24e7e641e3
8.2 AEAD 解密

sealed_release.json 给出:

  • nonce = 12 字节(base64 解码 UdfpASn/7RhHRhS4
  • aad = "cold-forge/release/v1"
  • ciphertext = 58 字节 = 42 字节密文 + 16 字节 Poly1305 tag
1
2
3
4
from Crypto.Cipher import ChaCha20_Poly1305
cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce)
cipher.update(aad)
pt = cipher.decrypt_and_verify(ct[:-16], ct[-16:]) # 末16字节是 tag

MAC 校验通过,明文为:

1
flag{bd9b026e-b58e-416c-bf8f-d815573c35a6}

9. 完整可复现代码

单脚本、自包含(依赖 fpylllpycryptodome),从两个 JSON 直接输出 flag:

1
2
pip install fpylll cysignals pycryptodome
python3 solve_all.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#!/usr/bin/env python3
"""
攻击链:
FROST nonce 高位泄漏(152/256 bits) -> Hidden Number Problem -> LLL/CVP-embedding
-> 恢复分片 s1,s2 -> 合成门限私钥 sk(2*s1-s2 mod q)
-> 重建确定性 BIP340 签名(aux=0) -> SHA256("cold-forge/release/v1"||sig)
-> ChaCha20-Poly1305 解封 -> flag
"""
import json, hashlib, base64
from fpylll import IntegerMatrix, LLL
from Crypto.Cipher import ChaCha20_Poly1305

# ---------- secp256k1 ----------
q = int("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",16)
p = int("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",16)
Gx = int("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",16)
Gy = int("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",16)
G = (Gx, Gy)

def point_add(P, Q):
if P is None: return Q
if Q is None: return P
x1,y1=P; x2,y2=Q
if x1==x2 and (y1+y2)%p==0: return None
lam=((3*x1*x1)*pow(2*y1,-1,p) if P==Q else (y2-y1)*pow(x2-x1,-1,p))%p
x3=(lam*lam-x1-x2)%p
return (x3,(lam*(x1-x3)-y1)%p)

def point_mul(k, P=G):
R=None
for b in bin(k)[2:]:
R=point_add(R,R)
if b=='1': R=point_add(R,P)
return R

def bytes_from_int(x): return x.to_bytes(32,'big')
def tagged_hash(tag,msg):
th=hashlib.sha256(tag.encode()).digest()
return hashlib.sha256(th+th+msg).digest()
def has_even_y(P): return P[1]%2==0
def xonly(P): return bytes_from_int(P[0])
def centered(x):
x%=q
return x-q if x>q//2 else x

# ---------- Step 1&2: HNP 恢复分片 ----------
DISCARD=104; DRIFT=1<<106

def recover_share(signer, transcripts, lam):
n=len(transcripts); A=[]; B=[]
for t in transcripts:
c=int(t["challenge"],16); z=int(t["z"],16); tm=int(t["nonce_msb"],16)
A.append((lam*c)%q) # a_j = λ·c_j
B.append((z-tm*(1<<DISCARD))%q) # b_j = z_j - t_j·2^104
C=1<<DISCARD
M=IntegerMatrix(n+2,n+1)
for i in range(n): M[i,i]=q # (n·e_i, 0)
for i in range(n): M[n,i]=A[i] # (a, 0)
for i in range(n): M[n+1,i]=B[i] # (b, C)
M[n+1,n]=C
LLL.reduction(M)
cands=set()
for r in range(M.nrows):
row=[M[r,c] for c in range(M.ncols)]
if abs(row[-1])!=C or not all(abs(x)<DRIFT for x in row[:-1]): continue
for sgn in (+1,-1): # LLL 可能给出 ±u
p0=(B[0]+sgn*row[0])%q
cands.add((p0*pow(A[0],-1,q))%q)
for x in cands:
if all(abs(centered(a*x-b))<DRIFT for a,b in zip(A,B)): return x
return None

tele=json.load(open("frost_telemetry.json"))
lam={1:int(tele["lagrange_coefficients"]["1"],16),
2:int(tele["lagrange_coefficients"]["2"],16)}
shares={s:recover_share(s,[t for t in tele["transcripts"] if t["signer"]==s],lam[s])
for s in (1,2)}
print(f"s1 = {shares[1]:064x}\ns2 = {shares[2]:064x}")

# ---------- Step 3: 合成门限私钥 + 验证 ----------
sk=(lam[1]*shares[1]+lam[2]*shares[2])%q
P=point_mul(sk)
print(f"sk = {sk:064x}")
print("sk·G == group_public:", P==(int(tele["group_public"]["x"],16),
int(tele["group_public"]["y"],16)))

# ---------- Step 4: 确定性 BIP340 签名 ----------
msg=bytes.fromhex(tele["message_hex"])
d0=sk; Pk=point_mul(d0)
d=d0 if has_even_y(Pk) else q-d0
aux=b"\x00"*32
t=bytes(a^b for a,b in zip(bytes_from_int(d),tagged_hash("BIP0340/aux",aux)))
# 关键: nonce preimage 是 t || bytes(P) || m (公钥! 不是私钥)
k0=int.from_bytes(tagged_hash("BIP0340/nonce",t+xonly(Pk)+msg),'big')%q
R=point_mul(k0)
k=k0 if has_even_y(R) else q-k0
e=int.from_bytes(tagged_hash("BIP0340/challenge",xonly(R)+xonly(Pk)+msg),'big')%q
sig=xonly(R)+bytes_from_int((k+e*d)%q)
print("sig =",sig.hex())

# ---------- Step 5: KDF + 解封 ----------
kdf_key=hashlib.sha256(b"cold-forge/release/v1"+sig).digest()
seal=json.load(open("sealed_release.json"))
nonce=base64.b64decode(seal["nonce"]); aad=base64.b64decode(seal["aad"])
ct=base64.b64decode(seal["ciphertext"])
cipher=ChaCha20_Poly1305.new(key=kdf_key,nonce=nonce)
cipher.update(aad)
pt=cipher.decrypt_and_verify(ct[:-16],ct[-16:]) # 末16字节是 Poly1305 tag
print("FLAG:",pt.decode())

运行输出:

1
2
3
4
5
6
s1 = 4acb7a4deb36fc64ad41e8a3e373b682f035e18b29aea6336aed739be5a6eeb6
s2 = c192e508f6dc1c6fd2c2950775a564a1a04f90eacf5dde9da3bc56f96c5e9cc2
sk = d4040f92df91dc5987c13c4051420862facb0f1233480e04f1f0eecb2f2581eb
sk·G == group_public: True
sig = d3d4ced5a8b7b334f7a1baaaf71fe68a9b2bb02f218aa39cfa0d978fd5fc108b6707bfc6914d3b9b341bd606e69f1bd1d1ab0062d59ab39ea014baa8e07450b3
FLAG: flag{bd9b026e-b58e-416c-bf8f-d815573c35a6}

10. 漏洞本质与防护建议

10.1 漏洞本质

本挑战是基于 nonce 偏置的格攻击的完整实战。攻击成立依赖两个条件叠加:

  1. 签名者的有效 nonce 被侧信道量化泄漏:每个会话只泄漏 152/256 位,看似”很多位还安全”,实则 20 个会话累积的线性约束足以在格上恢复 256 位私钥分片;
  2. FROST 的分片可被线性方程外推z = k + λ·c·s 一旦 nonce 部分可估,私钥分片就暴露;两个分片再经公开的拉格朗日系数直接合成群私钥。

这再次印证密码学中的铁律:nonce 必须是均匀随机且不可预测的,哪怕只泄漏最高位也足以致命。经典参考攻击包括对 Schnorr/ECDSA 的 HNP 攻击(Howgrave-Graham & Smart;Boneh & Venkatesan 的 HNP 框架;以及针对区块链签名 nonce 偏置的多次实际窃币事件)。

10.2 防护建议
  • nonce 生成必须恒定时间、随机、且绝不泄漏任何位;使用 RFC 6979 确定性 nonce 时也要保证实现正确且无侧信道;
  • 门限签名设备加装防侧信道硬件(如恒定时间运算、掩码),并定期做泄漏评估;
  • 不要让采集/遥测数据包含 nonce 的量化观测——本题的”遥测探针”本身就是安全隐患;
  • 密钥轮换与检测:若怀疑存在 nonce 泄漏,应立即轮换分片并全网升级;
  • 验证 BIP340 等实现时务必用官方测试向量对拍(本次正是靠 test-vectors.csv 定位到 nonce preimage 用公钥而非私钥的实现错误)。

题解完毕。最终 flag:flag{bd9b026e-b58e-416c-bf8f-d815573c35a6}


TinyNTRU

1. 题目概述

题目提供了一个基于 NTRU 的简化加密方案,命名为 TinyNTRU。我们获得了以下文件:

  • challenge.py:生成挑战的脚本,读取 flag、生成公私钥、加密 flag,并输出 public_key.txtoutput.txt
  • ntru.py:核心 NTRU 加密/解密实现。
  • public_key.txt:包含公钥参数 h(多项式)以及系统参数 N, p, q 等。
  • output.txt:包含加密后的密文列表 ciphertexts

目标:利用已知的公钥和密文,恢复私钥,解密密文得到 flag。

2. 参数分析

public_key.txt 中提取关键参数:

  • N = 127
  • p = 3
  • q = 12289
  • 公钥多项式 h(127 个系数)

ntru.py 中,私钥生成方式如下:

1
2
3
4
5
F = sample_sparse_ternary(N, df, rng, avoid_zero=True)   # df=7, 系数 ±1,非零
G = sample_sparse_ternary(N, dg, rng) # dg=7, 系数 ±1,非零
f = [0]*N; f[0] = 1
f = poly_add(f, [p*x for x in F], N) # f = 1 + 3*F
g = [p*x for x in G] # g = 3*G

因此:

  • f 的常数项为 1,另外 7 个位置是 ±3,总共有 8 个非零系数。
  • g 有 7 个非零系数,均为 ±3

私钥向量 (f, g) 的欧几里得范数:

对于 NTRU 格,其维度为 2N = 254,高斯启发式估计的最短向量长度约为:

私钥向量的长度远小于高斯期望,因此它构成了格中的极端短向量,使用 LLL 格基约简即可轻松恢复。

3. NTRU 加密与解密简述

  • 加密:随机选择稀疏多项式 r(系数 ±1,dr=6 个非零),计算密文 e = r * h + m (mod q),其中 m 是消息多项式(系数在 {0,1,2},即三进制表示)。
  • 解密:计算 a = f * e (mod q),然后将系数中心化到 (-q/2, q/2],再对 p=3 取模得到 m

正确性依赖于 f * h ≡ g (mod q),从而 f*e ≡ r*g + f*m (mod q),由于 rg 很小,乘积的系数远小于 q,所以取模后中心化可消除噪声。

4. 攻击原理:NTRU 格与最短向量问题

定义循环卷积多项式环。公钥 h 满足:

等价于存在整数多项式 k 使得:

因此向量 (f, g) 属于格:

该格的一个基可以构造为:

其中 Hh 的循环矩阵(H[i][j] = h[(j-i) mod N])。格中的任意向量 (a, b) 满足 a = q u + b h(模意义),且 b 的系数可任意。对于短向量 (g, -f),我们有:

(g, -f) 属于该格。另一种常用格基为:

其短向量为 (f, g)

由于 (g, -f) 的长度极小,LLL 约简可以快速找到它。

5. 恢复私钥的步骤

5.1 构造循环矩阵 H

给定公钥系数 h[0..N-1],构造 N×N 矩阵 H,使得对于任意向量 vH * v 等于循环卷积 h * v(系数按指数模 N 相加)。具体:

1
H[i][j] = h[(j - i) mod N]

这样 H * v 的第 i 个分量为,正是卷积。

5.2 构造格基

使用第一种形式:

这是一个 2N × 2N 的整数矩阵。

5.3 LLL 约简

在 SageMath 中,直接调用 B.LLL(),得到约简基。遍历所有行,检查第二部分(后 N 个系数)是否构成候选 f,即满足:

  • f[0] = ±1(常数项为 1,取负号也可以,因为 -f 也满足条件)
  • f[0] 外,其他系数均为 0±3
  • 非零系数总数正好为 8(即 1 + df
  • 计算 g = f * h mod q,将系数中心化到 [-q/2, q/2],检查其是否恰有 7 个非零系数且均为 ±3

只有同时满足这两点的候选才是真正的私钥。

5.4 解密

有了 f,对每个密文块 c 进行解密:

1
2
3
t = f * c mod q
t_centered = center_lift(t, q) # 系数调整到 (-q/2, q/2]
m = [coeff % p for coeff in t_centered] # 得到三进制系数

然后将三进制系数块转换为字节,最后拼接得到 flag。

6. 实战脚本

使用 SageMath 实现上述过程。脚本 solve.sage 如下(关键部分已加注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env sage
from sage.all import *
import re, ast

N = 127
p = 3
q = 12289
CHUNK = 24

def mod_center(x, mod):
x %= mod
if x > mod//2:
x -= mod
return x

def poly_mul(a, b, mod=None):
out = [0]*N
for i, ai in enumerate(a):
if ai == 0: continue
for j, bj in enumerate(b):
if bj == 0: continue
out[(i+j) % N] += ai * bj
if mod is not None:
out = [x % mod for x in out]
return out

def decrypt(c, f):
t = poly_mul(f, c, q)
lifted = [mod_center(x, q) for x in t]
return [x % p for x in lifted]

def blocks_to_bytes(blocks):
raw = bytearray()
for blk in blocks:
v = 0
for coeff in reversed(blk):
v = v*3 + (coeff % 3)
raw.extend(int(v).to_bytes(CHUNK, 'little'))
if len(raw) < 2:
raise ValueError("Missing length prefix")
size = int.from_bytes(raw[:2], 'big')
return bytes(raw[2:2+size])

def is_sparse(poly, nonzero_count, allowed_vals):
if len(poly) != N:
return False
cnt = 0
for x in poly:
if x != 0:
cnt += 1
if x not in allowed_vals:
return False
return cnt == nonzero_count

def recover_candidates(B):
Bred = B.LLL()
candidates = []
for row in Bred:
# 尝试第二部分作为 f,如果不行则尝试第一部分
for block in [row[N:], row[:N]]:
for sign in (1, -1):
f_cand = [sign * block[i] for i in range(N)]
# 检查 f = 1 + 3F
if abs(f_cand[0]) != 1:
continue
ok = True
for i in range(1, N):
if f_cand[i] not in (-3, 0, 3):
ok = False
break
if not ok:
continue
if sum(1 for x in f_cand if x) != 8:
continue
# 验证 g = f*h mod q 是否稀疏
g = poly_mul(f_cand, h, q)
g_centered = [mod_center(x, q) for x in g]
if not is_sparse(g_centered, 7, (-3, 0, 3)):
continue
if f_cand not in candidates:
candidates.append(f_cand)
return candidates

# 读取公钥
with open("public_key.txt", "r") as f:
pub_data = f.read()
match = re.search(r"h\s*=\s*(
$$
.*?
$$
)\s*", pub_data, re.DOTALL)
h = ast.literal_eval(match.group(1))
assert len(h) == N

# 读取密文
with open("output.txt", "r") as f:
out_data = f.read()
match = re.search(r"ciphertexts\s*=\s*(
$$
.*
$$
)\s*", out_data, re.DOTALL)
ciphertexts = ast.literal_eval(match.group(1))
print(f"[+] Loaded {len(ciphertexts)} ciphertext blocks")

# 构造循环矩阵 H
H = matrix(ZZ, N, N)
for i in range(N):
for j in range(N):
H[i, j] = h[(j - i) % N]

I = identity_matrix(ZZ, N)
Z = zero_matrix(ZZ, N, N)

candidates = []
# 尝试两种格基
print("[*] Trying lattice B = [[qI, H], [0, I]] ...")
B = block_matrix(ZZ, [[q*I, H], [Z, I]])
candidates = recover_candidates(B)

if not candidates:
print("[*] Trying lattice B = [[qI, 0], [H, I]] ...")
B = block_matrix(ZZ, [[q*I, Z], [H, I]])
candidates = recover_candidates(B)

if not candidates:
# 使用 BKZ 作为后备
print("[*] Trying BKZ (block size 20) ...")
Bbkz = B.BKZ(block_size=20)
# 简化检查
for row in Bbkz:
for block in [row[N:], row[:N]]:
for sign in (1, -1):
f_cand = [sign * block[i] for i in range(N)]
# ... 同前面检查
# (省略完整代码,见完整脚本)
pass

if not candidates:
raise RuntimeError("No candidate f found.")

print(f"[+] Found {len(candidates)} candidate(s).")

for idx, f in enumerate(candidates):
print(f"[*] Testing candidate #{idx+1} ...")
blocks = []
for c in ciphertexts:
blocks.append(decrypt(c, f))
try:
plain = blocks_to_bytes(blocks)
if plain.startswith(b"flag{"):
print("[+] Flag found!")
print(plain.decode())
exit(0)
except:
continue

7. 运行结果

执行脚本后输出:

1
2
3
4
5
6
7
8
9
10
[+] Loaded 2 ciphertext blocks
[*] Trying lattice B = [[qI, H], [0, I]] ...
[*] Trying lattice B = [[qI, 0], [H, I]] ...
[+] Found 2 candidate(s). Attempting decryption...
[*] Testing candidate #1: f[:5] = [1, 0, 0, 0, 0] ... (total nonzeros: 8)
Block trits (first 10): [1, 2, 2, 1, 1, 1, 0, 2, 2, 0]
Block trits (first 10): [0, 1, 0, 1, 1, 0, 1, 0, 1, 1]
Decrypted raw bytes (hex): 666c61677b32376465633438662d663963372d346261642d396131622d6637616137376261646335667d
[+] Flag found!
flag{27dec48f-f9c7-4bad-9a1b-f7aa77badc5f}

Misc

SilentConfig

1. 题目背景

题目描述
安全团队在巡检中发现数据库出现异常查询行为,但线上日志只保留了部分片段。攻击者可能通过 Web 接口读取了某个敏感业务配置。请关联分析附件中的日志,恢复攻击者最终获得的配置值。

Flag 格式flag{uuid}

附件内容:一份 MySQL 查询日志(片段),记录了攻击者利用 SQL 盲注逐字符窃取 system_setting 表中 sync_token 配置值的过程。

2. 日志格式分析与盲注原理

日志中每条记录形如:

1
2026-07-18T10:43:01.060+08:00 ... rows_sent=1 query="...ORD(MID((SELECT value FROM system_setting WHERE name='sync_token'),{位置},{长度}))>{阈值}..."

关键字段

  • rows_sent=1:表示查询返回了 1 行,即 WHERE 条件为 → 当前字符的 ASCII 码 大于 阈值。
  • rows_sent=0:表示查询返回 0 行,即条件为 → 当前字符的 ASCII 码 小于或等于 阈值。

攻击者通过不断调整阈值,利用二分查找法确定每个字符的精确 ASCII 值,从而还原整个 sync_token 字符串。

攻击目标sync_token 是一个标准 UUID(v4)格式,共 36 个字符,其中第 9、14、19、24 位是连字符 -。有效字符集为 0-9(ASCII 48-57)和 a-f(ASCII 97-102),以及连字符(ASCII 45)。

3. 数据提取与分组

从日志中提取所有包含 sync_token 的查询,并按位置(1~36)分组。每个位置通常有多次测试,对应不同的阈值。例如,位置 1 出现如下记录:

1
2
3
4
... >53 rows_sent=1
... >51 rows_sent=1
... >55 rows_sent=0
... >54 rows_sent=1

这些记录共同决定了该字符的最终 ASCII 值。

4. 位置解析方法

位置 1 为例:

  • >53 → 真,即 ASCII > 53
  • >51 → 真,即 ASCII > 51
  • >55 → 假,即 ASCII ≤ 55
  • >54 → 真,即 ASCII > 54

结合分析:ASCII 必须 > 54 且 ≤ 55,因此唯一可能为 55,对应字符 '7'

当测试序列不完整时,可能需要利用上下界推断。例如,位置 2 仅出现 >101 为真,而 UUID 字符的最大 ASCII 为 102(f),因此可推断该字符为 102(f)。

5. 所有位置的解析结果

经过对日志中每条记录的二分推导,得到下表(? 表示无法唯一确定):

位置日志推断逻辑确定 ASCII字符
1>55假(≤55), >54真(≥55) → 55557
2>101真,最大102 → 102102f
3>57真(≥58), >97假(≤97), 且≥58 ≤97 且非数字 → 9797a
4>53假(≤53), >52假(≤52), >51真(≥52) → 52524
5>98真(≥99), >99假(≤99) → 9999c
6>97真(≥98), >98假(≤98) → 9898b
7>49真(≥50), >50假(≤50) → 50502
8>99真(≥100), >100假(≤100) → 100100d
9UUID 固定连字符45-
10>52真(≥53), >53假(≤53) → 53535
11>100真(≥101), >101假(≤101) → 101101e
12>56真(≥57), >57假(≤57) → 57579
13>57真(≥58), >97假(≤97) → 9797a
14UUID 固定连字符45-
15日志完全缺失该位置的测试缺失?
16>99真(≥100), >100假(≤100) → 100100d
17>53真(≥54), >54假(≤54) → 54546
18>53真(≥54), >54假(≤54) → 54546
19UUID 固定连字符45-
20>57真(≥58), >97真(≥98), 缺少更高阈值测试98~102b/c/d/e/f?
21>55真(≥56), >56假(≤56) → 56568
22>101真,最大102 → 102102f
23>48真(≥49), >49假(≤49) → 49491
24UUID 固定连字符45-
25>50真(≥51), >51假(≤51) → 51513
26>98真(≥99), >99假(≤99) → 9999c
27>56真(≥57), >57假(≤57) → 57579
28>49真(≥50), >50假(≤50) → 50502
29>54真(≥55), >55假(≤55) → 55557
30>48假(≤48), 最小 ASCII 为 48 → 48480
31>57真(≥58), >97假(≤97) → 9797a
32>99真(≥100), >100假(≤100) → 100100d
33>52真(≥53), >53假(≤53) → 53535
34>48真(≥49), >49假(≤49) → 49491
35>100真(≥101), >101假(≤101) → 101101e
36>51真(≥52), >52假(≤52) → 52524

由此得到不完整的 UUID 模板:

1
7fa4cb2d-5e9a-?d66-?8f1-3c9270ad51e4

其中:

  • 第 15 位:? —— 日志完全缺失该位置的盲注请求,无法直接获得。
  • 第 20 位:? —— 仅知道 ASCII ≥ 98,但缺少 >98>99>100>101>102 的测试,故候选字符为 b(98)、c(99)、d(100)、e(101)、f(102)。

6. 利用 UUID v4 规范补全缺失位

目标 sync_token 是标准 UUID,遵循 RFC 4122 规范。对于 UUID 版本 4(随机生成)而言:

  • 第 15 位(即 time_hi_and_version 字段的高半字节)固定为 4,表示版本号。
  • 第 20 位clock_seq_hi_and_reserved 字段的高两位)称为变体,必须为 89ab,但最常见的变体是 89ab 中的前三位被保留,实际随机生成时,该位固定为 89ab 之一。然而我们已知该字符 ASCII ≥ 98,而 8(56)、9(57)、a(97) 均不满足 ≥98,因此唯一剩余候选是 b(98)。

因此,缺失的两处可以唯一确定为:

  • 位置 15 → 4
  • 位置 20 → b

完整 UUID 为:

1
7fa4cb2d-5e9a-4d66-b8f1-3c9270ad51e4

7. 最终 Flag

根据题目要求的格式,提交:

1
flag{7fa4cb2d-5e9a-4d66-b8f1-3c9270ad51e4}

8. 总结与反思

  • 本题考察了对 SQL 盲注日志的分析能力,特别是理解 rows_sent 的真假含义,并逆向推导出字符的 ASCII 值。
  • 日志不完整是常见情况,需要结合已知的格式规范(如 UUID 标准)补全缺失信息。
  • 如果忽略 UUID 的固定位,将无法得到唯一的答案,这也突显了在安全分析中利用领域知识的重要性。
  • 攻击者利用二分查找法,只需约 log₂(95) ≈ 7 次请求即可确定一个字符,整个窃取过程在数分钟内完成,提醒我们在真实环境中应对此类盲注漏洞采取严格的输入过滤和异常监控措施。

解题工具:可编写简单的脚本解析日志,自动分组并推导 ASCII,但本题手动分析亦可,关键在于细致与逻辑严密。