CodeShell.kr - Afterglow

Challenge

80 条 Schnorr 式签名记录,每个 nonce 的高 7 位已知。这是隐藏数问题,难点在于部分已知值被污染,必须把格攻击改成鲁棒版本。

Read what remains

读出残留。

1
https://codeshell.kr/challenges/afterglow/

Solution

protocol.txt 给出完整规格,trace.json 给 165-bit p、160-bit qg = 2^36y = g^x mod p,以及 80 条 Schnorr 式签名记录:

1
2
R = g^k mod p;   h = SHA256(UTF8(m) || BE(R, 21)) mod q
s = (k + h*x) mod q; top7 = k >> 153; 若干条 top7 被污染

k 的高 7 位已知、低 153 位未知,这是 HNP(隐藏数问题)

Step 1:先用边界算术筛掉一部分坏行。k < qq / 2^153 = 73.31, 所以 top7 不可能超过 73;80 条里有 5 条超过(89, 98, 89, 81, 103), 判定为污染行 [16, 21, 30, 53, 60]。这一步不需要猜也不需要解。

Step 2:格构造。朴素格 [q*B*I_n | 0 ; h_1*B ... h_n*B | 1] 虽然含有 短向量 (e'_i*B, ..., -x),但条目量级 2^306,BKZ(40) 跑了 20 分钟毫无进展。 改成先消去私钥(第 0 行解出 x 后代入其余行),条目回到 2^160:

1
2
3
c_i  = (s_i - top7_i*B - B//2) mod q     # 误差居中,界减半
t_i = h_i * h_0^-1 mod q; A_i = c_i - t_i*c_0 mod q
e'_i = A_i + t_i*e'_0 mod q; x = (c_0 - e'_0) * h_0^-1 mod q

Kannan 嵌入(维度 81):对角线放 q,再加 (t_1..t_{n-1}, 1, 0)(A_1..A_{n-1}, 0, B),格中含有 (-e'_i, -e'_0, B),末位绝对值为 B 的短向量 直接给出 e'_0。单次 LLL 从小时级降到 0.1 秒级

Step 3:标定离群容忍度。用真实 p、q、g 与真实 h 造合成实例:

1
2
corrupt=0 -> 80/80 正确        corrupt=3 -> 失败
corrupt=1 -> 79/80 正确 干净子集只需 50 行即可解出

这同时证明模型与 h 推导无误,且失败原因只可能是离群点数量。

Step 4:全量 75 行(含 7 个值 ≤ 73 的坏行)必然失败,于是在保留行里随机取 50 行跑格,用公钥 y = g^x mod p 当判据(每个候选直接验,不靠启发式打分)。 坏行实际有 12/80,子集干净概率不高,第 321 次试验命中:

1
$ sage -c "exec(open('solvers/afterglow_ransac.py').read())"
1
2
3
4
x = 809562626794024278667892851735365218205552836285
rows explained : 68/80
corrupt rows : 12 -> [16, 21, 22, 30, 31, 45, 53, 58, 60, 62, 72, 77]
g^x mod p == y : True

12 个坏行里有 5 个正是 Step 1 抓到的,另外 7 个值都 ≤ 73、事前无法辨认; 12 这个总数也与污染率吻合(128 个取值里只有 74 个合法,随机坏值约 42% 落在非法区, 5 / 0.42 ≈ 12)。

三个坑sage <file> [args] 对这些脚本静默无输出(exit 0、stdout 空), 必须用 sage -c "exec(open(path).read())";朴素格 + BKZ 是死路,慢归约先查条目 量级;单次 RANSAC 失败不等于模型错,先在合成真值上标定出「干净子集最小 50 行」 与「只容忍 1 个离群点」,才能判断是采样次数不够。

Script

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
"""CodeShell.kr — Afterglow: RANSAC with small subsets (run with sage).

Calibration (afterglow_synth.py, using the real h values and moduli):

corrupt=0 -> 80/80 correct
corrupt=1 -> 79/80 correct (one outlier is tolerated)
corrupt=3 -> fails outright
clean subsets of size 50 still solve

and `q/2^153 = 73.31`, so `top7 > 73` is provably corrupt (5 rows: 89, 98, 89,
81, 103). Dropping those leaves 75 rows that still contain a handful of
small-valued corruptions, which is why the all-rows reduction fails.

So: sample small subsets and keep only those that are clean or have a single
outlier. With ~7 remaining bad rows out of 75, a random 50-subset is usable
about 15% of the time, so a few dozen trials suffice.

Every candidate is checked against the public key `y = g^x mod p`.

Usage:
sage -c "exec(open('.../afterglow_ransac.py').read())" [trials]
"""

import hashlib
import json
import random
import sys
from pathlib import Path

from sage.all import Matrix, ZZ

ROOT = Path('codeshell')
TRACE = ROOT / 'extracted' / 'afterglow' / 'trace.json'
SHIFT = 153


def load():
d = json.loads(TRACE.read_text())
p, q, g, y = (int(d[k]) for k in ('p', 'q', 'g', 'y'))
nbytes = (p.bit_length() + 7) // 8
hs, ss, tops = [], [], []
for r in d['records']:
h = int.from_bytes(
hashlib.sha256(r['m'].encode()
+ int(r['R']).to_bytes(nbytes, 'big')).digest(),
'big') % q
hs.append(h)
ss.append(int(r['s']))
tops.append(int(r['top7']))
return p, q, g, y, hs, ss, tops


def score(q, hs, ss, tops, x):
return sum(1 for h, s, t in zip(hs, ss, tops)
if ((s - h * x) % q) >> SHIFT == t)


def candidates(q, hs, ss, tops, idx, B):
n = len(idx)
h0, s0, t0 = hs[idx[0]], ss[idx[0]], tops[idx[0]]
c0 = (s0 - (t0 << SHIFT) - (B >> 1)) % q
inv_h0 = pow(h0, -1, q)
ts = [(hs[j] * inv_h0) % q for j in idx[1:]]
As = [((ss[j] - (tops[j] << SHIFT) - (B >> 1)) - t * c0) % q
for j, t in zip(idx[1:], ts)]
dim = n + 1
m = Matrix(ZZ, dim, dim)
for i in range(n - 1):
m[i, i] = q
for i in range(n - 1):
m[n - 1, i] = ts[i]
m[n, i] = As[i]
m[n - 1, n - 1] = 1
m[n, n] = B
out = []
for row in m.LLL().rows():
if abs(int(row[dim - 1])) != B:
continue
for e0 in (int(row[dim - 2]), -int(row[dim - 2])):
x = (c0 - e0) * inv_h0 % q
if 0 < x < q:
out.append(x)
return out


def main():
trials = int(sys.argv[1]) if len(sys.argv) > 1 else 60
p, q, g, y, hs, ss, tops = load()
B = 1 << SHIFT
mx = (q - 1) >> SHIFT
keep = [i for i, t in enumerate(tops) if t <= mx]
print(f"n={len(hs)}, kept={len(keep)} (dropped {len(hs) - len(keep)} "
f"provably corrupt)", flush=True)

random.seed(2026)
best = (0, None)
for size in (55, 50, 45, 40):
found = 0
for t in range(trials):
idx = random.sample(keep, size)
for x in candidates(q, hs, ss, tops, idx, B):
if pow(g, x, p) == y:
print(f"\n*** SOLVED (size {size}, trial {t}) ***",
flush=True)
ok = score(q, hs, ss, tops, x)
bad = [i for i, (h, s, tp) in
enumerate(zip(hs, ss, tops))
if ((s - h * x) % q) >> SHIFT != tp]
print(f"rows explained: {ok}/{len(hs)}", flush=True)
print(f"corrupt rows : {bad}", flush=True)
dg = hashlib.sha256(x.to_bytes(20, 'big')).hexdigest()[:24]
print(f"answer: CodeShell{{{dg}}}", flush=True)
return
ok = score(q, hs, ss, tops, x)
if ok > best[0]:
best = (ok, x)
print(f" [size {size} t{t}] best rows {ok}/{len(hs)}",
flush=True)
found += 1
print(f"size {size}: {trials} trials, {found} candidates, "
f"best rows {best[0]}/{len(hs)}", flush=True)

print(f"\nnot solved; best rows {best[0]}/{len(hs)}", flush=True)


if __name__ == '__main__':
main()
CodeShell{19527f1fabf4d87f2039f509}