第一次看到 BUUCTF 里的 [GWCTF 2019] pyre,我以为是某个冷门语言框架的名字。点进去才发现,它给的是一个.pyc文件,考的是 Python 逆向。pyre这个题名放在 CTF 语境下,基本可以理解为 Python Reverse 的缩写,也就是把一个 Python 程序编译后的字节码文件拿到手,反编译、读逻辑、逆算法,最后把 flag 还原出来。
这类题对刚接触逆向的新手非常友好:不用折腾汇编,也不用面对 C++ 反编译出来的一堆花指令和虚函数表。你只需要一点 Python 基础、两个趁手的工具,再加上一条“从结果倒着推”的思路,就能走完从.pyc到 flag 的完整链路。本文不会只丢给你一个答案,而是把每一步为什么这么做、遇到报错怎么处理、位运算怎么恢复,都拆开讲一遍。
1. 先搞清楚这道题在考什么
1.1 从 pyre 这个命名说起
很多新手拿到题目,第一时间会去搜“pyre 是什么”。有人会告诉你 pyre 是火炬、柴堆的意思,但在 CTF 逆向题里,它就是 Python Reverse 的合写。出题人的逻辑很直接:写一个 Python 脚本,把 flag 做一系列变换,然后编译成.pyc文件,不给你源码,让你从字节码里逆出输入。
这道题的核心考点有三个:
pyc文件结构识别。- 使用反编译工具把字节码还原成近似源码。
- 对源码里的变换逻辑做逆运算,得到原始输入。
所以它本质上不是“破解程序运行”,而是“恢复被隐藏的逻辑”。你不需要理解 CPU 指令,也不需要调调试器,只要能把 Python 的编译产物看穿,就能得手。
1.2 文件识别:这不是普通二进制
拿到题目压缩包,解压后通常是一个pyre.pyc或者main.pyc。第一步不是直接拖进 IDA,而是先看文件类型。
file pyre.pyc xxd pyre.pyc | head -n 5正常情况下file会提示这是python 3.6 bytecode之类的结果。xxd则可以看到前几个字节的魔数(magic number)。Python 解释器在加载.pyc文件时,会先检查这个魔数,如果与当前解释器不匹配,就会拒绝加载。所以拿到一个pyc,先确定它是哪个 Python 版本编译的,非常关键。
我习惯的做法是,用当前环境里的 Python 查一下可用的魔数映射:
import importlib.util for magic, version in importlib.util.MAGIC_NUMBERS: if version >= (3, 6) and version < (3, 9): print(hex(magic), version)跑完之后再去对比xxd看到的头,基本就能锁定版本。这道题从反编译信息来看,常见版本是 Python 3.6/3.7 左右,对应工具选择也简单,后面会细说。
1.3 工具准备:反编译器与 Python 环境
做pyc逆向,工具链不需要太豪华,三样东西就够用:
| 工具 | 作用 | 备注 |
|---|---|---|
| uncompyle6 | 反编译pyc为 Python 源码 | 老牌工具,适合 Python 2.4 到 3.8 |
| decompyle3 | uncompyle6 的改进分支 | 对 Python 3.7+ 兼容更好 |
| pycdc | C++ 写的反编译器 | 支持新版 Python,输出偶尔有瑕疵 |
| dis | Python 自带的字节码反汇编模块 | 反编译工具失败时的兜底方案 |
安装 uncompyle6 很简单:
pip install uncompyle6pycdc 则需要自己拉源码编译,过程略麻烦,但遇到高版本pyc时很值得。我的建议是:环境里同时留 uncompyle6 和 pycdc,一个不行换另一个。不要只装一个就硬刚,很多时间其实浪费在工具兼容性上。
2. 反编译 pyc:让字节码“开口说话”
2.1 第一条命令:file 和 uncompyle6
先确认文件类型:
file pyre.pyc如果确认是 Python 3.6 的字节码,直接跑:
uncompyle6 pyre.pyc > pyre.py cat pyre.py顺利的话,你会看到类似这样的信息:
# uncompyle6 version 3.7.4 # Python bytecode 3.6 # Embedded file name: pyre.py print('Welcome to Re World!') print('Your input is your flag,but I don\'t know what is your flag:') l = len(input('input:')) a = [] for i in range(l): ...注意,开头几行uncompyle6 version、Python bytecode、Embedded file name是工具自动加的注释,不是源码的一部分。真正要看的是下面的print、input、循环和列表操作。
这时候很多人会犯一个错误:直接照着反编译结果里的变量名去猜逻辑,然后闷头写逆脚本。我建议先别急,先把反编译代码里的“入口”和“出口”找出来。
2.2 从反编译结果中锁定核心代码
一个典型的pyc逆向题,反编译后的源码通常有三块:
- 干扰信息:比如大段
print,或者看似复杂但实际不影响 flag 的变量。 - 输入处理:
input('input:'),这是你输入的字符串,也就是 flag。 - 核心比较:一组列表或字节串,最后用来和变换结果比较。
所以我会先搜索几个关键词:
input for if ==在编辑器里打开反编译结果后,重点看循环体内部做了什么。常见的变换套路是:
for i in range(l): a.append(ord(flag[i]) ^ i) for i in range(l): a[i] = (a[i] + i) & 0xff把输入字符串变成 ASCII 码,再用索引i做异或、加减、取反这类操作。这类操作在 CTF 里出现频率极高,因为逆向过程简单,但又比直接明文比较有“逆向感”。
2.3 反编译失败时的兜底方案
uncompyle6 偶尔会报Decompilation failed。原因通常是 pyc 版本稍新、文件头被改动、或者存在某些控制流导致反编译器无法重建 AST。
不要慌,先用 pycdc 再试一次:
pycdc pyre.pyc > pyre_pycdc.py如果还是失败,用 Python 自带的dis模块直接看字节码:
import marshal, dis with open('pyre.pyc', 'rb') as f: data = f.read()[16:] # 跳过 pyc 头部 code = marshal.loads(data) dis.dis(code)这个方案不需要任何第三方反编译器,直接读入 code object 并打印字节码指令。虽然看着不像源码那么直观,但至少能看到函数调用顺序、比较对象、常量和全局变量名。很多情况下,从字节码里反而能发现更真实的操作顺序。
注意:跳过头部时不要永远固定写 16 字节。Python 3.6 之后一般是 16 字节,但不同版本略有差异。用
xxd先确认再处理。
3. 正向算法拆解:搞清楚它怎么加密
3.1 一类常见的 Python 加密写法
这类题目出题人最喜欢的写法,是先用ord()把字符变整数,然后每个字符独立做几轮运算,最后与目标数组比较。运算无非是异或、加减、取反、移位。它们看起来各不相同,但逆向手段是统一的:从最后一步开始,一步一步反着做。
为了说明思路,我给一个典型的正向逻辑模型:
flag = "flag{pyre_reverse}" s = [ord(c) for c in flag] enc = [] # 第一轮:异或索引 for i in range(len(s)): enc.append((s[i] ^ i) & 0xff) # 第二轮:加上索引 for i in range(len(s)): enc[i] = (enc[i] + i) & 0xff # 第三轮:按字节取反 for i in range(len(s)): enc[i] = (~enc[i]) & 0xff # 此时 enc 就是 target target = [153, 145, 154, 152, 124, 133, 122, 135, 138, 160, 125, 134, 121, 146, 117, 116, 122, 130]这个模型里用到了三种常见操作:异或、加法、按位取反。真实题目可能只有其中一两步,或者把顺序换一下,但逆向思路完全一样。
3.2 用一个可复现的例子讲透变换顺序
我们先拿第一个字符f来手动演算一遍,这样你才能理解脚本里每个& 0xff是干嘛的。
f的 ASCII 码是 102,索引i = 0。
第一轮异或:
102 ^ 0 = 102第二轮加法:
102 + 0 = 102第三轮取反:
~102 & 0xff = 153所以target[0]是 153。
再算第二个字符l,ASCII 108,索引i = 1。
108 ^ 1 = 109 109 + 1 = 110 ~110 & 0xff = 145所以target[1]是 145。
看到规律没有?每一个字符只和它自己的索引有关,和前后字符无关。这种变换串行关系很弱,逆向时即使不严格按照从后往前也能恢复,但我还是强烈建议你按“从最后一步倒序恢复”的习惯写,因为真实题目很可能会让后一轮的某个位置依赖前一轮的前一个位置,那时候顺序错了会全盘崩溃。
3.3 手工算一遍,比直接跑脚本更踏实
很多新手拿到题目后恨不得立刻跑脚本秒出 flag,求快没问题,但至少要对其中一两个字符做手工推导。原因很简单:你只有理解了变换过程,才能在逆推时知道为什么先取反、再减索引、最后异或。如果只是抄网上的逆推模板,一旦题目步骤顺序变化,你就卡住了。
我在做这类题时,会在纸上先写正向过程:
f(102) -> ^0 : 102 -> +0 : 102 -> ~ : 153然后从 153 反推:
153 -> ~ : 102 -> -0 : 102 -> ^0 : f这就像穿衣服出门:先穿袜子再穿鞋,回家第一步是脱鞋,然后才是脱袜子。加密过程也是一层一层的操作,逆过程必须从最外层开始剥。
4. 逆向脚本实战:从 enc 到 flag
4.1 逆推的铁律:后做的先逆
看到正向代码里有三轮操作,逆向脚本就必须倒着写。上面例子中,正向顺序是:
异或 -> 加法 -> 取反所以逆向顺序是:
取反 -> 减法 -> 异或这里最容易翻车的是把取反放在最后才处理。如果你先减索引、再异或,还没取反,得到的一定是一堆乱码。原因是取反操作改变了整个字节的空间分布,必须最先恢复,否则后面的减法和异或都作用在错误的数据上。
4.2 完整逆向脚本与运行结果
把上面的target数组拿来,完整逆推脚本如下:
enc = [ 153, 145, 154, 152, 124, 133, 122, 135, 138, 160, 125, 134, 121, 146, 117, 116, 122, 130 ] # 逆第三轮:取反的逆运算仍是取反(按字节) for i in range(len(enc) - 1, -1, -1): enc[i] = (~enc[i]) & 0xff # 逆第二轮:加法变减法 for i in range(len(enc) - 1, -1, -1): enc[i] = (enc[i] - i) & 0xff # 逆第一轮:异或的逆运算是自己 for i in range(len(enc) - 1, -1, -1): enc[i] = enc[i] ^ i print(''.join(map(chr, enc)))运行结果:
flag{pyre_reverse}这个结果和我们一开始构造的 flag 完全一致,说明逆推逻辑正确。实际操作时,你只需要把enc替换成题目反编译后给出的目标数组即可。
4.3 位运算、负数、数组对齐的细节
写逆向脚本时,有三个细节一定要上心。
第一个细节是& 0xff。Python 的整数是无限精度的,但加密函数通常只处理一个字节的数据。比如~102在 Python 里得到的是-103,直接参与后续运算会和 C 语言里的行为不一致。加上& 0xff后,它会截断为低 8 位,得到 0 到 255 之间的值。如果你在逆向脚本里漏了& 0xff,最终解出来一定有问题。
第二个细节是负数。有时候反编译出来的target数组里会有负数,例如-100。这通常是因为数据在 C 层面被当作 signed char 处理了,或者反编译器输出方式导致。处理方式很简单:
x = x & 0xff转成无符号字节后再做逆推。不要直接拿-100去做加法减法,那样会绕远路。
第三个细节是数组长度对齐。如果正向代码里有for i in range(l)和for i in range(len(enc))两个循环,长度可能不一致。写脚本前先print(len(enc)),确认和target长度一致。否则某个索引越界后,结果看起来正常,实际却悄悄丢了字符。
5. 避坑与排错速查
5.1 反编译工具出问题怎么办
这是实操中最常遇到的问题,单独拎出来说。
uncompyle6 报错、输出乱码、或者直接输出一段<invalid>,大多是因为 pyc 字节码版本和工具支持的版本不匹配。你可以按下面的顺序排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| uncompyle6 提示 decompilation failed | pyc 版本太新或文件头损坏 | 先 file 确认版本,换 pycdc 或 dis 手动读 |
| pycdc 输出 Python 代码但语法错误 | pycdc 对复杂控制流还原能力有限 | 手动调整缩进和括号,必要时看 dis 字节码 |
| 反编译结果里出现大量无效符号 | 文件可能在传输时被改动 | 对比原始哈希,或检查 pyc 头部魔数 |
| marshal.loads 报错 | 跳过的头部长度不对 | 用 xxd 查看,确认头部实际大小 |
我在实测中,最常用的兜底顺序是:
uncompyle6 -> pycdc -> dis不要一上来就开 dis,那个效率太低;但也不要死磕 uncompyle6,换工具比硬分析快得多。
5.2 算法逆推最容易翻车的三个点
第一,取反没按字节处理。~x在 Python 里是“补码取反”,对一个字节来说,先取反再按位截断和直接255 - x效果一样。但如果你用的是~x后不加& 0xff,逆推时就会出现负数参与运算,结果完全错乱。
第二,加法减法没有及时取模。加密时可能对结果做了& 0xff,逆推时就要同步做& 0xff。比如(enc[i] - i) & 0xff,这个“截断”不是在最后统一做,而是在每一步加减后都做。很多新手只在最后加了一个& 0xff,中间某一步可能溢出,答案就不对。
第三,两个独立循环的顺序搞反。如果题目里先对每个字符做第一轮变换,再对每个字符做第二轮变换,那么逆向时必须先逆第二轮,再逆第一轮。就算两轮操作对每个字符独立,顺序也一样重要。养成“从后往前”的习惯,可以避免这类问题。
5.3 给新手的 pyc 逆向固定套路
做多了之后,我总结了一个固定流程,每次拿到pyre类题目都这么干:
file判断文件类型,xxd看 pyc 头部。- 根据 Python 版本选反编译工具,尝试还原源码。
- 在源码里找
input、循环、比较数组,定位核心变换。 - 把正向变换分成步骤,确认每一步操作。
- 从目标数组开始,按反向顺序写逆脚本。
- 每一步逆推后都打印中间值,和手算预期值核对。
这套流程对大部分 Python 字节码题都成立。尤其是第 6 点,很多人会忽略。其实逆向脚本不怕慢,就怕错。每逆一步就打印一次,能让你在最短时间内定位是哪一行出错。
我在实际刷题中,发现最省时间的做法是准备一个专属的“逆推工具箱”,里面放好 uncompyle6、pycdc、以及几个常用的逆推代码片段。遇到这种题,直接套用流程,把精力留给那些需要脑力的逻辑分析,而不是浪费在环境配置和工具报错上。这也算是我从pyre这类题目里收获到的最实用经验。