news 2026/9/15 21:01:24

CTF MISC隐写实战:从low题破解LSB与低频隐写套路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CTF MISC隐写实战:从low题破解LSB与低频隐写套路

攻防世界MISC高手进阶区里有一道叫“low”的题,我第一次打开附件的时候,文件名就一个low,没有任何描述。这种干净得让人发慌的题目,其实非常考验你平时积累的做题节奏。这篇文章我就拿这道题当引子,把MISC里和“low”相关的隐写思路、工具用法、脚本写法全部拉通讲一遍。无论你最后下载到的附件是图片还是音频,这套分析方法都能直接用上。

1. 拿到“low”这道题的前十分钟,我在想什么

1.1 文件名low留给我们的三条线索

CTF的出题人很少会起一个毫无意义的名字,尤其是MISC题,文件名本身往往就是最大的提示。看到low这个词,我脑子里第一反应不是“简单”,而是“低”。它至少能对应三条技术线索:第一是low bit,也就是最低有效位隐写,通常说的LSB;第二是low frequency,低频段隐写,常见于音频文件;第三是low contrast,低对比度信息隐藏,比如把文字藏在纯色图片的极微小色差里。

这三条线索指向的文件类型和解法完全不同。如果一开始就默认它是图片LSB,结果附件是个WAV音频,那前面所有操作都白费。我的习惯是先不急着开工具,而是在心里列一个分支清单,等做完基础排查后再收缩范围。很多新人卡题不是因为不会用工具,而是没有做这一步“线索管理”,看到题目就直接埋头跑命令,跑不出结果就慌。

1.2 先看文件属性:大小、扩展名、哈希值

拿到附件的第一步永远是确认文件真实身份,而不是看扩展名。我用的是Linux环境,一条命令完成:

file low.png

这里有个很常见的坑:Windows资源管理器显示的文件扩展名不一定真实,很多MISC题会把zip改成png,甚至把ELF可执行文件改成jpg。file命令能直接识别文件的真实格式,这是所有后续分析的基础。

接下来要看文件大小。一张正常的风景PNG在1024x768分辨率下,大小通常在1MB到2MB之间;如果是纯色或简单渐变,可能只有几十KB。如果一张看似简单的图体积异常大,或者看起来应该很大的图却只有几KB,那就要引起警觉了。体积异常大说明里面可能塞了额外数据,体积异常小说明图片本身可能被人为压缩过,隐藏信息的方式也会受限。

最后顺手算一下哈希值:

md5sum low.png sha1sum low.png

记录下来不是为了炫技,而是为了后面方便在搜索引擎里检索原始附件。有些题目可能来自公开的隐写分析样本,哈希一查就能找到线索。另外一个实际好处是,如果你后面修改了文件(比如修复PNG宽度),有个原始哈希能随时对比,避免弄混版本。

1.3 不要跳过strings和binwalk

很多老手容易犯一个毛病:觉得strings和binwalk太基础,直接跳过,结果在隐写上折腾半小时,最后发现flag就在文件尾部的字符串里。这种题在MISC里并不少见,尤其是一些“高手进阶区”的题,反而会利用选手的轻视心理。

strings low.png | head -50

这个命令会把文件里所有长度超过一定阈值的可打印字符串都拉出来。PNG文件的文本块、注释、EXIF信息、藏在文件尾部的数据,都会在这里暴露。如果直接看到flag{...},那这道题就结束了。即使没看到flag,看到一串可疑的base64或者URL,也值得记录下来。

接着跑binwalk:

binwalk low.png

binwalk的作用是扫描文件内是否嵌入了其他文件。它会识别出PNG里的IDAT块、zlib压缩数据,以及可能存在的ZIP、RAR、其他图片等。看到“Zlib compressed data”不要激动,PNG本身的图像数据就是zlib压缩的,这属于正常结构。真正的重点是看有没有文件头特征出现在正常结构之外,比如在PNG的IEND结束标记之后又跟了一个PK头,那说明后面藏了一个压缩包。

这两个命令加起来不到一分钟,却能过滤掉相当一部分“送分题”。在我做完这轮基础检查之后,low这道题并没有直接暴露flag,但是文件类型是PNG,大小中等偏小,没有明显的异常字符串。那接下来,重点就要放到图像隐写上了。

