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 python3.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; fopen(pyre.pyc,rb); f.read(16); codemarshal.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]来推一遍第一轮外层i3时[ab, bc, cd, d]第二轮外层i2时[a2bc, b2cd, cd, d]第三轮外层i1时[a3b3cd, b2cd, cd, d]可以看出最左端的元素最终会被右侧所有元素的某种组合系数累加数值会明显变大。这也是为什么 target 数组里的数普遍超过 255甚至上千——因为输入字符的 ASCII 值在这个步骤被撑大了。逆向这段的关键是操作顺序。正向时外层循环从大到小内层循环从小到大逆向时必须完全反过来外层从小到大内层从大到小。原因是正向累加过程中code[j1]已经被当前外层循环更新过了所以逆推时也要先处理靠右侧的值再处理左侧值。具体的逆运算在解密脚本部分会写这里先记住结论它就是一次差分累加或前缀和式的变换方向反着来符号换成减号即可。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-1code[j] code[j1]4. 逆差分外层 i 从 1 到 l-1内层 j 从 i-1 到 0code[j] - code[j1]2. 整表反转首尾交换3. 逆反转再做一次反转3. 两轮异或偶数位与右邻异或奇数位与左邻异或2. 逆异或从后往前奇数位与左邻异或偶数位与右邻异或4. 对比与 target 比较1. 从 target 出发转回 ASCII得到 flag这张表直接对应最终的解密脚本顺序。记住一个原则写解密脚本时先做离 target 最近的那一步逆运算然后一步一步往外退。4. 解密脚本从target一步步还原flag4.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[i1]所以输入长度必须是偶数否则最后一个元素会越界。这也说明这道题里 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 反编译工具选型对比工具选型直接影响效率。做这类题这么多年我个人的选择优先级是这样的工具适用版本优点缺点建议uncompyle6Python 2.4 - 3.7社区成熟可读性高支持范围广3.7 以上支持差遇到复杂控制流会失败首选配合 py3.6 环境decompyle3Python 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 逆向这种变换逻辑清晰的题目正向推导和反向验证永远是效率最高的组合。