CodeShell.kr - Phase Between Words

Challenge

18 秒 8 kHz 立体声录音,题面说里面有东西。难点在于符号栅格要自己定:音段边界必须用样本级方法取,块级 FFT 会把边界量化到错误位置。

Something is hiding in the recording. Find it and submit the flag in the format CodeShell{...}

录音里藏着东西。

1
https://codeshell.kr/challenges/phase-between-words/

Solution

附件是立体声 PCM WAV,8000 Hz、18.000 秒、144000 帧。

Step 1:先看声道结构。左右声道 RMS 都在 7000 左右,但差值 L − R 的 RMS 只有 1321,差一个数量级。再按频率拆开:公共信号同时出现在 L、R、L−RL+R 里,而 73 Hz 只在 L、91 Hz 只在 RL−R 里两者都在,L+R 里各减半),即

1
2
3
L    = 公共信号 + 73 Hz 正弦
R = 公共信号 + 91 Hz 正弦
L−R = 公共信号 + 73 Hz + 91 Hz

两个"额外音"的振幅在每 100 ms 块上恒定(73 Hz 为 1237±56,91 Hz 为 875±40),相位去掉确定性推进后也是常数(残差 ±3°),所以它们只是干扰项,不承载数据。

Step 2:公共信号分两段:0–4.8 s 在 440 Hz 与 660 Hz 之间严格交替;4.8–18 s 是 330 Hz 长音,被 1 个周期(约 21 样本)的 440 Hz 短促插入打断。

Step 3:用过零率取样本级音段边界(不能用块级 FFT,否则边界被量化到块栅格)。第一段每段的周期数恰好是 44 或 88(440 Hz)、66 或 132(660 Hz),因为 440 Hz × 100 ms = 44 个整周期、660 Hz × 100 ms = 66 个整周期,单位就是 100 ms。把每段按 800 样本折算成符号槽,440 → 0、660 → 1:

1
2
3
ABAABBAB ABAABAAB ABABAABA ABABAABA ABAABBBB ABABAABA
01001101 01001001 01010010 01010010 01001111 01010010
M I R R O R

48 个符号槽 → 48 bit → 恰好 6 个字节,MIRROR。换其他符号率作对照,只有 800 样本/符号得到全可打印结果:

1
2
3
4
unit=200 : printable 0/24   b'\x0f\x00\xff\x0f...'
unit=400 : printable 6/12 b'0\xf30\xc33\x0c3\x0c0\xff3\x0c'
unit=800 : printable 6/6 b'MIRROR' <-- 正确栅格
unit=1600: printable 4/4 b'UUUZ'

Step 4:验证第二段不是数据。330 Hz 段的长度(以 330 Hz 周期计)是

1
13 3 17 13 3 17 17 72 3 17 13 17 3 13 3 13 17 58   (周期 18)

这个 18 值图案严格重复 13 次(直方图 3×66、13×66、17×80、58×13、72×13),周期恰为 1.000 s。所以第二段是"录音"背景。

Verification

  • 附件与页面快照以题目工作区为准,manifest.json 记录每个附件的 SHA-256。
  • 不在文章中保存 session cookie、CSRF token 或其他认证数据。
  • Reversing 全类(Opcode Picnic、Switchyard、Glass Register、Threaded Echo、Dead Branches、Blackbox VM、Involution、Fractured Loom)都有二进制自身的回执:六个 opcode-picnic 系成员由同一个通用求解器解出并复现四个已知答案,Involution 与 Fractured Loom 则用 GDB 逐指令对齐正向模型后再反向执行。
  • Palindrome、Errata、Crossfeed、Palimpsest 有两套独立实现或 CRC 自洽;Check-in 有两套独立 DP;Mosaic 有 9 种子 OCR 共识加像素宽度自校验;Phase Between Words 的 48 bit 在 L、R、L−RL+R 四个通道解出同一结果。
  • Marginalia 的密钥由「列 IC 定长 + 逐列卡方」与「词表逐列局部搜索」两条独立路径收敛到同一明文。
  • Ten Gates 是校验点最多的题:仿射变换系数唯一、候选数 6 等于 expected_candidate_countoracle_book.bin 解出后的 SHA-256 与 book_plaintext_sha256 逐位相符、最终密码长度 30 与 gate10.plaintext_length 一致。
  • Afterglow 由公钥判定:候选 x 必须满足 g^x mod p == y,且它能解释 80 条记录中的 68 条、恰好 12 条被污染(其中 5 条可由 top7 > 73 的边界算术事前判定),污染率与边界算术的预测一致。
  • Parallax 有三重自洽:128 字节流以 INFINITY 开头、流末尾 32 字节等于前 32 字节的 SHA-256、896 条边配对无歧义(非零签名恰好各出现 2 次、32 条零签名边构成外框)。
  • 未完成的题(Residual、Sixteen Plates、Reversal、Obsidian)在正文里保留已确认的步骤与下一步切入点,不写成结论。Fried Egg 的答案标注为候选。
  • 其余各题的答案待站点表单返回成功回执后再更新。

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
#!/usr/bin/env python3
"""phase-between-words: exact tone-segment map via zero crossings.

The side channel is a tone whose instantaneous period switches between ~18
samples (440 Hz) and ~12 samples (660 Hz), then settles at ~24 samples
(330 Hz). Recover the exact segment boundaries sample-accurately.
"""
from pathlib import Path