2. LSB隐写:名为low的题十有八九藏在这里

2.1 LSB隐写的原理,用人话讲清楚

LSB全称Least Significant Bit,最低有效位。这个技术原理不难,但很多人理解得不够透彻。一张24位真彩色PNG图片,每个像素由红、绿、蓝三个颜色通道组成,每个通道用一个字节表示,取值范围从0到255。如果把一个字节写成二进制,比如十进制的200就是11001000,其中最右边那一位就是最低有效位,它只影响数值的奇偶性,对颜色的影响微乎其微。

把某个像素的红色分量从200改成201,肉眼完全看不出区别。隐写就是利用这个“看不出来”的空间,把要隐藏的数据逐位拆开,写进每个像素某个通道的最低位。举个例子,我想嵌入字母A,它的ASCII码是65,二进制是01000001。那我就需要8个像素,依次把它们的某个通道最低位改成0、1、0、0、0、0、0、1,这样就完成了1个字节的隐藏。

因为每个像素能提供3个bit(RGB各1位),一张1024x768的图片理论上可以存下约294KB数据,足够藏一个文本flag、一个二维码图片,甚至一个小压缩包。理解这一点很重要:如果你提取出来的数据非常大,明显超过了理论容量,那说明你的提取方向可能不对。

2.2 用Stegsolve逐通道翻看,正确姿势是什么

Stegsolve是MISC选手的老朋友,一个Java小工具,专门用来逐通道逐位平面查看图片。用Stegsolve打开low.png后,底部有一排按钮,通过左右箭头可以切换不同的颜色通道和位平面组合。

不要漫无目的地点击,要有顺序地看。先从Red plane 0、Green plane 0、Blue plane 0看起,这三个分别是RGB通道的最低位平面。如果某个位平面上出现了明显有规律的明暗图案,比如一行行条纹、方块状的二维码轮廓,或者隐约可辨的文字,那基本可以确定存在LSB隐写。

还有一种更常见的情况:单独看某个通道并不明显,但把三个通道的最低位合并成一张灰度图时,隐藏信息就会现出原形。Stegsolve的Gray Bits模式就是干这个的。它的计算方式是将三通道的同一个位平面叠加成8位灰度值,因为隐写数据可能分布在多个通道里,单通道看是破碎的,合并后才能拼出完整内容。

一个小提醒:Stegsolve打开超大尺寸图片会非常卡,有些还会直接卡死。如果你遇到的是几千像素宽的大图,可以先用Python的PIL把图片长宽缩减到500像素以内,保存后再丢进Stegsolve做快速筛查。注意这只是筛查,缩小过程会破坏像素最低位,所以筛查到疑似区域后,必须用原图做最终提取。

2.3 zsteg一键扫描,比肉眼快得多

手动翻位平面比较费时,所以我通常先跑一遍zsteg。zsteg是Ruby写的一个隐写检测工具,对PNG和BMP的LSB隐写检测非常出色。安装方式很简单:

gem install zsteg

用法:

zsteg -a low.png

-a表示尝试所有已知的通道组合、位序和扫描方向。它会自动输出检测到的隐藏信息,并且会注明使用的是哪套参数。比如输出里出现b1,bgr,lsb,xy,意思就是三个通道的最低位,按BGR顺序,按行扫描。

这个输出对你后续写脚本非常有价值。它相当于出题人的“说明书”,告诉你了数据的嵌入方式。zsteg扫不出来不代表没有LSB,只能说明常见组合都不对。有些题目会故意用变种,比如只把数据写进绿色通道的最低位,或者字节内部位序相反,这些zsteg不一定能覆盖。我的经验是:zsteg出结果直接收,没出结果就手动上Stegsolve,再不行就写脚本枚举组合。

3. 如果图片不是LSB,那“low”还能指向什么

3.1 音频文件的低频隐写

如果附件不是图片,而是一个WAV或MP3,那么“low”极有可能指低频段。这个方向我在做low题之前就专门练过,因为MISC里音频隐写也是一个高频考点。

