Typora破解
样本是官方 Typora 1.14.9 (build 7785),Developer ID Abner Lee (9HWK5273G4),公证过,Hardened Runtime 开着。激活不在前端 JS 里,而在原生类 LicenseManager / Crypto。
结论先放前面:服务端 RSA 只保护「这次激活事务」,不保护「这台机器现在能不能用」。 启动时只看本地 AES 字典里有没有 email + license 两个 key,再加上内存里一个 ObjC 布尔。会看二进制的人,绕过是小时级,不是「需要 RSA 私钥」级。
下面按机制、再按四种方式写。每种方式拆成 原理 和 过程。

样本和入口
| 项 | 值 |
|---|---|
| 版本 | 1.14.9 / 7785 |
| bundle | abnerworks.Typora |
| 二进制 | Mach-O universal,x86_64 + arm64 |
| Hardened Runtime | 开(flags=0x10000 runtime) |
| 关键 entitlements | allow-dyld-environment-variables、allow-jit、allow-unsigned-executable-memory |
allow-dyld-environment-variables=true 等于主动放行 DYLD_INSERT_LIBRARIES。Hardened Runtime 在这里被自己拆掉一角。
授权 UI 是 license.html / Welcome,JS 只负责把输入丢给 native:
1 | |
真正干活的是 -[LicenseManager activate:with:force:callback:]。
机制:写入有 RSA,启动没有
联网 / 离线激活(有验签)
- 邮箱正则。
quickValidateLicense:—— 只做格式和弱校验和,能过这一步不代表服务器认。- POST JSON 到
store.typora.io或dian.typora.com.cn的/api/client/activate。 - 响应
code==0之后,writeLicenseInfo:with:from:才走verifySig::RSA-2048 / SHA-256,公钥嵌在二进制里。 - 通过才把
_hasLicense = YES,AES 写本地文件,再通知 WebView。
离线激活把用户粘贴的 JSON 送进同一条 writeLicenseInfo,所以伪造离线包过不了,一样要合法签名。
序列号在客户端长这样:
- 正则
^([A-Z0-9]{6}-){3}[A-Z0-9]{6}$ - 字母表去掉 0/1/I/O:
L23456789ABCDEFGHJKMNPQRSTUVWXYZ - 去掉
-后 24 字符,校验和只覆盖前 16 字符的奇偶位
这只决定「激活按钮能不能点」。checksum 合法的码,服务器照样可以拒。
每次启动(无验签)
1 | |
readLicenseInfo 的判定可以写成一句:
1 | |
没有 RSA,没有 MAC,没有把设备字段再核一遍。字典里塞进这两个 key 再加密写回,下次启动就是已授权。

设备指纹和文件路径:
1 | |
本地加密:
1 | |
ECB、无 HMAC、密钥完全由公开硬件 UUID + 硬编码盐导出。换机器拷贝文件会解不开,在本机等于可任意重加密。
运行时真正拦功能的点
关于窗口上的 window._options.hasLicense = true 只是外观。试用过期之后,锁新标签、锁空文档的是 native:

-[LicenseManager hasLicense] 读 ivar _hasLicense(偏移 0x18)。ivar 为 nil 时默认返回 YES,init 里大约 50 秒后才会写成 kCFBooleanFalse。正常启动 readLicenseInfo 会立刻写成 NSNumber,这个窗口用不上,但默认值偏「已授权」。
-[LicenseManager cannotContonueUse:] 方法名就少写一个 i,hook / 补丁必须一字不差:
1 | |
调用点:
ControllerCommands setup(force=1)—— JS 命令通道TyWindowController newTab:(force=0)Document makeWindowControllers(空文档 force=1)- 授权面板 URL 参数
试用日写在同一份 AES 文件的 installDate。没有该字段时,回退 ~/Library/Preferences/abnerworks.Typora.plist 的创建时间。
四种方式总览

