HackThisSite - Steganography Mission 6

Challenge

A PNG image is provided. The only hint is Thank you Leafman. 题目提供了一张 PNG 图片,唯一的提示是 Thank you Leafman(感谢 Leafman)。

数据文件在 https://www.hackthissite.org/missions/stego/lvl/stego6.png,是一个 100x100 的 RGBA PNG。

Solution

Step 1: 文件类型与容器结构

file 只认出这是 100x100 的 8-bit RGBA PNG,共 1965 字节:

1
2
3
4
5
6
$ file stego6.png
stego6.png: PNG image data, 100 x 100, 8-bit/color RGBA, non-interlaced
$ wc -c stego6.png
1965 stego6.png
$ sha256sum stego6.png
207ef63dfb3d052e347d0b91c9ad9523f8e8212505013030abdbdbd9ff8fa751 stego6.png

PNG 是块(chunk)结构,先用 pngcheck -v 走一遍每个块:

1
2
3
4
5
6
7
8
9
10
11
12
$ pngcheck -v stego6.png
File: stego6.png (1965 bytes)
chunk IHDR at offset 0x0000c, length 13
100 x 100 image, 32-bit RGB+alpha, non-interlaced
chunk bKGD at offset 0x00025, length 6
red = 0x00ff, green = 0x00ff, blue = 0x00ff
chunk tIME at offset 0x00037, length 7: 12 Nov 2007 13:51:36 UTC
chunk IDAT at offset 0x0004a, length 1779
zlib: deflated, 32K window, default compression
chunk IEND at offset 0x00749, length 0
additional data after IEND chunk
ERRORS DETECTED in stego6.png

结构很干净:IHDR、一个背景色块 bKGD、一个时间戳块 tIME、一个 IDAT、IEND。bKGDtIME 都是普通元数据,没有藏东西。唯一的异常是最后一行:IEND 之后还有数据

IEND 是 PNG 的结束标记,规范上文件到它就结束。这里 IEND 块结束于偏移 0x751,而文件长 0x7AD,即后面多出 92 字节。pngcheck 把声明结束点之后还有字节直接报告成 ERRORS DETECTED。

Step 2: 排除图像内部的隐写

PNG 隐写的另一条常见路径是把数据写入 IDAT 解码后的像素位平面(LSB)。用 zsteg -a 扫全部通道、全部位平面:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ zsteg -a stego6.png | tr '\r' '\n' | sed '/^[[:space:]]*$/d' | head -n 12
extradata:0 .. text: "Tm90IGxpa2UgaXQncyBoYXJkIHRvICdkZWNyeXB0JyB0aGlzIGh1aD8gVGhlIHBhc3N3b3JkIGlzIGhnYnZadzA3Lg=="
chunk:0:IHDR .. file: Adobe Photoshop Color swatch, version 0, 100 colors; 1st RGB space (0), w 0x64, x 0x806, y 0, z 0; 2nd RGB space (0), w 0, x 0, y 0, z 0
b1,r,lsb,xy ..
b1,r,msb,xy ..
b1,g,lsb,xy ..
b1,g,msb,xy ..
b1,b,lsb,xy ..
b1,b,msb,xy ..
b1,a,lsb,xy ..
b1,a,msb,xy ..
b1,rgb,lsb,xy ..
b1,rgb,msb,xy ..

zsteg -a 的原始输出用 \r 分隔通道,这里用 tr 归一化成每行一个通道再看。)

所有 bN,channel,lsb/msb 位平面要么为空,要么只是 text: ["U" repeated 21 times] 这类单字符重复的噪声(那是图像本身大色块的规律,不是文本),没有一处读出可读字符串。第 2 行 chunk:0:IHDR 是 zsteg 把 IHDR 头字节误认成 Photoshop 色板,与本题无关。zsteg 唯一有效的一行是 extradata:0IEND 之后的 92 字节额外数据,内容是 Base64 文本。

Step 3: 提取 IEND 之后的附载

定位 IEND 块结束的位置,取出后面的字节:

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
#!/usr/bin/env python3
"""HackThisSite Steganography 6 - extract the appended payload from stego6.png.

The PNG container is normal (IHDR / bKGD / tIME / IDAT / IEND). After the IEND
chunk there are 92 extra bytes that are not part of the image stream: an ASCII
Base64 string. Decoding it yields a sentence that carries the password.
"""
import base64
import sys


def extract(path):
data = open(path, "rb").read()
i = data.find(b"IEND") # IEND is unique in a valid PNG
end = i + 8 # chunk length(4) + type(4) = end of IEND
trailer = data[end:].strip() # everything after the PNG stream
message = base64.b64decode(trailer).decode("ascii")
return end, trailer.decode("ascii"), message


def main():
path = sys.argv[1] if len(sys.argv) > 1 else "stego6.png"
end, b64, message = extract(path)
print("IEND ends at offset:", hex(end))
print("trailing bytes :", len(b64))
print("Base64 :", b64)
print("decoded message :", message)


if __name__ == "__main__":
main()

运行结果:

1
2
3
4
5
$ uv run python extract_payload.py stego6.png
IEND ends at offset: 0x751
trailing bytes : 92
Base64 : Tm90IGxpa2UgaXQncyBoYXJkIHRvICdkZWNyeXB0JyB0aGlzIGh1aD8gVGhlIHBhc3N3b3JkIGlzIGhnYnZadzA3Lg==
decoded message : Not like it's hard to 'decrypt' this huh? The password is hgbvZw07.

