a3341736201 发表于 2026-8-20 14:01

逆向PyInstaller打包的产物完整分析过程

## 写在前面

昨晚写完作业,闲着没事逛论坛,看到有人求助这个工具的问题,就下载下来拆开看了看。这工具是个 iOS 激活工具(A9-A11 Ramdisk 引导 + 激活记录写入),套了四层保护:PyInstaller 打包、字节码加密、Cython 编译核心、应用级验证(license + ECID 注册)。
虽然我不懂这是干嘛的,去搜了dy,发现是ios玩机党们用的软件,我也没有视频博主说的“开发板”,仅供分析参考把
这篇文章记录我从拿到 exe 到定位验证逻辑的整个过程,中间踩了不少坑,也走了一些弯路。有分析得不对的地方,还望各位大佬指正。

---

## 第一章:PyInstaller 解包

拿到 exe 先用标准工具 pyinstxtractor 试了一下,直接失败。没办法,只能手工解析 CArchive cookie。第一版代码按小端读,读出来全是垃圾:

```python
# 第一次写的解析代码(小端读取),错的
tocLen = b.readUInt32LE(base+8)    # 1,820,760,579,明显是垃圾
tocOff = b.readUInt32LE(base+12)   # 超界
pyver= b.readUInt32LE(base+16)   # 不是合法版本号

# 运行结果
TOC length: 1820760579 TOC offset: 1957402883 Python version: 2698641408
```

把 cookie 原始十六进制打出来看:

```
4d 45 49 0c 0b 0a 0b 0e   "MEI\014\013\012\013\016" 8字节魔数
03 9a 86 6c               len 字段
03 99 ab 74               TOC 字段
00 00 da a0               TOClen 字段
00 00 01 34               pyvers 字段,问题就出在这
70 79 74 68 6f 6e 33 38   "python38.dll"
```

`00 00 01 34` 按大端读是 0x134 = 308 = Python 3.8,按小端读是 0x34010000,一个不可能出现的版本号。PyInstaller 的 CArchive cookie 本来就是官方大端格式(`'!8sIIII64s'`),我一开始想当然按小端读,全错在这。

改大端之后:

```python
# 大端读取
def be32(off):
    return (b<<24)|(b<<16)|(b<<8)|b

archive_len = be32(cookie_pos+8)   # 60,458,604
toc_rel   = be32(cookie_pos+12)# 60,402,548
toc_len   = be32(cookie_pos+16)# 55,968
pyvers      = be32(cookie_pos+20)# 308 = Python 3.8
```

TOC 条目格式 ``,同样大端。解析通过,1137 个条目全部提取出来,main.pyd、fzip.pyd、mixpro 系列、libimobiledevice 工具链、tcl/tk 库,一个不少。

---

## 第二章:PYZ 字节码解密

CArchive 里有个 `PYZ-00.pyz`(2.6MB),488 个 Python 模块全在里头,但是加密的。`marshal.loads` 直接报 bad marshal data (unknown type code)。

PYZ 文件头:

```
50 59 5a 00            "PYZ\0" 魔数
55 0d 0d 0a            Python 3.8 字节码魔数
00 27 97 aa            TOC 位置(大端)= 2,594,730
01                     加密标志 = 1,确认加密
```

PyInstaller 4.x 加密会生成一个 `pyimod00_crypto_key` 模块,密钥就藏在它的常量表里。用 xdis 加载这个 134 字节的代码对象:

```python
>>> co = load_38_marshal('pyimod00_crypto_key')
>>> co.co_consts
['AC10@3774NL!O91@', None]   # AES 密钥直接躺在常量表里
```

拿到密钥之后查 PyInstaller 4.5.1 官方源码(pyz_crypto.py)确认算法:

