HackThisSite - Programming Mission 2

Challenge

Level 2 — Analyze the picture and find the ascii code

The pixels in the above image are numbered 0..99 for the first row, 100..199 for the second row etc. White pixels represent ascii codes. The ascii code for a particular white pixel is equal to the offset from the last white pixel. For example, the first white pixel at location 65 would represent ascii code 65 ('A'), the next at location 131 would represent ascii code (131 - 65) = 66 ('B') and so on. The text contained in the image is the answer encoded in Morse, where a test would be encoded as .- / - . ... -

图片像素按行编号(第 1 行 0..99,第 2 行 100..199,依此类推),白色像素表示 ASCII 码:某个白像素的 ASCII 值 = 它相对上一个白像素的位置偏移(第一个白像素相对 0 计)。把偏移还原成字符会得到一串 Morse 编码,解出的文本就是答案,限时 15 秒。

每次访问实例页都会重新生成一张随机图片和对应答案,15 秒内必须完成取图 → 解码 → 提交,手动看是来不及的。状态:verified(服务端返回 Good Job, ***, You have successfully completed this mission)。

Solution

  • 实例页 HTML 里图片标签是 <img src="/missions/prog/2/PNG" alt="Image" />
  • 直接请求 /missions/prog/2/PNG 返回 301 跳到带斜杠的 /missions/prog/2/PNG/,图片真实地址是后者;少一个斜杠就多一次重定向,对 15 秒的预算不划算。
  • file 报告 PNG image data, 100 x 30, 1-bit colormap:100 列正好对应题面的行编号规则,1-bit palette 里 index 0 = 黑, index 1 = 白

Step 1: 图片地址与格式

1
2
3
4
5
6
7
8
9
10
$ cd <hts-workspace> && export HTS_COOKIE='<mission-cookie>'
$ uv run python -c "
from common import session
s = session()
for u in ['https://www.hackthissite.org/missions/prog/2/PNG',
'https://www.hackthissite.org/missions/prog/2/PNG/']:
r = s.get(u, allow_redirects=False)
print(u, r.status_code, r.headers.get('Location'), len(r.content), r.headers.get('Content-Type'))"
https://www.hackthissite.org/missions/prog/2/PNG 301 http://www.hackthissite.org/missions/prog/2/PNG/ 256 text/html; charset=iso-8859-1
https://www.hackthissite.org/missions/prog/2/PNG/ 200 None 149 image/png
1
2
$ file evidence_sample.png
evidence_sample.png: PNG image data, 100 x 30, 1-bit colormap, non-interlaced

Step 2: 像素 → ASCII → Morse

扫图顺序是逐行、行内从左到右,线性位置 pos = y*100 + x。维护 prev(上一个白像素的位置,初值 0),遇到白像素就输出 chr(pos - prev),再把 prev 更新为 pos。因为字符就是 45 ('-')46 ('.')32 (' ') 这三个 ASCII 值,还原出来的字符串天然就是 Morse:空格分隔字母。一次真实样本的完整中间结果:

1
2
3
4
white pixel linear positions: [46, 92, 124, 169, 214, 260, 306, 338, 383, 428, 474, 520, 552, 597, 642, 688, 734, 766, 812, 858, 903, 949, 981, 1027, 1072, 1117, 1149, 1194, 1239, 1284, 1329, 1374, 1406, 1452, 1498, 1543, 1589, 1621, 1666, 1711, 1757, 1789, 1835, 1880, 1925, 1970, 2015, 2047]
offsets -> ascii codes : [46, 46, 32, 45, 45, 46, 46, 32, 45, 45, 46, 46, 32, 45, 45, 46, 46, 32, 46, 46, 45, 46, 32, 46, 45, 45, 32, 45, 45, 45, 45, 45, 32, 46, 46, 45, 46, 32, 45, 45, 46, 32, 46, 45, 45, 45, 45, 32]
ascii chars : '.. --.. --.. --.. ..-. .-- ----- ..-. --. .---- '
morse -> answer : IZZZFW0FG1

逐个核对着色位移:46-0 = 46 = '.'92-46 = 46 = '.'124-92 = 32 = ' ';后面的 45'-'。三个偏移值恰好覆盖 Morse 的全部符号与分隔符,说明偏移量 = ASCII 值、逐行顺序扫描的假设成立。Morse 只编码 A–Z 与 0–9,提交的是解出的明文串而不是 Morse 码本身。

Step 3: 一次运行内提交

