Python 多层混淆逆向实战:从 384KB 加密文件到完整源代码

免责声明:本文仅用于技术研究和安全分析。文中所涉及的程序是一个恶意短信轰炸工具,请勿用于非法用途。


一、背景

我们拿到一个 384KB 的 Python 文件 短信测压.py,文件名暗示这是一个短信压力测试工具。文件体积远超正常的 Python 脚本,说明其中包含大量混淆/加密数据

目标:完全反混淆,还原可读的 Python 源代码


二、环境准备

工具版本用途
Python 3.10.20conda 虚拟环境加载 3.8-3.10 marshal 格式
Python 3.11.15base 环境交叉分析 3.11+ bytecode
xdis6.3.0跨版本 bytecode 加载
decompyle33.9.3尝试反编译(未成功)
bash
# 创建 Python 3.10 环境(关键!)
conda create -n py310 python=3.10
# 安装辅助工具
pip install xdis decompyle3

三、混淆层级全景图

整个混淆链共 9 层,使用了三种编码/压缩手段的组合:

code
短信测压.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

原始文件开头:

python
_ = lambda __ : __import__('zlib').decompress(__import__('base64').b16decode(__[::-1]));
exec((_)(b'11524FD5...'))

解码逻辑

  1. [::-1] — 反转字节序
  2. b16decode — 十六进制解码(hex → bytes)
  3. zlib.decompress — zlib 解压缩
  4. exec — 执行结果

提取方法

python
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

python
_ = 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

python
# 动态生成的代码对象,反汇编如下:
# 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
# 等效的 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 输出的代码对象结构很简单:

code
names: ['exec', '_']
consts: [bytes(159KB), None]
bytecode: 16 bytes  →  exec(_(consts[0]))

这里的 _ 引用的是外层作用域中定义的 decode 函数。这层没有重新定义 _,直接复用。

连续重复 2 次同样的模式,数据量从 159KB → 119KB → 256KB 逐步变化。

自动剥离代码

python
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: 重新定义解码函数

到了这层,解码函数再次变化

python
# λ: marshal.loads(__[::-1])
# 只需要反转然后 marshal 加载!

之前的 zlib + base64 全部消失,只剩下纯反转 + marshal。数据也变为纯 marshal 格式(不含编码层):

code
bytes(256KB) → [::-1] → marshal.loads → 新代码对象

这种模式的切换可能是为了绕过长度检测——纯 marshal 数据比 base64 编码的数据更紧凑。


Layer 8: 最后的包装

又是一层反转+marshal。数据量从 256KB 微妙减少到 256070 字节(头信息变化)。


Layer 9: 最终代码

终于到达最终代码对象!

关键参数:

参数
函数/变量名154 个
常量数量277 个
嵌套代码对象108 个
模块级 bytecode1730 字节
总字符串常量3,544 个
静态字符串 URL792+ 个
可执行 API 配置406 个(331 独立端点)

函数命名列表(部分):

python
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 + 新增 qualnameexceptiontable 字段
  • Python 3.11 同时 移除了对 TYPE_STRING (0x73) 的支持

当 Python 3.11 尝试加载 Python 3.10 的 marshal 输出时:

code
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 库进行跨版本代码对象转换。

python
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用途
RESUME151在函数/生成器入口处
LOAD_ASSERTION_ERROR176断言错误加载
LOAD_BUILD_CLASS177类构建
STORE_NAME多个自适应指令
...176-255全部 80 个新 opcode

这些 opcode 在 Python 3.10 中根本不存在,导致:

  1. Python 3.10:可以 marshal.load(数据格式兼容),但无法 exec(opcode 未知)
  2. Python 3.11:可以 exec(opcode 已知),但无法 marshal.load(格式不兼容)

形成 死锁:没有单一 Python 版本能同时加载和执行这个代码。

5.3 最终的解决路径

code
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 长度问题

python
# 某些层反转后的数据长度不是 4 的倍数
# Python 标准库的 base64.b64decode 默认会自动填充
data = base64.b64decode(reversed_bytes)  # 自动处理 padding

Marshal 递归结构

python
# 代码对象可以嵌套包含其他代码对象(对应嵌套函数/类)
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 程序架构

code
┌─────────────────────────────────────────────┐
│                 初始化阶段                     │
│  • 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 使用的加密算法

python
# 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:

code
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 个配置——差了一大截。原因:

  1. 反编译器在 CACHE 指令处崩溃:Python 3.12 在 LOAD_GLOBAL(+4 个 CACHE)等指令后插入了额外 8 字节的 CACHE 槽位
  2. CACHE 覆盖了原 3.10 指令:重建字节码时,CACHE 槽位(opcode=0 的占位符)覆盖了后续 4 条 3.10 指令,导致反编译器在第一条 LOAD_GLOBAL 后就解析失败
  3. 反编译器跳过了整个函数体:prologue 损坏 → AST 构建失败 → 只输出函数签名

栈模拟还原

手动编写字节码栈模拟器,逐条指令追踪栈状态:

python
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 损坏处理

遇到栈下溢时用占位符填充,确保后续指令能继续执行:

python
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 逆向工具链

bash
# 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 关键教训

  1. Marshal 版本问题是最隐蔽的坑 — 花的时间远超解码本身
  2. 不要执着于完美执行 — 当 exec 导致段错误时,静态分析是更好的选择
  3. 字符串常量本身就暴露了代码意图 — 792 个 URL 和 3,544 个字符串加上 406 个可执行 API 配置说明了一切
  4. xdis 是跨版本 Python bytecode 分析的首选工具

八、附录

A. 完整提取脚本

python
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. 参考资料


本文所有分析均在隔离环境中完成。分析对象已标记为恶意软件,请勿运行。

登录后发表评论

请先登录账号后再发表评论