PyInstaller 加固样本逆向:从 CArchive 修复到 pyc 反编译
1. 缘起:为什么值得单独写一篇 PyInstaller 样本分析
在 Windows 恶意样本里,Python 打包程序已经不算少见。攻击者把 Python 写的 loader、窃密器、键盘记录器用 PyInstaller 打成 exe,既能跨机器运行,又能把真正的逻辑藏进字节码里。对恶意样本分析师来说,把 PyInstaller 里的 Python 字节码完整提取出来往往是分析的第一步。
这次遇到的样本表面上是一个普通 Windows 程序:运行后提示 Enter input:,输入错误就输出 Wrong input。但用 IDA 打开后,入口逻辑并不是常见的 C/C++ 业务代码,而是 PyInstaller bootloader 的特征——它真正的逻辑在附加数据段里。更值得注意的是,这个样本对通用提取工具做了两处明显的对抗:
- PyInstaller 的 cookie 魔数被改写,导致
pyinstxtractor直接报 “Missing cookie”; - CArchive 里的 Python 模块数据被自定义加密,即使绕过 cookie 检查,解压也会失败。
这两处对抗单独看都不难,但组合起来正好可以整理出一条可复用的 PyInstaller 恶意样本静态提取方法链。
2. 先建立整体认知:PyInstaller 程序长什么样
PyInstaller 打包出来的 Windows 程序可以粗略分成两层:
1 | ┌─────────────────────────────────────────────┐ |
关键点有两个:
- cookie:一小段固定魔数,通常长这样:
MEI\x0c\x0b\x0a\x0b\x0e。bootloader 从文件尾部往回搜索这段魔数,找到后解析后面的 CArchive 偏移和长度。 - CArchive 条目:
s类型是入口脚本(如main.py),m类型是被 import 的模块,z类型是 PYZ。它们大多数情况下是 zlib 压缩后的 Python marshal 数据。
通用提取器(如 pyinstxtractor)就是按这个结构工作的:先找 cookie,再读 TOC,再逐个解压条目。
3. 对抗点一:cookie 魔数被改写
用 pyinstxtractor 处理样本时,最先遇到的就是:
1 | [!] Error : Missing cookie, unsupported pyinstaller version or not a pyinstaller archive |
直接在文件里搜标准魔数 MEI\x0c\x0b\x0a\x0b\x0e,确实搜不到。但文件里存在大量 PyInstaller 特征字符串(pyi-python-flag、PyRun_SimpleStringFlags 等),说明它仍然是 PyInstaller 程序,只是 cookie 被改过。
继续搜索后发现,文件尾部 cookie 位置的实际字节是:
1 | 73 77 49 0c 0b 0a 0b 0e |
也就是说,作者把标准魔数里的 MEI 改成了 swI。
如果只看工具报错,很容易卡在“工具不支持”上。正确的做法是回到 bootloader 本身:程序能运行,说明它运行时一定能找到某个魔数,去启动器里看它到底在找什么。
在 IDA 里定位到 bootloader 的归档加载函数后,能看到它并不是直接比较一段明文魔数,而是通过常量加载、移位、加法等操作在运行时构造搜索模式。反汇编里类似这样的片段:
1 | mov rcx, cs:qword_140030E40 |
顺着这个逻辑确认,bootloader 实际接受的 cookie 就是 swI\x0c\x0b\x0a\x0b\x0e。启动器里甚至还保留了另一个变体(swI\x0d...),用于外部 .pkg 归档的加载路径。
修复方法:把文件尾部 cookie 的 swI 改回 MEI,字节数不变,不会影响任何偏移。修改后 pyinstxtractor 就能正常识别:
1 | data = bytearray(open('sample.exe', 'rb').read()) |
方法要点:通用提取器是基于“标准结构”写的,遇到魔数被改时,不要急着放弃或猜,先去 bootloader 里确认它运行时使用的真实魔数。
4. 对抗点二:Python 模块数据被自定义加密
cookie 修好后再提取,pyinstxtractor 能解析出 58 个 CArchive 条目,但关键模块全部解压失败:
1 | [!] Error : Failed to decompress pyimod01_archive |
把 pyimod01_archive 的原始数据前几个字节打出来看:
1 | 73 77 64 64 d2 70 f7 25 ... |
正常的 zlib 流应该以 78 9c 或 78 da 开头,而这里以 swdd 开头。很明显,作者在压缩前对数据做了处理,并且给每个加密条目都加了 swdd 前缀。
解密逻辑仍然藏在 bootloader 里。定位到负责解压条目的函数 sub_1400011F0,反编译后能看到一段非常直白的处理:
- 如果读到的数据以
swdd开头,先去掉前 4 字节; - 对剩余数据逐字节 XOR
0xAA; - 把处理后的数据交给 inflate 解压。
对应的 Python 验证脚本:
1 | import zlib |
第一次验证就成功解出 2999 字节,说明加密算法就是“前缀 + 固定单字节 XOR”,没有额外密钥或复杂状态。对于恶意样本分析来说这是好消息:固定 XOR 的混淆只能挡住不会看数据的工具,挡不住愿意往下跟一层的人。
5. 字节码恢复与反编译
解压出来的数据是 Python 3.8 的 marshal 代码对象(以 e3 00 00 00 开头),不是完整的 .pyc 文件。要反编译,需要补上 Python 3.8 的 pyc 头:
1 | 55 0d 0d 0a 00 00 00 00 00 00 00 00 00 00 00 00 |
补完头之后,用 uncompyle6 就能直接恢复主脚本源码。恢复出来的核心逻辑是一个自定义实现的 RC4 类加解密函数:
1 | def arc_cipher(data: bytes, key: bytes) -> bytes: |
到这里,样本的完整行为已经清楚了:它读入一段输入,用固定 key 做自实现 RC4,和内置密文比较。由于 RC4 是对称算法,用同一函数处理密文就能还原出正确输入。
验证时只需要跑一遍本地还原逻辑,确认“恢复出的输入能通过程序校验”即可。本文不展开具体输入值,因为重点不是这道题本身,而是这条提取链。
6. 对恶意样本分析的启发
6.1 通用提取器失效时,方法链比工具重要
这次分析真正有价值的地方,不是“把 swI 改成 MEI”,而是面对失效时的思考顺序:
1 | 识别打包器 |
工具负责加速,但结构知识负责兜底。PyInstaller 的 cookie、TOC、条目类型、pyc 头这些都是公开且固定的,只要 bootloader 还在按这套结构运行,就能手工还原。
6.2 静态分析的边界
这个样本展示了静态分析的一个典型困境:字符串、魔数、数据头都可以被改写,甚至模块数据可以被整体加密。如果只依赖“搜字符串”和“跑通用提取器”,很容易得出错误结论。
但静态分析仍然有价值:bootloader 是原生代码,它必须实现解密逻辑,才能让 Python 运行时加载模块。只要找到 bootloader 里 inflate 之前的处理代码,就能还原整个数据变换。混淆可以藏数据,但藏不住处理数据的代码。
6.3 与 ARK / SnowSword 检测思维的关联
这个样本的对抗方式,和 Rootkit 隐藏自己的思路很像:
- 恶意样本改掉 PyInstaller cookie 魔数 → 通用提取器失效;
- Rootkit 摘掉
ActiveProcessLinks/ 模块链表 → 通用枚举工具失效。
两者的共同点是:攻击者只需要让“单一数据源/单一工具”失效,就能显著提高分析门槛。 这也解释了为什么 SnowSword 做检测时要坚持多视角交叉验证——模块列表、对象目录、内存特征、运行时行为互相印证,而不是把某一个列表当成绝对事实。逆向分析同理:不能因为一个提取器报错就判定“不是 PyInstaller”,也不能因为一个字符串匹配就认定样本行为。
7. 遇到的问题与局限
- Python 版本不匹配:当前环境是 Python 3.11,样本是 Python 3.8,
pyinstxtractor对 PYZ 的自动提取会因 marshal 版本不匹配而跳过。主脚本在 CArchive 里不受影响,但 PYZ 内模块需要额外处理。 - 方法依赖具体 bootloader 修改:本次的
swdd + XOR 0xAA是针对这个样本的定制逻辑;换一个样本,前缀、密钥、算法位置都可能变化,不能直接套脚本。 - 手工解析容易踩偏移坑:CArchive 的 TOC 条目是定长头 + 变长名字,解析时如果字段偏移算错,后面的条目会全部错位。写脚本时要先用已知 DLL 条目(能正常 zlib 解压)做自检。
- 反编译质量依赖工具:
uncompyle6对 Python 3.8 支持尚可,但遇到复杂控制流仍可能失败;必要时需要结合字节码dis手工分析。
8. 总结与展望
这次分析形成了一条可以复用的 PyInstaller 恶意样本静态提取流水线:
1 | PE 启动器识别 |
后续可以把这套流程脚本化、半自动化:自动扫描 cookie 魔数变体、自动识别 swdd 这类前缀、自动尝试常见 XOR 密钥,并把 PYZ 内模块的提取也补全。再往后,还可以把同样的思路扩展到 PyArmor、Nuitka 等其它 Python 打包/加固方案,并尝试与动态行为分析结合,验证静态还原出的逻辑和实际运行行为是否一致。
参考
- PyInstaller 官方文档:https://pyinstaller.org/
- pyinstxtractor:https://github.com/extremecoders-re/pyinstxtractor
- uncompyle6:https://github.com/rocky/python-uncompyle6
- Microsoft PE Format:https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
- 本系列前篇:
恶意样本逆向分析学习记录、日报_PEPEPE_已知明文攻击
相关项目与联系
项目SnowSword —— Windows 10/11 x64 内核分析与 Anti-Rootkit 工具,源码与发布包在 Gitee。
技术合作Windows 内核 / 驱动开发 / 逆向分析 / 兼容性开发,范围与边界见 技术合作。
联系2160752730@qq.com · QQ 2160752730 · 微信 lst_3258110
