前两天在bugku平台上翻题,看到一个分值写100、名字叫easy-100的老题。这应该是很多人开开心心点进去,然后又卡在“为什么工具解出来是乱码”上面的典型例子。把这件事做完以后我最大的感受是:题目难度和“需不需要写完整代码”完全没关系,easy-100虽然前缀写着easy,但真正要跑通还是得自己动手把解码链路闭上,不能用网页解码器点一下就收工。
这篇博客就是把我在easy-100上从审题到写完脚本、再到提交验证的完整过程整理出来。文章会顺着我当时排查的顺序走,包含可直接复制运行的代码、判断每一层编码的方法、以及几个很容易被忽略的运行细节。不管是刚接触CTF的新手,还是已经在用Python刷题但还没习惯“先看字节再写代码”的人,应该都能在这里找到值得抄走的经验。
1. easy-100这道题的题面与素材到底长什么样
1.1 题面往往不会告诉你该用什么编码
在bugku这类平台上,题目通常只给你一句话描述,然后丢一个附件出来。easy-100也不例外,点了下载之后拿到一个没有后缀的文件,文件名就叫easy-100,大小只有不到2KB。这个大小非常微妙,如果是图片、压缩包或者音视频,不可能这么小;如果是纯文本,又显得有点“精心处理过”。
我当时的习惯是先不清醒脑袋乱猜,而是把它当成一个黑盒来看。把这文件扔到Linux命令行里,第一件事就是用file命令看真实类型。
$ file easy-100 easy-100: ASCII text这里会产生第一个认知误区:既然file说是ASCII text,很多人就会直接用文本编辑器打开,然后看到满屏的eJx...之类字符串,直接复制到浏览器里的Base64解码工具。解出来一堆二进制乱码之后,就不知道下一步该怎么办了。
这个场景太典型了,我愿把它称作“第二层陷阱”:题目根本不打算让你一眼看出藏在编码里的东西,它给你的是一个经过多层处理的最终结果。你眼里看到的是ASCII文本,但文本本身只是某一个编码层的壳。你需要在代码里一层一层剥掉它,而不是指望某个在线平台一键还原。
1.2 用命令行先探一遍文件,别急着解码
既然有了基础判断,第一步应该做的是看文件头和文件尾。用xxd展示十六进制内容,能最快地看出字符规律:
$ head -c 64 easy-100 | xxd 00000000: 654a 7a38 5967 4141 4141 4141 4141 416b eJz8YgAAAAAAAAAAk 00000010: 5342 4f49 7750 7148 3478 7952 434f 6741 SBOIwPqH4xyRCOgA ...看到eJ开头,经验就来了。Base64字符串里eJ这个组合是非常常见的Zlib压缩头特征,或者更准确地说,很多用Zlib压缩过的数据经过Base64编码后,开头都会出现eJ。所以我的脑子里马上出现了一条候选链路:
Base64字符串 -> Base64解码 -> Zlib解压 -> 得到真实内容
这里顺便说一句,很多人会下意识以为eJ就是某种特别的文件格式,实际上它只是Base64解码后数据开头那0x78 0x9c两个字节的Base64表达。0x78 0x9c是Zlib压缩流的头标志,看见eJ只能说明数据曾经被压缩过,不能说明其他东西。
1.3 为什么“在线工具解码一次”解决不了问题
有人可能会问:那我直接把eJ开头的字符串丢进在线Base64解码,再保存成文件,再解压,不就行了吗?实验上确实可以,但问题是这一步之后还有内容。而且当你得到的是Zlib解压后的二进制块时,文本编辑器打开全是不可见字符,你根本不知道该从哪里截断、哪里拼接,更别提后面的异或层了。
这是CTF题目和普通开发项目的一大区别:普通开发里,我们拿到的数据格式往往是协议定义好的,字段怎么排、编码怎么搞都有文档;但CTF题目经常故意省掉这些信息,让你必须自己设计一个“能处理未知格式”的脚本。你在在线工具里做一次解码,等于只走完了一条链路里的第一步,后面的每一步都得重新复制粘贴、重新转换。一旦涉及几十次循环判断,人工操作肯定出错。
所以从这时候起我就决定:与其在网页里点来点去,不如用Python写一个完整流程。后面的事实也证明,这个选择省了非常多时间。
2. 拆消息的编码链路:我靠什么特征判断每一层
2.1 从“看到什么”猜“该解什么”
在动手写代码之前,我习惯先把可能出现的编码层做一个整理。CTF里见过的经典编码包括Base64、Hex、URL编码、Zlib压缩、单字节异或,偶尔还会碰到逆序、转义、字符替换。它们各自都有很明显的特征,我列了一张表,后面写脚本的时候直接按这张表判断:
| 类型 | 肉眼或正则特征 | 对应的处理方式 |
|---|---|---|
| Base64 | 字母数字加+/,末尾可能有=,长度是4的倍数 | base64.b64decode |
| Hex | 只能出现0-9a-f,长度一般为偶数 | bytes.fromhex |
| URL编码 | 出现大量%xx | urllib.parse.unquote |
| Zlib压缩 | 解码后字节开头是78 9c,或Base64串开头是eJ | zlib.decompress |
| 单字节异或 | 字节分布与正常文本不符,但单键异或后恢复可打印 | 对256个键值暴力枚举 |
这张表看着很简单,但它是整个脚本的核心判断依据。编码这件事最怕的不是层数多,而是你压根不知道当前层是什么。有了特征表,写代码就变成“先识别,再解码,识别失败就换下一个策略”的流程。
2.2 异或层为什么不能靠眼睛判断
我在拿到Zlib解压后的数据时,第一眼看到的是大量十六进制字节,里面偶尔夹着一两段像英文单词的内容,但整体读不通。这就像是把一篇正常英文文本的每个字节都跟某个固定值做了按位异或,原来看得懂的句子就会变成半乱码状态。
单字节异或的最大特点是“密钥空间极小”:密钥的取值范围只有0到255,一共256种可能。既然如此,手写脚本去挨个试就行。对于一个足够长的密文,错误的密钥会让大部分字节变成不可打印字符,而正确的密钥能让超过95%的内容落在可打印ASCII范围内。这个判断标准虽然朴素,但实际效果非常好。
当然这不是说单字节异或只能这样做,如果你遇到的密文是用户密码之类的高位字符,可能需要加一些语言模型判断,或者用英文单词频次打分。但对于这种flag藏在明文里的CTF题,可打印字符比例已经完全够用。
2.3 从文件尾再确认一次
除了内容本身的特征,我还会用tail -c 16 easy-100 | xxd看一眼末尾。这个方法几乎不花钱,但很有效。如果末尾直接出现flag{或者类似字样,那说明编码层根本没走完;如果末尾是}符号,那说明真实flag可能就在文件末尾,只要处理前面部分即可。
easy-100这个案例里,文件尾部没有任何可读内容,说明最终flag应该藏在中间或者解压后的某个字段里。于是我的思路变成两条线同时推进:
- 一条线是把Base64层和Zlib层解开,看看内部结构;
- 另一条线是准备一段“暴力枚举单字节异或密钥”的代码,把解压后的数据交给它,让它把最像正常文本的密钥找出来。
这两条线合并之后,就是一份可以复用很久的完整脚本。
3. 完整代码逐段拆解:一份可直接复用的解题脚本
3.1 先给代码全貌
这份脚本我后来在好几个CTF题里都用过,不只是easy-100。它做的事情可以概括成一句话:把一个不知道处理过多少层的文件,自动尝试Base64、Zlib、单字节异或,最后输出可打印文本。
#!/usr/bin/env python3 import sys import re import base64 import zlib def check_printable(data: bytes) -> int: printable = 0 for b in data: # 保留换行、回车、制表符,以及可见ASCII字符 if b in (9, 10, 13) or 32 <= b < 127: printable += 1 return printable def find_xor_key(data: bytes, threshold: float = 0.95): if not data: return None, None for key in range(256): out = bytes([b ^ key for b in data]) ratio = check_printable(out) / len(data) if ratio >= threshold: return key, out return None, None def main(): if len(sys.argv) < 2: sys.exit("usage: python3 easy100.py easy-100") with open(sys.argv[1], "rb") as fh: raw = fh.read().strip() # 先用ASCII方式读取内容,若失败则保留字节 try: text = raw.decode("ascii").strip() except UnicodeDecodeError: text = raw.hex() # 第一层:判断是否为Base64 b64_pattern = r"^[A-Za-z0-9+/]+={0,2}$" if re.fullmatch(b64_pattern, text) and len(text) % 4 == 0: buf = base64.b64decode(text) print(f"[*] base64 decoded: {len(buf)} bytes") else: buf = raw # 第二层:尝试Zlib解压 try: buf = zlib.decompress(buf) print(f"[*] zlib decompressed: {len(buf)} bytes") except zlib.error: pass # 第三层:单字节异或暴力枚举 key, plain = find_xor_key(buf) if key is not None: print(f"[*] xor key found: 0x{key:02x} ({key})") output = plain.decode("utf-8", errors="replace") print(output) if "flag{" in output: print("[+] flag looks complete") else: print("[!] no flag found") print(buf[:200]) if __name__ == "__main__": main()这段代码很直白,但每一段都踩过真实的问题。下面我会按顺序解释为什么这么写,以及哪些地方很容易写错。
3.2 文件读取:为什么必须是二进制模式
脚本里打开文件用的是:
with open(sys.argv[1], "rb") as fh:有些人刚开始写的时候会用open(sys.argv[1], "r"),这两者之间有本质区别。CTF里拿到的素材经常是混合了文本和二进制内容的,你用文本模式打开后,Python会尝试按默认编码解码,遇到不认识的字节就会抛UnicodeDecodeError。就算侥幸没有报错,底层的换行转换也会悄悄改变数据内容,等进入Base64解码时结果就对不上了。
用"rb"读进来的数据是bytes类型,后续做Base64解码、Zlib解压、字节异或都顺理成章。这里建议所有做题的人养成一个条件反射:凡是要做加密、编码、二进制分析的,一律用二进制模式打开文件。
3.3 Base64判断逻辑里藏着两个边界
代码里对Base64的判断是:
b64_pattern = r"^[A-Za-z0-9+/]+={0,2}$" if re.fullmatch(b64_pattern, text) and len(text) % 4 == 0:这一步至少做了两件容易被忽略的事:
第一,只允许=出现在末尾,并且最多两个。=是Base64的补齐符号,一个合法的Base64字符串不会在中间出现等号,通常也不会有三个等号连续出现。如果你只写一个简单的字符集正则,不去管等号的位置,前面那段容易把普通文本误判成Base64。
第二,要求长度能被4整除。标准Base64编码后长度一定是4的倍数,当原始数据的字节数不是3的倍数时,会把末尾填充上=.虽然有些实现会省略等号,但大多数CTF题的附件是直接从标准函数产生的,保留这个判断能降低误判率。
3.4 Zlib解压失败时要怎么处理
脚本里解压用的是:
try: buf = zlib.decompress(buf) except zlib.error: pass这里很多新手会困惑:为什么解压失败了反而不报错?原因在于我们的脚本是一层一层去猜的,Base64解完之后,内容有可能是Zlib压缩流,也可能不是。如果解压失败,那说明当前数据本来就不是Zlib格式,那我们保留原样继续往下走就行,没必要中断脚本。
实际遇见过一种情况是,Base64解码后得到的是Gzip文件头1f 8b,而不是Zlib的78 9c。这时你直接丢给zlib.decompress同样会报错,但换用gzip.decompress就能正常解压。所以读到这里你应该记住一个经验:压缩层失败时不一定要停,可以试着用gzip模块再走一遍。不过easy-100这道题的原始数据是Zlib压缩,我用zlib.decompress就够了。
3.5 单字节异或枚举为什么选择“可打印比例”
异或的那段代码:
if b in (9, 10, 13) or 32 <= b < 127:把换行、回车、制表符和ASCII可见字符都算成可打印字符。这个设计很刻意,因为解压出来的数据如果是一段文本或flag,里面必然全是这些字符;而用错误密钥异或出来的字节,大概率会落在128到255的范围,或者落入各种控制字符区。
用一个简单的ratio >= threshold来筛选,阈值设成0.95,意思是解密结果里超过95%的字节可打印,就认为这个密钥很可能正确。这个数字不是拍脑袋定的,我试过几个场景:正常情况下正确密钥的ratio通常能达到0.98以上,而错误密钥很难超过0.7。把阈值设到0.95既能过滤噪声,又不会漏掉有效解。
顺带一提,这种暴力枚举256个可能键值的做法,在CTF里叫“单字节异或爆破”。它不仅仅适用于这种静态文件,也适用于很多协议分析场景。比如一个数据流,如果其中某个字段是被同一个值异或过的,那只要枚举256次,就能看到明文轮廓。
3.6 输出环节为什么用errors="replace"
最后打印明文时写了:
output = plain.decode("utf-8", errors="replace")这一步是为了防止极端情况下解密后的文本里还有个别非法UTF-8字节导致整个进程抛异常。errors="replace"会把这些字节替换成�字符,但不会影响对整体结果的判断。做题的时候,你真正关心的是flag长什么样,而不是某个字节的编码是否规范。如果输出里出现了若干替换符,大概率是密钥找对了但密文中存在个别噪声字节,小概率是密钥找错了,这时再回头检查一下异或层也不迟。
4. 运行过程中最容易踩的坑:二进制、补位、编码这些细节
4.1 第一个坑:TextIOWrapper悄悄改掉了你的数据
写脚本过程中我先把“二进制模式打开文件”这一步强行刻进脑子,但还是有朋友会在自己的版本里写成open(..., "r").最典型的现象是前面几步都好端端的,等到了zlib.decompress的时候报错:
error: Error -3 while decompressing data: incorrect header check很多人看到这个报错,第一反应是“Base64解错了”,实际上很有可能是文本模式打开文件时,Python把文件里的一些字节按当前环境的编码解码过一次,解码失败或内容被改写了。尤其当你把文件内容打印出来看时,字符串看起来像是完整Base64,但内部字节已经和原始文件不一样了。
所以我要把这条经验放到最前面:如果Zlib解压报incorrect header check,请先检查你的打开方式是不是"rb",而不是去研究压缩算法。
4.2 第二个坑:Base64长度不整除4时,别急着硬解
某些情况下,题目给出的Base64字符串并不会规规矩矩补上等号。它可能从一段网页源码里复制出来,末尾被截断了几个字节,导致长度变成len(text) % 4 != 0。如果你想强行用base64.b64decode解码,Python会直接抛异常。
遇到这种情况,我会先根据余数手动补等号:
- 余数为1:说明数据本身不合法,需要检查是否复制错了;
- 余数为2:末尾补两个等号;
- 余数为3:末尾补一个等号。
easy-100的文件没有遇到这种问题,但很多魔改版本会加上这个“去掉等号的题型”,所以完整代码里虽然没写自动补位,但我自己调试时经常手工补。你在自己的脚本里可以直接加一个分支:
if len(text) % 4 == 1: print("[!] broken base64") elif len(text) % 4 == 2: text += "==" elif len(text) % 4 == 3: text += "="4.3 第三个坑:不可见字符让字符串判等失效
还有一次,我在脚本里加了一句“如果解出来的内容包含flag就结束”,结果输出里明明有flag,程序却一直没退出。原因其实非常无聊:真正的flag末尾有一个不可见的\r字符,或者前面混进了一个零宽字符。肉眼在终端里看不到它们,但程序判断时会把它们当成普通字符算进去。
所以当你遇到“结果明明对了,代码却判断失败”的时候,可以先用repr()打印一下字符串,把所有不可见字符显示出来。这个方法比一切高级调试器都直接。我在调试时经常在脚本里临时加一行:
print(repr(output))输出里如果有\x00、\r之类的内容,你就能立刻看到问题所在。
4.4 把运行日志整理成一张可排查的对照表
脚本跑通后,我把所有可能的报错原因整理成了下面这张表,方便以后遇到相同问题时快速定位:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
incorrect header check | 文件打开模式不对 / 前面少解了一层 | 改用"rb",检查是否为Hex或URL解码 |
| 字符串不能被解码为Base64 | 有隐藏换行或非法字符 | repr()查看,必要时只取中间内容 |
| Zlib解压完仍是一堆乱码 | 还有异或或位颠倒层 | 用单字节异或枚举,或尝试逆序 |
| 密钥枚举出多个结果 | 阈值设置太低 | 提高阈值到0.95以上,并检查是不是短文本 |
| 输出的flag不完整 | 数据在中间被截断 | 检查每个解码步骤返回的字节数变化 |
这张表不一定覆盖所有情况,但它帮我省去了很多重复性排查时间。做题和写业务代码一样,把失败模式整理成清单,你会跑得比大多数人快。
5. 从跑通脚本到提交flag:我最后做的几件事
5.1 第一步,先跑一个最小验证
脚本能跑出来之后,我没有直接去提交,而是先把自己的输出保存下来做二次检查。我用的是:
$ python3 easy100.py easy-100 > result.txt $ cat result.txt这么做的好处是,终端输出的内容有时会被颜色或者换行影响,直接看文件反而更干净。在result.txt里,如果能明确看到flag{...}这样的结构,再提交,否则先别急。
CTF里flag的格式通常有很强的规律性,大多数时候会以flag{开头、以}结尾,里面由英文字母、数字和下划线组成。如果你解出来的“flag”中间出现了中文、空格或者特殊符号,那多半是密钥找错或者还有一层没处理完。
5.2 第二步,用长度和字符集做一次性检查
很多人在提交前会忽略一个非常有价值的信息:flag的长度是固定的。如果题目给出过长度提示,比如“flag长度为32位”,那你心里就有一把尺子。即使没给提示,你也可以统计输出文本里{和}之间的长度,再判断它像不像一个正常的flag。
我习惯临时加一段Python逻辑来做这个检查:
import re m = re.search(r"flag\{[^}]+\}", output) if m: content = m.group(0) print("flag length:", len(content)) print("flag:", content) else: print("no flag found")在一个多层编码题里,看到flag{...}出现只是第一步,确认它格式完整才算真正解完。Easy-100这题我跑出来的结果是完整的,这里就不把真实flag贴出来了,避免后面做这题的人直接抄答案。样例输出大概是:
[*] base64 decoded: 312 bytes [*] zlib decompressed: 844 bytes [*] xor key found: 0x2a (42) flag{e4sy_100_example_form_at}5.3 第三步,把脚本沉淀成自己的公共工具
这份脚本不应该是只在easy-100上用一次的“一次性代码”。我在跑通之后把关键函数单独存成了一个common_decode.py文件,专门放那些“识别编码、尝试解码、暴力枚举XOR”的函数。以后的CTF题只要拿到未知文件,第一件事就是先跑这个脚本,让它把所有容易识别的层先剥掉,省得非常多的试错时间。
我自己现在做题的习惯是:拿到一个新题,先不做任何人工猜测,直接跑一遍通用解码脚本;脚本没解出来的,再用XXD和strings人工分析。这样做的逻辑很简单,脚本可以毫无怨言地做100次字符串替换和字节翻转,而人做不到,也没必要做到。
如果你经常参加打CTF比赛,或者平时处理一些未知格式的数据,也可以把这个脚本当成起点,再往里面加gzip、Reverse、URL解码、常见替换表等模块。每加一层,你就多了一种自动化的可能性。最后你会发现,真正难的不是某个算法本身,而是“你知道该往哪个方向试”的判断力。Easy-100的完整代码只是这个判断力的第一个落脚点。