[ misc 知识点 ] 图片隐写
2026-08-22 20:05:38

1.图片隐写(决策树版)

用法:拿到图片 → 走”做题顺序” → 命中特征再查对应节。
原百科版备份:_backup_md/pre-rewrite-20260818/1.图片隐写.md

  • 宽高修改 / 结构隐写 / LSB / EXIF / FFT隐写 / JPG隐写 / GIF提取 / 盲水印 / 数字水印 / 汉信码 / 像素处理 / 图片分离 / 光栅图片 / IDAT隐写 / 图片修复 / Arnold / Steghide / Silenteye / Outguess / APNG / EZStego / 二维码 / 马赛克

🏷️ 例题:
| 20251201ISCTF 星髓宝盒/阿利维亚的传说/小蓝鲨的二维码
| 20260124SpringCTF ez_signin(二维码修复)/看图猜flag
| 20251108浙江省赛 RealSignin
| 20251206Mini VN welcome.pdf(汉信码+盲水印)
| NSSCTF刷题 zystego(LSB+DCT+PGP)/ Picture
| LitCTF2026 lit_lsb_base64 / lit_rush_qr
| 例题-图片隐写 0和255 / 网鼎杯-青龙-虚幻2(二维码修复)

做题顺序(默认流程)

  1. 010 看文件尾有没有附加数据;binwalk -eM 分离
  2. exiftool 扫元数据(Comment/Artist 常直接藏 flag)
  3. zsteg / stegsolve 扫 LSB
  4. pngcheck -v 看 chunk 结构(IDAT 数量异常?CRC 对不上?)
  5. 宽高爆破(PNG 有 CRC 校验才能爆;JPG 看 SOF0)
  6. 都没有 → 盲水印 / FFT / 像素级 / 工具隐写(steghide/outguess/silenteye)