用Audacity打开音频文件,把视图从波形切换到频谱图。默认情况下频谱图显示的频率范围会比较大,你需要手动把纵轴范围缩放到0到500Hz甚至更低。低频区域如果出现了一段波形图里看不到的图案、字符或者明显比周围更亮的谱线,那就说明有东西藏在低频段。

常见的低频隐写形式有几种:一是SSTV慢扫描电视,能把一张图片调制到音频里,需要用RX-SSTV或MMSSTV解码;二是DTMF双音多频,每个数字对应两个频率的组合,录下来再识别电话号码或坐标;三是摩斯电码,通过不同时长的音调来编码字母数字。判断方法很简单:听到音频内容后,如果感觉像是“滴答”声或者拨号音,就往这三种方向考虑。

关于文件大小,音频比图片更容易判断异常。一个44.1kHz采样率、16bit量化、双声道的WAV文件,每秒大小约176KB。一段30秒的音频应该在5MB左右。如果文件大小明显超出,那么多出来的部分很可能就是嵌入数据。

3.2 GIF或PNG的调色板隐写

还有一种“low”容易被忽略,就是颜色索引的最低位。索引色图片(比如GIF、PNG-8)存储的每个像素不是RGB值,而是一个调色板索引号。每个索引号对应一个颜色,颜色存放在文件的调色板区域。

这种图片的LSB隐写和24位真彩图完全不同。你不能直接看RGB位平面,因为像素值本身只是索引。比如两个像素的索引分别是5和6,它们对应的实际颜色可能差别很大,但最低位分别是1和0。如果隐藏数据时逐个修改索引值的最低位,视觉上可能会出现颜色的轻微跳变,但用Stegsolve的RGB位平面看是看不出规律的。

正确的分析方法是把索引矩阵提取出来,对每个索引值取最低位,重新排列成黑白图。Python的Pillow库可以直接拿到调色板和像素索引:

from PIL import Image img = Image.open('low.gif') palette = img.getpalette() data = img.tobytes()

拿到data后,对每个字节取b & 1,按图片的宽度重新排列成像素矩阵,生成新的黑白图像。如果看到二维码,就扫码;如果是二进制数据,就继续往下处理。这种题在MISC里属于偏冷门但偶尔会考的类型,一旦遇到,很多人会卡在为什么Stegsolve里什么都看不见。

3.3 窄幅图片与像素排列陷阱

“low”还有一种非常有趣的解释:图片的高度被改低了。很多MISC题会把一张包含二维码或文字的图片的PNG高度改小,比如原本是300x300,被改成300x3。图片查看器打开后只能看到一条几像素高的色带,肉眼完全看不出内容。

判断方法很简单:先用file看图片尺寸,如果宽度和高度比例悬殊,马上怀疑宽度或高度被人为改动。然后用十六进制编辑器打开PNG,找到IHDR块。IHDR是PNG文件的第一个数据块,里面第0到3字节是宽度,第4到7字节是高度,都是4字节大端整数。把高度改回合理值,图片就能恢复。

这里有个很有用的技巧:PNG的IHDR块后面跟了4字节的CRC32校验值。如果你改了高度但CRC对不上,很多图片查看器会提示文件损坏。此时不要盲目猜高度,而是爆破。写一个循环,从1到5000逐个尝试高度值,每试一个就算一次CRC32,和文件里的CRC比对,一旦相等就是原始高度。

import struct import zlib def fix_png_height(filepath): with open(filepath, 'rb') as f: data = f.read() ihdr_start = data.find(b'IHDR') width, height = struct.unpack('>II', data[ihdr_start+4:ihdr_start+12]) old_crc = struct.unpack('>I', data[ihdr_start+17:ihdr_start+21])[0] for new_height in range(1, 5000): new_data = data[:ihdr_start+8] + struct.pack('>I', new_height) + data[ihdr_start+12:ihdr_start+17] crc = zlib.crc32(new_data[4:ihdr_start+17]) & 0xffffffff if crc == old_crc: print(f'find height: {new_height}') break

所以,看见low,不要只往LSB一个方向想。音频低频、调色板低位、被改低的宽高,都是常见考点。前面这几节是排查思路,接下来我写一下真正动手提取数据时,脚本怎么写最顺手。

