简介:一份面向广东大学生网络攻防大赛参赛者、CTF Crypto方向练习者的crypto-xor2题目附件包,重点考察异或(XOR)加密原理与密文分析思路。资源包共2个文件,压缩后仅460B,包含密文文件cipher和同名Python脚本task.py,后者用于复现加密流程、定位密钥生成逻辑,是整个题目最直接的解题线索。已有516人学习或下载。题目要求选手从XOR运算“相同为0、不同为1”的位特性出发,结合已知明文攻击、重复模式检索、频率统计等手段推断密钥;配合task.py提供的算法细节,能够形成“解析脚本→还原密钥→异或解密”的完整闭环。学习这份附件,既能掌握单钥异或加密的脆弱点,也能积累CTF赛中处理小体积密文样本、编写Python解密脚本的实战经验,适合在赛前做针对性训练并延伸理解常见流密码思路。
1. 攻防大赛中的 crypto-xor2:越简单的题越容易翻车
那年的网络攻防大赛,Crypto 方向出了一道 crypto-xor2。附件不复杂,一个加密脚本一个十六进制文件,脚本里白纸黑字写着 XOR。可我第一次跑解密,输出全是乱码,这才意识到 xor2 里的 2 不是随便起的。
这题看着简单,坑却藏在两层变换里:循环密钥 XOR 之外,输出前整体反转了字节序。跳任何一层,解出来的都是乱码。这篇会讲透怎么拆两层变换、怎么用已知明文恢复密钥,以及完整解密脚本。
适合两类人:准备网络攻防大赛的选手,以及刷题总卡在 XOR 和编码转换上的朋友。看完照着做一遍,下次遇到同类题就有标准动作了。
2. 拆附件:加密脚本和输出文件里藏着哪些判断依据
2.1 附件通常长什么样
在比赛环境里,XOR 类题目最常见的配置是两个文件:一个 Python 加密脚本,一个包含密文输出的 txt 文件。这类题目的共同特征是加密逻辑本身不强,考的是能不能把加密过程完整还原。脚本越短,越容易在不起眼的地方埋操作,这是比赛出题很喜欢用的手法。
我拿到的附件里,核心文件是 encrypt.py 和一个 output.txt。output.txt 的内容是一长串十六进制字符,肉眼扫过去没有规律。加密脚本总共不到 20 行,这个体量在 Crypto 签到题和进阶题之间算是典型。签到题一般一个函数就能看完,进阶题则在某个参数上做文章,比如密钥长度隐藏、多轮异或、输出前变形。
拿到附件的第一件事,不是打开编辑器写解密脚本,而是把加密脚本从头到尾读一遍,把每一步操作按顺序写下来。这一步很多人跳过,最后付出的代价比想象中大。我习惯把脚本里的操作按执行顺序编号,编完号再决定先处理哪一步。这个习惯在处理多层变换时能省下大量排错时间。
2.2 加密逻辑逐行拆解
下面这段是我从附件加密脚本里整理出来的核心逻辑,变量名做了脱敏处理,操作顺序保持一致:
from binascii import hexlify KEY = b'??' # 未知密钥,长度未知 FLAG = b'flag{...}' # 待加密的明文 def encrypt(msg, key): k = len(key) # 第一层:循环密钥逐字节异或 xored = bytes([msg[i] ^ key[i % k] for i in range(len(msg))]) # 第二层:整体字节序反转 return hexlify(xored[::-1]).decode()output.txt 里存的就是这个函数的输出,一个十六进制字符串。把这段逻辑拆开看,加密过程只有两个动作:
第一个动作是异或。明文和密钥逐字节异或,密钥用完后从头再取。这种写法在 Python 里就是key[i % k],它决定了密钥长度是循环周期。循环密钥 XOR 在比赛中出现频率极高,本质是维吉尼亚密码的字节版,比单字节 XOR 多了一个周期概念。
第二个动作是反转。异或得到的字节流整体倒序,再转成十六进制写入文件。两个动作合起来正好对应题目名里的 xor2:第一层是 XOR,第二层是位置变换。很多人在第一步就栽了,只盯着异或那行,完全没注意到后面的切片反转。
2.3 两个关键参数的判断依据
读脚本时有两个参数决定解密脚本怎么写。
第一个是密钥循环方式。key[i % k]说明密钥循环使用,周期为 k。这种情况下,整段明文加密就是 repeating-key XOR。它和单字节 XOR 的最大区别在于:单字节 XOR 的密文分布保留明显字母频率特征,循环密钥会把频率特征打散。从 output.txt 里直接看,如果开头的字节分布比较均匀,基本可以判断不是单字节 XOR。
第二个是输出前的变换顺序。这里有一个关键点:xored[::-1]发生在 hexlify 之前。也就是说,output.txt 里的十六进制字符串,从左往右第一个字节对应原始字节流的最后一个字节。解密的第一个动作必须是反转,而不是直接 fromhex 后猜密钥。这个顺序写反,后面所有对位都会错位。
怎么从密文本身验证判断?一个办法是看长度。输出字符串长度是偶数,说明它是完整字节流的 hex 表示,明文长度等于字符串长度的一半。另一个办法是回到脚本找所有疑似变换的行,把「异或—反转—hex 编码」这个链条在纸上列出来。我一般会这么比:先看有没有 hexlify、base64,再看有没有循环密钥,最后看输出前对字节流做了什么处理,三个点排查完,加密逻辑就清楚了。
3. 攻击思路:为什么 XOR 题靠已知明文就能破
3.1 为什么不直接爆破全量密钥
拿到这类题,很多人第一反应是遍历密钥。想法很直接:密钥就藏在某个范围里,试到能解出 flag 为止。但问题在于,如果密钥每一位都可能落在 0 到 255 之间,密钥长度为 L 时暴力空间就是 256 的 L 次方。L 取 6 就是 280 万亿种组合,本地跑一天都跑不完。
但 XOR 有一个别的加密方式没有的性质:异或是对称运算,c = p ^ k天然等价于p = c ^ k。只要知道密文 c 和明文 p 中的任何一个,就能直接恢复密钥 k。这就是已知明文攻击的基础。比赛给的明文不是完全未知——flag 的标准格式摆在题目描述里,通常以flag{开头、以}结尾。光是前缀就已经提供了五个字节的已知明文。
所以问题的复杂度从「爆破全量密钥」降成了「验证密钥」,两者代价不在一个数量级。很多选手一上来就陷入暴力枚举的思维定式,忽略题目里明晃晃给的格式信息,这是 XOR 题最常见的弯路。
3.2 先还原第二层,再谈密钥恢复
攻击顺序上有一条铁律:任何非 XOR 的变换都要先还原。因为已知明文攻击依赖密文和明文逐字节对位,如果字节序是反的,密文第 0 个字节对应的是明文最后一个字节,拿前缀去异或密文前缀,得到的根本不是密钥。
解密第一步是处理 hex 字符串、反转字节流:
from binascii import unhexlify raw = open('output.txt').read().strip() xored = bytes.fromhex(raw)[::-1] # 先转换,再反转这里有个常见写法问题:bytes.fromhex(raw)返回的是字节数组,反转得到的就是异或层密文。如果直接反转字符串再 fromhex,虽然结果等价,但思路不清晰。先转 bytes 再反转,代码读起来和攻击逻辑一致。反转之后,xored[i]就和明文的第 i 个字节对上了,这时用前缀flag{的 ASCII 值逐个异或xored前五个字节,就能得到密钥在前五个位置上的片段。
3.3 用已知明文定位密钥长度和密钥内容
有了密钥片段,接着的问题是密钥总长多少。如果密钥长度小于等于已知明文长度,密钥已被完整恢复,直接解全明文。如果密钥比已知明文长,剩余位置需要枚举。
我一般会这样处理:先假设密钥长度在 1 到 8 之间,每个长度试一遍。对每个 L,用flag{前五个字节异或密文前五个字节,得到一个长度 5 的密钥片段;当 L 大于 5 时,用片段加未知尾部组合去解全文,看解出来是否可读。未知部分如果只有 1 到 3 个字节,穷举空间是 256 到 1677 万,配合可读性检查几秒钟就能跑完。
判断解出来的对不对,标准有两层:一是明文以flag{开头、以}结尾,二是中间所有字节都是可见 ASCII 字符。两个条件同时满足,基本可以确定密钥和明文都对。这种「已知前缀 + 可读性检查」的组合是 CTF XOR 题最稳的套路。还有一个辅助手段可以提前缩小 L 的范围:在密文里找重复片段,统计片段间距的公约数,这个思路来自 Kasiski 检测,遇到长密钥时很好用。
4. 完整复现:一个脚本跑通解密全流程
4.1 环境准备
解密脚本只需要 Python 3,不需要第三方库。比赛机 Python 版本通常在 3.8 以上,bytes.fromhex和列表推导式足够完成全部工作。
文件结构很简单:output.txt和solve.py放同一目录。output.txt里只保留十六进制字符串,不要有多余空行或开头空格,否则strip()之后可能残留异常字符。如果你的题目附件里还有额外说明文件,比如提示 flag 格式或者密钥长度,优先按说明调整脚本参数。
4.2 解密脚本
import sys from itertools import product def load_cipher(path): raw = open(path).read().strip() return bytes.fromhex(raw)[::-1] # 先还原第二层:字节反转 def readable(msg): return (msg.startswith(b'flag{') and msg.endswith(b'}') and all(31 < b < 127 for b in msg)) def solve(path): xored = load_cipher(path) prefix = b'flag{' for L in range(1, 9): if L <= len(prefix): key_part = bytes([xored[i] ^ prefix[i] for i in range(L)]) msg = bytes([xored[i] ^ key_part[i % L] for i in range(len(xored))]) if readable(msg): print(f'L={L}, key={key_part}') print(msg.decode()) return else: known = len(prefix) # 已知 5 字节 key_prefix = bytes([xored[i] ^ prefix[i] for i in range(known)]) tail_len = L - known if tail_len > 3: continue # 避免指数爆炸 for tail in product(range(256), repeat=tail_len): key = key_prefix + bytes(tail) msg = bytes([xored[i] ^ key[i % L] for i in range(len(xored))]) if readable(msg): print(f'L={L}, key={key}') print(msg.decode()) return print('not found') if __name__ == '__main__': solve('output.txt')脚本执行后如果打印出紧凑的密钥和一段明文,说明链路是通的。实际运行输出类似:
L=6, key=b'\x1f\x0a\x73\x4e\x21\x09' flag{...}4.3 参数说明与判决逻辑
脚本里三个地方值得展开讲。
第一处是readable()的判定标准。startswith 和 endswith 校验 flag 头尾,中间字符范围 32 到 126,排除控制字符和高位字节。这个标准误判的概率很低,因为随机解出的字节流同时满足三个条件的概率可以忽略不计。如果出现多个候选,再用题目描述里的 flag 格式过滤一遍就能收敛。
第二处是 L 的遍历范围。1 到 8 覆盖了大多数比赛常见的密钥长度;如果题目明说密钥很长,把 range 上限改大即可。tail_len 大于 3 时脚本直接跳过,这是为了避免指数爆炸。真遇到长密钥,先检查明文有没有更长的已知片段,比如题目里可能给出的提示词,再决定要不要扩前缀。
第三处是bytes.fromhex(raw)[::-1]的顺序问题。先转换再反转,和先反转字符串再转换,结果等价,但前者更符合攻击逻辑:转换得到真实字节流,反转恢复 XOR 层。写脚本时顺序写反不影响答案,但会影响排查问题时的思路。
如果题目用的前缀不是flag{,而是ctf{或别的格式,把开头prefix的值改掉重跑即可,算法本身不用动。
5. 避坑清单:XOR 题最容易翻车的五个点
5.1 现象:解出来全是乱码,但每个字节都像是异或过的
原因:加密不止一层,直接对 output.txt 做异或等于跳过第二层变换。字节序不对,密钥对不上位,恢复出来的自然是乱码。
解决:回到脚本,把发生在 XOR 之后、hexlify 之前的操作全部反向执行。字符串反转就反转回去,base64 就 base64 解码,字符位移就反向位移。我的习惯是先在纸上写加密变换链,再写解密链,两边对照检查一次。这题的变换链是「循环 XOR → 反转 → hex 输出」,解密链反过来就是「hex 解码 → 反转 → 循环 XOR」。
5.2 现象:脚本抛 TypeError unsupported operand type(s) for ^=
原因:open().read()得到的是字符串,字符串和整数异或直接报类型错误。这个错一眼就能看出来,但很多人不改类型就继续写下一行,导致后面全乱套。
解决:字符串先转 bytes。十六进制文本用bytes.fromhex(...)一次性完成类型转换和解码;普通字符流用.encode()。起始类型对了,后续异或都在 bytes 之间进行,不会再因为这个报错。
5.3 现象:密钥长度猜成 1,解出来半段可读半段乱码
原因:单字节 XOR 的假设忽略了循环密钥的周期。密文前半段碰巧落在某个模式上看起来正常,后面周期的密钥片段不同,就出现乱码。
解决:单独看每段确实像单字节 XOR,但整体不满足频率特征时,应该怀疑密钥长度大于 1。最直接的验证方式是按候选周期切段做频率统计,或者用上文脚本的 L 遍历法把长度 2 到 8 全试一遍。这种现象最常见的翻车点在于「第一眼像单字节」的直觉,值得留意。
5.4 现象:跑了很久的大循环,最后什么都没解出来
原因:试图在未知密钥长度下爆破全量密钥。256 的 N 次方组合,N 只要到 6 就是本地跑不动的量级。
解决:先降维。用已知明文定位密钥片段,再枚举剩余字节;能推的不要爆,能枚举 256 的不要枚举 256 的 N 次方。比赛里看到暴力量级超过千万级的循环,停下来重新审视题目给的已知条件。这一条也适用于其他加密题型,不只是 XOR。
5.5 现象:解出来了,但提交的时候不对
原因:flag 里可能存在不可见字符,比如 UTF-8 多字节字符,或明文包含换行符。解密脚本恢复了原始字节流,但打印时decode()失败,或终端转义干扰了复制。
解决:不要直接打印,先判断msg是否以flag{开头、以}结尾,再用repr()或把不可见字符转成 hex 输出,亲眼确认字节内容后再提交。可见字符范围判定写对了,下划线、花括号这类常见 flag 字符都能正常捕获。
6. 从 crypto-xor2 到一个通用习惯:解密前先画变换链
这题解完之后,我养成了一个对加密类题目都适用的习惯:写代码前,先把加密过程画成一条变换链。拿本题目举例,加密脚本四行代码对应的链是这样的:
# 原始加密逻辑 xored = bytes([msg[i] ^ key[i % k] for i in range(len(msg))]) # 步骤1:循环XOR reversed_bytes = xored[::-1] # 步骤2:字节反转 out = hexlify(reversed_bytes).decode() # 步骤3:hex编码对应到解密的变换链,就是一个反向执行的过程:
hex 解码 -> 字节反转 -> 循环密钥 XOR 恢复明文这条链的价值在于把「密文到明文」拆成独立小步骤,每步只做一件事。写解密脚本时,我按链的顺序逐个实现;如果某一步结果不对,直接在链上定位到那一步检查,不需要从头到尾看一遍脚本。密钥恢复环节则变得非常机械:已知前缀异或、循环验证、可读性检查,代码基本是固定模板。
后来我解过的不少 XOR 变体题,都是在链上某个位置做手脚:有的在 XOR 前先做位移,有的在输出前套一层 base64,有的把密钥顺序打乱。不管怎么变,链的模型不变,方向变一下就行。所以我的建议是:拿到附件,先花三十秒把加密脚本逐行翻译成链,再去碰键盘。这个动作不挑题目难度,越是短小精悍的题,越值得先列清楚再动手。从那以后,我每次遇到加密题目都强制走一遍「列链、检查方向、再写脚本」的流程。希望帮到你。
本文还有配套的精品资源,点击获取