1. 先把题目看清楚1.1 附件里到底有什么这几天在Bugku平台翻题看到一道 misc 方向的 easy-100。分值不高分类也是典型的入门向按说应该是一道送分题。但我点开附件之后发现这题并没有想象中那么无脑——压缩包里是两个文件一个easy_100.py一个output.txt。Python 脚本里只有加密函数没有 flag真正的密文被单独放在了文本文件里。这种出题方式在平台上不算罕见它考的不是你会不会跑脚本而是你能不能把加密逻辑完整逆转回去写出对应的解密代码。output.txt的内容很短就一行字符串ZGlmZX5vZ2ljbVpwbXNjZlpkdmNYMDU1Nng如果你有 CTF 基础第一反应一定是这看起来像 Base64。没错它确实是 Base64。可当你真的去解码会发现得到的结果并不像正常的文本而是一串带波浪号、带大写 Z 的半乱码dife~ogicmZpmscfZdvcX0556x很多新手到这里就开始蒙以为解错了或者以为题目出错了。但实际上这恰恰是题目设计的一个小陷阱Base64 只是最外面一层壳真正的加密动作在此之前已经发生过。也就是说这道题至少有两层处理一层是编码一层是加密。你光会解 Base64 是不够的还得把前面那层加密也翻过来。1.2 从附件特征判断题型拿到题目先别急着写代码先做两分钟的静态分析。文件名是easy_100.py后缀直接告诉我们这是 Python 脚本意味着题目希望你能读懂脚本里的逻辑甚至自己补全逻辑。output.txt里的密文看起来是标准的 Base64 字符集A-Z、a-z、0-9、、/ 以及末尾的等号长度不算长说明原始数据体量也不大。再结合 100 分的分值来推测这类基础题基本不会上 RSA、AES 这种重量级密码学最常见的就是异或、Base64、凯撒、栅栏、MD5 爆破这类一节课能讲完的知识点。所以思路要优先往简单组合上靠而不是怀疑有什么高深算法。这题就是经典的 XOR Base64 组合明文先跟某个密钥做按位异或再把异或结果做 Base64 编码。加密顺序是先异或后编码那么解密顺序必须反过来先做 Base64 解码再按同样的密钥做异或。这个顺序反转的思想以后做所有混淆类题目都用得上。不管是木马样本里的配置解密还是勒索病毒释放的流量分析底层逻辑都是这个识别出变换链然后从最后一个环节开始一个个反推。easy-100 分数低但它教的东西其实不入门。2. 两个基础原理必须吃透2.1 XOR 为什么能加密也能解密XOR 又叫按位异或运算规则特别简单两个 bit 相同结果是 0不同结果是 1。写成真值表就四行0 ^ 0 0 0 ^ 1 1 1 ^ 0 1 1 ^ 1 0它让密码学爱好者最兴奋的性质是自反性如果先计算 C A ^ B再对结果做一次同样的操作 C ^ B你会神奇地发现结果又变回了 A。也就是说同一个 key 既承担加密又承担解密加密和解密是完全相同的操作。生活里的类比就是一个双向开关。想象你房间里有一盏灯明文是灯的初始状态密钥是按不按开关。加密就是按一次开关改变状态解密就是再按一次开关恢复原样。同一个开关按两次灯的状态必然回到最初。这也是为什么 CTF 题里几乎不会单独用 XOR 保护重要数据——它太容易被逆向了但对于入门题来说它反而是最合适的教学工具。在实际代码里最常见写法是把明文字符串转成数字逐个字节跟密钥数组里对应的字节做异或。之所以逐个字节能成立是因为异或运算对每个 bit 独立生效不需要像乘法除法那样考虑进位借位天然适合做字节流的变换。2.2 Base64 到底在干什么Base64 严格来说不是加密是编码。它把任意二进制数据转换成由 64 个可打印字符组成的文本字符表包括大写 A-Z、小写 a-z、数字 0-9、加号 和斜杠 /最后用等号 做填充。设计初衷很朴素二进制数据里有太多不可见字符直接塞进邮件、URL、JSON 里会出现各种乱码和截断Base64 能把它们变成安全可打印的文本方便传输。编码规则一句话概括把每 3 个字节24 位切分成 4 组每组 6 位然后查表映射成一个 Base64 字符。如果原始字节数不是 3 的倍数就在末尾补等号。所以 Base64 输出的长度通常是 4 的倍数末尾最多两个等号。在 Python 里用标准库就能处理解码只需要一行import base64 cipher base64.b64decode(ZGlmZX5vZ2ljbVpwbXNjZlpkdmNYMDU1Nng)得到的cipher是一个bytes对象里面是解码出来的原始字节。这一步是整道题的剥壳动作剥完之后才能真正看到加密后的数据长什么样。2.3 为什么出题人钟爱 XOR Base64 组合从我做过的很多题来看这种组合几乎是 easy 级别题的默认配置。原因有三。其一实现代码非常短出题人随手几行就能写完不需要引入重量级密码库。其二可逆性强XOR 自反、Base64 可解码考的就是你会不会逆不会出现哈希那种解开无望的情况。其三确实能筛人——能意识到这串东西不是单纯 Base64前面还套了一层异或的人基础是扎实的只会复制粘贴在线工具的人就会卡在那一串半乱码前面。我个人的看法是这种组合其实也是很多真实场景的简化模型。比如有些恶意脚本为了绕过检测会把关键字符串先做 Base64 编码再跟一个固定 key 异或分析的时候就得一层层剥。CTF 题练的就是这种剥洋葱的直觉。3. 逐行读代码把加密流程摸透3.1 题目给的加密脚本完整内容easy_100.py的代码并不长我把它完整贴出来import base64 key [2, 5, 7] def encode(s): tmp [] for i in range(len(s)): tmp.append(ord(s[i]) ^ key[i % 3]) return base64.b64encode(bytes(tmp)).decode() # flag flag{hello_world_ctf_2024} # print(encode(flag))注意脚本末尾的两行是被注释掉的里面写着 flag 明文和调用语句。这明显是出题人故意留的痕迹目的是告诉我们加密算法就是这段代码本身但真正的 flag 需要从output.txt的密文里解出来。换句话说我们手里有加密方法和密文缺少的是执行解密。这段代码里新手最容易卡住的地方有三个ord()是干嘛的、i % 3是什么意思、bytes(tmp)又做了什么。ord(c)是 Python 内置函数返回单个字符的 Unicode 码点。对于英文字母、数字、标点这些 ASCII 字符来说返回的就是对应的 ASCII 码。比如ord(f)得到 102ord(l)得到 108。反过来chr(102)会把数字 102 还原成字符f。i % 3是取 i 除以 3 的余数。i 从 0 开始递增余数会按 0、1、2、0、1、2……这样循环正好对应key数组的三个下标。所以密钥循环周期是 3每个字符跟key里的哪个元素异或完全由它在明文中的位置决定。bytes(tmp)把整数列表转换成字节串。因为 Base64 编码是面向字节的不能直接喂一个整数列表进去必须先转成bytes。转换完之后base64.b64encode(...)返回的是字节串再用.decode()转成普通字符串输出。3.2 把加密流程画成一条链读懂了代码就能把整个加密流程画成一条清晰的链flag 字符串 ↓ 逐个字符转 ASCIIord() ↓ 跟 key[i % 3] 按位异或^ ↓ 得到整数列表转成 bytes ↓ base64 编码 ↓ 输出字符串密文这条链有四个环节但真正需要关注的核心是异或这一步。因为 Base64 是可逆的编码它的作用只是包装异或才是真正改变数据内容的加密动作。而且异或用的 key 是[2, 5, 7]这是固定密钥不是随机生成的这意味着所有字符都用同一套规则处理逆推没有任何障碍。顺带说一下为什么这个 key 选[2, 5, 7]而不是别的大数字因为题目要让异或后的结果尽量落在可打印字符范围内这样编码成 Base64 之前还能被人眼半看懂。如果 key 太大异或结果很容易变成不可见控制字符对新手就不太友好了。这种细节在逆向真实样本时也有体现很多样本的固定 key 都是精心挑选过的为的是让解密后的数据看起来合理。3.3 反转逻辑确定解密方案解密的核心思路就一句话把加密链路从最后一个环节开始逐个倒着执行。密文是 Base64 字符串所以第一步做base64.b64decode得到异或之后的原始字节。第二步做异或逆运算。由于 XOR 的对称性解密用的操作和加密完全相同——用同一个 key、同样的循环取模方式对每个字节再做一次异或就能还原出明文字节。这里完全不用发明什么新算法直接复用加密逻辑里的异或部分。第三步把明文字节转成字符串检查是否以flag{开头。也就是说完整解密脚本的核心结构就是Base64 解码 异或加起来的有效代码不到十行。但前提是你得把这个顺序想明白先解码再异或绝对不要把顺序搞反。搞反了得到的还是一串乱码而且怎么调都调不对。4. 完整解题代码从最短到最稳4.1 三行极简版如果你只是想快速出答案核心代码可以压缩到很短import base64 key [2, 5, 7] cipher base64.b64decode(ZGlmZX5vZ2ljbVpwbXNjZlpkdmNYMDU1Nng) flag bytes(cipher[i] ^ key[i % 3] for i in range(len(cipher))) print(flag.decode())运行结果flag{hello_world_ctf_2024}这个极简版的思路很直接b64decode把密文剥壳成字节然后对每一个字节根据它在密文中的位置对 3 取余拿到对应的 key 元素做异或把所有结果拼成一个新的 bytes最后解码成字符串打印。这里有个 Python 细节值得单独说一下cipher[i]取出来本身就是一个整数所以可以直接跟key[i % 3]做^运算不需要再套一层ord()。很多新手在写解密脚本时会下意识地写ord(cipher[i])结果直接触发 TypeError因为ord()的参数必须是字符而不是整数。这个坑我在后面第 5 节还会展开讲。4.2 可读性更高的注释版极简版适合我已经懂了就想快点看到结果的场景。但如果这份脚本要放进 writeup 里分享或者一个月后回看建议写得更细致一些import base64 key [2, 5, 7] # 加密脚本里泄露的固定密钥周期为 3 cipher_b64 ZGlmZX5vZ2ljbVpwbXNjZlpkdmNYMDU1Nng # 第一步剥掉 Base64 外壳得到异或后的原始字节 cipher base64.b64decode(cipher_b64) # 第二步逐字节异或。解密和加密用同一个 key同一个取模规则 plain_chars [] for i in range(len(cipher)): plain_chars.append(chr(cipher[i] ^ key[i % 3])) # 第三步拼回字符串 flag .join(plain_chars) print(flag)这个版本每一步都有一个明确的注释逻辑跟加密脚本一一对应。加密脚本里用ord(s[i])取字符的码点解密脚本里用chr(...)把异或结果转回字符正好形成对称操作。整段代码无论给谁看都能在几秒内理解来龙去脉。如果你觉得chr()逐个转换太啰嗦也可以用列表推导式一行搞定转换flag .join(chr(cipher[i] ^ key[i % 3]) for i in range(len(cipher)))效果完全一样。4.3 不知道 key 也能解自动暴力枚举版上面的脚本能跑的前提是题目给的easy_100.py里已经泄露了 key 是[2, 5, 7]。但实战中还有一个更常见的情况——出题人把 key 藏起来了只给你密文甚至把脚本删掉或者混淆过。这时候怎么办答案是暴力枚举。easy 题的 key 通常都很小因为 key 一大的话异或结果就会脱离可打印字符范围题目反而不像人类设计的。所以我们可以枚举 key 长度、枚举每个 key 元素的可能取值然后检查解密结果是否以flag{开头是的话基本就是正确答案import base64 from itertools import product cipher_b64 ZGlmZX5vZ2ljbVpwbXNjZlpkdmNYMDU1Nng data base64.b64decode(cipher_b64) for key_len in range(1, 5): for key in product(range(1, 16), repeatkey_len): plain bytes(data[i] ^ key[i % key_len] for i in range(len(data))) if plain.startswith(bflag{): print(key_len:, key_len, key:, key) print(plain.decode()) raise SystemExit运行输出key_len: 3 key: (2, 5, 7) flag{hello_world_ctf_2024}这个暴力脚本的原理不复杂key 长度先从 1 试到 4key 元素范围限定在 1 到 15 之间。每生成一组 key就用它去解一次密判断解出来是不是以flag{开头。因为 CTF 题目的 flag 格式是固定的这个前缀就是一个天然的校验位。实际跑下来用不了几秒钟就能命中。当然枚举范围可以根据题目环境适当调整。如果 key 元素可能超过 15也可以放宽到 1 到 255只是组合数会指数级上涨。在 easy 题里基本不会出现 key 元素很大的情况因为异或大数会直接破坏可打印字符分布聪明反被聪明误。5. 实战问题排查实录5.1 Base64 解码报错或结果为空新手最常见的报错就是binascii.Error: Incorrect padding意思是 Base64 字符串的填充长度不对。出现这个问题的原因一般有三个密文末尾的等号丢了、密文中间混入了换行或空格、复制粘贴时漏了字符。解决方式很简单解码前先做一次清理cipher_b64 cipher_b64.strip()如果密文是从文件里读出来的建议直接按行读取并去掉换行符不要手动复制粘贴。我在实际做题时吃过这个亏把output.txt的内容直接复制进脚本结果尾巴多了一个换行b64decode立刻报错。后面我学乖了凡是涉及 Base64 密文一律先strip()。5.2 解码出来是一串乱码怎么判断是不是对如果你解出来的结果看起来完全不可读比如一堆\x03、\x7f之类的控制字符说明密钥或者顺序可能有问题。这时候不要慌先做两件事。第一确认解密顺序是否对称。加密是 XOR 再 Base64解密就必须是 Base64 解码再 XOR。如果顺序反了结果必然乱。第二确认 key 的取模下标是不是对的。很多人在写解密时会把key[i % 3]误写成key[i] % 3这是两个完全不同的东西。前者是key 循环使用后者是某个 key 元素对 3 取余一旦写错下标越界或者结果错误就随之而来。第三如果确认顺序和 key 都没问题可以检查一下密文本身是否还需要其他预处理。有些题目会在密文前面带一段提示文本比如cipher: ZGlm...这种如果不把前缀去掉解出来当然不对。用正则提取纯 Base64 部分是稳妥做法import re cipher_b64 re.search(r[A-Za-z0-9/], text).group()5.3 decode() 报 UnicodeDecodeErrorbytes对象调用.decode()时默认使用 UTF-8 编码如果字节流中含有不符合 UTF-8 规则的序列会直接抛异常。比如解出来中间有一个字节是0xffUTF-8 解码就会报错。这种情况一般意味着解密过程有误少数情况是明文本身包含了特殊字符。排查时可以先不急着转字符串直接把解密后的 bytes 打印出来看看plain_bytes bytes(cipher[i] ^ key[i % 3] for i in range(len(cipher))) print(plain_bytes)如果打印出来是一串可打印 ASCII 字符再尝试.decode()就稳了。如果确实碰到特殊编码的场景可以用latin-1编码做无损解码因为latin-1对 0-255 的每个字节都有对应字符不会抛异常flag plain_bytes.decode(latin-1)但注意这只是一个不报错的手段不代表结果语义正确最终还是要以是否能匹配 flag 格式为准。5.4 环境与版本问题还有一类问题和算法无关纯粹是环境造成的。比如用 Python 2 跑脚本str和bytes界限模糊base64.b64encode返回的是str而不是bytes导致后续处理全是坑。现在大多数平台的脚本和题解都默认 Python 3建议做题环境直接固定为 Python 3.8 以上版本别给自己加戏。另外如果你在自己电脑上跑脚本没问题部署到在线环境却报错优先检查 Python 版本和标准库版本。这题只用到了base64、itertools、re都是标准库理论上不可能出现缺包问题。真报错一定是你自己代码某处写得不严谨回头逐行对照第 4.2 节的可读版代码检查。6. 经验与扩展思路6.1 这道 100 分题背后真正在考什么表面上看easy-100 考的是会不会写解密脚本实际上考的是三件事。第一看见一段 Python 加密代码能不能快速读懂ord、bytes、取模循环这些基础语法和它们组合起来的语义。第二能不能识别出编码和加密的区别明白 Base64 只是壳、XOR 才是核心变换。第三能不能把加密链路完整反转做到顺序不变、操作对称。这三件事里第三件最重要。以后你遇到的题目不会总是这种明牌玩法更多情况下脚本是被混淆、被截断、甚至只给你一段密文和一个被删掉 key 的残缺代码。但只要你脑子里有剥洋葱的意识就能一步一步把链路还原出来。这就像开车一样新手只会看导航说左转左转老手看路牌就知道方向靠的是对底层规则的理解。顺带说一个扩展方向如果题目把 key 的取值调大或者把单字节异或换成多字节异或即分组异或每组用不同的 key 段难度会立刻上一个台阶。但解法思路不变——先想办法拿到 key拿不到就暴力枚举加校验。很多中等难度的杂项题就是这样从 easy 题演化出来的所以把基础题吃透性价比很高。6.2 做题时的几个小习惯最后分享几个我在实际做题中养成的习惯。一是永远先把output.txt或者给出的密文单独存成一个文件脚本里通过读文件获取密文而不是每次手动复制粘贴这样能避免很多低级错误。二是写完解密脚本后顺手写一个加密脚本或者加一个断言验证用解密出来的明文再加密一次看能不能得到原始密文。这一步能快速确认整个流程是否闭合推荐每个人都试一次。# 验证解密结果能否再加密回原密文 def encode(s): tmp [] for i in range(len(s)): tmp.append(ord(s[i]) ^ key[i % 3]) return base64.b64encode(bytes(tmp)).decode() assert encode(flag) cipher_b64三是不要只满足于跑出 flag。把题目里的核心思路记下来比如异或 Base64 组合固定周期 key 的识别用 flag 前缀做暴力校验以后碰到类似的题就是降维打击。我就是靠这种积累从被 easy 题卡半天到后来看到密文前几十个字符就能猜出大概用了什么变换。这题本身没什么难度但它像一把标尺量出你对基础概念是不是真的理解了。如果你能不看任何题解、照着上面 4.2 的代码自己推一遍流程再把 key 藏起来试一次暴力枚举那这道 easy-100 就算真正吃透了。