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 能取到本实例的这张图:
<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
Step 1: 图片静态检查
先按常规隐写方法检查一遍。
$ file image1.pngimage1.png: PNG image data, 255 x 128, 8-bit/color RGB, non-interlaced
$ binwalk image1.pngDECIMAL HEXADECIMAL DESCRIPTION0 0x0 PNG image, total size: 1845 bytes
$ strings -n 4 image1.pngIHDRIDATx)Ei$'LERDbinwalk 没有发现任何附加文件,strings 只有 zlib 压缩残渣。再用结构解析确认 PNG chunk 列表和尾部数据:
import struct
d = open('image1.png', 'rb').read()i = 8while 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 + lnprint('total', len(d), 'end at', i)print('trailing:', d[i:][:100])IHDR 13 at 8IDAT 1788 at 33IEND 0 at 1833total 1845 end at 1845trailing: b''只有 IHDR / IDAT / IEND,没有 tEXt,IEND 之后也没有数据。数据只可能在像素里。
Step 2: 像素统计:找到离群像素
统计一张图里的颜色分布:
from PIL import Imageimport collections
im = Image.open('image1.png').convert('RGB')w, h = im.sizepx = 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))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) 244unique colors: 221R values {0} G {0, 5, 6, 7, 8, 9, 10}几个关键观察:
- 绝大多数像素是
(0, 0, b),也就是只有 B 通道在变化,R 恒为 0。 - B 通道是一条三角波渐变:每一行从左到右 ±1 地走,到端点反射。这是纯装饰性背景。
- G 通道基本全是 0,但有 88 个像素非零,而且每行最多一个。这些就是被写入的离群像素。
再看非零 G 像素的行分布,可以看到它落在几乎每一行上、每行只有一个:
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 位组字节,得到的只是渐变的固定重复模式,不是明文:
B 0 printable! UUUUUUUUUUUUUUUUV.................UUUUUUUUUUUUUUUUj................UU...B 1 printable! UUUUUUUUUV.................UUUUUUUUUUUUUUUUj................UU...逐行找那个离群像素,把它的 x 当字符码取出来,按行序拼接:
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 得到了确认。
layer 2 (base64): b'e39654b46edc59284c40bad96887ec541e476f96d6b7e81b3dff92601eebcfec'layer 3 (digest): e39654b46edc59284c40bad96887ec541e476f96d6b7e81b3dff92601eebcfec (64 hex chars)base64 解码后正好是 64 个 hex 字符,即一个 SHA-256。这就对上了题面说的 brute-force the encrypted hashes。
题面已经把搜索空间交代得很清楚:密码 10 位,由恰好两个字符(一个小写、一个大写)随机交替组成。所以不需要字典,穷举即可:
- 大小写字母对: 种
- 10 个位置的排列: 种
- 总计 个候选
这个规模在 CPython 里不到一秒就能执行完毕,完全不需要 hashcat 或 GPU:
import hashlibimport stringimport time
digest = 'e39654b46edc59284c40bad96887ec541e476f96d6b7e81b3dff92601eebcfec't0 = time.time()n = 0for 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 SystemExitfound fffffDDffD after 136801 hashes in 0.18 sStep 3: 渐变通道随机
把 G 非零 = marker 硬编码了进去,换一个实例就得到:
[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)分析:
row0 first 15: [(0, 0, 0), (1, 0, 0), (2, 0, 0), ..., (14, 0, 0)]R nonzero: 32327 G nonzero: 0gradient channel 0 {0: 104, 1: 1, 2: 7}原来这个实例把渐变画在了 R 通道,G 恒为 0。所以 G 非零 这个判据在第一张图成立纯属巧合:那张图渐变在 B 通道,于是 R 和 G 都是非渐变通道,离群像素里恰好有 G 的分量。
正确的判据应该是和渐变通道无关的:先认出哪条通道在当渐变通道(取值种类最多的那条),然后逐行找在另外两条通道上有非零值的那个像素。这样两种实例都成立:
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.48spw ('nnXnnXXXnn', 'sha256')用 tobytes() 直接按字节做也行,比 im.load() 少一轮 PIL 调用的开销:
raw = im.tobytes()at = lambda x, y: raw[3 * (y * w + x):3 * (y * w + x) + 3]Script
把四层串成一个进程,一次运行完成 fetch → 下载图 → 解码 → 爆破 → 提交。cookie 从环境变量读取,不落盘:
#!/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`).130 collapsed lines
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 argparseimport base64import hashlibimport ioimport osimport reimport stringimport sysimport time
from PIL import Imageimport 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 = 10IMAGE_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()