import numpy as np
import wave

WAV = Path(__file__).resolve().parents[1] / "assets/challenge-files/phase-between-words.wav"


def load_side():
with wave.open(str(WAV), "rb") as w:
fr = w.getframerate()
raw = w.readframes(w.getnframes())
a = np.frombuffer(raw, dtype="<i2").reshape(-1, 2).astype(np.float64)
return fr, a[:, 0] - a[:, 1]


def main():
fr, x = load_side()
# positive-going zero crossings
zc = np.where((x[:-1] < 0) & (x[1:] >= 0))[0] + 1
print(f"zero crossings: {len(zc)} first {zc[:5].tolist()} last {zc[-5:].tolist()}")

per = np.diff(zc)
freq = fr / per
print(f"period stats: min {per.min()} max {per.max()} median {np.median(per)}")
hist, edges = np.histogram(per, bins=np.arange(8, 32))
print("period histogram (8..31):")
for h, e in zip(hist, edges):
if h:
print(f" period {e:2.0f} ({fr / e:6.1f} Hz): {h}")

# classify each crossing interval, then group
def cls(p):
if p <= 15:
return 660
if p <= 21:
return 440
if p <= 30:
return 330
return 0

labels = [cls(p) for p in per]
runs = []
cur, start = labels[0], 0
for i in range(1, len(labels)):
if labels[i] != cur:
runs.append((cur, zc[start], zc[i], zc[i] - zc[start], i - start))
cur, start = labels[i], i
runs.append((cur, zc[start], zc[-1], zc[-1] - zc[start], len(labels) - start))

print(f"\nsegments: {len(runs)}")
for f, s, e, ln, ncyc in runs[:120]:
print(f" {f:3d} Hz samples {s:7d}-{e:7d} len {ln:6d} cycles {ncyc:4d}")


if __name__ == "__main__":
main()
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
#!/usr/bin/env python3
"""phase-between-words: try alternative symbol rates for the FSK layer.

800 samples/unit (100 ms) decodes to "MIRROR". Check whether a finer unit
yields a longer readable message (which would mean 100 ms was the wrong grid).
"""
from pathlib import Path

import numpy as np
import wave

WAV = Path(__file__).resolve().parents[1] / "assets/challenge-files/phase-between-words.wav"


def part1_runs():
with wave.open(str(WAV), "rb") as w:
fr = w.getframerate()
raw = w.readframes(w.getnframes())
a = np.frombuffer(raw, dtype="<i2").reshape(-1, 2).astype(np.float64)
x = a[:, 0] - a[:, 1]
zc = np.where((x[:-1] < 0) & (x[1:] >= 0))[0] + 1
per = np.diff(zc)

def cls(p):
if p <= 15:
return 660
if p <= 21:
return 440
return 330

labels = [cls(p) for p in per]
out = []
cur, start = labels[0], 0
for i in range(1, len(labels)):
if labels[i] != cur:
out.append((cur, zc[start], zc[i], zc[i] - zc[start]))
cur, start = labels[i], i
out.append((cur, zc[start], zc[-1], zc[-1] - zc[start]))
return fr, [r for r in out if r[0] in (440, 660) and r[3] > 300]


def main():
fr, runs = part1_runs()
total = runs[-1][2]
print(f"part-1 segments: {len(runs)} span {total} samples")

for unit in (200, 400, 800, 1600):
units = []
for f, s, e, ln in runs:
u = max(1, round(ln / unit))
units.extend([f] * u)
bits = [0 if f == 440 else 1 for f in units]
n = len(bits) // 8 * 8
if n == 0:
continue
bs = bytes(int("".join(map(str, bits[i:i + 8])), 2) for i in range(0, n, 8))
printable = sum(32 <= c < 127 for c in bs)
print(f"\nunit={unit}: {len(units)} symbols, {len(bs)} bytes, "
f"printable {printable}/{len(bs)}")
print(f" {bs!r}")


if __name__ == "__main__":
main()
CodeShell{MIRROR}