```python
# PyInstaller 原始加密代码(官方 pyz_crypto.py)
class PyiBlockCipher:
    def __init__(self, key=None):
      if len(key) > BLOCK_SIZE:
            self.key = key      # 超长截断
      else:
            self.key = key.zfill(BLOCK_SIZE)    # 短了补零
    def encrypt(self, data):
      iv = os.urandom(BLOCK_SIZE)
      return iv + self.__create_cipher(iv).CTR_xcrypt_buffer(data)
```

对应的解密代码:

```python
KEY = 'AC10@3774NL!O91@'
k = KEY[:16] if len(KEY) > 16 else KEY.zfill(16)

def decrypt_entry(data):
    iv = data[:16]            # 每条目前 16 字节是随机 IV
    ct = data
    ctr = Counter.new(128, initial_value=int.from_bytes(iv, 'big'))
    cipher = AES.new(k.encode(), AES.MODE_CTR, counter=ctr)
    return zlib.decompress(cipher.decrypt(ct))   # 先解密,再解压
```

解密首条出来是 `78 9c`(zlib 头)+ `e3`(marshal 代码对象头),488 个模块全部还原。

说句题外话,PyInstaller 这层加密防静态提取基本没戏。密钥必须随包分发,不然运行时自己都解不开,只要找到 `pyimod00_crypto_key` 就等于拿到了钥匙。这是它固有的弱点。

---

## 第三章:重建 Python 3.8 环境,让 .pyd 跑起来

模块解出来了,但核心逻辑全在 Cython 编译的 .pyd 里(main.pyd 1.7MB、fzip.pyd、inx.pyd 这些)。我系统上是 Python 3.13,cp38 编译的 .pyd 根本加载不了,3.13 的 marshal 也解析不了 3.8 的代码对象。静态啃 C 代码太费劲,干脆把程序动态跑起来。

下载 Python 3.8.10 embeddable(8MB),把提取的 .pyd 全放进去,写个导入钩子直接加载解密出的 3.8 marshal 模块:

```python
class PyzLoader(importlib.abc.Loader):
    def exec_module(self, module):
      co = marshal.loads(open(self.path, 'rb').read())
      module.__file__ = self.path
      module.__loader__ = self
      if self.ispkg:
            module.__path__ = # 双路径:pyc 子模块 + .pyd 子模块
      exec(co, module.__dict__)

class PyzFinder(importlib.abc.MetaPathFinder):
    def find_spec(self, fullname, path=None, target=None):
      if fullname in binary_mods: return None   # 有 .pyd 的模块交给系统加载
      fp = lookup.get(fullname)
      if fp:
            ispkg = any(k.startswith(fullname+'.') for k in lookup) \
               or any(b.startswith(fullname+'.') for b in binary_mods)
            return importlib.util.spec_from_loader(fullname, PyzLoader(fp, ispkg), is_package=ispkg)
```

中间踩的坑:

| # | 报错 | 原因 | 修复 |
|---|------|------|------|
| 1 | `No module named 'tkinter'` | embeddable 版没有 tkinter | 从 exe 里提取 tcl8.6/tk8.6 完整库 + `_tkinter.pyd`,设置 TCL_LIBRARY/TK_LIBRARY 环境变量 |
| 2 | `DLL load failed: 找不到指定的模块` | pythoncom.pyd 依赖名为 `pywintypes38.dll` 的文件 | 把 DLL 复制成它依赖的名字 |
| 3 | `does not define module export function (PyInit_pywintypes38)` | 新版 pywin32 DLL 实际导出名是 `PyInit_pywintypes`,没版本后缀 | 改名成 `pywintypes.pyd` / `pythoncom.pyd` |
| 4 | `cryptography has no attribute __version__` | 我建的 cryptography 目录被当成 namespace 包抢先加载 | PyzFinder 插到 meta_path 最前,包路径同时包含真实 .pyd 目录 |
| 5 | `'mixpro' is not a package` | ispkg 判断只看 PYZ,没看二进制子模块 | ispkg 判断加上 binary_mods 前缀匹配 |

最终 main.pyd 的完整 GUI 在环境里跑起来了,后面的验证测试全部在真实运行的代码上做。

