HackThisSite - Programming Mission 10

Challenge

This level is hybrid of Programming/Stego missions, The main purpose being: Get the data from the image, and get the answer from that data. The data itself is encoded and encrypted multiple times, in the end is a 10 character password. The password consists of two characters, one upper case and one lower case, repeating in random order. Example: XyyXyXXyXy In order to get this data you must brute-force the encrypted hashes you get in each step. To do this within the time limit you must write a smart brute forcing method, good luck. Save this image to get started.

这道题是 Programming 和 Stego 的混合题:从图片里取出数据,再从数据里取出答案。数据被多层编码和加密,最后是一个 10 字符的密码。密码由两个字符组成(一个大写一个小写),随机交替排列,例如 XyyXyXXyXy。每一步都会拿到加密过的 hash,必须在限时内用足够聪明的方式爆破。把图片存下来开始吧。

实例页 https://www.hackthissite.org/missions/prog/10/ 里图片地址是随机的 image.php?<随机数>,表单只有 solution 一个字段,限时 45 秒;图片按实例随机生成,只有带 cookie 的 session 能取到本实例的这张图,裸 URL 拿不到:

1
2
3
<form name="submitform" action="/missions/prog/10/index.php" method="POST">
<input size="75" name="solution">
<input name="submitbutton" type="submit" value="Submit (remaining time: 45 seconds)">

45 秒意味着不能人工看图片,整个链路 fetch → 下载 → 解码 → 爆破 → 提交必须在一次脚本运行里执行完毕。

Solution

图片静态检查

先按常规隐写方法检查一遍。

1
2
3
4
5
6
7
8
9
10
11
12
$ file image1.png
image1.png: PNG image data, 255 x 128, 8-bit/color RGB, non-interlaced

$ binwalk image1.png
DECIMAL HEXADECIMAL DESCRIPTION
0 0x0 PNG image, total size: 1845 bytes

$ strings -n 4 image1.png
IHDR
IDATx
)Ei$'
LERD

binwalk 没有发现任何附加文件,strings 只有 zlib 压缩残渣。再用结构解析确认 PNG chunk 列表和尾部数据:

1
2
3
4
5
6
7
8
9
10
11
import struct

d = open('image1.png', 'rb').read()
i = 8
while i < len(d):
ln = struct.unpack('>I', d[i:i + 4])[0]
typ = d[i + 4:i + 8].decode('latin1')
print(typ, ln, 'at', i)
i += 12 + ln
print('total', len(d), 'end at', i)
print('trailing:', d[i:][:100])
1
2
3
4
5
IHDR 13 at 8
IDAT 1788 at 33
IEND 0 at 1833
total 1845 end at 1845
trailing: b''

只有 IHDR / IDAT / IEND,没有 tEXt,IEND 之后也没有数据。数据只可能在像素里。

像素统计:找到离群像素

统计一张图里的颜色分布:

1
2
3
4
5
6
7
8
9
10
11
12
from PIL import Image
import collections

im = Image.open('image1.png').convert('RGB')
w, h = im.size
px = im.load()
cols = collections.Counter(px[x, y] for y in range(h) for x in range(w))
print(im.mode, im.size)
for c, n in cols.most_common(6):
print(c, n)
print('unique colors:', len(cols))
print('R values', set(c[0] for c in cols), 'G', set(c[1] for c in cols))
1
2
3
4
5
6
7
8
9
RGB (255, 128)
(0, 0, 56) 244
(0, 0, 57) 244
(0, 0, 58) 244
(0, 0, 59) 244
(0, 0, 60) 244
(0, 0, 62) 244
unique colors: 221
R values {0} G {0, 5, 6, 7, 8, 9, 10}

几个关键观察:

  • 绝大多数像素是 (0, 0, b),也就是只有 B 通道在变化,R 恒为 0。
  • B 通道是一条三角波渐变:每一行从左到右 ±1 地走,到端点反射。这是纯装饰性背景。
  • G 通道基本全是 0,但有 88 个像素非零,而且每行最多一个。这些就是被写入的离群像素。

再看非零 G 像素的行分布,可以看到它落在几乎每一行上、每行只有一个:

1
2
nonzero G count 88
[(90, 0, 9), (84, 1, 6), (77, 2, 6), (53, 3, 10), (78, 4, 10), (106, 5, 6), ...]

