跳过正文

Mac Mxle授权机制逆向复盘

·9 分钟· loading
asm2apex
作者
asm2apex
目录

MVP结算画面
#

Mxle
Mxle Cracked

正文开始
#

先把边界说清楚:

  • 版本只针对 Mxle 1.4.1 (build 7)
  • 主要记录 arm64 slice 的 patch,x86_64 只作为结构参考
  • 这是一次 Swift/macOS 二进制逆向复盘,不是通用补丁模板
  • hardened runtime 下改完字节还要重新签名,否则 dyld 会直接拒载

这篇文章做五件事:

  1. 梳理 Mxle 的 license 状态到底存在哪里
  2. 解释为什么只改初始化值不够
  3. 拆开 SwiftUI body 里的 enum dispatch 模板
  4. 整理最终可用的 arm64 patch 集
  5. 记录重签名、验证方式和踩过的坑

0. App 信息:先把样本钉住
#

逆向里最怕“我记得差不多”。版本、架构、签名、后端地址这些基础信息先固定下来,后面的偏移才有意义。

Bundle ID com.tw93.MxleApp
Version 1.4.1 (build 7)
架构 Universal,x86_64 slice 在 fat 0x4000arm64 slice 在 fat 0x4CC000
主语言 Swift,可识别 _TtC4Mxle* 符号
签名 hardened runtime,flags=0x10000,team 5EH69Y5X38
Entitlements disable-library-validation=falseallow-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

其中 _statusLicenseStatus。这个 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

这里的 w1storeEnumTagSinglePayload(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
NOP

48 字节序列,从 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.app

codesign --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 字串)