Hello Navi

Tech, Security & Personal Notes

Challenge

This challenge is all about algorithm coding, and encryption. The aim of this challenge is to take a comma-delimited string, which represents a 9x9 Sudoku, and parse this string into a form which can be analyzed. The Sudoku should then be analyzed and solved, and returned to its comma-delimited form. This comma-delimited solution would then be hashed using the SHA1 hashing system. The resulting hash should be applied as the key to decrypt the CBC-mode 64bit-block-size Blowfish-encrypted Base64-encoded string provided. The decrypted string is the password for this challenge. Submit it quickly! 这道题的核心是写算法和解密:把一串逗号分隔的字符串当作 9x9 数独解析、求解,再把它还原成同样的逗号分隔形式;对这个结果做 SHA1,把得到的 hash 当作 key,用 CBC 模式、64-bit 分组的 Blowfish 解密题目给的 Base64 密文,明文就是关卡密码。要求快速提交。

题目页面给出:一个 9x9 数独(有向量的题面 + 一份 copy/paste 用的逗号串)、一段 Base64 密文,以及加密算法的 PHP 源码 blowfish.phps。页面还挂了 180 秒倒计时。

Solution

整体链路很直白:解析数独 → 求解 → 回填逗号串 → SHA1 → Blowfish-CBC 解密 → 提交。

真正的难点不在数独,而在 blowfish.phps 是一份魔改过的 Blowfish 实现:它不是标准算法,也没有现成库能直接对上。pycryptodome 的 Blowfish 解不出正确明文,必须逐行复刻这份 PHP。下文先列出从源码中提取的三个关键约定,再给完整脚本。

Step 1: 算法约定

blowfish.phps 开头的两个成员变量直接决定了模式:

1
2
public $hashcfg      = 1 ;   // Key Hashing MD5: 0  SHA1: 1
public $encryptmode = 1 ; // Encryption mode: EBC: 0 CBC: 1

即 key 用 SHA1、模式用 CBC。继续看 key schedule:

1
2
3
4
5
6
7
8
9
10
11
12
13
function keys($key)
{
$key_hash = sha1($key);

//Convert the $key into a 16Byte key
$key = $this->_str2long(substr(str_pad($key, 16, $key_hash),0,16));

//XOR Pbox1 with the first 32 bits of the key, XOR P2 with the second 32-bits of the key,
for($i=0;$i<count($this->pbox);$i++)
{
$this->pbox[$i] ^= $key[$i%4];
}
}

sha1($key) 在 PHP 里默认返回 40 字符的十六进制字符串。题面流程是把 SHA1 的结果当 key 传进来,也就是这个 40 字符的 hex 串;它长度已经 ≥ 16,str_pad 不起作用,于是 substr(...,0,16) 只取 hex 串的前 16 个字符。这 16 个字符会被 _str2long()(unpack('N*'),big-endian 32-bit)拆成 4 个 word 参与 key schedule。

第二处是 round 函数,也是这份实现最不常规之处:

1
2
3
4
5
6
7
8
9
10
11
12
13
function sbox_round($integer)
{
//Split $integer into four 8 Bit blocks
$b0 = $integer<<24 & 0xFF;
$b1 = $integer<<16 & 0xFF;
$b2 = $integer<<8 & 0xFF;
$b3 = $integer & 0xFF;

$return = ($this->sbox0[$b0] + $this->sbox1[$b1] % 4294967295) ;
$return = ($return ^ $this->sbox2[$b2]) + $this->sbox3[$b3] % 4294967295;

return $return;
}

标准 Blowfish 用右移 >> 取四个字节索引,这里全是左移 <<。在 64 位 PHP(以及 Python)整数语义下,(x << 24) & 0xFF、(x << 16) & 0xFF、(x << 8) & 0xFF 恒为 0,只有 x & 0xFF 这个低字节存活。所以 F 函数退化成一个只用低字节查表的表达式,必须原样照抄,不能用标准 F。

第三处是 CBC 的 IV 和输出格式:

1
2
3
4
if($this->encryptmode == 1) {
$cipher[0][0] = time();
$cipher[0][1] = (double)microtime()*1000000;
}

IV 取的是时间戳 [time(), microtime()*1000000] 两个整数;更关键的是收尾的打包循环 for($i = 0; $i<count($cipher); $i++)把 $cipher[0](也就是这个 IV)也写进了输出。所以 base64 解码后的密文里,前 8 字节是 IV,真正的密文 block 从第 3 个 32-bit word 开始。解密时要把第一块当 IV 用。

Step 2: 求解数独

题面的 copy/paste 串按逗号切正好是 81 个字段(行主序),空 cell 就是空字段;求解后按 ",".join(81 个数字) 回填,格式与题面完全一致。这里用最朴素的全解回溯,顺带枚举多个解(题面提示可能有多个解):

1
2
3
def parse_puzzle(cells):
return [[0 if cells[r * 9 + c] == "" else int(cells[r * 9 + c])
for c in range(9)] for r in range(9)]

solve_sudoku() 是全解回溯(行优先试填 + 行/列/宫校验),最多收 limit 个解;完整实现见文末 完整脚本。

Step 3: 复刻 Blowfish

把 PHP 的 block_encrypt / sbox_round / keys 逐行翻译成 Python。注意 block_encrypt 本身是标准 Blowfish 加密轮,所以逆过程就是标准解密轮;另外用随机 block 做 enc/dec 往返自检,以确认逆轮写对。逐行翻译后的 Blowfish 类(sbox_round / block_encrypt / block_decrypt / keys 与 PHP 一一对应)见文末 完整脚本。

keys() 里 key 材料只取前 16 字节,self.P[i] ^= kw[i % 4] 之后就是标准的 P-box / S-box 展开循环。

CBC 解密时把第一块取出当 IV(实现见文末 完整脚本 的 blowfish_cbc_decrypt)。

