reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
114
vendor-new/rev-skills/.claude/skills/re-python/SKILL.md
Normal file
114
vendor-new/rev-skills/.claude/skills/re-python/SKILL.md
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: re-python
|
||||
type: atomic
|
||||
description: >
|
||||
Python 打包/混淆样本分析:PyInstaller/PyArmor/Nuitka/Cython 解包、pyc 版本识别与反编译。
|
||||
触发词:Python打包、PyInstaller、PyArmor、pyc、python exe、Python 样本、打包样本。
|
||||
capabilities: [bytecode-parser]
|
||||
---
|
||||
|
||||
# Python 打包样本分析(PyInstaller / PyArmor / pyc)
|
||||
|
||||
## 何时使用 / 何时不用
|
||||
|
||||
- 用:PyInstaller 单文件/目录 exe、PyArmor 加固样本、.pyc 文件、Nuitka/Cython 编译产物、Python 恶意软件打包样本
|
||||
- 不用:纯 .py 源码混淆(base64/编码包装)→ 转 [[re-script-deob]];Python 模型权重(pkl/onnx)→ [[re-ai-model]]
|
||||
|
||||
## 工具准备
|
||||
|
||||
参考 [[re-analyze/platform-tips]]——解包/反编译为静态步骤,免沙箱;动态取明文([[re-sandbox]])按最高原则进沙箱。
|
||||
|
||||
### python3(基础运行时,必备)
|
||||
|
||||
- Linux: `apt install python3` / `dnf install python3` / `pacman -S python`
|
||||
- macOS: `brew install python`(系统自带)
|
||||
- Windows: 官网安装包或 `choco install python`
|
||||
- 验证: `python3 --version`
|
||||
|
||||
### pyinstxtractor(PyInstaller 归档提取)
|
||||
|
||||
- 多平台: `pip install pyinstxtractor-ng` 或 GitHub 脚本 `python pyinstxtractor.py`
|
||||
- 验证: `pyinstxtractor-ng <目标.exe>` 运行无报错(无独立 --help 命令)
|
||||
|
||||
### PyArmor-Unpacker(PyArmor 加固解包)
|
||||
|
||||
- 多平台: `git clone https://github.com/Svenskithesource/PyArmor-Unpacker`,按 README 按 PyArmor 版本选三方法之一
|
||||
- 验证: 仓库内 python 脚本可运行
|
||||
|
||||
### pycdc / pycdas(pyc 反编译)
|
||||
|
||||
- Linux/macOS: `git clone https://github.com/zrax/pycdc && cd pycdc && cmake . && make`
|
||||
- Windows: 预编译二进制或 WSL 编译
|
||||
- 验证: `./pycdc --help`
|
||||
|
||||
### file(打包器识别辅助,通用)
|
||||
|
||||
- 各系统自带(Linux binutils / macOS / Windows 需额外装或跳过)
|
||||
- 验证: `file --version`
|
||||
|
||||
## 操作步骤
|
||||
|
||||
按顺序执行,每步产物存档(路径 + sha256,见 [[re-triage]])。
|
||||
|
||||
1. **识别打包器**:
|
||||
```sh
|
||||
file sample.exe
|
||||
strings sample.exe | grep -iE 'PyInstaller|_MEIPASS|pyi-|PyArmor|pyarmor' | head
|
||||
```
|
||||
- PyInstaller 特征:`PyInstaller` 版本串、`_MEI` 临时目录名、`pyi-` 前缀引导器
|
||||
- PyArmor 特征:`pyarmor` runtime 字符串
|
||||
- Nuitka/Cython:无 pyc、纯编译产物(`file` 显示普通可执行,无 Python runtime 打包特征)
|
||||
- 识别失败但确认 Python 相关 → 按可疑 PyInstaller 处理
|
||||
|
||||
2. **PyInstaller 解包**:
|
||||
- pip 安装用户:`pyinstxtractor-ng sample.exe`;GitHub 脚本用户:`python pyinstxtractor.py sample.exe`
|
||||
```sh
|
||||
python pyinstxtractor.py sample.exe
|
||||
# 输出 sample.exe_extracted/ 目录,含 PYZ-00.pyz(依赖归档)与主脚本 .pyc
|
||||
```
|
||||
- 定位主 .pyc(名字与入口脚本对应);PYZ 内模块自动解出至 `PYZ-00.pyz_extracted/`;模块清单可用 archive_viewer.py 查看
|
||||
|
||||
3. **PyArmor 解包**(步骤 1 检测到 PyArmor 时):
|
||||
- 按 PyArmor 版本选 PyArmor-Unpacker 三方法之一(README 判断版本 → 对应方法)
|
||||
- 解包产物仍为 pyc 或源码,继续下一步
|
||||
|
||||
4. **pyc 版本识别**:
|
||||
```sh
|
||||
python3 -c "import struct,sys; print(struct.unpack('<H', open('main.pyc','rb').read(2))[0])"
|
||||
```
|
||||
- magic 对照(小端 2 字节,常见值;以本机 MAGIC_NUMBER 为准):
|
||||
- `0d33`=3.6、`0d42`=3.7、`0d55`=3.8、`0d61`=3.9、`0d6f`=3.10、`0da7`=3.11、`0dcb`=3.12
|
||||
- 3.13+:不写死数值,按本机 MAGIC_NUMBER 换算(`python3 -c "import importlib.util; print(hex(int.from_bytes(importlib.util.MAGIC_NUMBER[:2],'little')))"`)
|
||||
- pyc 头布局(3.7+ 统一 16 字节):magic(4) + flags(4) + mtime(4) + size(4),code 对象从偏移 16 开始;flags 低 1 位 = hash-based(PEP 552,mtime/size 无意义);PyInstaller 解出的 pyc 头 flags/mtime/size 全 0(清零是正常现象,不是损坏)
|
||||
- 版本决定 marshal 格式:3.14 起 marshal 升到 v5,code 对象内部布局与旧版不同——手写 marshal 解析须按 magic 版本分派;pycdc 等工具对 3.13+ 支持滞后,优先用匹配版本 python 的 `dis`/`marshal` 兜底
|
||||
- 版本匹配目标则用对应版本 python 或 pycdc 反编译;不匹配先装匹配版本再试
|
||||
|
||||
5. **反编译与清理**:
|
||||
```sh
|
||||
pycdc main.pyc > main_decompiled.py # 或匹配版本 python 的 dis 模块
|
||||
```
|
||||
- 清理 confusion code:删假函数/死代码(PyArmor 常见的无引用包装函数),定位核心逻辑(加密/网络/外泄)
|
||||
- 反编译失败(版本不匹配/混淆)→ 用 `dis` 字节码级分析关键函数,或转 [[re-binary-core]] 深度还原
|
||||
|
||||
## 跨域联合
|
||||
|
||||
- [[re-managed]] 网关:本技能归属(选择树「Python 打包样本」分支)
|
||||
- [[re-script-deob]]:纯脚本混淆(无打包)场景
|
||||
- [[re-malware]]:恶意 Python 样本的行为验证与 IOC(解包后转行为分析)
|
||||
- [[re-binary-core]]:pyc 深度还原 / 混合产物(内嵌 native 模块 [[re-format-elf]])
|
||||
- 底座 [[re-triage]]:打包器识别的初勘输入
|
||||
|
||||
## 常见坑与陷阱
|
||||
|
||||
- **pyc 版本不匹配直接反编译失败**:现象——pycdc 输出乱码或报错;原因——magic 版本与目标不符;对策——先做步骤 4 的 magic 识别,按版本选工具
|
||||
- **pyc 头 mtime/size 为 0 不是损坏**:现象——PyInstaller 解出的 pyc 用工具打开报错/空白,怀疑解包失败;原因——PyInstaller 写 PYZ 时清零 mtime/size(marshal 本体不受影响);对策——按 magic 识别版本后跳过 16 字节头,直接 `marshal.loads` 验证 code 对象,或交给 pycdc
|
||||
- **hash-based pyc(PEP 552)**:现象——pyc 头 flags 字段非 0(低 1 位为 1),mtime/size 是哈希或占位;原因——构建方用 hash 校验模式编译;对策——识别只看 magic 与 flags 低 1 位,反编译不受影响(code 对象仍是标准 marshal 流)
|
||||
- **PyArmor 版本差异导致解包器失效**:现象——PyArmor-Unpacker 报不支持;原因——PyArmor 版本过新/过旧;对策——按版本换方法,或手动定位 runtime 的加解密逻辑(转 [[re-binary-core]])
|
||||
- **Cython/Nuitka 无 pyc**:现象——找不到 .pyc;原因——编译型产物;对策——直接分析产物([[re-format-elf]] / [[re-ghidra]]),不找 pyc
|
||||
- **假函数干扰定位**:现象——解包后源码充斥无引用包装函数;原因——PyArmor 的 confusion code;对策——先按调用关系过滤(无调用者即候选),再定位核心逻辑
|
||||
- **strings 找打包器特征被加密隐藏**:现象——strings 无 PyInstaller 特征但行为是 Python;原因——字符串加密/壳;对策——按行为线索与扩展名判断,必要时动态侧([[re-sandbox]])观察
|
||||
- **混淆样本整体是"加密数据 + 引导壳 + 原生解密组件"三件套**:现象——静态看只有一处引导调用和一段加密字节串常量,找不到正常 pyc,函数字节码在内存里也长期保持密文;原因——整份程序以 marshal 流形态加密后作为一个字节串常量整体内嵌进引导调用,解密密钥由随样本交付的原生扩展内部静态数据派生(密钥材料与配方随构建变化),单个函数执行前才原位解密、返回后立即原位重加密,明文存在的窗口被压到极短;函数常量表还被注入负责进入/离开/完整性校验的原生桩对象,文件里只剩标记字符串,真实对象只能运行期生成;对策——把样本拆成三件套分别处理,函数级明文要么静态还原加解密流程、要么在运行期解密窗口内抓取;注意解密后的函数体仍是普通 CPython 字节码,不存在自定义字节码虚拟机——看起来像 VM 的花哨字节只是加密后的标准字节码
|
||||
- **密钥材料定位用"盐锚点 + 相对偏移",别硬编码绝对地址**:现象——换一个构建版本的样本,旧的密钥定位脚本全部失效;原因——密钥由多段静态数据拼接后取摘要派生(典型拼段含盐、内嵌公钥、签名许可证描述哈希、一段需先还原的掩码数据),掩码区域靠固定长度区域与重复字节 pad 做 XOR 还原,而文件中的对齐填充同样表现为连续相同字节,全零候选一旦被当 pad 就派生错误密钥;参与拼接的输入还受条件分支控制(外部密钥文件、试用/正式开关);对策——在原生扩展的原始字节中先搜可见盐锚点字符串,其余密钥材料一律以锚点位置为基准按相对偏移计算,同一套代码可跨多个构建复用;自动找 pad 时排除全零候选;按标志位判断启用哪些输入;静态还原不必让启动期完整性校验(如公钥验签)通过,从分支逻辑复现出密钥派生的输入拼接方式即可
|
||||
- **解密产物是标准 marshal 流,但重建 CodeType 有格式怪癖**:现象——按常规 .pyc 流程解析解密出的字节流时字段错位、常量错乱,个别 Python 版本上还会直接崩溃且异常不可捕获;原因——序列化时常量元组声明的长度可能小于实际写入个数(多余条目无头部直接跟在后),流里还可能出现私有类型字节区间,代表只能由运行时在内存中生成的原生对象(静态读不到本体,占位符不剔除会导致后续常量索引错位),密文字节码直接进反汇编或 CodeType 构造在部分版本上会崩解释器;对策——用一次性模板代码编译后 .replace() 构造 CodeType 而非直接调构造器(构造器参数顺序随版本变化),本地变量数显式传入;原生对象以占位符表示并在构造前剔除;单条记录解析失败时保留已读字段(字节码与常量靠前、通常已拿到),整体降级为逐个报告完整解析的 code 对象而非全盘失败;解密失败用模板占位字节码顶替,密文仅十六进制展示;3.11 起 code 对象布局变化,从 blob 头读目标版本并自动切到匹配版本解释器,不为每个版本各写一套解析规则
|
||||
- **动态取明文要卡"进入桩解密后"的窗口,导入方式也有讲究**:现象——运行时 hook 后抓不到明文,或回调内部崩溃;原因——受保护函数每次调用都经进入桩解密、执行、离开桩重加密三步,明文窗口极短(短函数可能立即离开),事后读取拿不到;进入桩对外是 Python 可调用对象(内建函数对象形态),可从模块常量表辨认,真实原生函数指针藏在 C 函数对象的元数据结构里、经内存布局偏移可读出;首轮导入时回调内部再触发同一钩子会嵌套破坏回调链;以 __main__ 方式运行会走另一条运行时路径、绕过发现机制;对策——拦截模块首次解密后执行的那次调用,从传入 code 对象的常量中读出进入桩指针;首轮导入返回空指针中止、重启一轮等内联钩子就位,后续执行用具名模块方式导入;定位导入槽在数据段内扫描已解析的真实函数地址指针,而不是扫调用点(调用点引用的是槽地址,直接扫容易错)
|
||||
- **混淆器内部格式不是文档化兼容接口,结论必须标注版本**:现象——同一套还原脚本换版本就失效,且说不清坏在哪一步;原因——密钥派生输入、嵌入标记位置、blob 头、函数级描述符、原生入口、marshal 重建行为都可能随版本变化,工具与目标解释器版本也强绑定(code 对象/marshal 布局随版本变);对策——任何结论标注精确版本,跨版本复用工具不假设可用;对每个新版本逐项重验(密钥配方、标记位置、头部结构、描述符、入口函数、重建行为),未经真实样本验证的分支显式标注未实测;静态重建与动态捕获(运行期真实解密产物)逐字段对照互相验证——动态能暴露静态推断的格式错误(如把原生对象误当普通常量、元组声明长度偏差),静态补足动态够不到的死路径(未调用函数、未触达分支);试用/正式、有无外部密钥文件等形态差异按标志位与分支逻辑处理,不假设单一形态
|
||||
@@ -0,0 +1,168 @@
|
||||
# Python 打包产物最小解析示例
|
||||
|
||||
以下示例以本机 Python 3.14.7 与 PyInstaller 6.22.2 为标本,命令与输出可原样复现(工具链:`python3`、`pip install pyinstaller pyinstxtractor-ng`;系统 pip 受 PEP 668 限制时先 `python3 -m venv` 再装)。结构定义见 [[layout]]。
|
||||
|
||||
## 1. pyc 头解析(python3 本机可复现)
|
||||
|
||||
### 生成 pyc
|
||||
|
||||
```sh
|
||||
python3 -c "import py_compile; py_compile.compile('sample.py', cfile='sample.pyc')"
|
||||
# sample.py 内容:import base64
|
||||
# k = base64.b64decode("czNjcmV0")
|
||||
# print(k)
|
||||
```
|
||||
|
||||
### struct 解析头 + marshal 验证 + 反汇编
|
||||
|
||||
```python
|
||||
import struct, marshal, dis
|
||||
|
||||
d = open('sample.pyc', 'rb').read()
|
||||
magic, flags, mtime, size = struct.unpack('<IIII', d[:16])
|
||||
print('magic=0x%08x flags=%d mtime=%d size=%d' % (magic, flags, mtime, size))
|
||||
print('code 对象从偏移 16 开始,类型字节 0x%02x' % d[16])
|
||||
code = marshal.loads(d[16:]) # 与解释器同版本的权威解析
|
||||
print('argcount=%d nlocals=%d stacksize=%d' % (code.co_argcount, code.co_nlocals, code.co_stacksize))
|
||||
dis.dis(code)
|
||||
```
|
||||
|
||||
本机实测输出:
|
||||
|
||||
```
|
||||
magic=0x0a0d0e2b flags=0 mtime=1787648948 size=56
|
||||
code 对象从偏移 16 开始,类型字节 0xe3
|
||||
argcount=0 nlocals=0 stacksize=3
|
||||
0 RESUME 0
|
||||
1 LOAD_SMALL_INT 0
|
||||
LOAD_CONST 1 (None)
|
||||
IMPORT_NAME 0 (base64)
|
||||
STORE_NAME 0 (base64)
|
||||
...
|
||||
```
|
||||
|
||||
对照要点:`magic` 低 2 字节 `0x0e2b` = 3.14;`flags=0` = timestamp 校验;`size=56` = 源文件字节数;类型字节 `0xe3` = `0x63|0x80`(code + REF 标志),PyInstaller 解出的条目标 `0x63`——两种都合法,别拿类型字节判损坏。
|
||||
|
||||
### 字节样例(pyc 头 16 字节,逐字段标注)
|
||||
|
||||
```
|
||||
00000000: 2b0e 0d0a 0000 0000 9e5b 8d6a 3800 0000 +......[.j8...
|
||||
magic=0x0a0d0e2b─┘ └flags=0 └size=56(0x38)
|
||||
mtime=0x6a8d5b9e──────┘
|
||||
00000010: e300 0000 0000 0000 0000 0000 0003 0000 ................
|
||||
类型0xe3(code+REF) argcount=0 posonly=0 kwonly=0 nlocals=3
|
||||
```
|
||||
|
||||
手工核对:`xxd sample.pyc | head -2` 与上述一致;PyInstaller 解出的 pyc 头 mtime/size 为 0(清零正常,见 [[layout]] 头字段表)。
|
||||
|
||||
## 2. PyInstaller onefile:构建 → 解包 → cookie 解析
|
||||
|
||||
### 构建与解包
|
||||
|
||||
```sh
|
||||
pip install pyinstaller pyinstxtractor-ng
|
||||
pyinstaller --onefile sample_app.py
|
||||
python3 -m pyinstxtractor_ng dist/sample_app
|
||||
```
|
||||
|
||||
本机实测解包输出(节选):
|
||||
|
||||
```
|
||||
[+] Possible entry point: sample_app.pyc
|
||||
[+] Found 130 files in PYZ archive
|
||||
[+] Successfully extracted pyinstaller archive: dist/sample_app
|
||||
```
|
||||
|
||||
产物结构见 [[layout]] §2 末;入口 pyc 头 `2b 0e 0d 0a | 00000000 | 00000000 | 00000000`——flags/mtime/size 全 0(清零正常),code 对象从 0x10 开始(`63 00 00 00 ...`)。
|
||||
|
||||
### CArchive cookie 手工解析(大端,`!8sIIII64s`)
|
||||
|
||||
```python
|
||||
import struct
|
||||
|
||||
d = open('dist/sample_app', 'rb').read()
|
||||
magic = b'MEI\x0c\x0b\x0a\x0b\x0e'
|
||||
i = d.rfind(magic) # cookie 在归档末尾,取最后一次出现
|
||||
ln, toc, toclen, pyver = struct.unpack_from('!IIII', d, i + 8)
|
||||
name = d[i + 24:i + 88].split(b'\0')[0].decode()
|
||||
start = i + 88 - ln # 归档起点 = cookie 尾 − archive_length
|
||||
print('cookie @0x%x' % i)
|
||||
print('archive_length=%d toc_offset=0x%x toc_length=%d pyver=%d' % (ln, toc, toclen, pyver))
|
||||
print('pylib=%s archive 起点=0x%x' % (name, start))
|
||||
```
|
||||
|
||||
本机实测输出:
|
||||
|
||||
```
|
||||
cookie @0x974549
|
||||
archive_length=9847480 toc_offset=0x963220 toc_length=4160 pyver=314
|
||||
pylib=libpython3.14.so.1.0 archive 起点=0x102e9
|
||||
```
|
||||
|
||||
对照要点:pyver=314 即 Python 3.14(`major*100+minor`);`toc_offset + toc_length + 88 == archive_length`(实测 0x963220+4160+88=9847480 吻合);onefile 下归档起点之前是 bootloader——所有归档内偏移都要加该起点换算;**起点 = cookie 尾 − archive_length**(cookie 自身 88 字节不算进归档,算错会整体偏移 88 字节)。
|
||||
|
||||
### TOC 条目遍历(变长,16 字节对齐)
|
||||
|
||||
```python
|
||||
import struct
|
||||
|
||||
d = open('dist/sample_app', 'rb').read()
|
||||
magic = b'MEI\x0c\x0b\x0a\x0b\x0e'
|
||||
i = d.rfind(magic)
|
||||
ln, toc, toclen, pyver = struct.unpack_from('!IIII', d, i + 8)
|
||||
start = i + 88 - ln # 归档起点
|
||||
pos = start + toc
|
||||
end = pos + toclen
|
||||
while pos < end:
|
||||
entry_len, off, clen, dlen, comp, tc = struct.unpack_from('!IIIIBc', d, pos)
|
||||
name = d[pos + 18:pos + entry_len].split(b'\0')[0].decode('latin1')
|
||||
print('%-26s type=%s off=0x%-7x clen=%-7d' % (name[:26], tc, off, clen))
|
||||
pos += entry_len
|
||||
```
|
||||
|
||||
本机实测输出(节选):
|
||||
|
||||
```
|
||||
struct type=m off=0x0 clen=232
|
||||
pyimod01_archive type=m off=0xe8 clen=2896
|
||||
pyimod02_importers type=m off=0xc38 clen=13829
|
||||
pyimod03_ctypes type=m off=0x423d clen=2874
|
||||
pyiboot01_bootstrap type=s off=0x4d77 clen=1158
|
||||
pyi_rth_inspect type=s off=0x51fd clen=1486
|
||||
sample_app type=s off=0x57cb clen=366
|
||||
libbrotlicommon.so.1 type=b off=0x5939 clen=63578
|
||||
...(共 58 条,遍历终点 == cookie 位置 0x974549)
|
||||
base_library.zip type=z off=0x761256 clen=415154
|
||||
PYZ.pyz type=z off=0x7c6808 clen=1690136
|
||||
```
|
||||
|
||||
对照要点:`type=m` = 内嵌引导模块(zlib 压缩 pyc 块)、`s` = 脚本类(入口/启动钩子)、`b` = 二进制数据、`z` = PYZ 类条目(含 base_library.zip 与 PYZ.pyz);名字无扩展名且是变长的——按 `entry_length` 跳(条目对齐到 16 字节),不能按定长扫。
|
||||
|
||||
## 3. PYZ 头解析
|
||||
|
||||
```python
|
||||
import struct
|
||||
d = open('sample_app_extracted/PYZ.pyz', 'rb').read()
|
||||
print('MAGIC=%r pyc_magic=%s toc_offset=0x%x' % (
|
||||
d[:4], d[4:8].hex(), struct.unpack('!i', d[8:12])[0]))
|
||||
```
|
||||
|
||||
本机实测输出:
|
||||
|
||||
```
|
||||
MAGIC=b'PYZ\x00' pyc_magic=2b0e0d0a toc_offset=0x19b92b
|
||||
```
|
||||
|
||||
对照要点:PYZ 头 = `PYZ\0` + pyc magic(同 pyc 版本判定)+ 大端 TOC 偏移;TOC 在文件尾部(marshal 列表),模块条目按 (name, (typecode, offset, length)) 索引 zlib 压缩的 code 对象。
|
||||
|
||||
## 实现教训(内化)
|
||||
|
||||
- 版本先于一切:pyc magic → marshal 版本 → PyInstaller pyver 三处分别判版本,混用即错位
|
||||
- 归档偏移是相对的:onefile 的 cookie 用 `rfind` 定位,归档起点 = cookie − archive_length
|
||||
- TOC 条目按 entry_length 跳,16 对齐,别定长扫
|
||||
- 官方 `marshal.loads` + `dis` 是权威解析路径;手写解析只到头部字段为止
|
||||
|
||||
## 使用注意
|
||||
|
||||
- 样例为本地构建产物,仅作结构对照;分析真实样本以样本自身头字段为准([[layout]] 各表)
|
||||
- 动态取明文(进入桩解密窗口内抓取)在沙箱内执行([[re-sandbox]],见 [[re-analyze/platform-tips]])
|
||||
@@ -0,0 +1,125 @@
|
||||
# Python 打包产物布局:pyc / PyInstaller / PyArmor
|
||||
|
||||
三类产物的分析入口不同:`.pyc` 直接按头 + marshal 解析;PyInstaller 先按 CArchive/PYZ 解出 pyc 再走前一条;PyArmor 是「外壳 pyc + native runtime」组合,结构与版本强绑定。本文件字段值以本机实测(Python 3.14.7 / PyInstaller 6.22.2)为准,版本差异单独标注。
|
||||
|
||||
## 1. pyc 文件布局
|
||||
|
||||
pyc = 16 字节头 + 一个 marshal 序列化的 code 对象(3.7+ 统一;3.7 前头为 12 字节)。
|
||||
|
||||
### 头字段(16 字节,3.7+)
|
||||
|
||||
| 偏移 | 名称 | 长度 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 0x00 | magic | 4 | `2b 0e 0d 0a`(3.14 实测)或小端 u16 读取(0x0e2b);版本判定依据 |
|
||||
| 0x04 | flags | 4 | 0=timestamp 校验;低 1 位=1 为 hash-based(PEP 552,mtime/size 无意义) |
|
||||
| 0x08 | mtime | 4 | 源文件 mtime;PyInstaller 写 PYZ 时清零(非损坏) |
|
||||
| 0x0c | size | 4 | 源文件字节数;同被 PyInstaller 清零 |
|
||||
| 0x10 | code 对象 | — | marshal 流从此开始 |
|
||||
|
||||
### magic 版本对照(小端 u16)
|
||||
|
||||
| 值 | 版本 | 值 | 版本 |
|
||||
|---|---|---|---|
|
||||
| 0x0d33 | 3.6 | 0x0d6f | 3.10 |
|
||||
| 0x0d42 | 3.7 | 0x0da7 | 3.11 |
|
||||
| 0x0d55 | 3.8 | 0x0dcb | 3.12 |
|
||||
| 0x0d61 | 3.9 | 0x0e2b | 3.14(本机实测) |
|
||||
|
||||
3.13 及以后:不写死数值,按目标机器换算——`python3 -c "import importlib.util; print(hex(int.from_bytes(importlib.util.MAGIC_NUMBER[:2],'little')))"`。
|
||||
|
||||
### marshal code 对象概览
|
||||
|
||||
- code 对象 marshal 类型字节 = `0x63`('c');带引用标志 `FLAG_REF`(0x80) 时为 `0xe3`——`py_compile` 产物顶层为 `0xe3`,PyInstaller 解出条目标 `0x63`,两种都合法
|
||||
- 3.14 起 marshal 版本为 5(`marshal.version`),code 对象内部字段布局与旧版不同——手写 marshal 解析必须按 magic 版本分派,别假设字段偏移
|
||||
- 反编译兜底一律用匹配版本的官方 `marshal.loads` + `dis`,而不是手写解析器
|
||||
|
||||
## 2. PyInstaller 布局
|
||||
|
||||
### 产物形态
|
||||
|
||||
```
|
||||
onefile: [bootloader(ELF/PE/Mach-O)] [CArchive 数据] [cookie]
|
||||
onedir : 程序目录 = bootloader 可执行文件 + 同目录 _internal/(内含 PKG 归档或散文件 + PYZ + 依赖)
|
||||
```
|
||||
|
||||
- 两种形态都含一个 PYZ(模块字节码归档)与一个 PKG/CArchive(其余资源)
|
||||
- onefile 运行时把 CArchive 解到 `_MEIxxxxxx` 临时目录再执行(`strings` 里的 `_MEI` 特征即此)
|
||||
|
||||
### CArchive cookie(归档尾,88 字节,大端)
|
||||
|
||||
格式 `!8sIIII64s`(实测 PyInstaller 6.22.2 源码 `_COOKIE_FORMAT`):
|
||||
|
||||
| 偏移 | 名称 | 长度 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 0x00 | MAGIC | 8 | `MEI\x0c\x0b\x0a\x0b\x0e` |
|
||||
| 0x08 | archive_length | 4 | 归档总长(= toc_offset + toc_len + 88) |
|
||||
| 0x0c | toc_offset | 4 | TOC 起始偏移(相对归档起始) |
|
||||
| 0x10 | toc_length | 4 | TOC 字节数 |
|
||||
| 0x14 | pyvers | 4 | `major*100+minor`(314 = Python 3.14,实测) |
|
||||
| 0x18 | pylib_name | 64 | Python 动态库名(`libpython3.14.so.1.0`,实测) |
|
||||
|
||||
定位:在文件中扫描 MAGIC,取最后一次出现(cookie 在归档末尾);onefile 下归档前是 bootloader,**归档起点 = cookie 位置 + 88 − archive_length**(cookie 自身不算进归档,少加 88 会整体偏移)。
|
||||
|
||||
### CArchive TOC 条目(变长,对齐 16)
|
||||
|
||||
条目 = 18 字节头(`!IIIIBc`,大端)+ UTF-8 名字(含 NUL),整条目补齐到 16 字节:
|
||||
|
||||
| 偏移 | 名称 | 长度 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 0x00 | entry_length | 4 | 本条目总长(含名字与补齐) |
|
||||
| 0x04 | data_offset | 4 | 数据相对归档起始的偏移 |
|
||||
| 0x08 | compressed_length | 4 | 压缩后长度 |
|
||||
| 0x0c | data_length | 4 | 原始长度 |
|
||||
| 0x10 | compress | 1 | 0=不压缩,1=zlib |
|
||||
| 0x11 | typecode | 1 | 实测:'m'=内嵌引导模块(zlib 压缩 pyc)、's'=脚本类(入口/启动钩子)、'b'=二进制数据、'z'=PYZ 类条目(含 base_library.zip 与 PYZ.pyz) |
|
||||
| 0x12 | name | 变长 | UTF-8 + NUL 结尾(名字无扩展名),条目 16 对齐 |
|
||||
|
||||
### PYZ(模块字节码归档)
|
||||
|
||||
头 16 字节 + 模块数据 + 尾部 TOC(marshal list):
|
||||
|
||||
| 偏移 | 名称 | 说明 |
|
||||
|---|---|---|
|
||||
| 0x00 | MAGIC | `PYZ\0`(4 字节) |
|
||||
| 0x04 | pyc magic | 与 pyc 相同的字节码 magic(实测 `2b 0e 0d 0a`) |
|
||||
| 0x08 | toc_offset | 大端 i32,TOC 相对 PYZ 起始的偏移 |
|
||||
| 0x0c | 保留 | 4 字节 |
|
||||
|
||||
- 模块条目:`zlib.compress(marshal.dumps(code_object))` 顺序写,无独立头
|
||||
- TOC:marshal 序列化的 `(name, (typecode, offset, length))` 列表,位于文件尾部;typecode 有模块(PYMODULE)/包(PKG,`__init__`)/命名空间包(NSPKG,0 长度)
|
||||
- pyinstxtractor-ng 解包 PYZ 后为 `PYZ.pyz_extracted/` 目录,每个模块重建 16 字节 pyc 头(mtime/size 为 0)
|
||||
|
||||
### 解包产物结构(pyinstxtractor-ng,实测)
|
||||
|
||||
```
|
||||
sample_app_extracted/
|
||||
├── sample_app.pyc # 入口脚本(code 对象从 0x10 开始)
|
||||
├── PYZ.pyz # 模块归档
|
||||
├── PYZ.pyz_extracted/ # 解出的模块 pyc
|
||||
├── python3.14/ # 版本目录
|
||||
│ └── lib-dynload/ # 扩展模块 .so
|
||||
├── base_library.zip # 标准库 zip
|
||||
└── lib*.so.1.0 # 运行时依赖
|
||||
```
|
||||
|
||||
## 3. PyArmor 布局(版本强绑定)
|
||||
|
||||
PyArmor 产物形态随版本变化(外壳结构/密钥派生/加密算法都变过),以下为通用骨架,细节按版本核实:
|
||||
|
||||
- 加固后的 pyc 是「外壳」:真实字节码加密,运行期由 `pyarmor_runtime`(随样本交付的 native 扩展)解密执行
|
||||
- 产物含大量无引用的包装函数(confusion code)干扰静态分析
|
||||
- 常见三件套形态:加密数据(字节串常量)+ 引导调用 + native 解密组件;函数级加解密窗口极短(进入桩解密、执行、离开桩重加密)
|
||||
- 解包思路与工具见 SKILL.md:按版本选 PyArmor-Unpacker 三方法之一;还原结论必须标注精确 PyArmor 版本
|
||||
|
||||
## 实现教训(内化)
|
||||
|
||||
- 一切解析先读 magic 定版本:pyc 头、marshal 格式、PyInstaller cookie 的 pyvers 三处各自判版本
|
||||
- PyInstaller 归档偏移是「相对归档起始」不是文件起始(onefile 前有 bootloader);cookie 里 archive_length 是换算基准
|
||||
- TOC 条目是变长的(名字补齐 16 字节),按 entry_length 跳,别按定长扫
|
||||
- pyc 头 mtime/size 为 0 是 PyInstaller 正常行为,不是解包失败
|
||||
|
||||
## 使用注意
|
||||
|
||||
- 静态解包/反编译免沙箱;动态取明文([[re-sandbox]])按最高原则
|
||||
- 与 [[re-managed]](本技能归属网关)、[[re-triage]](初勘输入)、[[re-script-deob]](纯脚本混淆)配合
|
||||
- Cython/Nuitka 产物无 pyc,直接转 [[re-format-elf]]/[[re-ghidra]] 分析 native
|
||||
Reference in New Issue
Block a user