HackThisSite - Programming Mission 6

Challenge

Bypass the image captcha

This level is about OCR. Write a program which is able to read all the characters in the given image, and let it beat this image captcha automatically. Type in all characters from the image which you can find HERE. Begin from the innermost character and continue clockwise. You have 30 seconds time to send the solution.

状态:verified(服务端返回 Good Job, ***, You have successfully completed this mission)。

这道题的 OCR 标题是个幌子:图片端点返回的是矢量图元,没有像素可以处理 (/missions/prog/6/image.png 不存在)。所以这篇 writeup 记的是真正验证通过的路线: 把矢量图元当数据解析,靠几何重建字形。

Solution

1. 先看清端点返回的是什么

实例页 https://www.hackthissite.org/missions/prog/6/ 里的链接指向 /missions/prog/6/image。直接 GET 它拿到的是 HTMLContent-Type: text/html), 内容是真正的 drawIt()(完整数组约 3350 个整数,下面只列开头):

1
2
3
4
5
6
$ curl -sL -b '<mission-cookie>' 'https://www.hackthissite.org/missions/prog/6/image/'
\r\n<html>\r\n<head>\r\n\r\n<script type="text/javascript">\r\n<!--\r\n
var strHTML = "";\t\r\n
function drawIt()\r\n{\r\n
var drawData = new Array(565,623,565,618,768,354,757,364,520,643,528,645,
794,563,782,556,658,452,672,448,700,663,697,656,490,519,486,520,363,464,

drawData 是一串扁平整数,drawIt() 每读 4 个数画一条线、每读 5 个数画一段圆弧 (第 5 个字段是起始角度,因为 data[i+2] >= 10 用来区分):第 3 个值为半径, 半径小于 10 就说明这条记录实际是 起点 x、起点 y、终点 x、终点 y 的线段。判据就藏在 数据本身里:半径不可能是 10 以下,所以小值一定是坐标

一个很容易浪费半小时的坑:/missions/prog/6/image 必须补尾斜杠。 不带斜杠会 301 到 /image/,但重定向响应的 body 是空的(只有 \r\n 两个字节), 解析器只会报 drawData not found。第 5 关的 corrupted.png.bz2 是同一个坑。 在线 solver 里第一步就是 if not url.endswith("/"): url += "/"

2. 图元 → 字形

把图元按原样画进一张 idmap[x][y] = 第几个图元 的栅格图,然后对栅格做 8 邻域洪泛: 连成一片的像素就是一个字形,顺便把该字形用到的图元 id 全部收集起来。 这样字形切分完全不依赖列投影之类的排版假设,对螺旋上各种朝向的字形都成立。

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
import math
import re
import time

import numpy as np
import requests
from scipy.spatial import cKDTree

BASE = "https://www.hackthissite.org"
N_CHARS = 253
STEP = 0.35 # point-cloud sampling spacing, in device px


def session(cookie):
s = requests.Session()
s.headers.update({
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Cookie": cookie,
"Referer": BASE + "/missions/programming/",
})
return s


def fetch_drawdata(s):
"""Instance page -> captcha URL -> the flat drawData array. Timer starts here."""
page = s.get(BASE + "/missions/prog/6/", timeout=20).text
hit = re.search(r'(?:href|src)="([^"]*prog/6/image[^"]*)"', page)
url = hit.group(1) if hit else "/missions/prog/6/image"
if not url.endswith("/"):
url += "/" # bare path 301s but the redirect body is empty
if url.startswith("/"):
url = BASE + url
html = s.get(url, timeout=20,
headers={"Referer": BASE + "/missions/prog/6/"}).text
m = re.search(r"new Array\(([^)]*)\)", html, re.S)
return [int(x) for x in re.findall(r"-?\d+", m.group(1))]


def primitives(data):
"""Split drawData exactly the way drawIt() does: 4 ints = line, 5 ints = arc."""
i, out = 0, []
while i + 2 < len(data):
if data[i + 2] >= 10:
out.append(("L", *[float(v) for v in data[i:i + 4]]))
i += 4
else:
out.append(("A", *[float(v) for v in data[i:i + 5]]))
i += 5
return out


def rasterize(prims, w, h):
"""idmap[x][y] = index of the primitive that painted that pixel."""
idmap = [[-1] * h for _ in range(w)]
for idx, p in enumerate(prims):
if p[0] == "L":
x1, y1, x2, y2 = p[1:5]
n = max(2, int(math.hypot(x2 - x1, y2 - y1) * 2))
pts = [(x1 + (x2 - x1) * t / n, y1 + (y2 - y1) * t / n)
for t in range(n + 1)]
else:
x0, y0, r, s, e = p[1:6]
n = max(2, int(abs(e) / 4.0))
pts = [(x0 + r * math.cos(math.radians(s + e * t / n)),
y0 - r * math.sin(math.radians(s + e * t / n)))
for t in range(n + 1)]
for fx, fy in pts:
xx, yy = int(round(fx)), int(round(fy))
if 0 <= xx < w and 0 <= yy < h:
idmap[xx][yy] = idx
return idmap


def glyphs(idmap):
"""8-neighbour flood fill: one connected blob = one character."""
w, h = len(idmap), len(idmap[0])
seen = [[False] * h for _ in range(w)]
out = []
for sx in range(w):
for sy in range(h):
if idmap[sx][sy] < 0 or seen[sx][sy]:
continue
stack, pts, ids = [(sx, sy)], [], set()
seen[sx][sy] = True
while stack:
x, y = stack.pop()
pts.append((x, y))
ids.add(idmap[x][y])
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
nx, ny = x + dx, y + dy
if (0 <= nx < w and 0 <= ny < h and not seen[nx][ny]
and idmap[nx][ny] >= 0):
seen[nx][ny] = True
stack.append((nx, ny))
if len(pts) >= 4: # drop 1-3 px specks
xs = [q[0] for q in pts]
ys = [q[1] for q in pts]
out.append({"ids": sorted(ids),
"cx": sum(xs) / len(xs),
"cy": sum(ys) / len(ys),
"n": len(pts)})
return out

3. 字形排进螺旋

题面已经给了读序:从最内层开始,顺时针。字形落在一条对数螺旋上:每 10 度放一个字形, 半径每步乘 1.005。于是读序 = 第几圈(半径)主序 + 圈内第几个(角度)次序。

先网格搜索螺旋中心:让所有字形的极角落进 10 度格子(最内圈的几个字形半径太小、 角度噪声大,直接跳过不参与打分)。再把 序号 k = 圈内角序 j + 36 * 圈号 m 迭代拟合 ln r = A + B·kB = ln 1.005),直到分配稳定:

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
from collections import Counter

B_LOG = math.log(1.005)


def fit_center(gs, init):
"""Grid-search the spiral centre so every glyph angle lands on the 10-deg lattice."""
def score(cx, cy):
tot, cnt = 0.0, 0
for g in gs:
if math.hypot(g["cx"] - cx, g["cy"] - cy) < 150:
continue
ang = math.degrees(math.atan2(g["cy"] - cy, g["cx"] - cx))
tot += abs((ang + 5.0) % 10.0 - 5.0) ** 2
cnt += 1
return tot / max(1, cnt)

best = (score(*init), init[0], init[1])
for span, step in ((80.0, 1.0), (4.0, 0.2), (0.6, 0.05)):
x0, y0 = best[1], best[2]
x = x0 - span
while x <= x0 + span:
y = y0 - span
while y <= y0 + span:
sc = score(x, y)
if sc < best[0]:
best = (sc, x, y)
y += step
x += step
return best[1], best[2]


def order_glyphs(gs, cx, cy):
"""k = angle index + 36 * turn; k = 0 is the innermost glyph, k grows clockwise."""
for g in gs:
g["r"] = math.hypot(g["cx"] - cx, g["cy"] - cy)
g["ang"] = math.degrees(math.atan2(g["cy"] - cy, g["cx"] - cx)) % 360.0
g["slot"] = int(round(g["ang"] / 10.0)) % 36
cnt = Counter(g["slot"] for g in gs)
j0 = max(cnt, key=lambda s: cnt[s]) # innermost slot holds the extra glyph
for g in gs:
g["j"] = (g["slot"] - j0) % 36
A = math.log(min(g["r"] for g in gs))
for _ in range(8): # iterate ln r = A + B*k
for g in gs:
m = round((math.log(g["r"]) - A - B_LOG * g["j"]) / (36.0 * B_LOG))
m = min(max(m, 0), 7)
k = g["j"] + 36 * m
g["k"] = k if 0 <= k <= N_CHARS - 1 else None
pts = [(g["k"], math.log(g["r"])) for g in gs if g["k"] is not None]
n = len(pts)
mk = sum(a for a, b in pts) / n
mr = sum(b for a, b in pts) / n
slope = (sum((a - mk) * (b - mr) for a, b in pts)
/ sum((a - mk) ** 2 for a, b in pts))
A = mr - slope * mk
return sorted([g for g in gs if g["k"] is not None], key=lambda g: g["k"])

4. 只读 16 个原型

字符集是大写十六进制0123456789ABCDEF),而且字形是矢量定义的:同一个字符的路径 形状每次完全一样,只是被整体旋转过(旋转角等于它所在位置的极角 + 90 度,让字形的上 朝外)。所以只要把每个字形转回标准朝向,同一字符的点云就能完全重合。

