Typora 激活逻辑逆向 (macOS)

用 IDA Pro 分析 Typora macOS 版(ARM64 Mach-O)的激活逻辑,定位核心类 LicenseManager,厘清「在线激活 + RSA 验签 + AES 本地存储」的完整流程,最终只改 12 字节 + ad-hoc 重签名,完整绕过所有许可证检查。

基本信息

  • 文件: app (macOS Mach-O 64-bit, ARM64)
  • 工具: IDA Pro
  • 目标: 分析并绕过 Typora 的激活逻辑

分析过程

1. 定位核心类

通过字符串搜索 activatelicensefillLicense 等关键词,定位到核心类 LicenseManager,其中包含完整的激活逻辑。

2. 激活流程概览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
用户输入 email + license code
|
v
quickValidateLicense: (本地格式校验)
|
v
POST https://store.typora.io/api/client/activate (在线验证)
|
v
verifySig: (RSA-2048 + SHA256 验证服务器返回签名)
|
v
writeLicenseInfo:with:from: (AES-256加密写入本地)
|
v
postNotification -> "fillLicense" -> UI移除UNREGISTERED角标

3. 许可证格式

-[LicenseManager quickValidateLicense:] (0x10006B464) 执行本地校验:

  • 正则: ^([A-Z0-9]{6}-){3}[A-Z0-9]{6}$,即 XXXXXX-XXXXXX-XXXXXX-XXXXXX
  • 字符集: L23456789ABCDEFGHJKMNPQRSTUVWXYZ(31字符,排除 0/1/I/O 等易混淆字符)
  • 校验和: genCheckSum: 对去掉连字符的字符串,隔位取字符在字符集中的索引累加,对 31 取模生成 2 位校验字符,与末尾 2 位比对

4. 在线激活

-[LicenseManager activate:with:force:callback:] (0x10006D284) 构造 POST 请求:

参数 含义
v 版本号
license 序列号
email 邮箱
l 设备标签 (deviceLabel)
f 设备指纹 (fingerPrintNew)
u 用户ID
force 是否强制激活
type 许可证类型

服务器返回 JSON 中 code 字段决定结果:

code 含义
0 成功,调用 writeLicenseInfo:with:from: 保存
1 设备数超限,弹窗选择是否强制激活
-1 / -2 失败

5. 签名验证

-[LicenseManager verifySig:] (0x10006BFF0):

  1. 取返回字典所有 key 按字母排序
  2. 排除 sigoldSigoldFinger
  3. 其余字段值按序拼接
  4. 调用 +[Crypto verify:with:] 用硬编码的 RSA-2048 公钥 + SHA-256 验签

6. 本地存储

  • 路径: {AppSupport}/.{fingerPrintNew}(隐藏文件)
  • 加密: AES-256-ECB, PKCS7 padding
  • 密钥: SHA256(IOPlatformUUID + "typora-license") 前 32 字节
  • 数据: NSKeyedArchiver 序列化的 licenseDict 字典

7. 试用期与 UI 控制

  • dayRemainsdayRemainsFrom:(15): 15天免费试用
  • cannotContonueUse:: 超过 20天 且无许可证则强制要求激活
  • postNotification: 根据 _hasLicense ivar 发送 fillLicenseunfillLicense 通知
    • fillLicense → JS 执行 File.option.hasLicense = true,移除 UNREGISTERED 角标
    • unfillLicense → JS 执行 File.option.hasLicense = false,显示角标

绕过方案

为什么不能走正常激活?

激活流程是 在线验证:客户端把序列号 POST 到 store.typora.io,服务器返回的数据带有 RSA-2048 签名,客户端用硬编码的公钥验签。我们没有私钥,无法伪造合法的服务器响应,所以必须从 本地判断逻辑 入手。

背景知识:ObjC 的”方法”与”成员变量”

在分析 patch 之前,需要理解 Objective-C 中一个关键区别:

1
2
3
4
5
@interface LicenseManager {
NSNumber *_hasLicense; // 成员变量 (ivar),存储在对象内存中
}
- (BOOL)hasLicense; // 方法,被其他代码调用
@end
  • _hasLicense (ivar): 对象里的一个字段,存的是 @(YES)@(NO),由 readLicenseInfo 在启动时根据磁盘上的许可证文件来设置。
  • hasLicense (方法): 一个可以被外部调用的函数,原本会读取 ivar 并返回其值。

