CTF逆向里,Python题一直是新手建立信心最快的方向。最近我在某个CTF平台的逆向区碰到一道叫 pyre 的题,附件下载下来只有一个几KB的 .pyc 文件。刚开始我以为是个打包过的Python程序,后来看了眼文件名才反应过来:pyre 基本就是 py reverse 的意思,这就是一道典型的 Python 字节码逆向题。这类题的套路非常固定——出题人写一段Python代码,编译成 .pyc,然后只把 .pyc 丢给你。你要做的就是反编译回源码,看懂它如何处理输入,再写脚本把变换一层层逆回去,从代码里硬编码的目标数组中还原出 flag。整个过程不涉及汇编,也不涉及脱壳,工具链成熟,非常适合理清"正向变换 + 逆向推导"的思维。这篇文章就拿这道 pyre 题当引子,把从拿到 .pyc 到最终跑出 flag 的完整链路拆开讲一遍,后面凡是遇到类似的 pyc 题目,你都可以照这个流程走。
1. 拿到附件后的第一件事:确认Python版本
1.1 为什么.pyc在CTF里这么容易下手
Python 是解释型语言,运行时会把源代码编译成字节码,然后缓存在__pycache__目录或目标目录下,后缀就是 .pyc。CTF 出题人为了制造一点逆向难度,会把原始的 .py 删掉,只留编译后的 .pyc。但这个"编译"和 C/C++ 编译成机器码完全是两码事。pyc 里保存的是字节码指令、常量池、变量名、甚至部分符号表信息,结构非常规整,反编译的成功率极高。
你可以把 pyc 想象成一份"说明书":它虽然删掉了最终的设计蓝图,但说明书里保留了零件的尺寸、型号和组装顺序,只要花点时间就能把蓝图大致还原出来。这也是为什么 pyc 题在逆向分类里往往被归为"友好题"——它更像是一道需要耐心整理的翻译题,而不是硬核的二进制逆向。
做这类题的时候,很多人上来就反编译,结果报错或者反编译出一堆乱码,然后开始怀疑工具坏了。其实 90% 的问题是出在 Python 版本不匹配上。pyc 文件头部有一个 magic number,用来标识它是由哪个版本的 Python 编译产生的。不同版本的字节码指令集、变量存储方式都有差异,反编译工具也必须按对应版本来处理。
1.2 用文件头识别编译版本
拿到附件后不要急着跑工具,先看一眼文件头。命令行里执行:
xxd pyre.pyc | head -n 3我当时看到的前几行大概长这个样子:
00000000: 330d 0d0a 0000 0000 5d06 4d5f b923 4761 3.......].M_.#Ga 00000010: e300 0000 0000 0000 0000 0300 0000 4000 ..............@.前四个字节是33 0d 0d 0a。注意字节序,实际读出来的整数是0x0d33,转换成十进制是 3379。这个值对应 Python 3.6。如果你记不住这些数值也没关系,有两个很省事的办法:
- 用
file命令直接看,它会输出类似python 3.6 byte-compiled的描述,虽然不一定每次都准,但能提供参考。 - 在本地用
import importlib.util查看当前 Python 的 magic,然后和 pyc 头部比对。
我自己一般用 Python 做快速确认:建一个新环境跑一句import importlib.util; print(importlib.util.MAGIC_NUMBER.hex()),如果输出是330d0d0a就说明环境版本合适。这个办法最稳妥,因为 pyc 的坑通常就是"本地 Python 版本和题目的 pyc 版本不一致"。
1.3 准备一个同版本Python环境
识别出版本是 3.6 之后,老老实实建一个 Python 3.6 的环境。这一步不是强迫症,而是因为 Python 字节码在版本之间会有指令变化,反编译工具和marshal.load()都要求 pyc 的版本和运行环境一致,否则轻则报错,重则反编译出完全不可读的内容。
我当时用的是 conda 创建环境:
conda create -n py36 python=3.6 conda activate py36 pip install uncompyle6如果手头没有 conda,用 pyenv 或者直接安装多版本 Python 都行。关键是让 3.6 环境成为当前默认环境。之后所有和 pyc 相关的操作,比如反编译、用 dis 读字节码,都放在这个环境里做,能省掉大量莫名其妙的报错。
2. 反编译还原出check函数
2.1 uncompyle6一条命令拿到源码
环境准备好后,反编译本身是件很轻松的事。uncompyle6 是这个场景下的主力工具,命令很简单:
uncompyle6 pyre.pyc > pyre.py打开生成的 pyre.py,我看到了一个核心的check()函数,以及一堆模块级的常量和调用。uncompyle6 生成的代码通常会有大量临时变量命名和冗余结构,但整体逻辑是可以读的,把它整理一下,核心逻辑是这样:
def check(): code = list(map(ord, input("please input: "))) for i in range(len(code)): if code[i] > 128: break for i in range(len(code) - 1, 0, -1): for j in range(i): code[j] += code[j + 1] l = len(code) for i in range(l // 2): code[i], code[l - i - 1] = code[l - i - 1], code[i] for i in range(0, l, 2): code[i] ^= code[i + 1] for i in range(1, l, 2): code[i] ^= code[i - 1] print(code) check()看到这份源码的时候,我的第一反应是:这道题把输入字符串转成 ASCII 码列表,然后做了三层变换——双层循环累加、整表反转、两轮异或。最后print(code)打印出来的并不是 flag,而是变换后的列表。出题人其实是把"标准答案"藏在了另一个地方——通常在源码末尾会有一个很长的整型列表,那是经过同样变换后的目标值,也就是我们说的 target 数组。
2.2 工具失效时的备用方案:dis手动读字节码
不是每次反编译都这么顺利。pyc 文件如果版本太新,比如 Python 3.8 以上,uncompyle6 几乎都会翻车。或者代码里用了某些控制流混淆,反编译工具会直接报 "Decompilation failed"。
这时候别慌,我们还有一条兜底路线:用 dis 模块直接看字节码。Python 3.6 环境下执行:
python3 -c "import dis, marshal; f=open('pyre.pyc','rb'); f.read(16); code=marshal.load(f); dis.dis(code)"这段命令的意思是:先把 pyc 文件头的 16 字节信息读掉,用marshal.load()把真正的 code object 加载出来,然后交给dis.dis()打印指令助记符。输出会是一堆LOAD_FAST、BINARY_OP、FOR_ITER之类的指令,读起来很枯燥,但里面会暴露常量、变量名和函数调用顺序,足够你逆向出大概逻辑。
另外,pycdc 是 C++ 实现的跨版本反编译器,对高版本 Python 的兼容性比 uncompyle6 好。pycdas 是它的字节码转储模式。如果 uncompyle6 失败,我一般会按下图顺序尝试:
- uncompyle6 或 decompyle3 先试。
- 失败就用 pycdc 再试。
- 还不行就用 pycdas 或 dis 手工看字节码。
- 在线反编译工具可以做快速参考,但有敏感代码时注意别乱传。
2.3 在源码尾部找到target数组
反编译整理之后,注意看check()函数之外的部分。模块顶层通常有一个长整型列表,或者一串被赋给某个变量的常数值,目标就是它。由于这题的真实 target 比较长,而且直接贴出来相当于把题目答案剧透给还没做完的人,我这里先不贴完整数组。你反编译之后在源码底部一定能看到它——一堆超过几百甚至上千的整数整齐排列着。
这个 target 的出现其实也验证了加密流程的方向:check()函数把用户输入转成 ASCII 码,层层变换最后打印出一串大数。出题人自己跑一遍正确的 flag,得到那串大数,写死在代码里。我们要做的,就是把这串大数当作起点,把每一步变换都倒着做回去。
3. 把加密流程拆开,逐层求逆
3.1 第一处变换:双层循环其实是一个差分累加
先看最显眼的这段:
for i in range(len(code) - 1, 0, -1): for j in range(i): code[j] += code[j + 1]外层循环从后往前扫,内层循环从前往后累加后面一个元素。这个操作的效果并不是简单的相邻相加,反复执行之后,每个元素都会被右侧所有元素的加权值影响。用一个四元素数组[a, b, c, d]来推一遍:
- 第一轮外层
i=3时:[a+b, b+c, c+d, d] - 第二轮外层
i=2时:[a+2b+c, b+2c+d, c+d, d] - 第三轮外层
i=1时:[a+3b+3c+d, b+2c+d, c+d, d]
可以看出,最左端的元素最终会被右侧所有元素的某种组合系数累加,数值会明显变大。这也是为什么 target 数组里的数普遍超过 255,甚至上千——因为输入字符的 ASCII 值在这个步骤被"撑大"了。
逆向这段的关键是操作顺序。正向时外层循环从大到小,内层循环从小到大;逆向时必须完全反过来,外层从小到大,内层从大到小。原因是正向累加过程中,code[j+1]已经被当前外层循环更新过了,所以逆推时也要先处理靠右侧的值,再处理左侧值。具体的逆运算在解密脚本部分会写,这里先记住结论:它就是一次"差分累加"或"前缀和"式的变换,方向反着来,符号换成减号即可。
3.2 第二处变换:整表反转
中间这段:
for i in range(l // 2): code[i], code[l - i - 1] = code[l - i - 1], code[i]就是最朴素的数组反转,也就是把[a, b, c, d]变成[d, c, b, a]。它的逆操作还是反转,再做一次就恢复原样,没有陷阱。
3.3 第三处变换:相邻位异或
最后两轮是异或:
for i in range(0, l, 2): code[i] ^= code[i + 1] for i in range(1, l, 2): code[i] ^= code[i - 1]第一轮让所有偶数位和自己右边相邻的奇数位异或;第二轮让所有奇数位和自己左边相邻的偶数位异或。注意,第二轮用到的左边元素,已经被第一轮更新过了。也就是说,奇数位code[i]异或的并不是原始偶数位,而是"原始偶数位异或原始奇数位"之后的值。这会导致一个有意思的现象:两轮异或之后,奇数位实际上恢复成了加密前的偶数位。
用[a0, a1, a2, a3]来手推一遍:
- 第一轮:偶数位 0、2 分别变成
a0^a1、a2^a3 - 第二轮:奇数位 1、3 分别变成
a1^(a0^a1) = a0、a3^(a2^a3) = a2
所以两轮异或后数组变成了[a0^a1, a0, a2^a3, a2]。要把这个变换逆回去,必须从后往前处理:
- 先处理奇数位
a0、a2:它们已经是原偶数位,可以用来解出原奇数位。 - 再处理偶数位:用已经恢复的右侧奇数位去异或,得到原偶数位。
这也是为什么解密脚本里逆异或的循环方向是range(l-1, -1, -1),并且奇数位和偶数位处理方式不同。如果你按正向顺序去解,数组后半段还没恢复就开始用,结果必然全乱。
3.4 加密与解密对应关系一览
到这里,整道题的变换链就清楚了。正向加密流程和解密流程是完全镜像的:
| 加密步骤 | 正向操作 | 解密步骤 | 逆向操作 |
|---|---|---|---|
| 1. 差分累加 | 外层 i 从 l-1 到 1,内层 j 从 0 到 i-1,code[j] += code[j+1] | 4. 逆差分 | 外层 i 从 1 到 l-1,内层 j 从 i-1 到 0,code[j] -= code[j+1] |
| 2. 整表反转 | 首尾交换 | 3. 逆反转 | 再做一次反转 |
| 3. 两轮异或 | 偶数位与右邻异或,奇数位与左邻异或 | 2. 逆异或 | 从后往前,奇数位与左邻异或,偶数位与右邻异或 |
| 4. 对比 | 与 target 比较 | 1. 从 target 出发 | 转回 ASCII,得到 flag |
这张表直接对应最终的解密脚本顺序。记住一个原则:写解密脚本时,先做离 target 最近的那一步逆运算,然后一步一步往外退。
4. 解密脚本:从target一步步还原flag
4.1 完整脚本长什么样
理清逆推顺序之后,解密脚本就很好写了。这里我依然用反编译整理的逻辑给出完整代码。脚本的核心就是前面那张表的四个步骤:
def decrypt(target): code = target[:] # 复制一份,别直接改动原题目里的数组 l = len(code) # step1: 逆两轮异或,从后往前 for i in range(l - 1, -1, -1): if i % 2 == 1: code[i] ^= code[i - 1] else: code[i] ^= code[i + 1] # step2: 逆反转 code = code[::-1] # step3: 逆差分累加 for i in range(1, l): for j in range(i - 1, -1, -1): code[j] -= code[j + 1] # step4: 转回字符 flag = ''.join(chr(x) for x in code) return flag target = [] # 这里替换成反编译出来的硬编码数组 print(decrypt(target))这里有一个新手很容易踩的坑:直接把target传进函数,然后函数里code = target[:],看起来没问题,但如果你图省事写了code = target,后面所有逆操作都会直接改写原数组。等你第二次运行脚本或者想对比原始 target 的时候,数据已经被污染了。所以复制一份是最基本的自我保护。
逆异或那一步还有个边界问题。正向加密时第一轮用到了code[i+1],所以输入长度必须是偶数,否则最后一个元素会越界。这也说明这道题里 flag 的长度一定是偶数。如果你解出来的数组长度是奇数,先回头看一下是不是 target 复制错了或者反编译出来的代码有残缺。
4.2 用短样例验证脚本没有写反
为了证明上面这个解密脚本不是凭空拍脑袋,我拿一个超简单的输入验证了一遍。
先用正向加密脚本作用在一个四字符字符串 "ABCD" 上:
def encrypt(raw): code = list(map(ord, raw)) for i in range(len(code) - 1, 0, -1): for j in range(i): code[j] += code[j + 1] l = len(code) for i in range(l // 2): code[i], code[l - i - 1] = code[l - i - 1], code[i] for i in range(0, l, 2): code[i] ^= code[i + 1] for i in range(1, l, 2): code[i] ^= code[i - 1] return code print(encrypt("ABCD"))输出是:
[195, 68, 792, 268]这个结果和理论推导一致。然后我把它填进decrypt()的 target 里,跑出来的结果是:
ABCD这说明加密和解密方向是自洽的。真实的 pyre 题的 target 虽然长得多,但变换逻辑完全一样,用这个脚本把反编译出来的真实数组替换进去,跑出来的就是一大串以flag{开头的字符串。这里我就不把真实结果贴出来了,留一点空间给你自己运行验证。
4.3 常见翻车点排查
写这种解密脚本时,翻车往往不是因为原理难,而是因为粗心。我整理了几个高频问题,全部发生在实操过程中:
| 现象 | 原因 | 处理办法 |
|---|---|---|
| 解出来一堆负数 | 逆差分顺序写反了,内层循环没有从大到小走 | 检查for j in range(i - 1, -1, -1) |
| 解出来乱码 | 异或步骤方向错了,应该从后往前处理 | 检查range(l - 1, -1, -1) |
| 结果前几位对,后面错 | target 数组本身被前面某次运行改坏了 | 重新反编译或重新复制 target |
| 报错 index out of range | 输入长度是奇数,或者 target 长度不对 | 确认 flag 长度为偶数,核对反编译代码 |
| 字符转回时有数字超过 255 | 某个逆运算少做了一层 | 按表格逐步对照加密与解密流程 |
代码里我习惯在每个步骤后面加一句print(code[:20]),观察中间结果是否符合预期。比如逆异或之后,数组里会出现很多恰好是 ASCII 值范围的数字,那说明这一步基本没问题;如果出现大量几千的大数,说明你可能跳过了反转步骤。
5. pyc逆向题的通用套路和工具选型
5.1 一套可以复用的完整流程
做完这道 pyre 题,我发现 pyc 逆向其实有一套特别稳定的 SOP。之后再遇到类似题目,基本可以无脑按下面这个流程走:
- 先看 pyc 文件头确认 Python 版本,必要时创建对应版本的虚拟环境。
- 用 uncompyle6 或 pycdc 反编译,拿到源码后人工清理变量名和结构。
- 找到核心的 check 函数和硬编码的 target 数组。
- 把 check 函数里的每个变换步骤拆出来,想清楚正向做了什么。
- 从 target 开始,从后往前逐一求逆。
- 每写一个逆步骤,用一个短小的样例验证,最后替换成真实 target 跑结果。
这个流程不只是在 pyre 题上适用,几乎所有"给 pyc 文件求 flag"的题目都能套用。区别只在于变换层的数量和复杂程度,有的题还会加入 base64、AES 之类的额外加密,但核心思路完全一致:先反编译,再逆推。
5.2 反编译工具选型对比
工具选型直接影响效率。做这类题这么多年,我个人的选择优先级是这样的:
| 工具 | 适用版本 | 优点 | 缺点 | 建议 |
|---|---|---|---|---|
| uncompyle6 | Python 2.4 - 3.7 | 社区成熟,可读性高,支持范围广 | 3.7 以上支持差,遇到复杂控制流会失败 | 首选,配合 py3.6 环境 |
| decompyle3 | Python 3.7+ | 基于 uncompyle6 的加强版,兼容性更好 | 低版本支持不如 uncompyle6 | 高版本 pyc 的备选 |
| pycdc | 跨版本 | C++ 实现,鲁棒性好,不容易直接崩溃 | 输出的代码可读性一般,变量命名较乱 | uncompyle6 失败时优先换它 |
| pycdas | 跨版本 | 直接输出字节码助记符,适合深度分析 | 不是源码,阅读门槛高 | 需要手工逆向时使用 |
| dis | 随 Python 自带 | 零安装,永远可用 | 输出是助记符不是源码 | 最后的兜底手段 |
在线反编译工具我也偶尔用,图个方便,但要注意:代码上传到第三方网站本身有泄露风险,如果是比赛期间做未公开题目,我更倾向于本地工具,避免把题目给别人"剧透"的机会。
5.3 几个值得养成的小习惯
做完 pyre 这道题,我自己最深的体会是:pyc 逆向翻车通常不是解不出来,而是被"差不多的步骤"坑了。比如差分累加的内层循环方向,比如异或的逆序处理,差一行代码,结果天差地别。
我之前在调试类似题时,特别迷信"跑一遍看结果对不对"。但如果 target 很长,只看最终 flag 很难定位是哪一步错了。正确的做法是分步验证:把解密脚本拆成step1()、step2()、step3()这种独立函数,分别对短样例输出中间结果。
另外还有一个非常实用的校验点:题目里那个if code[i] > 128: break的判断,虽然不影响算法流程,但暗示了输入应当是 ASCII 可见字符。解密脚本跑出来的 flag 如果含有大量非 ASCII 字符,那基本可以断定之前的某个逆运算写错了。只要最终结果是flag{...}开头、纯可见字符结尾,就说明整条链路走通了。
最后再分享一个小技巧:遇到 pyc 题,先别急着硬啃字节码,先花五分钟把正向加密流程用短数据在草稿纸上推一遍。我当时就是拿 "ABCD" 四个字符走了一遍完整加密,再用同样的数据验证解密脚本,整个过程不到三分钟,却帮我避免了至少半个小时的盲目调试。对于 Python 逆向这种"变换逻辑清晰"的题目,正向推导和反向验证永远是效率最高的组合。