HackThisSite - Steganography Mission 9

Challenge

关卡页在 https://www.hackthissite.org/missions/playit/stego/9/,题面只有两句:

Download the song Here kiss me alone

附件是 https://www.hackthissite.org/missions/stego/lvl/Stego9.zip。作者 tiksi, 答案大小写敏感(页面提示 All missions are case sensitive)。

状态:live 已通关(verified)。提交后 profile 的 Stego: 一行出现 (9), 关卡页回执变为 already done this mission

Sample

1
2
3
4
5
curl -sL -b "$HTS_COOKIE" -o Stego9.zip \
'https://www.hackthissite.org/missions/stego/lvl/Stego9.zip'
7z l Stego9.zip
7z x Stego9.zip
soxi ecstasy-atb-music.wav
1
2
3
Type = zip
Date Time Attr Size Compressed Name
2008-06-24 08:51:34 ....A 1784818 1675128 ecstasy-atb-music.wav
1
2
3
4
5
6
Channels       : 2
Sample Rate : 11025
Precision : 16-bit
Duration : 00:00:40.47 = 446190 samples
File Size : 1.78M
Sample Encoding: 16-bit Signed Integer PCM

ZIP 里只有一个 40 秒、11025 Hz、16 bit 立体声的 WAV。单看左右声道各自都是一首连续的 电子舞曲,波形和频谱都很正常,秘密只在混音层面才会显形。

Solution

Step 1: WAV 块结构

先按 WAV 的块结构逐块核对尾部附加数据,以及头部声明长度与实际文件长度的一致性:

1
2
3
4
5
6
7
8
9
10
11
import struct

d = open("ecstasy-atb-music.wav", "rb").read()
print("riff", d[:4], "file_size", len(d))
pos = 12
while pos + 8 <= len(d):
cid = d[pos:pos + 4].decode("ascii", "replace")
size = struct.unpack_from("<I", d, pos + 4)[0]
print("%-4s offset=%d size=%d" % (cid, pos + 8, size))
pos += 8 + size + (size & 1) # 块按 2 字节对齐
print("end_of_chunks", pos, "== file size", pos == len(d))
1
2
3
4
5
riff b'RIFF' file_size 1784818
fmt offset=20 size=18
fact offset=46 size=4
data offset=58 size=1784760
end_of_chunks 1784818 == file size True

data 块正好覆盖到文件结尾(58 + 1784760 = 1784818),没有任何尾部附加数据; 头部声明的长度也和真实文件一致。信息只能在 PCM 采样里。

Step 2: 两声道反相

分别看左右声道的能量和相关性:

1
2
3
4
5
6
7
8
9
10
import wave
import numpy as np

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
L, R = pcm[0::2], pcm[1::2]
print("corr(L,R) = %.6f" % np.corrcoef(L, R)[0, 1])
print("std L=%.1f R=%.1f L-R=%.1f L+R=%.1f" % (
L.std(), R.std(), (L - R).std(), (L + R).std()))
1
2
corr(L,R) = -0.999697
std L=18641.6 R=18642.0 L-R=37280.8 L+R=458.9

这一步就是全部关键。两个声道的相关系数是 -0.9997:右声道是左声道的反相副本。 所以:

  • 现场听感正常,是因为播放器把两声道混合后音乐照旧;
  • L - R 的幅度几乎是被乘了 2 倍的歌曲本身(std 37281 ≈ 2×18641);
  • L + R 却塌缩成 std 只有 459 的极小残差。

音乐在求和时被抵消,留下的 L + R 才是承载信息的信号。把它单独导出成单声道:

1
2
3
4
5
6
7
8
9
10
11
12
13
import wave
import numpy as np

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr, nch, sw = w.getframerate(), w.getnchannels(), w.getsampwidth()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
residual = (pcm[0::2] + pcm[1::2]).astype(np.int16)
with wave.open("residual_sum.wav", "wb") as o:
o.setnchannels(1)
o.setsampwidth(sw)
o.setframerate(sr)
o.writeframes(residual.tobytes())
print("residual std=%.1f absmax=%d" % (residual.std(), np.abs(residual).max()))
1
residual std=458.9 absmax=1044

