HackThisSite - Steganography Mission 2

Challenge

did I hear that correctly?

我这个听对了吗?

关卡提供一个音频文件 https://www.hackthissite.org/missions/stego/lvl/2.wav,要求在音频里找出隐藏的密码。

Solution

先读容器与波形,确认处理对象:

1
2
3
4
5
6
7
8
$ soxi 2.wav
Input File : '2.wav'
Channels : 2
Sample Rate : 22050
Precision : 16-bit
Duration : 00:00:10.00 = 220500 samples ~ 750 CDDA sectors
Bit Rate : 706k
Sample Encoding: 16-bit Signed Integer PCM

22050 Hz、立体声、10 秒整、220500 帧。再看频谱能量分布:

1
2
3
L     0-200:0.000 200-500:0.003 500-1000:0.424 1000-1300:0.098
1300-1600:0.049 1600-2000:0.074 2000-3000:0.155 3000-5000:0.196
5000-11025:0.001 peak 500 Hz

能量几乎全在 5 kHz 以下,高频基本是空的,左右声道逐样本都不相等(L == R 占比 0.000),谱型却几乎一致,是典型的一段宽频噪声,听不出任何旋律或语句。题面那句 did I hear that correctly? 指向听觉域,但重放、反向、左右对调都得不到可懂内容。载荷不在时间轴上,而在频谱平面上。

音频隐写里最常见的一类就是把信息画进频谱图。做法是用短时傅里叶变换把 幅度 × 频率 × 时间 画成一张灰度/伪彩图,人眼在图上直接读字。

命令行一行即可:

1
$ sox 2.wav -n spectrogram -o spec.png -z 90

等效的图形界面操作是 Audacity:打开 2.wav,点音轨名左侧的下拉箭头选 Spectrogram,再用 Track → Spectrogram Settings 把频率上限压低到约 2 kHz。

图出来后,在低频段(约 1–2 kHz 之间)可以看到一串块状字符横贯整段时间轴。这些字是编码者在噪声底上挖出的幅度凹陷:笔画处的频段能量比周围低,于是形成深色的字形,对比度足够肉眼直读。

低频带上从左到右是 12 个字符,逐字读出来即可。整串是纯小写字母加数字,全局共 12 位,前后没有分隔符或校验位。

不想装 Audacity 时,用标准库加两个常用库就能把同一张图渲染出来:

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
#!/usr/bin/env python3
"""HackThisSite Steganography 2 - render the spectrogram that carries the password.

The WAV is 22050 Hz / stereo / 10 s of band-limited noise. Nothing in it is
audible: the payload is *painted* into the spectrum, so the blocky characters
show up only once the signal is drawn as a time/frequency image.

Usage:
uv run --with numpy --with pillow python render_spec.py 2.wav out.png
"""
import sys
import wave

import numpy as np
from PIL import Image

NFFT, HOP = 1024, 64 # ~46 ms window, 2.9 ms hop
FMAX = 2000 # the text sits in the low band
DYNAMIC_RANGE = 70.0 # dB of the colour ramp

def load_mono(path):
with wave.open(path, "rb") as w:
sr = w.getframerate()
frames = w.readframes(w.getnframes())
channels = w.getnchannels()
pcm = np.frombuffer(frames, dtype=np.int16).astype(np.float64)
pcm = pcm.reshape(-1, channels).mean(axis=1)
return sr, pcm

def main():
src = sys.argv[1] if len(sys.argv) > 1 else "2.wav"
dst = sys.argv[2] if len(sys.argv) > 2 else "spec.png"
sr, x = load_mono(src)

window = np.hanning(NFFT)
freqs = np.fft.rfftfreq(NFFT, 1.0 / sr)
keep = int(np.searchsorted(freqs, FMAX))
n_frames = 1 + (len(x) - NFFT) // HOP

power = np.empty((keep, n_frames))
for i in range(n_frames):
chunk = x[i * HOP:i * HOP + NFFT] * window
power[:, i] = np.abs(np.fft.rfft(chunk))[:keep]

db = 20.0 * np.log10(power + 1e-9)
db -= db.max()
img = np.clip((db + DYNAMIC_RANGE) / DYNAMIC_RANGE, 0.0, 1.0)
img = (img[::-1] * 255).astype(np.uint8) # low frequency at the bottom

Image.fromarray(img, "L").resize(
(n_frames, keep * 4), Image.NEAREST).save(dst)
print("wrote %s (%d x %d, 0-%d Hz)" % (dst, n_frames, keep * 4, FMAX))

if __name__ == "__main__":
main()
1
2
$ uv run --with numpy --with pillow python render_spec.py 2.wav spec.png
wrote spec.png (3430 x 372, 0-2000 Hz)

playit 系列的提交口径固定:formkey 每次加载关卡页都会变,取页面和 POST 必须在同一次运行里完成;POST 必须带 Referer: <关卡页>,否则模板页会把请求当无效 referer 丢掉;答案是大小写敏感的。会话 cookie 从环境变量读入,不落盘。

1
2
3
4
$ export HTS_COOKIE='HackThisSite=<mission-cookie>'
$ uv run python playit_submit.py stego 2 --submit "<password>" --also-profile
submit -> ACCEPTED? | bytes 15136
profile stego: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '12']

更稳的判据是个人主页的 Stego: 行出现 (2)(错误答案不会有任何显式报错):

1
Stego: (1) (2) (3) (4) (5) (6) (7) (8) (9) (12)

Key points

  • 频谱图隐写属于可视化载荷:数据直接画在频域平面上,人眼即可读出。
  • 听不对类题面往往是误导。先做容器/信号建档(声道数、采样率、时长、频段能量分布),再决定走听觉路线还是视觉路线,能省掉大量盲目反向和重采样。
  • sox ... spectrogram 与 Audacity 的 Spectrogram 视图本质相同,命令行版更适合写进可复现脚本;调 -z(动态范围)和频率上限能显著提升字形对比度。
  • 提交侧的两个硬约束:formkey 与页面同源、Referer 必带;答案大小写敏感,且唯一可靠的完成判据是个人主页 Stego: 行的编号列表。
jb298abc9qb2