---

## 第四章:license 和密钥派生

fzip.pyd 的字符串表里躺着三样东西:

```
Ajs8N8jdbb#98mhsvaub                      license 字符串
Mkns01b6b3%s4dno0qo09h24E               密码模板(%s 填 license)
ABCDEFGHIJKLNMOPQRSTUVWXYZ=            自定义字母表,注意 NM 顺序是反的
```

动态测 license 校验:

```python
>>> fzip.zip_Pro('Ajs8N8jdbb#98mhsvaub')   # 正确 license,通过
<fzip.zip_Pro object>

>>> fzip.zip_Pro('DLXK412YF196')            # 任意其他输入
KeyError: ''                              # 被拒
```

报错是 Python 字典的 KeyError,说明 zip_Pro 构造时在查一个本地字典。我把 20 个前缀都试了一遍,只有完整字符串能过,字典里应该就一个键。整个过程我挂着 socket 钩子,一个网络请求都没有,所以这个校验是纯本地的。

zip_Pro 的实例属性打出来,能看到完整的自定义字母表分片:

```python
>>> zp.a = 'hjklz'
>>> zp.q = 'abcdefg'      >>> zp.w = 'hijklnm'
>>> zp.e = 'opqrst'       >>> zp.r = 'uvwxyz'
>>> zp.t = '1234'         >>> zp.y = '5678'
>>> zp.u = '9012'         >>> zp.z = 'ABCDEFGHIJKLNMOPQRSTUVWXYZ='
```

密钥派生:hook 了 `cryptography.fernet.Fernet.__init__`,调用 make_key / make_key2 的时候直接把密钥截下来:

```python
>>> zp.make_key()   # Fernet 对象创建时截获密钥
FERNERT KEY: b'b5vM0i-_XUVKFBchVuhACbph_xhx0KdDmViY0bCRB84='

>>> zp.make_key2()
FERNERT KEY: b'PzTKIvfmnsBU4BasrwDwY9y0d4o_AtMiOBam_eDoyRo='
```

密钥是纯函数派生,同一输入每次结果相同,离线可复现。

另外 fzip 的实例属性把加密数据的存放位置也泄露了:

```python
>>> zp.user
'C:\\Users\\Administrator\\AppData\\Local\\TeamViewer\\Viewer\\'   # 加密数据藏这里
>>> zp.user2
'C:\\Users\\Administrator\\AppData\\Local\\Temp\\'
```

调用 `zp.decrypt_file('test.bin', 'out_dir')` 实际读的是 `%LOCALAPPDATA%\TeamViewer\Viewer\out_dir\test.bin`,报错信息里能看到完整路径。激活数据伪装成 TeamViewer 文件藏在系统目录里,算是个反分析手段。不过这招只防"扫目录的人",防不住"读二进制的人",路径字符串就明文躺在 .pyd 里。

---

## 第五章:为什么是 %LOCALAPPDATA% 那个 zip

main.pyd 二进制里,偏移 1595279 处:

```
...\Local\a18aa246f887bd13f69033938b363ff93df9c2d7.zip...
```

它周围 300 字节内的相邻字符串(字符串池按源码顺序排列,邻居基本是同一个函数的代码):

```
main._build_other_page      界面构建函数
main.updateXX               提交/刷新函数,"未注册"弹窗的归属函数
main.ifmount                  挂载检查
rightKey                      右键菜单
get_system_file               下载系统文件的函数名,关键邻居
GetVersion                  版本查询
ValueError
```

`get_system_file` 和 `updateXX` 就挨着这个 zip 路径,说明这个 zip 是"下载到本地、本地再读"的文件。

配套还有一条,偏移 1609827:

```
\%s.txt.send      把 %s.txt 发给服务器(提交注册信息)
```

它的上下文:

```
activation_records
activation_record.plist
FairPlay
iTunes_Control
iTunes
IC-Info.sisv
com.apple.commcenter.device_specific_nobackup.plist
\%s.txt.send      这里
zipj
```