P0 任意一条就能打穿授权。方式四只续试用钟。
| 方式 | 改什么 | 范围 | 持久 |
|---|---|---|---|
| 一、DYLD 注入 | hook 两个 ObjC 方法 | 副本进程 | 否 |
| 二、伪造本地文件 | AES 字典写入 email+license | 当前用户所有 Typora | 是 |
| 三、二进制补丁 | hasLicense 恒真、cannotContonueUse: 恒假 | 副本二进制 | 是(对这份副本) |
| 四、重置试用 | 删隐藏文件 + 重写 plist 创建时间 | 当前用户试用钟 | 再 20 天 |
方式一 DYLD 注入
原理
Hardened Runtime 默认会丢掉 DYLD_* 环境变量。但这份官方 entitlements 明确写了:
1 | |
所以 DYLD_INSERT_LIBRARIES 能进进程。库存公证包仍有 Library Validation:异 Team 的 dylib 会被拒。完整链是:
1 | |
allow-dyld-environment-variables 是原包自己放的;disable-library-validation 是后加的。两条都要。
注入之后只换两个 IMP,不改序列号、不碰 AES 文件、不需要 RSA 私钥:
-[LicenseManager hasLicense]→ 恒YES-[LicenseManager cannotContonueUse:]→ 恒NO
顺手把 ivar _hasLicense 写成 @YES,再发通知让 WebView 跑 fillLicense 那串 JS,关于窗口也会变成已激活外观。
过程
- 从 DMG 拷一份
Typora.app,不要动/Applications里正在用的那份。 - 原 entitlements 导出后补上:
1 | |
- ad-hoc 重签:
1 | |
- 写一个 dylib,在
constructor里method_setImplementation。选择子必须用字符串,因为原方法名拼错了:
1 | |
- 启动:
1 | |
- 对照:同一份 dylib 插进未重签的库存公证包,Library Validation 会挡住,hook 日志不应出现。
确认:日志里有「已替换 hasLicense / cannotContonueUse:」,随后有 hasLicense 被调用 → 强制 YES。菜单 Help → License 不再催激活。试用没过期时功能上本来就能用,以日志和过期后是否锁新标签为准。
方式二 伪造本地授权文件
原理
这是最能说明「启动不验签」的一条。
readLicenseInfo 启动时:
- 读
~/Library/Application Support/abnerworks.Typora/.<fingerprint> - AES-256-ECB 解密,密钥
SHA256(IOPlatformUUID + "typora-license") NSKeyedUnarchiver成字典- 只要同时有
email和license两个 key,_hasLicense = YES - 这里不调用
verifySig:
RSA 只出现在联网/离线激活的写入路径。持久化文件没有完整性,本机可以按同样算法重加密。
占位内容故意一眼假即可,不必是服务器签过的码,客户端启动也不再拿去验:
1 | |
后一个串能过客户端 quickValidateLicense: 的字母表和 checksum,服务器不会签它。对方式二无所谓,因为根本不走服务器。
过程
- 取本机 UUID:
1 | |
- 算指纹和 AES key:
1 | |
- 构造 NSKeyedArchiver 字典,至少包含
email、license,建议保留原来的installDate。 CCCryptAES-256-ECB PKCS7 加密后覆盖隐藏文件。写之前先备份。pkill Typora再打开,避免热进程把旧状态写回。- Help → License / 关于,邮箱和序列号应是上面那对占位符。
对照:把备份盖回去,字典里如果只剩 installDate,客户端会当成未授权。
副作用:改的是当前用户的 Application Support,/Applications/Typora.app 和任何副本都会读这份文件。验证完必须还原。
方式三 二进制补丁
原理
授权强制点收在两个 ObjC 方法里,函数体很短,适合直接改字节。
| 方法 | 原语义 | 补丁 |
|---|---|---|
-[LicenseManager hasLicense] |
读 ivar _hasLicense |
return 1 |
-[LicenseManager cannotContonueUse:] |
无授权且试用 < 1 天则拦截 | return 0 |
arm64 上 hasLicense 整函数 20 字节。原逻辑:
1 | |
改成:
1 | |
universal 二进制两片都要改。只改 arm64 的话,Rosetta 跑 x86_64 仍是旧逻辑。
补丁后 CDHash 对不上,必须:
1 | |
这就是「改字节 + 重签」。Gatekeeper 第一次会提示,点一次仍可跑。没有运行时完整性校验,程序不会因为自己被 ad-hoc 重签而拒绝工作。
过程
- 从库存 DMG 再拷一份副本(不要和方式一的注入副本混用)。
- 用特征码定位,不要写死文件偏移。出现 0 次或多次都拒绝写入。
- 对
Typora.app/Contents/MacOS/Typora的 arm64、x86_64 两 slice 分别打补丁。 - ad-hoc 重签整个
.app。 - 打开副本。
codesign -dv应是Signature=adhoc,不再是Abner Lee。
用 otool 直接看入口:
1 | |
开头应变成 mov w0, #0x1; ret。试用过期后,未补丁进程锁新标签,补丁进程不锁。
方式四 重置 20 天试用
原理
未授权时 cannotContonueUse: 用:
1 | |
installDate 在 AES 授权文件里。文件缺失该字段时,回退到 Preferences plist 的 birthtime。
只删 .<fingerPrint> 不够:plist 创建时间还在,试用不会回到 20。两条都要动。
这不是授权绕过,是试用钟。过了 20 天还可以再来一次,除非以后改成 Keychain + 首次网络时间交叉。
过程
当前还在试用期内的话,直接把日期改成「今天」,界面几乎看不出差别。要亲眼看锁 / 解锁:
- 先把
installDate写成 21 天前,并去掉email/license(否则会被当成已授权,试用锁走不到)。 - 打开 Typora,应出现试用结束,新标签 / 空文档被拦。
- 删掉 Application Support 里那份隐藏授权文件。
- 把
~/Library/Preferences/abnerworks.Typora.plist按原字节写回一遍,让 birthtime 变成现在(内容可以不变)。macOS 上cp往往保留 birthtime,需要删了再写,或用SetFile。 - 再打开,程序会自己把授权文件写回来,
installDate接近现在,剩余约 20 天。
建议先备份隐藏文件和 plist,看完立刻还原。官方安装同样读这两处。
对照:什么没用
| 手法 | 结果 |
|---|---|
只改 JS hasLicense |
试用期内看起来激活了;过期后 native 仍锁功能 |
| 按校验和生成序列号骗服务器 | checksum 可算,没有私钥过不了服务端 |
| 自签激活服务器 / 伪造离线 JSON | 要换嵌在包里的 RSA 公钥,等于还得做方式三 |
Frida / lldb 改 _hasLicense |
本次进程有效,和方式一同类,重启失效 |
根因
- 校验与强制分离。 密码学只出现在激活响应;强制点是内存布尔 + 本地文件两个字符串 key。
- 本地密文无完整性。 AES-ECB + 可导出密钥,等于混淆。
- Hardened Runtime 放行 DYLD insert。 注入改 ObjC 方法是官方允许的加载路径。
- 前端授权位可写。 增加「看起来激活了」的误判,不构成防护。
- 无运行时完整性。 不校验自己的 CDHash,被 ad-hoc 重签照样跑。
若这套逻辑要上线,最低限度是:每次启动用嵌入公钥验签持久化 payload(email / license / device / exp);关掉 allow-dyld-environment-variables;AES 换成 AEAD,key 进 Keychain;试用时间不要只靠一个可删文件的时间戳。客户端 DRM 对有二进制能力的人做不到绝对安全,但当前这套连业余静态分析都挡不住。
样本和汇编都基于 1.14.9。换版本后偏移和特征码会变,判定结构——启动不验签、两个 ObjC 布尔——才是可以跟着看的东西。