资讯详情

资讯详情

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 位索引色的 BMPImage.open之后np.array()拿到的是调色板索引而不是 RGB往上写 LSB 写的是索引号颜色直接跳变。字段偏移长度为什么要在意bfOffBits104像素数据起点直接改字节时要靠它定位biWidth/biHeight18 / 224 / 4高度为负表示自上而下存储行序会反过来biBitCount282只有 24 / 32 位适合做 LSB8 位是索引色biCompression3040 表示 BI_RGB 无压缩RLE 压缩的不能按字节平铺处理biSizeImage344部分写入器会填 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] ! bBM: raise ValueError(不是 BMP 文件) return { off_bits: int.from_bytes(head[10:14], little), # 像素区起点 width: int.from_bytes(head[18:22], little, signedTrue), height: int.from_bytes(head[22:26], little, signedTrue), 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 不能按行平铺处理这段只做校验不改任何字节。signedTrue是必须的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 bSTG1 # 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, formatBMP) # 无压缩写出位不会被抹掉 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_lsb1920×1080 容量典型 PSNR肉眼/统计上的表现1三通道约 759 KB51 dB 左右肉眼无感但直方图配对现象明显2三通道约 1.5 MB44 dB 左右平滑渐变区可见轻微噪点3三通道约 2.2 MB37 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 隐写注释扩展块、调色板配对与索引 LSB3.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和2k1是同一对相近色再对索引做 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 bGIFH 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), modeP) out.putpalette(pal.tolist()) out.save(dst, formatGIF, optimizeFalse) # optimizeTrue 会重排调色板索引全乱配对用的是贪心最近邻对每个还没用过的颜色找与它 L1 距离最小的未使用颜色配成一对复杂度 O(n²)n256 时几毫秒就跑完没必要上匈牙利算法。inv是置换的逆用来把原始索引映射到新顺序上这一步不改颜色只改编号画面完全一致。关键参数是optimizeFalse。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] bGIF87a: raw[:6] bGIF89a # 87a 没有扩展块定义 elif raw[:6] ! bGIF89a: 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(fAC 总数 {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,400AC 总数约 204 万质量因子 85 左右的图usable占比通常在两三成的区间也就是 40 万到 60 万 bit折合 50 到 75 KB。质量因子usable占比经验区间典型容量1920×10809535%~45%90~115 KB8525%~35%65~90 KB7515%~25%40~65 KB60 以下10% 以下25 KB 以内不建议4.3 JSteg 与 F5改哪类系数、容量怎么估JSteg 是最容易实现的版本按扫描顺序取|c| 1的 AC 系数把|c|的最本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →