Python逆向实战:从PyInstaller打包程序到算法逆向完整解析
1. 项目概述从一道CTF题看Python逆向的完整路径最近在复盘一些经典的CTF题目正好翻到了这道“[GWCTF 2019]pyre”。这道题在圈内挺有名的算是Python逆向的入门级“劝退”题也是很多朋友从Web、Pwn转向逆向分析时遇到的第一道坎。题目本身是一个被打包成可执行文件的Python脚本核心考察点就是如何从打包的二进制文件中提取出原始的Python字节码.pyc文件然后进行反编译和分析最终找到并理解其内置的验证逻辑写出逆向脚本或直接计算出Flag。我之所以想详细拆解这道题是因为它几乎涵盖了Python逆向分析中所有最基础、最核心的环节文件类型识别、PyInstaller打包程序解包、pyc文件修复、反编译工具使用、字节码阅读以及最后的算法逆向。整个过程就像一次标准的手术每一步都有其明确的工具和目的。对于刚接触逆向的新手来说跟着走一遍不仅能拿到Flag更能建立起一套处理类似问题的肌肉记忆。而对于有经验的朋友或许也能从中回顾一些容易被忽略的细节比如pyc文件的魔数修复或者如何快速定位关键代码。这道题的目标很明确你拿到一个名为“pyre”的可执行文件在Linux下可能是pyre在Windows下可能是pyre.exe运行后它会提示你输入flag然后进行验证。我们的任务就是不运行它或者运行了也不知道逻辑通过静态分析的手段把它内部的验证算法给“扒”出来。2. 解题环境与工具链准备工欲善其事必先利其器。Python逆向虽然门槛相对较低但工具选型不对或者环境没搭好也会让你在第一步就卡上半天。下面是我在多次实战中总结下来的一套稳定、高效的工具组合。2.1 核心工具介绍与选型理由file / Detect It Easy (DIE)用于初步文件分析。在Linux或Mac下直接用file命令就能看出文件是ELF可执行文件并且通常会有“Python”字样提示这是PyInstaller打包的。在Windows下我强烈推荐使用Detect It Easy (DIE)。它比传统的PEiD更强大能识别出大量的打包器、编译器类型。用它打开pyre.exe能立刻告诉你这是用PyInstaller打包的这直接决定了我们后续的解包方向。pyinstxtractor.py这是解包PyInstaller打包程序的“瑞士军刀”。它是一个Python脚本由ExtremeCoders开发。为什么必须用它因为PyInstaller打包时会把Python解释器、依赖库以及你的脚本字节码全部塞进一个可执行文件里。pyinstxtractor.py的作用就是把这个复合结构拆开把我们最关心的.pyc字节码文件提取出来。直接从GitHub上搜索下载即可。uncompyle6 / pycdc反编译工具。提取出的.pyc文件是字节码人类难以直接阅读。我们需要反编译成Python源代码。uncompyle6老牌、稳定对Python 3.7及以下版本的字节码支持非常好。安装简单pip install uncompyle6。它的优点是出错时提示相对友好。pycdc后起之秀采用C编写速度极快并且对高版本Python如3.8的字节码支持更好。需要从源码编译但对于这道2019年的题两者都能胜任。我通常两个都备着一个不行换另一个。010 Editor / WinHex / HxD十六进制编辑器。这是修复pyc文件头的关键工具。因为从PyInstaller提取出来的pyc文件文件头主要是魔数magic number可能被去掉了导致反编译工具无法识别。我们需要手动补上正确的文件头。Python 环境准备一个与题目可能使用的Python版本相近的环境用于运行我们修复和反编译后的脚本或者运行我们写的逆向脚本。根据题目年份2019Python 3.6或3.7的环境比较合适。注意工具版本很重要。特别是uncompyle6和pycdc对Python字节码版本的兼容性很敏感。如果遇到反编译失败首先应该怀疑是不是pyc文件的Python版本不对或者文件头不完整。2.2 环境搭建与快速配置对于新手我建议在Windows下操作因为图形化工具多。可以按照这个顺序快速搭建安装Python 3.7从官网下载安装包记得勾选“Add Python to PATH”。打开命令提示符CMD或PowerShell安装uncompyle6:pip install uncompyle6。下载pyinstxtractor.py和Detect It Easy放在一个专门的工作目录里比如D:\CTF\pyre。下载一个轻量级的十六进制编辑器如HxD免费开源。这样最基本的工具链就齐了。Linux或Mac用户操作类似包管理更简单但可能需要自己编译pycdc。3. 第一步文件解包与pyc提取拿到pyre文件后别急着双击运行。第一步永远是静态分析。3.1 识别文件类型在Linux终端下进入文件所在目录输入file pyre输出很可能类似于pyre: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]..., stripped。关键是要找有没有“Python”这个词。更明确的输出可能是“python script (executable)”之类的描述。在Windows下用Detect It Easy打开pyre.exe。在“类型”或“编译器”栏你会清晰地看到“PyInstaller”的标识。这一步确认了我们的解包方法——必须使用PyInstaller专用的提取工具。3.2 使用pyinstxtractor进行解包假设我们把pyinstxtractor.py和pyre文件都放在了D:\CTF\pyre目录下。 打开命令行进入该目录执行python pyinstxtractor.py pyre如果是Windows下的pyre.exe命令就是python pyinstxtractor.py pyre.exe如果执行成功你会看到类似下面的输出并生成一个名为pyre_extracted的文件夹[] Processing pyre.exe [] Pyinstaller version: 2.1 [] Python version: 37 [] Length of package: 9498752 bytes [] Found 984 files in CArchive [] Beginning extraction...please standby [] Possible entry point: pyre.pyc [] Successfully extracted pyre.exe这里有几个关键信息Python version: 37这告诉我们打包使用的Python版本是3.7。这个信息至关重要它决定了我们后面修复pyc文件头时要使用的魔数。Possible entry point: pyre.pyc这指出了打包的主脚本文件名为pyre.pyc。这就是我们最终要找的核心文件。进入pyre_extracted文件夹你会发现里面文件非常多有大量的.pyc文件和动态库。我们的目标就是找到那个入口点pyre.pyc。它通常就在根目录下或者在一个明显的子目录里。在这个题目中我们直接在pyre_extracted目录下就能找到pyre.pyc。3.3 关键步骤修复pyc文件头直接尝试用uncompyle6反编译刚提取的pyre.pyc很可能会失败报错“Unknown magic number”或“Decompilation failed”。这是因为PyInstaller提取出来的pyc文件可能缺少了标准的文件头。一个完整的.pyc文件结构如下部分大小说明魔数 (Magic Number)4字节标识Python版本和生成此pyc的字节码格式。不同Python版本不同。位域 (Bit Field)4字节通常与时间戳或哈希有关在Python 3.7中它可能是源文件大小或时间戳等。序列化后的代码对象剩余部分使用marshal序列化后的PyCodeObject这才是真正的字节码。从PyInstaller提取的pyre.pyc往往直接从“序列化后的代码对象”部分开始缺少了前8个字节的魔数和位域。我们的任务就是补上它们。如何补确定魔数之前pyinstxtractor的输出已经告诉我们Python版本是3.7。我们需要找到Python 3.7对应的魔数。一个简单的方法是在本地用Python 3.7随便写一个脚本test.py然后编译它python -m py_compile test.py这会生成一个__pycache__/test.cpython-37.pyc文件。用十六进制编辑器打开这个文件看前4个字节16进制表示。例如Python 3.7.0的典型魔数是0x420d0d0a小端序存储在文件里看到的是0a 0d 0d 42。注意Python 3.7的小版本号如3.7.0, 3.7.9魔数可能不同最保险的方法是使用题目同环境生成或者用一个已知的3.7版本pyc文件头。对于这道GWCTF 2019的题通常使用的魔数是0x420d0d0a。确定位域对于这种从打包文件中提取的、用于分发的pyc位域第二组4字节通常填充为0即可。所以我们可以用00 00 00 00。实际操作用HxD打开从pyre_extracted里提取的pyre.pyc我们称之为broken.pyc。全选所有内容CtrlA复制CtrlC。新建一个文件CtrlN在开头位置写入正确的魔数。对于0x420d0d0a小端序在HxD的左侧“十六进制”区域依次输入0A 0D 0D 42。注意输入顺序。接着输入位域00 00 00 00。然后将光标移到这8个字节之后粘贴CtrlV刚才复制的broken.pyc的全部内容。将新文件保存为pyre_fixed.pyc。现在pyre_fixed.pyc就是一个拥有完整文件头的、合法的Python 3.7字节码文件了。4. 第二步反编译与源代码分析有了修复好的pyc文件我们就可以一窥其内部逻辑了。4.1 使用uncompyle6进行反编译在命令行中执行uncompyle6 -o . pyre_fixed.pyc-o .参数表示将反编译出的源代码输出到当前目录文件名会自动基于pyc文件名生成比如pyre_fixed.py。 如果一切顺利当前目录下就会多出一个pyre_fixed.py文件里面就是可读的Python源代码。如果uncompyle6报错可以尝试换用pycdc# 假设pycdc可执行文件名为pycdc.exeWindows或pycdcLinux pycdc pyre_fixed.pyc pyre_decompiled.py4.2 核心代码逻辑剖析打开反编译得到的pyre_fixed.py我们就能看到题目的全部逻辑。下面我结合常见的出题模式来拆解这段代码# 通常反编译出来的代码开头会有些乱码或注释忽略即可直接看主体函数 print(Welcome to Re World!) print(Your input1 is your flag~~) str input() str list(str) # 将输入的字符串转为列表方便逐个字符操作 # 一个典型的逐字符变换循环 for i in range(len(str)): str[i] chr(ord(str[i]) ^ i) # 每个字符与其索引i进行异或操作 str[i] chr(ord(str[i]) i) # 异或结果再加上索引i # 将处理后的列表转回字符串 str .join(str) # 一个固定的、经过相同算法加密后的比较字符串 encode GWHT{...} # 这里会是很长一串乱码字符比如GWHT{...} if str encode: print(Congratulations!) else: print(Try again!)逻辑清晰了程序接收我们的输入。对输入的每一个字符先进行c ^ ic是字符的ASCII码i是它的位置索引从0开始然后再进行(c ^ i) i。将处理后的结果与一个硬编码在程序里的字符串encode进行比较。相等则成功。所以encode字符串就是flag经过相同算法加密后的结果。我们的目标就是逆向这个算法已知加密后的字符串encode求原始的flag。4.3 算法逆向与脚本编写加密过程是enc_char chr((ord(original_char) ^ i) i)那么逆向解密过程就是其逆运算已知enc_char先得到其ASCII码enc_code ord(enc_char)逆操作“加i”mid_code enc_code - i逆操作“异或i”orig_code mid_code ^ i得到原始字符original_char chr(orig_code)注意这里的i同样是字符的位置索引0-based。因此解密脚本非常简单encode GWHT{...} # 这里需要替换成你反编译代码中看到的实际字符串 flag for i in range(len(encode)): # 逆向运算 mid_code ord(encode[i]) - i orig_code mid_code ^ i flag chr(orig_code) print(flag)运行这个脚本就能直接得到flag。实操心得这种单字节、逐位置的对称加密算法在CTF逆向题中非常常见。关键在于识别出算法模式。看到ord(),chr(),^异或以及循环索引i参与运算就要立刻想到可逆性。写逆向脚本时一定要确保运算顺序与加密完全相反并且注意数据类型整数运算。5. 第三步验证与Flag获取编写好解密脚本后最后一步就是运行验证。5.1 运行解密脚本将反编译代码中完整的encode字符串通常是一长串看起来乱码的字符用引号括着复制到解密脚本的encode ‘...’中。然后运行你的解密脚本python solve.py。如果算法逆向正确你会立刻得到一串有意义的字符串格式很可能就是flag{...}或者题目规定的其他格式。5.2 提交Flag将解密脚本输出的字符串完整地提交到CTF平台即可通过该题目。6. 常见问题与深度排查指南在实际操作中很少能一帆风顺。下面是我总结的几个常见坑点及其解决方案。6.1 反编译工具报错“Magic Number”或“Decompilation Failed”这是最常见的问题几乎百分之百是因为pyc文件头不对。排查步骤确认Python版本再次检查pyinstxtractor.py输出的Python version。确保你使用的魔数与该版本匹配。检查魔数用十六进制编辑器打开你修复的pyre_fixed.pyc看前4个字节是什么。与一个已知正确的同版本pyc文件头对比。你可以自己用对应版本的Python生成一个test.pyc来对比。尝试不同魔数Python 3.7.0到3.7.9的魔数可能变化。可以搜索“Python magic number list”找到列表尝试附近的几个魔数例如0x420d0d0a,0x430d0d0a等。尝试pycdc有时uncompyle6对文件头要求更严格pycdc可能更宽容。换用pycdc试试。检查文件完整性确保从pyre_extracted中复制原始字节码内容时没有遗漏或错位。6.2 反编译出的代码逻辑混乱或无法运行有时反编译工具会出错导致生成的Python代码语法错误或者逻辑完全不对。解决方案直接阅读字节码这是进阶技能但有时更可靠。使用dis模块可以反汇编pyc文件。python -m dis pyre_fixed.pyc输出虽然是汇编风格的字节码但结合Python字节码指令集文档可以艰难地读懂核心逻辑比如循环、异或、比较等关键操作。对于本题这种简单算法从字节码中定位关键部分也是可行的。动态调试如果条件允许可以尝试用pyinstxtractor提取出所有依赖后在对应Python环境下尝试直接运行提取出的“项目”或者用调试器附加。但这道题通常不需要这么复杂。交叉验证用pycdc和uncompyle6分别反编译对比两者的输出。如果关键部分一致那么这部分代码很可能是正确的。6.3 解密脚本运行后输出乱码或报错这说明你的逆向算法可能写错了或者对encode字符串的处理有误。排查步骤打印中间值在解密脚本的循环里打印每一步计算后的mid_code和orig_code。检查是否有负数或非常大的数出现这可能导致chr()函数报错ord must be in range 0x0-0x10ffff。核对算法逐字符手动验算。取encode的第一个字符i0用手算或写一小段代码验证你的解密算法是否能得到你认为的flag第一个字符。因为i0时^0和0操作等于没变所以encode[0]应该就等于flag[0]。这是一个快速验证点。检查encode字符串确保你从反编译代码中复制的encode字符串完全正确包括两边的引号最好是复制到文本编辑器里检查没有遗漏转义字符比如\n,\t等。如果字符串中有反斜杠\要特别注意它是否是转义字符的一部分。6.4 提取出的文件夹中找不到明显的pyc文件有时pyinstxtractor提取出的入口点名字不是pyre.pyc或者它被放在了某个子目录。解决方案仔细阅读pyinstxtractor的输出信息看Possible entry point:后面跟的是什么名字。在提取的文件夹内使用文件搜索功能Windows下在资源管理器搜索*.pycLinux下用find . -name *.pyc列出所有pyc文件。根据文件大小和命名推测。主脚本的pyc文件通常不会太小名字也可能与题目名或“main”、“start”相关。可以逐个尝试修复和反编译最重要的那几个。7. 总结与技巧延伸通过这道“[GWCTF 2019]pyre”的详细拆解我们实际上走通了一个标准的、通用的Python逆向流程。这个流程对于绝大多数由PyInstaller、Py2Exe、Nuitka等工具打包的Python程序都适用。核心流程再梳理识别用file或DIE确定是Python打包程序。解包使用对应打包器的提取工具如pyinstxtractor提取出字节码文件。修复用十六进制编辑器为提取出的pyc文件补上正确的文件头魔数位域。魔数版本匹配是关键中的关键。反编译使用uncompyle6或pycdc将pyc反编译为可读的Python源码。分析阅读源码理解其加密或验证逻辑。逆向根据加密逻辑编写逆向解密脚本。获取Flag运行脚本得到flag。一些更高阶的技巧和思考对抗反编译有些题目会使用代码混淆、花指令或者修改Python解释器字节码的方式来增加反编译难度。这时可能需要动态分析使用sys.settrace挂钩、调试器或手动分析字节码。算法识别除了简单的异或、加减还可能遇到Base64、RC4、TEA、自定义置换等算法。需要积累常见算法的特征和识别方法。工具链自动化可以将上述步骤写成脚本自动完成文件识别、解包、修复魔数、反编译等一系列操作提高效率。环境隔离建议使用虚拟环境如venv或conda来管理不同版本的Python和反编译工具避免冲突。这道题就像一把钥匙帮你打开了Python逆向的大门。它所涉及的技术点——文件结构、字节码、反编译、简单算法逆向——是这座大厦的基石。以后再遇到更复杂的Python逆向题无非是在这些基石上增加了更多的房间和机关但解决问题的根本路径已然清晰。