HackThisSite - Steganography Mission 12

Challenge

This is an encoded message, the only tip you get is 'I am the not of a file'.

关卡作者 AzurPunk,页面在 https://www.hackthissite.org/missions/playit/stego/12/, 数据文件是 https://www.hackthissite.org/missions/stego/lvl/12.bmp。 题面只给了一句谜语提示 I am the not of a file,答案大小写敏感。

状态:live 已通关(verified)。提交后 profile 的 Stego: 一行出现 (12), 关卡页回执为 You have already done this mission.

Sample

1
2
3
curl -sL -b "$HTS_COOKIE" -o 12.bmp \
'https://www.hackthissite.org/missions/stego/lvl/12.bmp'
file 12.bmp
1
2
12.bmp: PC bitmap, Windows 3.x format, 123 x 1 x 8, image size 126,
resolution 2834 x 2834 px/m, cbSize 1204, bits offset 1078

一张 123×1、只有 1 个像素高的 8 bpp(调色板) BMP,整个文件才 1204 字节。

Solution

先把 BMP 头字段算清楚:

1
2
3
4
5
6
7
8
9
10
11
import struct

d = open("12.bmp", "rb").read()
bf_type, bf_size, _, _, off = struct.unpack_from("<2sIHHI", d, 0)
hsz, w, h, planes, bpp, comp, img_size = struct.unpack_from("<IiiHHII", d, 14)
row = ((w * bpp + 31) // 32) * 4 # 8bpp 每行按 4 字节对齐
print("bfType", bf_type, "bfSize", bf_size, "dataOffset", off)
print("w", w, "h", h, "bpp", bpp, "biSizeImage", img_size)
print("row_bytes", row, "pixel_bytes", row * h)
print("palette_entries", (off - 14 - hsz) // 4)
print("trailing", len(d) - off - row * h)
1
2
3
4
5
bfType b'BM' bfSize 1204 dataOffset 1078
w 123 h 1 bpp 8 biSizeImage 126
row_bytes 124 pixel_bytes 124
palette_entries 256
trailing 2

几个点先说清楚:dataOffset=1078 正好等于 14 + 40 + 1024, 即文件头 14 字节 + 信息头 40 字节 + 256 项调色板(每项 4 字节 = 1024)。 像素区是 124 × 1 = 124 字节(123 个像素 + 1 个对齐填充),像素之后只剩两个 0000 填充, 没有尾部附加文件。信息只能在像素值里。

确认这个调色板的内容:

1
2
3
4
5
6
7
import struct

d = open("12.bmp", "rb").read()
off = struct.unpack_from("<I", d, 10)[0]
pal = d[14 + 40:off]
gray = all(tuple(pal[i:i + 3]) == (i // 4, i // 4, i // 4) for i in range(0, len(pal), 4))
print("palette is grayscale identity:", gray)
1
palette is grayscale identity: True

调色板是恒等灰度斜坡:第 i 项就是 (i, i, i)。所以像素索引值等于它显示出来的灰度, 每字节一个像素,直接读原始字节即可。

题面 I am the not of a file 里的 not 就是按位取反:255 - x。 把 124 个像素字节逐个取反:

1
2
pixel bytes : af b4 fc fb eb ff ff ff ff ff 8b 7f 04 c7 a5 b5 ...
NOT (255-x): 50 4b 03 04 14 00 00 00 00 00 74 80 fb 38 5a 4a ...

取反后的头四个字节是 50 4b 03 04PK\x03\x04:一个 ZIP 归档的本地文件头。 原来这张图片的像素值,本身就是某个文件按位取反后的样子,正如提示所说: 它就是某个文件的 not

把取反结果当作 ZIP 打开:

1
2
3
4
5
6
7
8
9
10
11
12
13
import io
import struct
import zipfile

d = open("12.bmp", "rb").read()
off = struct.unpack_from("<I", d, 10)[0]
w, h, planes, bpp = struct.unpack_from("<iiHH", d, 18)
row = ((w * bpp + 31) // 32) * 4
px = d[off:off + row * h]
decoded = bytes(255 - b for b in px) # bitwise NOT
with zipfile.ZipFile(io.BytesIO(decoded)) as z:
print(z.namelist())
print(z.read("pass.txt").decode())
1
2
['pass.txt']
6ae4nt5TB

压缩包里只有一个 pass.txt,内容是 9 个字符的密码,即本题答案(大小写敏感)。

Key points

  • 这道题的隐写只有一步:整个像素区就是另一个文件的按位取反。 关键动作只有一个字节级变换 x -> 255 - x,不需要图像处理库,直接读字节即可。
  • 定位思路来自题面谜语:I am the not of a file 里的 not 取双关:既表示否定,也指逻辑/按位取反(NOT)。对像素字节做 255 - x 后出现 PK\x03\x04,方向立刻坐实。
  • 本题是 8 bpp 调色板图,dataOffset = 1078 = 14 + 40 + 256×4; 调色板恰好是恒等灰度 (i,i,i),所以像素索引即灰度值,无需查表直接对原始字节取反。
  • 排除顺序仍然是先结构后内容:头解析排掉了尾部附加数据/头部尺寸不符, 而 256 项调色板本身也没藏东西(恒等灰度)。排除结构之后,答案就在 124 个像素字节里, 按提示做一次 NOT 再解压即可。
  • 提交:/missions/stego/template.php,字段为 formkey(每次加载都变)、lvlpass, 并且必须带 Referer: .../missions/playit/stego/12/,否则后端只回 Invalid Referer 且不计分。
6ae4nt5TB