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'd 和 pretend
之间有两个空格;少一个空格就无法匹配实际数据。除了这 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
| 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