设备信息(ECID/SN/IMEI)被拼成 txt 发出去,服务器返回的注册状态又被本地缓存成 zip。

再看 `download_F` 函数的变量名(从 func_code 提取):

```python
download_F: varnames = ('self', 'X',
    'q','w','e','r','t','y','u','i','o','p',
    'a','s','d','f','g','h','j','k','l','z',
    'enuoxy', 'line', 'cw')
```

和上面 zip_Pro 的字母表分片完全一致(q='abcdefg'、w='hijklnm'、z='ABCDEFGHIJKLNMOPQRSTUVWXYZ=' 这些),所以密钥派生和注册文件下载都发生在 download_F 里。

根据这些代码证据,链路大致是这样(注意:这是根据代码推断的,完整流程我没有真机跑过):

```
用户点击"提交游戏机"
iosxl():读设备 ECID/SN/IMEI,打包成 %s.txt
download_F():
      1. 用自定义字母表 + license 派生 Fernet 密钥
      2. 下载/读取注册列表:%LOCALAPPDATA%\a18aa246f887bd13f69033938b363ff93df9c2d7.zip
      3. Fernet 解密,得到已注册 ECID 列表
      4. 本地比对:本机 ECID 在不在列表里
不在:弹窗"未注册,请联系经销商注册ECID"
在:服务器签发激活文件,putactive() 通过 SSH 写入设备
```

文件名为什么是 32 位十六进制:`a18aa246f887bd13f69033938b363ff93df9c2d7` 是 MD5 格式。我拿 license、'updateXX'、'getAC' 这些常见字符串算过 MD5,都对不上,可能是服务器地址或某个内部标识的 MD5,这点没证实。用哈希命名的目的应该就是隐藏文件用途。

为什么放 %LOCALAPPDATA%:fzip 里有 `\Local\` 字符串和 expanduser 函数名,应该就是用 expandvars 之类拼接的。%LOCALAPPDATA% 是 Windows 标准的每用户可写缓存目录,注册列表缓存到这里,避免每次启动都问服务器,用户目录下也看不到软件自己的痕迹。

---

## 第六章:启动检查 B(),从"软件自杀"到一行补丁

弹窗钩子加调用栈跟踪,把 B() 的完整行为抓出来了:

```
--- app create ---
DIALOG showwarning ('提示', '网络错误')          检查失败弹窗
WEB BROWSER open: 'https://docs.qq.com/doc/p/45ab7f373ac6cd73d1e5019b6eaabaf81e884460'打开操作手册
--- B ---
DIALOG showwarning ('提示', '网络错误')          再次弹窗
B EXC: can't invoke "destroy" command: application has been destroyed   窗口已经被销毁
```

有意思的是整个过程里我挂着 socket/DNS/subprocess 钩子,一个网络请求都没抓到。所谓"网络错误"其实是本地状态检查失败(没有设备、没有服务器环境),然后弹窗、打开腾讯文档手册、最后直接把窗口销毁,等于软件自杀。

还原出来的逻辑:

```python
# B() 原始逻辑(反汇编加行为还原)
def B(self):
    P = 网络检查()               # 检查失败(无服务器环境时)
    if not P:
      showwarning("提示", "网络错误")
      url = "https://docs.qq.com/doc/p/45ab7f373ac6cd73d1e5019b6eaabaf81e884460"
      webbrowser.open(url)   # 打开腾讯文档操作手册
      self.close_root()      # 销毁窗口
      return
    L = ...# 正常流程