这个残差和歌曲本身几乎不相关(corr(L+R, L) = 0.0115),是一个独立的信号。

Step 3: 残差频谱与 1 kHz 载波

对残差做精细 FFT,看能量集中在哪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import wave
import numpy as np

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
res = pcm[0::2] + pcm[1::2]

chunk = res[100000:100000 + 8192] * np.hanning(8192)
spec = np.abs(np.fft.rfft(chunk))
freqs = np.fft.rfftfreq(8192, 1.0 / sr)
band = (freqs > 500) & (freqs < 2000)
top = band.nonzero()[0][np.argsort(spec[band])[-5:]][::-1]
for i in top:
print("%7.1f Hz mag %8.1f" % (freqs[i], spec[i]))
1
2
3
4
5
 998.6 Hz  mag 963327.3
999.9 Hz mag 917071.2
1002.6 Hz mag 903649.9
1001.3 Hz mag 665056.8
1004.0 Hz mag 341981.2

残差里几乎只有一个 约 1000 Hz 的纯载波(相邻几条谱线都挤在 1 kHz 附近, 是同一根载波在 FFT 分辨率上的展宽)。再用时间轴看它,按 100 ms 一格量载波的能量, 会发现它一段一段地通断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import wave
import numpy as np
from scipy.signal import butter, hilbert, sosfiltfilt

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
res = pcm[0::2] + pcm[1::2]

sos = butter(4, [800 / (sr / 2), 1200 / (sr / 2)], btype="band", output="sos")
env = np.abs(hilbert(sosfiltfilt(sos, res)))
env = np.convolve(env, np.ones(32) / 32, mode="same")
step = sr // 10
print(" ".join("%4d" % env[i * step:(i + 1) * step].mean()
for i in range(30)))
1
517  249 1034  781    4 1019 1027   12  747 1017  271  495 1035  482    0    1  997 1006   23  708  994  283  394  856  513  222 1033  676    1  947

每 100 ms 的能量在接近 1000 和接近 0 两档之间来回跳,这正是通断键控(OOK), 把通断的时间长度读出来就是莫尔斯电码。

Step 4: 包络通断 → 莫尔斯

对上一步的包络取阈值、做游程编码,按运行长度区分点 / 划和字符内间隔 / 字符间间隔 / 词间间隔,然后查表:

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
import wave
import numpy as np
from scipy.signal import butter, hilbert, sosfiltfilt

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
res = pcm[0::2] + pcm[1::2]

sos = butter(4, [800 / (sr / 2), 1200 / (sr / 2)], btype="band", output="sos")
env = np.abs(hilbert(sosfiltfilt(sos, res)))
env = np.convolve(env, np.ones(32) / 32, mode="same")
on = env > env.max() * 0.25
first, last = np.flatnonzero(on)[[0, -1]]
on = on[first:last + 1]

runs = []
cur, start = bool(on[0]), 0
for i in range(1, len(on)):
if bool(on[i]) != cur:
runs.append((cur, i - start))
cur, start = bool(on[i]), i
runs.append((cur, len(on) - start))

ms = lambda n: round(n / sr * 1000)
print("on ms:", [ms(n) for c, n in runs if c][:24])
print("off ms:", [ms(n) for c, n in runs if not c][:24])
1
2
on  ms: [51, 201, 202, 202, 201, 202, 202, 202, 201, 202, 201, 201, 52, 50, 52, 52, 51, 52, 51, 51, 202, 52, 51, 52]
off ms: [124, 124, 124, 124, 249, 124, 124, 124, 124, 249, 124, 124, 124, 125, 499, 124, 124, 124, 124, 249, 124, 124, 124, 124]

时长分成非常干净的四档:

  • 通 ≈ 51 ms(点)与 ≈ 201 ms(划),约 1:4;
  • 断 ≈ 124 ms(字符内部间隔)、≈ 249 ms(字符之间)、≈ 499 ms(词之间),约 1:2:4。

