1. 缘起:为什么值得单独写一篇 PyInstaller 样本分析

在 Windows 恶意样本里,Python 打包程序已经不算少见。攻击者把 Python 写的 loader、窃密器、键盘记录器用 PyInstaller 打成 exe,既能跨机器运行,又能把真正的逻辑藏进字节码里。对恶意样本分析师来说,把 PyInstaller 里的 Python 字节码完整提取出来往往是分析的第一步。

这次遇到的样本表面上是一个普通 Windows 程序:运行后提示 Enter input:,输入错误就输出 Wrong input。但用 IDA 打开后,入口逻辑并不是常见的 C/C++ 业务代码,而是 PyInstaller bootloader 的特征——它真正的逻辑在附加数据段里。更值得注意的是,这个样本对通用提取工具做了两处明显的对抗:

  1. PyInstaller 的 cookie 魔数被改写,导致 pyinstxtractor 直接报 “Missing cookie”;
  2. CArchive 里的 Python 模块数据被自定义加密,即使绕过 cookie 检查,解压也会失败。

这两处对抗单独看都不难,但组合起来正好可以整理出一条可复用的 PyInstaller 恶意样本静态提取方法链。

2. 先建立整体认知:PyInstaller 程序长什么样

PyInstaller 打包出来的 Windows 程序可以粗略分成两层:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────┐
│ PE 启动器(bootloader) │
│ 原生 C/C++ 代码,负责解包、加载 Python │
├─────────────────────────────────────────────┤
│ overlay:追加在 PE 文件尾部的 CArchive │
│ ├── TOC:条目名称 / 偏移 / 大小 / 类型 │
│ ├── 条目类型: │
│ │ s/m → Python 字节码(模块/入口脚本) │
│ │ z → PYZ.pyz(压缩的 Python 模块库) │
│ │ b → DLL / PYD / 数据文件 │
│ └── cookie:位于文件尾部,用于定位 CArchive│
└─────────────────────────────────────────────┘

关键点有两个:

  • 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
2
73 77 49 0c 0b 0a 0b 0e
= swI\x0c\x0b\x0a\x0b\x0e

也就是说,作者把标准魔数里的 MEI 改成了 swI。

如果只看工具报错,很容易卡在“工具不支持”上。正确的做法是回到 bootloader 本身:程序能运行,说明它运行时一定能找到某个魔数,去启动器里看它到底在找什么。

在 IDA 里定位到 bootloader 的归档加载函数后,能看到它并不是直接比较一段明文魔数,而是通过常量加载、移位、加法等操作在运行时构造搜索模式。反汇编里类似这样的片段:

1
2
3
4
5
6
7
mov rcx, cs:qword_140030E40
mov [rsp+Buffer], rcx
mov r8d, 8 ; 搜索长度 8 字节
shr rcx, 18h
add cl, 0Dh ; 修正第 4 个字节
mov [rsp+Buffer+3], cl
call sub_1400084F0 ; 从文件尾部反向搜索魔数

顺着这个逻辑确认,bootloader 实际接受的 cookie 就是 swI\x0c\x0b\x0a\x0b\x0e。启动器里甚至还保留了另一个变体(swI\x0d...),用于外部 .pkg 归档的加载路径。

修复方法:把文件尾部 cookie 的 swI 改回 MEI,字节数不变,不会影响任何偏移。修改后 pyinstxtractor 就能正常识别:

1
2
3
4
data = bytearray(open('sample.exe', 'rb').read())
idx = data.rfind(b'swI\x0c\x0b\x0a\x0b\x0e')
data[idx:idx+3] = b'MEI'
open('sample_patched.exe', 'wb').write(data)

方法要点:通用提取器是基于“标准结构”写的,遇到魔数被改时,不要急着放弃或猜,先去 bootloader 里确认它运行时使用的真实魔数。

4. 对抗点二:Python 模块数据被自定义加密

cookie 修好后再提取,pyinstxtractor 能解析出 58 个 CArchive 条目,但关键模块全部解压失败:

1
2
3
[!] Error : Failed to decompress pyimod01_archive
[!] Error : Failed to decompress pyimod02_importers
[!] Error : Failed to decompress CreackMe&&FxxMe

把 pyimod01_archive 的原始数据前几个字节打出来看:

1
2
73 77 64 64 d2 70 f7 25 ...
= swdd....(不是 zlib 头)

正常的 zlib 流应该以 78 9c 或 78 da 开头,而这里以 swdd 开头。很明显,作者在压缩前对数据做了处理,并且给每个加密条目都加了 swdd 前缀。

解密逻辑仍然藏在 bootloader 里。定位到负责解压条目的函数 sub_1400011F0,反编译后能看到一段非常直白的处理:

  1. 如果读到的数据以 swdd 开头,先去掉前 4 字节;
  2. 对剩余数据逐字节 XOR 0xAA;
  3. 把处理后的数据交给 inflate 解压。

对应的 Python 验证脚本:

1
2
3
4
5
6
7
8
import zlib

raw = open('pyimod01_archive.bin', 'rb').read()
assert raw[:4] == b'swdd'