(90, 0, 9) 的含义是:第 0 行、第 90 列那个像素的 G 值是 9。G 的值本身不携带信息(只有 5~10 这几个取值,像是随机噪声),真正携带信息的是这个像素的 x 坐标

顺带排除了 LSB 路线,对 R/G/B 三个通道分别取最低位再按 8 位组字节,得到的只是渐变的固定重复模式,不是明文:

1
2
B 0 printable! UUUUUUUUUUUUUUUUV.................UUUUUUUUUUUUUUUUj................UU...
B 1 printable! UUUUUUUUUV.................UUUUUUUUUUUUUUUUj................UU...

逐行找那个离群像素,把它的 x 当字符码取出来,按行序拼接:

1
2
marker offsets (first 20): [90, 84, 77, 53, 78, 106, 85, 48, 89, 106, 81, 50, 90, 87, 82, 106, 78, 84, 107, 121]
layer 1: ZTM5NjU0YjQ2ZWRjNTkyODRjNDBiYWQ5Njg4N2VjNTQxZTQ3NmY5NmQ2YjdlODFiM2RmZjkyNjAxZWViY2ZlYw==

88 个字符、以 == 结尾、全部落在 base64 字母表内:第一层是 base64 得到了确认。

1
2
layer 2 (base64): b'e39654b46edc59284c40bad96887ec541e476f96d6b7e81b3dff92601eebcfec'
layer 3 (digest): e39654b46edc59284c40bad96887ec541e476f96d6b7e81b3dff92601eebcfec (64 hex chars)

base64 解码后正好是 64 个 hex 字符,即一个 SHA-256。这就对上了题面说的 brute-force the encrypted hashes

题面已经把搜索空间交代得很清楚:密码 10 位,由恰好两个字符(一个小写、一个大写)随机交替组成。所以不需要字典,穷举即可:

  • 大小写字母对:26 × 26 = 676 种
  • 10 个位置的排列:2¹⁰ = 1024 种
  • 总计 676 × 1024 = 692224 个候选

这个规模在 CPython 里不到一秒就能执行完毕,完全不需要 hashcat 或 GPU:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import hashlib
import string
import time

digest = 'e39654b46edc59284c40bad96887ec541e476f96d6b7e81b3dff92601eebcfec'
t0 = time.time()
n = 0
for lo in string.ascii_lowercase:
for up in string.ascii_uppercase:
for mask in range(1 << 10):
pw = ''.join(up if (mask >> i) & 1 else lo for i in range(10))
n += 1
if hashlib.sha256(pw.encode()).hexdigest() == digest:
print('found', pw, 'after', n, 'hashes in', round(time.time() - t0, 2), 's')
raise SystemExit
1
found fffffDDffD after 136801 hashes in 0.18 s

渐变通道随机

G 非零 = marker 硬编码了进去,换一个实例就得到:

