图片隐写(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())

# 将文本转为 bit 流
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())

# 提取所有 LSB
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)

# 先读取 4 字节长度头
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) # 封面高4bit + 秘密高4bit
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 # 提取低4bit并左移
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
# 将 secret.txt 嵌入 cover.jpg,生成 stego.jpg
steghide embed -cf cover.jpg -ef secret.txt -p password -sf stego.jpg

# 从 stego.jpg 中提取
steghide extract -sf stego.jpg -p password -xf extracted.txt

4.2 zsteg 检测 LSB

1
2
3
4
5
# 全面检测 PNG 中的 LSB 隐写
zsteg -a image.png

# 查看各颜色通道的 bit plane
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
# 检查 JPEG 文件尾后是否有附加数据
xxd image.jpg | tail -20

# 检查 PNG chunk 结构
pngcheck -v image.png

# 查看文件尾边界
hexdump -C image.jpg | grep -E 'ff d9'

六、CTF 中的常见考点

  1. 文件尾附加数据binwalk -e 或直接用十六进制编辑器查看
  2. LSB 隐写zsteg 一键出结果
  3. 图片宽高被篡改:CRC 校验失败,暴力枚举正确宽高
  4. 多帧 GIF:逐帧查看,可能有额外帧
  5. 二维码修复:部分损毁的 QR 码可以恢复
  6. 两张相似图片:像素 XOR 或相减,差异部分可能藏信息
  7. 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 中的宽高
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):
# 用新的宽高计算 CRC
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 解题关注工具覆盖面和脚本能力,实际攻防关注隐蔽性和抗检测能力。