Hello Navi

Tech, Security & Personal Notes

Challenge

Level 4 — Parse an XML file

随机生成的 XML 描述若干 Line(XStart/XEnd/YStart/YEnd)与 Arc(XCenter/YCenter/Radius/ArcStart/ArcExtend),可选 Color(blue/green/red/yellow,缺省 white)。绘图后会出现五串字符,按蓝、绿、红、黄、白顺序提交。限时 120 秒。

每次生成的 XML 和答案都不同。解题流程是在同一实例的有效时间内取得 XML、解压并绘制五种颜色的完整图像,再依照 blue,green,red,yellow,white 顺序读取并提交。

Solution

关卡页生成实例,随后 XML 资源位于 /missions/prog/4/XML/。先加载关卡页,再下载压缩数据;直接请求 XML 而没有先加载关卡页时,响应可能只有换行,解析会因缺少 XML 根节点而失败。命令中的 <mission-cookie> 替换为当前会话 Cookie:

1
2
3
$ curl -sL -b '<mission-cookie>' -o /dev/null 'https://www.hackthissite.org/missions/programming/4/'
$ curl -sL -b '<mission-cookie>' -o plotMe.xml.bz2 'https://www.hackthissite.org/missions/prog/4/XML/'
$ bzip2 -dc plotMe.xml.bz2 > plotMe.xml

XML 中的 Line 和 Arc 按 Color 字段归类;缺少 Color 的图元归入 white。坐标按题面 1000×1000 画布绘制,每种颜色单独输出完整画布,不裁剪或拆分字符。PIL 的 y 轴向下,因此对坐标执行 y_screen = 1000 - y。Arc 的边界框为 (XCenter-Radius, 1000-(YCenter+Radius), XCenter+Radius, 1000-(YCenter-Radius)),角度传入 start=-(ArcStart+ArcExtend)、end=-ArcStart。

输出顺序与答案字段顺序一致:01-blue.png、02-green.png、03-red.png、04-yellow.png、05-white.png。完整图保留字符之间的相对位置,按这五张图人工读取。答案是五段以英文逗号分隔的大写十六进制字符串;不同实例间不可复用答案。

这道题可以用机器识别文字的办法或者交给 AI,但给的120秒时间完全足够人力解题了(除非你打字实在太慢)。

复现脚本支持本地 XML、.bz2 文件和 live GET。离线模式仅解压、解析和绘图,不提交答案;依赖 Python Pillow。

1
2
$ cd <ctf-workspace>
$ uv run python /path/to/manual_helper.py --bz2 /path/to/plotMe.xml.bz2 --out /path/to/hts4-manual-test

输出图片位于 hts4-manual-test/colours/。完整脚本如下:

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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# -- coding: utf-8 --
#!/usr/bin/env python3
"""HackThisSite Programming 4 manual-reading assistant.

Modes:
offline: read a saved XML;
live: GET a fresh XML into scratch using HTS_COOKIE.

The script performs only fetch/decompress/parse/render. It never POSTs and never
writes the cookie. It creates five complete colour layers in answer order:
01-blue.png, 02-green.png, 03-red.png, 04-yellow.png, 05-white.png.
Each image keeps the original 1000x1000 canvas and all geometry for that colour.
"""
from __future__ import annotations

import argparse
import bz2
import hashlib
import json
import math
import os
import re
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.request import Request, urlopen

from PIL import Image, ImageDraw

BASE = "https://www.hackthissite.org"
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"Chrome/131.0.0.0 Safari/537.36")
ORDER = ("blue", "green", "red", "yellow", "white")
RGB = {
"blue": (0, 0, 255),
"green": (0, 255, 0),
"red": (255, 0, 0),
"yellow": (255, 255, 0),
"white": (255, 255, 255),
}
CANVAS = 1000
SCALE = 4
WIDTH = 2 * SCALE


def fetch_url(url: str, headers: dict[str, str]):
request = Request(url, headers=headers)
with urlopen(request, timeout=20) as response:
return response.read(), dict(response.headers)