做法:把每个字形的图元绕自身中心反向旋转 -(ang + 90) 度,再把图元采成点云、 减去自身均值(消掉平移),就得到朝向归一化的点云。

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
def rotate_prim(p, cx, cy, deg):
"""Rotate a primitive about (cx, cy) by deg degrees in screen coords."""
th = math.radians(deg)
c, s = math.cos(th), math.sin(th)

def R(x, y):
dx, dy = x - cx, y - cy
return (cx + dx * c - dy * s, cy + dx * s + dy * c)

if p[0] == "L":
x1, y1 = R(p[1], p[2])
x2, y2 = R(p[3], p[4])
return ("L", x1, y1, x2, y2)
x, y = R(p[1], p[2])
return ("A", x, y, p[3], p[4] - deg, p[5]) # the start angle shifts too


def sample(prims):
"""Sample canonical primitives to a point cloud, centred on its own mean."""
pts = []
for p in prims:
if p[0] == "L":
x1, y1, x2, y2 = p[1:5]
n = max(2, int(math.hypot(x2 - x1, y2 - y1) / STEP))
pts += [(x1 + (x2 - x1) * t / n, y1 + (y2 - y1) * t / n)
for t in range(n + 1)]
else:
x0, y0, r, s, e = p[1:6]
n = max(2, int(abs(e) / 4.0))
pts += [(x0 + r * math.cos(math.radians(s + e * t / n)),
y0 - r * math.sin(math.radians(s + e * t / n)))
for t in range(n + 1)]
a = np.array(pts, dtype=np.float64)
a -= a.mean(0)
return a