challenges/hts-prog/2/solve.py 全文(依赖 challenges/hts-prog/common.py 里的 session()/fetch_level()/submit(),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
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
#!/usr/bin/env python3
"""HackThisSite Programming level 2 solver.

Task: "Analyze the picture and find the ascii code" (15 second time limit).

Pipeline (all inside one run, well under 15 s):

1. GET the level page -> the server generates a fresh random answer and
the matching 100x30 1-bit PNG for this session.
2. GET /missions/prog/2/PNG/ (same session) -> the current image.
3. Scan the image left-to-right / top-to-bottom. A white pixel at linear
position p encodes chr(p - previous_white_position) (the first white
pixel is measured from 0). The resulting characters are a Morse string:
'-' (0x2d) = dash, '.' (0x2e) = dot, ' ' (0x20) = letter separator.
4. Morse-decode the string -> the answer (a random uppercase A-Z0-9 word).
5. POST it as `solution` to the level page.

The session cookie is read from the HTS_COOKIE environment variable by
common.py and never written to disk.

Set HTS_DRY=1 to decode without submitting (useful for a timing rehearsal).

Run from the workspace root:
export HTS_COOKIE='HackThisSite=...'
uv run python challenges/hts-prog/2/solve.py
"""

import io
import os
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from common import body_text, fetch_level, session, submit # noqa: E402

from PIL import Image # noqa: E402

PNG_URL = "https://www.hackthissite.org/missions/prog/2/PNG/"

# Standard international Morse. The image only ever encodes A-Z and 0-9.
MORSE = {
".-": "A", "-...": "B", "-.-.": "C", "-..": "D", ".": "E", "..-.": "F",
"--.": "G", "....": "H", "..": "I", ".---": "J", "-.-": "K", ".-..": "L",
"--": "M", "-.": "N", "---": "O", ".--.": "P", "--.-": "Q", ".-.": "R",
"...": "S", "-": "T", "..-": "U", "...-": "V", ".--": "W", "-..-": "X",
"-.--": "Y", "--..": "Z",
"-----": "0", ".----": "1", "..---": "2", "...--": "3", "....-": "4",
".....": "5", "-....": "6", "--...": "7", "---..": "8", "----.": "9",
}


def decode_image(data):
"""Return (morse_string, list_of_ascii_codes) for a compiled PNG blob."""
im = Image.open(io.BytesIO(data))
w, h = im.size
px = im.load()
codes = []
prev = 0
for y in range(h):
for x in range(w):
if px[x, y]: # palette index 0 = black, 1 = white (1-bit PNG)
cur = y * w + x
codes.append(cur - prev)
prev = cur
return "".join(chr(c) for c in codes), codes


def morse_decode(morse):
"""Decode a space separated Morse string; '/' separates words."""
out = []
for token in morse.split(" "):
if token == "":
out.append(" ")
elif token == "/":
out.append(" ")
elif token in MORSE:
out.append(MORSE[token])
else:
raise ValueError("unrecognised Morse token: %r" % token)
return "".join(out).strip()


def main():
dry = bool(os.environ.get("HTS_DRY"))
s = session()
t0 = time.monotonic()

# 1. load the level page -> server picks a new answer for this session
fetch_level(s, 2, keep=os.path.join(os.path.dirname(os.path.abspath(__file__)),
"live_level2.html"))
t_page = time.monotonic()

# 2. same session fetches the freshly generated image
r = s.get(PNG_URL, timeout=20)
r.raise_for_status()
img = r.content
t_img = time.monotonic()

# 3. pixels -> ASCII -> Morse string
morse, codes = decode_image(img)
print("image bytes :", len(img), "size:",
Image.open(io.BytesIO(img)).size)
print("ascii codes :", codes[:24], "... (%d total)" % len(codes))
print("morse string : %r" % morse)

# 4. Morse -> answer
answer = morse_decode(morse)
t_dec = time.monotonic()
print("answer : %s" % answer)

if not answer or not all(c.isalnum() for c in answer):
print("!! decoded answer does not look like a clean A-Z0-9 token")

print("timing : page=%.2fs img=%.2fs decode=%.2fs total=%.2fs"
% (t_page - t0, t_img - t_page, t_dec - t_img, t_dec - t0))

if dry:
print("HTS_DRY set -> not submitting")
return 0

# 5. submit
ok, resp = submit(s, 2, answer)
t_end = time.monotonic()
print("submit total : %.2fs" % (t_end - t0))
print("verdict :", ok)
print("response tail :")
print(body_text(resp)[-900:])
return 0


if __name__ == "__main__":
raise SystemExit(main())

运行(实测从 GET 实例页到提交成功 3.45 s,限时 15 s):

1
2
3
4
5
6
7
8
9
$ cd <hts-workspace> && export HTS_COOKIE='<mission-cookie>'
$ uv run python challenges/hts-prog/2/solve.py
image bytes : 149 size: (100, 30)
ascii codes : [45, 46, 46, 45, 32, 46, 46, 46, 45, 45, 32, 46, 45, 32, 45, 45, 46, 32, 46, 46, 45, 45, 45, 32] ... (44 total)
morse string : '-..- ...-- .- --. ..--- -.- --.- --. ..- .- '
answer : X3AG2KQGUA
timing : page=1.69s img=0.33s decode=0.02s total=2.04s
submit total : 3.45s
verdict : True

服务端响应正文:

1
2
3
Congrats
Good Job, ***, You have successfully completed this mission
Page Generated: Sat, 12 Sep 2026 06:19:13 +0000
X3AG2KQGUA(本次运行的答案;随机生成,重访实例页会变)