def fetch_live(cookie: str, out: Path):
"""Fetch and decompress one instance; cookie is kept only in memory."""
out.mkdir(parents=True, exist_ok=True)
headers = {
"User-Agent": UA,
"Cookie": cookie,
"Referer": BASE + "/missions/programming/",
}
page_bytes, _ = fetch_url(BASE + "/missions/prog/4/", headers)
page_text = page_bytes.decode("utf-8", errors="replace")
links = re.findall(
r'href=["\']([^"\']*prog/4/(?:XML|xml)[^"\']*)',
page_text,
flags=re.I,
)
url = (
BASE + links[0] if links and links[0].startswith("/")
else links[0] if links else BASE + "/missions/prog/4/XML/"
)
if not url.endswith("/"):
url += "/"
packed, response_headers = fetch_url(url, headers)
if packed[:3] != b"BZh":
raise RuntimeError(
"XML endpoint did not return bzip2; "
f"bytes={len(packed)} content_type={response_headers.get('Content-Type')}"
)
raw = bz2.decompress(packed)
xml = out / "instance.xml"
(out / "instance.xml.bz2").write_bytes(packed)
xml.write_bytes(raw)
return xml, url, packed, raw


def local(tag):
return tag.rsplit("}", 1)[-1]


def load_xml(path: Path):
raw = path.read_bytes()
if raw.startswith(b"BZh"):
raw = bz2.decompress(raw)
return ET.fromstring(raw)


def draw_colour(xml: Path, colour: str, dst: Path):
"""Render one complete colour layer on the original 1000x1000 canvas."""
root = load_xml(xml)
image = Image.new("RGB", (CANVAS * SCALE, CANVAS * SCALE), "black")
draw = ImageDraw.Draw(image)
count = 0
for element in root:
kind = local(element.tag)
data = {local(x.tag): (x.text or "").strip() for x in element}
current = (data.get("Color") or "white").lower()
if current != colour:
continue
count += 1
ink = RGB[colour]
if kind == "Line":
x0 = float(data["XStart"]) * SCALE
y0 = (CANVAS - float(data["YStart"])) * SCALE
x1 = float(data["XEnd"]) * SCALE
y1 = (CANVAS - float(data["YEnd"])) * SCALE
draw.line((x0, y0, x1, y1), fill=ink, width=WIDTH)
elif kind == "Arc":
cx = float(data["XCenter"])
cy = float(data["YCenter"])
radius = float(data["Radius"])
start = float(data["ArcStart"])
extend = float(data["ArcExtend"])
box = (
(cx - radius) * SCALE,
(CANVAS - (cy + radius)) * SCALE,
(cx + radius) * SCALE,
(CANVAS - (cy - radius)) * SCALE,
)
draw.arc(box, -(start + extend), -start, fill=ink, width=WIDTH)
image.resize((CANVAS, CANVAS), Image.Resampling.LANCZOS).save(dst)
return count


def render(xml: Path, out: Path):
out.mkdir(parents=True, exist_ok=True)
counts = {}
files = {}
for index, colour in enumerate(ORDER, 1):
dst = out / f"{index:02d}-{colour}.png"
counts[colour] = draw_colour(xml, colour, dst)
files[colour] = str(dst)
return counts, files


def main():
ap = argparse.ArgumentParser()
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--xml", type=Path, help="use a saved XML; no network")
src.add_argument("--bz2", type=Path, help="use a saved bzip2 XML; decompress locally")
src.add_argument("--live", action="store_true", help="fetch one fresh XML")
ap.add_argument("--cookie", default=os.environ.get("HTS_COOKIE"),
help="session cookie for --live; never written")
ap.add_argument("--out", type=Path, default=None,
help="output directory; defaults to Hermes scratch")
args = ap.parse_args()
started = time.monotonic()
if args.live:
if not args.cookie:
raise SystemExit("--live requires HTS_COOKIE or --cookie")
out = args.out or Path("hts4-manual-" + str(int(time.time())))
xml, url, packed, raw = fetch_live(args.cookie, out)
else:
out = args.out or Path("hts4-manual-offline")
out.mkdir(parents=True, exist_ok=True)
if args.bz2:
packed = args.bz2.read_bytes()
if packed[:3] != b"BZh":
raise SystemExit(f"not a bzip2 file: {args.bz2}")
raw = bz2.decompress(packed)
xml = out / "instance.xml"
xml.write_bytes(raw)
url = "offline-bz2"
else:
xml = args.xml.resolve()
url, packed, raw = "offline", b"", xml.read_bytes()
counts, files = render(xml, out / "colours")
result = {
"xml": str(xml),
"url": url,
"xml_sha256": hashlib.sha256(raw).hexdigest(),
"bz2_sha256": hashlib.sha256(packed).hexdigest() if packed else None,
"out": str(out),
"colour_dir": str(out / "colours"),
"files": files,
"counts": counts,
"elapsed_s": round(time.monotonic() - started, 3),
"posted": False,
}
(out / "result.json").write_text(
json.dumps(result, indent=2), encoding="utf-8"
)
print(json.dumps(result, indent=2))
print("Read montages in order: " + ",".join(ORDER))


