1. 第 19 关到底在考什么玩过 Python Challenge 的人都知道第 19 关是个坎。前面那些关卡还在跟你玩正则、zip、PIL 取色到了这里画风突然变成了一堆二进制位、十六进制文件和隐写术。这关的页面名是bin.html图片素材却往往带着hex字样标题和文件名之间那一点点微妙反差就是整道题最大的线索。很多人第一次打开时都是一脸懵页面上看不到什么文字源码里也没有明显注释只有一张看起来再普通不过的 PNG。可偏偏下一关入口藏在这张图里而且不是靠肉眼找要靠 Python 把像素“拆开”才能看见。如果你已经卡在这里说明你离真正会用 PIL 和 NumPy 处理图像数据只差最后一步。这关适合两类人一类是在刷 Python Challenge 但被劝退的玩家另一类是对 CTF 里的隐写题感兴趣想通过一个完整实例搞懂 LSB、位平面、进制转换的初学者。我下面写的方案全部基于原题常见解法整理代码可以直接复制本地跑不依赖任何网络服务。2. 为什么先想到“位平面”而不是直接看图片2.1 一张普通 PNG 怎么藏信息先想一个问题如果我要把一行文字藏进一张图片里应该怎么做最简单的办法就是“最低有效位隐写”。一张 PNG 图片里每个像素通常有三个颜色通道分别叫 R、G、B每个通道取值 0 到 255。把某个值加 1 或者减 1肉眼根本分不出来。比如一个像素的 R 通道值是128二进制是10000000改成129二进制是10000001在屏幕上看起来几乎完全一样。于是这多出来的 1 个 bit 就可以用来携带信息。第 19 关的思路就是这个。页面标题写bin就是在暗示你“用二进制去看”图片文件名却带着hex又在暗示你“先用十六进制去想”。两下里一对你自然应该想到把图片的数据当成一个二进制串来读而不是当成照片来欣赏。2.2 Python 里bin()和hex()是怎么配合的Python 内置了两个非常不起眼的函数bin()和hex()。bin(255)返回0b11111111hex(255)返回0xff这两个函数做的事情刚好是互逆的显示转换。第 19 关的关卡名bin.html和素材里的hex字样本质上就是在提醒你把十六进制表达的数据用bin()转成二进制再把二进制按 8 位一组翻译成 ASCII。如果你在玩这一关之前没写过隐写题可能完全想不到这一步。但一旦想到“位平面”这个概念题目就立刻变成一道普通的进制转换题了。2.3 解题流程先捋一遍防止越走越偏我在实际解题时不会一上来就写一堆代码而是先把流程固定下来避免在图片上反复试错下载这关的 PNG 素材重命名为本地文件bin.png。用 PIL 打开图片转换成 RGB 模式拿到每个像素的 R、G、B 值。把每个通道的数值和1做按位与运算也就是取最低位。把所有最低位按顺序拼成一个二进制字符串。每 8 个 bit 分成一组用chr(int(segment, 2))转成字符。如果得到的是可读文本里面的英文单词就是下一关 URL 的一部分。你只要按这个流程走就不会被图片本身的样子干扰。卡关的大部分原因其实就是在第 3 步或第 4 步的顺序上出了偏差。3. 实操用 PIL NumPy 提取隐藏数据3.1 环境准备这关不需要太复杂的库装两样就够pillow和numpy。之所以用 NumPy是因为它处理像素矩阵比 PIL 的getdata()快得多而且后面换通道、改扫描方向时特别方便。python -m pip install pillow numpy装完以后在 Python 脚本里这样导入from PIL import Image import numpy as np3.2 读取图片并转成数组先解释一个基础点PIL 打开图片时图片可能是P模式调色板模式或者RGBA模式。为了统一操作最好先调用convert(RGB)。img Image.open(bin.png).convert(RGB) arr np.asarray(img, dtypenp.uint8) print(arr.shape)这个arr的形状是(高度, 宽度, 3)。比如一张 640x400 的图打印出来就是(400, 640, 3)。第三维的三个数字分别对应每个像素的 R、G、B 值。如果你下载的图片本身是RGBA上面这行convert(RGB)会直接把透明度通道丢掉。先别急着判断对不对后面我会讲什么时候需要保留 A 通道。3.3 从每个像素的最低有效位提取 0/1现在做核心操作把数组里每个数字都取模 2。一个整数的二进制最低位就是它除以 2 的余数也就是x 1。bits arr 1 # 元素为 0 或 1 flat bits.flatten() # 按“先从左到右再从上到下”的顺序展开 bit_str .join(map(str, flat))这里有一个容易踩坑的点flatten()默认是按行优先row-major展开。如果隐藏的信息是按列存的你直接展开会拿到一堆没有规律的数据。先别急着改后面我会单独讲怎么处理。把这行bit_str打印出来你会看到类似11000101...的长串。这就是所有像素最低位拼出来的二进制流。3.4 把二进制流翻译成文本二进制流不是随便看的要 8 位一组。ASCII 字符在 Python 里用chr()转即可。text for i in range(0, len(bit_str) // 8 * 8, 8): byte bit_str[i:i8] text chr(int(byte, 2)) print(text[:200])这段代码的意思是每 8 个字符也就是 8 个 bit当成一个二进制数转成整数再用chr()变成字符。如果你按原题的思路走最后输出的文本里会有一个非常关键的英文单词。这个单词就是下一关的页面名。我这边不直接把完整结果贴出来否则你少了一次自己跑出来的乐趣但可以提示一下它是两个英文词连在一起看起来像一个专有名词。3.5 如果直接读出来是乱码试试这三个变体我实际跑这一关时第一次也拿到了乱码后来发现是“字节内的位序反了”。什么意思呢一个字节10000001如果按从右往左读就是10000001反过来变成10000001不对应该是10000001反过来是10000001的镜像我用具体例子说明。假设原始二进制是01000001从左往右读是0x41也就是字符A从右往左读就变成10000010也就是0x82直接乱码。所以当你发现解码结果不是可读文本时优先尝试下面三种调整# 变体 1每个字节内部反转 bytes_rev [bit_str[i:i8][::-1] for i in range(0, len(bit_str)//8*8, 8)] rev_text .join(chr(int(b, 2)) for b in bytes_rev) print(rev_text[:200])# 变体 2只取 R 通道不混合 RGB r_bits arr[:, :, 0] 1 r_str .join(map(str, r_bits.flatten())) print(R channel LSB:, r_str[:64])# 变体 3换一种扫描方向按列展开 col_bits arr 1 col_str .join(map(str, col_bits.transpose(1, 0, 2).flatten())) print(Column-major LSB:, col_str[:64])这些变体不是闲得没事写的。很多题目为了增加难度会把信息按字节反序存或者只藏在 R 通道里。第 19 关的难点也在这里不是提取有多难而是你根本不知道作者当初用的哪一种顺序。4. 常见问题与排查技巧实录这一节我直接整理成速查表方便你卡壳的时候快速定位。现象可能原因处理方式解码出来全是乱码完全没有英文通道选择错误或者 RGB 顺序不对只取 R只取 G只取 B分别试一遍乱码里能看到大量可读字母但中间夹杂符号字节内位序反了使用[::-1]反转每个 8 位组输出以BZh开头隐藏数据被 bzip2 压缩过把二进制流转成 bytes 后用bz2.decompress()输出以x»等不可读开头数据可能经过 zlib 压缩尝试zlib.decompress()提取出来是0b11010010...这种带前缀的字符串你直接把bin()的结果写入了图片解码前先去掉0b前缀图片有多个颜色通道透明背景不见了你用了convert(RGB)把 A 通道丢了改用convert(RGBA)并尝试第四通道最低位4.1 通道顺序问题一张彩色 PNG 的像素格式一般是RGB但有些图片是RGBA也就是多了一个透明度通道。如果你把RGBA图片直接convert(RGB)等于把 alpha 通道整个丢掉。如果隐藏信息刚好放在 A 通道里你读到死都解不出来。处理办法很简单img Image.open(bin.png).convert(RGBA) arr np.asarray(img, dtypenp.uint8) print(arr.shape) # (height, width, 4)然后你就要分别试四个通道for channel in range(4): c_bits arr[:, :, channel] 1 c_str .join(map(str, c_bits.flatten())) text .join(chr(int(c_str[i:i8], 2)) for i in range(0, len(c_str)//8*8, 8)) print(fchannel {channel}:, text[:100])我第一次做类似题目时就因为懒得试 alpha 通道白白折腾了一个小时。4.2 图片模式是P模式怎么办有些 PNG 为了减小体积会使用索引颜色模式。这种模式里每个像素存的是一个调色板索引而不是直接的 RGB 值。如果你直接np.asarray(img, dtypenp.uint8)拿到的数字不是真实颜色而是调色板编号。所以遇到P模式一定要先转成RGB或者RGBA再读取数据。if img.mode ! RGB: img img.convert(RGB)不要试图在P模式下做 1得到的位序列大概率是调色板序号不是像素颜色值没有意义。4.3 提取出来是压缩数据怎么办二进制的隐藏信息不一定直接是明文。有的关卡会在文本外面套一层 bzip2 或 zlib 压缩。判断方法很简单看解码出来的字节开头。如果是BZh说明是 bzip2。如果是x说明是 zlib。如果是PK说明后面可能还有一个 zip 文件。处理代码raw .join(chr(int(bit_str[i:i8], 2)) for i in range(0, len(bit_str)//8*8, 2)) try: decompressed bz2.decompress(raw.encode(latin1)) print(decompressed) except Exception as e: print(not bzip2:, e)raw.encode(latin1)是为了把 0-255 范围内的字符原样转回字节避免 UTF-8 编码捣乱。4.4 输出里出现了关键英文词但不知道怎么进下一关当你能从输出里认出某个英文单词时其实已经算解完了。不要想着把整个文本都理解透彻很多隐写题的明文只是提示不是答案本身。下一关的 URL 结构通常是/pc/hex/你找到的单词.html也就是说你只要把你提取出来的那个英文词替换到 URL 页面上就能进入下一关。这一关最终得到的词指向的页面对应的就是 Python Challenge 第 20 关的入口。5. 这关真正值得记住的解题经验与其说第 19 关考的是 PIL不如说它考的是你有没有“把图片当数据”的思维转变。图片在你眼里是一张照片在 Python 眼里是一堆数字。隐写题的全部秘密就是作者在这些数字的最低位里偷偷塞了几个 bit。我这里再给你三个我自己踩过坑得到的经验。5.1 先看后缀名再看图片内容如果素材是.pngLSB 隐写是可行的。如果素材是.jpg就要小心了因为 JPEG 是有损压缩像素在保存过程中会变直接取最低位基本没用。所以遇到 PNG你才有必要去试 LSB遇到 JPEG反而应该去看 EXIF、文件尾、或者颜色修改痕迹。第 19 关的素材是 PNG所以 LSB 思路完全成立。5.2 不要迷信“一个脚本解出来就完事”很多初学者找到一段代码复制进去跑出来乱码就觉得题目错了。其实隐写题的容错点非常多通道顺序、位序、扫描方向、压缩方式任何一个不对都会失败。我建议你写一个类似下面的多路尝试函数把 4 个通道、2 种位序、2 种扫描方向全部遍历一遍def try_decode(arr, channel, byte_reverse, column_major): channel_data arr[:, :, channel] 1 if column_major: channel_data channel_data.T flat channel_data.flatten() s .join(map(str, flat)) groups [s[i:i8] for i in range(0, len(s)//8*8, 8)] if byte_reverse: groups [g[::-1] for g in groups] text .join(chr(int(g, 2)) for g in groups) return text然后循环 4通道x 2位序x 2方向共 16 种组合。哪个输出像英文哪个就是正确路径。这个方法能解决九成隐写题。5.3 拿到单词后顺手看一眼下一关文件类型第 19 关解出的单词通向第 20 关而第 20 关又是一张新的图。不过第 20 关的素材不再是一张普通 PNG而是一个 CSV 文件。你会发现通关序列里每一关都在给你铺垫下一关上一关让你熟悉二进制与十六进制下一关开始让你把数据文件重新解析成图像。这也是 Python Challenge 好玩的地方——它不是让你背 API而是让你在每一关里重新发现“原来 Python 还能这么用”。我个人最大的体会是这一关卡住的人不是因为不会写bin()或chr()而是因为第一反应里“图片必须用眼睛看”。等你过了这道坎再看后面那些关卡难度其实并不高真正难的是你敢不敢把一个看起来像照片的文件当成一个纯二进制串去处理。这就是第 19 关最值钱的收获。
阅读完成 · 觉得有帮助?