1. 图片隐写到底在考什么:从“看见”到“看穿”的思维转变
很多人第一次接触CTF里的图片分析题,脑子里第一反应是“这不就是一张图吗”,然后盯着屏幕看半天,试图用肉眼找出什么端倪。我刚开始也是这样,结果在一道签到题上卡了整整一个下午,最后发现答案就藏在文件末尾的一串十六进制里。从那以后我才明白,图片分析考的不是眼力,而是你对文件结构、编码方式、工具链的理解深度。
图片分析题的核心逻辑其实很朴素:出题人把flag藏在了一张图片的某个角落,你的任务就是找到它。但“角落”这个词的范围可以非常广——它可能是文件末尾追加的一段数据,可能是像素最低位里藏着的二进制流,可能是调色板里被替换的字节,也可能是两张图异或之后才显现的轮廓。每一种藏法对应一类知识点,而这一篇要聊的,是那些不依赖肉眼观察、需要动手拆解文件本身的进阶技巧。
这篇文章适合已经做过几道基础图片题、知道file命令和strings命令怎么用、但遇到稍微复杂一点的题就不知道从哪下手的读者。我会把重点放在工具选型逻辑、参数计算过程、以及实际排查时踩过的坑上,而不是简单罗列“用这个工具打开就行”。因为CTF最大的特点就是,同一道题换一个参数,解法可能完全不同,死记工具名称没有意义,理解原理才能举一反三。
在开始之前,先明确一个前提:本文讨论的所有操作都在合法的CTF竞赛环境或自己搭建的练习环境中进行。任何未经授权对他人系统或数据进行探测的行为都是不允许的,这一点不需要多解释。
2. 工具链的选型逻辑:为什么我最终只留下了这几个
2.1 从“工具收集癖”到“够用就好”
我见过很多新手在准备CTF图片分析时,第一件事是去搜“CTF图片隐写工具合集”,然后下载一大堆软件,每个都装一遍,结果真到做题的时候反而不知道该用哪个。我自己也经历过这个阶段,电脑里曾经同时装着七八个隐写分析工具,后来发现常用的就那么三四个。
工具选型的核心原则是:先理解文件格式,再选择工具。图片文件本质上就是按照特定规范排列的二进制数据,PNG有PNG的块结构,JPEG有JPEG的段结构,BMP更是直接把像素数据裸奔给你看。你理解了结构,用xxd加python就能解决大部分问题;你不理解结构,给你再多的工具也只是碰运气。
基于这个逻辑,我把常用工具分成三类:
- 文件结构查看类:
xxd、file、pngcheck、exiftool。这类工具帮你快速了解文件的全貌,定位异常区域。 - 数据提取与处理类:
binwalk、foremost、python(配合PIL、numpy)。这类工具用于分离嵌套文件、提取隐藏数据、进行像素级运算。 - 可视化辅助类:Stegsolve、GIMP。这类工具用于通道分离、帧浏览、色彩调整等需要肉眼判断的场景。
注意:工具不在多,在于你能否在需要的时候立刻想起它,并且知道它的参数该怎么调。我建议新手先把
xxd、python和Stegsolve这三个用熟,覆盖百分之八十的常见题型。
2.2 为什么Python是绕不开的一环
有人可能会问,既然有现成的工具,为什么还要写代码?答案很简单:现成工具只能处理已知的、标准化的藏法,而CTF出题人最喜欢干的事就是改参数、换顺序、加干扰。
举个例子,常见的LSB(最低有效位)隐写,很多工具都支持一键提取。但如果出题人把嵌入顺序从“从左到右逐行”改成“从下到上逐列”,或者只嵌入红色通道而把绿色蓝色通道作为干扰,现成工具可能就提取不出正确结果。这时候你需要自己写几行Python,控制读取像素的顺序和通道,才能拿到正确的数据。
再比如,两张图片异或的题目,工具可能只支持同尺寸图片直接异或,但如果出题人把其中一张图做了裁剪或者旋转,你就需要先用Python对齐尺寸和角度,再进行运算。这些操作听起来复杂,实际上核心代码可能不超过二十行。
我个人的习惯是,每遇到一种新的藏法,就用Python写一个对应的处理脚本,存到自己的工具库里。时间长了,这个脚本库就成了我解题时最可靠的武器。下面我会在具体题型里给出关键代码片段,你可以直接拿去改参数使用。
2.3 工具使用中的一个常见误区
很多教程会告诉你“用binwalk分析一下”,但不会告诉你binwalk的输出该怎么看。我见过有新手看到binwalk输出了一堆偏移量,就一个一个去dd提取,结果提取出来的全是垃圾数据。
这里的关键是:binwalk的扫描结果需要结合文件头特征来判断。比如你看到偏移量0x1A2B处有PK字样,那大概率是一个ZIP压缩包;看到0xFFD8,那是JPEG图片的开头。你需要知道常见文件格式的魔术字节(magic bytes),才能从binwalk的输出里筛选出真正有用的信息。
常见文件头对照表:
| 文件类型 | 魔术字节(十六进制) | ASCII表示 |
|---|---|---|
| PNG | 89 50 4E 47 | .PNG |
| JPEG | FF D8 FF | ... |
| GIF | 47 49 46 38 | GIF8 |
| ZIP | 50 4B 03 04 | PK.. |
| BMP | 42 4D | BM |
| 25 50 44 46 |
记住这张表,你在用xxd看文件的时候就能快速定位关键区域。
3. 文件结构层面的藏匿与提取:从末尾追加到块内嵌
3.1 末尾追加数据的识别与提取
这是最基础但也最容易被忽略的一种藏法。出题人把flag直接追加在图片文件的末尾,图片本身还能正常打开,因为图片查看器只读取到IEND标记(PNG)或EOI标记(JPEG)就停止了,后面的数据被忽略。
识别方法很简单:用xxd查看文件末尾,或者用binwalk扫描。如果看到图片结束标记之后还有数据,那基本就是它了。
提取方法:
# 先用binwalk看偏移量 binwalk image.png # 假设偏移量是0x1A2B,用dd提取 dd if=image.png of=hidden.txt bs=1 skip=$((0x1A2B))但这里有个坑:偏移量是十进制还是十六进制。binwalk默认输出的是十进制偏移量,但显示的时候可能会让你误以为是十六进制。我建议直接用binwalk -e让它自动提取,或者用python读取文件后手动切片,避免进制换算错误。
with open('image.png', 'rb') as f: data = f.read() # 找到IEND标记的位置 iend_pos = data.find(b'IEND') # IEND后面还有4字节的CRC校验,所以实际数据从iend_pos+8开始 hidden_data = data[iend_pos+8:] with open('hidden.txt', 'wb') as f: f.write(hidden_data)实操心得:有些出题人会在追加数据之前再加一个假的结束标记,或者把真正的数据分散在多个位置。遇到提取出来是乱码的情况,先用
file命令看看提取出来的数据是什么类型,再决定下一步怎么处理。
3.2 PNG块结构中的隐藏信息
PNG文件是由一个个“块”(chunk)组成的,每个块有固定的结构:4字节长度 + 4字节类型 + 数据 + 4字节CRC。标准块包括IHDR、IDAT、IEND等,但PNG规范允许存在“辅助块”(ancillary chunk),出题人可以在这些辅助块里藏信息。
常见的藏信息块:
tEXt:文本信息块,可以直接藏ASCII字符串zTXt:压缩文本块,需要解压iTXt:国际化文本块- 自定义块:出题人自己造一个块类型,把flag放进去
查看PNG块结构的方法:
# 用pngcheck查看块信息 pngcheck -v image.png # 或者用python手动解析 python3 -c " import struct with open('image.png', 'rb') as f: data = f.read() pos = 8 # 跳过PNG签名 while pos < len(data): length = struct.unpack('>I', data[pos:pos+4])[0] chunk_type = data[pos+4:pos+8].decode('ascii', errors='replace') print(f'类型: {chunk_type}, 长度: {length}, 偏移: {pos}') pos += 12 + length "如果发现某个块的长度异常大,或者块类型不是你认识的,那就要重点检查那个块的数据区域。
注意:修改PNG块数据后,需要重新计算CRC校验值,否则图片可能无法正常打开。计算CRC的代码:
import zlib chunk_data = chunk_type.encode() + data crc = zlib.crc32(chunk_data) & 0xffffffff
3.3 JPEG段结构中的隐藏信息
JPEG文件由多个“段”(segment)组成,每个段以0xFF开头,后面跟一个字节的段类型。常见的段包括SOI(FFD8)、APP0(FFE0)、APP1(FFE1,通常存EXIF信息)、DQT、SOF、SOS等。
出题人常用的藏法是在APPn段里塞数据,或者直接在SOS段之后、EOI之前追加数据。查看JPEG段结构:
# 用exiftool查看EXIF信息 exiftool image.jpg # 用xxd查看段结构 xxd image.jpg | head -50如果exiftool的输出里有奇怪的注释、作者信息、或者GPS坐标,那可能就是flag的线索。有些题目会把flag拆成几部分,分别放在不同的EXIF字段里,需要你拼起来。
实操心得:JPEG的EXIF字段可以存储缩略图,有些出题人会把真正的flag藏在缩略图里,而主图只是一个幌子。用exiftool的
-b -ThumbnailImage参数可以把缩略图提取出来单独查看。
4. 像素数据层面的藏匿与提取:LSB与通道运算
4.1 LSB隐写的原理与变种
LSB(Least Significant Bit)隐写是图片分析题里出现频率最高的题型之一。原理很简单:图片每个像素的每个颜色通道用8位二进制表示,改变最低位对肉眼来说几乎看不出区别,所以出题人可以把flag的二进制位替换到这些最低位上。
最基础的LSB藏法是按顺序替换RGB通道的最低位,提取的时候按顺序读取即可。但出题人为了增加难度,会做各种变种:
- 只嵌入某一个通道:比如只改红色通道的最低位,绿色蓝色不变
- 改变嵌入顺序:从下到上、从右到左、按列优先等
- 跳位嵌入:每隔一个像素嵌入一位
- 多图联合:把数据分散在多张图片里,需要按顺序拼接
面对这些变种,现成工具往往力不从心,自己写脚本才是王道。下面是一个通用的LSB提取脚本框架:
from PIL import Image import numpy as np def extract_lsb(image_path, channel=0, order='row', bit_count=1): """ channel: 0=R, 1=G, 2=B order: 'row' 逐行, 'col' 逐列 bit_count: 每个像素提取几位 """ img = Image.open(image_path) pixels = np.array(img) bits = [] if order == 'row': for row in pixels: for pixel in row: val = pixel[channel] for i in range(bit_count): bits.append((val >> i) & 1) elif order == 'col': for col_idx in range(pixels.shape[1]): for row_idx in range(pixels.shape[0]): val = pixels[row_idx][col_idx][channel] for i in range(bit_count): bits.append((val >> i) & 1) # 把bits转成字节 chars = [] for i in range(0, len(bits), 8): byte = 0 for j in range(8): if i + j < len(bits): byte = (byte << 1) | bits[i + j] chars.append(byte) return bytes(chars) # 使用示例 result = extract_lsb('image.png', channel=0, order='row') print(result[:100])这个脚本的好处是参数可调,你可以根据题目描述或者尝试不同的通道和顺序,直到看到可读的字符串(比如flag{)。
实操心得:提取出来的数据如果开头是乱码但中间有可读字符,可能是字节序或者位序的问题。尝试把每个字节的8位反过来读,或者把整个字节序列倒序,往往能解决问题。
4.2 通道分离与异或运算
有些题目不是直接藏数据,而是通过通道运算来隐藏信息。比如把flag编码成一张黑白图,然后和原图进行异或,得到一张看起来正常的图片。你需要把图片的各个通道分离出来,两两异或,才能看到隐藏的图案。
Stegsolve的“Analyse -> Frame Browser”和“Channel Viewer”功能可以快速做通道分离和异或,但如果你需要批量处理或者自动化,还是用Python更方便:
from PIL import Image import numpy as np def xor_channels(image_path, ch1, ch2): img = Image.open(image_path) pixels = np.array(img) result = pixels[:,:,ch1] ^ pixels[:,:,ch2] # 把结果保存为图片 Image.fromarray(result).save('xor_result.png') return result # 使用示例:红色通道和绿色通道异或 xor_channels('image.png', 0, 1)如果异或之后得到的是一张有噪声的图,可以尝试调整对比度或者做二值化处理,让隐藏的图案更清晰。
注意:异或运算要求两张图尺寸完全一致。如果题目给了两张图,先检查尺寸是否相同,不同的话需要先裁剪或缩放对齐。
4.3 调色板与索引颜色的陷阱
PNG图片支持调色板模式(Palette Mode),每个像素存储的是调色板的索引值,而不是实际的RGB值。出题人可以在调色板里做手脚,比如把调色板顺序打乱,或者把flag编码在调色板的颜色值里。
识别方法:用file命令查看图片,如果显示PNG image data, 8-bit colormap,那就是调色板模式。用Python读取调色板:
from PIL import Image img = Image.open('image.png') if img.mode == 'P': palette = img.getpalette() # palette是一个列表,每三个值表示一个RGB颜色 for i in range(0, len(palette), 3): r, g, b = palette[i], palette[i+1], palette[i+2] print(f'索引 {i//3}: RGB({r},{g},{b})')如果调色板里的颜色值看起来有规律(比如都是ASCII码范围),那可能就是flag的编码。
实操心得:有些题目会把图片从调色板模式转成RGB模式后,再在RGB数据里藏信息。所以拿到图片后,先用
img.mode看一下原始模式,再决定处理方式。
5. 实操全流程:一道综合题的完整拆解
5.1 题目背景与初步侦察
假设我们拿到一张名为challenge.png的图片,题目描述只有一句话:“找到隐藏的flag”。没有其他提示。
第一步,用file命令看文件类型:
file challenge.png # 输出:PNG image data, 1920 x 1080, 8-bit/color RGBA, non-interlaced正常的PNG图片,尺寸不小,RGBA四通道。用xxd看文件头:
xxd challenge.png | head -5 # 00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDR # 00000010: 0000 0780 0000 0438 0806 0000 00...IHDR块显示宽度0x0780=1920,高度0x0438=1080,位深8,颜色类型6(RGBA)。看起来正常。
第二步,用binwalk扫描:
binwalk challenge.png # DECIMAL HEXADECIMAL DESCRIPTION # 0 0x0 PNG image, 1920 x 1080, 8-bit/color RGBA, non-interlaced # 1234567 0x12D687 Zip archive data, at least v2.0 to extract发现偏移量1234567处有一个ZIP压缩包。用dd提取:
dd if=challenge.png of=hidden.zip bs=1 skip=1234567 unzip hidden.zip解压后得到一个flag.txt,打开一看是假的flag。这说明ZIP只是一个干扰项,真正的flag还在图片本身。
5.2 深入像素数据
既然文件结构层面没有直接找到flag,那就转向像素数据。用Stegsolve打开图片,切换到“Analyse -> Data Extract”模式,尝试不同的通道组合和位平面。
在Red通道的bit 0(最低位)提取时,看到开头有flag{字样,但后面跟着乱码。这说明数据确实藏在红色通道的最低位,但可能嵌入了干扰或者顺序不对。
回到Python,写脚本尝试不同的提取顺序:
from PIL import Image import numpy as np img = Image.open('challenge.png') pixels = np.array(img) # 尝试四种顺序:逐行正序、逐行倒序、逐列正序、逐列倒序 def extract(pixels, order): bits = [] if order == 'row_fwd': for row in pixels: for pixel in row: bits.append(pixel[0] & 1) elif order == 'row_bwd': for row in pixels[::-1]: for pixel in row[::-1]: bits.append(pixel[0] & 1) elif order == 'col_fwd': for c in range(pixels.shape[1]): for r in range(pixels.shape[0]): bits.append(pixels[r][c][0] & 1) elif order == 'col_bwd': for c in range(pixels.shape[1]-1, -1, -1): for r in range(pixels.shape[0]-1, -1, -1): bits.append(pixels[r][c][0] & 1) chars = [] for i in range(0, len(bits), 8): byte = 0 for j in range(8): if i+j < len(bits): byte = (byte << 1) | bits[i+j] chars.append(byte) return bytes(chars) for order in ['row_fwd', 'row_bwd', 'col_fwd', 'col_bwd']: result = extract(pixels, order) if b'flag{' in result: print(f'顺序 {order} 找到flag:') start = result.find(b'flag{') end = result.find(b'}', start) + 1 print(result[start:end])运行后发现col_bwd顺序下找到了完整的flag。原来出题人把数据按列倒序嵌入了红色通道的最低位。
5.3 参数计算与验证
为什么是列倒序?我们可以从数据量反推。图片尺寸1920x1080,总像素数2073600。如果每个像素嵌入1位,总共可以嵌入2073600位,即259200字节。flag长度通常在30-50字节,所以嵌入空间绰绰有余。
出题人选择列倒序,可能是为了增加提取难度,也可能是为了配合某种视觉上的效果。不管怎样,通过尝试不同的顺序组合,我们最终找到了正确的提取方式。
实操心得:在尝试提取顺序时,不要盲目试所有组合。先观察提取出来的数据开头是否有可读字符,如果有,说明通道和位平面选对了,只是顺序问题;如果完全是乱码,那可能是通道或位平面选错了,需要换一个再试。
6. 常见问题与排查技巧实录
6.1 提取出来是乱码怎么办
这是最常见的问题。排查思路如下:
- 确认通道和位平面是否正确:换一个通道或位平面再试。有时候flag藏在绿色通道的bit 1而不是红色通道的bit 0。
- 确认提取顺序:尝试逐行、逐列、正序、倒序的组合。
- 检查是否有加密或压缩:如果提取出来的数据开头是
PK,那是ZIP压缩包,需要解压;如果是\x1f\x8b,那是gzip压缩;如果看起来像Base64编码,先解码再说。 - 检查字节序:把每个字节的8位反过来读,或者把整个字节序列倒序。
6.2 图片打不开或者显示异常
有些题目会故意破坏图片的文件头或关键块,让你无法正常打开图片,但数据其实还在。这时候需要用xxd手动修复文件头。
比如PNG的文件头应该是89 50 4E 47 0D 0A 1A 0A,如果被改成了其他值,改回来即可。IHDR块的CRC如果被改了,图片也无法打开,需要重新计算CRC。
import zlib import struct with open('broken.png', 'rb') as f: data = bytearray(f.read()) # 假设IHDR块从偏移8开始 ihdr_data = data[8:8+4+4+13] # 长度+类型+数据 chunk_type = ihdr_data[4:8] chunk_data = ihdr_data[8:] crc = zlib.crc32(chunk_type + chunk_data) & 0xffffffff data[8+4+4+13:8+4+4+13+4] = struct.pack('>I', crc) with open('fixed.png', 'wb') as f: f.write(data)6.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 提取数据全是乱码 | 通道或位平面错误 | 换通道/位平面重试 |
| 提取数据开头可读后面乱码 | 提取顺序错误 | 尝试列序/倒序 |
| 图片无法打开 | 文件头或CRC被改 | 用xxd检查并修复 |
| binwalk扫描无结果 | 数据未追加在末尾 | 检查像素数据或块结构 |
| 异或结果全黑或全白 | 通道选择错误 | 尝试其他通道组合 |
| 调色板模式图片 | 数据藏在调色板 | 读取调色板值分析 |
最后再分享一个小技巧:在做图片分析题时,养成随时保存中间结果的习惯。比如提取出来的二进制数据先存成文件,用
file命令看一眼类型;异或生成的图片先保存下来,用图片查看器打开看看有没有肉眼可见的图案。很多时候,答案就在你随手保存的那个文件里。