简介:面向信息安全初学者与图像隐写研究者的MATLAB实现资源,聚焦LSB替换隐写:通过修改图像像素最低有效位完成秘密信息嵌入,并可从载体图中逆向提取。压缩包为zip格式,共5个文件,含LSBmain.m主程序、LSB_en.m嵌入脚本、LSB_de.m提取脚本三个MATLAB源文件,以及Lena.tiff、Baboon.tiff两张标准测试图像,包体仅462KB,适合快速下载与本地复现。资源可完整演示预处理、二进制编码、位替换、保存载体与信息提取的流程;可结合两幅测试图观察LSB嵌入对人眼视觉影响极小,同时了解该方法对压缩、缩放、滤波等操作敏感且易被检测的局限性。目前已有360人浏览学习,对正在准备信息隐藏实验或课程设计、希望掌握基础隐写实现与验证思路的读者尤其适用。无论是初学信息隐藏还是复现课程设计,都能借助这套轻量代码快速跑通嵌入—提取闭环。
1. 先把 LSB 隐写这件事说透
把一段文本藏进一张 1024×1024 的 PNG 图片里,保存后文件大小只有轻微变化,肉眼来回对比也看不出差别,但知道规则的人能逐位把消息原样读回来——这就是 LSB 隐写。LSB 是 Least Significant Bit,最低有效位;LSB 替换是这个家族里最原始也最常被当作出题点的手法:直接拿秘密比特覆盖像素通道的最低一位。LSB 隐写、LSB 替换这几个关键词经常一起出现在 CTF 图片隐写、数字水印和取证对抗领域里,标题里的 lionvpo 通常是题目或工具代号,不影响对解法本身的理解。下面按嵌入原理、可运行代码、参数与抗检测、CTF 实战这条线讲完,新手能照着跑通,熟手也能拿到参数边界和检测套路的参照。
2. LSB 替换的嵌入原理:位平面、通道与直方图
2.1 从 RGB 像素到 8 个位平面
一张 24 位真彩图里,每个像素由 R、G、B 三个通道组成,每个通道用一个字节表示,取值范围 0~255。把字节展开成二进制位,从高到低依次是第 7 位到第 0 位,第 0 位就是最低有效位。图像有 8 个位平面,权重差异极大:第 7 位是最高有效位,决定主颜色基调;往低位走,每个位置贡献的灰度差按 2 的幂递减,第 0 位只贡献 1/255 的亮度差,在普通显示器上根本无法单独分辨。
举例:某个像素通道值是 200,二进制是 11001000;改成 201,即 11001001,只动了最低位。显示亮度变化是 200/255 与 201/255 的区别,人眼基本感知不到。秘密信息的每个二进制位按约定顺序写进这些最低位,嵌入就完成了。载体图片纹理越丰富、边缘越多,这 1 个灰度级的扰动就越难被察觉;反过来,纯色天空、渐变背景这类平滑区域对改动极其敏感,几十个字节都可能留下痕迹。
2.2 用位平面拆图直观理解藏在哪
动手写嵌入代码之前,习惯上先把载体拆成 8 个位平面看一眼,确认最低位是不是真的"像噪声"。下面这段代码把灰度图的 8 个位平面分别存成图片:
from PIL import Image import numpy as np img = Image.open('carrier.png').convert('L') arr = np.array(img) for i in range(8): plane = (arr >> i) & 1 # 取出第 i 位 out = (plane * 255).astype(np.uint8) # 0/1 映射成黑白 Image.fromarray(out).save(f'plane_{i}.png')arr >> i把第 i 位移到最低位,& 1把其他位清零,plane里每个位置只有 0 或 1。保存后观察会发现:plane_7、plane_6 能清晰看出画面轮廓,越往低位平面走轮廓越淡,到 plane_0、plane_1 基本就是雪花噪声。高位位平面规律性极强,篡改会被一眼看穿;低位位平面本来就混乱,改掉几个 bit 不具有视觉显著性。LSB 隐写就是利用这个特性,把秘密信息埋进这套"本就混乱"的最低平面里。
2.3 同一条消息,两种改法:LSB 替换与 LSB 匹配
LSB 替换是最直接的实现:new_val = (val & 0xFE) | msg_bit,最低位被强制覆盖成消息位。LSB 匹配则换了个思路:先读原像素最低位,如果消息位相同就保持原值,不同就执行加减 1。以像素值 200(最低位 0)和消息位 1 为例,替换会固定变成 201;匹配可以在 199 和 201 里随机选一个,也把最低位置为 1,但改动幅度和方向带有随机性。
两种改法嵌入后的最低位都等于消息位,差别体现在统计特征上:嵌入率升高时,替换会产生明显的"值对频率趋同"现象,也就是 (2i, 2i+1) 这两档灰度出现次数越来越接近;匹配因为引入了 ±1 的随机选择,保留了原始直方图的部分不规则性。第 4 章会介绍检测器如何利用这个差异。另外要注意,这个原理只对 PNG、BMP 这类无损格式成立,JPEG 的有损压缩会直接破坏最低位,所以 LSB 题的载体几乎不会是 JPEG。
3. 用 Python 从零实现 LSB 替换的嵌入与提取
3.1 嵌入端:加一个 4 字节长度头再写入
直接把消息比特平铺进像素虽然简单,但提取时不知道消息有多长。常见做法是像网络协议一样加固定长度的头:用 4 字节大端整数记录消息字节数,再接原始数据。完整嵌入脚本如下:
import struct import numpy as np from PIL import Image def embed(carrier: str, out: str, secret: bytes): img = Image.open(carrier).convert('RGB') arr = np.asarray(img) # H x W x 3 # 4 字节长度头 + 消息,逐字节拆成位,高位在前 payload = struct.pack('>I', len(secret)) + secret bits = [] for byte in payload: for p in range(7, -1, -1): bits.append((byte >> p) & 1) flat = arr.reshape(-1) if len(bits) > len(flat): raise ValueError( f'载体容量不足: 需要 {len(bits)} 位, 只有 {len(flat)} 位') for i, bit in enumerate(bits): flat[i] = (flat[i] & 0xFE) | bit # 清最低位,写入消息位 Image.fromarray(flat.reshape(arr.shape)).save(out) # 用法示例 embed('carrier.png', 'stego.png', b'flag{hello_lsb}')每一步的逻辑:struct.pack('>I', len(secret))把消息字节数按大端序打包成 4 字节,提取端必须按同样的字节序解析;range(7, -1, -1)保证一个字节内高位在前,否则还原时拼出来的字节会左右颠倒;flat把 H×W×3 的数组展平成一维,逐位写入天然完成了"逐行逐像素逐通道"的扫描。& 0xFE是清零最低位,| bit写入消息位。
这里有一个容易踩的坑:Image.fromarray对 dtype 敏感,arr必须是 uint8。Pillow 默认读 RGB 图返回的就是 uint8,但如果中途做过归一化、像素值加减或浮点运算,务必补一句astype(np.uint8),否则保存时直接报错或写出错误颜色。
3.2 提取端:先读长度,再按格式取消息
提取是嵌入的逆过程:按同样顺序扫像素,取每个通道最低位,先凑 32 位还原出消息长度,再取后面的字节:
def extract(stego: str) -> bytes: arr = np.asarray(Image.open(stego).convert('RGB')) flat = arr.reshape(-1) bits = [int(v) & 1 for v in flat] def take(n: int): chunk = bits[:n] del bits[:n] out = bytearray() for i in range(0, len(chunk), 8): byte = 0 for b in chunk[i:i+8]: byte = (byte << 1) | b out.append(byte) return bytes(out) length = struct.unpack('>I', take(32))[0] return take(length * 8)int(v) & 1取最低位,del bits[:n]是原地删除,让后面的take每次从剩余头部继续取。这个方法直观,但在工程代码里反复删除列表头部会引入不必要的开销。更稳妥的做法是维护一个偏移指针idx,每次从bits[idx:idx+n]取值再累加idx,避免修改共享状态。上面的写法是为了贴合"先长度后内容"的格式,便于阅读。
如果提取出来是乱码,优先检查三点:是否用了大端序读长度(<I和>I结果不同);字节内位序是否一致(嵌入用高位在前,提取就要高位在前拼接);载体通道顺序是否被工具改动过,比如某些编辑器会把 RGB 转成 BGR 再保存,导致位序错位。
3.3 容量、载体格式与 3 个必调参数
容量计算公式是:可用比特数 = 宽 × 高 × 通道数 × 占用位数。一张 1024×1024 的 PNG,三通道各取 1 位,可嵌入1024×1024×3 = 3,145,728位,约 393KB 文本。但实际使用不能按满算,要扣掉 4 字节长度头,还要考虑第 4 章说的检测阈值。
| 参数 | 说明 | 常用取值 |
|---|---|---|
| 通道选择 | 只改 R/G/B 还是全部通道 | 全部通道容量最大;单用 B 通道人眼更不敏感 |
| 位平面 | 用第 0 位还是低 2 位 | 默认第 0 位;低 2 位容量翻倍,但检测特征明显 |
| 扫描顺序 | 逐行、逐列、Z 字形、随机 | 逐行最简单;随机抗检测更好,需要额外存种子 |
提示:嵌入后可以用 PSNR 粗算一下图片失真,公式是
10 * log10(255^2 / MSE)。LSB 满嵌时 MSE 的理论值接近 1,PSNR 约 48dB,人眼无法区分,但统计检测器仍能看出问题。高的 PSNR 只代表"肉眼安全",不代表"检测安全"。
4. LSB 隐写参数与抗检测:扫描顺序、随机种子与嵌入率
4.1 顺序嵌入的特征:为什么左上角会暴露
逐行扫描意味着嵌入从图片左上角开始,连续写几十 KB 后,前几行像素的最低统计特征和整图其余部分明显不一致。用 Stegsolve 打开红/绿/蓝通道的 LSB 平面,会发现左上区域出现有规律的条纹或明显色块,这在平滑背景的载体上尤其刺眼。避免"开头即暴露"的常见做法是引入随机间隔:用密钥做种,生成像素坐标的伪随机序列,让消息比特分散到整张图。
import random def build_positions(w: int, h: int, channel: int, seed: int) -> list[tuple[int, int, int]]: rng = random.Random(seed) pos = [(x, y, c) for c in range(channel) for y in range(h) for x in range(w)] rng.shuffle(pos) return pos pos = build_positions(1024, 1024, 3, 20240517)random.Random(seed)保证同一把种子在任何机器上生成完全一致的序列,提取端只要知道种子和通道数,就能按相同顺序取位。channel参数决定参与嵌入的通道数:3 表示 RGB 全嵌,1 表示只嵌蓝色通道。这个方案的代价是把"你知道嵌入位置"变成了"你持有那把种子",密钥泄露等于消息泄露,所以种子要当作对称密钥管理。
4.2 嵌入率与直方图对称:检测器在看什么
即便嵌入位置做了随机化,嵌入率过高仍然会在统计规律上暴露。对 LSB 替换来说,最典型的破绽是灰度值成对出现的频率趋于一致。正常照片里,相邻灰度值 (2i, 2i+1) 的像素数量关系是随机的;LSB 替换嵌入率升高后,消息位 0/1 近似等概率,导致每对值中出现较少的一方向另一方向靠拢,两者频率越来越接近。RS 分析和卡方攻击检测的核心就是这个特征。
经验阈值大致是:嵌入率低于 10% 时,常规统计检测在高纹理载体上很难给出确定结论;高于 30% 时,RS 检测基本一抓一个准。所以实战中我一般反着算:先定一个能接受的最大嵌入率,再算消息体大小、挑载体。如果消息确实很大,优先换更高分辨率或更高噪声水平的载体图,而不是硬塞。平滑区域对嵌入率的容忍度显著更低,纯色渐变图里哪怕几百字节,直方图也能看出异常。
4.3 顺序替换、随机替换与 LSB 匹配的选型对比
三者对应不同的威胁模型:如果对手只是肉眼看图和跑 exiftool,顺序替换就够用;如果对手会跑 zsteg 和 RS 检测,就要考虑随机位置或 LSB 匹配。
| 方案 | 嵌入规则 | 提取条件 | 主要局限 |
|---|---|---|---|
| 顺序 LSB 替换 | 强制覆盖最低位 | 起始点、扫描顺序 | 左上角位平面出现条纹,值对频率趋同 |
| 随机 LSB 替换 | 强制覆盖最低位 | 起始点、扫描顺序、随机种子 | 仍有值对频率趋同的统计特征 |
| LSB 匹配 | 最低位不同则 ±1 | 起始点、扫描顺序 | 需要随机源决定 +1 还是 -1,实现略复杂 |
LSB 匹配的价值不在隐蔽性上限更高,而是它不产生 LSB 替换那种"成对值频率对称"的强特征,对卡方检测的抗性更好。但要注意,匹配在像素值为 0 或 255 时只能单向调整,连续的大块纯色区域会留下细微的梯度痕迹。没有万能的方案,只有针对检测方式的取舍。
5. CTF 图片隐写:拿到题先做这三件事
5.1 先跑 file、strings、exiftool,快速排除干扰
CTF 里 LSB 隐写题通常给 PNG 或 BMP。收到附件先做一套三连检测,不要急着拖进画图软件看:
file mystery.png strings -n 8 mystery.png | head -20 exiftool mystery.png | grep -i 'comment\|author\|software'这三条命令能排除一半以上的干扰项:文件头被改(修回来就能打开)、明文 flag 直接躺在 strings 输出里、Exif 注释字段藏线索。如果file显示是 JPEG,LSB 像素思路要立刻停手,转去看 DCT 系数;检索词里常见的 CTF jpeg 隐写题、pdf 隐写题考的不是像素最低位,PDF 的隐写点在对象流、压缩流和字体描述里。
5.2 zsteg 自动扫描与 Stegsolve 眼检互补
对 PNG/BMP,固定流程是先跑 zsteg 全参数扫描,再开 Stegsolve 人工核对:
zsteg -a mystery.pngzsteg 输出里b1,rgb,lsb,xy这类前缀是有效信息:b1表示每通道取 1 位,rgb是通道顺序,lsb是低位平面,xy是扫描方向。如果 zsteg 扫出来的内容读不通,先别急着排除 LSB,试着把位序反着拼字节——不少题目故意把消息做逐字节位反转,位平面上能看到文本形状,但常规工具读出来是乱的。Stegsolve 的价值在于人眼确认:切到 Red/Green/Blue 平面的第 0 位,有规律条纹的区域往往就是嵌入区,这能帮你判断题目到底嵌入在哪个通道、占了几位。
5.3 三个容易漏的 LSB 变体
第一个是低 2 位嵌入:不只用第 0 位,连第 1 位一起占用,容量翻倍。提取时要用flat[i] & 0x03一次还原两位,zsteg 的默认模式不一定覆盖这种情况,需要手动写脚本按两位拼字节。
第二个是单通道嵌入:只改蓝色通道,红色和绿色完全干净。视觉上最隐蔽,但 Stegsolve 切到 Blue plane 0 时规律性会暴露。判断技巧是把三个通道的 LSB 平面并排看,只有其中一块有明显周期性条纹,就是单通道 LSB。
第三个是音频 WAV 的 LSB:原理和图片完全一致,只是载体从像素变成 PCM 采样点。16 位采样里改最低位,听感毫无变化,提取时把采样值展平成一维数组,套用第 3 章的嵌入和提取逻辑即可。处理这类题时先确认文件尾有没有附加数据,binwalk扫一下比直接听快得多。
最后补一个验证技巧:嵌入 LSB 会改变 PNG 位流的压缩特性,文件体积通常会有百分之几的变化。如果一张图文件的体积与打开后看到的图像复杂度明显不匹配,或者文件尾部有多余块,这本身就是被动过手脚的旁证。
本文还有配套的精品资源,点击获取