1
2
3
4
[2/4] rows carrying a marker: 128/128
marker offsets (first 20): [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
layer 1: \x01\x00\x00\x00...
[3/4] layer 2 (base64): b''

128 行全都有 marker、坐标都在 0 附近,说明解析结果不可用。重新抓下这张图(image.php?21124)分析:

1
2
3
row0 first 15: [(0, 0, 0), (1, 0, 0), (2, 0, 0), ..., (14, 0, 0)]
R nonzero: 32327 G nonzero: 0
gradient channel 0 {0: 104, 1: 1, 2: 7}

原来这个实例把渐变画在了 R 通道,G 恒为 0。所以 G 非零 这个判据在第一张图成立纯属巧合:那张图渐变在 B 通道,于是 R 和 G 都是非渐变通道,离群像素里恰好有 G 的分量。

正确的判据应该是和渐变通道无关的:先认出哪条通道在当渐变通道(取值种类最多的那条),然后逐行找在另外两条通道上有非零值的那个像素。这样两种实例都成立:

1
2
3
4
5
6
gradient channel 0 {0: 104, 1: 1, 2: 7}   # 渐变在 R
[2/4] rows carrying a marker: 88/128
layer 1: NzQ2ODEwNzJiZmNjOGVmZGYxMGExODQ0NmI2MjFhNDJiMWI3OWNhMzNhZGFmOTA2MGE0MDFjNmViODVmODUzZA==
[3/4] layer 2 (base64): b'74681072bfcc8efdf10a18446b621a42b1b79ca33adaf9060a401c6eb85f853d'
[4/4] cracked sha256 after 369893 hashes in 0.48s
pw ('nnXnnXXXnn', 'sha256')

tobytes() 直接按字节做也行,比 im.load() 少一轮 PIL 调用的开销:

1
2
raw = im.tobytes()
at = lambda x, y: raw[3 * (y * w + x):3 * (y * w + x) + 3]

Script

把四层串成一个进程,一次运行完成 fetch → 下载图 → 解码 → 爆破 → 提交。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
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
#!/usr/bin/env python3
"""HackThisSite Programming mission 10 -- Automated Steganography (45 s limit).

Pipeline (all in one process, one instance fetch):

1. GET https://www.hackthissite.org/missions/prog/10/ -> form + random image URL
2. GET .../missions/prog/10/image.php?<rand> -> 255x128 RGB PNG
3. The PNG is a 255x128 triangle-wave gradient drawn in ONE randomly
chosen channel (R, G or B). The grader hides one byte per row in the
*x position* of the single pixel that also carries a non-zero value in
one of the other two channels:
char = chr(x_of_the_off_gradient_pixel)
Only 88 of the 128 rows carry a marker; concatenating those chars in row
order yields the base64 string.
4. base64-decode -> 64 hex chars -> a SHA-256 digest.
5. The answer is a 10-character password built from exactly two distinct
characters (one lower case, one upper case) in random order, so it is
found by hashing all 26*26 pairs x 2^10 orderings and comparing to the
digest (~0.7 s in CPython; no external tools needed).
6. POST the password back to .../missions/prog/10/index.php (field `solution`).

Usage:
cd <hts-workspace>
export HTS_COOKIE='HackThisSite=...'
uv run python challenges/hts-prog/10/solve.py # solve + submit
uv run python challenges/hts-prog/10/solve.py --dry-run # decode only

The cookie is read from HTS_COOKIE at runtime and is never written to disk.
"""

import argparse
import base64
import hashlib
import io
import os
import re
import string
import sys
import time

from PIL import Image
import requests

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

LEVEL = 10
IMAGE_RE = re.compile(r'src="(/missions/prog/10/image\.php[^"]*)"')
ALGOS = ("sha256", "sha512", "sha1", "md5", "sha384", "sha224")

def find_image_url(page_text):
m = IMAGE_RE.search(page_text)
if not m:
raise SystemExit("image.php URL not found on level page (not logged in?)")
return m.group(1)

def download_image(s, url):
r = s.get(BASE + url, timeout=20)
r.raise_for_status()
return r.content

def extract_base64(png_bytes):
"""Return (base64_string, per-row marker offsets).

The instance renders a triangle-wave gradient in ONE randomly chosen
channel (R, G or B) and hides one byte per row in the x-offset of the
single pixel that also carries a value in another channel. Detect the
gradient channel as the one with the most distinct values, then take,
for every row, the x of the first pixel whose other channels are non-zero.
"""
im = Image.open(io.BytesIO(png_bytes)).convert("RGB")
w, h = im.size
raw = im.tobytes()
print(f"[1/4] image: {w}x{h} {im.mode}")
at = lambda x, y: raw[3 * (y * w + x):3 * (y * w + x) + 3]

spread = {c: len({at(x, y)[c] for y in range(h) for x in range(w)})
for c in range(3)}
grad = max(spread, key=lambda c: spread[c])
others = [c for c in range(3) if c != grad]
print(f" gradient channel: {'RGB'[grad]} (distinct values {spread})")

xs = []
for y in range(h):
hits = [x for x in range(w)
if any(at(x, y)[c] != 0 for c in others)]
if hits:
xs.append(hits[0])
s = "".join(chr(x) for x in xs)
print(f"[2/4] rows carrying a marker: {len(xs)}/{h}")
print(f" marker offsets (first 20): {xs[:20]}")
print(f" layer 1 (chars from offsets): {s}")
return s, xs

def decode_layer1(b64_str):
pad = b64_str + "=" * (-len(b64_str) % 4)
raw = base64.b64decode(pad)
digest = raw.decode("ascii").strip()
print(f"[3/4] layer 2 (base64): {raw!r}")
if not re.fullmatch(r"[0-9a-f]+", digest):
raise SystemExit("decoded value is not a hex digest")
print(f" layer 3 (digest): {digest} ({len(digest)} hex chars)")
return digest

def candidates():
"""Every 10-char string built from one lower + one upper case letter."""
for lo in string.ascii_lowercase:
for up in string.ascii_uppercase:
for mask in range(1 << 10):
yield "".join(up if (mask >> i) & 1 else lo for i in range(10))

def brute_force(digest):
"""Hash candidates algo-major so the common case (sha256) is ~0.5 s."""
t0 = time.time()
for algo in ALGOS:
tried = 0
for pw in candidates():
tried += 1
if hashlib.new(algo, pw.encode()).hexdigest() == digest:
print(f"[4/4] cracked {algo} after {tried} hashes "
f"in {time.time() - t0:.2f}s")
return pw, algo
raise SystemExit("no password matched (unexpected hash algorithm?)")

def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true",
help="decode only, do not submit")
args = ap.parse_args()

