MVP结算画面 #


正文开始 #
先把边界说清楚:
- 版本只针对 Mxle
1.4.1 (build 7) - 主要记录 arm64 slice 的 patch,x86_64 只作为结构参考
- 这是一次 Swift/macOS 二进制逆向复盘,不是通用补丁模板
- hardened runtime 下改完字节还要重新签名,否则 dyld 会直接拒载
这篇文章做五件事:
- 梳理 Mxle 的 license 状态到底存在哪里
- 解释为什么只改初始化值不够
- 拆开 SwiftUI body 里的 enum dispatch 模板
- 整理最终可用的 arm64 patch 集
- 记录重签名、验证方式和踩过的坑
0. App 信息:先把样本钉住 #
逆向里最怕“我记得差不多”。版本、架构、签名、后端地址这些基础信息先固定下来,后面的偏移才有意义。
| 项 | 值 |
|---|---|
| Bundle ID | com.tw93.MxleApp |
| Version | 1.4.1 (build 7) |
| 架构 | Universal,x86_64 slice 在 fat 0x4000,arm64 slice 在 fat 0x4CC000 |
| 主语言 | Swift,可识别 _TtC4Mxle* 符号 |
| 签名 | hardened runtime,flags=0x10000,team 5EH69Y5X38 |
| Entitlements | disable-library-validation=false,allow-jit=false,sandbox 关 |
| 更新通道 | Sparkle:https://mole.fit/appcast.xml |
| License 后端 | https://live.dodopayments.com/licenses/{activate,validate,deactivate} |
这里最重要的是两点:它是 Swift 应用,并且开了 hardened runtime。前者决定了 license 状态会被 Swift enum、actor、Observable 这些结构包起来;后者决定了你改完二进制以后,不能指望原签名继续兜底。
1. 先抓主线:谁决定功能能不能用 #
Mxle 的功能解锁总闸是 LicenseGate。它是一个 Swift actor,所有受保护操作前都会查它。
内存布局里真正决定结果的是 +0x70 这一位:
+0x00 isa + refcount + actor mailbox
+0x10 _$observationRegistrar (96 字节)
+0x70 licensed: Bool
+0x78 authorizedOperationDepth: Int实例化位置:
- arm64:
0x1000FAF8C - x86_64:
0x100117353
初始化代码里会把 licensed 写成 false:
bl _swift_defaultActor_initialize
strb wzr, [x19, #0x70] ; licensed = false
str xzr, [x19, #0x78]
str x19, [global_singleton_slot]如果只看这里,很容易以为把 strb wzr 改成写 1 就结束了。问题就在于:这只是初始值。
真正麻烦的是后面还有同步逻辑会把 LicenseManager._status 回写到 LicenseGate.licensed。也就是说,初始化改成 true 只能赢第一秒,异步 sync 跑完以后,状态还是会被真实 license 结果覆盖回去。
这个同步函数在 0x1000FDC18,逻辑非常直接:
LicenseGate.shared.licensed = (LicenseManager._status.tag == 0);触发路径有 6 类:
- validate
- activate
- refresh
- deactivate
- launch
- observer
所以这条链路的核心不是“把一个 bool 改成 true”,而是同时顶住:
- 初始化值
- 状态 getter
- sync 回写
- UI 分支判断
少一个点,表现就会变成“刚启动看起来对,过一会儿又不对”。
2. LicenseManager:真实状态在哪里 #
LicenseManager 是一个 @Observable class,它包装了真实的 license 状态机。
v1.4.1 里关键 ivars 是:
+0x2B8 _status: LicenseStatus
+0x2C0 _isChecking: Bool
+0x2C8 _errorMessage: String?
+0x2D0 _cachedLicenseKey: String?
+0x2D8 provider: LicenseServerProvider
+0x2E0 _$observationRegistrar其中 _status 是 LicenseStatus。这个 enum 是典型的 single-payload enum:
case active(Date) // payload,tag = 0
case trial
case expired
case inactive
case invalid内存布局:
- size:
10 - stride:
16 .active的 payload:8 字节Date.timeIntervalSinceReferenceDate- tag 字节:offset
8
关键点只有一个:tag 为 0 时就是 active。
后面的 patch 基本都围绕这句话展开:要么让 _status getter 返回 tag 0,要么让查询 enum tag 的地方直接拿到 0,要么让 sync 无论如何都写 licensed = true。
3. SwiftUI 页面怎么判断“激活” #
Mxle 里 license 相关的 SwiftUI body 基本都用同一套模板读取 enum tag:
LDUR X8, [X0, #-8] ; X8 = metadata - 8 = VWT 指针
LDR X8, [X8, #0x30] ; X8 = VWT[getEnumTagSinglePayload]
MOV X0, status_buf
MOV W1, #4 ; 4 个空 case
BLR X8 ; W0 = enum tag这 5 条指令共 20 字节,模式很固定。真正决定 UI 分支的是最后的返回值 W0。
所以对这些 view body 来说,最省事的做法不是重建 Swift enum,而是把:
BLR X8改成:
MOVZ W0, #0这样 UI 就会认为当前状态是 .active。
涉及 4 处 dispatch:
- pill body:
sub_100110880,主界面右上角徽章 - ActivationOverlay:
sub_100001F64,设置里的 License Tab,以及点击 pill 弹出的面板 - OnboardingWindow inner:
sub_10016C18C,启动引导窗内层 - SettingsOverlay detail:
sub_100236758,license 行的 detail label
这里有个容易误判的点:LicenseStatus VWT[+0x30] 的 generic thunk 也可以改成永远返回 0,但 UI dispatch 并不全走它。也就是说,改 thunk 是保险,不是主菜。只改它,pill 还是可能显示“试用 · 激活”。
4. 最小可用 patch 集:别只改初始化 #
最终 arm64 patch 集是 11 处,大约 124 字节。
fat offset vmaddr size 作用
─────────────────────────────────────────────────────────
0x5C6FB0 0x1000FAFB0 8 B C LicenseGate.init: licensed=1
0x5C9C24 0x1000FDC24 4 B D sync: cset -> mov w8,#1
0x5C8F38 0x1000FCF38 16 B F status.getter: 返回 .active(date)
0x5C923C 0x1000FD23C 16 B G status.getter indirect thunk: 同 F
0x5CB6D8 0x1000FF6D8 12 B K LicenseStatus VWT[+0x30] thunk -> mov w0,#0; ret
0x6288FC 0x10015C8FC 4 B L NOP 启动期 OnboardingWindow 触发 TBZ
0x62E614 0x100162614 4 B M LicenseManager.init: mov w1,#1 -> mov w1,#0
0x5DC9E4 0x1001109E4 48 B Q pill body case 0/1 -> "license.allUnlocked"
0x5DC99C 0x10011099C 4 B R pill BLR X8 -> MOVZ W0,#0
0x638710 0x10016C710 4 B S OnboardingWindow BLR X8 -> MOVZ W0,#0
0x4CE35C 0x10000235C 4 B T ActivationOverlay BLR X8 -> MOVZ W0,#0如果用一句话概括:
- C/M/F/G 处理状态源头
- D 顶住异步 sync 回写
- K/R/S/T 处理 enum tag 查询
- L 处理启动引导
- Q 处理 pill 的显示文案
5. 关键 patch 细节 #
5.1 C:LicenseGate 初始化直接写 licensed=1 #
原字节:
0x5C6FB0: 7F C2 01 39 7F 3E 00 F9对应指令:
strb wzr, [x19,#0x70]
str xzr, [x19,#0x78]改成:
28 00 80 52 68 C2 01 39对应指令:
mov w8, #1
strb w8, [x19,#0x70]authorizedOperationDepth (+0x78) 不需要专门写回 0。对象由 _swift_allocObject 分配时已经零填充,这里省下来的空间刚好拿来写 licensed=1。
5.2 D:sync 无论如何都写 true #
sync 里原本通过 cset 判断 _status.tag == 0:
0x5C9C24: E8 17 9F 1A cset w8, eq改成固定写 1:
28 00 80 52 mov w8, #1后面的 strb w8, [x?, #0x70] 不动。这样即使异步 validate、refresh 或 observer 触发,回写到 LicenseGate.licensed 的也还是 true。
这一步非常关键。没有 D,单独改 C 通常只能撑到第一次 sync。
5.3 F/G:status.getter 直接返回 .active #
F = sub_1000FCF38 是直调版,G = sub_1000FD23C 是间接 thunk 版。调用方不同,所以两个都要改。
原函数 prologue 16 字节:
ff 43 01 d1 f6 57 02 a9 f4 4f 03 a9 fd 7b 04 a9直接改写为:
09 40 e8 d2 09 01 00 f9 1f 05 00 f9 c0 03 5f d6对应逻辑:
mov x9, #0x4200<<48 ; date in year ~2273
str x9, [x8] ; payload 写到 sret(X8)
str xzr, [x8, #8] ; tag = 0 (.active)
ret这里不是“骗 UI”,而是把状态 getter 自己就变成 active。这样依赖 _status 的逻辑能拿到一致结果。
5.4 K:enum tag generic thunk 永远返回 0 #
sub_1000FF6D8 本来只是个壳,里面会跳到 _swift_getEnumTagSinglePayloadGeneric。
原字节:
03 00 00 90 63 90 1b 91 86 d0 08 14改成:
00 00 80 52 c0 03 5f d6 1f 20 03 d5对应指令:
mov w0, #0
ret
nop它能覆盖所有走这个 VWT 槽的 enum tag 查询。但前面说过,SwiftUI body 的 dispatch 不完全走这里,所以 K 只是保险。单独改 K 不够。
5.5 M:LicenseManager 初始化为 active #
初始化时原来写的是 .trial:
0x62E614: 21 00 80 52 mov w1, #1改成:
01 00 80 52 mov w1, #0这里的 w1 是 storeEnumTagSinglePayload(buf, tag=1, ...) 的 tag 参数。tag 从 1 改成 0,就相当于初始状态从 .trial 改成 .active。
5.6 L:跳过启动期 OnboardingWindow #
原代码在某个 license 检查之后,用 TBZ 决定要不要弹引导:
0x6288FC: c8 01 00 36 tbz w8, #0, +0x38改成:
1f 20 03 d5 nop这样启动期不会因为那条分支再弹 OnboardingWindow。
5.7 Q:pill 不要消失,要显示“所有工具已解锁” #
这个点很有意思。原代码里 active 分支不是显示“已激活”,而是调一个无副作用函数后返回 (0, 0)。换句话说,SwiftUI 设计上 active 状态的 pill 不显示文字。
但目标不是把 pill 藏起来,而是显示“所有工具已解锁”。所以这里把 case 0/1 改写成和 case 2/3/4 类似的字符串构造路径,加载:
license.allUnlocked中文翻译就是:
所有工具已解锁新指令模仿 case 3 的字符串加载模板:
ADRP X8, page_of("license.allUnlocked")
ADD X8, X8, #offset
SUB X8, X8, #0x20
MOVZ X9, #0x11
MOVK X9, #0xD000, LSL #48
ADRP X2, page_of(_swiftEmptyArrayStorage_ptr)
LDR X2, [X2, #offset]
ADD X0, X9, #2
ORR X1, X8, #0x8000_0000_0000_0000
B loc_100110A40
NOP
NOP48 字节序列,从 fat offset 0x5DC9E4 开始:
68 16 00 90 08 41 0B 91 08 81 00 D1 29 02 80 D2
09 00 FA F2 E2 17 00 F0 42 64 41 F9 20 09 00 91
01 01 41 B2 0E 00 00 14 1F 20 03 D5 1F 20 03 D5这一步解决的是用户可见体验:不是“没有未激活提示”,而是明确显示已解锁。
5.8 R/S/T:view body dispatch 强制 active #
三个 view body 都是同一个改法:
原: 00 01 3F D6 BLR X8
新: 00 00 80 52 MOVZ W0, #0位置分别是:
- R:
0x5DC99C,pill body 内 - S:
0x638710,OnboardingWindow 内,sub_10016C18C - T:
0x4CE35C,ActivationOverlay 内,sub_100001F64
R 配合 Q,让 pill 显示“所有工具已解锁”。S/T 让 SwiftUI _ConditionalContent 走 active storage 分支。
6. 踩过的坑:能崩的都很合理 #
这些失败 patch 很值得保留,因为它们说明 Swift 二进制不能只按“看起来差不多”处理。
| 试过的 patch | 失败原因 |
|---|---|
H/I:activate completion CBZ X20, +0x28 -> B +0x28 |
强行忽略 error 走 success 路径,后续代码 deref nil error 描述符,直接崩 |
N:pill body 入口 mov x0,#0; mov x1,#0; ret |
暴力空白,pill 完全消失。目标是显示已激活,不是藏掉入口 |
P:NOP 5 处 _cachedLicenseKey 空检查 |
破坏 Swift Optional 的 retain/release 平衡,启动直接 crash |
| 单独 K | UI dispatch 走另一个 metadata 的 VWT,K 影响不到,pill 还是显示“试用 · 激活” |
| 单独 C,不做 D | 30 秒内 sync 异步任务跑完,把 licensed 写回 false |
这几个坑归根到底是同一个问题:Swift 的状态、生命周期和 UI 分支不是一根线。只拦其中一处,通常会被另一处校正回来;硬跳 success,又容易破坏对象和 Optional 的内存语义。
7. ad-hoc 重签名:字节改了,加载也要过 #
hardened runtime 下,改完二进制字节以后,原签名就不可信了。dyld 会拒载,所以必须重新签名,并加上必要 entitlements。
用到的例外:
<key>com.apple.security.cs.disable-library-validation</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key><true/>
<key>com.apple.security.cs.disable-executable-page-protection</key><true/>签名顺序也踩过坑:必须先签嵌套 bundle,再签主 app。否则 codesign 会报:
code object is not signed at all命令:
find Mxle.app -type d \( -name '*.framework' -o -name '*.xpc' -o -name '*.appex' \) \
-exec codesign --force --sign - --options runtime --timestamp=none {} \;
codesign --force --sign - --options runtime --timestamp=none \
--entitlements entitlements.plist Mxle.app
xattr -cr Mxle.appcodesign --verify --deep 可能会因为 Sparkle framework 内部某些官方签名资源不匹配而报 warning。ad-hoc 模式下,macOS 仍然能加载,不影响运行。
8. 怎么确认 patch 真的生效 #
最核心的验证点还是 LicenseGate.licensed 的消费点。看 0x100028b58:
ldr x8, [x22, #0x190] ; LicenseGate.shared
ldrb w9, [x8, #0x70] ; licensed
strb w9, [x22, #0x21d]
tbnz w9, #0, 放行 ; licensed=1 直接跳过 depth++
ldr x9, [x8, #0x78]
adds x9, x9, #1 ; 否则未授权计数 +1这里的判断很干净:licensed=1 时,tbnz 直接跳到放行分支。它不是 UI 文字上的“看起来激活”,而是功能 gate 的消费点真的拿到了 1。
至于 pill 当前显示什么,运行时挂 lldb 不太现实:hardened runtime 默认拒 attach。更直接的办法是:
- 用 user-visible 测试确认界面显示
- 用
otool反汇编 patch 后的 binary - 对照 fat offset 和 vmaddr 确认字节确实写对
9. 我的结论:这次关键不在“改几个字节” #
这次 Mxle 1.4.1 的 license 逆向,最容易误判的地方是把它当成一个普通 bool patch。
实际链路更像这样:
LicenseManager._status
↓
status.getter / enum tag
↓
sync
↓
LicenseGate.licensed
↓
功能 gate + SwiftUI body dispatch如果只改 LicenseGate.init,sync 会把它盖回去;如果只改 generic thunk,UI 可能不走那条 metadata;如果暴力跳 success,又会踩 Swift Optional 和对象生命周期的坑。
最后能稳定工作的原因,不是某个偏移“神奇”,而是 patch 集把四层都对齐了:
- 状态源头返回 active
- sync 不再回写 false
- UI dispatch 直接走 active 分支
- 重新签名让 macOS 接受修改后的二进制
逆向这种活最舒服的状态,其实不是找到一个能跑的字节,而是能解释它为什么会跑,以及为什么少一个点就会翻车。
10. 文件 / 偏移速查 #
fat header
x86_64 slice -> fat 0x4000 (vmaddr base 0x100000000)
arm64 slice -> fat 0x4CC000 (vmaddr base 0x100000000)
slice_off = vmaddr - 0x100000000
fat_off = 0x4CC000 + slice_off
arm64 关键符号:
LicenseGate metadata accessor 0x1000FB98C
LicenseGate.shared 全局槽 0x100446A08
LicenseGate init 0x1000FAF8C
sync (LicenseManager -> Gate) 0x1000FDC18
LicenseStatus VWT[+0x30] thunk 0x1000FF6D8
字符串:
license.allUnlocked 0x1003DC2D0 "所有工具已解锁"
license.pill.trial 0x1003E1EF0 "试用 · 激活"
license.pill.expired 0x1003E1ED0 "许可证已过期"
license.pill.issue 0x1003E1EB0 "许可证异常"
settings.license.activeDetail 0x1003E6F50 "已激活。停用后可换到另一台 Mac。"
Mxle/OnboardingWindow.swift 0x1003E31E0 (debug 字串)
Mxle/ActivationOverlay.swift 0x1003DC1C0 (debug 字串)