资讯动态

图片隐写入门:BMP、JPG、GIF三种格式的隐藏原理与提取实战

发布时间:2026/10/17 6:57:32 来源:尧图企业网站定制
简介面向BMP、JPG、GIF三种图片格式的隐写术学习这套C源码演示了如何在保持图像可读的前提下利用文件头非关键字段、位图数据区微调、JPEG的DCT系数修改以及GIF扩展块等途径嵌入附加信息并提供对应的信息提取思路。资源共5个文件由4个C源文件和1个头文件组成压缩包仅5KB代码轻量且结构清晰适合信息安全初学者、网络攻防爱好者及需要实现图片隐写功能的开发者阅读。压缩包内部按三种图片格式拆分模块便于单独研究某一类型的隐写策略。已有6126人学习下载说明该主题在实践中有较高关注度。通过阅读源码读者不仅可以理解不同图片格式在隐藏容量、隐蔽性与复杂度上的差异还能参考主程序与公共头文件掌握工程组织方式为后续自研隐写分析工具或图像信息隐藏方案打下基础。1. 图片隐写入门BMP、JPG、GIF 三种格式先分清做了几年隐写题最早把我绕进去的正是 BMP、JPG、GIF 这三种最常见的图片隐写载体。很多人拿到图就套 LSB 脚本结果 BMP 能出、JPG 乱码、GIF 闪一下就没——原因不是脚本不行而是三种格式的隐藏原理完全不同BMP 可以心安理得地直接动像素低位JPG 必须去量化 DCT 系数那层找位置GIF 则更爱藏在调色板、帧延时和数据块里。这篇会讲清楚每一种格式的判断依据再给可直接照着跑的提取/嵌入逻辑最后把最容易让新手翻车的位序、重编码、调色板重排问题单独拆开说。想入门信息隐藏或者手上正好有一张待检测的图按这篇的顺序走能少踩不少坑。2. BMP 篇从像素数组到尾部最直观的低位隐写2.1 先看文件头BMP 的哪些区域可以放信息做 BMP 隐写题我习惯先用十六进制把文件结构看一遍而不是直接跑脚本。BMP 结构非常规整文件头 14 字节跟着 DIB 头再往后就是像素数组。它不像 JPG 有复杂的熵编码也不像 GIF 满屏调色板指针所以对新手最友好也最适合拿来做信息隐藏教学的第一个样例。头 14 字节里值得记住的字段就四个字段偏移字节数常见值bfType020x424D字符串 “BM”bfSize24整个文件的字节大小bfReserved64通常为 0bfOffBits10454 或更大指向像素区起点常见 24 位 BMP 的 bfOffBits 等于 54意味着像素数组直接从第 54 字节开始。如果图像是 8 位调色板格式调色板区会跟在 DIB 头后面bfOffBits 一般变成 54 加调色板长度常见就是 5410241078。拿到文件后先核对这些值能避开很多假象。判断一张 BMP 是否被塞过东西我会先做三个小计算一是文件实际字节数是否等于 bfSize二是 bfSize 减去 bfOffBits 得到的像素区字节数是否等于按宽高和位深推出来的理论体积三是每行字节数是否符合 4 字节对齐规则。以 24 位 BMP 为例单行像素字节数需要对齐到 4 的倍数如果宽度是 639 像素理论每行 1917 字节实际会补到 1920 字节多出来的 3 字节就是 padding。只有当 padding 里出现大量非 0 数据时才有理由怀疑是隐写正常位图这部分是干净的 0。BMP 隐藏数据最常用的位置有两个一个是像素数组自身的 LSB适合放较长 payload另一个是文件尾部直接追加数据虽然容易被发现但实现成本最低。下面两节分别给可复现的做法和对应的判断方式。2.2 像素 LSB嵌入脚本与提取脚本LSB 的基本思路很好理解一个像素三个通道 R、G、B各取最低一位正好放 3 bit。写 payload 时可以按“像素内 R→G→B像素从左上到右下”的顺序也可以按“每行先填 R 通道再填 G 通道”的顺序。这两种约定很容易混提取之前必须先把顺序定下来否则就是整段乱码。一个可参考的嵌入脚本如下用 PIL 读图再转成 numpy 数组做逐像素修改# 嵌入脚本把 secret 写入 BMP 像素的最低有效位 import numpy as np from PIL import Image def bit_stream(data: bytes): # 约定每字节低位在前 for b in data: for i in range(8): yield (b i) 1 def embed_bmp(src_path: str, secret: bytes, dst_path: str): im np.array(Image.open(src_path).convert(RGB), dtypenp.uint8) bits bit_stream(secret) h, w, _ im.shape need len(secret) * 8 if need h * w * 3: raise ValueError(载体装不下需要更大的 BMP) idx 0 for n in range(need): p idx // 3 # 按像素推进 c idx % 3 # 通道顺序 R, G, B row p // w col p % w bit next(bits) im[row][col][c] (im[row][col][c] 0xFE) | bit idx 1 Image.fromarray(im).save(dst_path, formatBMP)对应提取脚本要严格按同一个约定读位# 提取脚本需要提前知道 payload 字节数 def extract_bmp(src_path: str, length: int): im np.array(Image.open(src_path).convert(RGB), dtypenp.uint8) h, w, _ im.shape bits [] for p in range(length * 8): row (p // 3) // w col (p // 3) % w c p % 3 bits.append(im[row][col][c] 1) out bytearray() # 低位在前还原字节 for k in range(0, len(bits), 8): byte 0 for j in range(8): byte | bits[k j] j out.append(byte) return bytes(out)代码里的两个关键约定再强调一遍字节内低位先写通道按 R、G、B 递增。如果题目给你的不是这个顺序提取会有两种典型现象字母整体倒着排比如galf{开头或字符内部 bit 翻转肉眼看到像乱码但细看每个字节的值和 ASCII 差 1、2、4、8。遇到这种情况先考虑调整通道序和位序而不是急着换工具。不知道 payload 长度时一般先提取 32 字节看开头常见题目要么直接用 ASCII 明文要么开头先放一个 4 字节长度。如果是纯二进制就按固定头部特征去搜。还要注意一点BMP 8 位索引色模式不能直接套这个脚本因为像素数组存的是调色板索引而不是 RGB 值convert(RGB)会把索引映射成真彩色提取时看到的已经是显示色信息早就丢了。遇到 8 位 BMP 要先读调色板再做索引层处理。2.3 尾部追加与错位偏移能一眼发现的藏法除了像素 LSBBMP 最简单粗暴的信息隐藏方式是直接往文件尾追加数据。图像显示不受影响文件大小变大提取只要读最后一段。这类题对新手最友好因为不需要任何算法但也最容易因为过度分析而错过。判断方法看一眼文件大小就够了。用下面这段小逻辑核对头部声明与实际大小file stego.bmp xxd -l 16 stego.bmp python3 - PY import struct with open(stego.bmp,rb) as f: data f.read() bfSize struct.unpack(I, data[2:6])[0] bfOffBits struct.unpack(I, data[10:14])[0] print(实际大小, len(data)) print(头部声明, bfSize) print(像素偏移, bfOffBits) PY如果实际大小明显大于头部声明的 bfSize或 bfSize 与理论像素区体积差出一截先别急着解 LSB直接用strings扫一遍尾部再xxd看末尾几百字节。很多题其实是把字符串、压缩包甚至第二张图直接拼在 BMP 末尾。还有一种比较少见的错位玩法修改 bfOffBits让它指向文件中间某个位置图像打开后可能正常但让按偏移读取数据的分析方式失效。遇到头部字段值和图像尺寸明显对不上时可以按“bfOffBits 从 54 开始逐字节试偏移”的方式做爆破每次偏移 1 字节重读一次观察能否得到可读字符。这种题不常见但理解它之后你会对 BMP 的“黑匣子”结构再少一层疑虑。3. JPG 篇在量化 DCT 系数上找位置3.1 JPG 为什么不让你直接改像素BMP 的 LSB 思路搬到 JPG 上基本是必翻车。JPG 保存时走的是“RGB→YCbCr→下采样→8×8分块→DCT→量化→Huffman 编码”这条流水线像素区从始至终不存在“原始裸像素”的概念。你直接改像素最低位再存一次 JPG量化过程会把这些改动当成高频噪声抹掉一部分结果提不出来图像还多了肉眼可见的块状噪声。所以 JPG 隐写的正确思路是去量化后的 DCT 系数里找位置。每个 8×8 块做完 DCT再除以量化表得到一堆整数系数其中 AC 系数大量集中在 0、±1 附近。把 payload 写进这些系数的最低位人眼几乎感知不到因为量化步长本来就留下了一定误差阈值。常见做法分三类JSteg 直接在量化 AC 系数 LSB 替换F5 用矩阵嵌入减少修改次数OutGuess 嵌入后再修正系数直方图让统计特征更接近原始图。判断依据也因此不同。JSteg 类隐写会让 AC 系数 LSB 出现非随机分布0、±1 之外的位置有规律F5 类隐写则更容易在系数直方图上看出偏差。所以碰到要选手法的 JPG 题我会先做两层判断第一层看有没有尾区和 marker 里的注释文本第二层才看 DCT 系数的统计异常。3.2 JSteg 的核心替换逻辑一个可复现的教学脚本下面这个代码块演示 JSteg 的核心替换逻辑不是完整工具重点在展示“哪些系数能改、哪些必须跳过”。实际使用时还需要直接访问 JPEG 的熵编码位流因为 OpenCV 解码出来的是像素重建结果拿不到量化系数这里只做系数层面的教学推演# 依赖 opencv-python、numpy展示 JSteg 风格的系数替换思路 import numpy as np import cv2 def jsteg_like_embed(y_channel, q_table, secret_bits): h, w y_channel.shape idx 0 for y0 in range(0, h - 7, 8): for x0 in range(0, w - 7, 8): block y_channel[y0:y08, x0:x08].astype(np.float64) - 128.0 dct np.round(cv2.dct(block)) qd np.round(dct / q_table) for i in range(8): for j in range(8): if i 0 and j 0: # DC 系数不载数据 continue v int(qd[i, j]) if v 0 or abs(v) 1: # 0 和 ±1 跳过 continue if idx len(secret_bits): return # 只动最低位正负数都适用 qd[i, j] v - (v 1) int(secret_bits[idx]) idx 1 # 反量化并反变换重新得到亮度块 y_channel[y0:y08, x0:x08] np.clip( cv2.idct(qd * q_table) 128.0, 0, 255 ) return y_channel跳过 0 和 ±1 的原因要讲透值为 0 的系数在熵编码里对应最长的连续零游程一旦改成 1文件体积会明显变化且解码端可能对 Huffman 码表产生不可预期行为±1 改成 0 或 2视觉虽然能接受但同样会打破原有游程结构。真正安全的替换对象是绝对值大于 1 的那些低频和中频系数它们数量够、修改后对图像质量影响小。提取时的核心逻辑按同样的跳过规则收集系数最低位def collect_jsteg_bits(qd): bits [] for i in range(8): for j in range(8): if i 0 and j 0: continue v int(qd[i, j]) if v 0 or abs(v) 1: continue bits.append(v 1) return bits这里要补充一个重要提醒真实 JSteg 提取时AC 系数是按 zigzag 扫描顺序流化的所以我上面的代码只适合演示系数层面的“改动规则”。实操中要么自己解析 JPEG 的 Huffman 段拿量化系数要么用成熟的 jsteg 提取工具按同样的跳过原则去扫。很多同学用 OpenCV 提取后得到全乱码原因就在这里解码路径不对拿到的根本不是嵌入时改的那层数据。3.3 面对一张 JPG 时的检查顺序与辅助手段拿到可疑 JPG我首先不是跑隐写工具而是先做三类快速排查。第一类看尾区和注释区用strings扫一遍再看 JPEG marker 里的 APP0、APP1、COM 段有没有多余文本很多题会把 payload 直接放在注释段比任何隐写算法都省事。第二类截尾后的体积变化把 JPG 重新转成 PNG再转回 JPG对比前后文件大小和图像 diff如果差异集中于某些固定 8×8 块说明 DCT 系数层可能被动过手脚。第三类看像素最低位平面的粗糙程度用看图工具逐通道看最低位时出现明显块状或条纹信号通常值得怀疑。手法载体位置可检测特征上手难度JSteg量化 AC 系数 LSBAC 系数 LSB 分布不均匀中F5矩阵编码后系数系数直方图与干净图偏差大高注释段APP1/COM markerstrings 直接可见低尾部附加文件末尾binwalk/xxd 一眼发现低辅助工具方面stegsolve 这类工具适合快速看通道位平面但对 JPG 的作用有限因为压缩后的最低位平面本来就带噪声。真正有效的还是回到系数层做统计分析取出每个 8×8 块的量化 DCT 系数看 0、±1、±2 的分布是否符合自然图规律。自然图里这组数通常呈中心集中且左右近似对称如果出现一侧明显偏移或大量系数被固定成奇偶交替那基本可以确定有 JSteg 类隐写。4. GIF 篇调色板、帧延时与数据块4.1 GIF 的容器结构索引色决定了玩法GIF 和 BMP、JPG 最大的不同是它不直接存像素颜色而是存“调色板索引”。文件头 6 字节是GIF87a或GIF89a随后是逻辑屏幕描述符全局调色板就跟着这段描述符走。图像数据区的每个像素值 0~255 只是调色板下标真正显示成什么颜色取决于调色板表里对应位置的 RGB 三元组。这个结构带出一个重要结论如果直接改像素索引值图像颜色会整体错乱但如果只改调色板表里某个 RGB 分量的最低位视觉效果几乎不变却能稳定承载数据。所以 GIF 信息隐藏最常见的位置就是调色板其次是图形控制扩展块里的帧延时再往后才是文件尾部追加。做题时先分清是静态 GIF 还是多帧 GIF多帧题里每一帧还可能带独立的局部调色板分析时要逐帧处理。判断 GIF 是否被处理过可以先看全局调色板大小是否等于 2 的幂。标准 GIF 的全局调色板大小由逻辑屏幕描述符里的 color table size 字段决定只能是 2、4、8、16、32、64、128、256。如果读出来是 12、80 这种数要么文件损坏要么调色板字段被动过。另一个关注点是帧数和每帧尺寸多帧 GIF 中突然出现一帧尺寸明显不同那帧往往就是隐写载体。4.2 调色板隐写的嵌入与提取调色板隐写的原理很简单调色板里每个颜色占 R、G、B 三个字节把 payload 按比特写到这些字节的最低有效位。由于调色板整体只影响“对应索引的颜色”而且人眼对个别颜色分量最低位的差异不敏感只要不把整个调色板顺序打乱视觉上基本看不出来。提取时用 PIL 逐帧拿调色板最方便from PIL import Image def gif_palette_extract(path: str): im Image.open(path) bits [] limit 64 # 先按 64 字节长度扫描 for frame_idx in range(im.n_frames): im.seek(frame_idx) pal im.getpalette() if pal is None: continue n len(pal) // 3 # 依次取每个颜色分量的最低位 for i in range(min(n, limit * 2 10)): bits.append(pal[i * 3 0] 1) bits.append(pal[i * 3 1] 1) bits.append(pal[i * 3 2] 1) out bytearray() for i in range(0, len(bits) - 7, 8): b 0 for j in range(8): b | bits[i j] j out.append(b) return bytes(out)对应嵌入的思路是反过来把原始调色板读进来按 R、G、B 顺序逐位修改最低位再写回。有一点必须提前预防PIL 保存 GIF 时会对调色板做去重、重排和优化直接save出来极可能改变调色板顺序导致自己刚嵌进去的数据对不上。所以做调色板隐写时最稳的做法是直接用十六进制定位 GIF 文件里的调色板区在原字节流上改那几位而不是靠 PIL 重新编码整张图。多帧 GIF 还要注意局部调色板和全局调色板的关系。某一帧如果带有局部调色板解码时它优先于全局调色板提取也必须落到那一帧自己的调色板上。看到im.seek(frame_idx)后getpalette()的结果和第一帧不同说明这帧用局部表要多做一层比对。局部调色板常常是藏 payload 的高发区因为平时大家只关注全局表。4.3 帧延时、扩展块与尾部数据的拆分思路GIF89a 的图形控制扩展块里有一个字节记录帧延时单位通常是 10ms 或 100ms不同工具写法略有差别。这个值的低位同样能存数据。PIL 读取帧延时非常简单from PIL import Image im Image.open(multi.gif) for i in range(im.n_frames): im.seek(i) d im.info.get(duration, 0) print(i, im.size, d)如果发现某些帧的延时数值与相邻帧差异特别大比如别的帧都是 0、10、20突然冒出一个 127多半是人为写进去的。把延时值转成二进制看最低位连续提取多帧就能拼出数据。另一种常见情况是文本扩展块GIF89a 的0x21 0xFE后面可以跟任意文本内容很多人会把备注、注释甚至压缩包塞进去strings 扫一遍就能发现。最后是 GIF 文件尾部追加数据。GIF 的结束标记是0x3B它后面多出来的任何字节都是非标准内容。用十六进制看到3B之后还有成片数据第一时间用 binwalk 确认是不是嵌入文件再用xxd看尾部偏移。下表总结了 GIF 的几个主要隐藏位点和对应检测手段位置是否改变视觉效果检测手段调色板 RGB 分量最低位基本不变对比相邻帧调色板像素索引值奇偶可能全局变色统计索引出现频次图形控制块帧延时不变逐帧读 duration文本扩展块不变strings/hex 扫描尾部附加不变binwalk/尾部 xxd还有一种比较偏的玩法是把每个颜色索引按奇偶性编码。比如全图必须有大量可统计像素通过调整某个索引出现的次数奇偶来代表 0 或 1。这种玩法不改变任何像素值直方图统计才能定位到适合作为最后再尝试的方向放在调色板和尾部都排查完之后看。5. 三种格式排查手记现象、原因、解法5.1 格式识别与读取时机坑一把 PNG 当成 BMP 做提取现象拿到一张扩展名为 .bmp 的图套用上一章 BMP 的 LSB 脚本提取结果是乱码原图却一切正常。原因文件扩展名和真实内容不一致。有些场合下载后自动改名或故意把 PNG 改成 BMP 让你绕弯路BMP 脚本读到的其实是一段 zlib 压缩流加 IDAT 块。解决动工前先看文件头。file命令和xxd -l 16比扩展名可靠得多。头两字节是42 4D才按 BMP 走89 50开头就是 PNG要去 PNG 的数据块层找 LSB 或尾部数据FF D8开头是 JPG按 DCT 思路排查。从那以后我每次拿新图第一件事就是敲file而不是双击打开看内容。坑二24 位 BMP 被转成 8 位索引色LSB 脚本失灵现象同一个图别人能提出来你提出来全乱且图片看起来颜色数很少像老式 256 色卡通图。原因8 位 BMP 用调色板存索引直接对 RGB 值做最低位提取没意义因为显示颜色已经是映射结果隐写数据可能存在于调色板或索引值本身。解决读取 BMP 头部里的 bpp 字段。24 或 32 位继续用像素 LSB8 位则先转向调色板处理提取思路参考 GIF 的调色板方法。另外注意 BMP 行顺序可能是从下到上存储的负高度表示自上而下不同库读出来的数组方向不一致导致结果横着乱。5.2 位序、调色板与重编码坑三嵌入顺序不匹配整段字节倒转现象提取出的内容偶尔能看出galf{、}galf这类倒序特征或每个字符的 bit 方向反了单看二进制和 ASCII 有规律可循但就是不对。原因写入时“低位在前”提取时按“高位在前”或写入按 R→G→B提取按 B→G→R或写入按行从左到右提取按行从右到左。顺序约定不一致是最常见的隐写翻车点。解决先把嵌入脚本里的约定列成三条通道顺序、像素遍历方向、bit 位序。提取时先嵌一个已知头比如FLAG再按三种组合试。如果AB被提成BA说明 bit 位序反了如果开头字符是倒序字符串说明整体字节序反了。多试一次对调就能定位不要反复加解密浪费时间。坑四GIF 调色板保存后被重排自己嵌的自己解不了现象本地调试时能提取换用 PIL 保存一次后调色板数据全变再提取输出完全乱码。原因Pillow 保存 GIF 时会对调色板做优化和重排同时可能合并相近颜色、去掉冗余索引导致原始调色板字节位置全部变化。解决修改调色板后不要用 PIL 重编码保存。直接在 GIF 的原始字节流里定位调色板区域全局调色板位于逻辑屏幕描述符后局部调色板位于图像描述符后以十六进制方式修改对应字节其他部分原样保留。这样保存后文件结构和嵌入前一致提取时仍能按原偏移读回。5.3 工具使用上的坑坑五Image.save 顺手把尾部和扩展块抹掉现象明明在 BMP 尾部追加了数据保存后再检查尾部干净了隐写内容消失。原因高级图像库保存时会重建整个文件结构非标准的尾部数据、注释块、扩展块如果不被格式支持会在重编码中被丢弃。解决凡是涉及头部字段、尾部数据、调色板修改的隐写都用“复制原文件 十六进制按偏移改字节”的方式处理最后直接写文件不调用图像库的保存函数。BMP 和 GIF 尤其要记住这一点。JPG 如果要保持 DCT 系数不变也要避免反复重压否则前一步的嵌入会在量化时被二次破坏。6. 收尾三格式通用判断顺序与一套能落地的检查流程把三章的思路收拢成一套可重复的检查流程我每次拿到图都会按下面这张表走一遍顺序动作得到的信息1filexxd -l 16真实格式不是扩展名给的答案2stringsbinwalk 尾部 hex是否有明文、附加文件、非标准数据3计算文件大小与理论体积差值BMP 是否尾部追加GIF 是否有多余数据块4逐通道看最低位平面观察统计特征BMP 是否有像素 LSBJPG 是否有系数异常5重编码对比或逐帧读调色板/延时JPG 的 DCT 隐写GIF 的帧级隐写这张顺序表最关键的原则是“先看格式再定手法”。BMP 优先像素 LSB 和尾部JPG 先查 tail 和 marker再看 DCT 系数GIF 先看调色板、扩展块、帧延时最后才去碰索引值和 LZW 层。宁可每个方向只花两分钟做快速验证也不要在同一个脚本上耗掉半小时。还记得我第一次做多帧 GIF 题第一帧调色板干净尾部也没有数据最后才发现 payload 藏在第五帧的局部调色板里而且那一帧延时被改成 137比正常值大了一整圈。从那以后我每次拿到图片类隐写题都强制自己先走一遍“格式确认→尾区扫描→体积核对→位平面观察→帧级拆分”的流程再决定用哪套脚本。这三类格式的隐写核心其实都落在同一个逻辑上任何能承载冗余的字段都可能被拿来藏信息关键是先找到那个字段。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