Base64 解出来是一句英文:Not like it's hard to 'decrypt' this huh? The password is hgbvZw07.。提示语里的 decrypt 实际是 Base64 解码(编码不是加密),密码就在这句话末尾。

Step 4: Submit

playit 型关卡的提交契约:formkey 每次加载关卡页都会变,所以取页面与提交必须在同一次运行里完成;POST 到 /missions/stego/template.php 时必须带 Referer: <关卡页>,否则模板回 Invalid Referer 且不计分。答案大小写敏感。会话 cookie 从环境变量 HTS_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
#!/usr/bin/env python3
"""HackThisSite Steganography 6 (playit) live solver.

The level ships a 100x100 RGBA PNG (stego6.png). Its real chunk structure ends
with IEND at offset 0x751, but 92 extra bytes follow, outside the PNG container:
a Base64 string that decodes to a readable sentence carrying the password.
Everything inside the image itself (all bit planes / channels) is noise, so the
payload lives only in the trailer.

Submission follows the playit contract: read the level page for a fresh formkey
(it changes on every load), then POST formkey/lvl/pass to template.php with the
level page as Referer. Without the Referer the server answers ``Invalid
Referer`` and the attempt does not count. The session cookie is read from the
HTS_COOKIE environment variable and never written to disk.

Usage:
export HTS_COOKIE='HackThisSite=...'
cd <hts-workspace>/challenges/hts-stego/6 && uv run python solve.py
"""
import base64
import os
import re
import urllib.parse
import urllib.request

UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36")
BASE = "https://www.hackthissite.org"
LEVEL = BASE + "/missions/playit/stego/6/"
SUBMIT = BASE + "/missions/stego/template.php"
PNG = BASE + "/missions/stego/lvl/stego6.png"
NEXT = "/missions/playit/stego/7/"
COOKIE = os.environ["HTS_COOKIE"]
USER = os.environ.get("HTS_USER", "")


def fetch(url, referer=None):
headers = {"Cookie": COOKIE, "User-Agent": UA}
if referer:
headers["Referer"] = referer
req = urllib.request.Request(url, headers=headers)
return urllib.request.urlopen(req, timeout=30).read()


def solve():
data = fetch(PNG)
end = data.find(b"IEND") + 8 # first byte after the IEND chunk
trailer = data[end:].strip() # Base64 text outside the PNG stream
msg = base64.b64decode(trailer).decode("ascii")
return msg, trailer.decode("ascii")


def submit(answer):
page = fetch(LEVEL).decode("utf-8", "replace")
formkey = re.search(r'name="formkey" value="([^"]+)"', page).group(1)
lvl = re.search(r'name="lvl" value="([^"]+)"', page).group(1)
body = urllib.parse.urlencode(
{"formkey": formkey, "lvl": lvl, "pass": answer}).encode()
req = urllib.request.Request(
SUBMIT, data=body,
headers={"Cookie": COOKIE, "User-Agent": UA,
"Content-Type": "application/x-www-form-urlencoded",
"Referer": LEVEL})
return urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")


def profile_levels():
page = fetch("%s/user/view/%s/" % (BASE, USER)).decode("utf-8", "replace")
m = re.search(r"<b>Stego:</b></font>(.*?)<br\s*/?>", page, re.S)
return re.findall(r"\((\d+)\)", m.group(1)) if m else []


def main():
msg, b64 = solve()
print("trailing Base64:", b64)
print("decoded message:", msg)
answer = re.search(r"password is (\S+?)\.", msg).group(1)
print("answer:", answer)
resp = submit(answer)
levels = profile_levels() if USER else []
if NEXT in resp or "6" in levels:
print("[+] accepted - profile Stego list contains (6)")
else:
print("[-] no acceptance marker")
if USER:
print("profile Stego:", levels)


if __name__ == "__main__":
main()

运行输出:

1
2
3
4
5
6
$ cd <hts-workspace>/challenges/hts-stego/6 && uv run python solve.py
trailing Base64: Tm90IGxpa2UgaXQncyBoYXJkIHRvICdkZWNyeXB0JyB0aGlzIGh1aD8gVGhlIHBhc3N3b3JkIGlzIGhnYnZadzA3Lg==
decoded message: Not like it's hard to 'decrypt' this huh? The password is hgbvZw07.
answer: hgbvZw07
[+] accepted - profile Stego list contains (6)
profile Stego: ['1', '3', '4', '6']

接受后 profile 的 Stego: 行计入 (6)。playit 关卡的响应正文里没有 wrong/correct 之类关键词,不能靠关键词判断,可靠判据是 profile 的类别计数出现 (6)

Key points

  • 判据是 PNG 的声明结束点与实际长度之差:IEND 块结束于 0x751,文件长 1965 字节,多出的 92 字节就是附载。file/binwalk 只看魔数,看不出这段附载;pngcheck -v 会把它报成 additional data after IEND chunk
  • IEND 在合法 PNG 里是唯一的,data.find(b"IEND") + 8 就能定位图像流的终点;不需要遍历块结构。
  • 图像内部(IDAT 解码后的所有通道位平面)是噪声,zsteg -a 全部位平面无有效文本;附载只在 trailer 里。zstegextradata 行会直接给出 trailer,是快速定位手段。
  • 附载是纯 ASCII 的 Base64 文本,先按字节读出来再 base64.b64decode;提示里的 decrypt 是障眼法,实际只是编码不是加密。
  • playit 提交纪律:formkey 每次加载都变,必须取页面 → 立刻提交;提交必须带 Referer: <关卡页>,否则不计分。
hgbvZw07