Crypto-XOR2实战:循环密钥与字节反转的完整解密思路
简介一份面向广东大学生网络攻防大赛参赛者、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 bflag{...} # 待加密的明文 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(bflag{) and msg.endswith(b}) and all(31 b 127 for b in msg)) def solve(path): xored load_cipher(path) prefix bflag{ 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(fL{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), repeattail_len): key key_prefix bytes(tail) msg bytes([xored[i] ^ key[i % L] for i in range(len(xored))]) if readable(msg): print(fL{L}, key{key}) print(msg.decode()) return print(not found) if __name__ __main__: solve(output.txt)脚本执行后如果打印出紧凑的密钥和一段明文说明链路是通的。实际运行输出类似L6, keyb\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() # 步骤3hex编码对应到解密的变换链就是一个反向执行的过程hex 解码 - 字节反转 - 循环密钥 XOR 恢复明文这条链的价值在于把「密文到明文」拆成独立小步骤每步只做一件事。写解密脚本时我按链的顺序逐个实现如果某一步结果不对直接在链上定位到那一步检查不需要从头到尾看一遍脚本。密钥恢复环节则变得非常机械已知前缀异或、循环验证、可读性检查代码基本是固定模板。后来我解过的不少 XOR 变体题都是在链上某个位置做手脚有的在 XOR 前先做位移有的在输出前套一层 base64有的把密钥顺序打乱。不管怎么变链的模型不变方向变一下就行。所以我的建议是拿到附件先花三十秒把加密脚本逐行翻译成链再去碰键盘。这个动作不挑题目难度越是短小精悍的题越值得先列清楚再动手。从那以后我每次遇到加密题目都强制走一遍「列链、检查方向、再写脚本」的流程。希望帮到你。本文还有配套的精品资源点击获取