用 IDA Pro 分析 Typora macOS 版(ARM64 Mach-O)的激活逻辑,定位核心类 LicenseManager,厘清「在线激活 + RSA 验签 + AES 本地存储」的完整流程,最终只改 12 字节 + ad-hoc 重签名,完整绕过所有许可证检查。
基本信息
文件 : app (macOS Mach-O 64-bit, ARM64)
工具 : IDA Pro
目标 : 分析并绕过 Typora 的激活逻辑
分析过程 1. 定位核心类 通过字符串搜索 activate、license、fillLicense 等关键词,定位到核心类 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):
取返回字典所有 key 按字母排序
排除 sig、oldSig、oldFinger
其余字段值按序拼接
调用 +[Crypto verify:with:] 用硬编码的 RSA-2048 公钥 + SHA-256 验签
6. 本地存储
路径 : {AppSupport}/.{fingerPrintNew}(隐藏文件)
加密 : AES-256-ECB, PKCS7 padding
密钥 : SHA256(IOPlatformUUID + "typora-license") 前 32 字节
数据 : NSKeyedArchiver 序列化的 licenseDict 字典
7. 试用期与 UI 控制
dayRemains → dayRemainsFrom:(15): 15天 免费试用
cannotContonueUse:: 超过 20天 且无许可证则强制要求激活
postNotification: 根据 _hasLicense ivar 发送 fillLicense 或 unfillLicense 通知
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 - (BOOL )hasLicense { NSNumber *val = self ->_hasLicense; if (val == nil ) { return NO ; } return [val boolValue]; }
原始汇编 (ARM64) 1 2 3 0x100069FB8 : LDR X0 , [X0 , #0x18 ] 0x100069FBC : CBZ X0 , ret_false ...
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 - (BOOL )hasLicense { return YES ; }
Patch 后汇编 1 2 0x100069FB8 : MOV W0 , #1 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 ; if ([self hasLicense]) return NO ; if ([self dayRemainsFrom:20 ] < 1 ) return YES ; 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 - (void )windowDidLoad { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector (onFillLicense) name:@"fillLicense" object:nil ]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector (onUnfillLicense) name:@"unfillLicense" object:nil ]; LicenseManager *mgr = [LicenseManager sharedInstance]; if (![mgr hasLicense]) { NSString *js = [self jsWhenUnfillLicense]; [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 - (void )postNotification { BOOL licensed = [self ->_hasLicense boolValue]; if (licensed) { [[NSNotificationCenter defaultCenter] postNotificationName:@"fillLicense" object:nil ]; [delegate execOnEachWindow:@"File.option.hasLicense = true" ]; } else { [[NSNotificationCenter defaultCenter] postNotificationName:@"unfillLicense" object:nil ]; [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 { NSString *path = [self recordFilePathNew]; self ._licenseDict = [self _readLicenseInfo:path]; BOOL v18; id email = [self ._licenseDict objectForKey:@"email" ]; if (email != nil ) { id license = [self ._licenseDict objectForKey:@"license" ]; v18 = (license != nil ); } else { v18 = NO ; } BOOL v19 = [self hasLicense]; self ._hasLicense = @(v18); if (v18 != v19) { [self postNotification]; } }
原始汇编 (定位到关键行) 在 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 0x10006B830 : LDR X0 , [X19 , #8 ] 0x10006B834 : ADRL X2 , "email" 0x10006B83C : BL objectForKey: 0x10006B848 : MOV X21 , X0 0x10006B84C : CBZ X0 , no_email 0x10006B850 : LDR X0 , [X19 , #8 ] 0x10006B854 : ADRL X2 , "license" 0x10006B85C : BL objectForKey: 0x10006B868 : CMP X0 , #0 0x10006B86C : CSET W20 , NE 0x10006B874 : B continue no_email: 0x10006B878 : MOV W20 , #0 continue: 0x10006B884 : MOV X0 , X19 0x10006B888 : BL hasLicense 0x10006B88C : MOV X21 , X0 0x10006B890 : ADRP X8 , classRef_NSNumber 0x10006B894 : LDR X0 , [X8 , ...] 0x10006B898 : MOV X2 , X20 0x10006B89C : BL numberWithBool: 0x10006B8A8 : LDR X8 , [X19 , #0x18 ] 0x10006B8AC : STR X0 , [X19 , #0x18 ]
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 0x10006B898 : MOV X2 , #1
具体字节修改 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]; BOOL v19 = [self hasLicense]; self ._hasLicense = @(YES ); 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 也会拦):
arm64 (Apple Silicon) 上修改过的二进制,确认架构无误:
按顺序跑完后再 ./你的程序 ; echo "退出码: $?"。
退出码变 0 或正常运行 → 修好了。
仍是 137 → 签名没生效,把 codesign -dv --verbose=4 的输出贴给我看。
变成 139 (SIGSEGV) → 签名过了,但你 patch 的字节改坏了逻辑,那就得回头核对 patch 位置/字节,我可以帮你在 IDA 里对一下。
另外提醒一句:重签名前如果用了 strip 或别的工具,确保 patch 已经写进文件了。先 codesign --remove-signature 再签是关键,直接覆盖签有时不生效。