BMP图像隐写技术¶
BMP(Bitmap)是一种未压缩的位图格式,由于其简单的文件结构和无损特性,在CTF隐写题中非常常见。BMP文件不进行压缩,所有像素数据都以原始形式存储,这使得隐写操作更加直接和可靠。
BMP文件格式基础¶
文件结构¶
BMP文件由以下几部分组成:
┌─────────────────────────┐
│ BMP文件头 (14字节) │ ← 文件类型、大小等基本信息
├─────────────────────────┤
│ 位图信息头 (40字节) │ ← 图像宽度、高度、位深度等
├─────────────────────────┤
│ 调色板 (可选) │ ← 仅用于8位及以下的图像
├─────────────────────────┤
│ 像素数据 │ ← 实际的图像数据
└─────────────────────────┘
BMP文件头(14字节)¶
位图信息头(40字节)¶
偏移 大小 说明
0x0E 4 信息头大小,通常为40
0x12 4 图像宽度(像素)
0x16 4 图像高度(像素)
0x1A 2 颜色平面数,固定为1
0x1C 2 每像素位数(1, 4, 8, 24, 32)
0x1E 4 压缩方式(0=不压缩)
0x22 4 图像大小(字节,可为0)
0x26 4 水平分辨率(像素/米)
0x2A 4 垂直分辨率(像素/米)
0x2E 4 调色板颜色数
0x32 4 重要颜色数
十六进制特征¶
典型BMP文件头:
42 4D // 'BM' 文件标识
XX XX XX XX // 文件大小
00 00 00 00 // 保留
36 00 00 00 // 像素数据偏移(通常0x36=54字节)
28 00 00 00 // 信息头大小(40字节)
...
像素数据存储¶
BMP像素数据的特点:
- 从下到上存储:第一行像素是图像的最底部
- 行对齐:每行像素数据必须是4字节的倍数,不足部分用0填充
- BGR顺序:24位BMP使用BGR而非RGB(蓝-绿-红)
LSB隐写¶
LSB(Least Significant Bit)隐写 是BMP最常见的隐写方式,通过修改像素的最低有效位来嵌入信息。
原理¶
BMP中每个颜色分量占8位(0-255),修改最低位对视觉影响极小。例如:
原始像素: R=235, G=142, B=78
二进制: R=11101011, G=10001110, B=01001110
嵌入3个bit: 1, 0, 1
修改后: R=11101011, G=10001110, B=01001111
结果: R=235, G=142, B=79
视觉差异: 几乎不可见
单通道LSB¶
只使用一个颜色通道(通常是蓝色通道)的LSB进行隐写。
出题脚本:
from PIL import Image
def encode_lsb_single(image_path, message, output_path, channel=2):
"""
单通道LSB隐写
channel: 0=R, 1=G, 2=B
"""
img = Image.open(image_path)
pixels = list(img.getdata())
# 将消息转换为二进制
binary_message = ''.join(format(ord(c), '08b') for c in message)
binary_message += '1111111111111110' # 结束标志
new_pixels = []
msg_index = 0
for pixel in pixels:
pixel_list = list(pixel)
if msg_index < len(binary_message):
# 修改指定通道的LSB
pixel_list[channel] = (pixel_list[channel] & 0xFE) | int(binary_message[msg_index])
msg_index += 1
new_pixels.append(tuple(pixel_list))
new_img = Image.new(img.mode, img.size)
new_img.putdata(new_pixels)
new_img.save(output_path)
print(f"已嵌入 {len(binary_message)} bits到 {channel} 通道")
# 使用示例
encode_lsb_single('cover.bmp', 'flag{hello_lsb}', 'stego.bmp', channel=2)
多通道LSB¶
使用RGB三个通道的LSB,容量是单通道的3倍。
出题脚本:
def encode_lsb_rgb(image_path, message, output_path):
"""RGB三通道LSB隐写"""
img = Image.open(image_path)
pixels = list(img.getdata())
binary_message = ''.join(format(ord(c), '08b') for c in message)
binary_message += '1111111111111110' # 结束标志
new_pixels = []
msg_index = 0
for pixel in pixels:
new_pixel = list(pixel)
# 依次修改R、G、B三个通道
for i in range(3):
if msg_index < len(binary_message):
new_pixel[i] = (new_pixel[i] & 0xFE) | int(binary_message[msg_index])
msg_index += 1
new_pixels.append(tuple(new_pixel))
new_img = Image.new(img.mode, img.size)
new_img.putdata(new_pixels)
new_img.save(output_path)
encode_lsb_rgb('cover.bmp', 'flag{rgb_steganography}', 'stego.bmp')
多位LSB¶
不仅修改最低1位,而是修改最低2位、3位甚至4位,容量更大但更容易被检测。
def encode_lsb_multibit(image_path, message, output_path, bits=2):
"""
多位LSB隐写
bits: 使用的LSB位数(1-4)
"""
img = Image.open(image_path)
pixels = list(img.getdata())
binary_message = ''.join(format(ord(c), '08b') for c in message)
binary_message += '1111111111111110'
mask = (1 << bits) - 1 # 生成掩码,bits=2时为0b11
clear_mask = ~mask & 0xFF # 清除掩码
new_pixels = []
msg_index = 0
for pixel in pixels:
new_pixel = list(pixel)
for i in range(3):
if msg_index + bits - 1 < len(binary_message):
# 提取bits个bit
bits_to_embed = int(binary_message[msg_index:msg_index+bits], 2)
# 清除低bits位,然后嵌入
new_pixel[i] = (new_pixel[i] & clear_mask) | bits_to_embed
msg_index += bits
new_pixels.append(tuple(new_pixel))
new_img = Image.new(img.mode, img.size)
new_img.putdata(new_pixels)
new_img.save(output_path)
# 使用2位LSB
encode_lsb_multibit('cover.bmp', 'More capacity!', 'stego.bmp', bits=2)
容量计算¶
单通道单位LSB容量:
图像尺寸: 800×600 像素
总像素: 480,000
容量: 480,000 bits = 60,000 bytes ≈ 58.6 KB
RGB三通道容量:
容量: 480,000 × 3 bits = 1,440,000 bits = 180,000 bytes ≈ 175.8 KB
2位LSB容量:
容量: 480,000 × 3 × 2 bits = 2,880,000 bits ≈ 351.6 KB
解题¶
使用StegSolve¶
1. 打开StegSolve: java -jar Stegsolve.jar
2. File -> Open,选择BMP文件
3. 使用方向键查看各个位平面:
- Red plane 0 (R通道最低位)
- Green plane 0 (G通道最低位)
- Blue plane 0 (B通道最低位)
4. 如果看到文字或图案,说明该位平面有隐写
5. Analyse -> Data Extract:
- 勾选要提取的通道和位平面
- 点击Preview查看
- 点击Save Bin保存数据
使用Python脚本提取¶
def extract_lsb_rgb(image_path, output_path):
"""提取RGB三通道LSB数据"""
img = Image.open(image_path)
pixels = list(img.getdata())
binary_data = ''
for pixel in pixels:
for i in range(3): # R, G, B
binary_data += str(pixel[i] & 1)
# 查找结束标志
end_marker = '1111111111111110'
end_pos = binary_data.find(end_marker)
if end_pos != -1:
binary_data = binary_data[:end_pos]
# 转换为字符
message = ''
for i in range(0, len(binary_data), 8):
byte = binary_data[i:i+8]
if len(byte) == 8:
message += chr(int(byte, 2))
with open(output_path, 'w') as f:
f.write(message)
print(f"提取的消息: {message}")
extract_lsb_rgb('stego.bmp', 'extracted.txt')
使用zsteg¶
# 安装zsteg
gem install zsteg
# 自动检测所有LSB隐写
zsteg stego.bmp
# 输出示例:
# b1,r,lsb,xy .. text: "flag{hidden_message}"
# b1,rgb,lsb,xy .. file: PNG image data
# b2,b,lsb,xy .. text: "secret"
# 提取特定通道
zsteg -E "b1,rgb,lsb,xy" stego.bmp > output.txt
# 参数说明:
# b1: 第1位(LSB)
# rgb: RGB三通道
# lsb: 最低有效位
# xy: 按行扫描顺序
文件头隐写¶
宽高修改¶
BMP的宽度和高度存储在固定位置,修改这些值可以隐藏部分图像。
原理¶
检测方法¶
# 使用hexdump查看文件头
hexdump -C image.bmp | head -20
# 查看宽高信息
# 偏移0x12: 宽度(4字节,小端序)
# 偏移0x16: 高度(4字节,小端序)
# 计算实际高度
实际数据大小 = 文件大小 - 像素数据偏移量
每行字节数 = 宽度 × 3 (24位BMP)
实际高度 = 实际数据大小 / 每行字节数(考虑对齐)
修复脚本¶
import struct
def fix_bmp_height(input_path, output_path):
"""自动修复BMP高度"""
with open(input_path, 'rb') as f:
data = f.read()
# 读取文件头信息
file_size = struct.unpack('<I', data[0x02:0x06])[0]
pixel_offset = struct.unpack('<I', data[0x0A:0x0E])[0]
width = struct.unpack('<I', data[0x12:0x16])[0]
height = struct.unpack('<I', data[0x16:0x1A])[0]
print(f"当前宽度: {width}, 高度: {height}")
# 计算实际高度
pixel_data_size = file_size - pixel_offset
row_size = ((width * 3 + 3) // 4) * 4 # 4字节对齐
actual_height = pixel_data_size // row_size
print(f"计算出的实际高度: {actual_height}")
if actual_height != height:
# 修改高度
data = bytearray(data)
data[0x16:0x1A] = struct.pack('<I', actual_height)
with open(output_path, 'wb') as f:
f.write(data)
print(f"已修复高度为: {actual_height}")
else:
print("高度正确,无需修复")
fix_bmp_height('hidden.bmp', 'fixed.bmp')
使用010 Editor手动修改¶
文件大小伪造¶
修改文件头中的文件大小字段,使其小于实际大小,隐藏尾部数据。
def hide_in_filesize(cover_bmp, secret_data, output_bmp):
"""在BMP文件大小字段后隐藏数据"""
with open(cover_bmp, 'rb') as f:
bmp_data = bytearray(f.read())
# 记录原始文件大小
original_size = len(bmp_data)
# 修改文件头中的大小为原始大小(不包括追加数据)
bmp_data[0x02:0x06] = struct.pack('<I', original_size)
# 追加秘密数据
bmp_data.extend(secret_data.encode())
with open(output_bmp, 'wb') as f:
f.write(bmp_data)
# 提取
def extract_from_filesize(stego_bmp):
with open(stego_bmp, 'rb') as f:
data = f.read()
declared_size = struct.unpack('<I', data[0x02:0x06])[0]
actual_size = len(data)
if actual_size > declared_size:
hidden_data = data[declared_size:]
print(f"找到隐藏数据: {hidden_data.decode()}")
return hidden_data
调色板隐写¶
8位BMP使用调色板(256色),每个调色板条目占4字节(B、G、R、保留)。
原理¶
调色板中的"保留"字节通常为0,可以用来隐藏数据。由于调色板只用于索引颜色,修改保留字节不影响显示。
出题脚本¶
def encode_palette(bmp_8bit, message, output_path):
"""8位BMP调色板隐写"""
with open(bmp_8bit, 'rb') as f:
data = bytearray(f.read())
# 检查是否是8位BMP
bits_per_pixel = struct.unpack('<H', data[0x1C:0x1E])[0]
if bits_per_pixel != 8:
print("必须是8位BMP!")
return
# 调色板从偏移0x36开始(54字节后)
palette_offset = 0x36
# 将消息嵌入调色板的保留字节
for i, char in enumerate(message):
if i >= 256:
break
# 每个调色板条目4字节,保留字节是第4个
data[palette_offset + i * 4 + 3] = ord(char)
with open(output_path, 'wb') as f:
f.write(data)
# 提取
def extract_palette(stego_bmp):
with open(stego_bmp, 'rb') as f:
data = f.read()
palette_offset = 0x36
message = ''
for i in range(256):
byte = data[palette_offset + i * 4 + 3]
if byte == 0:
break
message += chr(byte)
print(f"提取的消息: {message}")
return message
解题¶
# 手动提取调色板数据
def extract_palette_manual(bmp_path):
with open(bmp_path, 'rb') as f:
data = f.read()
# 调色板从0x36开始
palette_offset = 0x36
print("调色板保留字节:")
reserved_bytes = []
for i in range(256):
reserved = data[palette_offset + i * 4 + 3]
reserved_bytes.append(reserved)
if reserved != 0:
print(f"索引 {i}: {reserved} ({chr(reserved) if 32 <= reserved < 127 else '?'})")
return bytes(reserved_bytes)
像素值差分隐写¶
原理¶
利用相邻像素之间的差值来嵌入信息。人眼对差值的变化不敏感。
出题脚本¶
def encode_pvd(image_path, message, output_path):
"""像素值差分隐写 (Pixel Value Differencing)"""
img = Image.open(image_path)
pixels = list(img.getdata())
width, height = img.size
binary_message = ''.join(format(ord(c), '08b') for c in message)
new_pixels = list(pixels)
msg_index = 0
# 按行处理,每次取相邻两个像素
for y in range(height):
for x in range(0, width - 1, 2):
if msg_index >= len(binary_message):
break
idx1 = y * width + x
idx2 = y * width + x + 1
pixel1 = list(new_pixels[idx1])
pixel2 = list(new_pixels[idx2])
# 使用蓝色通道
diff = abs(pixel1[2] - pixel2[2])
bit = int(binary_message[msg_index])
# 奇偶性嵌入: 奇数=1, 偶数=0
if (diff % 2) != bit:
# 调整差值
if pixel2[2] < 255:
pixel2[2] += 1
else:
pixel2[2] -= 1
new_pixels[idx2] = tuple(pixel2)
msg_index += 1
new_img = Image.new(img.mode, img.size)
new_img.putdata(new_pixels)
new_img.save(output_path)
文件尾部追加¶
原理¶
BMP解析器读取到像素数据结束后就停止,可以在文件末尾追加任意数据。
检测方法¶
# 使用binwalk扫描
binwalk image.bmp
# 输出示例:
# DECIMAL HEXADECIMAL DESCRIPTION
# 0 0x0 PC bitmap, Windows 3.x format
# 54 0x36 Zlib compressed data
# 123456 0x1E240 ZIP archive data
# 提取嵌入文件
binwalk -e image.bmp
# 或使用foremost
foremost image.bmp
出题脚本¶
def append_to_bmp(cover_bmp, secret_file, output_bmp):
"""在BMP尾部追加文件"""
with open(cover_bmp, 'rb') as f:
bmp_data = f.read()
with open(secret_file, 'rb') as f:
secret_data = f.read()
with open(output_bmp, 'wb') as f:
f.write(bmp_data)
f.write(secret_data)
print(f"已追加 {len(secret_data)} 字节")
# 提取
def extract_from_tail(stego_bmp):
with open(stego_bmp, 'rb') as f:
data = f.read()
# 计算BMP数据的实际结束位置
pixel_offset = struct.unpack('<I', data[0x0A:0x0E])[0]
width = struct.unpack('<I', data[0x12:0x16])[0]
height = struct.unpack('<I', data[0x16:0x1A])[0]
row_size = ((width * 3 + 3) // 4) * 4
bmp_end = pixel_offset + row_size * height
if len(data) > bmp_end:
hidden_data = data[bmp_end:]
print(f"找到尾部数据: {len(hidden_data)} 字节")
# 尝试识别文件类型
if hidden_data[:2] == b'PK':
print("可能是ZIP文件")
elif hidden_data[:4] == b'\x89PNG':
print("可能是PNG文件")
return hidden_data
位平面隐写¶
原理¶
将图像的每个颜色通道的8位分解为8个位平面,在特定位平面上绘制图案或文字。
像素值: 235 = 11101011
位平面分解:
Bit 7 (MSB): 1
Bit 6: 1
Bit 5: 1
Bit 4: 0
Bit 3: 1
Bit 2: 0
Bit 1: 1
Bit 0 (LSB): 1
在Bit 0平面上可以绘制黑白图案
出题脚本¶
from PIL import Image, ImageDraw, ImageFont
def create_bitplane_stego(cover_bmp, text, output_bmp, plane=0, channel=2):
"""
在位平面上绘制文字
plane: 0-7,0是LSB
channel: 0=R, 1=G, 2=B
"""
img = Image.open(cover_bmp)
pixels = list(img.getdata())
width, height = img.size
# 创建一个黑白图像,用于文字
text_img = Image.new('1', (width, height), 0)
draw = ImageDraw.Draw(text_img)
# 绘制文字
try:
font = ImageFont.truetype("arial.ttf", 40)
except:
font = ImageFont.load_default()
draw.text((50, height//2), text, fill=1, font=font)
# 提取文字图像的像素
text_pixels = list(text_img.getdata())
# 嵌入到指定位平面
new_pixels = []
mask = 1 << plane # 创建位掩码
clear_mask = ~mask & 0xFF
for i, pixel in enumerate(pixels):
pixel_list = list(pixel)
text_bit = text_pixels[i]
# 清除该位,然后设置新值
pixel_list[channel] = (pixel_list[channel] & clear_mask) | (text_bit << plane)
new_pixels.append(tuple(pixel_list))
new_img = Image.new(img.mode, img.size)
new_img.putdata(new_pixels)
new_img.save(output_bmp)
create_bitplane_stego('cover.bmp', 'FLAG', 'stego.bmp', plane=0, channel=2)
解题¶
使用StegSolve查看各个位平面:
1. 打开StegSolve
2. File -> Open BMP文件
3. 按左右方向键切换视图:
- Red plane 0 ~ 7
- Green plane 0 ~ 7
- Blue plane 0 ~ 7
4. 如果某个位平面显示清晰的图案/文字,即为隐写内容
5. 可以保存该位平面: Analyse -> Data Extract
区域隐写¶
原理¶
只修改图像的特定区域(如边角、纯色区域)的像素,减少被发现的概率。
def encode_region(image_path, message, output_path, region='corner'):
"""区域隐写"""
img = Image.open(image_path)
pixels = list(img.getdata())
width, height = img.size
binary_message = ''.join(format(ord(c), '08b') for c in message)
binary_message += '1111111111111110'
# 定义嵌入区域
if region == 'corner':
# 左上角 100×100 区域
region_pixels = []
for y in range(min(100, height)):
for x in range(min(100, width)):
region_pixels.append(y * width + x)
elif region == 'border':
# 图像边框
region_pixels = []
# 上下边
for y in [0, height-1]:
for x in range(width):
region_pixels.append(y * width + x)
# 左右边
for y in range(1, height-1):
for x in [0, width-1]:
region_pixels.append(y * width + x)
# 嵌入数据
new_pixels = list(pixels)
msg_index = 0
for idx in region_pixels:
if msg_index >= len(binary_message):
break
pixel = list(new_pixels[idx])
pixel[2] = (pixel[2] & 0xFE) | int(binary_message[msg_index])
new_pixels[idx] = tuple(pixel)
msg_index += 1
new_img = Image.new(img.mode, img.size)
new_img.putdata(new_pixels)
new_img.save(output_path)
工具汇总¶
StegSolve¶
最常用的BMP隐写分析工具。
# 下载
wget http://www.caesum.com/handbook/Stegsolve.jar
# 运行
java -jar Stegsolve.jar
# 功能:
# - 查看各个位平面(Red/Green/Blue plane 0-7)
# - 数据提取(Data Extract)
# - 图像对比(Image Combiner)
# - 帧浏览(Frame Browser)
zsteg¶
自动检测和提取LSB隐写。
# 安装
gem install zsteg
# 基本使用
zsteg image.bmp
# 常用参数
zsteg -a image.bmp # 显示所有可能的隐写
zsteg -E "b1,rgb,lsb,xy" image.bmp > output.bin # 提取数据
# 参数说明:
# b1-b8: 位平面(b1=LSB)
# r,g,b,rgb: 颜色通道
# lsb,msb: 最低/最高有效位
# xy,yx: 扫描顺序
binwalk¶
扫描和提取嵌入文件。
Python PIL/Pillow¶
编写自定义脚本。
from PIL import Image
import struct
# 基本操作示例
img = Image.open('image.bmp')
width, height = img.size
pixels = list(img.getdata())
# 访问单个像素
pixel = pixels[0] # (R, G, B)
# 修改像素
new_pixels = []
for pixel in pixels:
r, g, b = pixel
# 修改LSB
b = (b & 0xFE) | 1
new_pixels.append((r, g, b))
# 保存
new_img = Image.new('RGB', (width, height))
new_img.putdata(new_pixels)
new_img.save('modified.bmp')
010 Editor¶
十六进制编辑器,配合BMP模板使用。
综合解题思路¶
标准分析流程¶
# 第一步: 基本信息
file image.bmp
exiftool image.bmp
# 第二步: 查看文件头
hexdump -C image.bmp | head -20
# 第三步: 检查文件完整性
# 计算宽高是否正确
# 检查文件大小是否异常
# 第四步: LSB检测
zsteg -a image.bmp
# 第五步: 位平面分析
java -jar Stegsolve.jar
# 逐个查看Red/Green/Blue plane 0-7
# 第六步: 嵌入文件扫描
binwalk image.bmp
foremost image.bmp
# 第七步: 手动分析
# 使用Python脚本提取可疑数据
常见题型¶
类型1: LSB隐写¶
特征: zsteg检测到可读文本或文件签名
解法:
zsteg image.bmp
# 输出: b1,rgb,lsb,xy .. text: "flag{...}"
# 提取
zsteg -E "b1,rgb,lsb,xy" image.bmp > flag.txt
类型2: 宽高修改¶
特征: + 图片显示不完整 + 文件大小与显示尺寸不符
解法:
类型3: 位平面图案¶
特征: StegSolve某个位平面显示清晰图案
解法:
类型4: 文件追加¶
特征: binwalk检测到多个文件
解法:
类型5: 调色板隐写¶
特征: 8位BMP文件
解法: