拿到一个勒索病毒的Python打包样本,第一件事就是想办法把它还原成能看的源码。这个套路我已经跑过几轮了,涉及的工具链基本固定:pyinstxtractor负责拆包,uncompyle6负责反编译,中间还有一步很容易被忽略——修复pyc头。今天就把这条链路完整走一遍,包括踩过的坑和备选方案,给做恶意样本分析的朋友做个参考。
先说清楚这个场景。勒索病毒用Python写并不罕见,攻击者图的是开发效率高、跨平台方便、免杀库现成。为了不让受害者直接看到代码,也为了让目标机器上有Python解释器就能跑,他们通常会拿PyInstaller把脚本打成Windows可执行文件。所以分析这类样本时,真正的战场不在逆向PE文件本身,而在还原PyInstaller的打包内容、把pyc反编译回Python源码。
这篇文章写给三类人:一类是刚接触恶意代码分析的安全新人,想知道这类样本从哪下手;一类是已经会跑工具但经常在反编译环节翻车的人,我这里有不少报错对照;还有一类是自己写的工具被PyInstaller打包后源码丢了、想从产物里抢救点东西出来的开发者。无论哪种场景,方法论是通用的。
1. 前置准备:环境、样本和工具链选型
1.1 为什么勒索病毒会以Python + PyInstaller形态出现
这类样本这两年见得越来越多,原因很现实。Python写勒索逻辑比C/C++快很多,加密库、文件遍历、系统API调用都有成熟封装,攻击者甚至可以拿开源项目改一改就上线。打包成exe之后,Python代码以字节码形式藏在资源段里,普通用户根本看不懂,足够挡住绝大多数人。
但反过来看,这也是分析者的机会。C/C++写的勒索病毒要逆出核心逻辑,得啃汇编、过混淆、还原加密流程,工作量极大。Python样本只要把pyc恢复出来,绝大部分逻辑直接以接近源码的形式摆在你面前,静态分析效率高了一个量级。这也是pyinstxtractor这类工具如此重要的原因。
1.2 分析环境搭建与安全基线
动手之前先把环境说清楚。我用的是一台独立虚拟机,Windows 7/10 x64都行,装好Python 3.7或3.8,配好快照。为什么强调这两个小版本,后面会讲,uncompyle6对3.8以下的兼容性最稳,3.9以上的pyc它经常罢工。
安全基线就三条:全程断网、只做静态分析、样本保持原始哈希记录。勒索病毒样本无论看起来多像“分析靶场玩具”,都不要双击运行,更不要在自己的主机上解包。所有操作都限制在隔离虚拟机内,宿主机的共享文件夹关闭,U盘不接,防止样本自动释放或感染相邻系统。
1.3 工具清单与版本选择
具体工具我列一下,都是免费开源的:
- pyinstxtractor.py:一个Python脚本,专门拆PyInstaller打包体,GitHub上能找到,官方仓库多年未更新但依然好用。
- uncompyle6:Python字节码反编译工具,支持Python 2.7和3.x到3.8。
- xdis / decompyle3:uncompyle6不兼容时的替代品。
- 010 Editor 或 Python的hexdump模块:看pyc头、改字节时用。
- py_compile 或 importlib.util:用来获取当前Python版本的pyc魔法数(magic number)。
这些工具的安装都很简单,uncompyle6直接pip装,pyinstxtractor是一个独立脚本,下载后放到工作目录就行。需要提醒的是,不要把样本和解包工具放在同一个目录下反复折腾,我习惯建三个子目录:sample/ 放原始样本,extract/ 放解包产物,output/ 放反编译后的源码,互不干扰也方便记录分析过程。
2. pyinstxtractor拆包:原理与实操
2.1 PyInstaller打包格式的核心结构
拆包之前先理解PyInstaller到底干了什么。PyInstaller把Python脚本、依赖库、资源文件一起塞进一个自定义的归档容器里,这个容器在PE文件中叫做CArchive。CArchive有自己的头部和目录区(TOC),TOC记录着每个条目在文件中的偏移量、长度和类型,类型包括PYZ(压缩的Python库)、PKG(程序主逻辑包)、DATA(数据文件)、BINARY(二进制扩展模块)等。
pyinstxtractor做的事,就是扫描PE文件,定位CArchive,解析TOC,把里面所有条目按类型dump出来。也就是说它并不需要真正“脱壳”或“解密”,只是把打包内容精确地切出来,这也是它能处理绝大多数PyInstaller版本的原因。
2.2 提取操作与产物解读
拿到样本后先确认确实是PyInstaller产物,方法有两个:一是用PE工具看文件区段,PyInstaller打包的exe通常有pyi-windows-manifest等特征字符串;二是直接用pyinstxtractor试跑,工具能识别出CArchive就算成功。
执行命令很简单:
python pyinstxtractor.py sample.exe跑完后同目录下会出现一个sample_extracted文件夹。正常的话里面能看到main.pyc(或实际入口名的pyc)、大量依赖库的pyc、还有可能的二进制扩展文件。我个人经验是先看结构再动手反编译,重点关注三个位置:
- 最上层能直接看到的pyc文件,一般是主脚本的字节码。
- PYZ归档解出来的库文件,通常在PYZ.pyz_extracted子目录里。
- 有没有pyd或dll类文件,说明样本可能引入了C扩展,这部分pyc解不了。
2.3 为什么有时候提取出来就能反编译,有时候不行
这里有个关键区别。PyInstaller打包时,依赖库的pyc通常是完整带头的,而主脚本.pyc往往经过特殊处理,文件头被剥离了。提取出来一看,库文件pyc前16字节是正常的魔法数和时间戳,主脚本pyc却直接从marshal数据开始,或者前几字节是乱码。这就是下一步要修pyc头的直接原因。
还有一个注意点:pyinstxtractor提取出的目录里有些pyc文件其实是运行时生成的临时文件,或者PyInstaller内部模块,比如pyimod00_importers.pyc。分析勒索病毒时这些大概率用不上,别被它们带偏重点。
2.4 拆包失败场景与应对思路
不是每次都能顺利拆出来。我碰到过几种失败情况:
- 样本套了额外的壳,比如把PyInstaller产物又用UPX压缩过,pyinstxtractor找不到CArchive。这时先脱壳再拆包。
- 攻击者改过PyInstaller的bootloader或自定义TOC,特征被抹掉,工具识别失败。这种情况我会用HxD手动搜索特征字符串硬定位CArchive,再手工解析TOC,费劲但可行。
- 打包时某些资源用的特殊PyInstaller版本,pyinstxtractor会有兼容问题,可以换新版分支,比如带archive.py修复的仓库版本。
3. 修复pyc头:反编译前最容易翻车的一步
3.1 不同Python版本的pyc头结构差异
pyc文件头不是固定的。Python 3.7以后,pyc头是16字节:4字节魔法数 + 4字节标志位/哈希类型 + 4字节时间戳 + 4字节源文件大小。魔法数随Python小版本变化,标志位指示是否启用hash校验,时间戳和大小用于源码与字节码的对应检查。
反编译工具识别pyc,首要看魔法数。如果魔法数缺失或不对,uncompyle6会直接报“Bad magic number”,根本不会进入下一步。PyInstaller主脚本pyc被剥离头部后,我们需要把正确的16字节补回去。
3.2 获取魔法数的标准做法
获取魔法数最靠谱的方式是拿你本机的Python版本生成一个参考pyc:
import py_compile py_compile.compile(r'blank.py', cfile=r'reference.pyc')blank.py是一个只含pass的空文件。生成的reference.pyc前16字节就是当前Python版本的完整头。我习惯用Python 3.8跑这个操作,因为这个版本的魔法数在uncompyle6那里兼容性最好。
更快速的办法是直接在Python交互环境里取:
import importlib.util print(importlib.util.MAGIC_NUMBER.hex())拿到后转成字节写入即可。
3.3 修复脚本实例与验证
我自己写了一个小脚本,专门处理这种缺头pyc:
import struct, sys, importlib.util def fix_pyc_header(src, dst): with open(src, 'rb') as f: data = f.read() # 判断是否已经有完整头 magic = importlib.util.MAGIC_NUMBER if data[:4] == magic: print(f'{src} header ok') open(dst, 'wb').write(data) return # 搜索标准pyc头的magic标记 idx = data.find(b'\xe3') if idx == -1: print(f'{src} code object not found') return # 新头: magic + flags(0) + timestamp + srcsize header = magic + struct.pack('<III', 0, 0x5F5F5F5F, 0) new_data = header + data[idx:] open(dst, 'wb').write(new_data) print(f'{src} fixed -> {dst}') if __name__ == '__main__': fix_pyc_header(sys.argv[1], sys.argv[2])这个脚本的思路是先检查头部缺失,再在数据里找到marshal code object的起始标记(通常以\xe3开头),然后把正确的头拼接上去。观察\xe3位置是修复过程中的关键技巧,因为有些pyc头部并不完全为零,直接用固定16字节覆盖会覆盖掉真正的数据。
修复完之后,用marshal模块验证一下:
import marshal with open('fixed.pyc', 'rb') as f: f.read(16) code = marshal.load(f) print(type(code))能正常输出types.CodeType就说明修复成功。也可以用dis模块看一眼字节码:
import dis dis.dis(code)这一步我非常建议在批量反编译前做,可以先挑一个功能简单的库pyc验证工具链通不通,再处理真正的主脚本。走通一遍再批量处理,不容易积压一堆坏文件。
4. uncompyle6反编译:实操、边界与替代方案
4.1 uncompyle6的兼容性边界
uncompyle6的官方支持范围是Python 2.7和3.x到3.8。原理上它是按字节码版本做规则匹配的,Python 3.9以后新增的字节码指令、异常处理机制(异常组的引入)让它的规则表失效,所以经常返回“decompiling failed”或生成明显错的代码。所以前面才反复强调分析环境用Python 3.8,就是为了让样本pyc的版本落在uncompyle6能力范围内。
如果样本是Python 3.9以上版本编译的,uncompyle6基本没辙。这时候我通常会先看pyc头里的magic对应哪个Python版本,再决定下一步工具。有些样本会用高版本Python打包,但内部逻辑简单,硬啃或者换工具也能救回来。
4.2 标准反编译操作与批量处理
单文件反编译很简单:
uncompyle6 -o output/ fixed_main.pyc-o参数指定输出目录,工具会自动把.pyc扩展名换成.py。如果只要单个文件不写目录,它会直接输出到控制台。
实际分析时,提取目录里的pyc可能有几十个,我写了一个简单脚本批量处理:
for pyc in extract/*.pyc; do uncompyle6 -o output/ "$pyc" 2>> error.log done这个循环配合error.log,可以快速筛出哪些反编译成功、哪些失败。成功的结果直接看,失败的文件再单独处理。我自己跑这个过程时发现,有些库文件反编译后非常有价值,比如样本引用的加密库代码、配置模块,这些往往会暴露攻击者怎么调用的加密算法。
4.3 报错不是终点:强制模式与字节码辅助
uncompyle6最常见的报错是“Decompiling failed: AST out of range”和“No module named...”。前者通常是某个函数结构太复杂或者用了高版本语法,后者是pyc引用了缺失的外部模块,但这不影响反编译本体,报错后依然可能输出大部分源码。
一个实用的技巧是加--verify参数,让uncompyle6尝试对反编译结果重新编译,再和原pyc对比字节码。如果验证不通过,它会提示结果可能不精确,但代码依然有参考价值。我自己处理勒索样本时,很少要求100%还原,能做到逻辑可读就足够提取IOC和加密行为了。
如果uncompyle6彻底失败,我会退回用dis模块直接看字节码:
import dis, marshal with open('bad_one.pyc', 'rb') as f: f.read(16) code = marshal.load(f) dis.dis(code)字节码虽然不如源码直观,但函数名、字符串常量、import调用都暴露得很清楚。尤其是样本中调用AES加密库或者遍历目录的代码,在字节码层一眼就能识别出来。
4.4 高版本Python样本的替代方案
遇到uncompyle6搞不定的高版本pyc,我的备选工具是decompyle3和pycdc。decompyle3专门支持Python 3.7到3.9的部分版本,pycdc是C++实现的Cpython反编译器,兼容性广但还原质量中等。还有在线反编译服务可以做兜底,但样本敏感度高,不建议上传第三方平台,一切离线处理更稳妥。
工具选型的总结就是:能用uncompyle6就跑uncompyle6,输出质量最好;跑不了再上decompyle3和pycdc;最后都用不了就dis硬啃字节码。这套组合拳下来,绝大多数Python打包样本都能被翻个底朝天。
5. 源码到手之后:快速锁定核心逻辑与IOC
5.1 入口定位与主流程还原
反编译会得到一堆.py文件,从哪里看起?我的习惯是先看入口文件。PyInstaller打包时,入口脚本通常是被直接提取到根目录的那个pyc对应的源码。如果反编译出了main.py,直接打开它。
勒索病毒的主流程基本跑不掉这几件事:收集系统信息、遍历磁盘文件、判断文件后缀并进行加密、在目录里投放勒索信、修改或禁用系统恢复功能、展示勒索界面。我在源码里搜索关键词的效率很高:walk、listdir、glob、encrypt、decrypt、AES、RSA、base64、shell、os.system、subprocess等。
如果找到加密函数,重点看它调用了哪些密码学库。常见组合是Crypto.Cipher模块的AES配合RSA公钥加密会话密钥,加密后的密钥写在文件尾部或单独文件里。这类信息直接决定了动态解密能不能写脚本,也决定文件恢复的可行性。
5.2 从源码中提取IOC与行为指纹
IOC这个词听着玄,其实就是能用来识别样本的可观察特征。在源码里我能提取到:
- 加密文件后缀名,比如.encrypted、.locked、特定的随机后缀。
- 勒索信文件名和内容里的联系方式、钱包地址。
- 被加密文件的排除名单,很多勒索病毒会跳过Windows目录和Program Files。
- 互斥量名,用于防止重复运行。
- 网络行为代码中的C2地址(如果样本有外联行为)。
提取这些信息时,我会同步记录下来,做成一个样本行为摘要。这个摘要比完整的源码更能支撑后续的蓝队响应和溯源工作。不只是“这个样本是AES加密”,而是“文件后缀为abc,勒索信名为READ_ME.txt,排除了哪些目录,修改了哪些注册表键”这种可直接固化的指标。
5.3 碰到底层混淆或字符串解密时的处理
现在很多Python勒索样本会做一层混淆,最常见的是把关键字符串用base64编码后硬编码在源码里,运行时解码。反编译后的源码里可能看到一堆类似于b'xxxxxx'的字符串变量,后面跟一个decode调用。这种情况直接在源码里搜decode,把对应的base64字符串复制出来解一下就行。
更高端一点的会用marshal.dumps把一段代码序列化后再exec执行。反编译出来可能是一段又长又乱的字节串,此时不要试图从源码层还原,直接用marshal.loads解出内层code object,再单独对这块字节码做dis或uncompyle6反编译。处理混淆本身也是逆向的常见流程,遇到一次就会记住套路了。
6. 常见问题与排查技巧实录
6.1 高频错误和解决办法速查
整理一张表,都是实际操作频率最高的问题:
| 现象 | 原因 | 对策 |
|---|---|---|
| pyinstxtractor找不到CArchive | 样本被额外加壳 | 先脱壳再用pyinstxtractor |
| 提取后没有main.pyc | 入口脚本不叫main | 翻看提取目录下其他根级pyc,或者从pyz目录找 |
| uncompyle6报Bad magic number | pyc头缺失或版本不匹配 | 用参考pyc修复头,确认Python版本 |
| uncompyle6报AST out of range | 使用了高版本语法 | 换decompyle3/pycdc,或与dis联用 |
| 反编译结果只有几个函数 | 某些复杂函数反编译失败 | 用dis查看失败的函数字节码 |
| 源码中字符串都是乱码/bytes | 内置base64编码混淆 | 搜索decode调用,逐一解出字符串 |
这些情况我都在不同样本上遇到过,没有一个是无解的。关键心态是:反编译是辅助手段,不是最终目的,我们只要能拿到足够的信息即可,不需要强迫每个函数都完美还原。
6.2 Python版本不一致造成的一连串坑
版本问题是这套流程里最隐蔽的坑。比如我用Python 3.7的环境去跑一个基于Python 3.8生成的pyc,魔法数对不上,修复头之后uncompyle6能跑但结果可能南辕北辙。更麻烦的是,有些样本用了Python 3.10的walrus运算符或3.9的新语法,旧版工具解析不了。
我的建议是准备两到三个版本的Python环境,至少覆盖3.7、3.8和3.10。先用pyc头里的魔法数确定样本版本,再选择对应的分析环境。判定版本可以直接查magic对照表,也可以用一个简单办法:用不同版本的Python分别import该pyc(使用importlib),能正常加载且不出SyntaxError的那个版本大概率接近编译版本。
6.3 提取产物不完整时的补救思路
有时候pyinstxtractor解出来的目录缺东西,比如主脚本pyc找不到,或者PYZ解出来的依赖库不完整。这时别急着放弃,检查一下原始exe的大小和提取目录的大小,如果相差太多,可能CArchive之外还有附加数据。这种情况我会直接用HxD打开exe,搜索“PYZ”、“MEIPASS”、“_MEI”等字符串,手工定位附加段。
如果目标机器上实际运行样本还能拿到进程内存转储,那么在内存里找pyc是另一个思路。但作为静态分析为主的文章,我只能说动态内存提取是最终的兜底手段,启动过程要有完备的沙箱和监控措施。
6.4 几个实用技巧:哈希、记录与复核
最后分享几个实操习惯。
第一步永远先算样本哈希并记录。分析完整个样本后,把提取的pyc、修复后的pyc、反编译源码都各自算一遍哈希,这能保证从原始样本到最终产物的整个链路可追溯,将来写分析报告也用得上。
每修复一个pyc头,我都在文件名里标注来源,比如main_fixed.pyc、lib1_fixed.pyc,防止加工过的文件与原文件混淆。反编译时产生的error.log不是垃圾,它是后续深入分析的线索索引,报错文件往往意味着样本的关键模块用了复杂语法,值得回头看。
在验证反编译结果时,我偶尔会把反编译的.py文件重新用py_compile编译回pyc,再对比反编译前后的字节码结构。如果差异过大,说明反编译过程丢失了关键信息,这时再回头用dis核对具体函数。
7. 分析流程复盘与个人心得
整套流程走下来,从拿到打包好的exe到产出可读源码和IOC清单,熟练之后通常半小时内完成。pyinstxtractor拆包是机械操作,pyc头修复是技术活,uncompyle6反编译是运气加经验,最后源码分析是重点。每个环节都有各自的坑,但走通一次之后,后续再遇到都是体力活。
我个人在实操中有一条体会:不需要迷信“源码完美复原”。即使uncompyle6反编译出来的代码有残缺,或者个别函数因为高版本语法无法还原,只要能还原出主流程、加密逻辑和IOC,分析目的就已经达成。反过来,如果过分追求每个字节都还原,反而会在个别复杂函数上耗掉大量时间,得不偿失。
这篇文章涉及的样本分析思路,同样适用于分析其他PyInstaller打包的恶意程序或商业软件。工具链不变,变的是你对源码的解读。拿到源码后的耐心和技术功底,才真正决定了分析的深度。希望这篇实战记录对你有帮助。