data = bytes(b ^ 0xAA for b in raw[4:]) # 去掉 swdd 前缀后整体 XOR 0xAA
plain = zlib.decompress(data) # 得到 marshal 代码对象
print(len(plain), plain[:16].hex())

第一次验证就成功解出 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
2
3
4
5
6
7
8
9
10
11
12
def arc_cipher(data: bytes, key: bytes) -> bytes:
# 自实现 RC4 加密/解密
...
return bytes(out)

key = b'NSSCTF'
user_input = input("Enter input: ").encode()
encrypted = arc_cipher(user_input, key)
if encrypted == target_bytes:
print(f"Your are right, flag is NSSCTF{{{user_input.decode()}}}")
else:
print("Wrong input")

到这里,样本的完整行为已经清楚了:它读入一段输入,用固定 key 做自实现 RC4,和内置密文比较。由于 RC4 是对称算法,用同一函数处理密文就能还原出正确输入。

验证时只需要跑一遍本地还原逻辑,确认“恢复出的输入能通过程序校验”即可。本文不展开具体输入值,因为重点不是这道题本身,而是这条提取链。

6. 对恶意样本分析的启发

6.1 通用提取器失效时,方法链比工具重要

这次分析真正有价值的地方,不是“把 swI 改成 MEI”,而是面对失效时的思考顺序:

1
2
3
4
5
6
识别打包器
→ 理解标准结构(cookie / CArchive / PYZ / pyc)
→ 工具报错时,回到 bootloader 确认真实约定
→ 定位自定义处理逻辑(魔数构造、解密、解压)
→ 手工恢复数据
→ 补结构头,交给反编译器

工具负责加速,但结构知识负责兜底。PyInstaller 的 cookie、TOC、条目类型、pyc 头这些都是公开且固定的,只要 bootloader 还在按这套结构运行,就能手工还原。

6.2 静态分析的边界

这个样本展示了静态分析的一个典型困境:字符串、魔数、数据头都可以被改写,甚至模块数据可以被整体加密。如果只依赖“搜字符串”和“跑通用提取器”,很容易得出错误结论。

但静态分析仍然有价值:bootloader 是原生代码,它必须实现解密逻辑,才能让 Python 运行时加载模块。只要找到 bootloader 里 inflate 之前的处理代码,就能还原整个数据变换。混淆可以藏数据,但藏不住处理数据的代码。

6.3 与 ARK / SnowSword 检测思维的关联

这个样本的对抗方式,和 Rootkit 隐藏自己的思路很像:

  • 恶意样本改掉 PyInstaller cookie 魔数 → 通用提取器失效;
  • Rootkit 摘掉 ActiveProcessLinks / 模块链表 → 通用枚举工具失效。

两者的共同点是:攻击者只需要让“单一数据源/单一工具”失效,就能显著提高分析门槛。 这也解释了为什么 SnowSword 做检测时要坚持多视角交叉验证——模块列表、对象目录、内存特征、运行时行为互相印证,而不是把某一个列表当成绝对事实。逆向分析同理:不能因为一个提取器报错就判定“不是 PyInstaller”,也不能因为一个字符串匹配就认定样本行为。

7. 遇到的问题与局限

  1. Python 版本不匹配:当前环境是 Python 3.11,样本是 Python 3.8,pyinstxtractor 对 PYZ 的自动提取会因 marshal 版本不匹配而跳过。主脚本在 CArchive 里不受影响,但 PYZ 内模块需要额外处理。
  2. 方法依赖具体 bootloader 修改:本次的 swdd + XOR 0xAA 是针对这个样本的定制逻辑;换一个样本,前缀、密钥、算法位置都可能变化,不能直接套脚本。
  3. 手工解析容易踩偏移坑:CArchive 的 TOC 条目是定长头 + 变长名字,解析时如果字段偏移算错,后面的条目会全部错位。写脚本时要先用已知 DLL 条目(能正常 zlib 解压)做自检。
  4. 反编译质量依赖工具:uncompyle6 对 Python 3.8 支持尚可,但遇到复杂控制流仍可能失败;必要时需要结合字节码 dis 手工分析。

8. 总结与展望

这次分析形成了一条可以复用的 PyInstaller 恶意样本静态提取流水线:

1
2
3
4
5
6
PE 启动器识别
→ cookie 修复
→ CArchive 手工解析
→ 定位 bootloader 自定义解密
→ 恢复 Python 字节码
→ 补 pyc 头 + 反编译

后续可以把这套流程脚本化、半自动化:自动扫描 cookie 魔数变体、自动识别 swdd 这类前缀、自动尝试常见 XOR 密钥,并把 PYZ 内模块的提取也补全。再往后,还可以把同样的思路扩展到 PyArmor、Nuitka 等其它 Python 打包/加固方案,并尝试与动态行为分析结合,验证静态还原出的逻辑和实际运行行为是否一致。

参考

相关项目与联系

项目SnowSword —— Windows 10/11 x64 内核分析与 Anti-Rootkit 工具,源码与发布包在 Gitee。

技术合作Windows 内核 / 驱动开发 / 逆向分析 / 兼容性开发,范围与边界见 技术合作。

联系2160752730@qq.com · QQ 2160752730 · 微信 lst_3258110