
简介这是一套面向信息安全、图像处理和CTF取证学习者的图片隐写示例代码聚焦BMP、JPG、GIF三种常见格式的信息隐藏与提取。包体共5个文件含4个C源文件和1个头文件压缩包仅5KB结构轻量适合直接阅读、编译并二次修改。源码分别覆盖BMP位图数据区嵌入与文件头非关键参数篡改、JPEG的DCT系数微调以及GIF在LZW压缩环境下扩展块和全局颜色表的利用并给出对应的信息提取与检测思路头文件封装了BMP尺寸等辅助函数便于快速搭建实验。已有6126人学习下载可帮助初学者从代码层面理解隐写术原理也可直接用于网络安全课程实验或相关项目的基础模块。1. 图片隐写不是玄学BMP、JPG、GIF 三种格式藏的思路完全不一样做过几场 CTF 之后你会发现一个反直觉的结论越简单的格式藏东西越方便越是日常见得多的格式坑反而越多。BMP 可以直接往像素低位里塞数据塞完肉眼几乎无感是最适合新手入门的隐写载体JPG 走 DCT 变换和量化想不露痕迹得跟压缩算法较劲GIF 则是最容易被忽略的——它有色表、有帧间隔、有 LZW 压缩每一个字段都可能藏东西。这篇就专门写这三种格式的隐写与提取思路。我会按格式特性 → 藏法原理 → 可复现步骤 → 常见翻车点的顺序来讲BMP 给的是不依赖外部工具、用 Python 几行就能跑的思路JPG 给的是针对 DCT 系数的操作方法GIF 给的是从文件结构层面做检查的排查路径。不管你是 CTF 新手还是已经打过几场的老手这套东西都能直接拿去用。2. BMP 隐写从文件头到像素字节逐字节排查的教科书2.1 为什么 BMP 是隐写题的理想载体BMP 是少数不压缩、不丢失信息的位图格式文件结构非常直白文件头BITMAPFILEHEADER、信息头BITMAPINFOHEADER、调色板可选、像素数据。像素数据按行存储每行补位到 4 字节对齐每个像素根据色深不同占 1、2、3 或 4 字节。最常用的是 24 位真彩色也就是每个像素的 B、G、R 各占 1 字节。这种格式对隐写友好到什么程度因为它没有任何压缩变换像素点的值就是最终显示的颜色你改掉最低一位LSB肉眼根本分辨不出来。不像 JPG改一个像素可能牵动整个 8x8 块的 DCT 系数改不好就出块效应。所以碰到 BMP 隐写题第一反应就应该是检查像素低位、检查文件头后用字节直接拼数据、检查是否有额外数据段。from PIL import Image img Image.open(flag.bmp) width, height img.size pixels list(img.getdata()) # 提取每个像素的 LSB按位拼接成字节 bits for r, g, b in pixels: bits str(r 1) str(g 1) str(b 1) # 每 8 位转一个字节打印可打印字符 for i in range(0, len(bits), 8): byte bits[i:i8] value chr(int(byte, 2)) if 32 ord(value) 126: print(value, end)这段脚本思路很简单遍历每个像素取 RGB 三个通道各自的最低一位按顺序拼接再每 8 位组成一个 ASCII 字符。多数情况下 LSB 隐写都是按这个顺序写入的最多在写入顺序上做点调整。解读一下参数img.getdata()返回的是像素值迭代器顺序是从左上角到右下角逐行扫描str(r 1)是取红色通道 LSB 的写法如果想提取第二位就改成(r 1) 1。跑完如果输出乱码但前面有规律说明插入顺序不是简单的 RGB 交替可能是按 RRRR...GGGG...BBBB 分组写也可能是从右下角往回写这属于 LSB 的变种后面第 4 章会集中讲怎么排查。2.2 不依赖 Python十六进制层面的手工排查法用脚本提取是效率最高的做法但排查过程不能只靠脚本盲跑。我一般会先把 BMP 文件用十六进制编辑器打开做一次目测级别的检查。为什么要这样因为很多隐写工具会在文件头或者文件末尾追加数据这类非像素隐写是脚本抓不到的。文件头是 54 字节的标准结构重点看这几个字段的实际值偏移 0x00 处是 0x4D42BM 标识、偏移 0x0A 处是像素数据起始偏移常见值是 54 或 1078偏移 0x12 处是宽、0x16 处是高偏移 0x1C 处是色深。如果发现像素数据起始偏移大于正常值说明文件头和数据之间塞了额外内容如果文件尾部还有大量非零数据也可能是一段直接附加的明文信息。另一个隐蔽的做法是修改位图高度值。比如把文件头里标注的高度改小显示时图片被裁剪但实际数据还在文件里。用 Python 的struct模块可以直接读出信息头里的真实尺寸import struct with open(clip.bmp, rb) as f: data f.read() # 0x12 偏移处为宽度(4字节)0x16 偏移处为高度(4字节) width struct.unpack(i, data[0x12:0x16])[0] height struct.unpack(i, data[0x16:0x1A])[0] offset struct.unpack(I, data[0x0A:0x0E])[0] print(f宽度: {width}, 高度: {height}, 像素数据偏移: {offset}) print(f文件实际大小: {len(data)}) print(f预期像素数据大小: {width * height * 3 if width * height * 3 0 else 尺寸异常})这里的关键是struct.unpack的格式说明符i表示小端序有符号 32 位整数I表示小端序无符号 32 位整数。BMP 文件头所有多字节字段都是小端序如果拿大端去读宽度和高度值会完全错乱。数字签名里负数、超出实际尺寸的值都需要警惕——有些出题人就是通过把高度改成负值来让某些工具解析失败的像 Windows 画图这类软件读取负数高度会直接拒绝打开但PIL可以正常读。2.3 BMP 隐写最常踩的坑颜色通道顺序和写入方向BMP 像素数据的存储顺序是 BGR 不是 RGB。这个问题在隐写题里出现频率极高出题人写脚本时用 OpenCV 读图然后保存成 BMPOpenCV 读出来的通道顺序是 BGR如果你用 PIL 去提取拿到的顺序是 RGB。两边对不上提取结果全是乱的。我见过一个现场案例某道题用 OpenCV 往 R 通道 LSB 写数据提取脚本却用 PIL 扫描 R 通道结果提取出来是一堆无意义乱码。后来我查了一下才发现问题出在通道顺序上把提取脚本改成读取pixels[i][2]第三个元素才对上了因为 PIL 的getdata()返回的是 RGB 三元组OpenCV 的 BGR 写入对应到 PIL 里正好是 R 通道。写入方向的坑更隐蔽。很多新手工具默认从左上角开始逐行写入但也有的从右下角反向写还有的只写入图像中心区域或者在 LSB 里每隔一个像素写一位。遇到提取结果前几行是正常文本、后面越来越乱的情况优先怀疑写入方向遇到文本分布在整个字符串里每隔几个字符就错乱优先怀疑是隔位写入。BMP 隐写的核心原则是任何顺序上的偏差都会导致字节错位而字节错位几乎是不可自动恢复的只能靠人工观察加点位变换脚本去碰。所以拿到题先别急着跑脚本先搞清楚像素数据布局再动手提取。3. JPG 隐写DCT 系数变更才是 JPG 藏东西的主战场3.1 为什么 BMP 的 LSB 套路在 JPG 上行不通JPG 不存像素原始值它先把图像切成 8x8 的像素块对每个块做离散余弦变换DCT得到 64 个频率系数然后按量化表量化把大量高频分量清成零最后对量化后的系数做熵编码。这意味着你看到的 JPG 文件内容是压缩后的结果不是真实像素。直接在 JPG 的某个字节低位做改动改完重新保存就可能被当作噪声丢掉。CTF 里 JPG 隐写主要分两类一类是在 DCT 系数上做文章另一类是直接在文件结构上做文章。前者典型的是 JSteg 系工具把秘密信息嵌入量化后的 DCT 系数的 LSB绕开像素域这种修改在视觉上几乎不可感知。后者则是在 JPG 的段结构上做文章——JPG 的头部有多个段每段有标记码和段长度有些工具会把数据藏在自定义的 APP 段或者注释段里。import cv2 import numpy as np # 读取 JPG 分隔后的量化 DCT 系数使用 skimage 的 jpegio 更直接 # 这里演示用 OpenCV 手动做 DCT 量化 img cv2.imread(carrier.jpg, cv2.IMREAD_GRAYSCALE) h, w img.shape # 用 JPEG 标准亮度量化表 qt np.array([ [16, 11, 10, 16, 24, 40, 51, 61], [12, 12, 14, 19, 26, 58, 60, 55], [14, 13, 16, 24, 40, 57, 69, 56], [14, 17, 22, 29, 51, 87, 80, 62], [18, 22, 37, 56, 68, 109, 103, 77], [24, 35, 55, 64, 81, 104, 113, 92], [49, 64, 78, 87, 103, 121, 120, 101], [72, 92, 95, 98, 112, 100, 103, 99] ], dtypenp.float32) # 分块 DCT 并量化 result_coeffs [] for i in range(0, h - 7, 8): for j in range(0, w - 7, 8): block img[i:i8, j:j8].astype(np.float32) - 128 dct cv2.dct(block) quant np.round(dct / qt) result_coeffs.append(quant) print(f分块数量: {len(result_coeffs)}) print(f第 0 块的量化 DCT 系数:\n{result_coeffs[0]})这段代码模拟了 JPG 编码前端的核心过程分块、DCT、量化。要注意cv2.dct是二维离散余弦变换处理的是 8x8 浮点块减 128 是为了将像素值从 0~255 平移到来 -128~127 的范围内这是 JPEG 标准的要求。量化之后的系数里低频分量左上角值远大于高频分量右下角值高频分量大量变成 0。JSteg 工具做的事就是扫描quant后的系数数组跳过 0 和 1在绝对值大于等于 2 的系数的 LSB 上按顺序写入秘密数据。做提取时不能直接打开 JPG 文件读 DCT 系数因为系数在熵编码之后又被压缩了一次。老老实实的方法是用skimage.io.imread(..., pluginjpegio)或者jpegio库直接拿到量化 DCT 系数这两个库会解析 JPG 的内部结构不需要自己实现熵解码。实际做题时如果环境里没装这两个库我一般会优先考虑另一个路径直接看文件尾部有没有附加数据很多 CTF JPG 题根本不走 DCT 域而是简单地在文件末尾补了一段字节。3.2 JPG 文件结构段、标记码、注释区都不是摆设JPG 以 FF D8 开头以 FF D9 结尾中间是一系列段。每段的开头是 2 字节标记码后面是 2 字节的段长度包含这 2 字节本身再后面才是段内容。常见的段有 FFE0APP0、FFE1APP1存 Exif、FFDBDQT 量化表、FFC0SOF 帧参数、FFC4DHT 霍夫曼表、FFDASOS 扫描开始和 FFEEAPP14Adobe 扩展。隐写题里最常动手脚的是 APP 段、COM 段FFFE 注释段和文件尾部。我遇到过把数据藏在 APP1 里的题目段长度被改成实际内容长度加 4如果只用工具看图根本发现不了得用十六进制编辑器打开才能看出来。xxd mystery.jpg | head -30一眼扫过去要确认几个点文件头是不是 FF D8 开头有没有奇怪的段标记有没有段长度和实际内容对不上。再看文件尾部用xxd -s -200 mystery.jpg查看最后 200 字节如果有大段非 FF D9 结尾的数据大概率有追加内容。还有一种情况是 JPG 尾部被附加了另一个完整文件——最常见的是附加 ZIP 或 RAR 压缩包这时直接改后缀解压就能出来。从段结构的视角看JPG 隐写题的套路远比 BMP 丰富因为文件本身就是分段组织的。3.3 JPG 提取的高频翻车点二次压缩、缩小bmp、jpg、gif图片隐写本文还有配套的精品资源点击获取