4. 手写Python提取低位数据的完整过程

4.1 一个能跑通的LSB提取脚本

工具能解决大部分常规情况,但遇到变种题,还是得自己写脚本。我把我常用的提取脚本简化一下放在这里,你们可以在此基础上改参数。

from PIL import Image def extract_lsb(image_path, channels='rgb', reverse_bits=False, bit=0): img = Image.open(image_path).convert('RGB') pixels = list(img.getdata()) bits = [] channel_map = {'r': 0, 'g': 1, 'b': 2} for px in pixels: for ch in channels: bits.append((px[channel_map[ch]] >> bit) & 1) if reverse_bits: for i in range(0, len(bits), 8): bits[i:i+8] = reversed(bits[i:i+8]) data = bytearray() for i in range(0, len(bits) - 7, 8): byte = 0 for b in bits[i:i+8]: byte = (byte << 1) | b data.append(byte) return bytes(data) if __name__ == '__main__': result = extract_lsb('low.png', channels='bgr', reverse_bits=False) with open('extracted.bin', 'wb') as f: f.write(result)

代码核心很简单:遍历每个像素的指定通道,取出最低位,拼接成字节数组,写到文件。参数里我特意留了channelsbit,因为实际题目里通道顺序和位平面不一定都是常规的。

很多朋友问,为什么提取出来的数据是乱码?绝大多数情况下不是脚本错了,而是参数不对。常见方向有四个:通道顺序、位序、扫描方向、取第几位。把这几个参数都做成可调,一个个试过去,效率比每次改脚本高得多。

4.2 针对“low”题的特殊处理:LSB not MSB、RGB顺序、位序

首先说位序。一个字节由8个位组成,最高位是bit7,最低位是bit0。隐写时有两种写入方式:一种是从bit7开始,把第一个bit写到第一个像素的最低位,第二个bit写到第二个像素的最低位,这样提取时正序读取8个bit得到一个字节;另一种是从bit0开始,先读bit0再读bit1,本质上是把8个bit整体反转。很多变种题会把位序倒过来,提取结果就会是乱码。

判断位序对不对,最直接的方法是看文件头。比如提取出来应该是一个PNG图片,如果结果以89 50 4E 47开头,说明方向正确;如果开头是E2 0A 38 81这种乱七八糟的值,大部分情况就是位序反了。

其次是通道顺序。常见的是RGB顺序,即每个像素先取R通道最低位,再G,再B。有些题为了加大难度,会改成BGR或者GRB。zsteg输出里会明确写出它用到的通道组合,照着填进脚本的channels参数即可。

第三是扫描方向。默认从左到右、从上到下逐行扫描,但有些题会按列扫描,也就是第一列从上到下读完,再第二列。另一种是蛇形扫描,奇数行从左到右,偶数行从右到左。这些在脚本里都需要把像素遍历顺序改掉。遇到提取出来数据毫无规律且不是文件头时,优先试列扫描和蛇形扫描。

4.3 从提取结果到flag:常见编码和文件头判断

提取出的数据不要急着用文本编辑器打开,先用命令行判断类型:

xxd extracted.bin | head

看第一行字节。不同文件格式都有固定的魔数特征:ZIP是50 4B 03 04,PNG是89 50 4E 47,JPEG是FF D8 FF,GIF是47 49 46 38。如果看到这些,直接把文件扩展名改对,然后解压或打开。

如果提取出来的是BASE64字符串,通常以ZmxhZ(flag的base64开头)或者一堆大小写字母加数字加=结尾。这种情况需要解码:

base64 -d extracted.txt > flag.txt

如果提取出来是一串看起来像是被反转的字符串,可以用rev命令倒序;如果看起来像是十六进制,可以用xxd -r -p转成二进制。这些技巧在low这类题里都很常见。

还有一点要注意:图片像素数量不一定是8的倍数,所以提取出的数据末尾会有几个填充bit,一般是0。如果文件头前面有多余的padding,需要手动找到文件头的位置,从这个位置开始截断。比如从第3个字节开始才是50 4B,那就把前两个字节删掉再解压。这个过程用脚本搜索魔数很容易完成。

