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:
$ 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.xmlXML 中的 Line 和 Arc 按 Color 字段归类;缺少 Color 的图元归入 white。坐标按题面 1000×1000 画布绘制,每种颜色单独输出完整画布,不裁剪或拆分字符。PIL 的 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。
$ 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/。完整脚本如下:
# -- 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 neverwrites 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 argparseimport bz2import hashlibimport jsonimport math170 collapsed lines
import osimport reimport sysimport timeimport xml.etree.ElementTree as ETfrom pathlib import Pathfrom 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 = 1000SCALE = 4WIDTH = 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()