Challenge
Look carefully: it’s obvious, just not at first sight.
Solution
Analysis
curl -sL -b "$HTS_COOKIE" -o 3.bmp \ 'https://www.hackthissite.org/missions/stego/lvl/3.bmp'file 3.bmp3.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 看不出任何异常。直接打开就是一块纯灰色。
BMP 头字段先算清楚,排除尾部附加数据 / 头部与实际数据不符这条最常规的隐写路子:
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())bfType b'BM' bfSize 544376 dataOffset 54w 504 h 360 bpp 24 biSizeImage 544322row_bytes 1512 pixel_bytes 544320bytes after pixels 2 0000像素区正好 字节,尾部只多出两个 0000 填充字节,没有可读的附加数据。
binwalk 也只认出一张 BMP,没有内嵌压缩包。
既然文件结构没东西,信息只能藏在像素值里。把三个通道各自做一次取值统计:
import numpy as npfrom 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())))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 的像素按它真实的坐标画成位图:
import numpy as npfrom PIL import Image
a = np.array(Image.open("3.bmp").convert("RGB"))marks = a[:, :, 0] != 62ys, 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]),一点噪声都没有。 把这块单独取出即为一行小号点阵文字:
....................##...................#..........#...##.......###...###...##..###...###...###...#.............##..........##.##..#..#.....##.##.##.##..#..#.............#..####.....##.#...#.####....#.#...#.#...#..####...###.#.#####..##..###...###...#.....##.##..#..###...#..##.#....#..#..#...#....##.##.##..#.....#...####.##.##..#...#.###..#..#..#...#.....#.#...#..#....#......##.#...#..#...#...##.#..#..#...#.....#.##..#..#...#......##..##..#..#...#....#.#..#..#...#..###...###...#..#####..##....###...#...#.###..#..#......................................................#.........................................................#.......................................................##......Key points
- 这是像素级近色隐写:载体整幅只有一种颜色 RGB(62,62,62),信息通过把个别像素的 单个通道调低 1 来编码。差值小到在正常显示下完全看不出,但通道统计会立刻暴露。
- 它是少数像素、单个通道、固定偏移 1 这一类隐写,与常见的 LSB 位平面(把信息平铺到最低位)不同。 所以定位方法也不同:先做通道取值统计,再看少数派的那些值落在什么坐标。
- 24 位 BMP 没有调色板,
palette这条线不用查;尾部附加、头部尺寸不符这两条也都被头解析排除了。 排除法的顺序:结构(头/尾/内嵌)→ 颜色通道 → 像素坐标。 - 提交:关卡表单三个字段
formkey(每次加载都变)、lvl、pass,POST 到/missions/stego/template.php,并且 必须带Referer: .../missions/playit/stego/3/, 否则后端拒绝。答案为大小写敏感的 11 位小写字符串。
n38f298hsjf