1. 先说结论:这两道题为什么值得自己动手复现
先交代一下背景。我今年备赛蓝桥杯网络安全赛的时候,把前几年的真题从头到尾过了一遍,其中“数字水印”和“RC4解密”这两道题让我印象最深。原因很简单:它们都属于“看起来高大上,实际套路固定”的题型,但你光看别人的WriteUp(也就是常说的WP)会觉得不过瘾——代码贴下来能跑,换个题目就抓瞎,真正卡住你的永远是对原理那一层窗户纸。
我自己复现了两三道同类题目之后,才意识到数字水印和RC4不是孤立的知识点,它们分别代表了隐写术和流密码这两个CTF出题的高频方向。Python在这两类题里几乎是万能工具:图片处理靠PIL和numpy,RC4加解密用pycryptodome或者干脆手写几十行代码,都能完美覆盖。更关键的是,用Python复现一遍,你不仅能拿到flag,还能把整个过程沉淀成一个可复用的脚本模板,下次比赛遇到同类题直接套用,省下大量现场调试时间。
这篇文章适合两类人看。一类是刚接触CTF,想系统理解隐写和流密码的新手;另一类是已经看过一些题解,但总觉得自己“好像懂了、实际又写不出来”的参赛者。我不打算从零普及进制、位运算这些最基础的东西,但会把每一步的原理、代码和验证方法都拆开讲清楚,保证你能照着跑通,并且能举一反三。
很多人有个误区:觉得数字水印听起来像复杂的图像算法,RC4又是密码学里的老牌算法,肯定是难题。实际上蓝桥杯这类比赛考的是“能不能在有限时间内完成题目”,而不是“能不能发明新算法”。所以它们的实现难度被出题人刻意控制在“了解原理就能写出来”的范围。下面我就按自己的实操顺序,一条条给你拆解。
2. 数字水印题:从“图片里藏东西”到完整提取链路
2.1 先给题目定个调:LSB隐写还是频域水印
数字水印这个说法听起来很学术,但比赛里的实现方式通常就两种:空域隐写和频域隐写。
空域隐写最常见的是LSB(Least Significant Bit,最低有效位)替换。原理一点都不复杂:图片的每个像素由RGB三个通道组成,每个通道取值是0到255,也就是8位二进制。你修改最末尾的1位,人眼基本分辨不出颜色变化,但却能在每3个像素里藏下整整1个字节的信息。如果图片是800乘600分辨率,一共48万个像素,可以存约16万字节,足够塞下一张二维码或者一段短文本了。
频域水印则是另一条路线,常见的是DCT(离散余弦变换)或者DWT(离散小波变换)。做法是把图像变换到频域,在特定频率系数里嵌入信息,再反变换回空间域。这种方式的抗压缩能力强一些,但肉眼不可见性更好,提取时也需要做同样的变换再取值,步骤比LSB多了好几个,所以比赛里反而不如LSB出现频率高。
判断一道题到底是哪类,我有个很实用的套路:拿到图片先跑一下binwalk或者strings,看看有没有明显的附加数据。如果图片本身没发现额外文件,那大概率就是藏在像素里了。这时候再用numpy把图片读成数组,检查最低位是否有规律;如果最低位的数据看起来像乱码但又不是自然图像的高频噪声,那十有八九就是LSB水印。相反,如果你发现图片的像素分布整体正常,但经过DCT后某些系数异常偏大或者成对出现,再考虑频域方案。
我复现的那道题,就是经典的LSB隐写。图片是一张看起来平平无奇的风景图,题目描述只写了“水印”两个字,没有任何多余提示。这种题目最考验基础功,因为你只有先排除掉表面的字符串隐藏,才会想到去看像素低位。
2.2 LSB水印提取的完整代码模板
我直接给你看我能跑通的代码。环境是Python 3.10,依赖只有Pillow和numpy,比赛现场用这两样就够了。
from PIL import Image import numpy as np def extract_lsb(img_path, channel_order='rgb', bit_plane=0): """ 提取LSB隐写信息 channel_order: 'rgb' 或 'bgr',根据图像库的通道顺序调整 bit_plane: 提取第几个最低位,默认0表示最低位 """ img = Image.open(img_path) # 统一转成RGB,避免PNG带透明通道造成干扰 img = img.convert('RGB') arr = np.array(img) # 如果题目给的图是BGR顺序,需要交换通道再处理 if channel_order.lower() == 'bgr': arr = arr[:, :, ::-1] # 提取所有像素指定bit的值 plane = (arr >> bit_plane) & 1 # 把按位取值拼接成字节 bits = plane.flatten() # 每8个bit组成1个字节 byte_chunks = bits[:len(bits) - len(bits) % 8].reshape(-1, 8) bytes_data = np.packbits(byte_chunks.astype(np.uint8), axis=1) data = bytes_data.flatten().tobytes() return data if __name__ == '__main__': data = extract_lsb('watermarked.png', 'rgb', 0) # 直接打印前256字节看看内容 print(data[:256]) # 保存成文件备用,如果提取出来是图片或压缩包,这里就能发现 with open('extracted.bin', 'wb') as f: f.write(data)这段代码的逻辑分四步:先把图片像素读成numpy数组,然后取出每个字节的最低位,接着把这些位按8个一组拼回字节,最后把字节流存成文件。注意我在代码里留了一个channel_order参数,是因为PIL的RGB顺序和OpenCV的BGR顺序不同,如果题目图片是用OpenCV生成的,你用PIL直接提就会遇到“内容对不上”的问题。
拿到提取结果后不要急着分析,先判断文件头。如果开头是PK,那是个ZIP;如果是89 50 4E 47,那是PNG图;如果是文本内容,直接打印就能看到。我当时提取出来的是一个PNG二维码,扫出来就是flag。这是最常见的出题链路:LSB藏二维码二维码扫出flag,或者LSB藏一个ZIP压缩包压缩包里再藏flag。
2.3 频域水印的识别与还原思路
虽然我复现的题目是LSB,但比赛里频域水印这几年也有出现,我把思路也一并说下,免得你真遇上了无从下手。
频域水印的提取思路:对图片分块做DCT变换,然后从特定位置的系数里读出嵌入的信息。嵌入时通常是把某两个频率系数的大小关系作为“0”和“1”的编码。比如约定第(i,j)个系数大于第(j,i)个系数表示1,否则表示0。所以你提取时要做同样的事:分块、DCT、比较系数、拼比特。
import numpy as np from scipy.fftpack import dct, idct # 比赛环境如果没有scipy,可以自己写DCT def extract_dct_watermark(img_path, block_size=8, positions=((4, 5), (5, 4))): from PIL import Image img = Image.open(img_path).convert('L') # 转灰度 arr = np.array(img, dtype=np.float64) h, w = arr.shape bits = [] for y in range(0, h - block_size, block_size): for x in range(0, w - block_size, block_size): block = arr[y:y+block_size, x:x+block_size] dct_block = dct(dct(block.T, norm='ortho').T, norm='ortho') # 比较两个中频系数的相对大小 if dct_block[positions[0]] > dct_block[positions[1]]: bits.append(1) else: bits.append(0) bytes_data = [] for i in range(0, len(bits) - 7, 8): byte = 0 for bit in bits[i:i+8]: byte = (byte << 1) | bit bytes_data.append(byte) return bytes(bytes_data)这段代码里我用了scipy的DCT函数,比赛一般允许你装第三方库。如果环境受限,你也可以用numpy的矩阵乘法自己实现8×8 DCT,代码量多几十行而已。
频域水印的坑在于:它提取出来的信息往往是经过纠错编码或者重复嵌入的,不会像LSB那样直接给你一个完整文件。比如出题人可能把一段文本重复嵌入了好几遍,提取时你需要按多数表决的方式还原。遇到这种情况,我建议提取完先看前几个字节是不是有规律的重复,如果是,就做逐位多数表决;如果要提取的内容是图片,可能还要补上缺失的同步信息,那就得逆推嵌入时的块排列顺序了。说实话,这类题的计算量大,现场写脚本容易出错,我一般优先做LSB,频域题留到后面有时间再啃。
2.4 实操中我踩过的最大的坑
第一个坑是通道顺序。我第一次复现时用PIL直接提取,出来的是一堆乱码,后来仔细检查才发现原题图片是用OpenCV生成的。OpenCV的默认通道顺序是BGR,我用RGB顺序提取,每三个字节里就有两个字节顺序是反的,自然解不出内容。这一点强烈建议在脚本里预留通道顺序参数,不要写死在代码里。
第二个坑是图片格式转换造成的干扰。如果原图是JPEG,经过有损压缩后像素值会发生变化,原本嵌入的LSB位会被破坏。我这道题原图是PNG,所以能顺利提取,但如果你拿到的是JPEG格式的题图,先看看题目有没有额外提示“图片未被压缩”。如果确实被压缩了,LSB这条路基本走不通,必须换思路,比如检查JPEG的EXIF信息或者DCT系数。
第三个坑是提取出来的字节顺序。LSB提取有两种常见的位序选择:一种是把像素最低位从左到右依次排列,每8个bit组成一个字节时高位在前;另一种是低位在前。具体用哪一种,取决于嵌入时的代码怎么写。我的建议是写提取脚本时同时提供两种位序模式,跑一次对比结果,看哪种能跑出有效文件头。判断标志很简单:ZIP文件头固定是“PK”,PNG文件头固定是“89 50 4E 47”,一比对就知道对没对。
第四个坑是额外数据。有些题目的LSB隐写只填了图片的一小部分区域,其余像素的最低位本来就是自然图像的随机噪声。如果直接全图提取,前面可能是一段有效数据,后面跟着大量无意义字节。这时候别慌,把整个字节流保存下来,用binwalk扫描一下,看有效数据从哪里开始、到哪里结束,再手动截断就行了。
3. RC4解密:从“算法原理”到一行行实现
3.1 RC4与解密题的出题习惯
RC4是一种流密码,名字听起来古老,但比赛的出题率一点不低。原因是它实现简单,几十行代码就能写完,而且加密后的数据一般以Base64编码呈现,识别度很高。RC4的核心逻辑就两个部分:密钥调度算法(KSA)和伪随机子密码生成算法(PRGA)。
KSA的过程是把一个256字节的S盒用密钥打乱。PRGA则是用S盒不断生成伪随机字节流,也就是密钥流。加密时把密钥流和明文逐字节异或,得到密文;解密时用同样的密钥再生成一次相同的密钥流,和密文异或,就能还原出明文。
比赛中的RC4题几乎不会让你直接拿库调用,因为那样太简单了。出题人通常会做一层包装:要么密文先Base64解码再交给RC4,要么密钥藏在某个文件里需要先提取,要么先用RC4加密一段固定数据作为校验,验证密钥是否正确。还有些变种题会改变S盒初始化的长度,比如只用密钥的一部分做初始化,或者打乱S盒初始值。所以我建议你不仅会调库,还要能手写RC4,这样遇到变种可以随时改。
我复现的那道题给了一个文本文件,内容是Base64字符串,题目描述只提到“RC4”和一个密码本。看起来简单,但我在第一次尝试时直接调pycryptodome的ARC4模块解密,发现输出全是乱码,后来才发现密码本里的密钥需要先经过一次MD5哈希,哈希后的十六进制字符串才是真正的RC4密钥。这种“包装一下密钥”的出题套路特别常见,所以反复试错是正常的。
3.2 手写RC4解密脚本
有些环境没有pycryptodome,或者出题人把RC4改了参数,这时候手写就是唯一选择。我手写的版本如下,代码不长,但每一步都需要理解。
def rc4_ksa(key_bytes): """密钥调度算法:用密钥打乱S盒""" S = list(range(256)) j = 0 key_len = len(key_bytes) for i in range(256): j = (j + S[i] + key_bytes[i % key_len]) % 256 S[i], S[j] = S[j], S[i] return S def rc4_prga(S, data_len): """生成密钥流并逐字节异或""" i = 0 j = 0 keystream = [] for _ in range(data_len): i = (i + 1) % 256 j = (j + S[i]) % 256 S[i], S[j] = S[j], S[i] K = S[(S[i] + S[j]) % 256] keystream.append(K) return keystream def rc4_decrypt(cipher_bytes, key_bytes): S = rc4_ksa(key_bytes) keystream = rc4_prga(S, len(cipher_bytes)) plain_bytes = bytes([c ^ k for c, k in zip(cipher_bytes, keystream)]) return plain_bytes if __name__ == '__main__': import base64, hashlib cipher_b64 = "这里是题目给的Base64字符串" cipher_bytes = base64.b64decode(cipher_b64) # 实际运行时密钥往往需要额外处理 raw_key = b"password_book_key" key_bytes = hashlib.md5(raw_key).hexdigest().encode() plain = rc4_decrypt(cipher_bytes, key_bytes) print(plain)这段代码的逻辑顺序是:先把密钥字节流交给rc4_ksa生成初始S盒,再用rc4_prga逐字节生成密钥流,最后和密文逐字节异或。需要注意的一点是,RC4解密和加密完全对称,所以同一个函数既可以解密也可以加密,这就意味着你只需要实现一次。
我把密钥写成MD5再转十六进制字符串,是因为我复现的那道题就是这么设计的。你在实际做题时可以根据出题提示灵活调整,key_bytes可以换成任意处理后的字节流。判断密钥是否正确,最直接的方法是解密结果开头是否有可读字符,比如明文以“flag{”开头。如果解出来是无规则乱码,先别怀疑算法,先怀疑密钥处理方式。常见的处理包括MD5、SHA1、Base64解码,甚至直接把密钥文件原样读入。
3.3 面对“变种RC4”时怎么调整
变种RC4的核心在于改变了KSA或PRGA的某个环节,但只要你能识别出“它还是RC4”,就能针对性修改。
第一种变种是密钥长度截断。标准RC4理论上密钥长度1到256字节都可以,但有些题只取密钥的前8字节或前16字节参与KSA,其余字节被忽略。这时候你直接把key_bytes换成截断后的版本即可。我有一个经验:先用题目给的原始密钥跑一次,如果解出来乱码,就把密钥逐个尝试截断,从前8字节开始试到全量,总有一款能对上。
第二种变种是S盒初始值被修改。标准S盒初始是0到255按顺序排列,但有些题目把S盒初始值改成逆序或者某个伪随机序列。这种题光看代码很难发现,除非你拿到出题人给的“提示”,或者解密后能看到特征明文。处理方法是反推:如果题目给了你一个已知明密文对,你就可以从KSA的逻辑反推S盒初始值,但这个方法工程量大,现场一般不划算。更现实的做法是先用标准RC4跑,如果失败,再仔细观察题目代码片段,看有没有对S盒做额外初始化操作。
第三种变种是加密数据不止一次RC4。比如先用密钥A加密,再用密钥B加密,解密时就要用B再解一层、A再解一层。这种情况密文长度不变,但你要尝试多次调用rc4_decrypt,每次用不同的密钥。判断是不是多次加密有个简单方法:如果第一次解密后输出仍然是Base64或看起来像密文,不是明文,那就可能是多层加密。这时候把第一次结果再次Base64解码再解一遍,直到出现可读内容为止。
我建议你在复现过程中养成一个小习惯:每解出一层,就把中间结果以十六进制形式打印出来,并判断它是可打印文本、Base64、ZIP文件头还是纯乱码。这样一层一层剥下去,就算题目包装了三层加密,你也能有条理地推进,而不是拿到乱码就慌。
3.4 解密完怎么确认结果
拿到解密结果不要只看“是不是flag”,还要注意编码和格式。我复现的那道题最后得到的明文不是纯文本,而是包含一段JSON加一个PNG图片的数据块。如果你直接print字符串,图片部分会显示成乱码,容易被误判为解密失败。所以我的习惯是先把解密结果保存成文件,再用file命令或者binwalk看它的真实类型。
这种“解出来是一个文件而不是纯文本”的出题方式这两年越来越多,因为单纯的RC4解密已经偏简单了,出题人希望多考一步文件识别。你最好在脚本里把输出同时打印成文本并写入二进制文件,然后再对文件做类型判断。如果明文里有flag且是纯文本,直接打印就能看到;如果明文里包含图片,保存成二进制后打开图片即可。另一个需要留意的点是编码。题目里的Base64字符串可能不是标准的Base64,有时候是Base64 URL安全变体,把“+”和“/”换成了“-”和“_”,Python标准库的base64.b64decode会直接报错。遇到这种情况,用base64.urlsafe_b64decode替代即可。
4. 常见问题与排查技巧实录
把两题复现完之后,我把中间踩过的坑整理成了一张排查表,现场做题时可以直接对照,省去很多重复尝试。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| LSB提取结果是乱码 | 通道顺序写反 | 切换RGB/BGR再试 |
| PNG格式题图LSB提取失败 | 图片不是PNG而是被改名 | 用file命令确认真实格式 |
| DCT水印提取后重复内容 | 嵌入时做了重复冗余 | 按多数表决还原 |
| RC4解密后全是乱码 | 密钥预处理方式不对 | 尝试MD5/SHA1/Base64等变换 |
| RC4解密后是Base64 | 多层加密或包装 | 继续解码并用RC4再解 |
| Base64解码报错 | URL安全变体 | 用urlsafe_b64decode |
| 打印明文出现乱码 | 明文含二进制文件 | 保存为文件再识别类型 |
| 提取的ZIP解压失败 | 提取范围包含噪声 | 用binwalk定位有效数据位置 |
这表里的前两条就救过我一次。有次我拿到的题图文件名是.png,但用file命令一看实际是JPEG,我还在傻傻做LSB提取,折腾半小时才知道路走错了。所以看到“图片题”第一件事是看真实文件类型,而不是被扩展名带着跑。
还有一个细节想单独说说:关于图片的最低有效位和二值化处理。很多新手提取完LSB位后,直接把这些0/1输出成文本,发现是乱码,就以为失败。其实正确思路是把提取出的字节流保存成文件,再用文件类型工具识别。有一类题最喜欢在LSB里藏ZIP,然后ZIP里放加密的图片,图片里再藏一层信息,这种链路如果只盯着文本输出,永远发现不了真相。
RC4这边,我遇到过最阴间的坑是密钥文件本身的换行符没有去除。从文本文件读进来的密钥末尾可能带一个换行符或者回车换行,这会导致密钥和出题人用的不一致。我现在的做法是统一对密钥做strip()处理,把首尾空白去掉,如果还不行,再考虑其他预处理。另外,有些题目会对密钥做UTF-8编码再进RC4,如果你的key_bytes是直接用了字符串的UTF-8编码,而题目先做了一次Unicode转字节再进RC4,也会解不出来。这种通常需要看题目给的代码片段才能确定,现场多试几种编码方式不会错。
5. 把代码沉淀成自己的“竞赛工具箱”
5.1 工具库选型:哪些Python库够用且必带
我在复现这两道题时,实际用到的第三方库很少:Pillow负责图片读取,numpy负责像素数组运算,pycryptodome负责标准RC4和常见哈希,另外binwalk是命令行工具,用来确认文件类型和扫描隐藏数据。
如果比赛环境允许你预装库,我建议提前在本地测试好以下组合,而不是现场慢慢装:
pip install pillow numpy pycryptodomesystem自带工具里,file、binwalk、strings这三个命令极度有用。strings能直接扫出图片里是否藏了明文字符串,比如“flag{”字样;binwalk能识别图片尾部是否拼接了ZIP或者RAR;file一眼看出真实文件格式。比赛时先用这三个命令做“快筛”,能省一大半时间。
不要迷信某些“全自动隐写工具”,比如那些把几十种隐写算法集成在一起一键扫描的工具。真实比赛里,这类工具一般需要你自己判断算法后再选择相应模块,而且很多工具对图片通道顺序、位序等细节处理得并不好。自己写脚本反而更可控,因为你能清楚知道每一步在做什么。但这不代表不用工具,binwalk和strings这种轻量命令行工具,是帮你在前期缩小范围的好帮手。
5.2 脚本模板化的两个实用建议
第一,把每个算法封装成独立函数,并且参数化选项一次给全。比如前面LSB提取函数里有channel_order和bit_plane两个参数,RC4解密函数里有key_preprocess参数用于选择密钥预处理方式。这样现场一种参数不行,改一行就能换另一种,不用改逻辑重写。
第二,脚本里统一把中间结果写到磁盘文件。我复现时习惯把LSB提取结果、RC4每层解密结果都保存成独立文件,命名带序号,比如step1_lsb.bin、step2_rc4_layer1.bin。这样一旦后续发现某一步判断错了,可以直接从那个中间文件继续调试,不用重新跑全链路。尤其在高强度比赛里,重新加载一张图、重新跑解密可能只需要几秒,但“手动重跑整个流程”却很容易让人疲劳出错。
第三,也是最重要的,给脚本加一个“文件头识别”函数。因为几乎所有隐写提取或者解密题,结果都要以某种文件格式呈现,识别文件头能迅速确定结果有没有问题。我封装了一个简单的函数:
MAGIC = { b'\x89PNG': 'png', b'PK': 'zip', b'Rar!': 'rar', b'GIF8': 'gif', b'\xff\xd8\xff': 'jpeg', b'BM': 'bmp', } def sniff_filetype(data: bytes): for magic, name in MAGIC.items(): if data.startswith(magic): return name # 如果前128字节里大量可打印字符,则认为可能是文本 printable = sum(1 for b in data[:128] if 32 <= b <= 126) if printable / len(data[:128]) > 0.85: return 'text' return 'unknown'这个函数短小但特别实用。比赛时我几乎每解一层就调用一次,看结果到底是什么类型,再决定下一步怎么做。文本、图片、压缩包,三类结果各有各的后续处理方式,识别清楚就等于方向正确。
5.3 做题策略:先把“能验证答案”的题做透
我发现一个普遍现象:很多人比赛时总是在难题上死磕,结果简单题反而没做干净。数字水印和RC4属于典型的“答案可验证”题型——你能确认是否解出flag,不像有些逆向题还需要动态调试半天才能确认结果。所以我的策略是:比赛开始后先花五分钟把所有题目文件都过一遍,用strings和file快速标记出哪些题可能涉及隐写或流密码;然后优先做那些“有明确输出判断标准”的题,比如能直接提取文件、能直接解密出可读文本的题。这类题一旦解出,基本就是稳稳拿分。
另外建议你在备赛阶段把今天这两个题目做成一份标准操作手册:遇到图片题,从文件类型识别、strings扫描、LSB提取、DCT提取这个顺序去排查;遇到密文题,从Base64识别、RC4标准解密、密钥预处理、多层解包这个顺序去尝试。这套流程你练熟之后,比赛时的反应速度会快很多,因为大部分操作已经变成肌肉记忆了。
说实话,蓝桥杯网络安全赛这类竞赛的题目难度并不在于“算法有多深”,而在于“你对常见套路是否足够敏感”。数字水印和RC4两道题,一个偏向数据处理与观察力,一个偏向算法实现与调试耐心,恰好覆盖了CTF入门阶段需要重点培养的两项能力。我把它们完整复现过一遍之后,最大的收获不是记住了脚本,而是建立了“先判断类型、再按模板处理、最后验证文件头”的做题心法。
最后再分享一个小技巧:如果你发现自己写的脚本和网上题解行为不一致,不要急着抄题解,先对比两者的某一层中间结果。比如LSB提取完的字节流前几个字节、RC4解密完第一轮的十六进制,这些中间值能很快定位是哪一步出现了差异。比赛时最忌讳的就是“复制粘贴、跑通就跑、跑不通就慌”,学会调试中间结果,远比背下十个工具调用更重要。