HackThisSite - Steganography Mission 4

Challenge

A GIF image is provided. The only hint is I am being hexed!. 题目提供了一张 GIF 图片,唯一的提示是 I am being hexed!(我被十六进制了)。

关卡作者 GTADarkDude,数据文件在 https://www.hackthissite.org/missions/stego/lvl/stego4.gif

Solution

file 只按魔数认出这是 GIF89a,175x36,看不出别的:

1
2
3
4
$ file stego4.gif
stego4.gif: GIF image data, version 89a, 175 x 36
$ wc -c stego4.gif
4703 stego4.gif

提示指向十六进制,于是直接 xxd 看尾部。GIF 的 trailer 是单字节 0x3b;),标准文件到它就结束了:

1
2
3
4
5
00001210: aeda 6c01 0df0 4ef2 2002 b713 0400 3b30  ..l...N. .....;0
00001220: 3131 3130 3030 3030 3031 3130 3131 3030 1110000001101100
00001230: 3031 3131 3030 3030 3131 3030 3031 3130 0111000011000110
00001240: 3131 3130 3030 3130 3031 3130 3030 3130 1110001001100010
00001250: 3131 3031 3030 3030 3131 3030 3031 30 110100001100010

trailer 后面跟着一串 ASCII 字符 '0''1'(字节值 0x30/0x31)。0x3b 直接收尾的话,文件在 0x121e 就该结束了,实际还有 64 字节。

光看尾部不够,先确认整张图没有别的附载(注释扩展、应用扩展、调色板里塞数据)。按 GIF 规范从 header 往后解析每个块:

1
2
3
4
5
6
header: GIF89a
LSD: 175x36 packed=0xd5 gct=True gct_entries=64 bg=0 aspect=0
@0x00d5 EXT GraphicControl (0xf9) subblocks=1 len=4 preview=b'\x00\x00\x00\x00'
@0x00df IMAGE_DESC at (0,0) 175x36 packed=0x00 lct=False lct_entries=0 interlace=False
LZW_min=6 data_len=4395
@0x121e TRAILER 0x3B; remaining bytes after = 64

结构干净:一个 GCT(64 项)、一个 Graphic Control Extension、一个 Image Descriptor 加 LZW 数据,之后就是 0x3B trailer。没有 Comment 或 Application Extension,附载只有 trailer 之后那 64 字节。

要注意 0x3B 这个字节值在 LZW 图像数据里也会自然出现,本文件里一共出现 23 次,0x121e 是最后一次。所以定位 trailer 不能取第一个 0x3B,要么按块走到 trailer,要么取最靠后的那一个。

trailer 之后的 64 个 '0'/'1' 字符每 8 位一组拼成字节:

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
#!/usr/bin/env python3
"""HackThisSite Steganography 4 ("I am being hexed!") — extract the appended payload.

The GIF is a single-frame GIF89a image. After the normal image data it carries a
standard GIF trailer byte 0x3B, and then 64 extra bytes that are not part of the
image stream: the ASCII characters '0' and '1'. Grouped into bytes they spell an
8-character lowercase password.
"""
import sys

def extract(path):
data = open(path, "rb").read()
trailer = data.rfind(b"\x3b") # last GIF trailer byte (0x3B = ';')
trailing = data[trailer + 1:] # everything after the trailer
if not set(trailing) <= set(b"01"):
raise SystemExit("trailing data is not a '0'/'1' bit string: %r" % trailing[:32])
bits = trailing.decode("ascii")
if len(bits) % 8:
raise SystemExit("bit string length %d is not a multiple of 8" % len(bits))
out = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits), 8))
return trailer, bits, out

def main():
path = sys.argv[1] if len(sys.argv) > 1 else "stego4.gif"
trailer, bits, out = extract(path)
print("trailer 0x3B at offset:", hex(trailer))
print("trailing bit string (%d bits):" % len(bits), bits)
print("decoded bytes (hex):", out.hex())
print("decoded ASCII :", out.decode("ascii"))

if __name__ == "__main__":
main()

运行结果:

1
2
3
4
5
$ uv run python extract_bits.py stego4.gif
trailer 0x3B at offset: 0x121e
trailing bit string (64 bits): 0111000000110110001110000110001101110001001100010110100001100010
decoded bytes (hex): 7036386371316862
decoded ASCII : p68cq1hb

64 位、8 个字节刚好对齐:70 36 38 63 71 31 68 62p68cq1hb

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
#!/usr/bin/env python3
"""HackThisSite Steganography 4 (playit) live solver.

The level ships a single-frame GIF89a. Its normal container ends with the GIF
trailer byte 0x3B, but 64 more bytes follow: the ASCII characters '0' and '1'.
Grouped eight at a time they spell the 8-character password.

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/4 && uv run python solve.py
"""
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/4/"
SUBMIT = BASE + "/missions/stego/template.php"
GIF = BASE + "/missions/stego/lvl/stego4.gif"
NEXT = "/missions/playit/stego/5/"
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(GIF)
trailer = data.rfind(b"\x3b")
bits = data[trailer + 1:].decode("ascii")
return bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits), 8)).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():
answer = solve()
print("answer:", answer)
resp = submit(answer)
if NEXT in resp:
print("[+] accepted - server handed out the go-on link to level 5")
elif USER and "4" in profile_levels():
print("[+] accepted - profile Stego list contains (4)")
else:
print("[-] no acceptance marker in response")
if USER:
print("profile Stego:", profile_levels())

if __name__ == "__main__":
main()

运行输出:

1
2
3
4
$ cd <hts-workspace>/challenges/hts-stego/4 && uv run python solve.py
answer: p68cq1hb
[+] accepted - profile Stego list contains (4)
profile Stego: ['3', '4']

接受后 profile 的 Stego: 行计入 (4)。playit 关卡的响应正文里没有任何 wrong/correct 字样,不能靠关键词判断,可靠判据是响应里出现通往 stego/5 的 go-on 链接,外加 profile 的类别计数。

Key points

  • 判据是文件的声明结束点与实际长度之差:GIF 到 trailer 就该结束,多出来的字节就是附载。file/binwalk 只看魔数,看不出这段附载。
  • 0x3B 在 LZW 数据里是普通字节值,会重复出现;本文件出现 23 次,必须取最靠后(trailer)的那个,或按容器结构走到 trailer。用 data.index(b"\x3b") 取第一个会切错位置。
  • 附载是 ASCII 形式的位串,不是二进制位本身。先按字符读成 '0'/'1' 字符串,再每 8 位归一化成一个字节。
  • playit 提交纪律:formkey 每次加载都变,必须取页面 → 立刻提交;提交必须带 Referer: <关卡页>,否则不计分。
p68cq1hb