CTF Misc纯色噪点图隐写:RGB通道分离与位平面实战 1. 纯色噪点图为什么是 Misc 隐写的高发区做 CTF 的 Misc 方向你迟早会遇到这样一张附件打开是纯白、纯黑或者纯灰尺寸还不小肉眼看就是一块板。截图发给队友队友回一句你确定没下错附件。但把图放大到 400%你会看到每个像素都在极小的范围里抖比如 RGB 三个值都挤在 250 到 255 之间或者每个通道互相差个一两点。这就是我在标题里说的纯色噪点图也是 RGB 通道分离这类手法最经典的战场。这类题的价值在于它特别适合当入门第一课。它不需要你懂逆向、不需要你写 pwn 脚本、也不需要你分析流量包只要求你建立两个基本认知第一图片不是一张画它是一堆数字第二人眼的分辨能力极其有限只要把差异控制在某个阈值以下你看到的就只是纯色而机器看到的是完整的信息。把这两点想通RGB 通道分离就不再是一个魔法操作而是一次再普通不过的数据读取。适合谁来读这篇如果你刚接触 Misc只会用 Stegsolve 点来点去点不出来就放弃那这篇正好补上为什么点这个按钮的部分如果你已经能独立出题但每次写脚本都要翻旧代码那这篇里的差分、位平面、阈值化几个套路可以直接拿去改。我在下面会把每一步的操作意图讲清楚不是给你一串命令让你照抄而是让你下次遇到变体时知道该往哪个方向试。1.1 一张肉眼看不出内容的图能藏多少东西先算一笔账。一张 1000×800 的 PNG总共 80 万个像素每个像素 3 个通道就是 240 万个字节级别的数据点。哪怕你只允许每个通道改动 ±1那也是每个点有 3 种状态可选原值、1、-1信息容量远超一个 flag 字符串。更狠的是你根本不需要每个像素都改只要挑一部分像素按隐藏图案的 0/1 去改剩下的保持原样整张图在人眼里依然是均匀的一片。这就是纯色噪点图的本质载体本身信息量极低一眼纯色但冗余极高几十万个像素点都能当比特位用。出题人最喜欢的是低对比度 大面积的组合因为对比度低意味着肉眼不可见面积大意味着能塞下完整的文字甚至二维码。实际比赛里我见过几种典型变体整张图 R 通道比 G、B 通道系统性高 1整张图每个像素的三通道值在 254 和 255 之间随机跳但跳的规律对应隐藏图案还有一种是把隐藏图案做成二值掩码掩码为 1 的位置像素整体加 1。这三种在肉眼层面几乎一模一样都是一片白但破解的切入点完全不同。所以第一步永远不是急着分离通道而是先搞清楚差异到底存在于哪里。1.2 RGB 通道分离到底在分离什么很多人第一次听通道分离脑子里浮现的是把一张彩色图拆成三张灰度图。这个理解没错但不够用。更准确的说法是把三维的像素数组沿第三个维度切开得到三个二维矩阵每个矩阵里的数值范围是 0 到 255。R 通道那张图表示每个位置红色分量有多亮G、B 同理。分离本身不产生任何新信息它只是把混在一起的数据摊开。真正有用的是分离之后做的事对比。你可以对比 R 和 G 的逐像素差值可以对比某个通道和它自身的平滑版本也可以对比最低位平面和随机噪声的统计特征。所有这些操作的共同点是——寻找不该有的规律。一张正常的纯色图通道之间的差应该处处为零一旦某处差值非零而且非零的位置连起来像一个字那答案就出来了。我习惯把这个过程类比成听诊通道分离是戴上听诊器差分是找心跳里那一下杂音阈值化是把杂音放大成你能听清的音量。三步缺一不可但顺序不能乱先放大再找杂音你会被噪声淹死。1.3 解题顺序先想原理再摸工具新手最容易犯的错是打开 Stegsolve 从头到尾把每个滤镜点一遍点完没出东西就判死刑。这个习惯的根源是把工具当成了抽奖机。实际上任何一张隐写图出题人都是按某个确定的规则写进去的你只要猜中规则就能读出来猜不中就换规则跟运气无关。我的固定顺序是这样的先看文件本身类型、大小、元信息、图像模式再看像素统计每个通道有多少个不同取值、最大值最小值分布然后做通道差分接着做位平面最后才考虑频域、调色板、附加数据这些进阶方向。这个顺序的好处是每一步都有明确的预期结果比如做完差分我应该看到一张有轮廓的图如果看到的是均匀噪声那就说明差异不在通道之间而在别的维度。这样排查起来是有方向的不是瞎试。2. 把像素拆开看RGB 通道分离的底层逻辑在写第一行代码之前有必要把几个概念钉死。这些东西看起来基础但真正卡住人的往往就是这些基础——比如为什么你的脚本算出来的差值全是 250 多为什么分离出来的图一片漆黑为什么同样的图别人能出你出不了。2.1 一个像素就是一串三元组RGB 图像在内存里就是一个形状为(高, 宽, 3)的数组数据类型通常是 8 位无符号整数也就是 uint8取值 0 到 255。数组的第 0 维是行从上到下第 1 维是列从左到右第 2 维是通道R、G、B 的顺序。所以arr[100, 200, 1]表示第 100 行第 200 列那个像素的绿色分量。搞清这个索引顺序非常重要因为后面所有的差分、掩码、裁剪操作都建立在这个坐标系上。我见过有人把行列搞反结果解出来的图是转置的二维码怎么扫都扫不出折腾半小时才发现是arr[:, 0]和arr[0, :]用混了。图像坐标和数学矩阵坐标的习惯不一样这点心里要有数。除了 RGB还有几个常见的图像模式需要认识L是单通道灰度RGBA多一个透明度通道P是调色板模式每个像素存的是索引真正的颜色在调色板表里CMYK是印刷用的四通道。P 模式是隐写题里的一个暗坑后面单独讲。2.2 位深、图像模式与通道数位深决定了每个通道能用多少个数值。8 位就是 0 到 255 共 256 级16 位就是 0 到 65535。绝大多数 CTF 隐写题都是 8 位因为 16 位图的文件体积大、处理麻烦而且肉眼分辨率的瓶颈在 8 位就已经很明显了。但如果你拿到一张 16 位的 PNG很多现成脚本会直接崩掉或者结果全错因为 numpy 读出来是 uint16阈值、归一化的常数都不一样。图像模式则决定了通道数。一张 RGB 图有三个通道可分离一张 L 图只有一个通道你没法做通道间差分只能做位平面或者和自身的平滑版本对比。所以我在实操第一步永远是打印img.mode和img.getbands()确认手上到底是什么类型。如果题目给的是 RGBA那 Alpha 通道也要纳入怀疑范围有些题就是把信息藏在透明度里你只分离 RGB 三个通道当然什么都看不到。还有一个容易忽略的点PNG 是无损压缩BMP 也是无损的而 JPEG 是有损的。±1 级别的差异在 JPEG 压缩后会被量化抹平所以纯色噪点图基本只会以 PNG 或 BMP 的形式出现。反过来说如果你在附件里看到一张 JPEG 还怀疑是通道隐写那基本可以排除出题人不会跟压缩算法对着干。2.3 为什么 ±1 的差异肉眼看不见人眼对亮度的分辨能力大约在 1% 到 2% 的量级具体取决于背景亮度和观察条件。在 0 到 255 的尺度上差 1 到 2 个数值相对变化不到 1%而且周围全是相近的值没有参照物视觉系统直接就把这点差异当成噪声忽略掉了。这就是为什么纯色噪点图能成立——它不是藏得深而是藏在人眼的盲区里。但对计算机来说差 1 就是一个完全不同的数值。255 - 254 1这个 1 在数组里是实打实的存在你可以把它提取出来、放大 255 倍、存成一张黑白图。原本不可见的图案瞬间就显形了。这个放大的动作是整套方法的核心理解了它你就理解了所有低对比度隐写的破解思路。顺便说一句这也是为什么很多题目的隐藏图案在分离出来之后对比度特别低、边缘发灰。因为原始差异只有 1你在放大过程中如果有任何一步引入了插值或者平滑图案就会糊。所以后面做缩放的时候我一定用最近邻插值绝不用双线性或者双三次。2.4 分离之后通常能看到什么结果无非三类。第一类是直接看到清晰的文字或图案这是最理想的情况说明差异就是按图案的 0/1 打的你只要把非零位置标黑就行。第二类是看到一片均匀的灰偶尔有几个亮点这通常意味着差异确实存在但分布很散需要调整阈值或者换个对比维度。第三类是完全的随机噪声说明你找错了方向通道之间没有人为差异得去看位平面、调色板或者文件结构。我个人的经验是第三类情况出现时不要马上换题先回头确认两件事图像模式对不对是不是 P 模式被强行转了 RGB通道顺序对不对有些库读出来是 BGR。这两个确认完还不行再考虑换方向。这个排查习惯帮我省下过不少时间。3. 环境准备三个工具就够了我不太喜欢把环境搭得特别重。这类题的处理流程本质上就是读数组、算、存图Python 加上 Pillow 和 NumPy 已经足够覆盖 90% 的场景剩下的 10% 用一个能看像素值的查看器补齐。3.1 Python 加 Pillow 加 NumPy 的最小组合安装就一行不用纠结版本这两个库的接口多年没变过。pip install pillow numpyPillow 负责读写各种格式的图片NumPy 负责数组运算。为什么不直接用 OpenCV因为 OpenCV 默认的色彩通道顺序是 BGR读进来之后你还得手动转一次一不小心就会在差分那一步把通道搞混。Pillow 的split()和 NumPy 的arr[..., i]都是 RGB 顺序跟直觉一致对排查问题更友好。当然你要是已经习惯 OpenCV 也无所谓只要记住cv2.imread之后补一句cv2.cvtColor(img, cv2.COLOR_BGR2RGB)就行。另外强烈建议装一个exiftool命令行下看一眼元信息能快速排除信息藏在注释里这种简单情况省得你在像素里白找半天。3.2 一个能看像素值的查看器有多重要脚本跑出来的结果有时候很迷惑比如差分图看起来全是黑的但你说不准是真的全零还是数值太小。这时候就需要一个能看像素值的工具把鼠标放上去读 RGB 数值。GIMP 可以很多轻量看图软件带取色功能也行实在没有就用 Python 打印几个坐标点的值。我一般会写一个三行的小函数专门打印指定区域的最小值、最大值和唯一值列表。这个习惯是从一次翻车里养成的当时差分图全黑我以为没信息结果打印出来发现所有值都是 1只是没归一化到 255。如果当时有个能看数值的工具能省我二十分钟。3.3 什么时候用命令行更省事有些操作用命令行比写脚本快得多。比如确认文件类型用file看十六进制用xxd | head批量分离通道用 ImageMagick 的convert input.png -channel R -separate r.png。这些工具在你只是想快速验证一个猜想的时候特别好用验证完再决定要不要写成脚本固化成流程。但要注意ImageMagick 的-separate在某些版本上对 Alpha 通道的处理不太一致如果你处理的是 RGBA 图最好还是回到 Python 里明确指定通道索引避免结果对不上。4. 完整实操从纯色图里把隐藏图案拽出来下面这套流程是我自己的固定套路从确认图像属性一直到出结果中间每一步都会解释为什么这么做。你可以把它当成模板遇到新题按顺序走一遍大概率能覆盖。4.1 第一步确认图像模式与元信息先别急着算先看清楚手上是什么。打开图片打印模式、尺寸、通道名再把数组的形状和数据类型打出来。这一步不超过十行代码但能避免后面 80% 的诡异问题。from PIL import Image import numpy as np img Image.open(challenge.png) print(mode:, img.mode) print(size:, img.size) print(bands:, img.getbands()) arr np.array(img) print(shape:, arr.shape, dtype:, arr.dtype) if arr.ndim 3: for i, name in enumerate(img.getbands()): ch arr[..., i] print(f{name}: min{ch.min()}, max{ch.max()}, unique{len(np.unique(ch))})重点看unique这个数字。一张正常照片单个通道的唯一值通常接近 256一张纯色噪点图唯一值可能只有 2 到 5 个。如果某个通道的唯一值特别少那它就是重点怀疑对象。如果三个通道都是满 256 个唯一值那基本可以排除纯色加微小扰动这种手法得换个思路。如果打印出来mode是P先别转 RGB直接看调色板img.getpalette()会返回一个列表里面可能有异常的颜色项。有些题把信息藏在调色板索引里你一转 RGB 就直接抹平了这是不可逆的操作。4.2 第二步通道分离与单通道保存确认是 RGB 之后先把三个通道分别存出来看一眼。这一步的意义不是找答案而是建立基线——你要知道每个通道单独看是什么样后面差分出来有异常时才有对比。r, g, b img.split()[:3] r.save(ch_r.png) g.save(ch_g.png) b.save(ch_b.png)存完用看图软件快速扫一眼。如果某个通道单独看就已经能隐约看出图案那恭喜这题基本白给直接对该通道做对比度拉伸就行。实际操作中这种情况不多因为差异往往藏在通道之间而不是单个通道内部但先看一遍不吃亏。这里有个细节img.split()返回的是一个元组RGBA 图会返回四个元素所以取前三个的时候用切片更稳妥。如果图是 L 模式split()只返回一个元素直接解包成三个变量会报错这也是为什么前面要先确认模式。4.3 第三步通道差分整套方法的核心现在进入正题。假设出题人是在 G 通道上做了手脚而 R 和 B 保持原样那么R - G或G - B就会在隐藏图案的位置出现非零值。这一步的关键是先转成有符号整数再相减原因下一节会详细讲。r arr[..., 0].astype(np.int16) g arr[..., 1].astype(np.int16) b arr[..., 2].astype(np.int16) d_rg r - g d_rb r - b d_gb g - b for name, d in [(rg, d_rg), (rb, d_rb), (gb, d_gb)]: print(name, min, d.min(), max, d.max(), unique, np.unique(d)[:10])打印出来的unique是判断的关键。如果某个差分结果只有两个取值比如 0 和 1那几乎可以确定信息就在这个维度上直接把非零位置标成黑色就行了。如果取值稍微多一点比如 -1 到 1那就做归一化再保存。def stretch(x, out): x x.astype(np.float64) mn, mx x.min(), x.max() if mx mn: print(flat channel, skip:, out) return y ((x - mn) / (mx - mn) * 255).astype(np.uint8) Image.fromarray(y).save(out) stretch(d_rg, diff_rg.png) stretch(d_rb, diff_rb.png) stretch(d_gb, diff_gb.png)存出来挨个看。如果三张都是均匀的噪声说明差异不在通道之间往下走位平面。还有一种情况值得单独说差异确实存在但被随机抖动掩盖了。出题人可能不光在掩码位置加 1还在全图随机加了一些噪声。这时候单纯看非零位置会得到一片雪花需要做统计筛选比如只保留出现频率异常的差值或者对结果做一次中值滤波去掉孤立噪点。这个技巧在后面的排查部分会展开。4.4 第四步位平面分解换个维度再看一遍通道差分找的是通道之间的关系位平面找的是单个通道内部的低位规律。把每个像素的某个通道值写成二进制取最低位组成一张二值图就是 LSB 位平面。正常的自然图像最低位基本是随机的看不出结构而如果出题人用 LSB 替换藏了东西最低位平面就会呈现出明显的图案或者文字轮廓。for i in range(arr.shape[2]): for bit in range(8): plane ((arr[..., i] bit) 1) * 255 Image.fromarray(plane.astype(np.uint8)).save(fbit{i}_{bit}.png)这段会生成 24 张图看起来笨但实际跑起来很快扫一眼就知道哪张有内容。经验上第 0 位最低位出内容的概率最高第 1 位和第 2 位偶尔也有。如果八张位平面全是随机噪点那这个通道基本可以排除。位平面还有一个变体不取单一位而是取低几位的组合。比如把低 3 位当作一个整体乘以 36 左右拉伸到 0 到 255。这种手法能藏更多信息但生成的图对比度会更明显出题人一般不会用纯色图来配它。4.5 第五步阈值化与对比度拉伸前面两步得到的结果往往对比度很低尤其是差异只有 1 的时候归一化之后可能还是灰蒙蒙的。这时候需要阈值化把有差异和没差异强行二分成黑白两色。这一步的参数选择很关键阈值太低会把噪声也拉进来太高会丢掉图案的细笔画。def binarize(x, thresh, out): y (np.abs(x) thresh).astype(np.uint8) * 255 Image.fromarray(y).save(out) binarize(d_rg, 0, bin_rg_0.png)先用 0 做阈值也就是只要不为零就算有。如果结果里噪点太多把阈值提到 1 或 2 再试。如果结果是一片全黑或者全白说明差异的极性反了取反再看。inv 255 - np.array(Image.open(bin_rg_0.png)) Image.fromarray(inv.astype(np.uint8)).save(bin_rg_0_inv.png)这个取反的操作看起来不起眼但实际救过我很多次。因为出题人写掩码的时候可能是1 表示背景、0 表示文字你按非零为黑的直觉去渲染出来的就是反的。另外如果原图尺寸很小比如 300×100二值化之后的文字可能只有几个像素高直接看看不清。这时候放大再用最近邻插值别用默认的双线性。small Image.open(bin_rg_0.png) big small.resize((small.width * 4, small.height * 4), Image.NEAREST) big.save(bin_rg_0_x4.png)4.6 第六步还原验证与出结果看到清晰的文字之后别急着提交。先确认几件事字符是否完整、有没有被截断、是不是二维码需要扫描。如果是二维码二值化图必须足够干净四周还要留白否则扫不出来。这时候可以手动裁一下把图案周围多余的边框去掉再补一圈白边。如果是文字注意形近字符的区分比如0和O、1和l、5和S。这类题经常在字符上做文章你肉眼看着像0实际是O提交就错。我的做法是把二值图放大到能看清每个像素的程度逐个字符比对宁可慢一点。有时候图案会正好压在图像边缘被裁掉一部分。这时候可以试着把差分结果整体平移几个像素看看是不是有内容跑到画布外面了。这种细节上的耐心往往就是区分做出来和卡住的地方。5. 常见问题排查速查表这一节是我这些年踩过的坑的集合基本上新手会遇到的问题都能在这里找到对应。5.1 分离出来全是黑的怎么办这是最高频的问题。原因可能有好几种按可能性排序一是通道顺序搞错了你以为在算 R-G实际算的是 G-R结果全变成负数渲染成图当然是黑的二是没有做归一化差异值只有 0 和 1直接当灰度图存就是接近全黑三是图像模式不对P 模式被隐式转成了别的形式。排查方法很直接先打印差分结果的 min 和 max如果 min 是负数、max 是 0那就是符号反了如果 min 是 0、max 是 1那就是缺归一化如果 min 和 max 都是 0那才是真的没差异得换方向。下面这张表可以当速查用。现象最可能的原因处理办法差分图全黑min 为负通道顺序反了交换被减数和减数或取绝对值差分图全黑max 为 1没做对比度拉伸归一化到 0 到 255差分图全黑max 为 0该方向确实无差异换通道组合或转位平面差分图全白极性反了取反后重新保存图有轮廓但糊缩放用了插值改用最近邻插值图有轮廓但满屏雪花掩码之外还有随机噪声提高阈值或做中值滤波图案是转置的行列索引搞反检查arr[:, 0]与arr[0, :]5.2 uint8 下溢最容易踩的一个坑这个必须单独拎出来说。如果你直接用 NumPy 的 uint8 数组做减法5 - 8不会得到-3而是回绕成253。结果就是你的差分图看起来到处都有差异因为所有的负差值都被变成了接近 255 的大数。你会以为是隐藏图案实际上是数据类型的坑。我最早遇到这个的时候盯着那张差分图看了十几分钟怎么都想不通为什么纯色图能算出这么多差异。后来把unique打出来一看取值分布在 0 到 255 全range才反应过来是下溢。正确的写法是先把两个通道都astype(np.int16)或者np.int32做完减法再决定怎么处理。如果图的位深是 16 位那int32才够用因为 65535 减 0 会超出 int16 的范围。注意所有涉及通道相减、绝对值、求和的运算一律先转换成有符号的宽类型不要相信 uint8 的隐式行为。5.3 通道顺序、保存格式与调色板通道顺序这个坑前面提过但值得再强调一次。不同的库读取图片时通道顺序不一样Pillow 是 RGBOpenCV 是 BGR某些情况下 NumPy 的切片还会让顺序看起来对不上。最稳妥的方式是每读完一张图就打一次验证找一个已知点的像素值和你用查看器看到的值对比。对上了后面才能放心算。保存格式也有讲究。中间结果一律存成 PNG不要存 JPEG。因为你的差分结果本来对比度就低JPEG 有损压缩会把这些微小的差异全部量化掉辛辛苦苦算出来的图存完就废了。调色板模式是另一个低调的坑。P 模式图片在内存里每个像素只有一个索引值颜色查表得到。如果你直接用np.array(img)拿到的是索引数组形状是二维的没有通道维度这时候你写arr[..., 1]会直接索引越界。遇到 P 模式要么先convert(RGB)要么就专门去分析索引值本身和调色板表后者有时候才是正解。6. 进阶玩法与个人经验前面六步能解决大部分入门题但比赛里的题总会在某个环节加一层。这一节讲几个变体和提高效率的做法。6.1 通道运算不止做差做差是最常见的但不是唯一的。我遇到过把两个通道相加、把通道值异或、把通道值相乘取模的题目。判断依据还是那个思路先算看结果的unique数量。如果某种运算之后唯一值数量骤降到个位数那基本就是它了。ops { sum: r g b, xor: r ^ g ^ b, abs_rg: np.abs(r - g), mod2: (r g b) % 2, } for name, val in ops.items(): vals np.unique(val) print(name, unique:, len(vals), vals[:8])异或这个操作特别值得试因为异或的可逆性让它天然适合藏东西而且 RGB 异或之后的结果在视觉上完全随机不管你原来是不是纯色图。还有一种变体是把某个通道整体减一个常数制造出通道间的固定偏差。这种情况下差分图会呈现一个均匀的灰底隐藏图案表现为灰底上的一点点亮度变化肉眼很难看出但阈值化之后就很明显。判断方法是看差分的直方图如果有一个特别高的峰那就是固定的背景偏差。6.2 把流程写成一个可复用脚本比赛时间宝贵每次都手敲一遍代码不现实。我的做法是把这套流程封装成一个脚本输入图片路径自动输出所有中间结果到指定目录然后人工扫一遍。脚本大概长这样import os import sys import numpy as np from PIL import Image def stretch(x): x x.astype(np.float64) mn, mx x.min(), x.max() if mx mn: return None return ((x - mn) / (mx - mn) * 255).astype(np.uint8) def run(path, outdirout): os.makedirs(outdir, exist_okTrue) img Image.open(path) print(mode:, img.mode, size:, img.size) arr np.array(img.convert(RGB)).astype(np.int16) r, g, b arr[..., 0], arr[..., 1], arr[..., 2] pairs {rg: r - g, rb: r - b, gb: g - b} for name, d in pairs.items(): s stretch(d) if s is not None: Image.fromarray(s).save(os.path.join(outdir, fdiff_{name}.png)) binv (np.abs(d) 0).astype(np.uint8) * 255 Image.fromarray(binv).save(os.path.join(outdir, fbin_{name}.png)) for i in range(3): for bit in range(3): plane ((arr[..., i] bit) 1).astype(np.uint8) * 255 Image.fromarray(plane).save(os.path.join(outdir, fbit{i}_{bit}.png)) if __name__ __main__: run(sys.argv[1])这个脚本会一口气生成十几张图你只需要快速翻阅有内容的那张会非常显眼。把机械劳动交给脚本把判断力留给自己这是我认为比赛里性价比最高的一个习惯。6.3 比赛节奏与心态Misc 题有个特点就是做出来之后回头看会觉得特别简单但卡住的时候完全没有方向。我现在的做法是给每道题设一个时间盒比如二十分钟二十分钟内按固定流程走完一遍没出东西就先放下去做别的题等心态平复了再回来。因为这类题最怕的就是在一个错误的方向上反复加码越试越焦虑。另外提醒一句这类题的答案形式经常是flag{...}这样的字符串但也可能是一段英文句子、一个坐标、一串数字。看到图案之后先完整读出来不要因为觉得不像 flag就自己否定掉。我吃过这个亏当时解出来的图上写着一串看起来像乱码的字符我以为是噪声后来队友看了一眼说那是 base64解码就是答案。最后分享一个我一直在用的小技巧把每次做出来的图和数据都存下来按题目类型归档。等你做过十几道之后会发现出题人的套路其实就那几种回看之前的记录能省下大量试错时间。这个习惯比任何工具都值钱。我个人在实际操作中的体会是RGB 通道分离这类手法的难点从来不在代码而在相信自己的判断。当你算出差分结果是全零那就是全零不要怀疑是不是代码写错了当你看到图案那就是图案不要怀疑是不是自己想多了。工具给的答案是确定的不确定的只有人的犹豫。