图片隐写(Image Steganography),即把信息隐藏在图片中,使人从视觉上无法察觉。在 CTF 竞赛和实际攻防场景中都有广泛应用。
一、基本原理
图像在计算机中以像素矩阵存储,每个像素由多个通道(如 RGB 三通道,每通道 8 bit)组成。隐写的核心思路是:修改人眼不敏感的数据位来承载信息。
以最简单的方式举例——修改像素值的最低有效位(LSB):
1 2
| 原始像素: 10101100 11010010 01011101 (R=172, G=210, B=93) 隐藏 'A' : 10101100 11010010 01011100 (只改了 B 的最低位, 93→92)
|
人眼几乎无法分辨 93 和 92 的颜色差异,但机器可以读取这个最低位还原出隐藏信息。
二、常见隐写技术
2.1 LSB 替换(Least Significant Bit)
最基础的隐写方法。将秘密信息的每个 bit 替换到像素通道的最低有效位。
原理:8 bit 颜色值中,高位决定颜色主体,最低位的变化对视觉影响极小。一张 1920×1080 的图片,仅用每个像素 R 通道的最低位就能存储约 250KB 数据。
2.2 LSB 匹配(LSB Matching)
LSB 替换的改进版。如果秘密 bit 与载体 bit 不同,不只是翻转最低位,而是随机 +1 或 -1 整个像素值。这样做可以避免 LSB 替换引入的统计特征(偶数值总是减少、奇数值总是增加),对抗直方图分析。
2.3 频域隐写(DCT / DWT)
不直接修改像素值,而是将图像变换到频域(离散余弦变换 / 小波变换),在变换系数中嵌入信息。JPEG 压缩本身就使用 DCT,所以频域隐写对 JPEG 图片有更好的鲁棒性。
JSteg / F5 算法就是经典的 JPEG 频域隐写。
2.4 调色板隐写
对 PNG/GIF 等使用调色板的图片,通过重新排列调色板颜色顺序来编码信息。颜色表有 N 个条目时可以编码 log₂(N!) bit 信息。
2.5 附加数据隐写
直接在文件末尾追加数据。很多文件格式允许在结束标记后附加额外数据而不影响渲染:
- JPEG:在
FF D9(EOI 标记)后附加数据
- PNG:在
IEND chunk 后附加数据
- GIF:在
3B(trailer)后附加数据
三、Python 实现 LSB 隐写
3.1 编码:将文本藏入图片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| from PIL import Image
def lsb_encode(image_path, secret_text, output_path): """ 将 secret_text 编码到图片的 LSB 中 数据格式: [32bit 长度] + [utf-8 编码的文本] """ img = Image.open(image_path).convert('RGB') pixels = list(img.getdata())
text_bytes = secret_text.encode('utf-8') length_bytes = len(text_bytes).to_bytes(4, 'big') data = length_bytes + text_bytes bit_stream = ''.join(f'{byte:08b}' for byte in data)
if len(bit_stream) > len(pixels) * 3: raise ValueError('图片容量不足,需要更大的图片')
new_pixels = [] bit_idx = 0
for r, g, b in pixels: if bit_idx < len(bit_stream): r = (r & 0xFE) | int(bit_stream[bit_idx]) bit_idx += 1 if bit_idx < len(bit_stream): g = (g & 0xFE) | int(bit_stream[bit_idx]) bit_idx += 1 if bit_idx < len(bit_stream): b = (b & 0xFE) | int(bit_stream[bit_idx]) bit_idx += 1 new_pixels.append((r, g, b))
img.putdata(new_pixels) img.save(output_path, 'PNG') print(f'已编码 {len(text_bytes)} 字节 → {output_path}')
lsb_encode('original.png', 'Hello, 隐写!', 'stego.png')
|
3.2 解码:从图片提取隐藏文本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| def lsb_decode(stego_path): """从 LSB 隐写图片中提取隐藏文本""" img = Image.open(stego_path).convert('RGB') pixels = list(img.getdata())
bits = [] for r, g, b in pixels: bits.append(str(r & 1)) bits.append(str(g & 1)) bits.append(str(b & 1))
bit_str = ''.join(bits)
length_bits = bit_str[:32] text_length = int(length_bits, 2)
text_bits = bit_str[32:32 + text_length * 8] text_bytes = bytes( int(text_bits[i:i+8], 2) for i in range(0, len(text_bits), 8) )
return text_bytes.decode('utf-8')
secret = lsb_decode('stego.png') print(f'提取到: {secret}')
|
3.3 编码图片到图片
也可以把一整张图藏到另一张图里。载体图片用高 4 bit 存储自身,低 4 bit 存储隐藏图片:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| def embed_image(cover_path, secret_path, output_path): cover = Image.open(cover_path).convert('RGB') secret = Image.open(secret_path).convert('RGB').resize(cover.size)
cover_pixels = list(cover.getdata()) secret_pixels = list(secret.getdata())
new_pixels = [] for (cr, cg, cb), (sr, sg, sb) in zip(cover_pixels, secret_pixels): nr = (cr & 0xF0) | (sr >> 4) ng = (cg & 0xF0) | (sg >> 4) nb = (cb & 0xF0) | (sb >> 4) new_pixels.append((nr, ng, nb))
img = Image.new('RGB', cover.size) img.putdata(new_pixels) img.save(output_path, 'PNG')
def extract_image(stego_path, output_path): img = Image.open(stego_path).convert('RGB') pixels = list(img.getdata())
new_pixels = [] for r, g, b in pixels: sr = (r & 0x0F) << 4 sg = (g & 0x0F) << 4 sb = (b & 0x0F) << 4 new_pixels.append((sr, sg, sb))
result = Image.new('RGB', img.size) result.putdata(new_pixels) result.save(output_path, 'PNG')
|
四、常见工具
| 工具 |
用途 |
用法 |
| steghide |
JPEG/BMP/WAV 隐写 |
steghide embed -cf cover.jpg -ef secret.txt |
| zsteg |
PNG/BMP LSB 检测 |
zsteg -a stego.png |
| binwalk |
检测文件中附加/嵌入的数据 |
binwalk -e image.jpg |
| foremost |
文件分离提取 |
foremost -i image.jpg -o output/ |
| exiftool |
读取/修改 EXIF 元数据 |
exiftool -Comment="secret" photo.jpg |
| stegsolve |
GUI 图像分析,逐位平面查看 |
图形界面逐通道分析 |
4.1 steghide 示例
1 2 3 4 5
| steghide embed -cf cover.jpg -ef secret.txt -p password -sf stego.jpg
steghide extract -sf stego.jpg -p password -xf extracted.txt
|
4.2 zsteg 检测 LSB
1 2 3 4 5
| zsteg -a image.png
zsteg image.png --all
|
五、检测对抗(Steganalysis)
5.1 视觉检测
最直观的方法——逐位平面查看。把图片每个像素只保留同一个 bit,观察是否有异常纹理。stegsolve 的位平面分析功能就是基于这个原理。
5.2 统计检测
- 卡方检验(Chi-Square Test):检测 LSB 分布是否均匀。LSB 替换会改变值对 (2k, 2k+1) 的分布
- RS 分析:通过 Regular/Singular 分组分析估计嵌入量
- 直方图分析:LSB 替换会在直方图上留下 PoV(Pairs of Values)特征
5.3 元数据与文件结构检查
1 2 3 4 5 6 7 8
| xxd image.jpg | tail -20
pngcheck -v image.png
hexdump -C image.jpg | grep -E 'ff d9'
|
六、CTF 中的常见考点
- 文件尾附加数据:
binwalk -e 或直接用十六进制编辑器查看
- LSB 隐写:
zsteg 一键出结果
- 图片宽高被篡改:CRC 校验失败,暴力枚举正确宽高
- 多帧 GIF:逐帧查看,可能有额外帧
- 二维码修复:部分损毁的 QR 码可以恢复
- 两张相似图片:像素 XOR 或相减,差异部分可能藏信息
- PNG IDAT 块:可能有多余的 IDAT 块或压缩前的原始数据
宽高暴力枚举(PNG)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import struct import zlib
def fix_png_size(filename): with open(filename, 'rb') as f: data = f.read()
ihdr_start = data.find(b'IHDR') width = struct.unpack('>I', data[ihdr_start+4:ihdr_start+8])[0] height = struct.unpack('>I', data[ihdr_start+8:ihdr_start+12])[0]
for w in range(width, width + 2000): for h in range(height, height + 2000): new_ihdr = struct.pack('>IIBBBBB', w, h, *data[ihdr_start+12:ihdr_start+17]) if zlib.crc32(b'IHDR' + new_ihdr) == struct.unpack('>I', data[ihdr_start+17:ihdr_start+21])[0]: return w, h return None
|
七、总结
图片隐写的核心就一句话:利用信息论冗余来承载额外数据。
- 容量大但不可见性差 → LSB 多 bit 替换
- 不可见性好但容量小 → 频域隐写
- 最简单且兼容性最好 → 文件尾附加
实际选型取决于需求——CTF 解题关注工具覆盖面和脚本能力,实际攻防关注隐蔽性和抗检测能力。