if __name__ == "__main__":
main()

Challenge

题目提供 stego14.tar.gz,提示 Get the image here。需要先看清 tar 内部结构,再从图片里挖出隐藏的密钥与密文,最后用古典密码还原口令。 A tar.gz is provided; the answer is recovered by unpacking it, carving a RAR archive out of the JPG, and decrypting a classical cipher with a key that is itself hidden inside the archive.

Solution

1
2
3
$ tar tzf stego14.tar.gz
6578747261637400.jpg
$ tar xzf stego14.tar.gz

成员名 6578747261637400 是一串 ASCII 十六进制,解码后是 extract\0。这个名字在提示真正的载荷不在图片像素里,而在图片之后附加的数据里。

图片本体是一张 150x20 的基线 JPEG:

1
2
$ file 6578747261637400.jpg
JPEG image data, JFIF standard 1.02, baseline, precision 8, 150x20, components 3

JPEG 以 EOI(FF D9)结束,但文件真实长度超过了 EOI 的位置,后面还跟了一段数据。扫描标记来定位:

1
2
3
4
5
6
data = open("6578747261637400.jpg", "rb").read()
eoi = data.rfind(b"\xff\xd9")
print("file len :", len(data))
print("EOI at :", eoi)
print("trailing :", len(data) - eoi - 2, "bytes")
print("signature:", data[eoi + 2:eoi + 9])

输出显示 EOI 之后还有 5617 字节,且以 Rar! 开头,是一段 RAR v4 归档。把它切出来解开:

1
2
3
4
5
6
7
8
$ python3 -c "d=open('6578747261637400.jpg','rb').read(); i=d.rfind(b'\xff\xd9'); open('appended.rar','wb').write(d[i+2:])"
$ file appended.rar
appended.rar: RAR archive data, v4, os: Win32
$ 7z l appended.rar
Date Time Attr Size Compressed Name
------------------- ----- ------------ ------------ ------------------------
2009-01-23 01:02:14 ....A 12758 5546 key.jpg
$ 7z x appended.rar

归档里只有一个文件 key.jpg(48x13)。

key.jpg 是纯黑白位图,像素值只有黑(0)与白(255)两色,放大后是一行小字。直接按 ASCII 打印像素即可读:

1
2
3
4
5
6
from PIL import Image

im = Image.open("key.jpg").convert("L")
w, h = im.size
for y in range(h):
print("".join("#" if im.getpixel((x, y)) < 128 else "." for x in range(w)))

打印出来的是 *5+10。这正是仿射密码(Affine cipher)的密钥,含义为 E(x) = (5 * x + 10) mod 26,也就是 a = 5, b = 10。

6578747261637400.jpg 的像素对比度极低,灰度只落在 197–255 之间,肉眼看上去几乎全白。把灰度拉伸到完整范围后,里面藏着一行大写密文 PGNNZCFYXD:

1
2
3
4
5
6
from PIL import Image
import numpy as np

a = np.asarray(Image.open("6578747261637400.jpg").convert("L")).astype(int)
b = np.clip((255 - a) * 255.0 / (a.max() - a.min()), 0, 255).astype("uint8")
Image.fromarray(b).resize((a.shape[1] * 8, a.shape[0] * 8)).save("cipher_zoom.png")

