
简介面向信息安全初学者、CTF 隐写题练习者及图像处理爱好者的图片隐写源码包聚焦 BMP、JPG 与 GIF 三种常见格式的信息隐藏与提取思路。内容围绕文件头参数篡改、像素数据差异利用、DCT 系数调整、LZW 压缩结构以及尾部或扩展块追加等方式展开并涉及隐藏信息检测与提取所需的基础分析视角可用于课程实验、赛题复现和自测练习。压缩包共 5 个文件由 4 个 cpp 源文件和 1 个头文件组成整体约 5KB体量轻便便于阅读、编译和按模块拆解学习。已有 6127 人学习下载适合希望从代码层面理解 BMP、JPG、GIF 隐写实现细节、梳理文件结构与信息嵌入位置并积累检测与提取排错思路的读者参考。1. 三张图三种命图片隐写为什么不能一套代码打天下同一段 200 字节的文本塞进 BMP 三百行代码就能跑通塞进 JPG 同样的代码跑完图糊了、数据也没了塞进 GIF 索引一改整张图花屏。图片隐写最容易被低估的地方就在这里BMP、JPG、GIF 不是同一类容器一个把像素赤裸裸摊在文件里一个把像素变成量化系数再熵编码一个把像素换成调色板索引。载体结构决定了嵌入位置、容量上限和失真表现也决定了一套代码只能在一种格式上成立。这篇讲的是三种格式各自能落地的隐写路径BMP 走最低有效位的字节级替换JPG 走 DCT 量化系数的中频带嵌入GIF 走调色板顺序与索引位的排列编码。适合已经会用脚本批量处理图片、想把隐蔽信道做进日常流程的开发者也适合做数据外发审计、需要知道这几种手法长什么样的人。2. BMP 的 LSB 隐写从文件头偏移到可逆提取的最小闭环2.1 BMP 里真正要动的三个字段BMP 是三种格式里唯一把像素裸着摆出来的。24 位或 32 位深时没有压缩、没有熵编码、没有调色板强制重排文件头之后基本就是一条 BGR 字节流。图片隐写里最经典的 LSB 替换之所以几乎只在 BMP 上写得干净原因就在这改一个字节的最低位像素值最多偏移 1人眼看不出解码时按同样的字节位置读回来就行。但直接跳 54 字节开始写这个习惯会翻车。BMP 的文件头加信息头合计 54 字节这只在 BITMAPINFOHEADER40 字节信息头且 24 位无调色板时成立。8 位索引色 BMP 在信息头之后还跟着一张 256 项的调色板占 1024 字节V4/V5 头更长有些工具还会在像素区前后写入额外的元数据块。写死偏移换一张图就写进了调色板颜色全变图还能打开但已经废了。正确的做法是从文件头里读。BITMAPFILEHEADER 的第 10 字节开始是一个 4 字节小端整数 bfOffBits它直接告诉你像素数据从哪开始。相对偏移长度字段说明0x002bfType固定BM用于校验0x024bfSize整个文件字节数0x0A4bfOffBits像素数据起始偏移必须动态读0x124biWidth宽像素0x164biHeight高负数表示自上而下存储0x1C2biBitCount位深24 表示每像素 3 字节还有一个容易算错的地方是行对齐。BMP 规定每一行像素占用的字节数必须是 4 的倍数24 位图每行有效字节是 width×3不够就补 0 到 4 的倍数。1920 宽的图每行 5760 字节正好整除1921 宽的图每行 5763 字节要补 1 字节到 5764。这些补齐字节不属于图像内容但它们在文件里是实打实存在的。2.2 用 Python 手写 LSB 编码器与解码器下面这段代码只依赖标准库把读偏移、算容量、写比特、读比特整个闭环跑完。编码端把载荷转成比特流逐位写进像素区每个字节的最低位末尾追加三字节 0x00 作为终止标记。import struct def pixel_offset(buf: bytes) - int: # bfOffBits 在文件头第 10 字节4 字节小端 return struct.unpack_from(I, buf, 10)[0] def to_bits(data: bytes) - list: bits [] for byte in data: for i in range(7, -1, -1): # 高位在前保证解码顺序一致 bits.append((byte i) 1) return bits def lsb_embed(src: str, dst: str, payload: bytes, start: int 0) - int: with open(src, rb) as f: buf bytearray(f.read()) off pixel_offset(buf) body payload b\x00\x00\x00 # 3 字节终止标记 bits to_bits(body) # 容量 像素区总字节数 - 起始偏移每字节存 1 bit capacity len(buf) - off - start if len(bits) capacity: raise ValueError(f容量不足需要 {len(bits)} bit可用 {capacity} bit) pos off start for b in bits: buf[pos] (buf[pos] 0xFE) | b # 清最低位再或上待写比特 pos 1 with open(dst, wb) as f: f.write(buf) return len(bits) def lsb_extract(path: str, start: int 0, limit: int 1 20) - bytes: with open(path, rb) as f: buf f.read() off pixel_offset(buf) pos off start out bytearray() for _ in range(limit): byte 0 for _ in range(8): byte (byte 1) | (buf[pos] 0x01) pos 1 out.append(byte) if out.endswith(b\x00\x00\x00): return bytes(out[:-3]) # 去掉终止标记 raise ValueError(超过读取上限仍未找到终止标记) if __name__ __main__: used lsb_embed(cover.bmp, stego.bmp, hello lsb.encode(utf-8)) print(写入比特数:, used) print(解出:, lsb_extract(stego.bmp).decode(utf-8))几个地方值得单独说清楚。to_bits按高位在前拆字节解码端也按同样的顺序拼两边一旦有一边改成低位在前读出来就是完全不同的乱码而且这种错误不会有任何报错。buf[pos] 0xFE是先把最低位清零再用按位或写入待嵌比特比加减法安全不会因为像素值 255 加 1 溢出回绕。编码和解码都从off start开始按连续字节顺序遍历不区分行与行之间的补齐字节。这没问题只要两端用同一套遍历规则补齐字节被改掉也不影响任何显示因为解码器根本不读它们。容量估算很直接一张 1920×1080 的 24 位 BMP像素区约 6220800 字节容量就是 6220800 bit也就是 777600 字节约 760 KB。这个数字看着很诱人但真正落地时几乎没人敢用满原因在下一节的参数选择里。2.3 四个必调参数起始偏移、通道掩码、终止标记、容量占比start控制从像素区第几个字节开始写。默认 0 会从左上角第一个像素的蓝色分量写起如果这张图后续要被人打开看一眼左上角往往是视觉焦点。更稳妥的做法是跳过前几行或者干脆从一个固定的大偏移开始让改动落在视觉权重较低的区域。通道掩码决定写哪几个字节。24 位 BMP 每个像素三字节顺序是 B、G、R。人眼对绿色最敏感对蓝色最不敏感所以只写蓝色分量每像素第 0 字节即start步长为 3是失真最小的方案但容量直接掉到三分之一。全通道写入容量最大代价是每个像素三个分量都被扰动 ±1在纯色区域会形成轻微噪点。我的习惯是颜色平坦的图只写蓝通道纹理复杂的照片三通道全写。终止标记是个能用但不够稳的方案。如果载荷本身恰好包含连续三个 0x00 字节解码会在中途截断。二进制载荷比如压缩包片段出现 0x000000 的概率不低所以工程上更推荐把终止标记换成四字节长度前缀编码时先写入struct.pack(I, len(payload))解码时先读四字节拿到长度再精确读那么多字节。改动很小鲁棒性提升明显。容量占比是最容易被忽略的一个。把 760 KB 全部填满等于改动了几百万个像素的最低位图像的 LSB 平面从接近随机的自然噪声变成了完全随机的数据流。视觉上确实看不出来但统计检测方法比如对 LSB 平面做卡方分析、看相邻像素对的值分布一眼就能发现异常。常规做法是控制在 10% 到 30%既够塞一段文本或一个小配置文件又不至于把统计特征改得太干净。提示LSB 隐写对任何有损处理都是零容忍。把 stego.bmp 用聊天工具发一遍、或者另存为 JPG数据百分之百丢失。载体文件必须以原始二进制形式传递中途不能经过任何重新编码。3. JPG 隐写的硬约束为什么改像素位会全盘失效3.1 从 RGB 到量化系数JPG 压缩链路里哪一步才是可改的把 BMP 那套代码原样套到 JPG 上会遇到一个很干脆的结果图片能打开看起来也没什么变化但一个比特都读不回来。原因不在代码在压缩链路。JPG 编码大致走这么几步RGB 转 YCbCr 颜色空间通常还会对色度分量做下采样图像切成 8×8 的块每块做二维离散余弦变换DCT把 64 个像素值变成 64 个频率系数每个系数除以量化表里对应的步长再取整高频分量因为步长大绝大多数直接变成 0最后按 zigzag 顺序重排做熵编码写进文件。关键在于除以量化步长再取整这一步。低频系数步长小亮度表左上角是 16、11、10、16…高频系数步长很大右下角能到 99。一个系数被量化之后落点是一个整数格点反量化回来是格点 × 步长。你在像素域改的那 1 个最低位经过 DCT 之后变成所有 64 个频率系数上的微小扰动再经过量化取整绝大多数扰动直接被抹平能活下来的纯属偶然。所以 JPG 隐写的操作位置只有一个量化之后的 DCT 系数本身。在这一层改改完直接写回系数、交给熵编码中间的量化不会再把你的改动吃掉。代价是整个流程必须下沉到系数级别不能再用打开图片改像素再保存的思路。3.2 在 8×8 DCT 中频带嵌入numpy 最小验证代码下面这段用 numpy 和 scipy 做一个单块的完整验证分块、DCT、量化、在最中间那几个频率位置按奇偶性写比特、反量化、IDCT、拼回图像。它不写 JPG 文件但把为什么必须在这一层改演示得很清楚。import numpy as np from scipy.fftpack import dct, idct # 标准 JPEG 亮度量化表左上低频右下高频 Q 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.float64) # 中频位置避开左上低频改动可见和右下高频会被量化抹平 POS [(2, 1), (1, 2), (2, 2), (1, 3), (3, 1), (3, 2), (2, 3), (4, 1)] def fdct(block): return dct(dct(block.T, normortho).T, normortho) def fidct(coef): return idct(idct(coef.T, normortho).T, normortho) def embed_block(block, bits, alpha1.0): coef fdct(block - 128.0) # DCT 前先做电平搬移 q coef / Q for (r, c), bit in zip(POS, bits): v int(np.round(q[r, c])) if v 0: # 0 无法用奇偶区分强制推到 ±1 或 ±2 v 2 if bit 0 else 1 elif abs(v) % 2 ! bit: # 往远离 0 的方向挪一格改动幅度最小 v 1 if v 0 else -1 q[r, c] v * alpha # alpha 控制嵌入强度1.0 为标准量化 return fidct(q * Q) 128.0 def extract_block(block): coef fdct(block - 128.0) q coef / Q bits [] for (r, c) in POS: v int(np.round(q[r, c])) bits.append(abs(v) % 2) return bits # 构造一个 8x8 测试块 rng np.random.default_rng(0) src np.full((8, 8), 128.0) rng.normal(0, 20, (8, 8)) src np.clip(src, 0, 255) payload [1, 0, 1, 1, 0, 0, 1, 0] stego np.clip(embed_block(src, payload), 0, 255) print(读出比特:, extract_block(stego)) print(最大像素偏差:, np.abs(stego - src).max()) print(PSNR:, 10 * np.log10(255 ** 2 / np.mean((stego - src) ** 2)))POS选的是 zigzag 序里排在中间那一段位置比如 (2,1)、(1,2)、(2,2)。这些位置的量化步长在 14 到 22 之间既不会像低频那样一改就影响整块亮度也不会像高频那样被大步长直接归零。alpha是嵌入强度1.0 表示严格按标准量化表走调大等于人为放大系数幅度抗重压缩能力更强但失真也更大。v 0这个分支是必须写的。系数被量化成 0 之后奇偶性是 0即偶数如果直接用它表示比特 0那所有原始为 0 的系数都会被误判成 0 比特提取端拿到的全是 0。正确的处理是把 0 推到 ±1 或 ±2代价是这块区域多了一次明显的改动所以中频位置的选择要和这一步一起考虑。3.3 鲁棒性三参数量化步长、嵌入位置与重压缩存活率在真实工程里落 JPG 隐写绕不开三个参数。量化步长决定了改动一格系数带来的像素变化幅度。标准表的步长已经是一个平衡点如果你的载体打算经历一次轻度重压缩比如社交平台会重新编码就得把 alpha 提到 1.5 到 2.0让系数偏离格点中心更远压缩后才不至于翻回去。代价是 PSNR 从 45 dB 掉到 38 dB 附近肉眼开始能在平滑区域看到细微的块状纹路。嵌入位置决定容量和存活的取舍。只用 8 个中频位置每块 8 bit1920×1080 的图有 32400 个 8×8 块亮度通道理论上限约 31 KB。这个数字看着不错但实践里建议控制在每块 3 到 4 bit、总容量 10 KB 以内因为块与块之间存在视觉掩蔽效应同一个位置在平坦块上改 4 格会很明显在纹理块上改 4 格就看不出来。按块的方差动态选择嵌入位数是常见做法。重压缩存活率需要实测不能靠推算。测法是嵌入固定载荷用不同质量因子比如 85、90、95重新保存一遍再用提取端读统计比特错误率。比特错误率低于 5% 时加上纠错编码还能完整恢复高于 15%这套参数基本没有实用价值。注意JPG 隐写的提取必须知道原来的量化表和嵌入位置。换了量化表系数除以新表得到的整数值完全不同奇偶性自然全乱。所以这类方案要么把量化表固定在代码里要么在提取前先从文件里读回量化表。4. GIF 隐写调色板顺序、索引位与多帧容量4.1 GIF 块结构速查全局色表与图像描述符的字节位置GIF 的像素不是颜色是调色板的索引。每个像素存一个字节或更少位这个字节指向调色板里的某一项调色板项才是真正的 RGB 值。这个间接层给了隐写两个完全不同的切入面改索引或者改调色板。先看结构。文件开头是 6 字节签名GIF87a或GIF89a接着是 7 字节的逻辑屏幕描述符其中第 10 个字节从 0 开始数是打包字段。相对偏移长度内容0x006签名GIF89a0x062逻辑屏宽小端0x082逻辑屏高小端0x0A1打包字段bit7 全局色表标志bit2-0 色表大小指数0x0B1背景色索引0x0C1像素宽高比0x0D变长全局色表长度 3 × 2^(N1)打包字段低三位 N 决定全局色表项数N7 时是 256 项占 768 字节N0 时是 2 项占 6 字节。没有全局色表时 bit7 为 0色表从 0x0D 之后才开始直接跳到块区。块区里只关心两种0x21 是扩展块注释、应用数据、图形控制都在这里0x2C 是图像描述符0x3B 是文件结束。图像描述符的结构是1 字节标识 0x2C2 字节左边距2 字节上边距2 字节宽2 字节高1 字节打包字段然后是可选局部色表、1 字节 LZW 最小码长、若干数据子块。局部色表的启用条件和大小跟在全局色表里是同一套规则。4.2 调色板排列编码交换哪一对颜色才不会翻车调色板里存的是颜色像素存的是索引。交换调色板中两项的位置所有指向这两项的像素颜色会互换而索引数据一个字节都不用动。这就是排列编码的基础选 N 对视觉上接近的颜色每一对是否交换表示 1 bit。实现上先扫全局色表找出颜色距离比如 RGB 欧氏距离小于某个阈值、且在图像里使用频率都较高的项两两配对。距离越小交换后视觉差异越不明显但距离太小又会被调色板量化工具当成重复色合并所以阈值一般取 8 到 20 之间。import struct def locate_global_palette(buf: bytes): if buf[:6] not in (bGIF87a, bGIF89a): raise ValueError(不是 GIF 文件) packed buf[10] if not (packed 0x80): return None # 没有全局色表 n 3 * (2 ** ((packed 0x07) 1)) return 13, n # 起始偏移, 字节长度 def color_dist(pal: bytearray, i: int, j: int) - float: d 0.0 for k in range(3): diff pal[3 * i k] - pal[3 * j k] d diff * diff return d ** 0.5 def pick_pairs(pal: bytearray, max_dist: float 16.0): count len(pal) // 3 used, pairs set(), [] for i in range(count): if i in used: continue for j in range(i 1, count): if j in used: continue if color_dist(pal, i, j) max_dist: pairs.append((i, j)) used.update({i, j}) break return pairs def swap_items(pal: bytearray, i: int, j: int): for k in range(3): a, b 3 * i k, 3 * j k pal[a], pal[b] pal[b], pal[a] def embed_palette(path_in: str, path_out: str, bits: list): with open(path_in, rb) as f: buf bytearray(f.read()) start, size locate_global_palette(buf) pal bytearray(buf[start:start size]) pairs pick_pairs(pal, max_dist16.0) if len(bits) len(pairs): raise ValueError(f色表只找到 {len(pairs)} 对可交换颜色) for (i, j), bit in zip(pairs, bits): if bit: swap_items(pal, i, j) buf[start:start size] pal with open(path_out, wb) as f: f.write(buf) print(f使用 {len(bits)} 对颜色承载 {len(bits)} bit)pick_pairs是贪心配对从索引 0 开始找第一个距离足够近的伙伴配对后就把它俩从候选池里剔除避免同一个颜色被重复使用。max_dist是核心参数调大能配出更多对、容量更大但每次交换带来的颜色偏移也更明显调小容量骤降某些调色板精简的图可能一对都凑不出来。解提取的代码和嵌入几乎对称按同样的配对规则重新找出 pairs逐对比较当前顺序和原始顺序交换过就是 1没交换就是 0。这里有个前提配对算法必须是确定性的同一张调色板无论算多少次都得到同一组 pairs。所以距离比较要用固定精度不要引入随机数或者哈希顺序依赖。4.3 多帧 GIF 的容量叠加与 LZW 数据区禁忌单帧 GIF 的排列编码容量很有限。256 色调色板最多配出 128 对也就是 16 字节。这个量级只能塞个短标识做不了什么正经事。容量主要靠两条路补。第一条是多帧叠加。动图的每一帧可以带独立的局部色表每张局部色表都能承载一份排列编码数据。一个 30 帧的动图每帧配出 40 对总容量就是 1200 bit150 字节。帧数越多、每帧的调色板冗余度越高容量越大。有些方案还会故意把一帧拆成多次重绘来增加帧数但这会明显改变播放表现不值得。第二条是未使用的调色板项和重复颜色。很多 GIF 的 256 项调色板里实际只用了 60 多项剩下近 200 项是空的或者来自量化器的填充值。这些项对应的像素数为零随便改颜色都没有任何视觉影响可以直接当原始字节区写入数据。判定未使用的办法是完整解一遍 LZW、统计每个索引的出现次数这个统计本身就要写完整的解码器属于工程量较大的部分。LZW 数据区本身不建议直接做 LSB 替换。这部分是压缩后的码流改任意一位都可能让解码器在某个位置读出一个完全错误的码字长度导致后面的数据流全部错位整帧图像崩掉。而且 LZW 是变长编码改动一位的影响范围不可预测属于典型的黑匣子操作。如果一定要在索引层做隐写正确的位置是解码后的索引数组找出那些颜色相同或极近的索引组在组内互换索引值这样像素颜色不变索引值变了可以承载比特。这本质上和调色板排列是同一个思路只是换了个平面。注意很多图像工具在保存 GIF 时会自动重排调色板——去重、按使用频率排序、压缩掉未使用的尾部项。你在字节层写进去的交换顺序经过一次这样的保存就全没了。所以载体必须以原始字节传递或者改用不做调色板优化的保存方式。5. 图片隐写避坑清单5 个真实会翻车的排查记录5.1 现象BMP 写进去之后图片颜色整体偏了原因起始偏移写死成 54但这张载体是 8 位索引色 BMP信息头后面跟着 1024 字节调色板前 1024 个比特全写进了色表像素区一个都没动。解码端按同样偏移读读出来的数据其实是调色板的最低位的组合能读出东西但全是垃圾。解决永远从bfOffBits读偏移不要假设。在编码函数入口加一句校验off 54 and off len(buf)越界直接抛异常别让它静默写错位置。5.2 现象JPG 嵌入后提取比特错误率接近 50%原因在像素域改了最低位然后另存为 JPG重新压缩时量化把改动抹掉了。50% 的错误率意味着提取结果和原始数据完全不相关等同于随机猜。解决确认改动发生在量化后的 DCT 系数上而不是像素上。另外检查提取时读到的量化表是否和嵌入时一致很多库在保存时会重新生成量化表质量因子不同表就不同。5.3 现象GIF 嵌入成功用看图软件另存一次后数据消失原因图像工具保存时重建了调色板把相邻的近似颜色合并了或者把未使用的尾部项截掉了交换顺序被彻底打乱。解决载体文件全程以二进制方式传递不经过任何会重写调色板的工具。验证方法是嵌入后立刻用字节比对工具确认调色板区的差异符合预期再另存一次比对第二次的差异——如果差异消失说明这个工具不能用。5.4 现象解码时载荷被截断只读出一半原因用的是三字节 0x00 终止标记而载荷里恰好包含连续的三个零字节解码端提前判定结束。解决换成四字节长度前缀。编码时先写struct.pack(I, len(payload))解码时先读四字节拿长度再精确读对应比特数。改动不到十行彻底消除这类冲突。5.5 现象容量算出来够实际写到一半报越界原因容量用了width * height * 3 / 8计算但 24 位 BMP 每行有 4 字节对齐的补齐字节实际像素区大小是((width * 3 3) // 4 * 4) * abs(height)。宽不是 4 的倍数时两者会差出一截更麻烦的是有些文件在像素区之后还追加了 ICC 配置文件或 EXIF 块直接用文件总长度减偏移会把它们算进容量。解决容量统一用len(buf) - off计算只保证不越过文件末尾同时把上限再乘一个 0.9 的安全系数。宁可少写不要越界。6. 把隐写做成可验证方案容量、失真与检测的三条底线三种格式跑通之后真正决定这套东西能不能用的是三个可以量化的指标。维度BMP LSBJPG 中频 DCTGIF 调色板排列典型容量1920×1080 单帧约 760 KB 上限实用 10%~30%理论约 31 KB实用 10 KB 以内单帧 16 字节级动图按帧叠加视觉失真极低纯色区可见轻微噪点中等PSNR 38~45 dB极低取决于配对颜色距离抗重编码无任何有损/无损重存都可能破坏轻度重压缩可存活无工具重排色表即失效实现复杂度低标准库即可高需要系数级读写中需要块结构解析容量这一栏的差距不是巧合。BMP 每个字节都能承载 1 bit因为它没有压缩JPG 每个 8×8 块只有中间那几个系数既稳定又不显眼能用的位置天然稀少GIF 的调色板总共就 768 字节再怎么排列也有物理上限。失真这一栏建议每次都实测不要凭感觉。BMP 和 GIF 用 PSNR 加肉眼抽查就够JPG 除了 PSNR还要看块效应——把 stego 图和原图做差放大看差值是否集中在块边界上如果集中在边界说明改动落到了低频系数需要把POS往高频挪。检测这一栏是很多人跳过的。LSB 替换会把图像的 LSB 平面变成接近均匀随机的分布而自然图像的 LSB 平面本身带有轻微偏斜。对一张图分别统计 LSB 为 0 和为 1 的像素数量再做卡方检验嵌入比例超过 40% 时基本必然报警。JPG 的检测看的是系数直方图的配对现象——相邻的奇偶系数出现频率趋同时说明有人做过奇偶嵌入。想做得隐蔽就得把嵌入比例压下来或者引入纠错编码、把同样的载荷分散到更少的比特上。我自己的习惯是任何一套隐写参数定下来之前先做一遍自检嵌入、提取、比对原数据确认零错误把 stego 图另存一次再提取记录错误率最后对 LSB 平面跑一次统计看偏离自然分布有多远。三个数字都记下来换参数时对比着看。这一步花不了十分钟但省下来的返工时间要以小时计。踩过最深的坑是参数调完觉得看起来没问题就直接用了等到提取端在另一台机器上读出乱码才发现那张载体的位深和自己以为的不一样。文件头里的字段不会骗人多读一次总比事后找后悔药强。希望帮到你。本文还有配套的精品资源点击获取