BMP/GIF/JPG图片信息隐藏:LSB、调色板与DCT系数三套实现
简介:这套面向信息安全爱好者与中高级C++开发者的图片隐写学习素材,围绕BMP、JPG、GIF三种常见图像格式展开,帮助读者理解如何在文件头、像素数据区、DCT系数、扩展块及尾部等位置嵌入与提取隐藏信息。压缩包共5个文件,以4个cpp源码和1个h头文件为主,按格式拆分为BMP、JPG、GIF等实现文件,包体仅5KB,轻量便于阅读与二次调试。已有6127人学习下载,说明该主题在CTF、网络情报与版权保护等场景中关注度较高。读者可从中获得三种格式的隐写思路与代码骨架,包括文件头参数篡改、相邻像素差异利用、DCT系数调整、LZW压缩下的策略以及尾部追加的注意事项,也能为后续编写检测与提取工具提供参考。适合希望通过源码对照掌握信息隐藏原理、并进一步研究隐写分析方法的读者。
1. 三个格式三套打法:同样是改一个最低位,BMP 能活、JPG 直接报废
把一段话藏进图片,最直觉的做法是改像素的最低位。这套操作在 BMP 上能活,在 GIF 上要绕两道弯,在 JPG 上保存那一刻就全没了——BMP、GIF、JPG 的图片信息隐藏,本质是三套完全不同的打法,不是同一个脚本换个后缀。BMP 是空间域光秃秃直接改,像素按行平铺,改最低位几乎零成本;GIF 走调色板索引加 LZW,你得先解决“索引一动颜色就跳”的问题;JPG 的像素是从量化后的 DCT 系数重建出来的,动像素等于自欺欺人,只能直接改系数。
这份资源把三种格式的嵌入与提取都实现了出来,适合两类人:想搞清楚“为什么 JPG 不能做空间域 LSB”的,以及需要一个能跑通、能对照的实现来改自己业务逻辑的。下面按格式拆,每一段都落到可执行的代码和参数上。
2. BMP 隐写:24 位真彩的 LSB 写入与容量账
2.1 先读对文件头三个字段,别上来就 np.array()
BMP 是最“裸”的位图格式:没有压缩、没有熵编码,像素字节按行平铺在文件里,所以 LSB 替换在它身上几乎零成本。但前提是读对三个字段——bfOffBits、biBitCount、biCompression。很多人栽在第一步:拿一张 8 位索引色的 BMP,Image.open之后np.array()拿到的是调色板索引而不是 RGB,往上写 LSB 写的是索引号,颜色直接跳变。
| 字段 | 偏移 | 长度 | 为什么要在意 |
|---|---|---|---|
bfOffBits | 10 | 4 | 像素数据起点,直接改字节时要靠它定位 |
biWidth/biHeight | 18 / 22 | 4 / 4 | 高度为负表示自上而下存储,行序会反过来 |
biBitCount | 28 | 2 | 只有 24 / 32 位适合做 LSB,8 位是索引色 |
biCompression | 30 | 4 | 0 表示 BI_RGB 无压缩,RLE 压缩的不能按字节平铺处理 |
biSizeImage | 34 | 4 | 部分写入器会填 0,不能拿它当唯一依据 |
import numpy as np from PIL import Image def bmp_header(path): with open(path, 'rb') as f: head = f.read(54) # 文件头 14 字节 + BITMAPINFOHEADER 40 字节 if head[:2] != b'BM': raise ValueError('不是 BMP 文件') return { 'off_bits': int.from_bytes(head[10:14], 'little'), # 像素区起点 'width': int.from_bytes(head[18:22], 'little', signed=True), 'height': int.from_bytes(head[22:26], 'little', signed=True), 'bit_count': int.from_bytes(head[28:30], 'little'), 'compression': int.from_bytes(head[30:34], 'little'), 'size_image': int.from_bytes(head[34:38], 'little'), } info = bmp_header('cover.bmp') assert info['bit_count'] in (24, 32), '先转成真彩 BMP 再做' assert info['compression'] == 0, 'RLE 压缩的 BMP 不能按行平铺处理'这段只做校验,不改任何字节。signed=True是必须的:biHeight为负值时表示图像自上而下存储,如果你直接用abs()忽略符号,提取时行序会整体翻转,嵌入和提取对不上,排查起来毫无头绪。
还有一个容易忽略的点是行对齐。BMP 每一行的字节数要求是 4 的倍数,公式是((width * bit_count + 31) // 32) * 4,当width * 3不是 4 的倍数时行尾会有填充字节。所以千万不要拿整块f.read()的结果直接reshape(H, W, 3),一定会错位。我的做法是:读像素交给 Pillow(它会把填充去掉,返回紧凑的 H×W×3 数组),写回也交给 Pillow 的编码器,中间只在 numpy 数组上改位。
2.2 LSB 写入:一个长度头加位平面批量替换
真正的嵌入逻辑很短,但有几个顺序问题必须一次性定死:用哪几个通道、每个通道改几位、比特在数组里的填充顺序。这三件事在嵌入端和提取端必须完全一致,否则就是自己制造黑匣子。
import numpy as np from PIL import Image MAGIC = b'STG1' # 4 字节标记,用来判断图片有没有被重新编码过 def embed_bmp(src, dst, payload: bytes, n_lsb: int = 1, channels=(0, 1, 2)): img = Image.open(src) if img.mode not in ('RGB', 'RGBA'): raise ValueError(f'当前 mode={img.mode},先转成 RGB 再嵌入') arr = np.array(img).astype(np.uint8) # 头部:4 字节 magic + 4 字节大端长度,后面才是正文 header = MAGIC + len(payload).to_bytes(4, 'big') bits = np.unpackbits(np.frombuffer(header + payload, np.uint8)) plane = arr[:, :, list(channels)].reshape(-1) # 行优先摊平,只取参与通道 capacity = plane.size * n_lsb if bits.size > capacity: raise ValueError(f'容量不足:需要 {bits.size} bit,实际只有 {capacity} bit') # 第 i 个比特 -> 第 i//n_lsb 个通道字节的第 i%n_lsb 位 idx = np.arange(bits.size) // n_lsb pos = (np.arange(bits.size) % n_lsb).astype(np.uint8) mask = (np.uint8(1) << pos) plane[idx] = (plane[idx] & ~mask) | (bits.astype(np.uint8) << pos) arr[:, :, list(channels)] = plane.reshape(arr.shape[0], arr.shape[1], len(channels)) Image.fromarray(arr).save(dst, format='BMP') # 无压缩写出,位不会被抹掉 return bits.size def extract_bmp(path, n_lsb: int = 1, channels=(0, 1, 2)): arr = np.array(Image.open(path).convert('RGB')) plane = arr[:, :, list(channels)].reshape(-1) def read_bits(start, count): i = np.arange(start, start + count) return ((plane[i // n_lsb] >> (i % n_lsb).astype(np.uint8)) & 1).astype(np.uint8) head = np.packbits(read_bits(0, 64)).tobytes() if head[:4] != MAGIC: raise ValueError('没找到标记:图片可能被重新编码或换过通道参数') size = int.from_bytes(head[4:8], 'big') return np.packbits(read_bits(64, size * 8)).tobytes()逻辑上分三段。第一段把magic + 长度 + 正文拼成一个字节串再unpackbits拆成比特流,长度只占 4 字节却决定了后面所有比特的归属,所以它必须固定在最前面。第二段是向量化的位替换,idx和pos把一维比特流映射到“第几个字节的第几位”,mask负责先清零再写入,避免覆盖掉同一字节的高位。第三段是回写,plane是花式索引产生的副本,必须显式赋回arr,否则改的是副本。
参数上,n_lsb是最核心的旋钮:取 1 时每个通道贡献 1 bit,容量为W*H*len(channels)/8字节;取 2 容量翻倍,但改动幅度从 ±1 变成 ±3,噪声开始能被看出来。channels默认三通道全用,只改蓝色通道传(2,)会更隐蔽,代价是容量掉到三分之一——人眼对蓝通道最不敏感,这是色彩空间给的一点便宜。
2.3 容量、PSNR 和“改几位”的取舍
拿一张 1920×1080 的 24 位 BMP 算账:通道总数 1920×1080×3 = 6,220,800 个,每个通道 1 bit,理论容量 777,600 字节,差不多 759 KB。这个数字很诱人,但把 759 KB 塞满之后,图片的低位噪声已经均匀铺满整幅图,虽然人眼看不太出来,统计检测一测一个准。
n_lsb | 1920×1080 容量 | 典型 PSNR | 肉眼/统计上的表现 |
|---|---|---|---|
| 1(三通道) | 约 759 KB | 51 dB 左右 | 肉眼无感,但直方图配对现象明显 |
| 2(三通道) | 约 1.5 MB | 44 dB 左右 | 平滑渐变区可见轻微噪点 |
| 3(三通道) | 约 2.2 MB | 37 dB 左右 | 明显噪点,不建议用于对外发布的图 |
算 PSNR 就三行,别靠感觉:
def psnr(a, b): mse = np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2) return float('inf') if mse == 0 else 10 * np.log10(255.0 ** 2 / mse) # psnr(np.array(Image.open('cover.bmp')), np.array(Image.open('stego.bmp')))还有一个比n_lsb更影响成败的参数:写入位置是不是顺序的。上面那段代码从第一个像素开始顺次写,任何人拿到图从头读 64 bit 就能看到 magic,等于把锁挂在门上。实际用的时候我会加一层置乱:
rng = np.random.default_rng(20240501) # 同一个种子就是同一把钥匙 perm = rng.permutation(plane.size) # 把写入顺序打散 plane_w = plane[perm[:bits.size]] # 按置乱后的位置取字节 # ...改完位之后,再 plane[perm[:bits.size]] = plane_w 写回置乱只解决“顺序可读”的问题,解决不了统计特征。这一点放到第 6 章用卡方分析说。容量和隐蔽性永远是对冲的,我的习惯是把容量控制在理论值的 20%~30%,剩下的余量用来抵御不可预见的二次处理。
3. GIF 隐写:注释扩展块、调色板配对与索引 LSB
3.1 GIF 的块结构:0x21 0xFE 是天然的藏匿点
GIF 是一串块拼起来的容器,不是一整块像素阵列。文件从头到尾是:6 字节文件头(GIF87a或GIF89a)、7 字节逻辑屏幕描述符、可选的全局调色板、一堆数据块、最后的0x3B结束符。数据块只有两类:0x2C开头的是图像描述符,0x21开头的是扩展块(0xF9图形控制、0xFE注释、0x01纯文本、0xFF应用扩展)。
所有这些块的载荷都用“子块”组织:一个长度字节(1~255),跟这么多字节数据,可以连续多段,最后用一个长度为 0 的子块收尾。这既是判断 GIF 是否被截断的依据,也是塞东西的地方。0x21 0xFE注释扩展最省事,播放器碰到直接跳过,不会影响渲染。
还有个细节:只有GIF89a才定义了扩展块,文件头如果是GIF87a,某些严格的解码器会拒绝解析扩展。往注释块里写数据前先确认版本,必要时把第 6 个字节从7改成9。
3.2 调色板配对加索引 LSB:肉眼看不出来的那条路
GIF 像素存的是调色板索引,不是颜色值。直接改索引的最低位,等于把这个像素换成调色板里的另一个颜色,如果这两个颜色差得远,画面上就出现一块孤零零的异色。所以正确做法是先重排调色板:把颜色按视觉距离两两配对,让索引2k和2k+1是同一对相近色,再对索引做 LSB,这样翻转一位只会在两个几乎一样的颜色之间跳。
import numpy as np from PIL import Image def pair_palette(pal: np.ndarray): """把 256 色调色板贪心配对,返回重排后的顺序;相邻两个元素是一对""" pal = pal.reshape(-1, 3).astype(np.int32) n = pal.shape[0] used = np.zeros(n, bool) order = [] for i in range(n): if used[i]: continue d = np.abs(pal - pal[i]).sum(1) # L1 距离够用,算得快 d[used] = 10 ** 6 d[i] = 10 ** 6 j = int(np.argmin(d)) order += [i, j] used[i] = used[j] = True return order def remap_gif(src, dst, payload: bytes, n_lsb: int = 1): im = Image.open(src) if im.mode != 'P': raise ValueError('需要 P 模式(带调色板)的 GIF') idx = np.array(im) # 调色板索引矩阵 pal = np.array(im.getpalette(), np.uint8) order = pair_palette(pal) inv = np.zeros(len(order), np.int64) inv[order] = np.arange(len(order)) idx = inv[idx].astype(np.uint8) # 重映射索引,颜色本身不变 pal = pal.reshape(-1, 3)[order].reshape(-1) flat = idx.reshape(-1) header = b'GIFH' + len(payload).to_bytes(4, 'big') bits = np.unpackbits(np.frombuffer(header + payload, np.uint8)) if bits.size > flat.size * n_lsb: raise ValueError('索引数量不够,考虑加帧或提高 n_lsb') i = np.arange(bits.size) pos = (i % n_lsb).astype(np.uint8) mask = (np.uint8(1) << pos) flat[i // n_lsb] = (flat[i // n_lsb] & ~mask) | (bits.astype(np.uint8) << pos) out = Image.fromarray(flat.reshape(idx.shape), mode='P') out.putpalette(pal.tolist()) out.save(dst, format='GIF', optimize=False) # optimize=True 会重排调色板,索引全乱配对用的是贪心最近邻:对每个还没用过的颜色,找与它 L1 距离最小的未使用颜色配成一对,复杂度 O(n²),n=256 时几毫秒就跑完,没必要上匈牙利算法。inv是置换的逆,用来把原始索引映射到新顺序上,这一步不改颜色,只改编号,画面完全一致。
关键参数是optimize=False。Pillow 保存 GIF 时如果开了优化,会重新裁剪调色板、合并相同颜色,你的索引映射全部作废,提取端读到的是另一套编号,血泪经验。同理,putpalette必须在fromarray之后、save之前调用,顺序错了调色板会被覆盖成灰度。
容量上,每像素 1 bit,一张 800×600 的 GIF 是 60,000 字节,动图按帧累加。注意有些 GIF 每帧带自己的局部调色板,这时候配对要按帧做,不同帧的索引含义不一样,混着用会串色。
3.3 注释扩展块:容量最大、隐蔽性最差
如果目标不是“不被发现”而是“把数据跟着图一起带走”,注释扩展块是效率最高的选择:不改一个像素,不动调色板,无损,容量只受文件大小限制。
def gif_add_comment(src, dst, payload: bytes): with open(src, 'rb') as f: raw = bytearray(f.read()) if raw[:6] == b'GIF87a': raw[:6] = b'GIF89a' # 87a 没有扩展块定义 elif raw[:6] != b'GIF89a': raise ValueError('不是 GIF') packed = raw[10] gct_size = 3 * (2 ** ((packed & 0x07) + 1)) if packed & 0x80 else 0 insert_at = 13 + gct_size # 6 字节头 + 7 字节 LSD + 全局调色板之后 block = bytearray([0x21, 0xFE]) # 扩展引入 + 注释标签 for i in range(0, len(payload), 255): chunk = payload[i:i + 255] block.append(len(chunk)) # 子块长度必须 <= 255 block += chunk block.append(0x00) # 块终止符 raw[insert_at:insert_at] = block with open(dst, 'wb') as f: f.write(raw) def gif_read_comment(path): raw = open(path, 'rb').read() pos, out = 13, bytearray() packed = raw[10] if packed & 0x80: pos += 3 * (2 ** ((packed & 0x07) + 1)) while pos < len(raw): if raw[pos] == 0x21 and raw[pos + 1] == 0xFE: pos += 2 while raw[pos] != 0x00: n = raw[pos] out += raw[pos + 1:pos + 1 + n] pos += 1 + n return bytes(out) pos += 1 return b''插入点选在全局调色板之后、第一个数据块之前,是兼容性最好的位置:解码器读到这里时渲染上下文还没建立,注释块直接跳过。子块长度硬上限是 255,超过就必须切段,忘了切会让解码器把长度字节当成数据长度读,整个文件报废。末尾的0x00也不能省。
读取端就是线性扫描0x21 0xFE,简单粗暴但够用。这条路的短板很明显:任何一个十六进制编辑器打开都能看到,图床和一些平台在转存时会把非必要块剥掉,剥完你的数据就没了。所以注释块适合“自己存档、内部流转”,不适合“对外发布还要保密”。
4. JPG 隐写:动量化后的 DCT 系数,别动像素
4.1 为什么空间域 LSB 在 JPG 上必死
JPEG 的编码链路是:色彩空间转换 → 8×8 分块 → DCT → 除以量化表取整 → zigzag 扫描 → 熵编码。解码是反过程。你在像素上改一个最低位,编码器重新做 DCT 时这个改动会被摊到该块的 64 个系数上,紧接着除以量化表取整,把所有小于量化步长的变化全部归零。量化表里中高频的步长动辄十几二十,1 个灰度级的改动连个水花都留不下。
所以 JPG 的信息隐藏只有一条正路:直接在量化后的 DCT 系数上改,改完写回熵编码,不再重新走 DCT 和量化。这条路能成立是因为解码端拿到的就是这些系数,只要系数没变,重建出的像素就符合你的预期。
顺带说清楚几条“看着能用、实际很脆”的近路:在文件尾0xFFD9之后追加数据、往0xFFFECOM 段里写、往 APPn 段里写。它们零失真、实现只要几行,但一查就出来,而且绝大多数图片处理链路(缩略图生成、格式转换、平台转存)会把这些段直接丢掉。我的定位是:近路用来做内部标记,真要做隐写就用系数。
4.2 读出量化后的 DCT 系数:布局和可用位置统计
Python 里读系数最省事的是jpegio,它依赖系统 libjpeg,装的时候如果报错,先把 libjpeg 的开发包补上。
pip install jpegio numpy读出来之后是两个东西:系数数组和量化表。系数数组的形状是(H, W),H、W 是 8 的倍数,每 8×8 一块;块内 64 个位置对应不同频率,第 0 个是 DC,第 1~63 是 AC。量化表同样是 64 个值,和系数位置一一对应。
import numpy as np import jpegio as jio cj = jio.read('cover.jpg') coef = cj.coef_arrays[0] # 量化后的 DCT 系数,int16 qt = cj.quant_tables[0] # 量化表,和 coef 块内位置同序 def ac_view(coef): """把系数数组重排成 (块行, 块列, 8, 8),方便按块取 AC""" by, bx = coef.shape[0] // 8, coef.shape[1] // 8 return coef.reshape(by, 8, bx, 8).transpose(0, 2, 1, 3) ac = ac_view(coef)[:, :, :, 1:] # 丢掉 DC,只留 63 个 AC hist = np.bincount(np.abs(ac).ravel()) # 绝对值直方图 total = ac.size usable = total - hist[0] - hist[1] # 跳过 0 和 ±1,剩下才是 JSteg 可用的 print(f'AC 总数 {total}, 可用 {usable} ({usable/total:.1%}), 容量 {usable//8} 字节')这段代码不写数据,只做容量勘察,但它是整个 JPG 流程里最该先跑的一步。ac_view的转置是为了让最后两维是块内的 8×8 坐标,[:, :, :, 1:]取 AC;不同库对块内顺序的处理不一样,jpegio的系数和量化表都按 zigzag 顺序给出,换用别的库之前先拿一张已知图核对一次,否则你改的“高频”可能其实是 DC,画面上直接出现方块。
usable的算法是 JSteg 的定义:跳过值为 0 的系数(改了会破坏游程编码,文件体积暴涨)和绝对值为 1 的系数(改成 0 或 2 都会显著改变统计分布)。一张 1920×1080 的 JPG,块数是 240×135 = 32,400,AC 总数约 204 万;质量因子 85 左右的图,usable占比通常在两三成的区间,也就是 40 万到 60 万 bit,折合 50 到 75 KB。
| 质量因子 | usable占比(经验区间) | 典型容量(1920×1080) |
|---|---|---|
| 95 | 35%~45% | 90~115 KB |
| 85 | 25%~35% | 65~90 KB |
| 75 | 15%~25% | 40~65 KB |
| 60 以下 | 10% 以下 | 25 KB 以内,不建议 |
4.3 JSteg 与 F5:改哪类系数、容量怎么估
JSteg 是最容易实现的版本:按扫描顺序取|c| > 1的 AC 系数,把|c|的最
本文还有配套的精品资源,点击获取