def cloud(g):
"""Orientation-normalised point cloud of one glyph."""
ang = round(g["ang"] / 10.0) * 10.0
return sample([rotate_prim(p, g["cx"], g["cy"], -(ang + 90.0))
for p in g["prims"]])

离线阶段把 253 个点云两两算 Chamfer 距离、做完全连接层次聚类,t = 0.80 正好切出 16 类,和字符集大小一致(这一步本身就是 16 类假设的验证:多一类少一类都说明阈值错了)。 每类取 medoid(到同类其他成员平均距离最小的那个字形)作为原型,把 16 个原型打成 ASCII 点阵人眼看一遍,就得到标签表。下面是复核过的其中两个原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
label=8  medoid=k1            label=4  medoid=k38
|..#####.. |....#...
|..#...##. |.#..#...
|.#.....#. |.#..#...
|.#.....#. |.#..#...
|..#...#.. |#...#...
|..#####.. |########
|.##....#. |....#...
|.#......# |....#...
|#.......# |....#...
|#.......# |....#...
|.#......# |....#...
|.##...##. |....#...
|...####.. |....#...

16 个原型全部人工确认过一次(这一步不能省:标签错一个字符,整串就错一个位置, 而服务端只会回一句 wrong,不会指出错误位置)。原型存成 templates_gen.py, 在线阶段只做最近原型查表。

5. 最近模板分类

点云之间用对称 Chamfer 距离比形状:对每个点找另一方最近点的距离,两个方向取平均。 比位图 IoU 更耐受采样密度差异,也不需要对齐网格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
TEMPLATES = {          # char -> list of canonical primitives, auto-generated offline
}


def classify(order, templates):
tchars = sorted(templates)
tclouds = {c: sample(templates[c]) for c in tchars}
trees = {c: cKDTree(tclouds[c]) for c in tchars}
chars, margins = [], []
for g in order:
c = cloud(g)
ct = cKDTree(c)
ranked = sorted((0.5 * (trees[t].query(c)[0].mean()
+ ct.query(tclouds[t])[0].mean()), t)
for t in tchars)
chars.append(ranked[0][1])
margins.append(ranked[1][0] - ranked[0][0])
return "".join(chars), min(margins)

