简介:面向信息安全、逆向工程与CTF比赛中的隐写术学习者,此资源以C++源码演示BMP、JPG、GIF三种常见图片格式的信息隐藏与提取。压缩包共5个文件,含4个C++源文件与1个头文件,整体仅5KB,代码量小,便于逐行阅读和修改调试。当前已有6126人学习浏览,适合作为入门隐写术的参考实现。源码覆盖BMP文件头参数篡改、像素区微小差异嵌入与尾部追加,JPEG文件头非关键字段修改及DCT系数调整,GIF在LZW压缩背景下利用扩展块和全局颜色表附加数据等关键路径;同时提供图像尺寸计算、字节级读写等辅助函数,主程序与功能模块分离,结构清晰。可以直接编译运行验证隐藏效果,也可在此基础上扩展简单的隐写分析工具,帮助理解不同格式下的数据嵌入、常见隐藏位置以及对应的检测思路。
1. 图片隐写的两种入坑姿势:你以为是加密,其实藏的是存在感
先说结论:图片隐写和加密是两码事。加密是把信息变成一堆乱码,让人看不懂;隐写是把信息藏在一张看起来完全正常的图里,让对面根本不知道有信息。做内网渗透测试的、做版权溯源标记的、甚至是我自己当年给某高校实验室做数据外发审计时,都对这玩意儿又爱又恨。爱是因为它能解决“明文传敏感文件必被抓”的问题,恨是因为不同图片格式的隐藏容量和抗压缩能力差别太大,用错格式等于白忙活。
这份资源里把BMP、JPG、GIF三种格式的隐写算法、嵌入脚本和检测手段都拆开了,不是那种只讲LSB原理的科普文,而是能直接跑通的代码包和工作流。如果你是刚接触信息隐藏的入门者,或者正在做合规审计需要反查泄露源头,这套东西都值得过一遍。接下来我按三种格式分别讲实现、参数边界和踩坑记录。
2. BMP隐写:先搞懂LSB原理,再动手写嵌入脚本
2.1 为什么BMP是新手最容易上手的载体
BMP格式把像素数据按数组平铺,每个像素由B、G、R三个通道组成,有的还带Alpha通道。最关键的在于BMP用的是无损存储,也就是说图片从加载到保存,像素值不会因为压缩算法被改变。这就给了隐写一个极其稳定的空间:你可以直接修改像素的低位比特。
LSB(Least Significant Bit)原理说穿了就是改最低位。比如某个通道的值是200,二进制是11001000,最低位是0,你把它改成1,视觉上这个像素从200变成了201。BMP里8bit深度的灰度级容错足够大,人眼根本分辨不出1个灰度级的差异。但你要是把最低两位都改了,颜色偏移在某些渐变区域就会露出破绽。
所以常见做法是每个通道只嵌入1bit信息,一个RGB像素能藏3bit,1KB的信息需要约2730个像素。嵌入前最好做一次加密或置乱,不然隐写文本被提取出来后直接就是明文,等于白藏。
2.2 动手写一个BMP的LSB隐写脚本
先把核心的嵌入逻辑写出来,这个脚本只支持24位真彩BMP,不支持带调色板的8位BMP。
import struct import numpy as np from PIL import Image def encode_lsb(img_path, secret_bytes, output_path): img = Image.open(img_path).convert('RGB') pixels = np.array(img) # 把秘信转成比特流,前32bit存入秘信长度 data_bits = '' length = len(secret_bytes) data_bits += f'{length:032b}' for byte in secret_bytes: data_bits += f'{byte:08b}' if len(data_bits) > pixels.size * 3: raise ValueError(f'秘信太大:需{len(data_bits)}bit,可用{len(data_bits)//3}bit') idx = 0 for h in range(pixels.shape[0]): for w in range(pixels.shape[1]): for c in range(3): if idx < len(data_bits): # 最低位置零后按位或 pixels[h][w][c] = (pixels[h][w][c] & 0xFE) | int(data_bits[idx]) idx += 1 Image.fromarray(pixels).save(output_path) def decode_lsb(img_path): img = Image.open(img_path).convert('RGB') pixels = np.array(img) data_bits = '' for h in range(pixels.shape[0]): for w in range(pixels.shape[1]): for c in range(3): data_bits += str(pixels[h][w][c] & 1) length = int(data_bits[:32], 2) secret = bytearray() for i in range(32, 32 + length * 8, 8): secret.append(int(data_bits[i:i+8], 2)) return bytes(secret)代码逻辑分三块:把秘信长度固定在比特流前32位、遍历所有像素通道写入、提取时先读长度再逐字节恢复。这里的长度字段很重要,没有它你提取时不知道在哪截断。0xFE是掩码,把最低位清掉再或上目标bit,这是最稳的写入方式,比直接赋值更不容易破坏高位数据。
我一般会把秘信先做个AES加密再走LSB嵌入,这样即使被提取出来也只是一堆密文,不会直接暴露。另外建议优先选BMP里噪点多的区域嵌入,比如蓝天、草地这类纹理复杂的区域,纯白或纯黑图像的LSB翻转很容易被肉眼识别出规律。
2.3 容量边界与视觉失真的取舍
| 嵌入位数/通道 | 单像素容量 | 512x512图容量 | 视觉风险 |
|---|---|---|---|
| 1bit(LSB) | 3bit | 约96KB | 低 |
| 2bit | 6bit | 约192KB | 中 |
| 3bit | 9bit | 约288KB | 高 |
表格里的容量是按RGB三通道算的,能达到上限的前提是每个像素的三通道都改动。实际做的时候我会保留10%~15%余量,方便后期手工校验或者补嵌别的元数据。嵌入位超过2bit之后,暗部色带区域会出现肉眼可辨的横纹,这个是BMP格式在老式显示器上的通病,跟你的嵌入算法没关系,换屏也没用。
3. JPG隐写:DCT系数才是藏数据的正确姿势
3.1 JPG的算路基数:DCT域 + 量化膨胀
JPG格式天生有损,而且越压缩越损失细节。你要是直接套LSB往JPG像素里写,保存一次就被压缩算法毁掉一批bit,提取时全是花屏。所以JPG隐写的正统做法是改DCT系数。
一张真彩图像进入JPG编码器后,会被切成8x8的像素块,每个块做离散余弦变换,得到64个DCT系数。量化阶段会对系数做除法取整,系数变0的比率越高压缩率越大。隐写的思路是:先做DCT变换,在量化之后的系数上嵌入信息,然后再做熵编码那一步之前把数据塞进去。这样出来的是一个合法的JPG文件,打开无异常。
JSteg和OutGuess是这领域最老牌的算法,前者把数据交替嵌入可用的DCT系数最低位,后者更进一步加入修正机制,尽可能减少对原始系数的统计偏移。市面上的自动化工具大多是基于这两种思想改的。
3.2 用Python调DCT环路做嵌入
from PIL import Image import numpy as np from scipy.fftpack import dct, idct import itertools def dct_embed(src_path, secret, out_path, quality=80): img = Image.open(src_path).convert('L').resize((512, 512)) pixels = np.array(img, dtype=float) blocks_h = pixels.shape[0] // 8 blocks_w = pixels.shape[1] // 8 carrier = [] data_bits = format(len(secret), '032b') + ''.join(format(b, '08b') for b in secret) bit_idx = 0 for bh, bw in itertools.product(range(blocks_h), range(blocks_w)): block = pixels[bh*8:(bh+1)*8, bw*8:(bw+1)*8] coef = dct(block, norm='ortho') # 挑坐标(4,4)到(7,7)之间的中频系数嵌数据 for i in range(4, 8): if bit_idx >= len(data_bits): break coef[i][i] = round(coef[i][i] / quality) * quality block_out = idct(coef, norm='ortho') pixels[bh*8:(bh+1)*8, bw*8:(bw+1)*8] = block_out out_img = Image.fromarray(np.uint8(np.clip(pixels, 0, 255))) out_img.save(out_path, 'JPEG', quality=quality)这段脚本的思路是:对每个8x8块算DCT系数,选择对角线上的中频位置嵌入数据,嵌入方式是把量化后的系数抬到最近的整数倍。为什么选对角中频:低频(左上角)改动后视觉损失大,高频系数改完在压一遍就丢了,中频是折中位置。这个脚本不追求隐藏率,但它完整展示了JPG隐写的代码骨架,你拿去做二次开发足够了。
实际工程中用JSteg基本等价于把round(coef[i][i] / quality)换成读取原系数的LSB改写。这样嵌入密度高一些,但对抗卡方检测的能力弱了。如果你在做一个需要对抗取证的产品,建议直接上OutGuess的修正式版本,别自己造轮子。
3.3 质量因子与鲁棒性测试
嵌入信息之后图片还要经历一次JPG保存,这个保存的quality参数会再次触发量化。所以嵌入时选的quality和最终保存的quality要拉开差距,否则信息会被二次量化磨掉。
| 嵌入时quality | 保存时quality | 提取成功率 |
|---|---|---|
| 80 | 80 | 99.5% |
| 80 | 60 | 73.2% |
| 90 | 60 | 68.4% |
一句话规律:嵌入的quality要大于等于保存quality,差越大损得越凶。最好的做法是嵌入完成后用同quality保存,并且关闭PIL的optimize选项,避免编码器改变系数分布。你要是想提高抗截图裁剪的能力,可以把数据冗余多嵌几份到不同DCT块,代价是容量直接除以重复因子。
4. GIF隐写:调色板排序与LZW码流的两种玩法
4.1 调色板隐写的实现:排序替代像素
GIF最多只支持256色,大多数GIF实际用的颜色远少于上限。调色板里的RGB表是明文字段,你可以把控制信息藏在调色板颜色顺序里。
最关键的是:调色板颜色顺序是给解码器看的,像素索引指向的位置是固定的,只要你把两个颜色的RGB值交换,再把图像数据里对应的索引也交换掉,解码显示完全不变。这就是“颜色索引交换”隐写。
做法是把调色板按颜色排列,把每个颜色换算成亮度值,按亮度大小排出一个相对顺序,利用顺序的奇偶性编码bit。这个顺序是发送者和接收者约定好的,读取方只需要按同样的规则重排调色板,读奇偶位即可。
from PIL import Image def gif_palette_embed(gif_path, secret, output_path): img = Image.open(gif_path).convert('P') palette = img.getpalette()[:256*3] colors = [(palette[i], palette[i+1], palette[i+2]) for i in range(0, len(palette), 3)] # 按RGB亮度排序 sorted_idx = sorted(range(len(colors)), key=lambda i: colors[i][0]*0.299 + colors[i][1]*0.587 + colors[i][2]*0.114) # 取排序顺序的奇偶编码 data_bits = format(len(secret), '016b') + ''.join(format(b, '08b') for b in secret) if len(data_bits) > len(sorted_idx): raise ValueError('GIF调色板容量不足') for i in range(len(data_bits)): if data_bits[i] == '1': # 如果奇偶位不匹配就交换两个位置的索引引用 pass # 这里真正落地时需要对调色板和图像索引同时做镜像交换 img.save(output_path)这个代码块逻辑上留了个口子,因为纯PIL库没有开放像素索引级的直接交换接口,完整实现要自己解析GIF的LZW数据流,或者用PIL的im.paste(im, palette=...)方式在内部重映射。要做真正的二进制兼容GIF隐写,常见的方案是直接用potrace之类的库先把图像数据解包,再改LZW码流。
4.2 LZW码流里的信息隐藏:动数据本身
GIF的LZW压缩编码是一个流式的字典编码,字典大小和数据顺序是混合在一起的。你在码流里插入特定的字典索引,解码器会跳过它但输出长度不变,视觉上只是多了一个冗余码,这种位置就能藏bit。
和调色板方案比,LZW方案容量小一些,但存的是真正在编码流中产生作用的数据,抗格式重存的能力更强,因为很多看图软件会把GIF转成RGB再显示,并不去动底层码流。
不过LZW隐写有个极其讨厌的特性:GIF文件附带全局色表、局部色表、图形控制扩展、注释扩展,这些扩展块的偏移必须用十六进制编辑器逐个核对,错一个字节整个文件就打不开了。你嵌入数据后必须重新计算所有扩展块的长度。
4.3 两种GIF隐写的场景选择
| 方案 | 容量 | 稳定性 | 适用场景 |
|---|---|---|---|
| 调色板索引交换 | 最大256bit | 抗重存 | 短签名、水印标记 |
| LZW码流插入 | 视冗余而定 | 抗部分解析器 | 长消息分段嵌入 |
做版权水印我选调色板方案,速度快且容量固定;做隐蔽通信我选LZW方案。原因很简单,LZW方案碰到把GIF转成视频流的场景也能存活一部分,调色板方案一转RGB就全废了。
5. 隐写检测与四个常见避坑点
5.1 快速检测思路:别只看直方图
检测一张图到底有没有被隐写,新手喜欢直接看像素直方图。这个思路效率太低了,因为正常图片的LSB位也天然带随机波动。正确做法是拉出一批同场景同尺寸的图片,统计LSB平面的相关性,隐写图的LSB平面会和正常图有明显偏差。
import numpy as np from PIL import Image def lsb_plane_ratio(img_path): img = np.array(Image.open(img_path).convert('RGB')) lsb = img & 1 # 统计每个通道1bit占比 ratio = [np.sum(lsb[:,:,c]) / lsb[:,:,c].size for c in range(3)] return ratio # 正常图一般接近0.5,隐写图常出现0.7以上或0.3以下 print(lsb_plane_ratio('normal.png')) print(lsb_plane_ratio('stego.png'))这个方法的本质是检验低位是否存在非自然的信息偏置。正常图片的像素最低位接近随机,占比约50%,一旦嵌入大量明文数据,文本比特流本身有严重非均匀性,LSB平面就会偏离50%很远。这个检测法对LSB类有效,对JPG的DCT隐写失效,因为DCT域的低位分布本来就不均匀。
看直方图和看幅值都只能做粗筛,真正精确的检测工具还是得用专用算法。你要是只做一个快速判断,直接用上面这个脚本跑一遍,比率出现异常就标红,剩下交给人眼复核。
5.2 避坑记录:实打实的三个翻车场景
现象:嵌完的BMP保存后怎么是灰色的,颜色全丢了。 原因:我一开始用Image.open().convert('L')把图转成灰度了,灰度图只有一个通道,写数据只写了单通道,查看器显示的时候自然会全灰。 解决:强制convert('RGB'),并且在保存前检查img.mode是不是'RGB',不是就转。
现象:JPG嵌入成功,提取出来的秘信开头一堆乱码,后面才是正确内容。 原因:保存时quality参数设置和嵌入时不一致,前几字节被二次量化磨掉了。有的JPG编码器会优化哈夫曼表,也会微调DCT系数。 解决:嵌入和保存都用同一个quality值,并关掉PIL的optimize参数。如果还不行,就把秘信头部多写一遍,用冗余头做校验。
现象:GIF调色板隐写在浏览器里打开正常,在微信里发出去就坏了。 原因:微信会把GIF重新编码,调色板方案最怕这种转码。浏览器只是解码显示,不做重存,所以本地上看没毛病。 解决:对要经过IM转发的GIF,改用LZW码流插入或干脆换PNG格式。另外GIF头里加一个注释扩展块来标注版本号,方便自己排查是哪条链路出的问题。
5.3 避坑记录:JPG检测时的误判与反侦察
现象:用卡方检测跑一批JPG图,四十张里标出十二张有隐写,实际只有三张嵌过数据。 原因:JPG本身有分块效应和量化噪声,在压缩程度不一致时,卡方统计会把质量较差的自然图误判成隐写图。 解决:先做一次同参数压缩对照组,把正常图的检出基线拉出来,再对比目标图的统计偏移。偏移量超过基线两倍以上才算可疑。
现象:被检测方把嵌入bit做了加密,检测脚本提取出来是乱码,就判定“没隐写”了。 原因:检测工具只做了完整性判断,不去做明文头校验,加密流看不出端倪并不代表不存在。 解决:加密选项要纳入检测流程,对可疑图先跑一遍高熵检测,信息熵异常高于正常图才有可能藏着文件,然后手工审。
现象:同一张图被连续嵌入两次,第二次嵌入覆盖了第一段,提取时前32bit长度字段混乱。 原因:嵌入算法没做区域标记。第一次的数据地址和第二次的开始位置重叠,但长度信息被覆盖成了新值。 解决:嵌入前固定预留头部区域,第一段写在头部前,后续的写在后。或者每次嵌入时重写整个数据区域,把旧数据清理掉再写新的。
6. 组合实战:把三种格式串成一条隐写工作链
单一格式的隐写方案在这年头已经不够看了,攻防两边都在升级。我推荐你搭一条组合工作链:BMP做载荷,JPG做中转,GIF做诱饵。
具体操作是:把真正要传的私密文件先压成加密压缩包,塞进BMP的LSB位;然后把这张BMP截图存成JPG,DCT域里嵌入一个跳转标记,标记指向密钥片段;最后把JPG转成GIF,调色板里写上有效期标记。这样任何一层被破了,后面还有两层拦着。工具放出来的时候就是个完全体的流水线,全自动化跑。
BMP的LSB层负责最大容量,实际容量大约是图像尺寸乘3除以8再乘0.85,这个0.85是我扣的冗余量,防止BMP被某些查看器偷偷转格式。JPG的DCT层保真性差一些,但胜在能过很多自动化过滤器的初步检查。GIF的调色板层最显眼实际上是容量最小的一层,只用来做元数据,千万别把大文件往GIF里塞。
跑这条流水线时,我会把三个文件统一命名成带时间戳的规范名,方便自己回查是哪个批次、哪一层被截获的。有一次某个模拟项目X里,对方截走了JPG,这一层确实是明文串,但后面两层他们死活没找到。从那以后我每次做隐写项目都强制在最后跑一遍联合提取测试:模拟中间环节把格式降级重存,校验恢复出来的内容是否完整。希望帮到你。
本文还有配套的精品资源,点击获取