HackThisSite - Steganography Mission 13

Challenge

Steganography

The only hint you get is The LaughingMan. 唯一提示是 The LaughingMan

题目提供一张 BMP 图片,密码藏在文件结构中。资源地址是 https://www.hackthissite.org/missions/stego/lvl/13.bmp

Solution

Analysis

先确认 BMP 的声明信息和实际文件长度:

1
2
3
$ file 13.bmp
13.bmp: PC bitmap, Windows 3.x format, 138 x 34 x 24, image size 14144,
cbSize 14198, bits offset 54

再解析 BMP 文件头。24bpp、宽度 138 的行长度按 4 字节对齐后为 416 字节:

1
2
3
4
5
6
7
8
file length       = 21002
bfSize = 14198
pixel offset = 54
width × height = 138 × 34
bits per pixel = 24
row bytes = 416
pixel bytes = 416 × 34 = 14144
expected end = 54 + 14144 = 14198

文件头声明的合法结束位置是 14198,但实际文件有 21002 字节;多出的 6804 字节并不是简单地集中在文件尾部,而是混入了文件流。

对整个文件搜索题目中反复出现的 ASCII 文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pathlib import Path

raw = Path("13.bmp").read_bytes()
junk = b"I thought what I'd do was, I'd pretend I was one of those deaf-mutes"

positions = []
start = 0
while True:
pos = raw.find(junk, start)
if pos < 0:
break
positions.append(pos)
start = pos + 1

print("file length:", len(raw))
print("junk length:", len(junk))
print("junk occurrences:", len(positions))
print("junk bytes:", len(junk) * len(positions))
1
2
3
4
file length: 21002
junk length: 69
junk occurrences: 56
junk bytes: 3864

这里的字符串中,I'dpretend 之间有两个空格;少一个空格就无法匹配实际数据。除了这 56 段文本,垃圾块后面还跟着一批 0x00 字节:

1
2
3
extra bytes       = 21002 - 14198 = 6804
repeated strings = 56 × 69 = 3864
zero padding = 6804 - 3864 = 2940

因此只删除 ASCII 文本还不够,残留的零填充仍会让像素行错位。清理逻辑是:遇到完整的垃圾字符串时跳过它,然后继续跳过紧随其后的 0x00 字节;其他字节按原顺序保留。

Step 1: 重建合法 BMP

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
#!/usr/bin/env python3
import struct
from pathlib import Path

source = Path("13.bmp").read_bytes()
junk = b"I thought what I'd do was, I'd pretend I was one of those deaf-mutes"

clean = bytearray()
pos = 0
junk_count = 0
zero_count = 0

while pos < len(source):
if source.startswith(junk, pos):
junk_count += 1
pos += len(junk)
while pos < len(source) and source[pos] == 0:
zero_count += 1
pos += 1
continue

clean.append(source[pos])
pos += 1

clean = bytes(clean)
Path("13_clean.bmp").write_bytes(clean)

_, declared_size, _, _, pixel_offset = struct.unpack_from(
"<2sIHHI", clean, 0
)
_, width, height, _, bpp, _, image_size = struct.unpack_from(
"<IiiHHII", clean, 14
)
row_bytes = ((width * (bpp // 8) + 3) // 4) * 4
expected_size = pixel_offset + row_bytes * abs(height)

print("junk blocks:", junk_count)
print("zero padding:", zero_count)
print("clean length:", len(clean))
print("declared size:", declared_size)
print("expected size:", expected_size)
print("image size:", image_size)
assert len(clean) == declared_size == expected_size
1
2
3
4
5
6
junk blocks: 56
zero padding: 2940
clean length: 14198
declared size: 14198
expected size: 14198
image size: 14144

清理后的文件重新满足 BMP 头部的全部长度约束。直接打开或放大 13_clean.bmp,即可看到隐藏文本:

1
2
3
4
$ convert 13_clean.bmp -resize 400% 13_clean.png
$ file 13_clean.bmp
13_clean.bmp: PC bitmap, Windows 3.x format, 138 x 34 x 24, image size 14144,
cbSize 14198, bits offset 54
acf42hvx10