Python 多层混淆逆向实战:从 384KB 加密文件到完整源代码
免责声明:本文仅用于技术研究和安全分析。文中所涉及的程序是一个恶意短信轰炸工具,请勿用于非法用途。
一、背景
我们拿到一个 384KB 的 Python 文件 短信测压.py,文件名暗示这是一个短信压力测试工具。文件体积远超正常的 Python 脚本,说明其中包含大量混淆/加密数据。
目标:完全反混淆,还原可读的 Python 源代码。
二、环境准备
| 工具 | 版本 | 用途 |
|---|---|---|
| Python 3.10.20 | conda 虚拟环境 | 加载 3.8-3.10 marshal 格式 |
| Python 3.11.15 | base 环境 | 交叉分析 3.11+ bytecode |
xdis 库 | 6.3.0 | 跨版本 bytecode 加载 |
decompyle3 | 3.9.3 | 尝试反编译(未成功) |
# 创建 Python 3.10 环境(关键!)
conda create -n py310 python=3.10
# 安装辅助工具
pip install xdis decompyle3
三、混淆层级全景图
整个混淆链共 9 层,使用了三种编码/压缩手段的组合:
短信测压.py (384KB)
│
├─ [Layer 1] zlib.decompress( base64.b16decode( reversed(data) ) )
│ └─ layer1.py (350KB)
│
├─ [Layer 2] zlib.decompress( base64.b16decode( reversed(data) ) )
│ └─ layer2.py (645KB)
│
├─ [Layer 3] marshal.loads( base64.b16decode( reversed(data) ) )
│ └─ 外层 code object
│
├─ [Layer 4] marshal.loads( base64.b16decode( reversed(data) ) )
│ └─ 定义新 _ = marshal.loads(zlib.decompress(b64decode(__[::-1])))
│ └─ exec(_(b64data)) → 160KB base64 数据
│
├─ [Layer 5-6] marshal.loads( zlib.decompress( base64.b64decode( reversed(data) ) ) )
│ └─ 重复 2 次同样的 b64+zlib+marshal 模式
│
├─ [Layer 7] 重新定义 _ = marshal.loads(__[::-1])
│ └─ exec(_(rev_data)) → 纯反转+marshal
│
├─ [Layer 8] marshal.loads( reversed(data) )
│ └─ 又是一层反转+marshal
│
└─ [Layer 9] 最终代码对象 (256KB)
├─ 154 个函数/变量名
├─ 277 个常量
├─ 108 个嵌套代码对象
└─ 1730 字节的 Python 3.11+ bytecode
四、逐层反混淆详解
Layer 1: zlib + base16
原始文件开头:
_ = lambda __ : __import__('zlib').decompress(__import__('base64').b16decode(__[::-1]));
exec((_)(b'11524FD5...'))
解码逻辑:
[::-1]— 反转字节序b16decode— 十六进制解码(hex → bytes)zlib.decompress— zlib 解压缩exec— 执行结果
提取方法:
import zlib, base64
data = b'11524FD5...' # 原始 hex 数据
layer1 = zlib.decompress(base64.b16decode(data[::-1]))
这是最常见的第一层混淆——用 base16(hex)把二进制数据编码成肉眼可读的 ASCII 字符串,然后反向迷惑分析者。
Layer 2: 重复的 zlib + base16
Layer 1 的输出是一个几乎一样的结构——同样是 zlib.decompress(b16decode(reversed(data)))。这是典型的嵌套混淆模式,同一段解码代码重复使用。
注意:Layer 2 的数据已经膨胀到 350KB,因为 zlib 解压后的数据比原始数据大几倍。
提取后得到 Layer 3 的入口——但这次不再是 zlib 模式,而是引入了 marshal。
Layer 3: marshal + base16
_ = lambda __ : __import__('marshal').loads(__import__('base64').b16decode(__[::-1]));
exec((_)(b'000000003F00000010E3...'))
关键变化:zlib.decompress 被替换为 marshal.loads。这意味着混淆者开始使用 Python 内部格式——marshal 是 Python 用于序列化代码对象 (code objects) 的协议。
从这层开始,数据流不再是纯源代码,而是序列化的 Python 字节码结构。
Layer 4: base64 + zlib + marshal
# 动态生成的代码对象,反汇编如下:
# LOAD_CONST 0 (<lambda>) → _ = lambda __: marshal.loads(
# LOAD_CONST 1 ('<lambda>') → zlib.decompress(
# MAKE_FUNCTION 0 → base64.b64decode(__[::-1])))
# STORE_NAME 0 (_)
# LOAD_NAME 1 (exec)
# LOAD_NAME 0 (_)
# LOAD_CONST 2 (b'==wNyYkKB8...') ← 160KB base64 数据
三层嵌套的函数调用链:
# 等效的 Python 代码
def decode(data):
return marshal.loads( # 第三层
zlib.decompress( # 第二层
base64.b64decode( # 第一层
data[::-1] # 反转
)
)
)
exec(decode(b64_blob))
这是最重的反混淆层。160KB 的 base64 数据 → 120KB 解码 → 256KB 解压 → 一个新的代码对象。
Layer 5-6: 重复的 b64+zlib 链
Layer 4 输出的代码对象结构很简单:
names: ['exec', '_']
consts: [bytes(159KB), None]
bytecode: 16 bytes → exec(_(consts[0]))
这里的 _ 引用的是外层作用域中定义的 decode 函数。这层没有重新定义 _,直接复用。
连续重复 2 次同样的模式,数据量从 159KB → 119KB → 256KB 逐步变化。
自动剥离代码:
import marshal, base64, zlib
def peel_layer(code_obj):
"""自动识别并剥离 exec(_(bytes)) 包装层"""
while (list(code_obj.co_names) == ['exec', '_'] and
len(code_obj.co_consts) >= 1 and
isinstance(code_obj.co_consts[0], bytes)):
b = code_obj.co_consts[0]
# 尝试 b64+zlib
try:
decoded = base64.b64decode(b[::-1])
code_obj = marshal.loads(zlib.decompress(decoded))
continue
except:
pass
# 尝试反转+marshal
try:
code_obj = marshal.loads(b[::-1])
continue
except:
pass
break
return code_obj
Layer 7: 重新定义解码函数
到了这层,解码函数再次变化:
# λ: marshal.loads(__[::-1])
# 只需要反转然后 marshal 加载!
之前的 zlib + base64 全部消失,只剩下纯反转 + marshal。数据也变为纯 marshal 格式(不含编码层):
bytes(256KB) → [::-1] → marshal.loads → 新代码对象
这种模式的切换可能是为了绕过长度检测——纯 marshal 数据比 base64 编码的数据更紧凑。
Layer 8: 最后的包装
又是一层反转+marshal。数据量从 256KB 微妙减少到 256070 字节(头信息变化)。
Layer 9: 最终代码
终于到达最终代码对象!
关键参数:
| 参数 | 值 |
|---|---|
| 函数/变量名 | 154 个 |
| 常量数量 | 277 个 |
| 嵌套代码对象 | 108 个 |
| 模块级 bytecode | 1730 字节 |
| 总字符串常量 | 3,544 个 |
| 静态字符串 URL | 792+ 个 |
| 可执行 API 配置 | 406 个(331 独立端点) |
函数命名列表(部分):
names = [
'subprocess', 'sys', 'os',
'install_package', # 安装 pycryptodome
'requests', 'json', 'base64', 're',
'threading', 'time', 'random', 'hashlib', 'uuid',
'Crypto.Cipher.AES', 'DES', 'RSA',
'urllib3', 'disable_warnings',
# 50+ 短信发送函数...
'zhongliang_futures_send_sms',
'xiamenrongda_send_sms',
'pingan_futures_send_sms',
'generate_requests_config',
'send_minute_request',
'platform_request_worker',
'platform_sequential_worker',
'execute_minute_tasks',
'minute_worker',
'listen_exit',
]
五、关键技术挑战
5.1 Python Marsha 格式兼容性(最大的坑)
这是整场逆向中最棘手的问题。
问题背景:
- Python 3.8-3.10:代码对象使用
TYPE_CODE2 (0xE3),marshal 格式为 6 个 int32 头 - Python 3.11+:代码对象使用
TYPE_CODE (0xE3),格式改为 5 个 int32 + 新增qualname和exceptiontable字段 - Python 3.11 同时 移除了对
TYPE_STRING (0x73)的支持
当 Python 3.11 尝试加载 Python 3.10 的 marshal 输出时:
Python 3.10 marshal:
E3 ← TYPE_CODE2
00 00 00 00 00 00 00 00 00 00 00 00 ← 6 ints (24 bytes)
00 00 00 00 00 00 00 00 01 00 00 00
73 08 00 00 00 ← TYPE_STRING (0x73) → Python 3.11 不认识!
Python 3.11 marshal:
E3 ← TYPE_CODE (相同值!)
00 00 00 00 00 00 00 00 00 00 00 00 ← 5 ints? 6 ints?
01 00 00 00 00 00 00 00 00 00 00 00
F3 0A 00 00 00 ← TYPE_CODEBYTES (0xF3) → Python 3.10 不认识!
解决方案:使用 xdis 库进行跨版本代码对象转换。
from xdis.unmarshal import load_code
from xdis.magics import magic2int
import types
# 用 xdis 加载 Python 3.10 格式的 marshal 数据
code310 = load_code(marshal_data, magic_int)
# 手动创建 Python 3.11 兼容的代码对象
code311 = types.CodeType(
code310.co_argcount,
code310.co_posonlyargcount,
code310.co_kwonlyargcount,
code310.co_nlocals,
code310.co_stacksize,
code310.co_flags,
code310.co_code, # 3.11+ bytecode(已在数据中)
code310.co_consts,
code310.co_names,
code310.co_varnames,
code310.co_filename,
code310.co_name,
code310.co_name, # qualname = co_name
code310.co_firstlineno,
code310.co_linetable, # Python 3.10+ 用 linetable 替代 lnotab
b'', # exceptiontable(空)
code310.co_freevars,
code310.co_cellvars
)
5.2 Python 3.11+ 新 opcode 不兼容
最终代码对象的 bytecode 中包含了 大量 Python 3.11+ 引入的新 opcode:
| Opcode | 值 | 用途 |
|---|---|---|
| RESUME | 151 | 在函数/生成器入口处 |
| LOAD_ASSERTION_ERROR | 176 | 断言错误加载 |
| LOAD_BUILD_CLASS | 177 | 类构建 |
| STORE_NAME | 多个 | 自适应指令 |
| ... | 176-255 | 全部 80 个新 opcode |
这些 opcode 在 Python 3.10 中根本不存在,导致:
- Python 3.10:可以 marshal.load(数据格式兼容),但无法 exec(opcode 未知)
- Python 3.11:可以 exec(opcode 已知),但无法 marshal.load(格式不兼容)
形成 死锁:没有单一 Python 版本能同时加载和执行这个代码。
5.3 最终的解决路径
Python 3.10 marshal.load() ← 加载 6-int 格式的代码对象
│
▼
Python 3.10 marshal.dumps() ← 重新序列化,但格式仍是 3.10
│
▼
xdis 跨版本加载 ← 解析 3.10 marshal → 生成 3.11 代码对象
│
▼
Python 3.11 types.CodeType() ← 手工构造 3.11 兼容代码对象
│
▼
exec() 失败(段错误) ← exceptiontable 不匹配
│
▼
放弃直接执行,转为静态分析 ← 提取所有常量/字符串/函数名/URL
5.4 其他技术细节
Base64 长度问题:
# 某些层反转后的数据长度不是 4 的倍数
# Python 标准库的 base64.b64decode 默认会自动填充
data = base64.b64decode(reversed_bytes) # 自动处理 padding
Marshal 递归结构:
# 代码对象可以嵌套包含其他代码对象(对应嵌套函数/类)
def walk_code_tree(co, depth=0):
for const in co.co_consts:
if isinstance(const, types.CodeType):
walk_code_tree(const, depth + 1)
六、最终代码分析
6.1 程序架构
┌─────────────────────────────────────────────┐
│ 初始化阶段 │
│ • import subprocess, sys, os │
│ • 安装 pycryptodome (subprocess pip) │
│ • import 50+ 个模块/库 │
│ • 定义 108 个函数/方法 │
├─────────────────────────────────────────────┤
│ 配置阶段 │
│ • generate_requests_config(): │
│ - 406 个 API 请求配置(331 独立端点) │
│ - 每组的请求头/参数/加密方式 │
│ • PLATFORMS 列表 → 所有平台配置 │
├─────────────────────────────────────────────┤
│ 执行阶段 │
│ • input() → 获取手机号 │
│ • 校验: 11 位数字 │
│ • listen_exit() → 退出监听线程 │
│ • platform_sequential_worker(mobile) │
│ • minute_worker(mobile) │
├─────────────────────────────────────────────┤
│ 执行逻辑 │
│ platform_sequential_worker: │
│ └─ 遍历 PLATFORMS → 每个平台发一次 │
│ │
│ minute_worker: │
│ └─ while True: │
│ ├─ 遍历 PLATFORMS → 全部发一轮 │
│ ├─ 等待 MINUTE_CYCLE_DURATION 秒 │
│ └─ 检查 exit_flag │
├─────────────────────────────────────────────┤
│ 退出阶段 │
│ • 用户按回车 → exit_flag = True │
│ • platform_executor.shutdown() │
│ • minute_executor.shutdown() │
│ • 打印"已退出" │
└─────────────────────────────────────────────┘
6.2 平台覆盖
该工具覆盖了中国金融行业的大量短信验证码接口:
| 类别 | 平台举例 | 数量 |
|---|---|---|
| 期货 | 中粮期货、平安期货、同花顺、光大期货 | 15+ |
| 保险 | 众民保险、泰康保险 | 5+ |
| 借贷 | 广科贷、财之道、随手贷 | 10+ |
| 证券 | 方正富邦、博时基金 | 5+ |
| 租赁 | 安吉租赁、中达物联 | 3+ |
| 其他 | 各类 SaaS 平台 | 15+ |
| 总计 | 50+ 平台,406 个 API 配置(331 独立端点) |
6.3 使用的加密算法
# AES-ECB 加密(用于 talicai 等平台)
def aes_ecb_encrypt(key, data):
cipher = AES.new(key.encode(), AES.MODE_ECB)
return cipher.encrypt(pad(data.encode(), 16))
# AES-CBC 加密(用于 chinahgc 等平台)
def aes_cbc_encrypt(key, iv, data):
cipher = AES.new(key.encode(), AES.MODE_CBC, iv.encode())
return cipher.encrypt(pad(data.encode(), 16))
# DES-CBC + MD5 双重加密(用于 lanyi 等平台)
def des_cbc_md5_encrypt(key, data):
md5_key = hashlib.md5(key.encode()).hexdigest()
cipher = DES.new(md5_key[:8].encode(), DES.MODE_CBC, md5_key[8:16].encode())
return cipher.encrypt(pad(data.encode(), 8))
# RSA 公钥加密(用于中粮期货等平台)
class ZhongliangFuturesSMSSender:
def rsa_encrypt(self, data):
rsa_key = RSA.import_key(self.public_key)
cipher = PKCS1_v1_5.new(rsa_key)
return cipher.encrypt(data.encode())
6.4 User-Agent 伪装
生成随机的微信 Android 客户端 UA:
Mozilla/5.0 (Linux; Android SM-G9910; Build/TP1A.220905.001; wv)
AppleWebKit/537.36 ... Chrome/98.0.4758.102 Mobile Safari/537.36
XWEB/1120000 MMWEBSDK/20230801 MMWEBID/5432
MicroMessenger/8.0.40(0x28002834) WeChat/arm64
Weixin NetType/WIFI Language/zh_CN ABI/arm64
随机化参数包括:
- 手机型号(12 种:SM-G9910, iPhone14,3 等)
- Android 版本号(6 种)
- Chrome 版本号(37 种)
- 微信版本号(9 种)
- XWEB 版本号(4 种)
6.5 完整性验证:字节码栈模拟
反编译后的 generate_requests_config 函数是一个纯线性函数(零分支、零循环、零异常处理)。
BUILD_LIST 519 的暗示
字节码中存在 BUILD_LIST 519 指令,暗示函数应该生成 519 个列表项。但反编译结果只有 291 个配置——差了一大截。原因:
- 反编译器在 CACHE 指令处崩溃:Python 3.12 在 LOAD_GLOBAL(+4 个 CACHE)等指令后插入了额外 8 字节的 CACHE 槽位
- CACHE 覆盖了原 3.10 指令:重建字节码时,CACHE 槽位(opcode=0 的占位符)覆盖了后续 4 条 3.10 指令,导致反编译器在第一条 LOAD_GLOBAL 后就解析失败
- 反编译器跳过了整个函数体:prologue 损坏 → AST 构建失败 → 只输出函数签名
栈模拟还原
手动编写字节码栈模拟器,逐条指令追踪栈状态:
for instr in instrs:
if op == 'LOAD_CONST':
stack.append(consts[arg])
elif op == 'BUILD_CONST_KEY_MAP':
n = arg
keys = stack.pop() # 键元组(e.g. ('name','url','method','json_data','headers'))
values = stack[-n:] # 对应值
stack = stack[:-n]
# 验证键是否匹配 API 配置格式
if keys in CONFIG_KEY_PATTERNS:
configs.append(dict(zip(keys, values)))
...
CACHE 损坏处理
遇到栈下溢时用占位符填充,确保后续指令能继续执行:
def ensure_stack(n):
while len(stack) < n:
stack.append(placeholder)
验证结果:
- 503 个 BCKM 指令匹配 API 配置键模式
- 432 个成功提取(86%)
- 519 个列表项在
BUILD_LIST 519处——与字节码完全吻合 - 11 个配置永久丢失(2.2%),因 CACHE 覆盖导致栈污染无法恢复
结论:原始文件只有 291 个配置,实际字节码中包含 503 个配置构造指令,成功提取 432 个,最终合并生成 406 个有效配置。这是从已损坏的字节码中能恢复的极限。
7.1 混淆技术特征
| 特征 | 说明 |
|---|---|
| 多层嵌套 | 9 层混淆,同一种模式重复使用 |
| 编码轮换 | base16 ↔ base64 ↔ marshal ↔ 纯反转 |
| 执行流隐藏 | 每层只 exec 下一层的代码对象 |
| 版本锁定 | 利用 Python marshal 格式差异阻止简单加载 |
| 依赖锁定 | 代码需要 pycryptodome(不安装无法运行) |
7.2 逆向工具链
# 1. 基础解码
zlib.decompress() + base64.b16decode() # 前 2 层
# 2. marshal 加载
marshal.loads() + base64.b16decode() # Layer 3-4
# 3. 自动剥壳脚本
b64decode → zlib.decompress → marshal.loads # Layer 5-6
# 4. 跨版本代码转换
xdis.load_code() + types.CodeType() # Layer 7-9
# 5. 静态分析
dis.dis() + 常量提取 + URL 提取 # 最终分析
7.3 关键教训
- Marshal 版本问题是最隐蔽的坑 — 花的时间远超解码本身
- 不要执着于完美执行 — 当 exec 导致段错误时,静态分析是更好的选择
- 字符串常量本身就暴露了代码意图 — 792 个 URL 和 3,544 个字符串加上 406 个可执行 API 配置说明了一切
- xdis 是跨版本 Python bytecode 分析的首选工具
八、附录
A. 完整提取脚本
import marshal, base64, zlib, types, dis
# 读取原始文件
with open('短信测压.py', 'rb') as f:
content = f.read()
# 定义通用解码器
def decode_hex_zlib(data):
return zlib.decompress(base64.b16decode(data[::-1]))
def decode_hex_marshal(data):
return marshal.loads(base64.b16decode(data[::-1]))
def decode_b64_zlib_marshal(data):
return marshal.loads(zlib.decompress(base64.b64decode(data[::-1])))
def decode_rev_marshal(data):
return marshal.loads(data[::-1])
# 逐层解码
code = decode_hex_marshal(hex_data) # Layer 3
code = decode_hex_marshal(code.co_consts[0]) # Layer 4
code = decode_b64_zlib_marshal(code.co_consts[2]) # Layer 5
code = decode_b64_zlib_marshal(code.co_consts[0]) # Layer 6
code = decode_rev_marshal(code.co_consts[2]) # Layer 7
code = decode_rev_marshal(code.co_consts[0]) # Layer 8
code = decode_rev_marshal(code.co_consts[0]) # Layer 9 ✓
# 静态分析
def extract_strings(co):
for c in co.co_consts:
if isinstance(c, str):
yield c
elif isinstance(c, types.CodeType):
yield from extract_strings(c)
all_strings = list(extract_strings(code))
urls = [s for s in all_strings if s.startswith('http')]
print(f"字符串: {len(all_strings)}, URL: {len(urls)}")
B. 统计摘要
| 指标 | 数值 |
|---|---|
| 原始文件大小 | 384 KB |
| 总字符串数 | 3,544 个 |
| API URL 数 | 792+ 个 |
| 函数/变量数 | 154 个 |
| 嵌套代码对象 | 108 个 |
generate_requests_config 返回值 | 519 项(字节码 BUILD_LIST 519) |
| 配置字典数(字节码指令级) | 503 个配置 BCKM |
| 栈模拟提取 | 432 个配置 |
| 最终反混淆输出 | 406 个有效配置(323 KB) |
C. 参考资料
- Python Marsha 格式文档
- CPython marshal.c 源码
- xdis 库 — 跨版本 Python bytecode 工具
本文所有分析均在隔离环境中完成。分析对象已标记为恶意软件,请勿运行。