Python字节码逆向
Python字节码逆向
本文适合
拿到 .pyc、PyInstaller 打包程序、exec/marshal 混淆脚本后,不知道如何恢复 Python 逻辑的学习者。学完你能:判断 Python 字节码版本,修复/反编译 pyc,读取 code object 常量和字节码,并在反编译失败时用 dis 和动态 dump 还原校验链。
Python 程序可以被编译成 .pyc 字节码文件。CTF 中常见任务是从 .pyc、打包程序或混淆字节码中恢复源码逻辑。
一句话判断
当题目给出 .pyc、PyInstaller 可执行文件、marshal.loads、exec、base64、pyarmor 或可疑 Python 字节码时,先确认 Python 版本和 code object,再决定反编译、反汇编还是动态 dump。
Python 字节码逆向的关键不是“必须还原完整源码”,而是拿到常量、控制流和校验函数,能复现输入如何被判断。
题目中常见信号
- 附件是
.pyc、.pyo、PyInstaller 打包的.exe/ELF。 - 文件头有 Python magic number,或提取目录里有
PYZ-00.pyz_extracted。 - 脚本中出现
marshal.loads、types.CodeType、exec、eval、compile。 - 反编译工具提示 Python 版本不匹配或 unsupported opcode。
- 常量池里能看到
flag、wrong、correct、密文、key、lambda/code object。 - 代码多层
base64 + zlib + marshal + exec嵌套。
核心概念
.pyc 保存的是 Python 虚拟机执行的 code object。code object 里有常量、变量名、字节码、文件名、函数名和嵌套函数。
分析时要关注三件事:
- 版本:不同 Python 版本 magic number、header 长度、opcode 都不同。
- 结构:顶层 code object 的
co_consts可能继续包含嵌套 code object。 - 执行链:混淆常用
marshal.loads还原 code object,再用exec执行。
反编译失败时,dis 反汇编和常量池仍然能给出关键逻辑。动态 hook exec/marshal.loads 常能直接 dump 解密后的 code object。
最小分析流程
- 读取 magic number,判断 Python 版本和 pyc header 长度。
- 如果是 PyInstaller,先用 pyinstxtractor 提取,再修复 pyc 文件头。
- 用 uncompyle6/decompyle3/pycdc 尝试反编译。
- 反编译失败时,用
marshal加载 code object,打印co_consts、co_names、co_varnames并dis.dis。 - 递归查找嵌套 code object,定位
check、verify、main、比较逻辑和密文常量。 - 遇到
exec/marshal多层混淆时,hook 或改写脚本,在执行前 dump 真实代码。
最小验证示例
读取 pyc 头和反汇编:
import dis, marshal
path = "challenge.pyc"
with open(path, "rb") as f:
magic = f.read(4)
header_rest = f.read(12)
code = marshal.load(f)
print("magic =", magic.hex())
print("name =", code.co_name)
print("consts =", code.co_consts)
print("names =", code.co_names)
dis.dis(code)如果 marshal.load 失败,先确认 Python 版本和 header 长度;如果 dis 能看到 LOAD_CONST、BINARY_XOR、COMPARE_OP,就可以顺着常量和比较逻辑还原校验。
常见利用 / 解题路线
路线总览:
- 直接反编译路线:版本匹配时,用 uncompyle6/decompyle3/pycdc 还原源码。
- 常量池路线:打印
co_consts,找密文、key、flag 片段和嵌套 code object。 - dis 反汇编路线:反编译失败时读 opcode,恢复栈操作、循环和条件跳转。
- PyInstaller 路线:pyinstxtractor 提取 -> 修 pyc 头 -> 反编译主模块和 PYZ 模块。
- exec/marshal 路线:hook
exec、eval、marshal.loads,在运行时 dump 解密后的 code object。 - opcode 重排路线:比较标准 opcode 与题目虚拟机,恢复映射后再反汇编。
pyc 是什么
.pyc 是 Python 字节码缓存文件。
它不是机器码,而是 Python 虚拟机执行的字节码。
不同 Python 版本的字节码格式和 opcode 可能不同。
所以分析 .pyc 时,版本非常重要。
pyc 结构
.pyc 文件的结构因 Python 版本而异,但通常包含以下部分:
- magic number:文件头 4 字节,标识 Python 版本。例如
55 0d 0d 0a对应 Python 3.8。 - 时间戳或 hash:4-8 字节,用于判断源码是否变化。Python 3.3+ 还支持基于源码 hash 的校验。
- 代码标志:标识是否使用 hash 校验等。
- code object:核心部分,包含常量、变量名、字节码、行号表、闭包变量等。
magic number 可以帮助判断 Python 版本。
code object 中保存常量(co_consts)、变量名(co_varnames)、字节码(co_code)、函数对象等信息。
# 读取 pyc 文件的 magic number
import struct
with open("challenge.pyc", "rb") as f:
magic = f.read(4)
print(f"Magic: {magic.hex()}")
# Python 3.8: 550d0d0a
# Python 3.9: 610d0d0a
# Python 3.10: 6f0d0d0a
# Python 3.11: a70d0d0a
# 解析 code object
import dis, marshal
with open("challenge.pyc", "rb") as f:
f.read(16) # 跳过文件头(Python 3.8+ 为 16 字节)
code = marshal.load(f)
print(f"常量: {code.co_consts}")
print(f"变量名: {code.co_varnames}")
print(f"文件名: {code.co_filename}")
print(f"字节码长度: {len(code.co_code)}")反编译和反汇编
反编译尝试把字节码还原成 Python 源码。
反汇编则把字节码转成 opcode 列表。
如果反编译失败,反汇编仍然能帮助理解控制流和常量。
常见 Python 字节码操作码:
LOAD_CONST:加载常量到栈顶。LOAD_FAST:加载局部变量到栈顶。STORE_FAST:将栈顶存入局部变量。COMPARE_OP:比较操作,后面跟比较类型(==、<、in等)。CALL_FUNCTION/CALL:调用函数。JUMP_FORWARD/JUMP_ABSOLUTE:无条件跳转。POP_JUMP_IF_FALSE:条件跳转,栈顶为 False 时跳转。
# 使用 dis 模块反汇编 Python 函数
import dis
def check_flag(flag):
if len(flag) != 20:
return False
return flag.startswith("flag{")
dis.dis(check_flag)
# 输出示例:
# 2 0 LOAD_GLOBAL 0 (len)
# 2 LOAD_FAST 0 (flag)
# 4 CALL_FUNCTION 1
# 6 LOAD_CONST 1 (20)
# 8 COMPARE_OP 2 (==)
# 10 POP_JUMP_IF_TRUE 16
# ...
# 使用 uncompyle6 反编译 pyc 到源码
# pip install uncompyle6
# uncompyle6 challenge.pyc > challenge.py
# 使用 decompyle3(支持 Python 3.8+)
# pip install decompyle3
# decompyle3 challenge.pyc
# 使用 pycdc(C++ 实现,支持更高版本)
# pycdc challenge.pyc -o challenge.py混淆和打包
Python 题可能使用以下保护手段:
- PyInstaller:将 Python 解释器和 pyc 打包成单个可执行文件。使用
pyinstxtractor提取 pyc。 - pyarmor:对 pyc 进行加密保护,运行时解密。需要动态 dump 或 hook 解密函数。
- marshal:将 code object 序列化,可能嵌套多层。
- base64 + exec:将源码 base64 编码后用
exec(eval(...))执行。 - opcode 重排:修改 opcode 映射表,使标准反汇编工具失效。
- Cython:将 Python 编译成 C 扩展,更接近原生逆向。
打包不等于加密。很多时候先提取 pyc,再分析 code object。
# 从 PyInstaller 打包的程序中提取 pyc
# pip install pyinstxtractor
python pyinstxtractor.py challenge.exe
# 提取的文件在 challenge.exe_extracted/ 目录中
# 修复提取的 pyc 文件头(PyInstaller 会去掉 magic number)
# 需要从同版本 Python 的 pyc 文件复制前 4 字节
python -c "
import struct
with open('challenge.pyc_org', 'rb') as f:
data = f.read()
# Python 3.8 的 magic number
magic = b'\\x55\\x0d\\x0d\\x0a'
with open('challenge.pyc', 'wb') as f:
f.write(magic + b'\\x00' * 12 + data)
"
# 使用 pyarmor-unpacker 动态 dump 解密后的 code object
# 需要在 pyarmor 运行时 hook PyMarshal_ReadObjectFromString更多 Opcode
Python 3.8+ 常见操作码
栈操作:
NOP 空操作
POP_TOP 弹出栈顶
ROT_TWO 交换栈顶两个元素
ROT_THREE 旋转栈顶三个元素
DUP_TOP 复制栈顶
加载/存储:
LOAD_CONST 加载常量
LOAD_FAST 加载局部变量
LOAD_GLOBAL 加载全局变量
LOAD_DEREF 加载闭包变量
LOAD_ATTR 加载对象属性
STORE_FAST 存储到局部变量
STORE_GLOBAL 存储到全局变量
STORE_DEREF 存储到闭包变量
算术运算:
BINARY_ADD 加法
BINARY_SUBTRACT 减法
BINARY_MULTIPLY 乘法
BINARY_TRUE_DIVIDE 除法
BINARY_MODULO 取模
BINARY_POWER 幂运算
BINARY_AND 按位与
BINARY_OR 按位或
BINARY_XOR 按位异或
比较操作:
COMPARE_OP 比较(==, !=, <, >, in 等)
IS_OP is/is not 判断
CONTAINS_OP in/not in 判断
控制流:
JUMP_FORWARD 向前跳转
JUMP_ABSOLUTE 绝对跳转
POP_JUMP_IF_TRUE 条件跳转(真)
POP_JUMP_IF_FALSE 条件跳转(假)
FOR_ITER 迭代器循环
SETUP_LOOP 循环开始(3.8前)
BREAK_LOOP 跳出循环(3.8前)
函数调用:
CALL_FUNCTION 调用函数
CALL_METHOD 调用方法
CALL_FUNCTION_KW 带关键字参数调用
MAKE_FUNCTION 创建函数对象
RETURN_VALUE 返回值
推导式:
LIST_APPEND 列表推导追加
SET_ADD 集合推导追加
MAP_ADD 字典推导追加Python 3.10+ 变化
Python 3.10 引入了新的操作码:
CACHE 指令缓存(不是真实操作)
GET_LEN 获取长度
MATCH_MAPPING 模式匹配映射
MATCH_SEQUENCE 模式匹配序列
MATCH_KEYS 模式匹配键
Python 3.11 引入了自适应特化:
LOAD_ATTR_INSTANCE_VALUE 特化的属性加载
BINARY_OP_ADD_INT 特化的整数加法
COMPARE_OP_INT 特化的整数比较pyc 修复
PyInstaller 提取的 pyc 修复
import struct
import os
def fix_pyc(input_path, output_path, python_version="3.8"):
"""
修复 PyInstaller 提取的 pyc 文件。
PyInstaller 会去掉 magic number 和时间戳。
"""
# Python 版本对应的 magic number
magic_numbers = {
"3.7": b'\x42\x0d\x0d\x0a',
"3.8": b'\x55\x0d\x0d\x0a',
"3.9": b'\x61\x0d\x0d\x0a',
"3.10": b'\x6f\x0d\x0d\x0a',
"3.11": b'\xa7\x0d\x0d\x0a',
"3.12": b'\xcb\x0d\x0d\x0a',
}
magic = magic_numbers.get(python_version, magic_numbers["3.8"])
with open(input_path, 'rb') as f:
data = f.read()
# 检查是否已经有正确的 magic number
if data[:4] == magic:
print("文件已有正确的 magic number")
return
# 构造 pyc 文件头
# Python 3.8+ 头部结构:
# 4 bytes: magic number
# 4 bytes: PEP 552 标志
# 4 bytes: 时间戳(或 hash)
# 4 bytes: 源码大小(或 hash)
flags = struct.pack('<I', 0) # 检查标志
timestamp = struct.pack('<I', 0) # 时间戳
size = struct.pack('<I', 0) # 源码大小
header = magic + flags + timestamp + size
with open(output_path, 'wb') as f:
f.write(header + data)
print(f"已修复: {output_path}")
def fix_all_pyc_in_dir(input_dir, output_dir, python_version="3.8"):
"""批量修复目录中的 pyc 文件"""
os.makedirs(output_dir, exist_ok=True)
for filename in os.listdir(input_dir):
if filename.endswith('.pyc') or filename.endswith('.pyc_org'):
input_path = os.path.join(input_dir, filename)
output_path = os.path.join(output_dir, filename.replace('_org', ''))
try:
fix_pyc(input_path, output_path, python_version)
except Exception as e:
print(f"修复 {filename} 失败: {e}")自动检测 Python 版本
def detect_python_version(pyc_path):
"""
通过尝试不同的 magic number 修复 pyc 文件。
"""
versions = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
with open(pyc_path, 'rb') as f:
data = f.read()
for ver in versions:
try:
fix_pyc(pyc_path, f"temp_{ver}.pyc", ver)
import marshal
with open(f"temp_{ver}.pyc", 'rb') as f:
f.read(16)
code = marshal.load(f)
print(f"成功!Python 版本: {ver}")
print(f"文件名: {code.co_filename}")
print(f"常量: {code.co_consts[:5]}")
return ver
except:
continue
return NonePyInstaller 提取
使用 pyinstxtractor
# 安装
pip install pyinstxtractor
# 提取
python pyinstxtractor.py challenge.exe
# 提取结果在 challenge.exe_extracted/ 目录
# 主要文件:
# challenge.pyc - 主程序
# struct.pyc - Python 标准库
# pyimod00_crypto_key.pyc - 加密密钥(如果有)
# PYZ-00.pyz_extracted/ - PYZ 归档中的模块分析 PyInstaller 打包结构
import os
import struct
def analyze_pyinstaller(extracted_dir):
"""分析 PyInstaller 提取结果"""
# 查找主程序入口
for f in os.listdir(extracted_dir):
if f.endswith('.pyc') and 'main' in f.lower():
print(f"可能的入口: {f}")
# 查找加密相关文件
for f in os.listdir(extracted_dir):
if 'crypto' in f.lower() or 'key' in f.lower():
print(f"加密相关: {f}")
# 列出所有 pyc 文件
pyc_files = [f for f in os.listdir(extracted_dir) if f.endswith('.pyc')]
print(f"pyc 文件数量: {len(pyc_files)}")
# 分析 PYZ 归档
pyz_dir = os.path.join(extracted_dir, "PYZ-00.pyz_extracted")
if os.path.exists(pyz_dir):
pyz_files = os.listdir(pyz_dir)
print(f"PYZ 中的模块: {len(pyz_files)}")反混淆
常见混淆手段及应对
1. 字符串加密:
- 运行时解密
- 应对:动态 dump 解密后的字符串
- 工具:在 exec 调用前 hook
2. opcode 重排:
- 修改 Python 的 opcode 映射
- 应对:找到映射表,还原标准 opcode
- 方法:比较标准 Python 的 opcode 和修改后的
3. 控制流混淆:
- 插入无用分支、死代码
- 应对:手动简化或编写脚本清理
4. 变量名混淆:
- 将变量名改为无意义字符
- 应对:根据使用上下文推断含义
5. 多层嵌套:
- exec(exec(exec(...)))
- 应对:逐层解包动态 dump 脚本
import dis
import marshal
import sys
def dump_code_object(code, depth=0):
"""递归 dump code object"""
prefix = " " * depth
print(f"{prefix}函数名: {code.co_name}")
print(f"{prefix}文件: {code.co_filename}")
print(f"{prefix}行号: {code.co_firstlineno}")
print(f"{prefix}常量: {code.co_consts}")
print(f"{prefix}变量: {code.co_varnames}")
print(f"{prefix}字节码长度: {len(code.co_code)}")
# 递归处理嵌套的 code object
for const in code.co_consts:
if hasattr(const, 'co_code'):
dump_code_object(const, depth + 1)
def hook_exec():
"""hook exec 函数,dump 执行的代码"""
original_exec = exec
def hooked_exec(code, *args, **kwargs):
print(f"=== exec 被调用 ===")
if isinstance(code, str):
print(f"代码内容: {code[:200]}")
elif hasattr(code, 'co_code'):
dump_code_object(code)
return original_exec(code, *args, **kwargs)
import builtins
builtins.exec = hooked_exec常见失败原因
- 反编译失败就认为不能做:改用
dis、常量池、递归 code object 和动态 dump。 - 忽略 Python 版本:magic number 和 opcode 版本不匹配会导致反编译结果错误。
- PyInstaller pyc 不修头:提取出的 pyc 常缺 header,需要补同版本 magic 和头部。
- 不看常量池:密文、key、函数对象和嵌套代码常在
co_consts里。 - 把
exec字符串当普通数据:它可能是下一层源码或 code object,先 dump 再执行。 - 只处理顶层 code object:校验函数常藏在嵌套函数、lambda 或闭包中。
- 混淆脚本直接运行:先隔离环境,必要时替换危险函数,避免误执行系统命令。
迷你案例
题目给 main.pyc,decompyle3 报版本不支持。先用脚本打印 magic 和常量:
import marshal, dis
with open("main.pyc", "rb") as f:
print(f.read(4).hex())
f.read(12)
code = marshal.load(f)
print(code.co_consts)
dis.dis(code)常量池里有一个嵌套 code object check,反汇编看到循环执行 ord(input[i]) ^ 0x23 后和常量 bytes 比较。提取 bytes 逐位异或 0x23 得到 flag。这个案例的闭环是:反编译失败 -> 版本/常量检查 -> dis 读校验循环 -> 脚本逆运算。