按这个比例把游程翻译成 ./-,用 124/249/499 三档间隔切分字符和词:

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
import wave
import numpy as np
from scipy.signal import butter, hilbert, sosfiltfilt

MORSE = {
"-----": "0", ".----": "1", "..---": "2", "...--": "3", "....-": "4",
".....": "5", "-....": "6", "--...": "7", "---..": "8", "----.": "9",
}

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
res = pcm[0::2] + pcm[1::2]

sos = butter(4, [800 / (sr / 2), 1200 / (sr / 2)], btype="band", output="sos")
env = np.abs(hilbert(sosfiltfilt(sos, res)))
env = np.convolve(env, np.ones(32) / 32, mode="same")
on = env > env.max() * 0.25
first, last = np.flatnonzero(on)[[0, -1]]
on = on[first:last + 1]

runs = []
cur, start = bool(on[0]), 0
for i in range(1, len(on)):
if bool(on[i]) != cur:
runs.append((cur, i - start))
cur, start = bool(on[i]), i
runs.append((cur, len(on) - start))

unit = min(n for c, n in runs if c) # 最短的通段 = 一个点
words, word, sym = [], [], ""
for is_on, n in runs:
if is_on:
sym += "." if n < unit * 2.5 else "-"
elif n < unit * 4: # 字符内间隔,忽略
continue
elif n < unit * 7: # 字符间间隔
word.append(sym)
sym = ""
else: # 词间间隔
word.append(sym)
words.append(word)
word, sym = [], ""
if sym:
word.append(sym)
if word:
words.append(word)

digits = ["".join(MORSE[s] for s in w) for w in words]
print("morse digits:", " ".join(digits))
print("ascii :", " ".join(chr(int(d)) for d in digits))
1
2
morse digits: 107 56 120 48 119 53 98 98 117 113
ascii : k 8 x 0 w 5 b b u q

Step 5: 莫尔斯即十进制 ASCII 码

上一步每个词由 2 到 3 个莫尔斯字符组成,分别都是数字字符,例如第一个词是 .---- ----- --...(即 107)、第二个是 ..... -....(即 56)。换句话说, 莫尔斯电码表出的是一串十进制数字,每 2 到 3 位一组;这些数字按十进制读成 ASCII 码, 就是真正的明文:

1
2
107 56 120 48 119 53 98 98 117 113
k 8 x 0 w 5 b b u q

拼起来就是题面 kiss me alone 里埋的那个通关密码, 直接提交即可。

Key points

  • 这道题的核心是把反相的两个声道混回单声道:音乐在 L + R 里相互抵消,剩下的低电平残差(std 459,约为歌曲本身的 2.5%)才是秘密。 判断手法只需一个数字:corr(L, R) = -0.9997
  • 载体是通断键控的 1 kHz 载波:频域是一根尖峰(998.6 Hz),时域是一串方波包络。 用希尔伯特变换取包络、按 25% 阈值切成 0/1,运行长度自然分成 51/201 ms(点/划)与 124/249/499 ms(三档间隔),四档比例干净,噪声几乎不干扰判读。
  • 明文是莫尔斯数字 → 十进制 ASCII 码 → 字符的两级 编码。看到词里每 2 到 3 个连续数字字符、并且总长度围绕 ASCII 可打印区间 (10x / 5x / x8 / 11x)时,就该想到十进制 ASCII。
  • 排除顺序:结构(RIFF 分块 / 尾部附加)→ 声道相关 → 频谱 → 包络时长。 第 1 步排除了尾部附加数据,第 2 步定位到 L+R,第 3、4 步才把载波转成文本。
  • 提交:/missions/stego/template.php,字段为 formkey(每次加载都变)、lvlpass, 并且必须带 Referer: .../missions/playit/stego/9/。答错时服务端不回任何错误文案, 唯一可靠的判据是提交后 profile 的 Stego: 一行出现 (9)
k8x0w5bbuq