简介:PyInstaller提取器是一个面向Python逆向工程学习者的实用工具脚本,主要解决PyInstaller打包的Windows可执行文件难以分析的问题。它能够提取exe内的pyz/pyc内容,并自动修复pyc文件头,使Python字节码反编译器可以正常识别,从而还原出接近源码的中间产物。资源包共3个文件,包含核心Python脚本、Markdown使用说明与License许可文件,压缩后仅18KB,轻量且便于对照学习。脚本兼容Python 2.x和3.x,并已适配PyInstaller 2.0至4.2等多个常见版本,兼具通用性和实用性。已有830人学习下载,适合从事安全分析、CTF取证或PyInstaller程序逆向调试的读者,也可作为理解Python字节码结构和打包机制的一手参考资料。配合README说明,初学者能快速按步骤运行脚本,在真实exe上验证提取效果。
1. pyinstxtractor 在 PyInstaller 逆向里到底解决什么问题
拿到一个没有源码的 Python 桌面程序,第一反应往往是直接反编译 exe。但如果你先拿 strings 扫一遍,看到 CArchive、PYZ、bootloader 这些字样,就知道它是 PyInstaller 打的包。pyinstxtractor 就是干这个活的提取器:它不负责反编译,负责把 PyInstaller 归档里的 pyc、资源文件、动态库原样拆出来,拆完你再用 Python 版本对应的反编译工具去恢复源码。实际处理过几个 PyInstaller 打包的样本之后你会发现,解包这步远没有想象中难,真正卡人的是 Python 版本对齐和 TOC 格式差异。这篇文章从定位 overlay 一路讲到恢复源码,把命令、参数、坑位和验证脚本都放出来,新手照着跑能出结果,熟手可以拿去排查新版 PyInstaller 的兼容问题。
2. 提取原理:CArchive 头、TOC 与 PYZ 在打包产物里如何定位
PyInstaller 做出来的可执行文件,本质上不是把 Python 代码编译成机器码,而是把一个自带的 Python 解释器、依赖库和你的字节码文件拼装成一个自解压归档。程序启动时先把归档里的内容释放到内存或临时目录再执行。这个归档在 PyInstaller 里叫 CArchive,归档内部有一张目录表 TOC,记录每个文件叫什么、类型是什么、在归档里偏移量多少、原始长度多少。pyinstxtractor 的核心工作,就是先从 exe 文件里找到 CArchive 的魔法头,再顺着 TOC 把条目逐一读出来并落盘。
2.1 打包产物里到底有没有真正的 Python 源码
很多第一次接触的人会误以为 PyInstaller 输出的是一个编译完的二进制,源码已经“消失”了。实际上 PyInstaller 对纯 Python 模块做的处理非常轻:只是把 .py 编译成 .pyc 字节码,再把字节码压缩进 PYZ 归档。只要 Python 主版本号能对上,从 pyc 里恢复出可读的 Python 源码是常规操作。这里有个反直觉的点:PyInstaller 的 bootloader 是编译过的二进制,但你的业务代码基本都保留在 pyc 里,所以逆向的突破口不是反汇编 C 代码,而是把 pyc 从归档里捞出来。
所以你会看到提取出来的东西分两类:一类是纯 Python 模块对应的 pyc,这类文件信息量最大;另一类是扩展模块、动态链接库、图片和配置等资源文件。pyinstxtractor 只负责恢复归档结构,不改动文件内容。它提取出的 pyc 可能需要你手工补上文件头,因为打包时为了压缩体积,pyc 头通常会被去掉或改短,补完头之后才能交给反编译工具。
2.2 用较小代价找出 overlay 起点和 Python 版本 magic
在动手提取之前,可以先确认 exe 尾部是否藏着 CArchive。CArchive 的魔法头是一段特征明显的字节,常见形态是MEI\x0c\x0b\x0a\x0b\x0e。这段字节一般出现在文件末尾的 overlay 区域,所以不必全文件扫描,只看尾部一小段即可。如果你用十六进制编辑器打开 exe,翻到文件末尾附近,能看到连续的MEI开头,后面跟着一些二进制版本信息,基本就能确认它是 PyInstaller 产物。
命令行里也可以用一段 Python 快速定位:
python - <<'EOF' from pathlib import Path import sys data = Path('目标程序.exe').read_bytes() pos = data.rfind(b'MEI\x0c\x0b\x0a\x0b\x0e') if pos == -1: print('未找到 CArchive 魔法头') else: print('overlay 起点(十进制):', pos) print('起点前 16 字节:', data[pos-16:pos].hex()) print('起点后 32 字节:', data[pos:pos+32].hex()) EOF这段逻辑很简单:从文件尾部反向找MEI魔法,找到的位置就是 CArchive 头附近。pyinstxtractor 内部也有一套类似的定位逻辑,但它还会继续读 cookie 和版本信息,把 Python 版本号解析出来。自己写探测脚本的意义在于,当 pyinstxtractor 遇到不认识的 PyInstaller 新版本时,你可以先用这段代码确认魔法头还在不在,如果魔法头位置都对不上,那问题基本不在提取工具,而在打包器格式已经改变。
光有 overlay 位置还不够,还要知道目标程序是用哪个 Python 版本打包的。pyinstxtractor 启动时会拿当前 Python 环境的 magic number 和目标 pyc 里的 magic number 做比对,不一致会直接报警。这是因为不同 Python 版本的字节码格式不兼容,3.7 的 pyc 拿到 3.10 环境里反编译,大概率是一堆乱码。你可以在提取前先执行下面这条命令,看当前环境的 Python magic:
python -c "import importlib.util; print(importlib.util.MAGIC_NUMBER.hex())"常见版本的 magic 值:Python 3.7 是55 0d 0d 0a,Python 3.8 是55 0d 0d 0a但 bit field 不同,Python 3.9 是61 0d 0d 0a,Python 3.10 是6f 0d 0d 0a,Python 3.11 是6e 0d 0d 0a。这只是我常用的对照参考,不要死记,遇到具体文件以实际 magic 为准。后面排错章节会展开讲 magic mismatch 的处理。
2.3 为什么优先选 pyinstxtractor 而不是通用十六进制编辑器
有人会问,既然原理是找 TOC 再按偏移量切文件,那我用十六进制编辑器手动切不也行吗?小文件可以,但真实程序动辄几十上百个模块,TOC 条目之间有压缩标记、类型码、文件名编码差异,手工切非常容易看漏。pyinstxtractor 的价值在于,它把这些繁琐的解析逻辑封装成一个可重复执行的流程,输出目录结构清晰,pyc 和资源文件自动分开,省下的时间足够你多跑几个样本。
另外,pyinstxtractor 并不是把所有文件都当成 pyc 来处理。它识别出 TOC 里的z类型后,会把 PYZ 归档单独拆开,再把 PYZ 内的模块名还原成完整路径。这个细节很关键,因为 PyInstaller 打包时常把大量模块塞进一个 PYZ 压缩包里,如果没有这层嵌套解析,你只能得到一个巨大的 blob,拿不到独立 pyc。选型时我也比较过其他解包工具,多数只做了第一层 CArchive 展开,对 PYZ 的处理比较粗糙,而 pyinstxtractor 的 PYZ 展开逻辑足够应付绝大多数场景。
提示:pyinstxtractor 是单文件脚本,不需要安装依赖。把它放到和待提取 exe 同一个工作目录,直接用
python pyinstxtractor.py 目标程序.exe就能运行。如果你本地 Python 环境版本和目标程序不匹配,后面提取时会看到 magic mismatch 警告,这时不要跳过,先解决版本问题。
3. 提取实战:从 exe 拆出 pyc 再恢复到可读源码的完整流程
这一章是可复现的主流程。我以一个假设的 Python 3.8 环境打包出来的 Windows 程序为例,演示怎么从零开始把它的业务模块恢复成源码。整个链路是:准备环境 → 运行 pyinstxtractor → 检查输出 → 修复 pyc 头 → 反编译。每一步都有对应的命令和参数说明,你照着自己的文件路径替换即可。
3.1 搭建与目标相同 Python 版本的运行环境
最重要的一件事:让 pyinstxtractor 跑在和目标打包环境相同的 Python 主版本里。比如待提取程序里的 pyc magic 是 3.8 的,那就用 Python 3.8 来运行 pyinstxtractor。版本一致的情况下,脚本会直接复用当前环境的 magic,提取完的 pyc 头信息也更省事。相反,如果你本地只有 3.10,目标文件是 3.7 打的包,不是不能提取,但后面反编译阶段会遇到工具兼容性限制,最终效果差很多。
如果你不确定目标版本,可以先拿上一章的命令把 magic 十六进制打出来,再对照自己环境里的importlib.util.MAGIC_NUMBER。同一版本内部还有小版本差异,比如 3.8.0 和 3.8.10 的字节码格式基本一致,反编译工具大多能处理,所以不需要强行追求完全相同的补丁版本。平时我至少会准备一个 3.8 和一个 3.10 的虚拟环境,分别装好反编译工具,遇到样本时按 magic 切换环境。
进入工作目录,把 pyinstxtractor.py 准备好后,执行:
python pyinstxtractor.py 目标程序.exe这里的目标程序.exe换成你的实际文件。脚本会读取 exe、定位 overlay、解析 TOC 并逐条提取,全部过程只需要几秒。如果你看到类似Magic mismatch的输出,记住这个提示,先去换 Python 版本,而不是继续往下走。如果一切正常,脚本会在同目录生成一个带_extracted后缀的文件夹,所有提取结果都在里面。
3.2 运行 pyinstxtractor 并读懂输出目录
提取成功后,输出目录里通常包含几类内容:主程序同名的 pyc 文件、一个 PYZ 子目录或 pyz_extracted 子目录、若干动态库和资源文件,以及一个用于记录 PyInstaller 版本信息的文本文件。你首先要看的不是源码,而是目录结构,确认哪些是纯 Python 模块、哪些是 C 扩展。纯 Python 模块对应的文件名基本就是你打包前的模块名,比如main.pyc、utils.pyc;C 扩展通常以.pyd结尾,这类文件没法恢复成 Python 源码,只能拿来做动态分析。
用文件树确认结果:
find 目标程序_extracted -maxdepth 2 -type f | head -50如果目录里出现大量纯数字或哈希命名的文件,别急着怀疑脚本坏了。那可能是 PYZ 内部的模块名被压平后的结果,需要看pyz_extracted目录里的实际文件名。PyInstaller 在归档时经常把模块路径里的斜杠替换掉,你看到的文件名未必等于真实模块名,但内容仍然是标准 pyc。
确认完文件类型后,下一步是检查 pyc 头。很多 pyc 从归档里提取出来时是“裸”的,缺少标准文件头,直接扔给反编译工具会报Invalid pyc header。修复方法也简单:在当前 Python 环境里生成一个空 pyc,截取它的头部,拼到提取出来的 pyc 前面。要注意的是,不同 Python 版本的 pyc 头长度不一样,3.7 及以后通常是 16 字节,3.6 是 12 字节。如果头部长度不对,反编译工具能读一部分但会在后面报错。
3.3 补齐 pyc 头并用反编译工具恢复项目源码
补齐头部的过程可以用一个短命令完成。先准备好一个模板头:
echo '' > dummy.py python -m py_compile dummy.py python - <<'EOF' from pathlib import Path h = Path('__pycache__/dummy.cpython-38.pyc').read_bytes()[:16] Path('header.bin').write_bytes(h) print('header length:', len(h)) EOF这里生成的是当前 Python 环境对应版本的 pyc 头部,长度为 16 字节。如果你的待修复文件是 3.6 的,就把[:16]改成[:12]。拿到头部后,对提取出的每个裸 pyc 做拼接:
python - <<'EOF' from pathlib import Path header = Path('header.bin').read_bytes() for pyc in Path('目标程序_extracted').rglob('*.pyc'): raw = pyc.read_bytes() # 已经带头的文件跳过 if raw[:4] == header[:4]: continue out = pyc.with_suffix('.pyc.fixed') out.write_bytes(header + raw) EOF这段脚本会遍历提取目录下所有.pyc,判断是否已经有 magic 头,没有就补上。后缀改成.pyc.fixed是为了保留原始文件,方便回头对比。补完头之后,用反编译工具去恢复源码。我常用的顺序是:Python 3.8 及以下优先用 uncompyle6,Python 3.9 以上换成 decompyle3 或 pycdc。命令都是类似的:
uncompyle6 main.pyc.fixed > main.py如果反编译过程报错,不要立刻放弃,先检查是不是头没补对。你把main.pyc.fixed前 16 字节打印出来,和header.bin对比,只要前四个字节一致就说明 magic 对了,反编译工具还报错的话,问题多半出在代码层面,比如用了新版语法特性,或者打包时开启了字节码混淆。
4. pyinstxtractor 常见报错与排查:5 个高频坑逐一排雷
提取 PyInstaller 样本时,常见问题集中在版本匹配、目录结构异常和 pyc 头损坏三类。我按自己踩坑的经验整理了 5 条,每一条都按“现象 → 原因 → 解决”来讲,第 4 条是很多人容易看走眼的地方。
4.1 Magic mismatch:当前 Python 与目标打包版本不一致
现象:运行 pyinstxtractor 后,脚本提示 magic mismatch,并且部分 pyc 提取出来但反编译乱码。
原因:PyInstaller 打包时写入 pyc 的 magic 是打包环境的 Python 版本,而 pyinstxtractor 运行时用的 magic 是当前环境版本。两者不一致时,脚本无法确认目标字节码格式,提取逻辑虽然能继续,但产出的 pyc 可能被误判。
解决:切换 Python 版本后再试。最稳的办法是先看目标文件的 magic,再建一个对应版本的虚拟环境。如果你的系统里没有对应版本,可以用版本管理工具装一个,然后把 pyinstxtractor.py 放到该环境里执行。这里有一个血泪经验:不要为了省事用--python-version之类的参数硬改 magic,提取结果看起来对了,反编译时照样翻车。
4.2 提取目录里找不到主模块 pyc
现象:输出目录里有很多资源文件和动态库,但没看到main.pyc,或者主程序同名的 pyc 不存在。
原因:主模块被 PyInstaller 特殊处理,可能直接放在 CArchive 根 TOC 里,也可能被压进 PYZ。如果文件名被混淆,你不一定能第一时间认出来。
解决:先看有没有pyz_extracted目录,进去找体积最大的 pyc。另一种方法是直接在提取目录里搜包含入口特征的字符串,比如程序窗口标题、错误提示关键词,反查哪个 pyc 里有对应常量。按我的经验,入口模块通常叫main.pyc、__main__.pyc,但有些打包脚本会改入口文件名,这时候别只盯着文件名看,结合反编译结果判断更靠谱。
4.3 反编译时报 invalid pyc header
现象:补完头之后,反编译工具仍然报Invalid pyc header或类似错误,有时还提示 unknown magic number。
原因:这个坑十有八九是头补错版本。比如你先用 3.8 环境生成了 16 字节头,但目标 pyc 其实是 3.9 的,magic 对不上;或者是把 16 字节头拼到了一个已经带头的 pyc 上,导致重复。
解决:反编译前先验证头部。打印文件前 4 个字节,和importlib.util.MAGIC_NUMBER对比,不一致就换环境重新生成头。验证脚本很简单:
python - <<'EOF' from pathlib import Path import importlib.util for f in Path('目标程序_extracted').rglob('*.pyc.fixed'): magic = f.read_bytes()[:4] print(f.name, magic.hex(), 'OK' if magic == importlib.util.MAGIC_NUMBER else 'BAD') EOF把 magic 不一致的文件挑出来,换成目标版本的头部再拼接。如果所有文件都一致但反编译仍报错,再考虑是不是打包时对 pyc 做了额外处理。
4.4 PYZ 里的模块名全部变成下划线路径
现象:pyz_extracted目录里的文件不是正常模块层级,而是一长串下划线,比如my_project_main_py之类的名字。
原因:PyInstaller 在 PYZ 归档里通常用完整模块路径作为 key,路径分隔符会被替换成下划线。pyinstxtractor 拿到 key 后直接当成文件名落盘,所以目录结构看起来是扁平的。
解决:不用改这些文件名,反编译之后从源码里的co_filename字段恢复原始路径即可。如果你需要模块名映射,可以写个简单的对照脚本,把 pyc 里的code.co_filename打印出来,这样就能知道这个文件原本属于哪个包。这个操作在最后一章验证技巧里会展开。
4.5 提取出来的 pyc 能加载但行为对不上
现象:反编译出的源码能看懂,但运行逻辑明显缺了一部分,或者某些函数是空的。
原因:一种情况是 PyInstaller 开启了压缩,pyi 会把部分模块的 code object 做二次处理;另一种情况是程序里有动态生成的代码,运行时才拼出来,静态提取自然看不到。还有就是反编译工具本身对高版本 Python 的字节码还原率有限,控制流复杂时容易丢代码。
解决:这不是 pyinstxtractor 的锅,别在提取阶段浪费时间。先把 pyc 用marshal.loads加载成 code object,确认 code object 本身是完整的,再换个反编译工具试试。如果工具都还原不了,就用dis.dis看字节码,手动重建逻辑。这一步算是逆向中的玄学环节,耐心多试几种工具,比反复调提取参数有效得多。
5. 改造提取器:适配新版 PyInstaller 的 TOC 差异
PyInstaller 自己也在持续改格式,pyinstxtractor 这种第三方脚本偶尔会跟不上新版本。遇到新版打包器时,常见错误是提取到一半解析失败,或者解出来的 TOC 条目明显不对。这一章给你一套不改整个脚本也能自救的方法:先用探针脚本确认 CArchive 位置,再用 PyInstaller 自带的 archive viewer 对照 TOC,最后用一个最小提取函数把缺失条目捞出来。
5.1 先定位 CArchive:一个十六进制探针脚本
pyinstxtractor 认不出新版本时,最先怀疑的应该是 CArchive 魔法头的位置。新版 PyInstaller 可能调整了 cookie 的偏移计算方式,导致旧脚本从错误位置开始读 TOC。写一个探针脚本,把魔法头周围的字节打出来,对比旧版和新版之间的差异。
from pathlib import Path import sys data = Path(sys.argv[1]).read_bytes() magic = b'MEI\x0c\x0b\x0a\x0b\x0e' pos = data.rfind(magic) if pos == -1: print('魔法头不存在或已被修改') sys.exit(1) print('magic offset:', pos) start = max(pos - 32, 0) for off in range(start, min(pos + 64, len(data)), 16): chunk = data[off:off+16] printable = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk) print(f'{off:08x} {chunk.hex(" "):<47} {printable}')运行方式:
python probe.py 目标程序.exe输出里你会看到 magic 前后各 32 字节的十六进制和 ASCII 内容。重点观察 magic 后 4 个字节是多少,这个位置通常对应 cookie 里的版本信息。如果这里的字节分布和旧版明显不同,说明新版归档头有变化,pyinstxtractor 解析失败的原因就在这。探针逻辑不需要很复杂,它的目的只是让你在排查时有一个可见的锚点。
5.2 用 pyi-archive_viewer 对齐 TOC 项字段
PyInstaller 安装后会自带一个pyi-archive_viewer工具,它可以不提取文件,直接以交互方式展示归档内的 TOC 条目。当 pyinstxtractor 解析卡住时,用这个工具看真正的 TOC 结构,是最直接的对照方式。
pyi-archive_viewer 目标程序.exe进入交互界面后,输入?可查看命令列表,常用的有列出 TOC、选择归档、输出某些条目到当前目录。你先列出所有 TOC 项,观察每一项的格式。PyInstaller 5.x 和 6.x 的 TOC 项虽然都是“类型、名称、偏移、长度、压缩标记”这几个字段,但字段顺序和存储方式可能不同。你不用背这些差异,只需要把 archive_viewer 展示的字段和 pyinstxtractor 脚本里的解析代码做个对照,确认它读取的是哪个字段。
举个例子,如果 archive_viewer 显示某个模块的名称是my_project/main,但 pyinstxtractor 提取出来的文件名是my_project_main,说明脚本在解析名称时把分隔符替换掉了。这种差异不影响文件内容,但如果你要做自动化批量提取,就需要在脚本里保留原始名称字段。对照完之后,你基本能判断是这个字段顺序错了,还是压缩标记解释错了。
5.3 最小提取函数:按偏移量切片写出
当你搞清楚新版本 TOC 的字段含义后,可以自己写一个最小提取函数,把 archive_viewer 里看到的条目按偏移量切出来。这个函数不需要完整解析 CArchive,只需要传入文件路径、偏移量、长度和压缩标记,直接从文件里切数据。
from pathlib import Path def extract_entry(exe_path, out_dir, name, typecode, offset, length, compressed): data = Path(exe_path).read_bytes() chunk = data[offset:offset + length] if compressed: import zlib chunk = zlib.decompress(chunk) dest = Path(out_dir) / name.replace('/', '_') dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(chunk) print(f'{typecode} {name} -> {dest}')参数说明:typecode用来标记文件类型,z是压缩模块归档,m或p是纯 Python 模块,b是二进制动态库。你在 archive_viewer 里看到什么类型,就传给这个函数。这里需要注意压缩标记的判断,PyInstaller 的 TOC 里通常用 0 或 1 表示是否压缩,但新版本可能用别的值,建议先在 archive_viewer 里查看该条目的原始标记,再用对应逻辑解压。
这个函数是为了救急,不是要替代 pyinstxtractor。等 pyinstxtractor 后续版本适配了新格式后,回归到完整脚本即可。但如果你手里有一个马上就要分析的新版样本,这招能让你摆脱“工具不适配”的停滞状态。
提示:手动提取只适合少量条目。如果需要全量提取,还是尽量等 pyinstxtractor 更新,或者找一个已经适配新 PyInstaller 的维护版本,手动提取容易漏掉嵌套的 PYZ 归档。
6. 收尾验证:从 pyc 头到 code object 的一体化检查
提取和反编译都跑完后,还有一个容易被忽略的验证环节。很多人费劲反编译出一堆源码,结果发现某个模块其实没提取完整,或者 pyc 头补错了,白白浪费时间。我现在养成的习惯是:在进入反编译之前,先用 marshal 加载所有 pyc,确认 code object 能正常读出来,再批量交给反编译工具。
6.1 用 marshal 验证 pyc 头是否对齐
Python 的 pyc 文件去掉头部之后,剩下的内容是一个 marshal 序列化的 code object。只要能成功marshal.loads,就说明这个 pyc 的头部长度是对的,模块内容也是完整的。
import marshal from pathlib import Path def check_pyc(path: Path): data = path.read_bytes() # 从末尾开始尝试不同头长,找到能成功解析的长度 for header_len in (12, 16, 24): try: code = marshal.loads(data[header_len:]) print(path.name, 'OK', code.co_filename, header_len) return code except Exception: continue print(path.name, 'FAILED') return None for f in Path('目标程序_extracted').rglob('*.pyc.fixed'): check_pyc(f)这段脚本会从常见头长里试出能成功解析的那个。如果某一版 pyc 用的头长和标准不同,脚本也能直接发现。你看输出里的co_filename,它通常会显示打包前模块的原始路径,这个信息比文件名更可靠,能帮你还原项目目录结构。
6.2 反编译工具接力顺序
验证完 code object 之后,再按顺序跑反编译工具。我的一般顺序是:先 uncompyle6,失败换 decompyle3,再失败的用 pycdc。工具不是越多越好,而是要看它支持你当前 Python 版本的字节码到什么程度。比如 uncompyle6 对 3.8 支持尚可,到 3.9 之后基本就废了;pycdc 支持的版本范围广,但还原出来的代码风格比较乱,控制流容易变形。
如果你发现某个模块反编译结果明显缺函数,可以先打开该 pyc 的 code object,看co_names和co_consts里有没有目标函数名。如果名字有但反编译代码里没有,说明工具还原失败,换工具或者手动看字节码。这套验证流程走下来,至少能确保你不是在错误的提取结果上做二次返工。
我用这个检查习惯处理过很多次 magically 变形的 pyc 头,每次都靠 marshal 加载判断问题出在哪一层,而不是盲目重装反编译工具。解包这类工作,验证比提取更花时间。希望这套流程帮到你,让你少走两步弯路。
本文还有配套的精品资源,点击获取