t_start = time.time()
s = session()
page = fetch_level(s, LEVEL)
print("[0/4] level page fetched "
f"({len(page)} bytes, {time.time() - t_start:.2f}s)")

url = find_image_url(page)
print(f" image URL: {url}")
png = download_image(s, url)
print(f" image bytes: {len(png)} ({time.time() - t_start:.2f}s)")

b64_str, _ = extract_base64(png)
digest = decode_layer1(b64_str)
password, algo = brute_force(digest)
print(f" PASSWORD: {password!r} (distinct chars: {sorted(set(password))})")

if args.dry_run:
print(f"dry run: not submitting. total {time.time() - t_start:.2f}s")
return

ok, resp = submit(s, LEVEL, password)
print(f" submitted, verdict={ok} total {time.time() - t_start:.2f}s")
print(body_text(resp)[-600:])
if ok is not True:
sys.exit(1)

if __name__ == "__main__":
main()

实际运行输出(成功的一次):

1
2
3
4
5
6
7
8
9
10
11
12
13
[0/4] level page fetched (13602 bytes, 1.40s)
image URL: /missions/prog/10/image.php?11925
image bytes: 1967 (1.76s)
[1/4] image: 255x128 RGB
gradient channel: B (distinct values {0: 1, 1: 7, 2: 100})
[2/4] rows carrying a marker: 88/128
marker offsets (first 20): [79, 68, 108, 106, 90, 68, 85, 48, 89, 106, 90, 109, 79, 87, 90, 108, 77, 87, 78, 108]
layer 1 (chars from offsets): ODljZDU0YjZmOWZlMWNlMmYxNDQ2MTMxN2VjYTc2NTlkMDlkMTE0MTAwNGJkNjM4YTE0NGY4OGI1ZTA3NjljZg==
[3/4] layer 2 (base64): b'89cd54b6f9fe1ce2f14461317eca7659d09d1141004bd638a144f88b5e0769cf'
layer 3 (digest): 89cd54b6f9fe1ce2f14461317eca7659d09d1141004bd638a144f88b5e0769cf (64 hex chars)
[4/4] cracked sha256 after 620667 hashes in 0.78s
PASSWORD: 'xIxIIIIxxx' (distinct chars: ['I', 'x'])
submitted, verdict=True total 4.39s

服务端返回:

1
2
Congrats
Good Job, ***, You have successfully completed this mission

耗时账

45 秒限时下的实际占用:

  • 抓题目页:1.40 s
  • 下载图片:累计 1.76 s
  • 解析图片 + 爆破 SHA-256:约 2.6 s(其中 SHA-256 扫描 0.78 s)
  • 提交往返:约 0.1 s
  • 端到端 4.39 s,余量约 40 s

瓶颈实际在图片解析(Python 里对 32640 个像素做集合统计约 1.8 s),不在爆破。爆破之所以快,是因为题面已经把密码结构给死了:搜索空间是 692224 个候选而不是 52¹⁰,按已知哈希算法(SHA-256)顺序扫描,绝大多数情况下第一轮就命中。

xIxIIIIxxx