简介:LSB隐写术的MATLAB实现代码包,面向信息安全初学者与数字图像处理爱好者,用于理解最低有效位替换的基本原理与完整流程。包内含5个文件,包括3个MATLAB脚本和2张TIFF格式测试图像,整体大小约462KB,结构紧凑,便于直接运行和对照学习。目前已有360人学习下载。资源中的脚本覆盖了LSB信息嵌入、提取以及主程序调用等环节,配合Lena、Baboon等经典图像,可直观观察隐写前后视觉效果差异,并通过修改像素最低有效位实现秘密信息的隐藏与还原。该方法利用人眼对最低位变化不敏感的特性,在几乎不改变视觉观感的前提下完成数据嵌入。对于希望入门信息隐藏技术或开展相关课设实验的读者而言,这份代码提供了可复现的基础框架,能帮助快速掌握LSB实现的细节,同时为后续研究更安全的隐写算法打下基础。
1. LSB隐写在视觉不敏感区藏数据:先算清一张Lena图的真实容量
最早接触隐写的人,多半以为要先上频域变换、对抗训练才有意义。实际上信息隐藏的入门基准就是LSB替换——把像素最低位直接换成秘密位,视觉上几乎无感,代码不到四十行。
手头这套以lionvpo为代号的MATLAB实现,用LSBmain.m、LSB_en.m、LSB_de.m配Lena、Baboon两张无损TIFF图,把嵌入、提取、容量到反隐写检测的链路收在一处,适合课程设计、CTF图片隐写题和在工程里快速评估「这张图能藏多少」的人。
但LSB替换的边界很硬:512×512的RGB图满容量只有786432比特,扣掉长度头约98KB文本;中间但凡经过一次JPEG保存,载荷就整体蒸发。
2. 素材拆解:lionvpo文件结构与LSB替换的位平面原理
LSB替换的技术含量不在于那几行位运算,而在于三个前置决策:载体格式、扫描顺序、长度头。这三件事在lionvpo包里都能找到对应,先看原理再对文件,后面复现时才不会改出一堆隐蔽bug。
2.1 位平面权重:为什么最低位改了人眼无感
每个通道在计算机里是8位无符号整数,第8位是最高有效位,权重128;第1位是最低有效位,权重1。权重差异意味着改MSB相当于把像素亮度直接跳一半,而改LSB只是从灰度k走到k±1,换算成占比只有约0.39%。人眼在8bit显示设备上本来就不容易分辨相邻灰度级,何况大多数自然图像里相邻像素本身就存在±1的抖动。
| 位序 | 位权重 | 占256级满量程比例 | 修改后视觉表现 |
|---|---|---|---|
| 第8位 (MSB) | 128 | 50.20% | 亮度近乎翻倍,明显可见 |
| 第7位 | 64 | 25.10% | 大范围色带 |
| 第4位 | 8 | 3.14% | 低对比区域能察觉 |
| 第1位 (LSB) | 1 | 0.39% | 多数场景无法分辨 |
这张表对新手友好,但它也提示了一个容易被忽略的点:视觉无感不等于统计无痕。LSB替换会把自然图像的奇偶分布拉向均匀,这正是后面卡方检测能抓到它的根源。
2.2 lionvpo包里五个文件各自承担的任务
解压后是五个文件,没有复杂工程结构,正好可以当MATLAB函数化拆分的范例:主控脚本负责调度,嵌入和提取拆成两个独立函数,测试图单独存放。lionvpo应该是这套工程的项目代号,不是某个第三方库名,读代码时不用去找对应的toolbox。
| 文件 | 类型 | 职责 | 关键接口 |
|---|---|---|---|
| LSBmain.m | 脚本 | 主控:读图、组装秘密位流、调嵌入/提取 | 直接运行 |
| LSB_en.m | 函数 | LSB替换嵌入 | stego = LSB_en(cover, bits) |
| LSB_de.m | 函数 | LSB提取 | bits = LSB_de(stego, nbits) |
| Lena.tiff | 测试图 | 512×512 RGB标准载体 | uint8,3通道 |
| Baboon.tiff | 测试图 | 纹理丰富,适合对照实验 | uint8,3通道 |
文件名本身是接口信号:_en是encode,_de是decode,main负责把两者串起来。这种「脚本+函数」的拆分方式是我在课程项目里比较认可的做法——算法逻辑留白小,换成其他格式的载体图时只需要改imread一行。
2.3 嵌入前必须定死的三个约定
第一,载体格式必须无损。TIFF、BMP、PNG都可以,JPEG不行:DCT量化过程会重写低比特位,嵌入的载荷在压缩阶段就丢了大半。这也是项目里测试图选.tiff而不是.jpg的原因。
第二,扫描顺序必须一致。MATLAB里cover(:)按列优先展平,而很多教材示意图画的是逐行扫描。只要嵌入和提取用同一套顺序,内部实现是无所谓的;但如果你把图拿到Python里用OpenCV处理,行列顺序和通道顺序都要重新对齐。我一般会在注释里显式写明「列优先/行优先」,避免跨语言时出低级错误。
第三,位流前要预留长度头。提取端拿到载密图时并不知道秘密信息有多长,常规做法是固定用前32位存一个uint32长度,后面才是真正的载荷位。这套约定在LSBmain.m里体现得最明显,下一章会直接落到代码上。
提示:这里说的「约定」本质上就是隐写协议。换一张图、换一个扫描方向、换一种位序,只要双方约定一致,编解码都能成立;约定不一致,提取出来就是乱码。
3. 嵌入端复现:LSB_en.m的位运算实现与容量计算
嵌入端最核心的问题就两个:秘密位怎么组织,位替换怎么写。前者决定提取端能不能还原,后者决定嵌入速度和质量。
3.1 先把秘密文本转成二进制位流
MATLAB里文本是字符数组,要嵌入就得先转成uint8字节再逐位拆开。下面这个函数从字节的最低有效位开始拆,顺序和提取端保持一致:
function bits = text_to_bits(msg) data = uint8(msg); % 中文字符按 UTF-8 编码拆成多字节 nbytes = numel(data); bits = zeros(nbytes * 8, 1); % 预分配,避免循环里动态扩容 for k = 1:nbytes for b = 1:8 bits((k-1)*8 + b) = bitget(data(k), b); % b=1 表示取 LSB end end end逻辑说明:外层循环遍历每个字节,内层用bitget(data(k), b)从该字节的第1位(LSB)到第8位(MSB)逐个取出。选择LSB-first的理由是它和后面LSB替换的位号完全对应,阅读代码时不用来回换算。参数说明:msg是MATLAB字符串或字符数组,支持中文,但中文UTF-8一个汉字占3字节,最终位长会变成3×8×字数,在算容量时要折算进去。如果嫌双循环慢,可以用dec2bin加reshape,不过对于几十KB的文本,双循环的开销可忽略,可读性更重要。
3.2 位替换核心:清零最低位再叠加秘密位
LSB替换的标准实现有两类写法。一类是逐像素bitset,直观但慢;另一类是向量化:先用bitand把整张图的LSB清零,再把秘密位加回去。lionvpo这类课程代码通常用向量化版,处理512×512×3的Lena图只需要几毫秒:
function stego = LSB_en(cover, secret_bits) [h, w, c] = size(cover); % 高、宽、通道数 total = h * w * c; % 可嵌入的最大位数 if numel(secret_bits) > total error('秘密位长度 %d 超过容量 %d', numel(secret_bits), total); end flat = cover(:); % 按 MATLAB 列优先展平成列向量 flat = bitand(flat, uint8(254)); % 254 = 0b11111110,清零 LSB data = zeros(total, 1); % 补齐到整图位长,未用位置 0 data(1:numel(secret_bits)) = secret_bits; stego_flat = flat + uint8(data); % 秘密位叠加,像素最多变化 ±1 stego = reshape(stego_flat, h, w, c); end逻辑说明:bitand(flat, uint8(254))把每个像素通道的最低位置0,其余7位原样保留,这一步是「替换」而不是「叠加」的关键——先用掩膜清掉旧位,再用加法写入新位。data向量前段放秘密位、后段补0,补0部分对应的像素其实没被修改,因为原LSB已经被清掉,再加0等于原值。参数说明:cover要求uint8类型,如果读进来是double,需要先类型转换;secret_bits是0/1列向量,函数里会在加法时自动转uint8。返回值stego与cover同尺寸同类型,可以直接imwrite保存成无损格式。
和逐像素bitset方案相比,向量化的优点是避免MATLAB循环解释器开销;缺点是理解门槛稍高,调试时可以检查stego(idx)与cover(idx)的差值是否只落在0、±1三种取值上。
3.3 容量怎么算:头结构决定净荷
嵌入函数本身只关心位够不够放,但工程上必须留长度头。完整调用是这样:
% LSBmain.m 中的关键流程 cover = imread('Lena.tiff'); bits = text_to_bits('Hello, LSB'); header = de2bi(numel(bits), 32, 'left-msb'); % 32 位长度头 seq = [header(:); bits(:)]; stego = LSB_en(cover, seq);逻辑说明:de2bi把十进制长度转成32位二进制向量,'left-msb'指定高位在前,和提取端的bi2de方向必须一致。如果没有Communications Toolbox,用bitget循环逐位拆也可以,只是代码长一点。参数说明:header固定32位,seq是头部加正文的完整待嵌入位流,顺序就是后续提取端的解析顺序。
Lena.tiff是512×512×3,原始容量512×512×3=786432位,每通道贡献1位。扣除32位长度头,净荷786400位,折合98300字节。按纯ASCII文本算能存约96KB;按UTF-8中文算,一个汉字3字节,约32700字——这个数字对大多数课程设计和CTF题目的flag长度绰绰有余。Baboon.tiff同样是512×512,容量相同,差别在纹理分布,不影响容量计算。
| 计算项 | 数值 | 说明 |
|---|---|---|
| 像素总数 | 262144 | 512×512 |
| 通道数 | 3 | RGB 各贡献 1 bit |
| 总容量 | 786432 bit | 满嵌入 |
| 长度头开销 | 32 bit | uint32 |
| 净载荷 | 786400 bit ≈ 98.3 KB | ASCII 文本 |
注意:实际写入时我一般只用到净容量的90%,给文件尾、换行符和可能的对齐余量留空间。容量计算只是上限,不代表每次真的塞满。
4. 提取端还原:LSB_de.m解码逻辑、PSNR评估与常见排错
提取端写起来比嵌入端短,但坑反而多,因为提取端无法拿载密图反推原始载体,所有还原依据都来自协议本身。
4.1 LSB_de.m的对称提取
提取函数是嵌入的逆过程,读取最低位即可:
function bits = LSB_de(stego, nbits) flat = stego(:); % 与嵌入端同样的展平顺序 lsb = bitand(flat, uint8(1)); % 1 = 0b00000001,取出最低位 bits = double(lsb(1:nbits)); % 截取前 nbits 位 end逻辑说明:bitand(flat, uint8(1))等价于mod(flat,2),但位运算语义更清楚。提取端必须和嵌入端使用完全相同的展平顺序和位序约定,所以LSB_de里没有再传扫描方向参数——约定写死在函数里,比留参数更不容易两边不一致。
拿到长度头后再提取正文:
function msg = LSB_extract_with_header(stego) bits = LSB_de(stego, 32); % 先读长度头 nbits = bi2de(bits', 'left-msb'); % 解析出秘密位长度 payload = LSB_de(stego, 32 + nbits); % 读载荷区 payload = payload(33:end); % 跳过头部 nbytes = ceil(numel(payload) / 8); % 末尾不足8位补零 h = de2bi(payload, 8, 'left-msb'); % 每8位重排成字节 msg = char(bi2de(h)'); end逻辑说明:第一次调用只取32位,解析出nbits后第二次调用一次性读完头部加正文,最后按8位分组还原成字符。参数说明:bi2de的'left-msb'要和嵌入端de2bi方向一致,否则长度值会错乱。这套提取流程不需要知道载体原图,只依赖载密图和协议约定。
4.2 PSNR量化视觉差异:51dB是LSB替换的天花板
「看不看得出来」不能靠感觉。课程设计里最常用的客观指标是PSNR,直接用MSE换算:
function val = psnr_db(a, b) mse = mean((double(a(:)) - double(b(:))).^2); val = 10 * log10(255^2 / mse); end对LSB替换,PSNR有个理论天花板:满嵌入时每个位以50%概率翻转,像素值变化0或±1,MSE期望0.5,对应PSNR≈51.14dB。这个值不随图像内容变化,是LSB替换算法本身的属性。部分嵌入时MSE按比例缩小,PSNR相应升高:
| 嵌入方案 | 等效比例 | 理论MSE | 理论PSNR |
|---|---|---|---|
| 全通道满嵌入 | 100% | 0.50 | 51.14 dB |
| 前50%像素 | 50% | 0.25 | 54.15 dB |
| 仅蓝通道 | 33.3% | 0.167 | 55.91 dB |
实操时如果算出来PSNR显著低于50dB,说明嵌入过程不干净:可能不小心改了高比特位,或者载体和载密图之间发生过格式转换。反过来,PSNR高于55dB也不能证明安全——它只衡量质量损失,不衡量统计特征。
4.3 提取乱码先查这三处
提取出乱码时,问题几乎总在协议不一致,而不是算法错。排错顺序我一般固定是:
- 位序。确认嵌入端bitget和提取端bitand用了同一约定。快速验证办法:嵌入一个已知字节0xAA(二进制10101010),提取后对比,LSB-first和MSB-first会得出完全不同的结果。
- 通道与展平顺序。MATLAB第三维是RGB;如果图在中间被imwrite或其他语言库重新保存过,通道顺序可能变成BGR,提取时位流整体错位。用Baboon.tiff做交叉测试最容易暴露这个问题。
- 格式链路过了一次有损压缩。TIFF保存时如果选了JPEG压缩选项,同样丢LSB。确认全程无压缩:读入用imread,写出用无损设置,或者直接改成PNG中间格式。
注意:乱码排查时不要先怀疑随机数,先做上述三项检查。LSB的嵌入和提取是确定性逆运算,只要协议一致,结果必然完全还原。
5. 反隐写视角:用卡方统计量验证LSB替换痕迹
5.1 为什么替换会暴露:灰度对趋于平衡
自然图像相邻灰度值(2k和2k+1)的像素数通常不平衡,因为真实场景里亮度分布是连续的,落在偶数与奇数灰度上的概率天然有偏差。LSB替换把每个像素的LSB强行写成秘密位,秘密位近似随机0/1,于是每个灰度对(2k,2k+1)的计数会被拉向均值。卡方统计量正是量化这种「过度均匀」的工具:统计量越大,图像越不像自然图。
这里要区分LSB替换和LSB匹配:替换是直接写0/1,像素只发生0或±1的确定性变化;匹配则在保持LSB为所需值的前提下随机±1,奇偶分布同样被拉平,但数值分布更接近自然,卡方统计的检出率会下降。这是两代隐写算法在统计特征上的本质差异。
5.2 一个可跑的卡方检测脚本
function pval = chi2_lsb_detect(img) if ndims(img) == 3 gray = rgb2gray(uint8(img)); % Lena/Baboon 都是 RGB,先转灰度 else gray = uint8(img); end h = imhist(gray, 256); % 256 级灰度直方图 obs = reshape(h, 2, 128); % 每列是 (2k, 2k+1) 计数 total = sum(obs, 1); valid = total > 0; % 没有像素的灰度对不参与统计 expc = total / 2; stat = sum((obs(1, valid) - expc(valid)).^2 ./ expc(valid)); pval = chi2cdf(stat, sum(valid) - 1, 'upper'); end逻辑说明:imhist统计每个灰度级出现次数,reshape成2×128后每一列对应一个灰度对;卡方统计量用观测计数与期望计数(两者均值)的偏差平方除以期望求和。参数说明:返回值是上尾概率,p值越小越拒绝「自然图」假设,一般小于0.05就判定存在LSB替换痕迹。对lionvpo包生成的载密图跑一遍,嵌入前后的Lena会有数量级差异。这个检测的已知边界是:只对按固定顺序铺满全部像素的嵌入敏感,如果嵌入位置由密钥随机化,全局卡方会被稀释,取证场景里通常要做滑动窗口局部统计,再叠加RS分析做交叉确认。
本文还有配套的精品资源,点击获取