a = 5 与 26 互质,模逆元为 21(5 * 21 = 105 ≡ 1 (mod 26))。解密公式是 D(y) = 21 * (y - 10) mod 26:

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
def affine_decrypt(cipher, a=5, b=10, alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
a_inv = pow(a, -1, len(alphabet))
out = []
for ch in cipher:
if ch.upper() in alphabet:
x = alphabet.index(ch.upper())
out.append(alphabet[(a_inv * (x - b)) % len(alphabet)])
else:
out.append(ch)
return "".join(out)

def affine_encrypt(plain, a=5, b=10, alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
out = []
for ch in plain:
if ch.upper() in alphabet:
x = alphabet.index(ch.upper())
out.append(alphabet[(a * x + b) % len(alphabet)])
else:
out.append(ch)
return "".join(out)

ct = "PGNNZCFYXD"
pt = affine_decrypt(ct)
print(pt)
print(affine_encrypt(pt) == ct) # True:往返一致
bulldozinj

Challenge

Steganography

The only hint you get is The LaughingMan. 唯一提示是 The LaughingMan。

题目提供一张 BMP 图片,密码藏在文件结构中。资源地址是 https://www.hackthissite.org/missions/stego/lvl/13.bmp。

Solution

Analysis

先确认 BMP 的声明信息和实际文件长度:

1
2
3
$ file 13.bmp
13.bmp: PC bitmap, Windows 3.x format, 138 x 34 x 24, image size 14144,
cbSize 14198, bits offset 54

再解析 BMP 文件头。24bpp、宽度 138 的行长度按 4 字节对齐后为 416 字节:

1
2
3
4
5
6
7
8
file length       = 21002
bfSize = 14198
pixel offset = 54
width × height = 138 × 34
bits per pixel = 24
row bytes = 416
pixel bytes = 416 × 34 = 14144
expected end = 54 + 14144 = 14198

文件头声明的合法结束位置是 14198,但实际文件有 21002 字节;多出的 6804 字节并不是简单地集中在文件尾部,而是混入了文件流。

对整个文件搜索题目中反复出现的 ASCII 文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pathlib import Path

raw = Path("13.bmp").read_bytes()
junk = b"I thought what I'd do was, I'd pretend I was one of those deaf-mutes"

positions = []
start = 0
while True:
pos = raw.find(junk, start)
if pos < 0:
break
positions.append(pos)
start = pos + 1

print("file length:", len(raw))
print("junk length:", len(junk))
print("junk occurrences:", len(positions))
print("junk bytes:", len(junk) * len(positions))
1
2
3
4
file length: 21002
junk length: 69
junk occurrences: 56
junk bytes: 3864

这里的字符串中,I'd 和 pretend 之间有两个空格;少一个空格就无法匹配实际数据。除了这 56 段文本,垃圾块后面还跟着一批 0x00 字节:

1
2
3
extra bytes       = 21002 - 14198 = 6804
repeated strings = 56 × 69 = 3864
zero padding = 6804 - 3864 = 2940

因此只删除 ASCII 文本还不够,残留的零填充仍会让像素行错位。清理逻辑是:遇到完整的垃圾字符串时跳过它,然后继续跳过紧随其后的 0x00 字节;其他字节按原顺序保留。

Step 1: 重建合法 BMP

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
#!/usr/bin/env python3
import struct
from pathlib import Path

source = Path("13.bmp").read_bytes()
junk = b"I thought what I'd do was, I'd pretend I was one of those deaf-mutes"

clean = bytearray()
pos = 0
junk_count = 0
zero_count = 0

while pos < len(source):
if source.startswith(junk, pos):
junk_count += 1
pos += len(junk)
while pos < len(source) and source[pos] == 0:
zero_count += 1
pos += 1
continue

clean.append(source[pos])
pos += 1

clean = bytes(clean)
Path("13_clean.bmp").write_bytes(clean)

_, declared_size, _, _, pixel_offset = struct.unpack_from(
"<2sIHHI", clean, 0
)
_, width, height, _, bpp, _, image_size = struct.unpack_from(
"<IiiHHII", clean, 14
)
row_bytes = ((width * (bpp // 8) + 3) // 4) * 4
expected_size = pixel_offset + row_bytes * abs(height)

print("junk blocks:", junk_count)
print("zero padding:", zero_count)
print("clean length:", len(clean))
print("declared size:", declared_size)
print("expected size:", expected_size)
print("image size:", image_size)
assert len(clean) == declared_size == expected_size
1
2
3
4
5
6
junk blocks: 56
zero padding: 2940
clean length: 14198
declared size: 14198
expected size: 14198
image size: 14144

清理后的文件重新满足 BMP 头部的全部长度约束。直接打开或放大 13_clean.bmp,即可看到隐藏文本:

1
2
3
4
$ convert 13_clean.bmp -resize 400% 13_clean.png
$ file 13_clean.bmp
13_clean.bmp: PC bitmap, Windows 3.x format, 138 x 34 x 24, image size 14144,
cbSize 14198, bits offset 54
acf42hvx10

Challenge

This is an encoded message, the only tip you get is 'I am the not of a file'.

Solution

Analysis

1
2
3
curl -sL -b "$HTS_COOKIE" -o 12.bmp \
'https://www.hackthissite.org/missions/stego/lvl/12.bmp'
file 12.bmp
1
2
12.bmp: PC bitmap, Windows 3.x format, 123 x 1 x 8, image size 126,
resolution 2834 x 2834 px/m, cbSize 1204, bits offset 1078

一张 123×1、只有 1 个像素高的 8 bpp(调色板) BMP,整个文件才 1204 字节。

先把 BMP 头字段算清楚:

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

d = open("12.bmp", "rb").read()
bf_type, bf_size, _, _, off = struct.unpack_from("<2sIHHI", d, 0)
hsz, w, h, planes, bpp, comp, img_size = struct.unpack_from("<IiiHHII", d, 14)
row = ((w * bpp + 31) // 32) * 4 # 8bpp 每行按 4 字节对齐
print("bfType", bf_type, "bfSize", bf_size, "dataOffset", off)
print("w", w, "h", h, "bpp", bpp, "biSizeImage", img_size)
print("row_bytes", row, "pixel_bytes", row * h)
print("palette_entries", (off - 14 - hsz) // 4)
print("trailing", len(d) - off - row * h)
1
2
3
4
5
bfType b'BM' bfSize 1204 dataOffset 1078
w 123 h 1 bpp 8 biSizeImage 126
row_bytes 124 pixel_bytes 124
palette_entries 256
trailing 2

dataOffset=1078 正好等于 14 + 40 + 1024, 即文件头 14 字节 + 信息头 40 字节 + 256 项调色板(每项 4 字节 = 1024)。 像素区是 124 × 1 = 124 字节(123 个像素 + 1 个对齐填充),像素之后只剩两个 0000 填充, 没有尾部附加文件。信息只能在像素值里。

确认这个调色板的内容:

1
2
3
4
5
6
7
import struct

d = open("12.bmp", "rb").read()
off = struct.unpack_from("<I", d, 10)[0]
pal = d[14 + 40:off]
gray = all(tuple(pal[i:i + 3]) == (i // 4, i // 4, i // 4) for i in range(0, len(pal), 4))
print("palette is grayscale identity:", gray)
1
palette is grayscale identity: True

调色板是恒等灰度斜坡:第 i 项就是 (i, i, i)。所以像素索引值等于它显示出来的灰度, 每字节一个像素,直接读原始字节即可。

题面 I am the not of a file 里的 not 就是按位取反:255 - x。 把 124 个像素字节逐个取反:

1
2
pixel bytes : af b4 fc fb eb ff ff ff ff ff 8b 7f 04 c7 a5 b5 ...
NOT (255-x): 50 4b 03 04 14 00 00 00 00 00 74 80 fb 38 5a 4a ...

取反后的头四个字节是 50 4b 03 04(PK\x03\x04):一个 ZIP 归档的本地文件头。 原来这张图片的像素值,本身就是某个文件按位取反后的样子,正如提示所说: 它就是某个文件的 not。

把取反结果当作 ZIP 打开:

1
2
3
4
5
6
7
8
9
10
11
12
13
import io
import struct
import zipfile

d = open("12.bmp", "rb").read()
off = struct.unpack_from("<I", d, 10)[0]
w, h, planes, bpp = struct.unpack_from("<iiHH", d, 18)
row = ((w * bpp + 31) // 32) * 4
px = d[off:off + row * h]
decoded = bytes(255 - b for b in px) # bitwise NOT
with zipfile.ZipFile(io.BytesIO(decoded)) as z:
print(z.namelist())
print(z.read("pass.txt").decode())
6ae4nt5TB

Challenge

Steganography — I am not Shakespeare!

题目图片里是一段关于 bacon(培根)的文字,要求从中找出密码。

Solution

题面那句 I am not Shakespeare! 是双关:字形用的是文字排版(图里每个字母有粗体和非粗体两种形态),这就是 Bacon 密码需要的两种符号。

1
2
3
粗体字母   -> B
非粗体字母 -> A
按阅读顺序每 5 个字母一组 -> 查 Bacon 表(26 字母版)
nothere

Challenge

Download the song Here kiss me alone

Solution

Analysis

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。单看左右声道各自都是一首连续的 电子舞曲,波形和频谱都很正常,秘密只在混音层面才会显形。

Step 1: WAV chunks

先按 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: Channel inversion

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

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: Carrier

对残差做精细 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: Morse

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

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))
k8x0w5bbuq

Challenge

Thank you t_n83

Solution

Analysis

1
2
3
curl -sL -b "$HTS_COOKIE" -o stego8.bmp \
'https://www.hackthissite.org/missions/stego/lvl/stego8.bmp'
file stego8.bmp
1
2
stego8.bmp: PC bitmap, Windows 3.x format, 157 x 97 x 24, image size 45784,
cbSize 45838, bits offset 54

一张 157×97 的 24 位 BMP,体积很小。直接打开只看到一幅白底图上印着一行字和一条下划线。

BMP 的隐写常见路径只有几条:尾部附加数据、头部声明尺寸和真实长度不符、调色板里的垃圾项。 先排除这几条常见路径:

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

d = open("stego8.bmp", "rb").read()
bf_type, bf_size, _, _, off = struct.unpack_from("<2sIHHI", d, 0)
hsz, w, h, _, bpp, comp, img_size = struct.unpack_from("<IiiHHII", d, 14)
row = ((w * 3 + 3) // 4) * 4 # 24bpp 每行按 4 字节对齐
print("bfType", bf_type, "bfSize", bf_size, "dataOffset", off)
print("w", w, "h", h, "bpp", bpp, "biSizeImage", img_size)
print("row_bytes", row, "pixel_bytes", row * h)
print("trailing", len(d) - off - row * h)
1
2
3
4
bfType b'BM' bfSize 45838 dataOffset 54
w 157 h 97 bpp 24 biSizeImage 45784
row_bytes 472 pixel_bytes 45784
trailing 0

像素区正好 472 × 97 = 45784 字节,文件在像素之后没有任何多余数据(trailing=0); 24 bpp 的 BMP 没有调色板,palette 这条线也不用查。信息只能在像素值里。

分别统计 R、G、B 三个通道的取值分布:

1
2
3
4
5
6
7
8
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB"))
for i, name in enumerate("RGB"):
vals, counts = np.unique(a[:, :, i], return_counts=True)
top = sorted(zip(counts.tolist(), vals.tolist()), reverse=True)[:4]
print(name, "uniq =", len(vals), "top =", top)
1
2
3
R uniq = 26 top = [(14849, 255), (293, 0), (20, 240), (15, 124)]
G uniq = 14 top = [(14849, 255), (308, 0), (20, 240), (15, 124)]
B uniq = 14 top = [(14849, 255), (308, 0), (20, 240), (15, 124)]

图像呈灰度外观,但通道统计显示:R 通道比 G、B 多出一批取值。进一步比对三个通道:

1
2
3
4
5
6
7
8
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
print("R!=G:", int((r != g).sum()), "G!=B:", int((g != b).sum()))
ys, xs = np.where(r != g)
print("bbox x=[%d..%d] y=[%d..%d]" % (xs.min(), xs.max(), ys.min(), ys.max()))
1
2
R!=G: 15 G!=B: 0
bbox x=[68..82] y=[50..50]

绝大多数像素满足 R == G == B(真正的灰度),只有 15 个像素例外,而且全部 集中在同一行 y=50、x 从 68 到 82 的一段连续像素上。这 15 个点就是全部秘密。

这 15 个像素的形态是 (R, 0, 0):绿、蓝被清零,红通道独自保留一个值。 把这一行原样打印出来:

1
2
3
4
5
6
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
for x in range(66, 85):
print(x, tuple(a[50, x].tolist()))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
66 (255, 255, 255)
67 (255, 255, 255)
68 (112, 0, 0)
69 (97, 0, 0)
70 (115, 0, 0)
71 (115, 0, 0)
72 (119, 0, 0)
73 (111, 0, 0)
74 (114, 0, 0)
75 (100, 0, 0)
76 (61, 0, 0)
77 (89, 0, 0)
78 (114, 0, 0)
79 (82, 0, 0)
80 (111, 0, 0)
81 (116, 0, 0)
82 (55, 0, 0)
83 (255, 255, 255)
84 (255, 255, 255)

这些值不构成图案,它们本身就是字节。

一串代码把上面几步串起来即可,提取结果:

1
2
3
4
5
6
7
8
9
10
11
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
ys, xs = np.where((r != g) & (g == b)) # 红独有像素 = 载体
order = np.argsort(xs)
xs, ys = xs[order], ys[order]
text = "".join(chr(int(a[y, x, 0])) for x, y in zip(xs.tolist(), ys.tolist()))
print(text)
print(text.split("=", 1)[1]) # 取等号后的答案
YrRot7

Challenge

Steganography

关卡页只给出 Download the image Here,下载链接指向一个 ZIP 文件: https://www.hackthissite.org/missions/stego/lvl/stego7.zip。

The image contains the password in one of its Photoshop layers.

Solution

Analysis

先下载并检查外层 ZIP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ curl -sL -b "$HTS_COOKIE" \
-o stego7.zip \
'https://www.hackthissite.org/missions/stego/lvl/stego7.zip'
$ unzip -l stego7.zip
Archive: stego7.zip
Length Date Time Name
--------- ---------- ----- ----
69392 2008-04-27 01:36 stego7.tif
--------- ---------- ----- ----
69392 1 file
$ unzip -t stego7.zip
testing: stego7.tif OK
No errors detected in compressed data of stego7.zip.
$ unzip -o stego7.zip

ZIP 没有加密,里面只有一个 stego7.tif。外层 ZIP 只是把真正的载体再包了一层,直接对 ZIP 本身做图片隐写分析不会得到结果。

Step 1: 确认 TIFF 的图层信息

1
2
3
4
$ file stego7.tif
stego7.tif: TIFF image data, little-endian, direntries=22, width=175, \
height=36, bps=278, compression=none, PhotometricInterpretation=RGB, \
orientation=upper-left

普通 TIFF 信息只能看到一张 175×36 的 RGB 图;tiffinfo 也只列出一个正常的 TIFF directory。继续查看 Photoshop 私有数据块:

1
2
3
4
5
6
7
8
9
10
11
$ exiftool -a -G1 -s stego7.tif | grep -E \
'Layer|Software|ImageWidth|ImageHeight|BitsPerSample|Compression'
[IFD0] ImageWidth : 175
[IFD0] ImageHeight : 36
[IFD0] BitsPerSample : 8 8 8
[IFD0] Compression : Uncompressed
[IFD0] Software : Adobe Photoshop CS2 Windows
[Photoshop] LayerCount : 3
[Photoshop] LayerRectangles : 0 0 36 175, 9 0 36 175, 0 0 35 175
[Photoshop] LayerNames : Layer 2, Layer 0, Layer 1
[Photoshop] LayerVisible : Yes, Yes, Yes

关键点是:图层不在普通 TIFF directory 中,而是存放在 Photoshop 的 Image Resource 数据里。普通图片查看器只显示合成结果,密码所在的图层/alpha 通道会被背景干扰。

Step 2: 导出图层的 alpha 通道

ImageMagick 会把 Photoshop 图层作为额外的 TIFF scene 导出。逐个取 alpha 通道并放大:

1
2
3
4
$ for n in 0 1 2 3; do
convert "stego7.tif[$n]" -alpha extract -resize 1000% \
"alpha-$n.png"
done

前几个 scene 主要是合成图或空白/干扰内容;最后一个 alpha 图中出现清晰的点阵字形。可以直接目视读取

Key points

这关的载荷不在 ZIP 的压缩数据、TIFF 的普通像素或常规 EXIF 字段里,而在 Photoshop 保存的图层数据中。排查顺序应是:

  1. 解开外层 ZIP,确认真正的载体;
  2. 用 exiftool 检查 Photoshop 图层元数据;
  3. 导出各图层及其 alpha 通道;
4aH5CEta

Challenge

A PNG image is provided. The only hint is Thank you Leafman. 题目提供了一张 PNG 图片,唯一的提示是 Thank you Leafman(感谢 Leafman)。

数据文件在 https://www.hackthissite.org/missions/stego/lvl/stego6.png,是一个 100x100 的 RGBA PNG。

Solution

Step 1: Container

file 只认出这是 100x100 的 8-bit RGBA PNG,共 1965 字节:

1
2
3
4
5
6
$ file stego6.png
stego6.png: PNG image data, 100 x 100, 8-bit/color RGBA, non-interlaced
$ wc -c stego6.png
1965 stego6.png
$ sha256sum stego6.png
207ef63dfb3d052e347d0b91c9ad9523f8e8212505013030abdbdbd9ff8fa751 stego6.png

PNG 是块(chunk)结构,先用 pngcheck -v 走一遍每个块:

1
2
3
4
5
6
7
8
9
10
11
12
$ pngcheck -v stego6.png
File: stego6.png (1965 bytes)
chunk IHDR at offset 0x0000c, length 13
100 x 100 image, 32-bit RGB+alpha, non-interlaced
chunk bKGD at offset 0x00025, length 6
red = 0x00ff, green = 0x00ff, blue = 0x00ff
chunk tIME at offset 0x00037, length 7: 12 Nov 2007 13:51:36 UTC
chunk IDAT at offset 0x0004a, length 1779
zlib: deflated, 32K window, default compression
chunk IEND at offset 0x00749, length 0
additional data after IEND chunk
ERRORS DETECTED in stego6.png

结构很干净:IHDR、一个背景色块 bKGD、一个时间戳块 tIME、一个 IDAT、IEND。bKGD 和 tIME 都是普通元数据,没有藏东西。唯一的异常是最后一行:IEND 之后还有数据。

IEND 是 PNG 的结束标记,规范上文件到它就结束。这里 IEND 块结束于偏移 0x751,而文件长 0x7AD,即后面多出 92 字节。pngcheck 把声明结束点之后还有字节直接报告成 ERRORS DETECTED。

Step 2: Pixel analysis

PNG 隐写的另一条常见路径是把数据写入 IDAT 解码后的像素位平面(LSB)。用 zsteg -a 扫全部通道、全部位平面:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ zsteg -a stego6.png | tr '\r' '\n' | sed '/^[[:space:]]*$/d' | head -n 12
extradata:0 .. text: "Tm90IGxpa2UgaXQncyBoYXJkIHRvICdkZWNyeXB0JyB0aGlzIGh1aD8gVGhlIHBhc3N3b3JkIGlzIGhnYnZadzA3Lg=="
chunk:0:IHDR .. file: Adobe Photoshop Color swatch, version 0, 100 colors; 1st RGB space (0), w 0x64, x 0x806, y 0, z 0; 2nd RGB space (0), w 0, x 0, y 0, z 0
b1,r,lsb,xy ..
b1,r,msb,xy ..
b1,g,lsb,xy ..
b1,g,msb,xy ..
b1,b,lsb,xy ..
b1,b,msb,xy ..
b1,a,lsb,xy ..
b1,a,msb,xy ..
b1,rgb,lsb,xy ..
b1,rgb,msb,xy ..

(zsteg -a 的原始输出用 \r 分隔通道,这里用 tr 归一化成每行一个通道再看。)

所有 bN,channel,lsb/msb 位平面要么为空,要么只是 text: ["U" repeated 21 times] 这类单字符重复的噪声(那是图像本身大色块的规律,不是文本),没有一处读出可读字符串。第 2 行 chunk:0:IHDR 是 zsteg 把 IHDR 头字节误认成 Photoshop 色板,与本题无关。zsteg 唯一有效的一行是 extradata:0:IEND 之后的 92 字节额外数据,内容是 Base64 文本。

Step 3: Payload

定位 IEND 块结束的位置,取出后面的字节:

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
#!/usr/bin/env python3
"""HackThisSite Steganography 6 - extract the appended payload from stego6.png.

The PNG container is normal (IHDR / bKGD / tIME / IDAT / IEND). After the IEND
chunk there are 92 extra bytes that are not part of the image stream: an ASCII
Base64 string. Decoding it yields a sentence that carries the password.
"""
import base64
import sys


def extract(path):
data = open(path, "rb").read()
i = data.find(b"IEND") # IEND is unique in a valid PNG
end = i + 8 # chunk length(4) + type(4) = end of IEND
trailer = data[end:].strip() # everything after the PNG stream
message = base64.b64decode(trailer).decode("ascii")
return end, trailer.decode("ascii"), message


def main():
path = sys.argv[1] if len(sys.argv) > 1 else "stego6.png"
end, b64, message = extract(path)
print("IEND ends at offset:", hex(end))
print("trailing bytes :", len(b64))
print("Base64 :", b64)
print("decoded message :", message)


if __name__ == "__main__":
main()
hgbvZw07

Challenge

Steganography

题目给一张 BMP(/missions/stego/lvl/stego5.bmp),密码藏在像素数据里。

Solution

这关的手法是把像素值当数据(与改扩展名/尾部追加那类做法不同): 把 BMP 转成十六进制转储后,可以看到像素三元组(每 3 个十六进制字节 = 一个像素的 RGB)里 存在有规律的偏差。某些分量比邻近像素略高或略低,这些偏差就是被编码的比特。

1
2
3e 3f 3f   4e 4f 4f   42 43 42   3b 3b 0a
0 1 1 0 1 1 0 1 0 1 1 0

把这类偏差按顺序抽出来拼成位流,再按 8 位一组转 ASCII

Key points

把信息藏进像素的低位/微差是最经典的隐写:对肉眼和普通查看器完全不可见, 但只要把图像当作字节数组而不是图片来读,规律就立刻暴露。 它只能对抗只看图不看数据的处理流程。

syn-ack-rst