虽然名字相似,但它们在不同的地方被使用。两个都要 patch 才能完整绕过。


Patch 1: hasLicense 方法始终返回 true

函数: -[LicenseManager hasLicense]
地址: 0x100069FB8

原始伪代码

1
2
3
4
5
6
7
8
// 原始逻辑:从对象的 _hasLicense 成员变量读取实际值
- (BOOL)hasLicense {
NSNumber *val = self->_hasLicense; // 偏移 0x18 处加载
if (val == nil) {
return NO; // 没有值,返回 false
}
return [val boolValue]; // 返回实际的布尔值
}

原始汇编 (ARM64)

1
2
3
0x100069FB8: LDR X0, [X0, #0x18]   ; X0 = self->_hasLicense (从对象偏移0x18处加载)
0x100069FBC: CBZ X0, ret_false ; 如果 X0 == 0 (nil),跳转到返回false的分支
... ; 后续调用 [_hasLicense boolValue] 并返回

ARM64 小课堂:

  • LDR X0, [X0, #0x18]:从地址 X0+0x18 读取 8 字节到 X0。在 ObjC 方法中,X0 就是 self,0x18 是 _hasLicense 这个 ivar 在对象内存布局中的偏移。
  • CBZ X0, label:Compare and Branch if Zero。如果 X0 为 0(即 _hasLicense 为 nil),就跳转。

Patch 后伪代码

1
2
3
4
// patch 后:无论如何都返回 true
- (BOOL)hasLicense {
return YES; // 直接返回 1,不再读取 ivar
}

Patch 后汇编

1
2
0x100069FB8: MOV W0, #1    ; W0 = 1(函数返回值 = true)
0x100069FBC: RET ; 直接返回

ARM64 小课堂:

  • MOV W0, #1:将立即数 1 写入 W0 寄存器。ARM64 中函数返回值放在 X0/W0 中,W0 是 X0 的低 32 位。
  • RET:函数返回,等价于 BX LR

具体字节修改

1
2
3
4
5
6
文件偏移: 0x100069FB8 (需根据 IDA 基址 0x100000000 换算实际文件偏移)

原始字节: 00 0C 40 F9 40 00 00 B4
修改字节: 20 00 80 52 C0 03 5F D6
^^^^^^^^^^^ ^^^^^^^^^^^
MOV W0, #1 RET

这个 patch 影响了哪些调用者?

hasLicense 方法在以下两个关键位置被调用:

调用者 1: cannotContonueUse: — 控制是否强制退出 app

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 伪代码
- (BOOL)cannotContonueUse:(BOOL)strict {
if (!self._appStarted && !strict)
return NO;

// ★ 这里调用 hasLicense 方法
if ([self hasLicense]) // patch 后始终为 YES
return NO; // → 永远走这条路,不会强制退出

// 以下代码永远不会被执行了
if ([self dayRemainsFrom:20] < 1)
return YES; // 超过20天 → 强制退出
return NO;
}

patch 前:试用期超过 20 天且没有许可证 → 弹窗强制退出。
patch 后:永远不会强制退出

调用者 2: windowDidLoad — 控制是否注入 UNREGISTERED 角标的 JS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 伪代码 (TyWindowController)
- (void)windowDidLoad {
// ... 窗口初始化 ...

// 注册通知监听
[[NSNotificationCenter defaultCenter]
addObserver:self selector:@selector(onFillLicense)
name:@"fillLicense" object:nil];
[[NSNotificationCenter defaultCenter]
addObserver:self selector:@selector(onUnfillLicense)
name:@"unfillLicense" object:nil];

// ★ 这里调用 hasLicense 方法
LicenseManager *mgr = [LicenseManager sharedInstance];
if (![mgr hasLicense]) { // patch 后始终为 YES
// 以下代码永远不会被执行了
NSString *js = [self jsWhenUnfillLicense];
// 这段 JS 会在页面上注入一个 "UNREGISTERED ×" 的浮动角标
[bridge presetScriptOnEnd:js];
}
}

patch 前:窗口加载时,如果没有许可证就注入 UNREGISTERED 角标。
patch 后:窗口加载时永远不注入角标


Patch 2: _hasLicense 成员变量始终设为 true

函数: -[LicenseManager readLicenseInfo] 的内部逻辑
地址: 0x10006B898

为什么还需要这个 patch?

Patch 1 只改了 hasLicense 方法的返回值。但 _hasLicense 成员变量仍然会被 readLicenseInfo 设置为真实值。

程序中有些地方 不调用方法,而是直接读取 ivar,最关键的就是 postNotification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 伪代码: postNotification 直接读取 _hasLicense ivar,不走 hasLicense 方法
- (void)postNotification {
BOOL licensed = [self->_hasLicense boolValue]; // ★ 直接读 ivar!

if (licensed) {
// 发送 "fillLicense" 通知 → UI 移除角标
[[NSNotificationCenter defaultCenter]
postNotificationName:@"fillLicense" object:nil];
// 在所有窗口执行 JS
[delegate execOnEachWindow:@"File.option.hasLicense = true"];
} else {
// 发送 "unfillLicense" 通知 → UI 显示角标
[[NSNotificationCenter defaultCenter]
postNotificationName:@"unfillLicense" object:nil];
// 在所有窗口执行 JS
[delegate execOnEachWindow:@"File.option.hasLicense = false"];
}
}

如果只做 Patch 1,_hasLicense ivar 仍然是 @(NO)postNotification 还是会走 else 分支,显示 UNREGISTERED 角标。

原始伪代码:readLicenseInfo 中设置 _hasLicense 的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- (void)readLicenseInfo {
// 1. 从磁盘读取加密的许可证文件并解密
NSString *path = [self recordFilePathNew];
self._licenseDict = [self _readLicenseInfo:path];

// 2. 检查字典里有没有 "email" 和 "license" 两个 key
BOOL v18;
id email = [self._licenseDict objectForKey:@"email"];
if (email != nil) {
id license = [self._licenseDict objectForKey:@"license"];
v18 = (license != nil); // 两个都存在才算有许可证
} else {
v18 = NO;
}

// 3. ★★★ 这里就是 patch 点 ★★★
// v19 = [self hasLicense] 的返回值 (patch 1 后始终为 1)
BOOL v19 = [self hasLicense];

// 用 v18 的值设置 _hasLicense ivar
// → v18 取决于磁盘上有没有有效的许可证文件
// → 没有文件的话 v18 = NO → _hasLicense = @(NO)
self._hasLicense = @(v18); // ← 我们要让这里始终写入 @(YES)

// 4. 如果状态发生变化,发送通知刷新 UI
if (v18 != v19) {
[self postNotification]; // 根据 _hasLicense 的值决定发什么通知
}
}

原始汇编 (定位到关键行)

readLicenseInfo 函数中,从 0x10006B830 开始是检查 email/license 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
; --- 检查 email ---
0x10006B830: LDR X0, [X19, #8] ; X0 = self->_licenseDict
0x10006B834: ADRL X2, "email" ; X2 = @"email"
0x10006B83C: BL objectForKey: ; X0 = [dict objectForKey:@"email"]
0x10006B848: MOV X21, X0 ; X21 = email 值
0x10006B84C: CBZ X0, no_email ; email 为 nil → 跳转

; --- 检查 license ---
0x10006B850: LDR X0, [X19, #8] ; X0 = self->_licenseDict
0x10006B854: ADRL X2, "license" ; X2 = @"license"
0x10006B85C: BL objectForKey: ; X0 = [dict objectForKey:@"license"]
0x10006B868: CMP X0, #0 ; license 是否为 nil?
0x10006B86C: CSET W20, NE ; W20 = (license != nil) ? 1 : 0
0x10006B874: B continue ; 跳转到设置 _hasLicense

; --- email 为空的分支 ---
no_email:
0x10006B878: MOV W20, #0 ; W20 = 0 (没有 email → 没有许可证)

; --- 设置 _hasLicense ---
continue:
0x10006B884: MOV X0, X19 ; X0 = self
0x10006B888: BL hasLicense ; 调用 hasLicense 方法 (返回值存 X0)
0x10006B88C: MOV X21, X0 ; X21 = v19 (旧状态,用于后面比较)
0x10006B890: ADRP X8, classRef_NSNumber ; 加载 NSNumber 类
0x10006B894: LDR X0, [X8, ...] ; X0 = NSNumber class

; ★★★ 就是这条指令 ★★★
0x10006B898: MOV X2, X20 ; X2 = W20 (0 或 1),作为 numberWithBool: 的参数
0x10006B89C: BL numberWithBool: ; [NSNumber numberWithBool: X2]
; 后续把返回值存入 self->_hasLicense (偏移 0x18)
0x10006B8A8: LDR X8, [X19, #0x18] ; 读旧值
0x10006B8AC: STR X0, [X19, #0x18] ; _hasLicense = @(X2)

ARM64 小课堂:

  • CSET W20, NE:Conditional SET。如果前一条 CMP 的结果是”不等于”(即 license != nil),则 W20=1,否则 W20=0。
  • MOV X2, X20:在 ObjC 的 objc_msgSend 调用约定中,X0=self, X1=selector, X2=第一个参数。所以这条指令把 W20 的值传给 numberWithBool: 的参数。

Patch 内容

1
2
3
4
5
; 原始
0x10006B898: MOV X2, X20 ; X2 = W20 (email和license是否都存在)

; 修改为
0x10006B898: MOV X2, #1 ; X2 = 1 (强制为 true,无论有没有许可证文件)

具体字节修改

1
2
3
4
文件偏移: 0x10006B898 (需根据 IDA 基址换算)

原始字节: E2 03 14 AA (MOV X2, X20)
修改字节: 22 00 80 D2 (MOV X2, #1)

Patch 后伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)readLicenseInfo {
NSString *path = [self recordFilePathNew];
self._licenseDict = [self _readLicenseInfo:path];

// 检查 email/license 是否存在 (这部分代码还在,但结果被忽略了)
// ...

BOOL v19 = [self hasLicense]; // = 1 (Patch 1 的效果)

// ★ patch 后:不管磁盘上有没有许可证文件,都设为 YES
self._hasLicense = @(YES); // 原来是 @(v18),现在强制为 @(YES)

// v18 被强制为 1,v19 也是 1 (Patch 1),所以 1 != 1 为 false
// → postNotification 不会被调用
// → 但即使被调用,_hasLicense 也是 YES,会发 "fillLicense" 通知
if (1 != 1) { // 永远不成立
[self postNotification];
}
}

两处 Patch 的配合关系

程序中有 两套机制 检查许可证状态,分别依赖方法和 ivar,所以两处都要改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
                 ┌─────────────────────────────────┐
│ LicenseManager 对象 │
│ │
│ _hasLicense (ivar) ←── Patch 2 │
│ │ │
│ hasLicense() 方法 ←── Patch 1 │
└───┬───────────┬─────────────────┘
│ │
┌───────────┘ └───────────┐
▼ ▼
┌─── 调用方法的地方 ───┐ ┌─── 读ivar的地方 ───┐
│ │ │ │
│ cannotContonueUse: │ │ postNotification │
│ → 是否强制退出 │ │ → 发什么通知 │
│ │ │ → JS设什么值 │
│ windowDidLoad │ │ │
│ → 是否注入角标JS │ │ getLicensePanelUrl │
│ │ │ → 面板显示什么状态 │
└──────────────────────┘ └─────────────────────┘
检查点 依赖什么 Patch 前 Patch 后
cannotContonueUse: 是否强制退出 调用 hasLicense 方法 超20天无许可证→退出 永远不退出 (Patch 1)
windowDidLoad 是否注入角标 调用 hasLicense 方法 无许可证→注入JS角标 永远不注入 (Patch 1)
postNotification 发什么通知 直接读 _hasLicense ivar 无许可证→发unfillLicense 发fillLicense (Patch 2)
getLicensePanelUrlParams: 面板状态 直接读 _hasLicense ivar 状态=未激活 状态=已激活 (Patch 2)
前端JS File.option.hasLicense 由通知触发设置 false→显示角标 true→不显示 (Patch 2)

总结

两处共修改 12 字节 (8+4),完整绕过所有激活检查:

1
2
Patch 1 @ 0x100069FB8:  00 0C 40 F9 40 00 00 B4  →  20 00 80 52 C0 03 5F D6
Patch 2 @ 0x10006B898: E2 03 14 AA → 22 00 80 D2

重新签名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
Last login: Sat Jun 13 16:08:02 on ttys003
╭─ /Applications/Typora.app/Contents/MacOS ──────────────────────────────────────────────────────────── ✔  16:23:29
╰─ ./Typora ; echo "退出码: $?"
[1] 12144 killed ./Typora
退出码: 137

╭─ /Applications/Typora.app/Contents/MacOS ──────────────────────────────────────────────────────────── ✔  16:23:31
╰─ codesign --remove-signature ./Typora

╭─ /Applications/Typora.app/Contents/MacOS ──────────────────────────────────────────────────────────── ✔  16:23:42
╰─ codesign -s - --force ./Typora

╭─ /Applications/Typora.app/Contents/MacOS ──────────────────────────────────────────────────────────── ✔  16:23:47
╰─ codesign -dv --verbose=4 ./Typora
Executable=/Applications/Typora.app/Contents/MacOS/Typora
Identifier=abnerworks.Typora
Format=app bundle with Mach-O universal (x86_64 arm64)
CodeDirectory v=20400 size=3306 flags=0x2(adhoc) hashes=97+3 location=embedded
VersionPlatform=1
VersionMin=720896
VersionSDK=1704448
Hash type=sha256 size=32
CandidateCDHash sha256=555f56c824c22affc705d31a399394e2525659c1
CandidateCDHashFull sha256=555f56c824c22affc705d31a399394e2525659c1aa17d7ce01dc1ba1e84a38de
Hash choices=sha256
CMSDigest=555f56c824c22affc705d31a399394e2525659c1aa17d7ce01dc1ba1e84a38de
CMSDigestType=2
Executable Segment base=0
Executable Segment limit=786432
Executable Segment flags=0x1
Page size=16384
CDHash=555f56c824c22affc705d31a399394e2525659c1
Signature=adhoc
Info.plist entries=35
TeamIdentifier=not set
Sealed Resources version=2 rules=13 files=507
Internal requirements count=0 size=12

╭─ /Applications/Typora.app/Contents/MacOS ──────────────────────────────────────────────────────────── ✔  16:23:53
╰─ ./Typora ; echo "退出码: $?"
2026-06-13 16:23:55.749 Typora[12268:8699581] Could not find image named 'NSImageNameTouchBarTextListTemplate'.
2026-06-13 16:23:55.851 Typora[12268:8699581] -[WKWebView _setDrawsTransparentBackground:] is deprecated and should not be used.
2026-06-13 16:24:50.766 Typora[12268:8699581] TSM AdjustCapsLockLEDForKeyTransitionHandling - _ISSetPhysicalKeyboardCapsLockLED Inhibit
2026-06-13 16:24:50.768 Typora[12268:8699581] error messaging the mach port for IMKCFRunLoopWakeUpReliable
退出码: 0

╭─ /Applications/Typora.app/Contents/MacOS ──────────────────────────────────────────────────── ✔  1m 8s  16:25:03
╰─

退出码137一般就是签名问题。

退出码 137 = 128 + 9 = SIGKILL,在 macOS 上这基本就是内核因为签名校验失败直接把进程干掉了(AMFI 把它 kill 了)。修复就是重新签名:

直接 ad-hoc 重签:

1. 移除失效的旧签名

1
codesign --remove-signature 你的程序

2. 用 ad-hoc 签名(-s - 表示 ad-hoc,不需要证书)

1
codesign -s - --force 你的程序

3. 验证

1
2
codesign -dv --verbose=4 你的程序
./你的程序 ; echo "退出码: $?"

如果第 2 步报错,或者程序带 Hardened Runtime,加上 entitlements/runtime 选项:

1
codesign -s - --force --options runtime 你的程序

如果是 .app 应用包

要对包里真正的可执行文件签,而且最好整包 --deep 重签:

1
codesign -s - --force --deep 你的程序.app

还可能挡路的两个东西

去掉下载隔离属性(否则 Gatekeeper 也会拦):

1
xattr -cr 你的程序

arm64 (Apple Silicon) 上修改过的二进制,确认架构无误:

1
file 你的程序

按顺序跑完后再 ./你的程序 ; echo "退出码: $?"

  • 退出码变 0 或正常运行 → 修好了。
  • 仍是 137 → 签名没生效,把 codesign -dv --verbose=4 的输出贴给我看。
  • 变成 139 (SIGSEGV) → 签名过了,但你 patch 的字节改坏了逻辑,那就得回头核对 patch 位置/字节,我可以帮你在 IDA 里对一下。

另外提醒一句:重签名前如果用了 strip 或别的工具,确保 patch 已经写进文件了。先 codesign --remove-signature 再签是关键,直接覆盖签有时不生效。