HackThisSite - Steganography Mission 1

Challenge

This is an encoded message, the only tip you get is '2 null bytes' 这是一条编码过的消息,唯一的提示是 2 null bytes

NOTE: There is no encoding error

题目提供一个 BMP 图片文件,要求在像素数据里找出隐藏的密码。

Solution

1
2
3
4
$ curl -sL -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/stego/lvl/1.bmp" -o 1.bmp
$ file 1.bmp
1.bmp: PC bitmap, Windows 3.x format, 283 x 170 x 8, image size 48280, resolution 2835 x 2835 px/m, 256 important colors, cbSize 49358, bits offset 1078

8bpp indexed BMP。用 struct 按 BMP 规范解出各字段,验证像素数据范围:

1
2
$ uv run python3 -c "import struct; d=open('1.bmp','rb').read(); print(struct.unpack('<2sIHHI', d[:14]), struct.unpack('<IiiHHIIiiII', d[14:54]))"
(b'BM', 49358, 0, 0, 1078) (40, 283, 170, 1, 8, 0, 48280, 2835, 2835, 256, 256)

pixoff(1078) + biSizeImage(48280) = 49358,正好等于文件大小,说明文件尾部没有被追加数据,载荷只能在像素数据内部。调色板区(54–1077)是干净的灰度斜坡(第 i 项为 i,i,i,0),也没有异常项。

宽度 283 是奇数,行跨度为 ((8*283+31)//32)*4 = 284,所以每行末尾有 1 个 padding 字节。这一点很重要:扫描时会出现大量孤立的 00,但每行末尾那个 00 只是 padding,不是提示里的 2 null bytes

1
2
3
4
5
6
7
$ uv run python3 -c "
import re
d = open('1.bmp','rb').read()
px = d[1078:]
print([m.start() for m in re.finditer(b'\x00\x00', px)])
"
[59, 116]

只在像素数据内部出现两处 00 00,相对偏移 59 和 116,两者都落在第 0 行(文件里 BMP 自底向上存储,即图像最底行)。其余 0x00 全是 padding(rel = 284*r + 283,共 170 个)和调色板首项。

两个标记之间的 55 个字节全部只取两个值:

1
2
3
16 16 17 17 17 16 16 16 16 17 17 16 16 17 17 16 16 17 17 16 17 17 17 16
17 17 16 17 16 16 16 16 17 17 16 16 16 16 17 16 17 17 17 16 16 17 17 16
16 17 17 16 17 17 16

一个 8 位索引图里,孤立的单个灰度值在整个平坦背景上只取两种相邻取值(0x16 / 0x17),这就是两位符号的 bit 流:0x16 → 00x17 → 1。两个 00 00 正是这段 bit 流的起止标记。

0x16 → 00x17 → 1 展开得到 55 位:

1
0011100001100110011011101101000011000010111001100110110

55 不是 8 的倍数,每 8 位一组会在某处错位。逐一试偏移量(0–7 位)后可以确定:第一个字符只剩高 7 位 0011100,把它的末位 0 补回来再分组,后面正好整齐落成 6 个字节:

1
2
3
4
5
6
7
0011100 + 0 -> 00111000 -> 8
00110011 -> 3
00110111 -> 7
01101000 -> h
01100001 -> a
01110011 -> s
00110110 -> 6

题面那句 There is no encoding error 与实测不符:编码时丢了首位字节的一位,解码必须补回,否则凑不齐 8 位。

Script

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
"""HackThisSite Steganography 1 - reproducible decoder.

The BMP is an 8-bit indexed image whose pixel bytes carry the payload:
two consecutive 0x00 bytes mark the payload, and inside them every 0x16
stands for bit 0 and every 0x17 for bit 1.

Usage: uv run python3 solve.py 1.bmp
"""
import re
import struct
import sys

def load_pixels(path):
data = open(path, "rb").read()
pixoff = struct.unpack("<2sIHHI", data[:14])[4]
return data, data[pixoff:]

def extract_bits(px):
marks = [m.start() for m in re.finditer(b"\x00\x00", px)]
if len(marks) < 2:
raise SystemExit("no pair of null bytes found in pixel data")
payload = px[marks[0] + 2:marks[1]]
return "".join("0" if b == 0x16 else "1" for b in payload)

def bits_to_text(bits):
# 55 bits: the first character only kept its top 7 bits, so the
# trailing 0 of that byte has to be restored before grouping.
chars = [bits[:7] + "0"]
rest = bits[7:]
chars += [rest[i:i + 8] for i in range(0, len(rest), 8)]
return "".join(chr(int(c, 2)) for c in chars)

def main():
path = sys.argv[1] if len(sys.argv) > 1 else "1.bmp"
_, px = load_pixels(path)
bits = extract_bits(px)
print("payload bits (%d): %s" % (len(bits), bits))
print("decoded:", bits_to_text(bits))

if __name__ == "__main__":
main()
1
2
3
$ uv run python3 solve.py 1.bmp
payload bits (55): 0011100001100110011011101101000011000010111001100110110
decoded: 837has6
837has6