```

补丁一行就够:

```python
main.main.B = lambda self: None
```

改完的效果:不弹窗、不开浏览器、窗口存活、GUI 正常渲染。Cython 函数虽然编译成了 C,但暴露出来的 Python 层对象(cython_function_or_method)可以直接替换,Cython 的 binding=True 模式等于留了个后门。

---

## 第七章:"未注册"弹窗是本地限制的证据

| 证据 | 结论 |
|------|------|
| putactive/active/writeac/putacA5 全部通过 paramiko SSH 直接写设备 plist | 激活写入功能 100% 本地 |
| 弹窗消息是 `%s` 格式模板(字符串池里 `...%s未注册\n请联系经销商注册ECID`) | 弹窗由客户端本地格式化生成 |
| 注册列表缓存文件在 %LOCALAPPDATA% | 列表是本地比对,不是每次实时问服务器 |
| X2/X3/X4 是预签发令牌(时间戳 2024-09 / 2026-01) | 令牌是长期凭证,非实时会话 |

我的猜想基本成立:"未注册"这个弹窗是本地代码挡的,不是服务器实时拒绝。激活功能全在本地,但核心的激活数据包(就是那个 zip 的内容)和服务器签发的激活文件绕不开。
如果拿到一份合法 zip(密钥已经捕获,可以离线解密),理论上就能任意离线使用。
这一点还差最后一步验证:找台能正常激活的机器,把那个 zip 拿过来解密看看里面到底是什么。

---

## 结语

整个逆向过程走下来,打包层(PyInstaller)和字节码加密层(tinyaes)基本是防君子的,真正扛打的是 Cython 编译的核心模块。但代码就是代码,只要能在本地把模块加载起来,验证逻辑就藏不住。有什么分析得不对的地方,欢迎指正。也希望得到各位小手挥一挥,得到大家免费的评分!!

Sky2026 发表于 2026-8-21 08:25

非常详细的逆向分析过程,受益匪浅!有几点想和作者交流一下:

1. 关于Cython作为核心保护层:确实,Cython编译后的.pyd相比纯字节码逆向难度大很多,但它本质上还是本地代码,只要能在调试器中跑起来,关键函数的逻辑还是可以通过动态追踪还原的。作者在第六章用弹窗钩子+调用栈跟踪的方式抓B()的完整行为,这个思路很实用——与其静态硬啃Cython生成的C代码,不如在运行时拦截关键API调用。

2. 第五章中通过字符串邻域分析定位get_system_file函数的方法很巧妙。在没有符号表的情况下,利用源码顺序排列的字符串池作为"锚点"来推断相邻函数的功能,这比单纯靠交叉引用要高效得多。

3. 想请教一下:作者在重建Python 3.8环境时遇到的cryptography namespace包抢占问题(第4个坑),除了把PyzFinder插到meta_path最前面之外,有没有考虑过直接修改sys.modules中已加载的cryptography模块,或者在embeddable包中预先删除空的__init__.py?另外,那个32位十六进制文件名作者推测是MD5,有没有尝试过用license字符串加上常见盐值(比如软件名、版本号)跑一下对比?

整体来看,这个案例很好地展示了"多层防护但每层都有突破口"的典型逆向思路,打包层和加密层都是纸老虎,真正的门槛在于Cython核心模块的动态分析。期待作者后续分享激活文件格式的逆向结果!

忧郁之子 发表于 2026-8-20 14:16

论坛里曾经有人发过一个小工具,但是效果不大,没有这个详细,谢谢楼主

larryliao 发表于 2026-8-20 14:40

好厉害,源码都能还原出来, {:1_921:}

中国新蔡 发表于 2026-8-20 14:49

非常感谢大佬的分析,收益良多!

Babou 发表于 2026-8-21 00:03

厉害了,很多自己都解不开,现在都全靠ai了

CodingAQ 发表于 2026-8-21 00:19

大佬牛逼, pyinstall 逆向,感谢大佬分享,很有用

lulu846642013 发表于 2026-8-21 00:44

不懂随便看看{:1_907:}javascript:;

lynxtang 发表于 2026-8-21 09:03

不错不错,写的很详细,逆向的逻辑还是很清晰的。

formeeting 发表于 2026-8-21 09:40

强大的分析能力,学习了,感谢分享
页: [1] 2 3
查看完整版本: 逆向PyInstaller打包的产物完整分析过程