资讯动态

PyInstaller逆向实践:从exe提取pyc到Python源码还原

发布时间:2026/10/16 14:08:12 来源:尧图企业网站定制
简介PyInstaller提取器pyinstxtractor是面向逆向分析与安全研究的脚本用于从PyInstaller打包的Windows可执行程序中提取内部字节码适合开发者、取证人员和安全竞赛玩家。工具支持Python 2.x与3.x环境兼容PyInstaller 2.0到4.2等常见版本会自动修复字节码文件头便于后续反编译还原逻辑建议在生成该程序时所用的同一版本环境中运行。使用时仅需传入待分析程序的路径即可自动扫描并解出打包内容无需复杂配置。压缩包共3个文件含核心提取脚本、说明文档与许可证整体仅约18KB轻量实用可直接放入个人逆向工具箱随取随用。目前已有830人学习下载通过这份资源可拿到完整可运行的提取工具与配套说明理解打包结构快速完成拆包和字节码提取对入门程序逆向、恶意样本分析及安全竞赛练习很有帮助。1. 拿到一个没有源码的 PyInstaller 程序先别急着装反编译器做 python 逆向工程的人大概率遇到过这个场景某个工具只发布了 exe没有源码但你需要搞清楚它的接口逻辑、硬编码的密钥或者某个算法细节。网上大多数教程一上来就让你装 uncompyle6 然后对着 exe 反编译结果要么报错要么得到一堆乱码。问题通常不是反编译器不行而是你跳过了最关键的中间一步把 PyInstaller 打包产物里的 pyc 文件先完整、无损地提取出来。pyinstxtractor 就是干这件事的——它能把 PyInstaller 生成的 exe 拆开还原出内部的 pyc 文件之后你才能用 uncompyle6、decompyle3 或 pycdc 去恢复 Python 源码。适合刚接触 python 反编译的新手也适合需要批量处理多个样本的老手。2. 拆开 PyInstaller 的壳CArchive、TOC 与提取器的工作方式2.1 PyInstaller 打包产物长什么样要理解 pyinstxtractor 为什么重要得先知道 PyInstaller 把源码藏在了哪里。PyInstaller 打包后的 exe 不是一个纯 PE 文件它的尾部附加了一个自定义格式的归档段官方叫 CArchive。这个归档里存放了 Python 解释器动态库、依赖的 pyd/dll、资源文件以及最重要的——所有被 import 的模块编译后的 pyc 字节码。CArchive 的结构不复杂但很关键文件开头是一个四字节的 MAGIC 值通常是MEI\014\013\012\013\016接着是 TOCTable of Contents偏移量TOC 里记录了每个条目在归档段中的偏移、压缩标志、长度和名字。pyinstxtractor 的核心工作就是解析这份 TOC把每一个条目按原样切出来写成独立的文件。值得注意的一个细节是PyInstaller 从 5.x 开始调整了部分打包参数导致 CArchive 的段布局和旧版有细微差异。pyinstxtractor 能兼容到目前主流版本是因为它不依赖硬编码的偏移而是顺着 MAGIC 找到 TOC 后一步步解析条目。这也是它比盲目用binwalk或手动搜pyc头更可靠的原因。2.2 提取器怎么处理 pyc 的头部pyc 文件在 Python 3.7 之前带有一个 16 字节的文件头Magic Number 时间戳 源文件大小但从 Python 3.7 开始头被精简为 12 字节且不再保存源文件大小。pyinstxtractor 在提取时会把 pyc 原始头部完整保留下来这一点非常关键——某些提取器会自作主张重写 pyc 头导致后续反编译工具无法识别。我拆过的样本里大概有三分之一会出现“pyc 提取成功但反编译失败”的情况原因十有八九就是这个头没对齐。pyinstxtractor 的处理方式是将每个 pyc 条目按原始字节直接写入不做任何加工。所以提取完的 pyc 头是什么样PyInstaller 打包时就是什么样。如果你后面反编译时发现版本信息对不上需要手动修正的也是这个头部。2.3 提取流程的阶段拆解pyinstxtractor 的执行过程可以拆成四个阶段定位 CArchive 段扫描整个 exe 文件找到末尾的 MAGIC 标记。解析 TOC读取每个条目的偏移、大小、名称。导出条目将 TOC 中的每个条目存为单独文件遇到.pyz之类的嵌套归档会递归处理。输出结构在主输出目录下生成pyz_extracted子目录、struct目录和根目录下的入口 pyc。这里最容易踩的坑是主程序入口脚本不一定和 exe 同名。PyInstaller 在打包时会把入口脚本编译成pyi_rth_*.pyc或直接以脚本名命名的 pyc 放在归档根目录而不是藏在 pyz 里。很多新手打开输出目录后看到一堆 pyc 不知道哪个才是入口直接对着pyz_extracted里的模块一顿反编译结果拼不出完整逻辑。这个后面会细说。3. 从 exe 到 pyc一次完整的提取实战3.1 环境准备与基础命令pyinstxtractor 本身是单文件脚本不依赖第三方库这一点对分析环境很友好。我把常见做法列一下先准备一个干净的 Python 3.8 环境然后用命令行直接执行。# 假设目标 exe 是 target.exe与 pyinstxtractor.py 放在同一目录 python pyinstxtractor.py target.exe # 输出会创建 ./target.exe_extracted 目录 # 如果遇到权限问题换管理员/root 执行执行过程会打印解析 CArchive 的日志包括找到多少条目、是否检测到 Python 版本 magic 等。执行完检查输出目录ls -la target.exe_extracted/ # 预期看到pyz_extracted/ struct/ 以及若干 .pyc 和 .pyz 文件如果目录里没有pyz_extracted说明这个 exe 的打包方式比较特殊但入口 pyc 仍然会放在根目录。解析日志里出现的“Magic number”指的是 PyInstaller 归档自身的 MAGIC不是 Python 字节码的 Magic别混淆。3.2 快速定位入口脚本入口脚本是恢复业务逻辑的关键。我一般用两步快速定位它先看输出目录里和 exe 同名的 pyc再看pyi_rth_开头的文件名的调用关系。# 在输出根目录查找候选入口 pyc ls target.exe_extracted/*.pyc # 找到候选后用 Python 内置的 dis 模块看它依赖了哪些模块 python -m dis target.exe_extracted/target.pyc 2/dev/null | head -50pyi_rth_文件是 PyInstaller 的运行时钩子不是业务入口但能暴露 import 线索。真正的入口通常满足两个特征文件在归档根目录而不在pyz_extracted里且它 import 的模块在pyz_extracted里能找到。顺着这个调用链往回推比盲目反编译所有 pyc 快得多。3.3 处理 pyz 嵌套归档pyz_extracted目录里装的是 PyInstaller 内置模块归档解包后的产物如果你发现里面的 pyc 也是打包态就用同一个提取器再跑一次# 对嵌套的 pyz 再做一次递归提取 python pyinstxtractor.py target.exe_extracted/archive.pyz输出会生成archive.pyz_extracted目录然后继续检查。递归提取的次数通常不会超过两层但极少数用 PyInstaller 6.x 打包并开启压缩的样本可能要多跑一轮。每次递归提取都应该保留上一轮输出因为某些交叉引用需要原始路径才能对得上。4. 把 pyc 还原成源码反编译器选型、头部修正与参数调整4.1 三个反编译器的真实差异拿到 pyc 之后反编译器的选择直接决定你看到的是可读源码还是满屏LOAD_CONST。我实际用下来三个主流工具各有偏向工具支持的 Python 版本输出质量典型毛病uncompyle62.7 / 3.5-3.8高接近原始源码3.8 以上直接不识别3.7 偶尔误判decompyle33.7-3.8中高对 3.7 优化过3.8 支持有限pycdc3.4-3.10中覆盖率优先条件表达式、生成器有时还原失真选择逻辑并不复杂先判断 pyc 的 Python 版本再选工具。Python 3.7 优先 decompyle33.8 先用 uncompyle6 尝试不行再换 pycdc。3.9 以上基本只有 pycdc 能啃但输出可读性会打折。4.2 版本判断与头部修正反编译前必须先确认 pyc 的 Python 版本。方法很简单读 pyc 前四个字节的 Magic Number和 CPython 源码里的importlib/util.py对照。我知道一个更快的方式用xdis库直接识别。pip install xdis python -m xdis.disasm target.pyc | head -20输出里会明确显示Python version: 3.7这种信息。如果 pyinstxtractor 提取出来的 pyc 头缺了前四字节旧版 PyInstaller 偶尔干这事uncompyle6 会报Bad magic number。修正方法是用同版本 Python 编一个空 pyc把正确头部覆盖过去。# 用 Python 3.7 生成一个空 pyc 并取出它的头部 import py_compile, struct py_compile.compile(empty.py, cfileempty.pyc) with open(empty.pyc, rb) as f: header f.read(16) # 用这个头部去覆盖损坏的 pyc with open(target_fixed.pyc, wb) as f: f.write(header open(target.pyc, rb).read()[16:])这段逻辑是读取同版本空 pyc 的前 16 字节把目标 pyc 从第 16 字节开始的内容接在后面。注意 Python 3.7 之后头部是 12 字节3.6 及以前是 16 字节覆盖前先确认版本否则会把代码对象首字节吞掉。4.3 实际反编译操作与失败切换对某个 pyc 的反编译我习惯先跑 uncompyle6报错立刻切 pycdc不做无谓重试# 第一优先uncompyle6 uncompyle6 target_fixed.pyc target.py # 失败时切 pycdc pycdc target_fixed.pyc target_pycdc.pypycdc 是 C 写的单二进制不需要安装 Python 包但输出里经常带# decompile failed或者把try/except还原成if结构阅读时要有心理准备。对比两个输出文件可以交叉确认哪些代码块还原可信。另外提一句反编译出的源码大概率不是 100% 可运行的。PyInstaller 打包时编译器优化级别通常是-O或-OOdocstring 和断言会被剥离变量名保持原名但注释全丢。你要的是逻辑还原不是重新打包能跑别追求一字不差。5. 从提取到反编译的常见翻车现场六个典型坑与排除方法5.1 提取成功但反编译报 ParserError现象pyinstxtractor 正常跑完pyc 也出来了但 uncompyle6 一执行就报ParserError或者输出几百行后中断。原因九成情况是 pyc 的 Python 版本和反编译器支持范围不匹配。比如 pyc 是 3.10 编译的uncompyle6 根本不认识这种字节码格式。解决先用xdis确认版本再按对应规则换工具或者补头。我在一个样本上遇到过 3.8.5 和 3.8.10 之间的字节码差异导致 pycdc 还原失败最后换decompyle3过的所以版本要精确到小版本。5.2 提取出的 pyc 只有空壳现象反编译成功但源码是pass、...或者return None函数体丢了。原因PyInstaller 打包时如果开了--strip或者源文件本身被代码混淆器比如 PyArmor处理过反编译器拿不到有效的栈帧信息。解决这类 pyc 用任何常规反编译器都救不回来。如果确认是 PyArmor 加密只有两条路找运行内存中的明文或者在入口处 hook 反序列化点。如果是--strip导致换回非 strip 打包的版本再试。5.3 输出目录里找不到入口 pyc现象目录里全是pyi_rth_*.pyc找不到和 exe 同名甚至相关的文件。原因入口脚本可能被 PyInstaller 编进了pyz里或者被改名成了__main__.pyc。解决检查根目录下的__main__.pyc和pyz_extracted/__main__.pyc入口脚本被 PyInstaller 编译后默认命名为__main__。用dis看它的 import 列表就能找到业务模块的位置。5.4 反编译结果语法错误多到没法看现象还原出来的代码结构混乱for变whilewith变 try/finally。原因pycdc 对控制流的还原能力有限尤其是continue/break嵌套和异常处理。解决这类样本没有银弹。我的习惯是切 uncompyle6 或 decompyle3 再出一份结果然后手动比对优先采信后者的输出。pycdc 的结果用于理解大框架不行就靠 xdis 看字节码人工推逻辑。5.5 exe 内嵌资源文件提取不全现象输出目录里能看到 pyc但 ui 文件、配置文件、图片资源缺失。原因PyInstaller 的 CArchive 里包含非 Python 资源pyinstxtractor 默认只解出 pyc 相关条目资源需要手动从struct目录或归档原始数据里捞。解决先用pyi-archive_viewerPyInstaller 自带工具浏览归档内容找到资源条目后用extract命令导出。这种方法适合打包时用了--add-data的场景。5.6 Python 版本跨平台导致乱码现象在 Windows 上用某个提取器跑出来的 pyc拿到 Linux 上反编译中文注释全变乱码。原因不是编码问题是 pyc 头里的 magic 在不同平台有不同值跨平台后反编译器误判版本。解决提取后立刻看 pyc 头部 magic不要跨机器搬运后才验证。如果是跨平台分析在目标平台重新跑 pyinstxtractor保证 magic 和平台一致。6. 进阶玩法批量提取与自动化反编译流水线当你需要分析一批同源样本时挨个手动跑命令效率太低。我把这套流程做成了一个批量脚本核心是对每个 exe 执行提取然后遍历所有 pyc依次用 uncompyle6 和 pycdc 尝试输出成功率和失败报告。import os import subprocess import sys from pathlib import Path def extract_and_decompile(exe_path, output_dir): exe_path Path(exe_path) extract_dir exe_path.parent / f{exe_path.stem}_extracted # 阶段1提取 subprocess.run([sys.executable, pyinstxtractor.py, str(exe_path)], cwdexe_path.parent, checkTrue) # 阶段2遍历 pyc 并反编译 report {} for pyc in extract_dir.rglob(*.pyc): src pyc.with_suffix(.py) # 首选 uncompyle6 ret subprocess.run([uncompyle6, -o, str(src), str(pyc)], capture_outputTrue) if ret.returncode ! 0: # 失败切 pycdc ret subprocess.run([pycdc, str(pyc), -o, str(src)], capture_outputTrue) report[pyc.name] pycdc if ret.returncode 0 else failed else: report[pyc.name] uncompyle6 return report这个脚本的容错逻辑是uncompyle6 返回非零就切 pycdc两者都失败才标记 failed。跑完后打印报告优先看标记failed的 pyc它们往往是混淆或新版本字节码。脚本里的rglob(*.pyc)会遍历嵌套目录所以不用手动处理递归提取。批量模式下还需要注意输出目录清理问题重复提取同一 exe 会覆盖旧目录建议每次运行前删掉上次的_extracted文件夹避免新旧文件混在一起影响判断。最后一个习惯每次提取完我都会手动打开入口 pyc 看一眼第一行 import确认它依赖的模块是否都在pyz_extracted里。这套流程跑多了之后某次遇到一个加壳样本入口 pyc 的 import 指向一个不存在的模块顺藤摸瓜才发现它引用了外部 pyd而那个 pyd 才是真正的加密核心光靠反编译 pyc 根本还原不出来。从那以后我每次拆 PyInstaller 程序都强制走一遍“提取 → 确认入口 → 核对依赖 → 再反编译”的流程不跳过任何一步希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