自测里这个判据非常干净:253 个字形零误分类,且最近 vs 次近的最小间隔是 0.26 px (中位数 0.72 px)。也就是说没有任何一个字形的两候选挨得很近,分类结果没有模糊地带。

6. 在线 solver

30 秒限时从实例页生成那一刻开始算,所以不能拆成几步手工执行,必须一个进程内完成, 而且 POST 前要重新算一次时间预算。给足余量后:

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
def solve_once(cookie, dry=False):
s = session(cookie)
t0 = time.time()

data = fetch_drawdata(s) # timer starts here
prims = primitives(data)
w = int(max(p[1] for p in prims)) + 30
h = int(max(p[2] for p in prims)) + 30
gs = glyphs(rasterize(prims, w, h))
init = (sum(g["cx"] for g in gs) / len(gs),
sum(g["cy"] for g in gs) / len(gs))
cx, cy = fit_center(gs, init)
order = order_glyphs(gs, cx, cy)
for g in order:
ids = set(g["ids"])
g["prims"] = [p for i, p in enumerate(prims) if i in ids]
answer, margin = classify(order, TEMPLATES)
t_solve = time.time()

if dry:
return answer, t_solve - t0
resp = s.post(BASE + "/missions/prog/6/index.php",
data={"solution": answer, "submitbutton": "submit"},
timeout=30, headers={"Referer": BASE + "/missions/prog/6/"}).text
ok = "successfully completed" in resp or "Congrats" in resp
return answer, time.time() - t0, ok


def main(cookie):
answer, t_fetch_solve = solve_once(cookie, dry=True)
print("fetch+solve %.2fs len=%d margin>=%.2f"
% (t_fetch_solve, len(answer), 0.0))
answer, total, ok = solve_once(cookie)
print("total %.2fs accepted=%s" % (total, ok))

如果要给读序(方向 / 半径)留后手,就把 order 的排列换成候选序列 ((圈号, 角序) 的四种符号组合,以及按角序主序的轮辐式读法),每次失败都重新 fetch 再提交,因为答案每取一次页面就重新生成一次。实测不需要:第一次提交(最内层起、顺时针) 就被接受。

7. 自测与实战结果

离线自测用缓存下来的旧实例重现参考串,逐字节比对:

1
2
3
4
$ uv run python solve.py --selftest
glyphs=253 classes=0123456789ABCDEF
chamfer margin: min=0.264 p5=0.417 med=0.715 (weak<0.10: 0)
SELFTEST PASS - identical vs reference string

在线一次真实运行(--dry 只取不解,用来量时间):

1
2
$ uv run python solve.py --dry
DRY: fetch+solve in 4.17s (glyphs=253 t_fetch=2.16s t_solve=2.00s margin_min=0.231)

正式提交:

1
2
3
4
5
6
$ uv run python solve.py
[1] k-asc (inner->outer, CW) fetch=2.16s solve=1.95s total=5.36s -> ACCEPTED

--- response ---
Congrats
Good Job, ***, You have successfully completed this mission

fetch 到 submit 5.36 秒,不到限时 30 秒的两成;两次 HTTP 往返约 2.2 秒,点云分类 2.0 秒。

Vulnerabilities

题目把解题成本押在 OCR 的难度上,但答案并未经过像素: 客户端收到完整的矢量几何,服务端只比对一串文本。把形状数据下发给客户端、 再由客户端回传答案的验证码,等同于把答案一并下发;把字形转成点云、 按形状查表,恢复率接近 100%,旋转这类混淆对形状匹配不起作用。

防护需要两点:一是挑战由服务端渲染成位图后再下发,几何、字体、路径数据不出内网, 否则形状归一化加最近原型匹配即可识别;二是答案与实例一对一且一次性消费,30 秒窗口 与每次 GET 重新生成已经正确,缺的是限制同一实例的提交次数:当前被拒后可换排列重试, 等于给攻击者留了在线枚举读序的空间。另外这类端点要注意重定向的空 body (少一个尾斜杠就静默拿到 0 字节),排查时先看 Content-Type 和实际字节数, 状态码 200 不等于拿到了内容。

1DC830171AC5912279E6D1B037CB6C8E0ACA04884B342BC0B1CE8E52B837A2B13E19807311D93004839674F8286318BAC3AA9740F808E62CF19DCEC5D8F4604994A824E825671EA769B8ED32B8C8F77F27DB229AD575A736745C7F49E783468D3CEC5DC99A5A8E768286735C035BF4E973B27E26FD7D14A0C8CA18C828973(本次实例;每次取页面重新生成)