写在前面
昨晚写完作业,闲着没事逛论坛,看到有人求助这个工具的问题,就下载下来拆开看了看。这工具是个 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_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 提取):
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 编译的核心模块。但代码就是代码,只要能在本地把模块加载起来,验证逻辑就藏不住。有什么分析得不对的地方,欢迎指正。也希望得到各位小手挥一挥,得到大家免费的评分!!