HackThisSite - Programming Mission 4

Challenge

Level 4 — Parse an XML file

随机生成的 XML 描述若干 LineXStart/XEnd/YStart/YEnd)与 ArcXCenter/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 中的 LineArcColor 字段归类;缺少 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.png02-green.png03-red.png04-yellow.png05-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()