HackThisSite - Steganography Mission 3

Challenge

Look carefully: it's obvious, just not at first sight.

关卡作者 tiksi,页面在 https://www.hackthissite.org/missions/playit/stego/3/,数据文件是 https://www.hackthissite.org/missions/stego/lvl/3.bmp

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

Sample

1
2
3
curl -sL -b "$HTS_COOKIE" -o 3.bmp \
'https://www.hackthissite.org/missions/stego/lvl/3.bmp'
file 3.bmp
1
2
3.bmp: PC bitmap, Windows 3.x format, 504 x 360 x 24, image size 544322, \
resolution 2834 x 2834 px/m, cbSize 544376, bits offset 54

一张 504×360 的 24 位 BMP,file 看不出任何异常。直接打开就是一块纯灰色。

Solution

BMP 头字段先算清楚,排除尾部附加数据 / 头部与实际数据不符这条最常规的隐写路子:

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

d = open("3.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("bytes after pixels", len(d) - off - row * h, d[off + row * h:].hex())
1
2
3
4
bfType b'BM' bfSize 544376 dataOffset 54
w 504 h 360 bpp 24 biSizeImage 544322
row_bytes 1512 pixel_bytes 544320
bytes after pixels 2 0000

像素区正好 504×360×3 = 544320 字节,尾部只多出两个 0000 填充字节,没有可读的附加数据。 binwalk 也只认出一张 BMP,没有内嵌压缩包。尾部/头部这条路是封死的。

既然文件结构没东西,信息只能藏在像素值里。把三个通道各自做一次取值统计:

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

a = np.array(Image.open("3.bmp").convert("RGB"))
for i, name in enumerate("RGB"):
vals, counts = np.unique(a[:, :, i], return_counts=True)
print(name, "uniq =", len(vals), dict(zip(vals.tolist(), counts.tolist())))
1
2
3
R uniq = 2 {61: 186, 62: 181254}
G uniq = 1 {62: 181440}
B uniq = 1 {62: 181440}

整幅图本该是一块 RGB(62,62,62) 的纯灰。G、B 两个通道确实各自只有一个值 62, 但 R 通道有两个值:绝大多数是 62,另有 186 个像素是 61(比背景正好少 1)。 这一个计数差就是全部秘密所在。题面说的 obvious, just not at first sight, 指的正是这种肉眼看着是一整块灰、数值上却差了一个数的伪装。

把这 186 个 R=61 的像素按它真实的坐标画成位图:

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

a = np.array(Image.open("3.bmp").convert("RGB"))
marks = a[:, :, 0] != 62
ys, xs = np.where(marks)
print("count", marks.sum(), "x", xs.min(), xs.max(), "y", ys.min(), ys.max())
crop = marks[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
for r in crop:
print("".join("#" if v else "." for v in r))

186 个点全部落在一个 58×12 的矩形里(x=[83..140],y=[204..215]),一点噪声都没有。 把这块单独取出即为一行小号点阵文字:

1
2
3
4
5
6
7
8
9
10
11
12
....................##...................#..........#...##
.......###...###...##..###...###...###...#.............##.
.........##.##..#..#.....##.##.##.##..#..#.............#..
####.....##.#...#.####....#.#...#.#...#..####...###.#.####
#..##..###...###...#.....##.##..#..###...#..##.#....#..#..
#...#....##.##.##..#.....#...####.##.##..#...#.###..#..#..
#...#.....#.#...#..#....#......##.#...#..#...#...##.#..#..
#...#.....#.##..#..#...#......##..##..#..#...#....#.#..#..
#...#..###...###...#..#####..##....###...#...#.###..#..#..
....................................................#.....
....................................................#.....
..................................................##......

点阵是 5×9 的小写字体。按列上的空列把字切开,逐个认:

  • 第 1 字:左侧竖干 + 顶部拱起 → n
  • 第 2 字:上横、右竖、中横、右竖、下横 → 3
  • 第 3 字:上下两个闭环 → 8
  • 第 4、5 字:f 之后跟一个上弧下拐的形状 → f2
  • 第 6 字:上面闭环比 8 多一点、尾巴向左下拖 → 9
  • 第 7 字:与第 3 字同形 → 8
  • 第 8 字:长竖 + 右侧小拱 → h
  • 第 9、10 字:S 形曲线,紧挨一根带下勾的竖线 → sj
  • 第 11 字:与第 4 字的 f 同形 → f

连起来就是 11 个小写字符(关卡注明答案大小写敏感、作者尽量都用小写)。

若不想手工认字,把这块蒙版放大成图片(Image.resize(..., Image.NEAREST) 放大 16 倍) 肉眼即可读出,或者直接做一次简单的列分割 + 字形比对。核心只有一句: R=61 的像素选出来、按其坐标画出来,答案就在上面。

Key points

  • 这是像素级近色隐写:载体整幅只有一种颜色 RGB(62,62,62),信息通过把个别像素的 单个通道调低 1 来编码。差值小到在正常显示下完全看不出,但通道统计会立刻暴露。
  • 它是少数像素、单个通道、固定偏移 1 这一类隐写,与常见的 LSB 位平面(把信息平铺到最低位)不同。 所以定位方法也不同:先做通道取值统计,再看少数派的那些值落在什么坐标。
  • 24 位 BMP 没有调色板,palette 这条线不用查;尾部附加、头部尺寸不符这两条也都被头解析排除了。 排除法的顺序:结构(头/尾/内嵌)→ 颜色通道 → 像素坐标。
  • 提交:关卡表单三个字段 formkey(每次加载都变)、lvlpass,POST 到 /missions/stego/template.php,并且 必须带 Referer: .../missions/playit/stego/3/, 否则后端拒绝。答案为大小写敏感的 11 位小写字符串。
n38f298hsjf