png

  • 块结构:IHDR / IDAT / IEND,每块末尾 4 字节 CRC32(注意拼写:49 48 44 52
  • 宽高被改但 CRC 没更新 → 爆破宽高匹配 CRC:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import zlib
import struct

filename = '1.png'
with open(filename, 'rb') as f:
all_b = f.read()
crc32key = int(all_b[29:33].hex(),16)
data = bytearray(all_b[12:29])
n = 4095
for w in range(n):
width = bytearray(struct.pack('>i', w)) # q为8字节,i为4字节,h为2字节
for h in range(n):
height = bytearray(struct.pack('>i', h))
for x in range(4):
data[x+4] = width[x]
data[x+8] = height[x]
crc32result = zlib.crc32(data)
if crc32result == crc32key:
print("宽为:",end="")
print(int(width.hex(),16))
print("高为:",end="")
print(int(height.hex(),16))
f.close()

jpg

  • 基于标记的流式格式,宽高无 CRC 校验
  • 标记表(记住 C0/C4/DB 的区别):
标记 含义
FF D8 SOI
FF E0 APP0(JFIF)
FF E1 APP1(EXIF)
FF C0 SOF0(帧头,宽高在这里)
FF C4 DHT(霍夫曼表)
FF DB DQT(量化表)
FF DA SOS(压缩数据开始)
FF D9 EOI
  • 工具:jpeginfo -c 1.jpg 检查错误;exiftool -v3 1.jpg 看 SOF 位置
  • 坑:FF D8FF D9 之间多余的 FF Ex 段 = 附加数据;ZIP 常直接接在 FF D9 后面

bmp

  • 无 CRC / 校验字段;文件头 14 字节 + 信息头 40 字节,宽高 4 字节小端(84 03 00 00 → 0x384 = 900)
  • 详解见 Misc tools bmp 段

LSB

  • 特征:zsteg/stegsolve 直接出东西;RGBA 图 Alpha 通道只有 254/255(最低位正好编码 0/1)
  • 原理一行:改最低位人眼不可见;R/G/B/A 每个通道都能藏
  • 工具:zsteg / stegsolve / 自写提取脚本(见 20260322polarctfmisc 隐藏的二维码)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# alpha.py:查看 Alpha 值分布是否只有 254/255
from PIL import Image

img = Image.open('leaker.png')
if img.mode != 'RGBA':
img = img.convert('RGBA')

pixels = img.load()
width, height = img.size

alpha_values = set()
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
alpha_values.add(a)

print("所有 Alpha 值:", sorted(alpha_values))
if alpha_values == {254, 255}:
print("✅ 符合 LSB 隐写特征:Alpha 通道仅含 254 和 255!")

盲水印 / 数字水印

  • 特征:给两张”一模一样”的图 → 先像素差/XOR;什么都没有 → 盲水印(频域)
  • 工具:BlindWaterMark(python/java 版);有的题给一张图 + 嵌入参数就能提
  • 例题:例题-图片隐写

EXIF

  • 工具:exiftool image.jpg;010 搜 Exif / JFIF
  • 藏匿点:Comment(最常用)/ Artist / ImageDescription / Make / Model
  • 写入:exiftool -Comment="flag{...}" image.jpg

JPG 隐写工具

工具 特征 用法
steghide JPG/BMP/WAV,体积不变,需要密码 steghide extract -sf file -p pwd;无密码→爆破/看提示
outguess 专攻 JPG outguess -r
stegdetect 检测工具,判断 jsteg/jphide/outguess/f5 stegdetect target.jpg
silenteye 图片+音频,特征=电子噪音/蜂鸣声 GUI

GIF / APNG

  • 逐帧:stegsolve Frame Browser / convert input.gif frame_%02d.png / ezgif split;APNG 用 apngdis
  • 坑:帧间隔时间本身也能编码信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# gif帧提取:python gif_frames.py xxx.gif [out_dir]
from PIL import Image
import os
import sys

gif_path = sys.argv[1]
out_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.splitext(gif_path)[0] + "_frames"
os.makedirs(out_dir, exist_ok=True)

with Image.open(gif_path) as im:
for i in range(im.n_frames):
im.seek(i)
im.convert("RGBA").save(os.path.join(out_dir, f"frame_{i:04d}.png"))
print(f"✅ 已保存 {im.n_frames} 帧到 {out_dir}")

FFT / 频域隐写

  • 特征:图有规律条纹/网格/对称花纹;频谱图(幅度谱)有异常亮点
  • 流程:np.fft.fft2fftshift → 看幅度谱 → 提取
1
2
3
4
5
6
7
8
9
import numpy as np
from PIL import Image

img = Image.open("challenge.png").convert("L")
arr = np.array(img)
fft = np.fft.fft2(arr)
fft_shift = np.fft.fftshift(fft) # 把低频移到中心
magnitude = np.abs(fft_shift)
# 幅度谱上有文字/图案/异常点阵 → 信息就在那里

IDAT 隐写

  • 检查:pngcheck -v target.png;010 看每个 IDAT 块大小
  • 特征:IDAT 数量异常 / 块间有冗余数据 / 插了不参与渲染的数据

图片修复

问题 处理
文件头损坏 补魔数:PNG 89 50 4E 47 0D 0A 1A 0A;JPG FF D8 FF;GIF 47 49 46 38;BMP 42 4D
文件尾缺失 PNG IEND 49 45 4E 44 AE 42 60 82;JPG FF D9;GIF 00 3B
宽高被改 PNG → CRC 爆破;JPG → SOF0 段改宽高
中间嵌文件 binwalk -eM / foremost / 010 搜 50 4B52 61 72 剪切

Arnold 置乱

  • 特征:提示 Arnold / 周期性纹理的马赛克图
  • 公式:x' = (x+y) mod N, y' = (x+2y) mod N(变换有周期,继续变换能复原)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import numpy as np
from PIL import Image

def arnold_transform(img, times=1):
"""对图像做 times 次 Arnold 变换"""
N = img.shape[0]
result = np.zeros_like(img)
for _ in range(times):
for x in range(N):
for y in range(N):
x_new = (x + y) % N
y_new = (x + 2*y) % N
result[x_new, y_new] = img[x, y]
img = result.copy()
return result

img = np.array(Image.open("scrambled.png"))

二维码

  • 残缺/被遮挡 → 补三个定位图案(有容错率);反色/颜色异常 → 反色再扫;多码拼接 → 分离后单独扫
  • 扫码优先手机/微信(pyzbar 对破损码识别差)
  • 深入(结构化拼接/SA Header/掩码)→ 7.二维码;修复 → Qrazybox

马赛克

  • 给了原图+马赛克 → 对比猜测被盖区域
  • 只有马赛克 → 一般不可逆;但棋盘格/同色块替换可以推(同色块=同一值)

光栅图片 / 视觉密码

  • 两张随机噪声图 → 重叠(AND/XOR)出图案:
1
2
3
4
5
6
7
8
from PIL import Image
img1 = Image.open("share1.png").convert("1")
img2 = Image.open("share2.png").convert("1")
result = Image.new("1", img1.size)
for x in range(img1.width):
for y in range(img1.height):
result.putpixel((x, y), img1.getpixel((x, y)) ^ img2.getpixel((x, y)))
result.save("xor_result.png")

图片分离

  • binwalk -eM target.png(基于签名,能识别嵌入格式)
  • foremost target.png(基于文件头尾雕刻,只认内置格式)
  • 手动:010 搜 50 4B(ZIP)/ 52 61 72(RAR),剪切另存

像素处理

  • 特征:zsteg/steghide 都没结果,但图里明显有异色小点
  • 玩法:坐标 (x,y) 映射 / 只取某个通道 / 只取特定颜色像素(如纯红 FF0000)

其他工具

  • EZStego:索引色图(GIF/PNG-8)调色板重排隐写;zsteg 可扫
  • 汉信码:定位图案是散布四角的同心小方块(不是三个角大方块);微信小程序”汉信码”扫
  • OurSecret / DeepSound / OpenPuff / 零宽:见 Misc tools

附:LSB 深入(含调色板 LSB)

← 返回 图片隐写 | 相关工具 Misc tools

🏷️ 例题:
| 20260124SpringCTF 橘师傅切他上路(调色板LSB)
| 20251206Mini VN wav也能像png那样美丽吗(WAV LSB)
| 20260130春秋杯 隐形的守护者

LSB的核心思想是

任何可以被量化为离散数值的载体,其最低有效位都可以被替换来藏信息。

一、本质:LSB到底在操作什么

1
原始值 V → V' = (V & 0xFE) | secret_bit

改动量最大是±1,肉眼无法察觉。这个操作对任何整数型数据都成立

二、图像类LSB变体

1.经典RGB/RGBA像素LSB

2.调色板LSB——易踩坑

为什么调色板LSB不一样

索引色图像(GIF PNG-8)的像素值不是颜色,而是调色板索引号。

数据结构如下

1
2
3
4
5
6
7
8
调色板 (Palette):
Index 0 → (R0, G0, B0)
Index 1 → (R1, G1, B1)
Index 2 → (R2, G2, B2)
...

像素数据:
[2, 0, 1, 3, 0, 2, ...] ← 这些是索引号,不是颜色

隐写可以发生在两个层面:

层面 操作对象 隐写方式 检测难度
调色板颜色值 LSB 调色板中 RGB 分量 修改调色板条目的 R/G/B 最低位 低(调色板通常很小,肉眼可对比)
像素索引值 LSB 像素引用的索引号 修改像素的索引号最低位 高(需要理解索引与颜色的映射关系)

关键陷阱:索引号的 LSB 改变不等于颜色的LSB改变

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from PIL import Image

img = Image.open("challenge.png")
palette = img.getpalette() # 获取调色板 [R0,G0,B0, R1,G1,B1, ...]

# 方法1: 检查调色板颜色值的LSB
for i in range(0, len(palette), 3):
r, g, b = palette[i], palette[i+1], palette[i+2]
print(f"Index {i//3}: R_lsb={r&1}, G_lsb={g&1}, B_lsb={b&1}")

# 方法2: 提取像素索引的LSB
pixels = list(img.getdata())
bits = ''.join(str(p & 1) for p in pixels)
# 然后按8位一组转字符
上一页
2026-08-22 20:05:38
下一页