Typora 激活逻辑逆向 (Windows)
Typora Windows 版把许可证逻辑编进了 V8 字节码,没有 Mac 那样可直接改的原生闸口。本文记录如何对付 Electron Fuses + asar 打包 + 不可改的字节码,通过 hook 入口文件 launch.dist.js 绕过激活、消除启动弹窗,以及一路踩坑(独立弹窗窗口、注册表迷踪、诊断日志方法论)的全过程。
基本信息
- 文件:
Typora.exe(Windows PE x86-64, Electron 35.6.0) - 版本: 1.13.7 (releaseId: cf170905)
- 工具: IDA Pro, Python3, strings
- 目标: 分析并绕过 Typora Windows 版的激活逻辑
- 前置: macOS 版 Writeup — 建议先读 Mac 版,理解整体激活流程
与 macOS 版的架构差异
这是理解本篇的关键前提:同一个应用,两个平台的保护方案完全不同。
| macOS 版 | Windows 版 | |
|---|---|---|
| 核心二进制 | Typora (Mach-O ARM64, Objective-C) |
atom.compiled.dist.jsc (V8 字节码) |
| 许可证逻辑位置 | 原生编译的 ObjC 类 LicenseManager |
V8 bytecode cache,不可反编译 |
| 分析工具 | IDA → 直接看伪代码 | strings + 黑盒推断 |
| Patch 方式 | 修改 12 字节 ARM64 指令 | 多层 hook + 字符串替换 |
| 保护层数 | 1 层 (代码签名) | 3 层 (Electron Fuses + asar + 字节码) |
| 许可证存储 | {AppSupport}/.{fingerprint} (AES-256) |
注册表 HKCU\Software\Typora |
| 设备指纹 | IOPlatformUUID |
HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid |
一句话总结:Mac 版是拿手术刀在 X 光下精准切两刀;Windows 版是隔着一堵墙,一个一个出口堵。
1. 认识 Windows 版 Electron 项目结构
1.1 目录结构
1 | Typora/ |
1.2 入口分析
package.json 指定入口为 launch.dist.js:
1 | { |
launch.dist.js 的核心逻辑:
1 | // 注册 .jsc 文件的加载器 — 把 V8 字节码当 JS 模块加载 |
关键认知:
.jsc不是加密的 JS,而是 V8 字节码缓存 (bytecode cache)。
V8 引擎会把 JS 编译成字节码来执行,.jsc文件就是这个编译产物。
它不能被反编译回可读的 JS,但里面的 字符串常量 是明文存储的。
1.3 与文章方法的区别
看雪文章《向Typora学习electron安全攻防》描述的是 老版本 Typora,当时的保护方案是:
1 | 老版本: main.node (C++ 编译) → 运行时解密 JS → 传给 Electron 执行 |
当前 v1.13.7 的方案完全不同:
1 | 新版本: launch.dist.js → 加载 atom.compiled.dist.jsc (V8 字节码) |
所以文章的 frida 方法 对新版本不适用。
2. 分析 V8 字节码中的许可证逻辑
虽然 .jsc 不能反编译,但里面的字符串常量是明文的。用 strings 命令就能提取:
2.1 许可证格式验证
1 | $ strings atom.compiled.dist.jsc | grep "L23456789" |
与 Mac 版完全一致:
- 正则:
^([A-Z0-9]{6}-){3}[A-Z0-9]{6}$ - 字符集: 31 个字符 (排除 0/1/I/O 等易混淆字符)
- 校验和:
genCheckSum隔位取字符索引累加,mod 31
2.2 在线激活 API
1 | $ strings atom.compiled.dist.jsc | grep "api/client" |
激活请求发送到 https://store.typora.io/api/client/activate,与 Mac 版相同。
2.3 签名验证
1 | $ strings atom.compiled.dist.jsc | grep -E "publicDecrypt|jsDecrypt|sha256" |
使用 Node.js crypto.publicDecrypt + SHA-256 验证服务器响应签名。
我们没有私钥,无法伪造签名 → 必须走本地绕过。
2.4 许可证本地存储
1 | $ strings atom.compiled.dist.jsc | grep -E "Software.Typora|native-reg|IDate|WindowsLicense" |
Windows 版使用 注册表 存储许可证信息,路径为 HKCU\Software\Typora:
IDate: 安装日期 (用于计算试用期)- 其他许可证数据: 通过
native-reg模块读写
对比 Mac 版使用隐藏文件 {AppSupport}/.{fingerprint} + AES-256 加密。
2.5 试用期与 UI 控制
1 | $ strings atom.compiled.dist.jsc | grep -E "cannotContinue|hasLicense|fillLicense|UNREGISTERED" |
流程与 Mac 版对应:
1 | Mac: cannotContonueUse → hasLicense 方法 → _hasLicense ivar → postNotification |
具体的注入代码(从字节码中提取的完整片段):
1 | // 未激活时注入到渲染进程的 JS: |
2.6 许可证面板
许可证面板不是原生 dialog,而是一个 <webview> 嵌入在主窗口中:
1 | // frame.js 中的 showLicensePanel 函数 |
URL 参数包括 needLicense、hasActivated、email、license 等,由主进程字节码动态构造。
3. 保护机制分析
Windows 版有 三层保护,必须逐一突破:
3.1 第一层: V8 字节码 (.jsc)
核心许可证逻辑被编译为 V8 字节码,无法反编译为可读 JS,也无法像 Mac 版那样直接修改指令。
突破方式: V8 bytecode cache 不校验内容完整性。它只校验:
- Magic number (V8 版本标识)
- Flag hash (编译标志,已由
setFlagHashHeader修复) - Source hash (源码长度,不是内容 hash)
因此,修改 .jsc 中的 字符串常量 不会导致 cachedDataRejected,只要保持字节长度不变。
3.2 第二层: Electron Fuses
Electron Fuses 是编译时嵌入 Typora.exe 的开关位,用于控制安全特性。
用 Python 在 Typora.exe 中搜索 fuse sentinel dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX:
1 | 偏移 0x9D00420: dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX |
| Fuse | 名称 | 值 | 含义 |
|---|---|---|---|
| 0 | RunAsNode | 0 (关) | 不允许以纯 Node.js 模式运行 |
| 1 | EnableCookieEncryption | 0 (关) | — |
| 2 | EnableNodeOptionsEnvironmentVariable | 1 (开) | 允许 NODE_OPTIONS 环境变量 |
| 3 | EnableNodeCliInspectArguments | 0 (关) | 不允许 –inspect 调试 |
| 4 | EnableEmbeddedAsarIntegrityValidation | 0 (关) | ★ asar 完整性校验已关闭 |
| 5 | OnlyLoadAppFromAsar | 1 (开) | ★ 只从 .asar 文件加载 |
| 6 | LoadBrowserProcessSpecificV8Snapshot | 0 (关) | — |
| 7 | GrantFileProtocolExtraPrivileges | 1 (开) | — |
关键发现:
- Fuse 4 = 0: asar 完整性 不校验 → 可以安全地修改并重打包 asar
- Fuse 5 = 1: 只接受 .asar 文件 → 不能用文件夹替代,必须重打包回 .asar 格式
3.3 第三层: asar 打包格式
asar 是 Electron 的私有归档格式,结构如下:
1 | ┌─────────────────────────────────────┐ |
Header JSON 示例:
1 | { |
由于 Fuse 4 关闭,integrity 字段中的 hash 不会被实际验证。
3.4 main.node 分析 (排除项)
用 IDA Pro 分析了 resources/node_modules/main.node (PE DLL, x86-64):
- 导出函数:
napi_register_module_v1(Node.js 原生模块标准入口) - 4642 个函数,但 无任何许可证相关字符串
- 它是一个文件系统工具模块,与许可证无关
- 这与看雪文章描述的老版本
main.node完全不同
4. 绕过方案
需要修改 三个文件 来完整绕过:
4.1 Patch 1: 字节码字符串替换 (atom.compiled.dist.jsc)
V8 字节码中嵌入了渲染进程注入的 JS 代码字符串。直接修改这些字符串(保持长度不变):
1 | Patch 1a @ 0x1EEDA: |
为什么能改: V8 bytecode cache 只校验 magic/version/flags/source-length,
不校验字节码内容的完整性。只要字节长度不变,V8 会正常接受修改后的字节码。
4.2 Patch 2: 入口文件 hook 注入 (launch.dist.js)
在 require("./atom.compiled.dist.jsc") 之前注入代码,monkey-patch Electron API。
关键认识(来自诊断日志):启动时的「激活弹窗」不是主窗口里的某个 div,而是
字节码new BrowserWindow()创建的一个独立窗口,其顶层文档直接就是typora://app/typemark/page-dist/license.html?...&dayRemains=14&hasActivated=false&needLicense=true。
因此改主窗口的 CSS /frame.js/#uni-license-panel都够不着它,也因为它走typora://自定义协议,session.webRequest同样拦不到。正确做法是在loadURL
层面拦截:独立弹窗直接隐藏,内嵌 webview 则改写 URL 参数为「已激活」。
1 | // === 注入到 launch.dist.js 的 hook 代码(精简版)=== |
为什么在 bytecode 加载前注入有效:
1 | 时序: |
Electron 的 app、ipcMain 都是 单例对象。我们先 patch 它们的方法,
后面字节码拿到的是同一个对象,调用的就是我们 patch 过的版本。
诊断方法论:这一版能定位到「独立弹窗」是因为先做了一个诊断版 hook —— 把
browser-window-created/web-contents-created的每个 URL、reg query/delete的
结果、所有ipcMain.handle注册的频道名都写进~/typora_debug.log,启动一次就
拿到全部真相,而不是反复盲改盲试。最终版已移除全部日志代码。
4.3 Patch 3: 渲染进程前端修改 (frame.js)
frame.js 位于 asar 外部 (resources/appsrc/window/frame.js),可以直接修改。
1 | // 原始: showLicensePanel 创建 webview 加载 license.html |
这样用户手动打开许可证面板时会看到与 Mac 版一样的”已激活”界面,
而不是一个空白或激活表单。关闭按钮会隐藏面板并清理 DOM。
5. 三处 Patch 的配合关系
1 | ┌───────────────────────────────────────────────────────────┐ |
| 检查点 | 触发位置 | 绕过方式 |
|---|---|---|
| 试用期过期 → 强制退出 | 主进程 app.quit() |
Patch 2 ① 启动期间拦截 |
| 试用期弹窗 | 主进程 dialog |
Patch 2 ② 自动点掉 |
getHasLicense 返回 false |
IPC 通道 | Patch 2 ③ 返回 true |
hasLicense = false 注入 |
executeJavaScript |
Patch 1a 字符串 + Patch 2 ④ |
| UNREGISTERED 角标 | executeJavaScript |
Patch 1b 字符串 + Patch 2 ④ |
| 许可证面板弹出 | 渲染进程 showLicensePanel |
Patch 3 显示「已激活」 |
6. 部署方式
方法一: 一键脚本 (推荐)
1 | python3 patch_win.py [Typora安装目录] |
脚本自动完成(5 步): 备份 → 解包 asar → patch 字节码 → patch launch.dist.js → 重打包
→ patch frame.js → patch window.html。每个被改的 asar 外文件都会先生成 .orig 备份。
⚠️ 测试前务必彻底退出 Typora(含右下角托盘图标 / 任务管理器里的所有
Typora.exe)。
Typora 是单实例:若已有旧进程在后台,新启动只会让旧进程开窗,你的新 patch 不会生效。
方法二: 手动操作
1 | # 1. 安装 asar 工具 |
恢复原始版本
1 | cp resources/app.asar.orig resources/app.asar |
7. 踩坑记录
坑 1: 文件夹替代 asar 不启动
最初尝试把 app.asar 改名为 .bak,用解压出的 app/ 文件夹替代。
结果: Typora 完全无反应,不报错,不启动。
原因: Electron Fuse 5 (OnlyLoadAppFromAsar) = 1,
强制只从 .asar 文件加载,忽略文件夹。
解法: 必须修改后重新打包回 .asar 格式。幸运的是 Fuse 4 (asar 完整性校验) = 0,
所以重打包的 asar 不需要匹配任何 hash。
坑 2: launch.dist.js 语法错误
原始文件是 单行压缩 的,末尾结构为:
1 | ...return s.apply(e.exports,d)},require("./atom.compiled.dist.jsc"); |
注意逗号 , — 这是逗号运算符,整行是一个表达式语句。
最初在 require(...) 前插入 try { ... } 语句块,
变成了 },try { ... } — 逗号后面不能跟语句,语法错误。
解法: 把逗号改成分号 };try { ... },正确终止前一条语句。
坑 3: hook 拦不住弹窗(走了好几轮弯路)
拦了 executeJavaScript、ipcMain.handle、webContents.send、webRequest.onBeforeRequest,
又改了主窗口 window.html 的 CSS、frame.js 的 showLicensePanel,空白弹窗依旧出现。
最终靠诊断日志才看清真相:弹窗是字节码 new BrowserWindow() 创建的一个独立窗口,
顶层文档直接是 license.html。所以:
- 改主窗口 CSS /
frame.js/#uni-license-panel—— 全是另一个窗口里的事,够不着; session.webRequest——license.html走typora://自定义协议,根本不触发;- 早期试过
webContents.close()销毁它 —— 字节码仍持有该窗口引用,定时器回调
触发Object has been destroyed崩溃。
正解:在 browser-window-created / web-contents-created 里拦 loadURL,
判断到独立窗口加载 license.html 就 hide() + 移到屏幕外 + 覆盖 show(),
并改载 about:blank。窗口对象保持存活,字节码引用不失效,也就不崩。
坑 4: 试用天数 IDate 不在注册表
原以为试用倒计时存在 HKCU\Software\Typora\IDate,写了 reg delete 去清。
诊断日志显示 reg query 直接报「系统找不到指定的注册表项或值」——它压根不在那。dayRemains 是字节码自己算出来、拼进 license.html 的 URL 的。
所以根本不用碰注册表,直接在 loadURL 改写 URL 参数 dayRemains=9999 即可。
坑 5: 设置界面「未激活」(未完全解决)
设置面板 setting.html 用 getValue("hasLicense") 决定显示「已激活/未激活」,
该值作为 props 来自 window._options,而 _options = JSON.parse(IPC "setting.getExtraOption" 返回值)。
本想拦这个 IPC 把 hasLicense 改成 true,但日志证实它既不走全局 ipcMain.handle,
也不走 wc.ipc.handle / wc.mainFrame.ipc.handle——三个入口都挂了拦截,一个都没命中,
注册路径未明。
由于这只是显示文字(真正决定能否使用的 hasLicense 已为 true,功能不受影响),
权衡代价后保留此现状,未继续深挖。
8. 总结
修改清单
| 文件 | 类型 | 修改内容 |
|---|---|---|
atom.compiled.dist.jsc |
asar 内 | 3 处字符串替换 (hasLicense / UNREGISTERED 角标) |
launch.dist.js |
asar 内 | 注入约 5.8KB hook:退出拦截 + 弹窗隐藏 + URL 改写 + IPC |
frame.js |
asar 外 | showLicensePanel 置空(弹窗已在主进程拦掉,此处兜底) |
window.html |
asar 外 | 注入 CSS 隐藏 #uni-license-panel(兜底) |
| 合计 | 4 个文件,需重打包 asar |
对比 Mac 版: 1 个文件,12 字节,重签名即可。
最终效果
| 项 | 状态 |
|---|---|
| 编辑器可用 (hasLicense=true) | ✅ |
| 启动空白激活弹窗 | ✅ 已消除 |
| 关窗后台进程残留 | ✅ 已修 |
| 试用到期强制退出 | ✅ 已拦截 |
| 标题栏 UNREGISTERED 角标 | ✅ 已清 |
| 设置界面文字「未激活」 | ⚠️ 仅显示错误,不影响使用(见坑 5) |
为什么 Windows 版难得多
- 不可见性: V8 字节码是黑盒,不能像 IDA 分析 ARM64 那样看伪代码
- 不可改性: 字节码的指令不能直接修改(格式复杂且版本绑定),只能改字符串
- 分散性: 逻辑分散在 字节码 / frame.js / license.html / 独立弹窗窗口 / 多套 IPC 之间
- 多层保护: 需要同时对付 Fuses + asar + 字节码,任何一层没搞定都不行
- 间接性: 只能通过 hook Electron API 间接影响字节码行为,而非直接改逻辑
方法论教训:Mac 版在原生层有
hasLicense()这个唯一闸口,改一处全线联动;
Windows 版没有这种闸口,逻辑全在不可改的字节码里,只能逐个症状堵。盲改盲试
代价极高——真正高效的一步是先写诊断版 hook 打日志,一次启动看清所有窗口
URL / IPC / 注册表行为,再针对性下手。