0x00 前言
Mole 是一款 macOS 系统清理/优化工具,作者 tw93,使用 SwiftUI 开发。本文记录对其许可证验证机制的逆向分析与破解过程,仅供学习交流。
目标版本:Mole 1.3.0 (Build 5)
架构:Universal Binary (x86_64 + arm64)
工具:IDA Pro 、Python3
难度:中等
0x01 基本信息收集
| 字段 |
值 |
| Bundle ID |
com.tw93.MoleApp |
| 最低系统 |
macOS 14.0 |
| 语言 |
Swift (SwiftUI) |
| 签名 |
Developer ID + Hardened Runtime |
| 许可证服务 |
DodoPayments |
| 自动更新 |
Sparkle 框架 |
通过 lipo -detailed_info 确认 Fat Binary 结构:
architecture x86_64
offset 16384
size 3263952
architecture arm64
offset 3293184 ← 0x324000
size 3062992
0x02 许可证系统架构分析
关键类与枚举
从 ObjC metadata 提取到的许可证相关类:
_TtC4Mole14LicenseManager ← 许可证管理核心
_TtC4Mole11LicenseGate ← 许可证门控 UI
LicenseStatus 枚举
通过逆向 StatusPill 的分支逻辑,还原出枚举的 tag 值:
| Tag |
状态 |
说明 |
| 0 |
active |
已激活 |
| 1 |
invalid |
无效 |
| 2 |
trial |
试用 |
| 3 |
expired |
已过期 |
API 端点
服务器基址:https://live.dodopayments.com
| 路径 |
方法 |
用途 |
/licenses/activate |
POST |
激活密钥 |
/licenses/validate |
POST |
验证密钥 |
/licenses/deactivate |
POST |
停用设备 |
持久化存储
- UserDefaults domain:
com.tw93.MoleApp
licenseLastValidated: 上次验证时间戳
_cachedLicenseKey: 缓存的密钥
license_key_instance_id: 设备指纹
0x03 逆向分析过程
3.1 定位许可证状态初始化
IDA 加载 arm64 slice,从字符串入手。搜索 licenseLastValidated 定位到激活成功路径,再向上追溯找到状态设置逻辑。
关键函数 sub_1000DE6B8(启动时的许可证检查)流程:
App 启动
│
▼
读取缓存的 LicenseKey (UserDefaults)
│
├── 有 key + 有 instanceId → 发起 validate 请求验证
│
└── 无 key 或无 instanceId → 设置 _status = trial ← 我们要改这里
在 0x1000DE834 处找到"无缓存 key"的分支:
1000de834 LDR X19, [X22,#0x100] ; 加载 status 值指针
1000de838 LDR X20, [X22,#0xD8] ; 加载 LicenseManager 实例
1000de83c ADRL X0, unk_1002A7F68 ; LicenseStatus 类型元数据
1000de844 ADRL X1, unk_100211010
1000de84c BL sub_10000AF58 ; 获取类型元数据
1000de850 MOV X3, X0
1000de854 LDUR X8, [X0,#-8]
1000de858 LDR X8, [X8,#0x38] ; 获取 storeEnumTag 函数指针
1000de85c MOV X0, X19 ; status 值指针
1000de860 MOV W1, #2 ; ★ tag = 2 (trial)
1000de864 MOV W2, #4 ; numCases = 4
1000de868 BLR X8 ; 调用 storeEnumTag
1000de86c MOV X0, X19
1000de870 BL sub_1000DDCB4 ; 写入 _status 并触发 UI 更新
关键发现:MOV W1, #2 就是把状态设为 trial(tag=2)。改成 MOV W1, #0 即可设为 active(tag=0)。
3.2 定位 LicenseGate 门控
LicenseGate 是一个全屏覆盖层,阻止未激活用户使用 app。通过追踪 LicenseGate 类的初始化和引用,找到 sub_1000DE56C:
1000de56c LDR W8, [X22,#0x60] ; 读取状态比较结果(0=active)
1000de570 LDR X9, [X22,#0x58] ; 读取 LicenseGate 对象指针
1000de574 CMP W8, #0 ; 是否为 active?
1000de578 CSET W8, EQ ; ★ 如果是 active,W8=1;否则 W8=0
1000de57c STRB W8, [X9,#0x70] ; 写入 gate 的 isActive 标志位
[X9, #0x70] 是 LicenseGate 对象的布尔字段:
1 = 已激活,gate 放行
0 = 未激活,显示许可证弹窗
关键发现:CSET W8, EQ 根据状态判断是否放行。改成 MOV W8, #1 即永远放行。
3.3 验证 StatusPill 逻辑
右上角的状态标签由 sub_1000EC2FC 控制:
1000ec414 MOV W1, #4 ; 比较参数
1000ec418 BLR X8 ; 调用 getEnumTag
1000ec41c MOV X8, X0 ; 保存 tag 值
1000ec420 CMP W0, #1
1000ec424 B.LE loc_1000EC460 ; tag <= 1 → 不显示 pill(active/invalid)
1000ec428 CMP W8, #2
1000ec42c B.EQ loc_1000EC490 ; tag == 2 → 显示 "license.pill.trial"
1000ec430 CMP W8, #3
1000ec434 B.NE loc_1000EC49C ; tag == 3 → 显示 "license.pill.expired"
当 _status = active (tag=0) 时,B.LE 自然成立,pill 不显示。无需额外 patch。
0x04 Patch 方案
最终只需要 2 处 patch,改动极小:
Patch 1:LicenseGate 永远放行
| 项目 |
值 |
| 虚拟地址 |
0x1000DE578 |
| 文件偏移 |
0x402578 |
| 原始指令 |
CSET W8, EQ (0x1A9F17E8) |
| 修改为 |
MOV W8, #1 (0x52800028) |
| 效果 |
LicenseGate.isActive 永远为 true |
Patch 2:_status 初始化为 active
| 项目 |
值 |
| 虚拟地址 |
0x1000DE860 |
| 文件偏移 |
0x402860 |
| 原始指令 |
MOV W1, #2 (0x52800041) |
| 修改为 |
MOV W1, #0 (0x52800001) |
| 效果 |
启动时 _status = active 而非 trial |
文件偏移计算公式
文件偏移 = arm64_slice_offset + (VA - __TEXT_vmaddr)
= 0x324000 + (VA - 0x100000000)
0x05 Patch 实施
方法一:Python 脚本(推荐)
import shutil
import struct
src = './Mole.app/Contents/MacOS/Mole'
dst = './Mole.app/Contents/MacOS/Mole' # 直接改原文件,记得先备份
# 备份
shutil.copy2(src, src + '.bak')
patches = [
# Patch 1: LicenseGate 永远放行
(0x402578, struct.pack('<I', 0x52800028)),
# Patch 2: _status = active
(0x402860, struct.pack('<I', 0x52800001)),
]
with open(dst, 'r+b') as f:
for offset, new_bytes in patches:
f.seek(offset)
old = f.read(4)
f.seek(offset)
f.write(new_bytes)
print(f'[OK] 0x{offset:06X}: {old.hex()} → {new_bytes.hex()}')
print('Patch 完成!')
方法二:十六进制编辑器
用 010 Editor / Hex Fiend 打开 Mole.app/Contents/MacOS/Mole:
| 偏移 |
搜索(原始) |
替换为 |
0x402578 |
E8 17 9F 1A |
28 00 80 52 |
0x402860 |
41 00 80 52 |
01 00 80 52 |
⚠️ 注意:第二处 41 00 80 52 在文件中可能出现多次,务必定位到 0x402860 精确修改。
重签名
Patch 后原始签名失效,需要重签:
codesign --force --deep --sign - /path/to/Mole.app
0x06 效果验证
| 验证项 |
结果 |
| 启动是否弹出许可证窗口 |
否 ✓ |
| 右上角是否显示"试用·激活" |
否(pill 不显示 = active 状态)✓ |
| 内存中 _status 值 |
active (tag=0) ✓ |
| 功能是否正常使用 |
是 ✓ |
对比测试
- 只有 Patch 1(gate 放行)→ 右上角仍显示"试用 · 激活",说明 _status 还是 trial
- 加上 Patch 2(status = active)→ pill 消失,说明 _status 真正变为 active
0x07 技术要点总结
为什么不 patch 激活网络流程?
最初尝试 patch 激活回调(0x1000DCA9C),强制跳转到成功分支。但成功分支会读取网络响应数据(license key、instance ID 等),而这些数据在网络失败时不存在,导致 访问空指针闪退。
为什么选择 patch 状态初始化?
- 不依赖网络数据:直接在枚举赋值时改 tag,不需要伪造响应
- 改动最小:只改一个立即数(2→0),一条指令
- 效果真实:内存中
_status 确实是 active,所有读取状态的地方都会得到正确结果
- 无副作用:不会触发网络请求,不会写入错误数据
Fat Binary 处理
IDA 只加载了 arm64 slice,patch 也只改 arm64 部分。在 Apple Silicon Mac 上运行时只会加载 arm64 slice,所以完全没问题。如果需要在 Intel Mac 上使用,需要额外分析 x86_64 slice。
Swift 枚举在内存中的表示
Swift 枚举使用 tag(discriminator)区分 case。通过逆向 storeEnumTag 调用和 StatusPill 的分支逻辑,还原出 tag 与状态的对应关系:
storeEnumTag(ptr, tag, numCases)
- tag 0 → active
- tag 1 → invalid
- tag 2 → trial
- tag 3 → expired
0x08 免责声明
本文仅供技术学习与研究交流,请勿用于商业用途。如果觉得软件好用,请支持正版:
官网链接:https://mole.fit