我实际做low这道题的时候,一开始用默认的RGB顺序提取出来的完全是乱码,后来看了zsteg的输出,发现它扫描出的组合是BGR,立刻把脚本里的channels改成bgr,重新提取就得到了一个PNG文件,打开是一张二维码,扫码后才拿到最终flag。这个过程的每一步都不复杂,但顺序错一个就全错。

5. 复盘与通解:MISC高手的做题节奏

5.1 从附件到flag的决策树

经过low这道题,我总结了一套多步骤做题流程,现在几乎可以无脑复用。第一步永远是filestringsbinwalk三连,检查文件类型、可见字符串和隐藏文件。第二步根据文件类型选择主攻方向:图片就开Stegsolve翻位平面、跑zsteg;音频就切频谱图看低频段;压缩包就检查加密类型、伪加密和压缩包内文件列表。第三步,如果常规工具没有结果,就开始枚举变种:通道顺序、位序、扫描方向、位平面选择,每一项都写成脚本参数。第四步,提取出数据后,用xxdfile判断文件类型,搜索魔数定位真实数据起点,然后交给对应的解码工具。

这套流程看起来很简单,但真正执行到位需要大量练习。因为每一步之间是有跳转的,比如strings看到一串密码,接下来可能就要用这个密码去解压binwalk分离出来的zip,而不是继续纠结图片隐写。做题节奏的本质是信息流管理,你要根据当前信息不断调整下一步动作。

5.2 我在这种题上踩过的坑

踩过的坑太多了,挑几个最典型的说说。第一个坑是Stegsolve翻位平面不够细致。最低位平面的隐写图案往往不是一眼就能看出来的,它可能隐藏在看起来像噪点的细碎明暗中。如果没有把图片放大到100%或200%,很容易看漏。正确做法是把最低位平面和其他位平面对比,如果其它位平面是纯粹雪花噪点,而最低位平面有块状结构,才能判断有隐写。

第二个坑是zsteg没跑出结果就放弃。zsteg默认不会枚举所有组合,要加-a参数。而且zsteg对某些特定变种支持不好,比如数据不是连续存储,而是隔N个像素取一位。这种情况下zsteg扫不出来,但手动写脚本反而能解开。

第三个坑是二维码扫描不出来。提取出来的图片如果是二维码,但用手机或微信扫不出来,第一反应不要怀疑图片坏了。大概率是二维码被旋转了、翻转了或者背景反色了。把图片旋转180度、水平翻转、垂直翻转都试一遍,或者用Photoshop反色,再扫一次。low这道题里提取到的二维码非常浅,我当时把对比度拉高之后才扫出来。

第四个坑是PNG宽高爆破时忘了CRC。修改高度后如果图片打不开,千万不要继续盲试。正确做法是用CRC校验来锁定正确高度。我前面给的脚本就是干这个的,你们可以直接拿去用,输入文件路径就能跑出正确高度。

5.3 高手进阶区“low”到底考什么

做完了low这道题,我才琢磨出它为什么能进高手进阶区。它没有考九九乘法表式的复杂算法,也没有考冷门工具,而是把MISC最基本的几个点全部串起来了:文件名提示线索、基础命令排查、位平面观察、脚本提取、文件头识别、二维码处理。每一环单拎出来都能写一篇教程,但组合在一道题里,对完整性和耐心度的要求就上来了。

我觉得这道题的核心考点是“对隐写原理的理解程度”。如果你只背过“LSB就是把数据写在最低位”,但不知道最低位是奇偶性、不知道通道顺序会影响提取结果、不知道提取后还有文件头判断这一步,那遇到这道题还是会卡住。反过来,如果你能把这套原理讲给一个完全没接触过的人听,那做这道题就只是时间问题。

最后分享一个我坚持了很久的习惯:每次做MISC题,都把命令、输出、尝试过的参数组合、卡住的环节记在Markdown笔记里。后来再遇到类似的题,翻笔记比重新找题解快得多。low这道题让我把LSB隐写的所有变种都过了一遍,笔记里就多了一张完整的“LSB参数枚举表”。下次你做到类似题目,建议也这样做。现在的攻防世界题库里,low这道题可能已经改版,但底层思路不会变,照着上面的流程走一遍,你大概率能顺利拿到flag。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!