吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 559|回复: 6
上一主题 下一主题
收起左侧

[原创] 逆向PyInstaller打包的产物完整分析过程

[复制链接]
跳转到指定楼层
楼主
a3341736201 发表于 2026-8-20 14:01 回帖奖励

写在前面

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


第一章:PyInstaller 解包

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

# 第一次写的解析代码(小端读取),错的
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'),我一开始想当然按小端读,全错在这。

改大端之后:

# 大端读取
def be32(off):
    return (b[off]<<24)|(b[off+1]<<16)|(b[off+2]<<8)|b[off+3]

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 条目格式 [structlen:4][data_pos:4][compressed_len:4][uncomp_len:4][flag:1][typecode:1][name...],同样大端。解析通过,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 字节的代码对象:

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

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

# PyInstaller 原始加密代码(官方 pyz_crypto.py)
class PyiBlockCipher:
    def __init__(self, key=None):
        if len(key) > BLOCK_SIZE:
            self.key = key[0:BLOCK_SIZE]        # 超长截断
        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)

对应的解密代码:

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[16:]
    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 模块:

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__ = [PYZ_DIR, 真实pyd目录]  # 双路径: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 校验:

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

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

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

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

>>> 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 的时候直接把密钥截下来:

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

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

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

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

>>> 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_fileupdateXX 就挨着这个 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 提取):

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 钩子,一个网络请求都没抓到。所谓"网络错误"其实是本地状态检查失败(没有设备、没有服务器环境),然后弹窗、打开腾讯文档手册、最后直接把窗口销毁,等于软件自杀。

还原出来的逻辑:

# 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 = ...  # 正常流程

补丁一行就够:

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 编译的核心模块。但代码就是代码,只要能在本地把模块加载起来,验证逻辑就藏不住。有什么分析得不对的地方,欢迎指正。也希望得到各位小手挥一挥,得到大家免费的评分!!

免费评分

参与人数 4吾爱币 +4 热心值 +4 收起 理由
ylsql + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
wfghim + 1 + 1 用心讨论,共获提升!
ioyr5995 + 1 + 1 热心回复!
gajimian + 1 + 1 热心回复!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
忧郁之子 发表于 2026-8-20 14:16
论坛里曾经有人发过一个小工具,但是效果不大,没有这个详细,谢谢楼主
3#
larryliao 发表于 2026-8-20 14:40
4#
中国新蔡 发表于 2026-8-20 14:49
5#
mueling 发表于 2026-8-20 21:48
逻辑 分析一流!牛人
6#
Babou 发表于 2026-8-21 00:03
厉害了,很多自己都解不开,现在都全靠ai了
7#
CodingAQ 发表于 2026-8-21 00:19
大佬牛逼,[cy] pyinstall 逆向,感谢大佬分享,很有用
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-8-21 00:25

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表