Script

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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
"""HackThisSite Programming Mission 9 — One-Time-Pad Encryption.

Flow:
1. fetch /missions/prog/9/ and parse the comma-delimited 9x9 Sudoku + the
Base64 Blowfish ciphertext;
2. solve the Sudoku (backtracking, enumerate every solution);
3. join the solution back into the same comma-delimited form, SHA1 it, and
use the resulting SHA1 hex string as the Blowfish key;
4. decrypt the Base64 ciphertext with the *exact* Blowfish variant shipped in
blowfish.phps (hashcfg=1 -> SHA1, encryptmode=1 -> CBC-with-prepended-IV);
5. submit the printable plaintext as the challenge password.

The Blowfish class below is a line-for-line Python port of the PHP reference at
/missions/prog/9/blowfish.phps. It is NOT stock Blowfish: the round function
`F` uses `<<` (not `>>`) when slicing the four S-box indices, and the CBC "IV"
is `[time(), microtime()*1e6]` which is emitted as the first ciphertext block
instead of being transmitted separately. Both quirks must be reproduced.

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

import base64
import hashlib
import os
import re
import struct
import sys
import time

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

LEVEL = 9
FIELD = "password"


# --------------------------------------------------------------------------
# Blowfish (PHP blowfish.phps port)
# --------------------------------------------------------------------------
PBASE = [
0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344,
0xa4093822, 0x299f31d0, 0x082efa98, 0xec4e6c89,
0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c,
0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917,
0x9216d5d9, 0x8979fb1b,
]


def _load_sboxes():
"""Read the four S-box tables straight out of the challenge's PHP source."""
php = os.path.join(HERE, "blowfish.php")
if not os.path.exists(php):
raise SystemExit("blowfish.php missing — fetch it from "
"/missions/prog/9/blowfish.phps first")
src = open(php, encoding="utf-8").read()
boxes = []
for name in ("sbox0", "sbox1", "sbox2", "sbox3"):
m = re.search(r"\$%s\s*=\s*Array" % name, src)
j = m.end() - 1
depth = 0
for k in range(j, len(src)):
if src[k] == "(":
depth += 1
elif src[k] == ")":
depth -= 1
if depth == 0:
body = src[j:k]
break
boxes.append([int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", body)])
return boxes


SBASE = _load_sboxes()
MASK32 = 0xFFFFFFFF


class Blowfish:
"""Faithful port of the PHP reference implementation."""

def __init__(self):
self.P = PBASE[:]
self.S = [b[:] for b in SBASE]

def sbox_round(self, integer):
# NOTE: the original uses `<<` here, not `>>`. On a 64-bit interpreter
# (x<<24)&0xFF, (x<<16)&0xFF and (x<<8)&0xFF all collapse to 0, so only
# the low byte survives. Keep it exactly as written.
b0 = (integer << 24) & 0xFF
b1 = (integer << 16) & 0xFF
b2 = (integer << 8) & 0xFF
b3 = integer & 0xFF
r = self.S[0][b0] + self.S[1][b1] % 4294967295
r = (r ^ self.S[2][b2]) + self.S[3][b3] % 4294967295
return r

def block_encrypt(self, left, right):
vl, vr = left, right
for i in range(16):
vl ^= self.P[i]
vr ^= self.sbox_round(vl)
vl, vr = vr, vl
vl, vr = vr, vl
vr ^= self.P[16]
vl ^= self.P[17]
return vl, vr

def block_decrypt(self, left, right):
vl, vr = left, right
vl ^= self.P[17]
vr ^= self.P[16]
vl, vr = vr, vl
for i in range(15, -1, -1):
vl, vr = vr, vl
vr ^= self.sbox_round(vl)
vl ^= self.P[i]
return vl, vr

def keys(self, key):
"""Key schedule; `key` is the raw string the server feeds to keys()."""
if isinstance(key, str):
key = key.encode()
key_hash = hashlib.sha1(key).hexdigest().encode() # PHP sha1() -> hex
if len(key) >= 16:
material = key[:16]
else:
material = (key + key_hash * (1 + 16 // len(key_hash)))[:16]
kw = list(struct.unpack(">4I", material))
for i in range(18):
self.P[i] ^= kw[i % 4]
v0 = v1 = 0
for i in range(0, 18, 2):
v0, v1 = self.block_encrypt(v0, v1)
self.P[i] = v0
self.P[i + 1] = v1
for bi in range(4):
for i in range(0, 256, 2):
v0, v1 = self.block_encrypt(v0, v1)
self.S[bi][i] = v0
self.S[bi][i + 1] = v1


def blowfish_cbc_decrypt(b64_text, key):
"""Decrypt Base64 CBC-Blowfish where block 0 is the prepended IV."""
bf = Blowfish()
bf.keys(key)
data = base64.b64decode(b64_text)
words = list(struct.unpack(">%dI" % (len(data) // 4), data))
prev = (words[0], words[1]) # emitted IV block
out = bytearray()
for i in range(2, len(words), 2):
pl, pr = bf.block_decrypt(words[i], words[i + 1])
pl ^= prev[0]
pr ^= prev[1]
out += struct.pack(">II", pl & MASK32, pr & MASK32)
prev = (words[i], words[i + 1])
return bytes(out)


# --------------------------------------------------------------------------
# Sudoku
# --------------------------------------------------------------------------
def parse_puzzle(cells):
return [[0 if cells[r * 9 + c] == "" else int(cells[r * 9 + c])
for c in range(9)] for r in range(9)]


def solve_sudoku(grid, limit=64):
solutions = []

def valid(g, r, c, v):
for i in range(9):
if g[r][i] == v or g[i][c] == v:
return False
br, bc = 3 * (r // 3), 3 * (c // 3)
for i in range(br, br + 3):
for j in range(bc, bc + 3):
if g[i][j] == v:
return False
return True

def backtrack(g):
if len(solutions) >= limit:
return
for r in range(9):
for c in range(9):
if g[r][c] == 0:
for v in range(1, 10):
if valid(g, r, c, v):
g[r][c] = v
backtrack(g)
g[r][c] = 0
return
solutions.append([row[:] for row in g])

backtrack([row[:] for row in grid])
return solutions


def check_solution(g):
want = set(range(1, 10))
for r in range(9):
if set(g[r]) != want:
return False
for c in range(9):
if {g[r][c] for r in range(9)} != want:
return False
for br in (0, 3, 6):
for bc in (0, 3, 6):
if {g[br + i][bc + j] for i in range(3) for j in range(3)} != want:
return False
return True


# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def parse_page(text):
puzzle = re.search(r'copy/paste form: <input type="text" value="([^"]*)"',
text).group(1)
cipher = re.search(r"Blowfish encrypted string:\s*([A-Za-z0-9+/=]+)",
text).group(1)
return puzzle, cipher


def main():
s = session()
page = fetch_level(s, LEVEL)
puzzle, cipher = parse_page(page)
cells = puzzle.split(",")
if len(cells) != 81:
raise SystemExit("expected 81 cells, got %d" % len(cells))
print("[*] puzzle :", puzzle)
print("[*] cipher :", cipher)

grid = parse_puzzle(cells)
sols = solve_sudoku(grid)
print("[*] solutions:", len(sols))

answer = None
for idx, sol in enumerate(sols):
assert check_solution(sol), "invalid sudoku solution"
solstr = ",".join(str(v) for v in sum(sol, []))
digest = hashlib.sha1(solstr.encode()).hexdigest()
# The server feeds the SHA1 *hex string* to keys(); keys() keeps the
# first 16 characters because the digest is longer than 16 bytes.
plain = blowfish_cbc_decrypt(cipher, digest.encode())
printable = all(32 <= b < 127 for b in plain)
print("[*] sol #%d sha1=%s -> %r (printable=%s)"
% (idx, digest, plain, printable))
if printable:
answer = plain.decode().rstrip(" ") # strip space padding
break

if not answer:
raise SystemExit("no printable plaintext — key derivation wrong")

print("[*] password :", answer)
if os.environ.get("HTS_DRY"):
print("[*] HTS_DRY set — skipping submission")
return
time.sleep(3)
ok, resp = submit(s, LEVEL, answer, field=FIELD)
print("[*] verdict :", ok)
print(body_text(resp)[-1200:])


if __name__ == "__main__":
main()

Challenge

Level 7

This level is about image processing, inspired by pay-tv cracking. Code a program which is able to automatically unscramble the lines of a given image. Type in all characters from the image. Begin with the upper line, and add the lower line without a blank in between.

限时 180 秒:GET /missions/prog/7/ 生成随机实例(图在 /missions/prog/7/BMP), POST /missions/prog/7/index.php 字段 solution。

Solution

Step 1: 图像结构

实例图是 200×100 的 PNG。

对像素做颜色统计:

1
2
3
4
5
shape (100, 200, 3)
(245, 184, 143) px 1031 rows 28 span (3, 98) maxrow 95
(129, 127, 77) px 871 rows 27 span (7, 91) maxrow 65
(113, 82, 86) px 6 rows 6 span (18, 81) maxrow 1
(113, 66, 99) px 5 rows 5 span (20, 91) maxrow 1
  • 只有两个颜色的像素数在 800 以上(1031 / 871),它们就是两行文字的字形颜色;
  • 其余颜色每个只有 4–6 个像素,是逐像素噪声(不是行级噪声);
  • 两种字形色各自的 27–28 行散布在整幅图的高度上(span 3–98 / 7–91),说明加扰是行置换: 原始图的两行文字,它们的像素行被打乱后均匀撒在整张图里。

这就给出还原思路:先判断每一行原本在图像里的纵坐标,再把每个颜色的行按这个坐标排序,就能把两行文字拼回来。

Step 2: 行序判定

背景是一条垂直渐变,B 通道随行号单调变化;而行置换并不改变每一行的像素内容, 于是每一行自身的 B 通道统计量就保留了它原来的纵坐标信息。实测 (min+max)/2(midrange)足够稳定:

1
2
3
4
5
6
7
8
import numpy as np
from PIL import Image

a = np.array(Image.open("chal_live.bmp").convert("RGB")).astype(int)
H, W, _ = a.shape
bm = np.array([(a[y, :, 2].min() + a[y, :, 2].max()) / 2.0 for y in range(H)])
order = sorted(range(H), key=lambda y: bm[y]) # 按估计出的原始位置排序
rank = {y: k for k, y in enumerate(order)} # rank 0 == 原图最下面一行

对每个字形色取出它的行、按 rank 排序后渲染(白底黑字、5 倍放大),就得到两段干净的字形条带:

1
2
3
colour A (129, 127, 77): 27 rows -> ranks [8, 10, 12, ..., 46, 48, 49]
colour B (212, 179, 160): 27 rows -> ranks [50, 51, 52, ..., 75, 76]
mean ranks {'A': 29.5, 'B': 63.0} -> submit order: strip B first, then A

注意这里的 rank 是从下往上计数的(rank 0 = 底部):色带 B 的 rank 均值更大, 说明它原本位于上面那一行;所以拼接顺序必须是 B 的文字 + A 的文字。

Step 3: 读出字符

条带放大 5 倍后字形是纯黑白的点阵,逐像素打成 ASCII

1
2
line 1 (strip B): J 6 7 L 3 6
line 2 (strip A): M E 5 F E Y

Verify

同一个实例的提交实测(每行是 solution=<串> 的返回判定):

1
2
3
4
submit 'ME5FEYJ67L36' -> ok=False bad=True      # 先上后下、但左右顺序错
submit 'J67L36ME5FEY' -> ok=True bad=False # 接受
submit 'ME5FEY' -> ok=False bad=True # 只交一行不行
submit 'J67L36' -> ok=False bad=True

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.

Solution

Step 1: 端点数据

实例页 https://www.hackthissite.org/missions/prog/6/ 里的链接指向 /missions/prog/6/image。直接 GET 它拿到的是 HTML(Content-Type: text/html), 内容是真正的 drawIt()(完整数组约 3350 个整数,下面只列开头)。这意味着题目虽然把它描述成 OCR, 但客户端拿到的并不是一张 PNG,而是可以直接重建的绘图指令;“随螺旋倒置”的字符只是整体旋转, 不需要把倒置字符单独当作另一套字形:先按极角把每个字符旋回统一方向,再识别即可。

先试常规 OCR 也能验证这个判断,但它只能作为辅助而不是最终解法。把矢量指令渲染成 bitmap 后, 对整张螺旋图运行 Tesseract(--psm 3)在实测实例上直接得到 Empty page;即使改用 --psm 6/11/12, 输出也是断裂、乱序的片段。把字符按连通域切开、按所在极角旋转后逐字 OCR,实测一个 36 字符圈只能读出 约 34 个,且不同 psm、缩放和阈值会产生不同误识别。因此,常规 OCR 的正确用法是“渲染 + 分割 + 旋转 归一化 + 单字识别”,但要稳定读完 253 字符,仍需要对这些矢量字形做模板匹配;下面采用后者。

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,

Step 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

Step 3: 螺旋排序

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

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

Step 4: 原型提取

字符集是大写十六进制(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 个原型全部人工确认过一次。原型存成 templates_gen.py,

Step 5: 模板分类

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

完整的 templates_gen.py 内容如下。它是被 solve.py 导入的纯数据模块。

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
# auto-generated canonical glyph templates (relative to cloud mean)
TEMPLATES = {
'0': [
('A', -0.26, 3.31, 4.00, 180.00, 180.00),
('L', -3.46, -3.15, -3.86, 3.54),
('A', 0.14, -3.38, 4.00, 360.00, 180.00),
('L', 3.34, 3.08, 3.74, -3.62),
],
'1': [
('L', 0.46, -7.29, -2.54, -3.29),
('L', 0.46, -7.29, 0.46, 6.71),
('L', -2.54, 6.71, 3.46, 6.71),
],
'2': [
('L', 4.03, -3.38, -3.91, 6.96),
('A', -0.07, -2.95, 4.00, 0.00, 180.00),
('L', -3.91, 6.96, 4.63, 7.05),
],
'3': [
('L', -2.53, -1.73, -1.53, -1.73),
('A', -1.53, 2.27, 4.00, 225.00, 230.00),
('A', -1.53, -4.73, 3.00, 270.00, 225.00),
],
'4': [
('L', -1.51, -6.14, -3.21, 0.87),
('L', -3.21, 0.87, 4.39, 1.29),
('L', 0.72, -5.89, 0.46, 8.04),
],
'5': [
('L', -2.32, -2.11, 0.50, -1.86),
('L', 3.81, -7.25, -2.59, -7.10),
('L', -2.32, -2.11, -2.59, -7.10),
('A', 0.13, 2.36, 4.00, 180.00, 270.00),
],
'6': [
('L', -3.31, -0.43, 1.69, -9.09),
('O', 0.16, 1.57, 4.00),
],
'7': [
('L', 0.99, 1.85, -3.47, 1.58),
('L', 2.76, -5.21, -3.94, -5.61),
('L', 2.76, -5.21, -1.64, 8.41),
],
'8': [
('O', -0.06, -4.25, 3.00),
('O', 0.17, 2.82, 4.00),
],
'9': [
('L', -1.66, 8.64, 2.36, 0.52),
('O', -0.12, -1.44, 4.00),
],
'A': [
('L', 0.22, -7.18, 5.84, 7.01),
('L', -5.34, 7.07, 0.22, -7.18),
('L', 3.12, -0.58, -2.96, -0.64),
],
'B': [
('L', -4.42, -6.64, -4.17, 7.28),
('L', -4.42, -6.64, 0.96, -6.81),
('L', -4.17, 7.28, 1.21, 7.11),
('A', 0.36, 3.61, 4.00, -90.00, 180.00),
('A', 0.87, -3.65, 4.00, -90.00, 180.00),
('L', 0.79, -0.49, -3.66, 0.02),
],
'C': [
('A', 2.35, 0.31, 7.00, 55.00, 255.00),
],
'D': [
('A', -0.29, 0.22, 7.00, 270.00, 180.00),
('L', -4.29, -6.71, -4.35, 7.18),
('L', 0.17, -6.98, -4.29, -6.71),
('L', -4.35, 7.18, 0.11, 6.91),
],
'E': [
('L', -2.63, 7.00, 5.37, 7.00),
('L', -2.63, 7.00, -2.63, -7.00),
('L', 5.37, -7.00, -2.63, -7.00),
('L', -2.63, 0.00, 4.37, 0.00),
],
'F': [
('L', -2.19, 1.77, 4.51, 1.37),
('L', -1.79, 8.47, -1.73, -5.43),
('L', -1.73, -5.43, 6.34, -5.46),
],
}

分类函数在 solver 中使用这些模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)

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

Challenge

Level 5 — Fix a corrupted file

Get this file HERE, reconstruct it and send the password as answer.

有人用 Windows 命令行 ftp 客户端下载了一个 bz2 压缩的 PNG,PNG 里有一个重要密码;他漏考虑了一件事,文件因此损坏。下载 corrupted.png.bz2,把它修好并提交里面的密码。限时 600 秒。

Solution

  • 实例页 https://www.hackthissite.org/missions/prog/5/,文件链接是 /missions/prog/5/corrupted.png.bz2/。
  • 文件每个实例都不一样(两次抓到的分别是 13075 / 13961 / 15232 字节,内容与密码都不同),所以必须取文件 → 修复 → 读密码 → 提交在一次 600 秒窗口内完成。
  • file 报 bzip2 compressed data,但 bzip2 -t 失败 → 这就是题面说的损坏。
1
2
3
4
5
6
$ curl -sL -b '<mission-cookie>' 'https://www.hackthissite.org/missions/prog/5/corrupted.png.bz2/' -o corrupted.png.bz2
$ ls -l corrupted.png.bz2 && file corrupted.png.bz2
13075 corrupted.png.bz2
corrupted.png.bz2: bzip2 compressed data, block size = 900k
$ bzip2 -t corrupted.png.bz2
bzip2: corrupted.png.bz2: data integrity (CRC) error in data

题面提示用 Windows ftp 客户端下载、漏考虑的一件事,是没有切换到 binary 模式。FTP 的 ASCII type 面向文本传输,会在本地字符表示与标准 NVT-ASCII 表示之间转换,并按平台约定处理行尾;Windows 文档将它与按 one-byte units 传输的 binary type 区分。对本题的跨 Unix/Windows 路径,原始压缩流中作为 bare LF 的 0x0A 被当作行尾并扩展为 CRLF,也就是插入 0x0D。压缩流没有行结构,0x0A 只是普通数据字节,插入一个字节会改变后续 bzip2 bitstream,进而使校验与解压失效。这里的 0x0A → 0x0D 0x0A 是本题具体传输路径的结果,实际转换还取决于发送端、接收端和平台的行尾处理规则;本题的 CR/LF 统计与后续 PNG CRC 对照支持这一判断。

证据就在字节统计里:

1
2
$ python3 -c "d=open('corrupted.png.bz2','rb').read(); print(len(d), d.count(b'\r'), d.count(b'\n'), d.count(b'\r\n'))"
13075 115 57 55
  • \r 比 \n 多了 58 个 → 就是被插入的 CR;
  • 文件里 \r\r\n 出现 0 次 → 说明原本就是 \r\n 的位置没有被二次膨胀,插入只发生在原来的 0x0A 前;
  • 另外还有 2 处 \n\r(例如 ... 0d 0a 0d ...),说明原始位流里确实有少量天然的 \r\n 相邻(转换后转换结果呈现为 CRLF,但其中的 CR 是原始数据)。

所以修复不是简单 dos2unix:要决定哪些 CRLF 里的 CR 是插入的、哪些是原始数据。只删掉一部分、猜错一个位置,解出来的即为无意义的垃圾数据。

观测到的 CRLF 对很少(50-60 个),而天然 CRLF 通常只有 0-2 个。于是直接枚举保留子集,用解压结果是否以 PNG 魔数开头来判定:

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
import bz2
import itertools

PNG_MAGIC = b"\x89PNG\r\n\x1a\n"

def crlf_positions(data):
"""每个 0x0D 0x0A 对的起始下标。"""
return [i for i in range(len(data) - 1)
if data[i] == 0x0D and data[i + 1] == 0x0A]

def rebuild(data, keep, positions):
"""删掉每一个 CRLF 对里的 CR,但 keep 里的下标保留 CR(=原始数据)。

注意不能用 data.replace(b'\\r\\n', b'\\n'):相邻的 CRLF 会互相影响,
必须逐对扫描。
"""
keep = set(keep)
out, i, j = bytearray(), 0, 0
while i < len(data):
if i + 1 < len(data) and data[i] == 0x0D and data[i + 1] == 0x0A:
if j in keep:
out.append(data[i])
out.append(data[i + 1])
i += 2
j += 1
else:
out.append(data[i])
i += 1
return bytes(out)

def repair(data, max_keep=3):
pos = crlf_positions(data)
for k in range(max_keep + 1):
for combo in itertools.combinations(range(len(pos)), k):
try:
plain = bz2.decompress(rebuild(data, combo, pos))
except Exception:
continue
if plain[:8] == PNG_MAGIC:
return rebuild(data, combo, pos), plain, [pos[i] for i in combo]
raise SystemExit("could not repair the archive")

真实的一次运行:

1
2
3
4
5
[1] downloaded 15232 bytes
[2] 15232 bytes, 58 CRLF pair(s); searching for the genuine ones
[3] keep=2 tried=1398 -> decompressed 17109 bytes, PNG signature ok
genuine CRLF byte offsets: [9429, 14250]
[4] PNG verified: 1750x115, chunks ['IHDR', 'bKGD', 'pHYs', 'vpAg', 'IDAT', 'IEND'], all CRCs ok

k=0(全删)和 k=1 都不行,k=2 在枚举到第 1398 个组合时命中(即这次的两个 CR 属于原始数据)。恢复出的 PNG 还能过 chunk CRC 校验,说明重建完全正确。

PNG 是 1750×115 的横条,密码用粗体无衬线字体画在浅色背景上。

注意:密码是每个实例一次生成的(同一账号换一次页面就是另一串)。

  • 不能用 replace(b'\r\n', b'\n'):相邻 CRLF 会互相影响,必须逐对扫描决定保留哪一个 CR。

Vulnerabilities

这道题的损坏是经典的真实世界传输事故:文本模式的 FTP 会把二进制文件当文本处理,静默插入 0x0D。防御上:二进制传输永远走 binary 模式(ftp 里 bin,或直接 scp/curl),下载后校验哈希/魔数;对压缩包这类强结构数据,还应当用内容特征(魔数、CRC)而不是命令退出码来判断完整性。本例中解压器在数据已损坏时仍会输出字节流,只看退出码会误判。另外,密码以明文绘制在图片里再压缩交付,本身就是把答案交给客户端的做法。

Challenge

This level is about reversing an encryption algorithm.

关卡给出 PHP 加密函数 encryptString 的源码,以及一段空格分隔的整数密文;要把密文还原成明文 serial 文件,提交最后一个 serial。限时 120 秒。

每次访问实例页,服务端都会换一组随机的 password 和明文,所以取密文、解密、提交必须放在同一次运行里。

Solution

Step 1: 加密链

关卡页面的 PHP 源码是这样的两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function evalCrossTotal($strMD5) {
$intTotal = 0;
$arrMD5Chars = str_split($strMD5, 1);
foreach ($arrMD5Chars as $value)
$intTotal += '0x0' . $value; // 十六进制数字串,取值 0..15
return $intTotal;
}

function encryptString($strString, $strPassword) {
$strPasswordMD5 = md5($strPassword);
$intMD5Total = evalCrossTotal($strPasswordMD5);
$arrEncryptedValues = array();
$intStrlen = strlen($strString);
for ($i = 0; $i < $intStrlen; $i++) {
$arrEncryptedValues[] = ord(substr($strString, $i, 1))
+ ('0x0' . substr($strPasswordMD5, $i % 32, 1))
- $intMD5Total;
$intMD5Total = evalCrossTotal(substr(md5(substr($strString, 0, $i + 1)), 0, 16)
. substr(md5($intMD5Total), 0, 16));
}
return implode(' ', $arrEncryptedValues);
}

逐字节写成公式:

1
2
3
total_0      = evalCrossTotal(md5(password))    # md5(pw) 的 32 个十六进制位之和
enc[i] = ord(plain[i]) + hexval(md5(pw)[i % 32]) - total_i
total_{i+1} = evalCrossTotal(md5(plain[:i+1])[:16] + md5(str(total_i))[:16])

两个 PHP 语义细节决定了模型是否对得上:

  • '0x0'.$c($c 是 hex 字符)在 PHP 5 里是带 0x 前缀的数字串,按十六进制解释,所以 evalCrossTotal 就是把每个字符当 hex 位加起来,取值范围 0..15。
  • md5($int) 会把整数按十进制字符串化再取摘要,也就是代码里的 md5(str(total_i))。

关键在于 total 的演进方向:第 i+1 步的 total 由 明文前 i+1 个字符的 md5 和 当前 total 的十进制 MD5 拼出来,也就是只依赖已经解出的前缀。这是一个逐字节向前推进的链,第 i 个字节的减法里用到的 total_i 是前面所有字节共同算出来的。同时 md5(pw)[i % 32] 说明密码哈希的 32 个 nibble 每 32 列循环使用一次。这条链意味着逐列独立求解不成立:单个字节的 total_i 由全部已解前缀共同决定,搜索只能按链状态整体推进。

Step 2: 搜索约束

password 本身不需要知道,它只通过 md5(password) 进入公式。真正的未知量是 32 个 nibble h[0..31],以及初始值 total0 = sum(h)。可以从两个层面把搜索压住:

  • 逐位置枚举局部候选:第 i 个字符满足 ord(plain[i]) = enc[i] - h[i % 32] + total_i。h 只有 16 种取值;一旦某个 r = i % 32 被解出来,后面每一轮循环到同一个 r 时都复用它,不再重新枚举。
  • serial 格式提供硬约束:官方示例 serials_example.txt 显示明文是固定宽度的 serial 行,形如 XXX-XXX-OEM-XXX-1.1,每行以 UNIX 换行结尾(示例文件里那句 Don't forget the UNIX-style line breaks. 指的就是这个);末尾的 \n 同样参与加密,模板宽度是 serial 长度加 1(这里 20 列),不是排版噪声。逐列统计示例文件后发现,20 列里有 11 列是恒定字符(两个 -、OEM、尾部 1.1、换行),这些列的候选集被压到唯一字符;其余列限定在 [A-Z0-9]。这样绝大多数位置上 16 个候选里只有 1 个能活下来。
  • 全局约束收口:total0 是 32 个 nibble 之和,上界只有 32 * 15 = 480,直接枚举 0..480;搜索到最后要求 h 已经集齐 32 个 nibble 且 sum(h) == total0。错误的 total0 会在前几十个字符内因为候选集与模板冲突而全灭,只有正确的那个能走到第 100 个字符。实测单次搜索 1.7–5.6 秒。

Script

下面是自包含的解密脚本,只读密文文件和示例 serial 文件,不联网:

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
#!/usr/bin/env python3
"""HackThisSite Programming Mission 3 -- 只靠密文还原明文 serial 文件。

关卡页面给出的 PHP 加密链(等价形式):

total_0 = evalCrossTotal(md5(password)) # md5 的 32 个 nibble 之和
enc[i] = ord(plain[i]) + hexval(md5(pw)[i % 32]) - total_i
total_{i+1} = evalCrossTotal(md5(plain[:i+1])[:16] + md5(str(total_i))[:16])

password 未知,真正的未知量是 h[0..31] = md5(password) 的 32 个 nibble,
以及 total0 = sum(h)。serial 模板把每列候选压到 1 个或 36 个字符,
枚举 total0(0..480) 做前向搜索即可恢复明文。

用法:python decrypt.py [ciphertext.txt]
"""

import hashlib
import os
import re
import sys

ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
HERE = os.path.dirname(os.path.abspath(__file__))

def md5(s):
return hashlib.md5(s.encode("ascii")).hexdigest()

def hexval(c):
return int(c, 16)

def eval_cross_total(s):
"""PHP evalCrossTotal:'0x0' 前缀让每个字符按十六进制解释,取值 0..15。"""
return sum(hexval(c) for c in s)

def encrypt_hash(plain, pwd_md5):
"""给定 md5(password) 的十六进制字符串,复刻 encryptString。"""
total = eval_cross_total(pwd_md5)
out = []
for i, ch in enumerate(plain):
out.append(ord(ch) + hexval(pwd_md5[i % 32]) - total)
total = eval_cross_total(md5(plain[: i + 1])[:16] + md5(str(total))[:16])
return out

def build_template(example_text):
"""从官方示例 serial 推每列允许的 ord 集合,返回 (template, width)。"""
lines = [ln for ln in example_text.splitlines()
if ln and not ln.startswith("(")]
width = len(lines[0]) + 1
tpl = []
for pos in range(width - 1):
seen = {ln[pos] for ln in lines}
tpl.append({ord(seen.pop())} if len(seen) == 1
else {ord(c) for c in ALNUM})
tpl.append({ord("\n")}) # 每行以 UNIX 换行结束
return tpl, width

def recover(enc, template, width, max_states=2_000_000):
"""返回所有与密文、模板一致的明文。state = (total, h_tuple, prefix)。"""
solutions = []
for total0 in range(0, 32 * 15 + 1): # sum(32 个 nibble) 的上界 480
states = [(total0, (), "")]
dead = False
for i in range(len(enc)):
r = i % 32
allow = template[i % width]
nxt = []
for total, h, pre in states:
cands = (h[r],) if r < len(h) else range(16)
for hv in cands:
o = enc[i] - hv + total # ord = enc - h + total
if o not in allow:
continue
ch = chr(o)
h2 = h + (hv,) if r >= len(h) else h
nxt.append((eval_cross_total(
md5(pre + ch)[:16] + md5(str(total))[:16]), h2, pre + ch))
if not nxt or len(nxt) > max_states:
dead = True
break
states = nxt
if dead:
continue
for total, h, plain in states:
if len(h) == 32 and sum(h) == total0:
solutions.append((plain, h, total0))
return solutions

def load_template():
for path in (os.path.join(HERE, "serials_example.txt"), "/tmp/serials_example.txt"):
if os.path.exists(path):
with open(path, encoding="utf-8") as fh:
return build_template(fh.read())
raise SystemExit("serials_example.txt not found")

def main():
path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "ciphertext.txt")
with open(path, encoding="utf-8") as fh:
enc = [int(x) for x in re.findall(r"-?\d+", fh.read())]
template, width = load_template()
print("ciphertext: %d values" % len(enc))
sols = recover(enc, template, width)
if not sols:
raise SystemExit("no plaintext found")
for plain, h, total0 in sols:
pwd_hash = "".join(format(x, "x") for x in h)
assert encrypt_hash(plain, pwd_hash) == enc, "re-encryption mismatch"
serials = [ln for ln in plain.splitlines() if ln]
print("total0=%d md5(password)=%s serials=%d"
% (total0, pwd_hash, len(serials)))
for ln in serials:
print(ln)
print("last serial: %s" % serials[-1])

if __name__ == "__main__":
main()

脚本里最后那行 assert encrypt_hash(plain, pwd_hash) == enc 是自校验:把恢复出的明文用恢复出的 md5(password) 再加密一遍,必须和原始密文逐字节相等才算通过。搜索模型本身也用一次回环自测验证过(加密一段已知明文后不告诉求解器 total0,让它盲解):

1
2
3
4
5
6
7
8
9
case=serials-15  len=300 enc[0:4]=[-182, -207, -172, -229] -> 1 solution(s), exact=True
recovered total0=241 hash==md5(pwd): True
re-encrypt matches ciphertext: True
case=serials-5 len=100 enc[0:4]=[-174, -177, -158, -213] -> 1 solution(s), exact=True
recovered total0=231 hash==md5(pwd): True
re-encrypt matches ciphertext: True
case=serials-2 len= 40 enc[0:4]=[-154, -240, -175, -233] -> 1 solution(s), exact=True
recovered total0=220 hash==md5(pwd): True
re-encrypt matches ciphertext: True

三个长度(整份 15 行、5 行、2 行)都只解出唯一解,且恢复出的哈希与加密时用的密码哈希一致。

Verify

服务端接受的那次实例的输出(密文与求解器在同一目录):

1
2
$ cd /tmp/wu3          # ciphertext.txt + serials_example.txt + decrypt_wu.py
$ python decrypt_wu.py ciphertext.txt
1
2
3
4
5
6
7
8
ciphertext: 100 values
total0=215 md5(password)=82aab273c5920649ac0d8b947a00875b serials=5
T7F-EJS-OEM-XBO-1.1
2GT-IE2-OEM-T4Y-1.1
M0R-76P-OEM-H47-1.1
20L-W3T-OEM-CIC-1.1
5FO-TI5-OEM-N1J-1.1
last serial: 5FO-TI5-OEM-N1J-1.1

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 秒内必须完成取图 → 解码 → 提交。

Solution

  • 实例页 HTML 里图片标签是 <img src="/missions/prog/2/PNG" alt="Image" />。
  • 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

Challenge

Level 1 — Unscramble the words

找出被打乱顺序的原始单词,单词是从官方 wordlist 里随机挑的,30 秒内把原始单词按列表顺序用逗号分隔提交。

Find the original (unscrambled) words, which were randomly taken from a wordlist. Send a comma separated list of the original words, in the same order as in the list below. You have 30 seconds time to send the solution.

每次访问实例页都会重新生成一组乱序单词,30 秒的限时决定了只能脚本化。

Solution

  • 实例页 https://www.hackthissite.org/missions/prog/1/ 里,乱序词逐个用 <br /> 分隔,前面是 List of scrambled words:,后面是 Answer:,解析时按这两句切段最省事。
  • 词表是公开的固定文件:https://www.hackthissite.org/missions/prog/1/wordlist.zip(不需要登录态,匿名 curl 也能下)。解开后是 wordlist.txt,1274 行、CRLF 行尾。
  • 词表里不只有普通英文单词,还混着数字串和带符号的词(121212、654321、8675309、666666、html:) 等)。所以题面里出现 888888 这种条目是完全正常的,它本身就是一个合法条目。
1
2
3
4
5
6
7
8
9
10
11
$ curl -s -o wordlist.zip https://www.hackthissite.org/missions/prog/1/wordlist.zip && unzip -o wordlist.zip >/dev/null
$ file wordlist.txt && wc -l wordlist.txt
wordlist.txt: ASCII text, with CRLF line terminators
1274 wordlist.txt
$ head -6 wordlist.txt
html:)
121212
131313
123123
654321
8675309

打乱只改变字符顺序、不改变字符频次,所以每个乱序词 w 与原文的排序后字符串(把字符排序后拼接)完全相等。建索引时用排序后的串当 key,一次查表即可;重复字母(aremedr、rirmemto)也不会出错。

词表可能存在同一多重集对应多个词的极端情况,这里额外保底:命中不到时按长度过滤再比对排序串。

限时 30 秒,但真正的时间开销在解析上(字符串处理),网络只占两次往返:GET 实例页 + POST 答案。

完整脚本如下。脚本把 cookie 从 HTS_COOKIE 环境变量读取,依赖的 HTTP 与页面解析逻辑也一并包含在内;wordlist.txt 放在脚本同目录。

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
#!/usr/bin/env python3
"""Solve HackThisSite Programming Mission 1."""

import html
import os
import re
from pathlib import Path

import requests

BASE = "https://www.hackthissite.org"
LEVEL_URL = f"{BASE}/missions/prog/1/"
WORDLIST = Path(__file__).with_name("wordlist.txt")
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)


def session():
cookie = os.environ.get("HTS_COOKIE", "").strip().strip("'\"")
if not cookie:
raise SystemExit("HTS_COOKIE is not set")

client = requests.Session()
client.headers.update(
{
"User-Agent": USER_AGENT,
"Cookie": cookie,
"Referer": f"{BASE}/missions/programming/",
"Accept-Language": "en-US,en;q=0.9",
}
)
return client


def body_text(source):
source = re.sub(r"<script.*?</script>", "", source, flags=re.S)
source = re.sub(r"<br\s*/?>", "\n", source)
source = re.sub(r"</(?:p|div|tr)>", "\n", source)
source = re.sub(r"<[^>]+>", " ", source)
source = html.unescape(source)
source = re.sub(r"[ \t]+", " ", source)
return re.sub(r"\n\s*\n+", "\n", source)


def load_wordlist(path):
table = {}
with path.open("r", encoding="utf-8", errors="replace") as fh:
for line in fh:
word = line.strip()
if word:
table.setdefault("".join(sorted(word)), word)
return table


def parse_scrambled(page_text):
marker = "List of scrambled words:"
end_marker = "Answer:"
if marker not in page_text or end_marker not in page_text:
raise ValueError("could not find the scrambled-word section")
chunk = page_text.split(marker, 1)[1].split(end_marker, 1)[0]
return [token for token in re.split(r"[\s,]+", chunk) if token]


def unscramble(words, table):
result = []
for word in words:
key = "".join(sorted(word))
candidates = [value for value in table.values() if "".join(sorted(value)) == key]
if not candidates:
result.append(word)
else:
result.append(candidates[0])
return result


def submit(client, answer):
response = client.post(
f"{LEVEL_URL}index.php",
data={"solution": answer, "submitbutton": "submit"},
headers={"Referer": LEVEL_URL},
timeout=30,
)
response.raise_for_status()
text = body_text(response.text).lower()
success_markers = (
"congratulation",
"you have completed",
"completed this",
"successfully",
"correct",
"well done",
"mission accomplished",
"complete!",
)
failure_markers = (
"wrong",
"incorrect",
"not correct",
"try again",
"failed",
"too late",
"time is up",
"sorry",
)
success = any(marker in text for marker in success_markers)
failure = any(marker in text for marker in failure_markers)
if success and not failure:
return True
if failure and not success:
return False
return None


def main():
client = session()
response = client.get(LEVEL_URL, timeout=20)
response.raise_for_status()
words = parse_scrambled(body_text(response.text))
table = load_wordlist(WORDLIST)
answer = ",".join(unscramble(words, table))
print("scrambled:", words)
print("answer :", answer)
print("verdict :", submit(client, answer))


if __name__ == "__main__":
main()

运行方式:

1
2
3
4
5
6
$ cd <hts-workspace>/challenges/hts-prog/1
$ export HTS_COOKIE='<mission-cookie>'
$ uv run --with requests python solve.py
scrambled: ['amtnar', 'plradnot', 'chyeok', 'ubeadtht', 'aremedr', 'getayaw', 'udsettn', 'n1j6o3h', 'cuatain', 'rirmemto']
answer : mantra,portland,hockey,butthead,dreamer,gateway,student,john316,nautica,mortimer
verdict : True

Challenge

Keygen challenge. The program generates a license key based on the username. Reverse engineer the key generation algorithm and write a keygen that produces valid keys for any username.

密钥生成器 (keygen) 挑战。程序根据用户名生成 license key,需要逆向分析 key 生成算法,编写一个可以为任意用户名生成正确 key 的 keygen 程序。

HTS 附带的压缩包里同时有 Windows 版 (app17win.exe) 和 UNIX 版 (app17unix.tar.gz)。UNIX 版是 未 strip 的 ELF,符号表里直接留着 enc_and_check / main 的名字,用它反汇编最省事,而且可以直接跑起来做验证。

Solution

  • file 报 ELF 64-bit LSB PIE、x86-64,not stripped。
  • nm 直接给出两个关键符号:_Z13enc_and_checkPcS_(enc_and_check(char*, char*),就是校验函数)和 main。
1
2
3
$ nm unix/app17unix | grep -E 'enc_and|main'
0000000000001919 T main
000000000000148f T _Z13enc_and_checkPcS_

Step 1: 从符号找校验函数

enc_and_check(rdi=username, rsi=key) 里第一个分支就限定了 key 的长度:strlen(key) <= 3 直接返回 1(失败)。接下来把 key 的前 4 个字节与一张常量表比较,而比较方式是把 key 的字符左移 2 位(lea edx,[rax*4+0]):

1
2
3
4
5
6
7
8
9
10
11
12
; enc_and_check(char* username /*rdi->[rbp-0x488]*/, char* key /*rsi->[rbp-0x490]*/)
14bc mov rax,[rbp-0x490] ; key
14c6 call strlen
14cb cmp rax,0x3
154a mov DWORD PTR [rbp-0x440],0x120
1554 mov DWORD PTR [rbp-0x43c],0x150
155e mov DWORD PTR [rbp-0x438],0x14c
1568 mov DWORD PTR [rbp-0x434],0xb4
15b7 movzx eax,BYTE PTR [rax] ; key[i]
15ba movsx eax,al
15bd lea edx,[rax*4+0x0] ; key[i] * 4
15d2 cmp edx,eax ; == 表里的常量?

0x120/4=0x48='H',0x150/4=0x54='T',0x14c/4=0x53='S',0xb4/4=0x2d='-'。所以 key 必须以 HTS- 开头。

Step 2: key 的格式

后面几段循环分别规定了分隔符位置和内容长度:

  • 从下标 3 开始每隔 5 个字符必须是 -(add rbp-0x460, 5),即 HTS- 之后是每 4 个 hex 一组、用 - 分隔。
  • 另一段循环把 key 里所有的 - 去掉,用一个 sprintf(buf, "%s%c", buf, key[i]) 拼成一个纯 hex 串。
  • 最后要求这个纯 hex 串的长度恰好等于 2 * strlen(username):
1
2
3
4
5
6
7
16bb  mov    rax,[rbp-0x488]        ; username
16c5 call strlen
16ca lea rbx,[rax+rax*1] ; rbx = 2 * strlen(username)
16d5 call strlen ; strlen(hex buffer)
16dd cmp rbx,rax
16e0 je ok
16e2 mov eax,0x1 ; 长度不符 -> 失败

也就是说:每个用户名字符对应 2 个 hex 位(1 byte),整个 key 是 HTS- 加若干 4-hex 一个的分组。格式串也可以直接在 .rodata 里读到:0x3008="%s%c"、0x300d="%02X"。

Step 3: 逆向字节生成算法

主循环对用户名的每个字符 j 做一次计算。它先把第 j 对的两个 hex 字符用 sscanf(..., "%02X", ...) 解析成一个整数 hexval,然后按下面的公式算出应有的值 result,并要求 result == hexval:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
171e  movzx  eax,BYTE PTR [buf+idx]  ; 取两个 hex 字符
174e call sscanf("%02X") ; -> hexval ([rbp-0x474])
1773 mov eax,[rbp-0x470] ; acc(上一字节算出的值,初值 0)
1779 mov [rbp-0x46c],eax ; t = acc
177f cmp DWORD PTR [rbp-0x46c],0x1f
1786 jle 1791
1788 sub DWORD PTR [rbp-0x46c],0x1f ; while (t > 31) t -= 31; -> t = acc mod 31
178f jmp 177f
1791 mov rdx,[rbp-0x488] ; username
17a2 movzx eax,BYTE PTR [rdx+j]
17a5 movsx eax,al ; u = (signed char)username[j]
17a8 sub eax,[rbp-0x470] ; u - acc
17ae sar eax,1 ; (u - acc) >> 1 (算术右移)
17b0 mov esi,eax
17c3 movzx eax,BYTE PTR [rdx+j]
17c6 movsx edx,al ; u
17cf mov ecx,eax(=t) ; cl = t
17d1 shl edx,cl ; u << (t & 31)
17d5 not eax ; ~(u << t)
17d7 and eax,esi
17d9 mov [rbp-0x464],eax ; result

即:

1
2
3
t      = acc mod 31                # acc<=31 时不减,等价 acc % 31
result = ((signed_char)username[j] - acc) >> 1
result &= ~(username[j] << (t & 31))

算完之后 acc 更新为 result(18d5: mov [rbp-0x470], hexval),因为前面已经校验过 result == hexval,两者相等,所以下一轮的起点就是当前字节的值。

有一个伪装用的内层循环(0x17f7~0x18bb)遍历整个 key 和用户名,对 acc 执行一组 and/ shl / or / +1 运算。该循环位于 result 计算之后;acc 在循环结束时又通过 mov eax,[rbp-0x468] 被赋回此前保存的 hexval(0x18d5),因此这些运算的副作用不会影响 result,属于作者放置的烟雾弹。

Step 4: keygen

把上面的公式原样搬到 Python。两个细节必须对齐 x86:

  • username[j] 是 signed char(movsx),高位字节按负数处理。
  • 所有中间量都是 32-bit 补码,>> 1 是算术右移;移位量按 x86 规则只取低 5 位(& 31)。
  • 每个字节只有落在 0x00..0xFF 才能写成两个 hex 位,所以并非所有用户名都能表示,超出范围时报告不可解。
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
#!/usr/bin/env python3
"""HackThisSite Application Challenge 17 keygen.

Reconstructed from _Z13enc_and_checkPcS_ in app17unix (objdump -d -Mintel).

The binary checks a key of the form HTS-XXXX-XXXX-... where the number of
hex digits equals 2 * len(username). For each username byte u and the running
state `acc` (starts at 0), it recomputes

result = ((u - acc) >> 1) & ~(u << (acc mod 31)) # 32-bit, signed

and demands that the two hex digits for that position equal `result`; the next
state becomes `result`. `>>` is an arithmetic shift (x86 `sar`) and the shift
count is taken modulo 32 (x86 `shl cl`). Every intermediate is 32-bit two's
complement, but only values 0..0xFF can be written as two hex digits, so a
username is solvable only when every step lands in 0..255.
"""

MASK = 0xFFFFFFFF


def s32(x):
"""Wrap to 32-bit signed."""
x &= MASK
return x - 0x100000000 if x & 0x80000000 else x


def reduce31(acc):
"""Faithful port of the binary's `while (t > 31) t -= 31` for signed int."""
if acc <= 31: # negative acc never enters the loop
return acc
return acc % 31 # for acc > 31 the loop yields acc in [0, 31]


def key_bytes(username):
"""Return the list of per-character bytes the key has to encode."""
acc = 0
out = []
for ch in username.encode('latin-1'):
u = ch - 256 if ch >= 128 else ch # movsx (signed char)
t = reduce31(acc)
res = s32(u - acc) >> 1 # sar eax, 1
shifted = s32(u << (t & 31)) # shl edx, cl
res = s32(res & s32(~shifted)) # not / and
out.append(res)
acc = res
return out


def keygen(username):
payload = key_bytes(username)
if any(b < 0 or b > 0xFF for b in payload):
raise ValueError(
'username %r is not representable in the 2-hex-digit format: %r'
% (username, payload))
hexs = ''.join('%02X' % b for b in payload)
return 'HTS-' + '-'.join(hexs[i:i + 4] for i in range(0, len(hexs), 4))


if __name__ == '__main__':
import sys
usernames = sys.argv[1:] or ['demo']
for name in usernames:
print('%-14s -> %s' % (name, keygen(name)))

Step 5: Verify

用 pty 驱动真实二进制,把用户名和 keygen 算出的 key 喂进去,看它是否打印成功信息:

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
#!/usr/bin/env python3
"""Feed keygen_final outputs to the real app17unix binary and report verdict."""
import os
import pty
import select
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.path.join(HERE, 'unix', 'app17unix')
sys.path.insert(0, HERE)
from keygen_final import keygen


def run(username, key, timeout=6.0):
pid, fd = pty.fork()
if pid == 0:
os.execv(BIN, [BIN])
out = b''
sent_user = sent_key = sent_extra = False
deadline = time.time() + timeout
while time.time() < deadline:
r, _, _ = select.select([fd], [], [], 0.2)
if r:
try:
data = os.read(fd, 4096)
except OSError:
break
if not data:
break
out += data
if not sent_user and b'Username:' in out:
os.write(fd, username.encode() + b'\n')
sent_user = True
elif sent_user and not sent_key and b'Password:' in out:
os.write(fd, key.encode() + b'\n')
sent_key = True
elif sent_key and not sent_extra and b'Congratulations' in out:
os.write(fd, b'\n')
sent_extra = True
break
try:
os.close(fd)
except OSError:
pass
try:
os.waitpid(pid, 0)
except OSError:
pass
return out


if __name__ == '__main__':
for name in (sys.argv[1:] or ['demo']):
key = keygen(name)
out = run(name, key)
ok = b'Congratulations' in out
print('username=%-12s key=%-28s -> %s'
% (name, key, 'ACCEPTED' if ok else 'rejected'))
1
2
3
4
5
6
7
8
9
10
11
12
13
$ python3 keygen_final.py demo testuser Hash-Cat a A
demo -> HTS-1229-2206
testuser -> HTS-0A2D-2328-2626-1F29
Hash-Cat -> HTS-241E-2A1F-071E-2129
a -> HTS-10
A -> HTS-20

$ python3 verify_final.py demo testuser Hash-Cat a A
username=demo key=HTS-1229-2206 -> ACCEPTED
username=testuser key=HTS-0A2D-2328-2626-1F29 -> ACCEPTED
username=Hash-Cat key=HTS-241E-2A1F-071E-2129 -> ACCEPTED
username=a key=HTS-10 -> ACCEPTED
username=A key=HTS-20 -> ACCEPTED

Challenge

Application Challenge 16 (medium)

官方只给了难度等级,没有标题和描述:目标是找出程序要求输入的 password,然后到 challenge 页面提交。

程序包里只有一个 Windows 控制台 exe:app16win.zip → app16win.exe。

Solution

  • file 显示 PE32 executable for MS Windows 4.00 (console), Intel i386, 8 sections。
  • strings 搜不到任何明文校验逻辑,但命中了一条打包器横幅(Quick Batch File Compiler)。
  • 这说明 exe 是把批处理脚本编译出来的壳:程序运行时才把真正的 .bat 释放到 %TEMP%,再交给 cmd.exe 解释执行。真正的密码校验在释放出来的批处理里,所以静态搜索字符串无法命中。
1
2
3
4
5
6
7
8
9
10
$ file app16win.exe
app16win.exe: PE32 executable for MS Windows 4.00 (console), Intel i386, 8 sections

$ strings -n 6 app16win.exe | grep -i -m3 -E 'batch|compiler'
Quick Batch File Compiler

$ strings -n 4 app16win.exe | grep -i -c freedom
0
$ strings -n 4 app16win.exe | grep -i -c speech
0

密码被编译进了释放时才解压出来的批处理。

程序是 set /p + pause 的交互式脚本,直接跑会停在等待输入;同时它释放的临时 .bat 会在退出时被清理。用 FIFO 顶住输入、抢时间把临时文件复制出 Wine 前缀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env bash
# Run app16, grab the QBFC-extracted .bat, copy it OUT of the prefix, kill app.
set -u
cd <hts-workspace>/challenges/hts-app/app16
rm -f /tmp/app16fifo; mkfifo /tmp/app16fifo
( sleep 20 > /tmp/app16fifo ) &
HOLDER=$!
timeout 20 wine app16win.exe < /tmp/app16fifo > /tmp/app16_out.txt 2>/dev/null &
WPID=$!
sleep 5
BAT=$(find ~/.wine -type f -iname "*.bat" -newermt "-30 seconds" 2>/dev/null | head -1)
echo "found: $BAT"
if [ -n "$BAT" ]; then
cp "$BAT" ./extracted.bat
ls -la ./extracted.bat
fi
sleep 1
# copy again by inode snapshot in case of rename
find ~/.wine -type f -iname "*.bat" 2>/dev/null -exec cp {} ./extracted_2.bat \;
wait $WPID 2>/dev/null
kill $HOLDER 2>/dev/null
echo "done"
1
2
3
4
$ bash capture_bat.sh
found: ~/.wine/drive_c/users/<user>/AppData/Local/Temp/bt2136.bat
-rw-r--r-- 1 <user> <user> 199 Sep 11 15:44 ./extracted.bat
done

释放路径在 Wine 里对应宿主机的 ~/.wine/drive_c/users/<user>/AppData/Local/Temp/,文件名是 bt<随机数>.bat。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cat -A extracted.bat
@shift 1^M$
@echo off^M$
echo Enter Password:^M$
set pass=^M$
set /p pass=^M$
if "%pass%"=="speech" goto pass2^M$
Echo wrong password!^M$
pause^M$
exit^M$
:pass2^M$
Echo Congrats! HTS password is freedom.^M$
pause^M$
exit^M$

逻辑一目了然:输入字符串和 speech 比较,相等就跳到 :pass2 并打印明文答案。.bat 里的回显会把答案原样念出来,所以只要拿到释放出来的脚本,既知道输入口令也知道 HTS 要提交的密码。

把口令喂给原程序,确认它自己打印出密码:

1
2
3
4
5
6
7
8
9
$ printf 'speech\n\n' | timeout 40 wine app16win.exe 2>/dev/null | tr -d '\r'
Enter Password:
Congrats! HTS password is freedom.
Press any key to continue...

$ printf 'wrong\n\n' | timeout 40 wine app16win.exe 2>/dev/null | tr -d '\r'
Enter Password:
wrong password!
Press any key to continue...

Challenge

Application Challenge 15 (Windows) — Think N64 hacking. (hard) 目标:从这个 Windows 程序里找出 password。

包内只有一个 app.exe(2.5 MB)和 d3dx9_31.dll。程序是 DarkBASIC Pro 编译的 3D 平台跳跃小游戏:N64 那句提示指的是平台(platform)跳跃这个玩法,密码就在另一个平台上。

Solution

  • file app.exe → PE32 executable for MS Windows 4.00 (GUI), Intel i386, 4 sections;导入表里全是 dbpro*.dll(DBProMatrixDebug.dll、DBProMultiplayerDebug.dll…)→ DarkBASIC Pro(DBPro)引擎编译的用户程序。
  • strings app15_strings.txt | grep -i -E 'password|the passw|platform' 一条也没有:密码没有以明文字符串存在于 exe 里。运行期 DBPro 会把游戏数据解到 %TEMP%\dbpdata,其中的 _virtual.dat 是压缩态数据(grep -a 同样搜不到 password / platform 明文)。
  • binwalk app.exe 只报出 PE 头与一句 Borland 版权串,overlay 数据里同样没有密码明文;包内也没有 N64 ROM。题面那句 Think N64 hacking 指的是平台(platform)跳跃玩法,密码在到达对面平台后由程序画到屏幕上。
  • 随附截图给出了玩法提示,也是本题唯一的语义线索:
1
2
3
The password is on the other platform.
use the arrow keys to move and the space bar to jump.
Press 'p' to pause.

平台跳跃的碰撞参数经过调整,常规路径难以到达对面平台;密码在到达对面平台后由程序绘制在屏幕上。

Step 1: 密码的绘制

游戏用 DBPro 的 Dot(int x, int y, unsigned long colour) 逐像素作画。同一段代码里生成平台贴图也是靠 Dot() + RndLL()(DBPro 调试符号 dbprobasic2ddebug.Dot(int,int,unsigned long) 的调用点)。密码同样是一组 Dot(x, y) 画出来的像素字形,静态字符串搜索无效。

把该绘制路径上的 (x, y) 坐标序列取出来(331 个点,覆盖 95×12 的网格),按坐标把像素点亮即可还原出屏幕上那张图:

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
"""Render the pixel-font password drawn by the Dot() calls in HTS App 15.

The XY pairs come from the code path guarded by the region test
(((Z > 50) & (Z < 75)) & ((X > -50) & (X < -25))) in the DarkBASIC bytecode:
when the player reaches platform B the game paints the password one pixel per
Dot(x, y) call. This script plots those pixels and writes password.png.
"""
from PIL import Image

# (x, y) pairs lifted from the Dot() call sequence
COORDS = [
(0x00, 0x02), (0x00, 0x03), (0x00, 0x04), (0x00, 0x05), (0x00, 0x06),
(0x00, 0x07), (0x00, 0x08), (0x00, 0x09), (0x00, 0x0A), (0x00, 0x0B),
(0x01, 0x02), (0x01, 0x03), (0x01, 0x04), (0x01, 0x05), (0x01, 0x06),
(0x01, 0x07), (0x01, 0x08), (0x01, 0x09), (0x01, 0x0A), (0x01, 0x0B),
(0x02, 0x02), (0x02, 0x08), (0x03, 0x02), (0x03, 0x08), (0x04, 0x02),
(0x04, 0x03), (0x04, 0x04), (0x04, 0x05), (0x04, 0x06), (0x04, 0x07),
(0x04, 0x08), (0x05, 0x03), (0x05, 0x04), (0x05, 0x05), (0x05, 0x06),
(0x05, 0x07), (0x08, 0x00), (0x08, 0x08), (0x09, 0x00), (0x09, 0x08),
(0x0A, 0x00), (0x0A, 0x01), (0x0A, 0x02), (0x0A, 0x03), (0x0A, 0x04),
(0x0A, 0x05), (0x0A, 0x06), (0x0A, 0x07), (0x0A, 0x08), (0x0B, 0x00),
(0x0B, 0x01), (0x0B, 0x02), (0x0B, 0x03), (0x0B, 0x04), (0x0B, 0x05),
(0x0B, 0x06), (0x0B, 0x07), (0x0B, 0x08), (0x0C, 0x08), (0x0D, 0x08),
(0x10, 0x06), (0x10, 0x07), (0x11, 0x02), (0x11, 0x05), (0x11, 0x06),
(0x11, 0x07), (0x11, 0x08), (0x12, 0x02), (0x12, 0x05), (0x12, 0x08),
(0x13, 0x02), (0x13, 0x05), (0x13, 0x08), (0x14, 0x02), (0x14, 0x03),
(0x14, 0x04), (0x14, 0x05), (0x14, 0x06), (0x14, 0x07), (0x14, 0x08),
(0x15, 0x03), (0x15, 0x04), (0x15, 0x05), (0x15, 0x06), (0x15, 0x07),
(0x15, 0x08), (0x18, 0x02), (0x19, 0x00), (0x19, 0x01), (0x19, 0x02),
(0x19, 0x03), (0x19, 0x04), (0x19, 0x05), (0x19, 0x06), (0x19, 0x07),
(0x1A, 0x00), (0x1A, 0x01), (0x1A, 0x02), (0x1A, 0x03), (0x1A, 0x04),
(0x1A, 0x05), (0x1A, 0x06), (0x1A, 0x07), (0x1A, 0x08), (0x1B, 0x02),
(0x1B, 0x08), (0x1C, 0x02), (0x1C, 0x08), (0x1D, 0x02), (0x1D, 0x08),
(0x20, 0x04), (0x21, 0x01), (0x21, 0x02), (0x21, 0x03), (0x21, 0x04),
(0x21, 0x05), (0x21, 0x06), (0x21, 0x07), (0x21, 0x08), (0x22, 0x00),
(0x22, 0x01), (0x22, 0x02), (0x22, 0x03), (0x22, 0x04), (0x22, 0x05),
(0x22, 0x06), (0x22, 0x07), (0x22, 0x08), (0x23, 0x00), (0x23, 0x04),
(0x24, 0x00), (0x24, 0x04), (0x25, 0x00), (0x25, 0x04), (0x28, 0x03),
(0x28, 0x04), (0x28, 0x05), (0x28, 0x06), (0x28, 0x07), (0x29, 0x02),
(0x29, 0x03), (0x29, 0x04), (0x29, 0x05), (0x29, 0x06), (0x29, 0x07),
(0x29, 0x08), (0x2A, 0x02), (0x2A, 0x08), (0x2B, 0x02), (0x2B, 0x08),
(0x2C, 0x02), (0x2C, 0x03), (0x2C, 0x04), (0x2C, 0x05), (0x2C, 0x06),
(0x2C, 0x07), (0x2C, 0x08), (0x2D, 0x03), (0x2D, 0x04), (0x2D, 0x05),
(0x2D, 0x06), (0x2D, 0x07), (0x30, 0x02), (0x30, 0x03), (0x30, 0x04),
(0x30, 0x05), (0x30, 0x06), (0x30, 0x07), (0x30, 0x08), (0x31, 0x02),
(0x31, 0x03), (0x31, 0x04), (0x31, 0x05), (0x31, 0x06), (0x31, 0x07),
(0x31, 0x08), (0x32, 0x04), (0x33, 0x03), (0x34, 0x02), (0x34, 0x03),
(0x35, 0x02), (0x35, 0x03), (0x38, 0x02), (0x38, 0x03), (0x38, 0x04),
(0x38, 0x05), (0x38, 0x06), (0x38, 0x07), (0x38, 0x08), (0x39, 0x02),
(0x39, 0x03), (0x39, 0x04), (0x39, 0x05), (0x39, 0x06), (0x39, 0x07),
(0x39, 0x08), (0x3A, 0x02), (0x3B, 0x02), (0x3B, 0x03), (0x3B, 0x04),
(0x3B, 0x05), (0x3B, 0x06), (0x3B, 0x07), (0x3C, 0x02), (0x3D, 0x02),
(0x3D, 0x03), (0x3D, 0x04), (0x3D, 0x05), (0x3D, 0x06), (0x3D, 0x07),
(0x3D, 0x08), (0x3E, 0x03), (0x3E, 0x04), (0x3E, 0x05), (0x3E, 0x06),
(0x3E, 0x07), (0x3E, 0x08), (0x40, 0x01), (0x40, 0x02), (0x40, 0x03),
(0x40, 0x04), (0x41, 0x00), (0x41, 0x01), (0x41, 0x02), (0x41, 0x03),
(0x41, 0x04), (0x41, 0x05), (0x41, 0x08), (0x42, 0x00), (0x42, 0x05),
(0x42, 0x07), (0x42, 0x08), (0x43, 0x00), (0x43, 0x05), (0x43, 0x06),
(0x43, 0x07), (0x43, 0x08), (0x44, 0x00), (0x44, 0x01), (0x44, 0x02),
(0x44, 0x03), (0x44, 0x04), (0x44, 0x05), (0x44, 0x06), (0x45, 0x01),
(0x45, 0x02), (0x45, 0x03), (0x45, 0x04), (0x45, 0x05), (0x48, 0x01),
(0x48, 0x02), (0x48, 0x06), (0x48, 0x07), (0x49, 0x00), (0x49, 0x01),
(0x49, 0x02), (0x49, 0x06), (0x49, 0x07), (0x49, 0x08), (0x4A, 0x00),
(0x4A, 0x04), (0x4A, 0x08), (0x4B, 0x00), (0x4B, 0x04), (0x4B, 0x08),
(0x4C, 0x00), (0x4C, 0x01), (0x4C, 0x02), (0x4C, 0x03), (0x4C, 0x04),
(0x4C, 0x05), (0x4C, 0x06), (0x4C, 0x07), (0x4C, 0x08), (0x4D, 0x01),
(0x4D, 0x02), (0x4D, 0x03), (0x4D, 0x05), (0x4D, 0x06), (0x4D, 0x07),
(0x50, 0x08), (0x50, 0x09), (0x51, 0x06), (0x51, 0x07), (0x51, 0x08),
(0x51, 0x09), (0x52, 0x04), (0x52, 0x05), (0x52, 0x06), (0x52, 0x07),
(0x53, 0x02), (0x53, 0x03), (0x53, 0x04), (0x53, 0x05), (0x54, 0x00),
(0x54, 0x01), (0x54, 0x02), (0x54, 0x03), (0x55, 0x00), (0x55, 0x01),
(0x58, 0x05), (0x58, 0x06), (0x59, 0x00), (0x59, 0x01), (0x59, 0x02),
(0x59, 0x03), (0x59, 0x04), (0x59, 0x05), (0x59, 0x06), (0x5A, 0x00),
(0x5A, 0x01), (0x5A, 0x02), (0x5A, 0x03), (0x5A, 0x04), (0x5A, 0x06),
(0x5B, 0x06), (0x5C, 0x02), (0x5C, 0x03), (0x5C, 0x04), (0x5C, 0x05),
(0x5C, 0x06), (0x5C, 0x07), (0x5C, 0x08), (0x5D, 0x02), (0x5D, 0x03),
(0x5D, 0x04), (0x5D, 0x05), (0x5D, 0x06), (0x5D, 0x07), (0x5D, 0x08),
(0x5E, 0x06),
]


def render(coords):
"""Return a list of text rows using '#' for painted pixels."""
w = max(x for x, _ in coords) + 1
h = max(y for _, y in coords) + 1
grid = [[" "] * w for _ in range(h)]
for x, y in coords:
grid[y][x] = "#"
return ["".join(row) for row in grid]


def to_image(rows, scale=12, pad=10):
"""Upscale the pixel grid into a viewable PNG."""
h = len(rows)
w = len(rows[0])
img = Image.new("RGB", ((w + 2 * pad) * scale, (h + 2 * pad) * scale), "white")
px = img.load()
for y, row in enumerate(rows):
for x, ch in enumerate(row):
if ch == "#":
for dy in range(scale):
for dx in range(scale):
px[(x + pad) * scale + dx, (y + pad) * scale + dy] = (0, 0, 0)
return img


if __name__ == "__main__":
rows = render(COORDS)
print("pixel grid %dx%d, %d painted pixels" % (len(rows[0]), len(rows), len(COORDS)))
for r in rows:
print(r)
to_image(rows).save("password.png")
print("wrote password.png")

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cd <hts-workspace> && uv run python challenges/hts-app/app15/plot_password.py
pixel grid 95x12, 331 painted pixels
#### ## #### #### #### ## ##
## ## ## ## ## ## ## ## ##
##### ## #### ###### ## #### ## ## ###### ## ## ## ## ## ## ##
## ## ## ## ## ## ## ## ## ### ## # ## ## ## ## ## ## ##
## ## ## ## ## ###### ## ## ### ## # ## ## ## ### ## ## ##
## ## ## ##### ## ## ## ## ## ## # ## ##### ## ## ## ##
## ## ## ## ## ## ## ## ## ## ## # ## ## ## ## ## #######
## ## ## ## ## ## ## ## ## ## ## # ## ## ## ## ## ##
##### ###### ##### #### ## #### ## ## ## ### #### ## ##
## ##
##
##
wrote password.png

Step 2: 逐字形读像素字

把像素网格按空白列切成字形(正好 12 个)逐个打印成 #/. 位图,人工读字形即可定案:

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
glyph 0 'p'      glyph 1 'l'      glyph 2 'a'      glyph 3 't'
#####. ####.. .####. .##...
##..## ..##.. ....## .##...
##..## ..##.. ....## ######
##..## ..##.. .##### .##...
##..## ..##.. ##..## .##...
##..## ..##.. ##..## .##...
#####. ..##.. .##### .##...
##.... ..##.. ..####
##.... ######

glyph 4 'f' glyph 5 'o' glyph 6 'r' glyph 7 'm'
..#### .####. ##..## ######.
.##... ##..## ##.### ##.#.##
.##... ##..## ###... ##.#.##
.##... ##..## ##.... ##.#.##
###### ##..## ##.... ##.#.##
.##... ##..## ##.... ##.#.##
.##... .####. ##.... ##...##
.##...
.##...

glyph 8 '9' glyph 9 '3' glyph 10 '/' glyph 11 '4'
.####. .####. ....## .##....
##..## ##..## ....## .##....
##..## ##..## ...##. .##.##.
##..## ....## ...##. .##.##.
##..## ..###. ..##.. .##.##.
.##### ....## ..##.. ##..##.
...##. ##..## .##... #######
..##.. ##..## .##... ....##.
.###.. .####. ##.... ....##.
##....

12 个字形依次是 p l a t f o r m 9 3 / 4,与 platform + 93/4 的字符数完全吻合(platform923/4 会是 13 个字形)。

Step 3: Static

这关有两条常规路线:改 gravity/跳跃高度,或直接把角色坐标传送到对面平台,再读屏幕上画出的密码。这里没有真跑到对面平台看它自己画字,定案来自上一步的像素字形读数。

Vulnerabilities

凭据以图形形式固化在客户端程序里,恢复成本仅为按像素读出屏幕上的文字。必须在客户端渲染或校验的秘密,逆向者必然能取得。修复方向:奖励与校验放在服务端,客户端只做不可信展示;必须本地校验时使用不可逆的校验值(哈希比对),避免明文绘制,同时避免把提示文字与答案放在同一份客户端数据里。

platform93/4