HackThisSite - Steganography Mission 8

Challenge

Thank you t_n83

关卡页在 https://www.hackthissite.org/missions/playit/stego/8/,数据文件是 https://www.hackthissite.org/missions/stego/lvl/stego8.bmp。题面只有一句作者的致谢, 没有任何提示,答案大小写敏感。

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

Sample

1
2
3
curl -sL -b "$HTS_COOKIE" -o stego8.bmp \
'https://www.hackthissite.org/missions/stego/lvl/stego8.bmp'
file stego8.bmp
1
2
stego8.bmp: PC bitmap, Windows 3.x format, 157 x 97 x 24, image size 45784,
cbSize 45838, bits offset 54

一张 157×97 的 24 位 BMP,体积很小。直接打开只看到一幅白底图上印着一行字和一条下划线。

Solution

BMP 的隐写常见路径只有几条:尾部附加数据、头部声明尺寸和真实长度不符、调色板里的垃圾项。 先排除这几条常见路径:

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

d = open("stego8.bmp", "rb").read()
bf_type, bf_size, _, _, off = struct.unpack_from("<2sIHHI", d, 0)
hsz, w, h, _, bpp, comp, img_size = struct.unpack_from("<IiiHHII", d, 14)
row = ((w * 3 + 3) // 4) * 4 # 24bpp 每行按 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("trailing", len(d) - off - row * h)
1
2
3
4
bfType b'BM' bfSize 45838 dataOffset 54
w 157 h 97 bpp 24 biSizeImage 45784
row_bytes 472 pixel_bytes 45784
trailing 0

像素区正好 472 × 97 = 45784 字节,文件在像素之后没有任何多余数据(trailing=0); 24 bpp 的 BMP 没有调色板,palette 这条线也不用查。信息只能在像素值里。

分别统计 R、G、B 三个通道的取值分布:

1
2
3
4
5
6
7
8
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB"))
for i, name in enumerate("RGB"):
vals, counts = np.unique(a[:, :, i], return_counts=True)
top = sorted(zip(counts.tolist(), vals.tolist()), reverse=True)[:4]
print(name, "uniq =", len(vals), "top =", top)
1
2
3
R uniq = 26 top = [(14849, 255), (293, 0), (20, 240), (15, 124)]
G uniq = 14 top = [(14849, 255), (308, 0), (20, 240), (15, 124)]
B uniq = 14 top = [(14849, 255), (308, 0), (20, 240), (15, 124)]

图像呈灰度外观,但通道统计显示:R 通道比 G、B 多出一批取值。进一步比对三个通道:

1
2
3
4
5
6
7
8
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
print("R!=G:", int((r != g).sum()), "G!=B:", int((g != b).sum()))
ys, xs = np.where(r != g)
print("bbox x=[%d..%d] y=[%d..%d]" % (xs.min(), xs.max(), ys.min(), ys.max()))
1
2
R!=G: 15 G!=B: 0
bbox x=[68..82] y=[50..50]

绝大多数像素满足 R == G == B(真正的灰度),只有 15 个像素例外,而且全部 集中在同一行 y=50x6882 的一段连续像素上。这 15 个点就是全部秘密。

这 15 个像素的形态是 (R, 0, 0):绿、蓝被清零,红通道独自保留一个值。 把这一行原样打印出来:

1
2
3
4
5
6
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
for x in range(66, 85):
print(x, tuple(a[50, x].tolist()))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
66 (255, 255, 255)
67 (255, 255, 255)
68 (112, 0, 0)
69 (97, 0, 0)
70 (115, 0, 0)
71 (115, 0, 0)
72 (119, 0, 0)
73 (111, 0, 0)
74 (114, 0, 0)
75 (100, 0, 0)
76 (61, 0, 0)
77 (89, 0, 0)
78 (114, 0, 0)
79 (82, 0, 0)
80 (111, 0, 0)
81 (116, 0, 0)
82 (55, 0, 0)
83 (255, 255, 255)
84 (255, 255, 255)

肉眼没觉得是字,是因为这些值不构成图案,它们本身就是字节。 把每个红通道值当 ASCII 码读出来:

1
2
112 97 115 115 119 111 114 100 61 89 114 82 111 116 55
p a s s w o r d = Y r R o t 7

连起来是 password=YrRot7

一串代码把上面几步串起来即可,提取结果:

1
2
3
4
5
6
7
8
9
10
11
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
ys, xs = np.where((r != g) & (g == b)) # 红独有像素 = 载体
order = np.argsort(xs)
xs, ys = xs[order], ys[order]
text = "".join(chr(int(a[y, x, 0])) for x, y in zip(xs.tolist(), ys.tolist()))
print(text)
print(text.split("=", 1)[1]) # 取等号后的答案
1
2
password=YrRot7
YrRot7

注意等号前的 password= 只是标签,真正的密码是等号之后的 6 个字符,大小写敏感。

Key points

  • 这是一次伪灰度 + 单通道编码:整幅图看是灰度,价值信息只出现在红通道少数像素上。 定位方法和通道统计强绑定:先做三通道取值分布对比,再看多出来的那些值落在哪些坐标, 一步就能定位到 y=50 那 15 个像素。
  • 不是 LSB 位平面隐写:每个载体的红通道里存的是一整个可打印 ASCII 字节 (112='p'97='a' 等),而不是打散到低位。形状上也没有点阵文字的图案。 信息在数值里而不在几何形状里,所以 strings 也读不出(字节区间不连续、 旁边没有可见的连贯字符串),必须按坐标取红通道值再转字符。
  • 排除顺序仍然是:结构(头/尾/调色板)→ 通道分布 → 坐标取值。第 1、2 步快速排掉 尾部附加与头部尺寸不符;第 3 步把红独有像素选出来按 x 排序,直接得到明文字节流。
  • 提交:/missions/stego/template.php,表单字段为 formkey(每次加载都变)、lvlpass, 并且必须带 Referer: .../missions/playit/stego/8/,否则后端拒绝。答案大小写敏感。
YrRot7