一、逆向目标与核心思路
1. 目标
不是为了"破解软件",而是学习 Electron 应用的逆向通用思路:
- 绕过反调试机制
- 突破文件完整性校验
- 劫持核心 API 分析逻辑
- 实现离线激活流程劫持
2. 核心概念
Electron 应用基于 Node.js + 浏览器内核,所有核心行为都依赖 JavaScript/Node.js API。我们不需要懂二进制/C++,只需拦截(Hook)这些 API ,就能修改程序行为(比如让程序读"假文件"、返回"假结果")。
Electron基于主进程(Main Process) 和渲染进程(Renderer Process) 的双进程模型。
- 主进程:整个应用的入口,负责窗口管理、系统交互、生命周期控制,运行在Node.js环境中(有完整的Node API权限)。
- 渲染进程 :每个窗口对应一个渲染进程,负责页面渲染、用户交互,运行在Chromium环境中(默认无Node权限,需通过
webPreferences配置)。
一、编译后Electron应用的核心结构
编译后的应用会将源码、依赖、Electron运行时打包成独立文件,典型结构如下(以Windows为例):
xxx-win32-x64/
├── xxx.exe # 应用入口可执行文件
├── resources/ # 资源目录
│ ├── app.asar # 打包后的源码(main.js、preload.js、页面等)
│ └── app.asar.unpacked/ # 未打包的二进制依赖(可选)
└── electron*.dll # Electron运行时依赖
核心:源码被打包进app.asar(一种Electron专属的归档格式),但执行顺序逻辑和开发环境一致,仅资源加载路径发生变化。
二、编译后代码的完整执行顺序
以下是编译后可执行文件运行时的代码执行流程,对比开发环境标注差异点:
1. 启动阶段:Electron运行时初始化
1. 双击xxx.exe → 系统启动Electron运行时(内置Node.js + Chromium)
2. 运行时读取resources目录 → 定位app.asar包,提取并执行**编译后的主进程入口文件**(如main.js)
✨ 差异点:开发环境直接读取本地main.js,编译后读取asar内的main.js
二、前置准备(必装工具)
| 工具/环境 | 作用 | 安装方法 |
|---|---|---|
| Node.js + npm | 提供 JavaScript 运行环境,安装 asar 工具 |
官网 https://nodejs.org/ 下载 LTS 版,默认安装(勾选"Add to PATH") |
| Typora v1.12.4 | 目标分析软件 | 官网下载最新版,默认安装到 C:\Program Files\Typora(必须默认路径,否则需改代码) |
| 文本编辑器 | 写代码、改配置(如 VS Code、记事本++) | 任意编辑器均可,推荐 VS Code(官网 https://code.visualstudio.com/) |
验证安装
打开「命令提示符(CMD)」,输入以下命令,能显示版本号就是安装成功:
cmd
node -v # 显示 v18+ 即可
npm -v # 显示 8+ 即可
三、详细逆向步骤(按顺序来,一步都不能漏)
步骤1:安装 Typora 并初步测试反调试
- 安装 Typora :默认路径
C:\Program Files\Typora,安装后先正常启动一次,确认能打开(然后关闭)。 - 测试反调试 :
-
打开 CMD,输入命令(启动 Typora 并尝试调试):
cmdcd C:\Program Files\Typora Typora.exe --inspect -
现象:程序启动失败,弹出错误提示。
-
原因:Typora 有反调试机制 ,检测到
--debug/--inspect参数就拒绝启动。
3.Typora是基于Electron开发的应用,而Electron本身内置了Chromium的调试协议,支持通过--debug(旧版参数)或--inspect(新版参数)开启调试端口。
-
- 启动后,你可以用Chrome DevTools等工具直接连接调试端口,动态查看主进程和渲染进程的JS代码、调用栈与内存数据。
- 这是最直接、无侵入的调试方式,不需要提前解压asar包或修改代码。
在Electron应用的逆向流程中,这是最优先的尝试方向: - 如果调试成功,就能直接定位激活逻辑、验证机制等核心代码,效率远高于后续的静态分析。
- 即使失败,也能快速确认应用是否存在反调试机制,从而调整后续的逆向策略(比如需要先绕过反调试,再进行静态分析asar包)。
步骤2:定位入口文件
- 优先检查 resources 目录下的 package.json
有些应用会把package.json直接放在resources目录下(而非打包进 asar),可以直接查看其中的main字段。
比如 Typora 在resources目录下的package.json中,"main": "launch.dist.js",但这个文件实际在app.asar内。 - 替换加载优先级
按照 Electron 的规则,resources/app目录的优先级高于app.asar。
你可以解压app.asar并重命名为app目录,这样 Electron 启动时会优先加载app目录中的源码,你就能直接修改入口文件(比如绕过反调试)。
步骤3:解压 Electron 归档文件(app.asar)
Electron 会把核心代码打包成 app.asar(类似压缩包),我们需要解压它才能看到源码。
-
安装
asar解压工具 :
打开 CMD,输入命令(全局安装解压工具):cmdnpm i -g asar -
备份并解压
app.asar:
依次在 CMD 输入以下命令(每输一行按回车,注意路径不要错):cmd# 进入 Typora 的资源目录 cd C:\Program Files\Typora\resources # 解压 app.asar 到 app 文件夹(核心代码全在里面) asar extract app.asar app # 备份原始 app.asar(重要!后续要用到) rename app.asar app.asar.bak # 备份解压后的 app 文件夹(防止修改出错) robocopy app app.bak /E -
验证结果 :
打开C:\Program Files\Typora\resources,会看到新增app(解压后的源码)、app.bak(备份的源码)、app.asar.bak(备份的原始归档)三个文件/文件夹。
步骤4:修改 Electron 配置(Fuses),允许加载解压后的源码
-
问题现象 :
直接双击C:\Program Files\Typora\Typora.exe,发现程序打不开。
原因:Typora 配置了OnlyLoadAppFromAsar: true,只允许从app.asar启动,不允许加载解压后的app文件夹。
在 Electron 生态里,Fuses(熔断机制)是 Electron 官方在 v12 及以上版本引入的编译时安全配置机制,它的核心作用是在应用打包阶段就 "烧录" 一系列开关到 Electron 二进制文件中,永久锁定应用的运行时行为,防止被篡改、逆向或恶意利用。
查询Fuses配置electron-fuses read --app "D:\Program Files\Typora\Typora.exe"Analyzing app: Typora.exe Fuse Version: v1 RunAsNode is Disabled EnableCookieEncryption is Disabled EnableNodeOptionsEnvironmentVariable is Enabled EnableNodeCliInspectArguments is Disabled EnableEmbeddedAsarIntegrityValidation is Disabled OnlyLoadAppFromAsar is Enabled LoadBrowserProcessSpecificV8Snapshot is Disabled GrantFileProtocolExtraPrivileges is Enabled
enableNodeOptions 开关也打开了,所以无法用--debug/--inspect 启动,因为Fuses已经在打包时禁用了调试端口。
这里可以看到OnlyLoadAppFromAsar is Enabled,这就是导致"解压 app.asar 并重命名为 app 目录"的方法会失效,必须先破解 Fuses 才能修改加载优先级。
- 修改配置的方法 :
-
新建一个文本文件,重命名为
fix-fuses.cjs(注意后缀是.cjs,不是.txt)。 -
用 VS Code 打开这个文件,粘贴以下代码(复制完整,不要漏行):
javascript// 引入修改 Fuses 的工具和文件操作模块 const { flipFuses, FuseV1Options, FuseVersion } = require('@electron/fuses'); const fs = require('fs'); // Typora 程序的完整路径(默认安装路径,不要改) const fullPath = 'C:\\Program Files\\Typora\\Typora.exe'; // 第一步:备份原始 Typora.exe(防止修改出错,可恢复) fs.copyFileSync(fullPath, `${fullPath}.bak`); console.log('已备份 Typora.exe 为 Typora.exe.bak'); // 第二步:修改 Fuses 配置,关闭 OnlyLoadAppFromAsar flipFuses(fullPath, { version: FuseVersion.V1, [FuseV1Options.OnlyLoadAppFromAsar]: false, // 允许加载 app 文件夹 }).then(() => { console.log('Fuses 配置修改成功!现在可以加载解压后的 app 文件夹了'); }).catch((err) => { console.error('修改失败:', err); }); -
保存文件后,打开 CMD,输入以下命令运行这个脚本:
cmd# 先安装依赖工具 @electron/fuses npm i @electron/fuses # 运行修改配置的脚本(注意脚本路径,比如保存在桌面就先 cd 到桌面) cd C:\Users\你的用户名\Desktop # 替换成你的脚本保存路径 node fix-fuses.cjs -
看到"修改成功"提示后,再双击
Typora.exe,程序能正常打开了(但修改源码后会闪退,因为有完整性校验)。
-
常见的 Fuse 开关(对逆向分析影响较大)
Electron 提供了多个预设的 Fuse 开关,其中几个和你之前关注的逆向场景高度相关:
| Fuse 开关 | 作用 | 对逆向的影响 |
|---|---|---|
runAsNode |
禁止将 Electron 二进制文件当作 Node.js 脚本直接运行 | 防止攻击者通过 electron.exe --eval 执行恶意代码,也会阻碍逆向时的快速调试 |
enableNodeOptions |
禁止通过命令行传递 --node-integration 等 Node.js 选项 |
无法通过命令行强制开启 Node 集成或调试端口(如 --inspect),这也是 Typora 拒绝 --debug/--inspect 的原因之一 |
onlyLoadAppFromAsar |
强制 Electron 仅加载 app.asar 包,忽略 resources/app 目录 |
彻底打破了 Electron 原有的"app 目录优先级高于 app.asar"规则,无法通过替换 app 目录来修改代码(逆向时必须先绕过这个限制) |
enableEmbeddedAsarIntegrityValidation |
验证 app.asar 包的完整性(基于内置的哈希值) |
篡改 app.asar 后会导致应用启动失败,无法直接修改包内代码 |
步骤4:绕过文件完整性校验(核心步骤)
-
问题现象 :
只要修改
app文件夹里的launch.dist.js,启动 Typora 后几秒就闪退。原因:程序会校验 4 个核心文件的完整性(Hash 值),不匹配就调用
app.quit()退出。 -
绕过原理 :
劫持 Node.js 的
fs模块(文件操作模块),当程序试图读取这 4 个文件时,让它去读我们备份的原始文件(app.bak文件夹),这样 Hash 就匹配了。 -
实现方法:
-
打开
D:\Program Files\Typora\resources\app\launch.dist.js(解压后的源码入口文件)。 -
在文件最顶部,粘贴以下代码(拦截文件读取,重定向到备份目录):
javascript// 1. 引入需要的模块(Node.js 内置,不用额外安装) const fs = require('fs'); const path = require('path'); // 2. 配置:把 "resources/app/" 路径重定向到 "resources/app.bak/"(备份的原始文件) const fsPathFrom = /resources[\\/]app[\\/]/i; // 匹配程序要读的路径 const fsPathTo = 'resources\\app.bak\\'; // 重定向到备份目录 // 3. 劫持 fs 模块的核心函数(readFile、stat 等,都是校验文件用的) const fsHook = {}; // 要劫持的文件操作函数列表 const needHook = ['readFileSync', 'readFile', 'statSync', 'stat', 'open', 'openSync']; needHook.forEach((funcName) => { // 保存原始函数(后续还能调用) fsHook[funcName] = fs[funcName]; // 替换成我们的自定义函数 fs[funcName] = function (filePath, ...args) { // 如果程序要读 app 文件夹里的文件,就重定向到 app.bak if (typeof filePath === 'string' && fsPathFrom.test(filePath)) { const redirectPath = filePath.replace(fsPathFrom, fsPathTo); console.log(`[劫持文件读取] ${filePath} -> ${redirectPath}`); return fsHook[funcName].call(this, redirectPath, ...args); } // 其他文件正常读取 return fsHook[funcName].call(this, filePath, ...args); }; }); // 4. 劫持 fs.promises(异步文件操作,程序也会用) const fsPromisesHook = {}; const needHookPromises = ['readFile', 'open', 'stat']; needHookPromises.forEach((funcName) => { fsPromisesHook[funcName] = fs.promises[funcName]; fs.promises[funcName] = async function (filePath, ...args) { if (typeof filePath === 'string' && fsPathFrom.test(filePath)) { const redirectPath = filePath.replace(fsPathFrom, fsPathTo); console.log(`[异步劫持文件读取] ${filePath} -> ${redirectPath}`); return fsPromisesHook[funcName].call(this, redirectPath, ...args); } return fsPromisesHook[funcName].call(this, filePath, ...args); }; }); // 5. 拦截 app.quit(),防止程序退出(调试用,后续可删除) const electron = require('electron'); const originalQuit = electron.app.quit; electron.app.quit = function () { console.log('[拦截退出] 程序试图调用 app.quit(),已阻止!'); }; // 6. 开启调试工具(DevTools),方便后续分析 electron.app.on('browser-window-created', (_event, win) => { win.webContents.once('dom-ready', () => { console.log('已打开调试工具!'); win.webContents.openDevTools({ mode: 'detach' }); // 独立窗口显示调试工具 }); }); -
保存文件后,启动 Typora:不会闪退,且会弹出 DevTools 调试窗口(说明绕过成功)。
-
步骤5:分析离线激活逻辑(黑盒推导)
- 前端格式校验 :
打开 Typora → 帮助 → 离线激活,输入任意字符点击"激活",没反应。
用 DevTools 在关键代码处下断点调试发现:激活码必须满足「以+开头」或「以#结尾」,否则前端不提交。

这段代码是Typora渲染进程 中离线激活 的核心处理逻辑(代码经过ES6 Generator函数+混淆压缩),全程围绕渲染进程处理激活令牌 → 与主进程IPC通信完成验证 → 根据主进程返回结果更新本地激活状态展开,没有复杂的网络请求(离线激活特性),按钮点击触发后走纯本地+主进程验证的闭环。
先明确核心关联:未激活状态下,页面的「Activate」按钮点击后会触发oe函数 (代码里onClick: oe),而oe就是这段代码开头定义的匿名离线激活处理函数(代码里第一个大的generator函数,最终返回的function(t)),这是激活的唯一入口。
下面按执行顺序拆解完整的激活流程,同时解析混淆代码里的关键逻辑和函数作用:
一、核心:离线激活主流程(oe函数,按钮点击触发)
这个函数是激活的核心,接收一个激活令牌t (按钮点击时传入的用户输入/粘贴的激活码),全程是Generator函数(u.a.mark/u.a.wrap是co库/regenerator的混淆封装,处理异步流程),按switch (e.prev = e.next)的case分步执行,核心步骤如下:
Step 1:激活令牌格式校验(必过前置条件)
javascript
if ("+" == t[0] || "#" == t[t.length - 1]) {
e.next = 2; break
}
return e.abrupt("return"); // 格式不满足直接终止执行
- 要求激活令牌开头是+ 或者结尾是#,不满足则直接退出激活流程,无任何提示;
- 这是Typora离线激活令牌的专属格式标识,过滤无效的乱输入。
Step 2:令牌格式清洗,截取有效部分
javascript
t = t.substr(1, t.length - 2)
- 去掉令牌开头1位和结尾2位的格式标识,得到真正的有效令牌内容;
- 示例:如果原令牌是
+abcdef#,清洗后得到bcde(截掉开头+和结尾#,长度-2)。
Step 3:WebKit环境下的令牌解析与重组(Typora Electron基于WebKit,必走此分支)
这是离线激活的核心解析步骤 ,包裹在try-catch(e.prev=3/e.catch(3))中,解析失败直接弹错:
javascript
window.webkit && (
n = t.split("|") || ["", ""], // 按|分割令牌为数组,兜底空数组
r = Object(f.a)(n, 2), // 截取数组前2个元素(f.a是数组slice的混淆封装)
a = r[0], o = r[1], // 分割为a(主体部分)和o(签名sig)
// 核心:a进行base64解码 → 转JSON对象 → 追加sig签名字段 → 重新转JSON字符串
(i = JSON.parse(window.atob(a))).sig = o,
t = JSON.stringify(i)
)
- 解析前提:Typora的Electron内核基于WebKit,
window.webkit恒为true,此分支必执行; - 令牌格式要求:清洗后的有效令牌必须是**「base64字符串|签名字符串」** 的格式,按
|分割为两部分; - 关键操作:对第一部分
a做window.atob (base64解码),解析为JSON对象后,把第二部分o作为签名sig 追加到对象中,最后重新转成JSON字符串,作为最终传给主进程的激活参数。
Step 4:解析失败的异常处理
javascript
e.t0 = e.catch(3),
window.alert("Invalid Activation Token"), // 弹框提示「无效的激活令牌」
e.abrupt("return"); // 终止激活流程
- 任何解析错误(base64无效、JSON格式错误、分割后无数据)都会走到这里,弹框后直接退出;
- 这是用户最常遇到的「激活失败」提示的原因之一。
Step 5:显示加载状态,向主进程发起离线激活IPC请求
javascript
J(!0), // 显示加载中(比如按钮置灰、loading动画)
e.next = 14,
// 核心IPC通信:渲染进程 → 主进程,调用offlineActivation方法,传处理后的令牌t
window.Setting.invokeWithCallback("offlineActivation", t);
J(!0):渲染进程的加载状态控制,true表示开启加载,防止用户重复点击;window.Setting.invokeWithCallback:Typora封装的Electron IPC通信方法(渲染进程调用主进程并接收返回结果),是渲染进程和主进程的核心通信桥梁;- 调用主进程的
offlineActivation方法,传入处理后的令牌t,主进程在此完成真正的激活验证(比如令牌签名校验、许可证有效性判断,这部分逻辑不在这段渲染进程代码中,是逆向的核心关键点)。
Step 6:接收主进程返回结果,处理激活成功/失败
这是激活的最终环节,主进程执行offlineActivation后返回结果l,渲染进程按结果分支处理:
javascript
l = e.sent, // 接收主进程返回的结果l
c = Object(f.a)(l, 4), // 把返回结果分割为前4个元素(s/d/p/h)
s = c[0], d = c[1], p = c[2], h = c[3],
J(!1), // 隐藏加载状态
// 分支1:激活成功(s为true)
s ? (
Y(d), // 清空/重置错误提示
_(!0), // 全局标记「已激活」状态(核心:设置P为true,页面会重新渲染)
S(0), // 重置激活页面状态(比如清空输入框、隐藏激活表单)
L(p), // 存储许可证相关信息p(比如许可证名称、有效期)
U(h), // 存储许可证额外信息h(比如设备ID、激活时间)
Q("off") // 关闭试用倒计时/试用状态
) :
// 分支2:激活失败(s为false)
(
window.alert("Invalid Activation Token"), // 弹框提示无效令牌
Y(d || "Unknown Error") // 显示主进程返回的错误信息d,兜底未知错误
);
- 主进程返回结果
l是一个可分割的集合,按顺序解析为4个核心参数:s:激活结果标识(布尔值,true=成功,false=失败),是最核心的判断依据;d:错误信息/预留字段(成功时为空,失败时为具体错误原因);p/h:许可证相关信息(成功时返回,用于本地存储和页面展示);
- 激活成功的核心标记 :执行
_(!0)后,全局变量P会被设置为true(代码里能看到ue = Object(y.a)(P ? "Typora Activated" : "Activate Typora")),页面会根据P的状态重新渲染(隐藏激活按钮、显示「View License」和「Deactivate」按钮); - 激活失败则弹框提示,和解析失败的提示一致,无法从前端区分是「令牌格式错」还是「令牌本身无效」。
二、本次激活逻辑的核心逆向关键点
这段代码只是渲染进程的前端处理逻辑 ,真正的激活验证核心在主进程,也是后续逆向的重点,需要关注这2个关键点:
- 主进程的
offlineActivation方法 :渲染进程只是把处理后的令牌t传给主进程,主进程才是真正做令牌校验、签名验证、许可证有效性判断的地方,这段代码里没有任何验证逻辑,只是传参和接收结果; - 激活状态的持久化 :
_(!0)只是设置了内存中的全局状态P,Typora必然会把激活状态/许可证信息持久化到本地文件/注册表(比如Windows的注册表、macOS的plist文件),重启后读取该文件判断是否激活,这是破解的核心(比如直接修改本地持久化的激活状态)。
三、总结:Typora离线激活的完整闭环
用户点击激活按钮 → 传入激活令牌t → 渲染进程做格式校验/解析 → 处理为标准JSON参数 → IPC调用主进程offlineActivation方法 → 主进程执行核心验证 → 返回激活结果s → 渲染进程根据s更新本地状态/页面 → 激活成功(P=true)/失败(弹框)
简单来说:渲染进程只做「令牌的格式处理、页面交互、状态更新」,主进程做「真正的激活验证」,激活的核心是主进程offlineActivation方法的返回结果s是否为true。
后续逆向的核心方向就是:找到主进程中offlineActivation方法的实现代码,破解其令牌校验逻辑,或者强制让其返回s=true的结果。
-
监控 IPC 通信 :
前端(界面)会把处理后的激活码,通过 IPC(进程间通信)发送给主进程(核心逻辑)。我们需要监控这个通信,看参数和返回值。
-
在
launch.dist.js中继续添加以下代码(监控 IPC):javascript// 监控 IPC 通信(主进程接收前端的激活请求) const originalIpcHandle = electron.ipcMain.handle; electron.ipcMain.handle = function (channel, listener) { return originalIpcHandle.call(this, channel, async (event, ...args) => { // 只关注离线激活相关的频道(offlineActivation) if (channel === 'offlineActivation') { console.log(`[收到激活请求] 参数:${JSON.stringify(args)}`); try { const result = await listener(event, ...args); console.log(`[激活响应] 结果:${JSON.stringify(result)}`); return result; } catch (err) { console.error(`[激活错误]:${err}`); throw err; } } // 其他 IPC 通信正常处理 return listener(event, ...args); }); }; -
保存后启动 Typora,输入符合格式的激活码(比如
+test#),点击激活,在C:\Users\用户名\AppData\Roaming\Typora\typora.log能看到请求参数和响应(此时响应是[false, "Please input a valid license code"],激活失败)。
-
-
推导激活数据结构 :
程序会用 RSA 公钥解密激活码,解密后需要是一个 JSON 对象,包含特定字段。我们通过"伪造解密结果"推导需要的字段。
通过劫持crypto.publicDecrypt:控制解密后的返回结果,同时打印解密日志;
监控假设一 / 二的关键方法:Buffer.compare/Buffer.equals、crypto.verify/crypto.createHash,调用即打印日志;
监控假设三的关键方法:Buffer.prototype.toString,重点打印utf-8/utf8格式的调用;
保留原有 IPC 监控:联动查看激活请求 / 响应结果,辅助验证。
javascript// 主进程入口最顶部执行!!!黑盒测试全监控代码 // 通用时间戳函数,所有日志统一格式 const getTime = () => new Date().toLocaleString('zh-CN', { hour12: false }); // ============================================== // 监控【假设一】:直接比对Buffer → 监控Buffer.compare/Buffer.equals // ============================================== const originalBufferCompare = Buffer.compare; Buffer.compare = function (a, b) { console.log(`[${getTime()}] [检测到Buffer.compare调用] 比对的两个Buffer:a=${a.toString('base64')}, b=${b.toString('base64')}`); return originalBufferCompare.call(this, a, b); }; const originalBufferEquals = Buffer.prototype.equals; Buffer.prototype.equals = function (other) { console.log(`[${getTime()}] [检测到Buffer.equals调用] 比对的Buffer:当前=${this.toString('base64')}, 目标=${other.toString('base64')}`); return originalBufferEquals.call(this, other); }; // ============================================== // 监控【假设二】:二次哈希验证 → 监控crypto.verify/crypto.createHash // ============================================== const originalCryptoVerify = crypto.verify; crypto.verify = function (alg, data, pubKey, sig) { console.log(`[${getTime()}] [检测到crypto.verify调用] 算法:${alg} | 公钥:${pubKey.toString().slice(0, 50)}...`); return originalCryptoVerify.call(this, alg, data, pubKey, sig); }; const originalCryptoCreateHash = crypto.createHash; crypto.createHash = function (alg) { console.log(`[${getTime()}] [检测到crypto.createHash调用] 哈希算法:${alg}(MD5/SHA1/SHA256等)`); return originalCryptoCreateHash.call(this, alg); }; // ============================================== // 监控【假设三】:转字符串处理 → 监控Buffer.toString,重点命中utf-8/utf8 // ============================================== const originalBufferToString = Buffer.prototype.toString; Buffer.prototype.toString = function (encoding = 'utf-8', start, end) { // 重点打印utf-8/utf8格式的调用,其他格式(如base64/hex)仅轻量打印 if (['utf-8', 'utf8'].includes(encoding)) { console.log(`[${getTime()}] [检测到Buffer.toString(utf-8)调用] 转换后字符串:${originalBufferToString.call(this, encoding, start, end)}`); } else { // 非utf-8格式,仅标记调用,避免日志刷屏 // console.log(`[${getTime()}] [检测到Buffer.toString调用] 编码:${encoding}`); } return originalBufferToString.call(this, encoding, start, end); }; // ============================================== // 劫持crypto.publicDecrypt:控制解密返回结果,基础监控 // ============================================== const originalPublicDecrypt = crypto.publicDecrypt; crypto.publicDecrypt = function (...args) { try { let pubKey, encryptedData; if (args.length === 2) [pubKey, encryptedData] = args; else if (args.length === 1 && typeof args[0] === 'object') [pubKey, encryptedData] = [args[0].key, args[1]]; // 打印解密入参 console.log(`[${getTime()}] [crypto.publicDecrypt执行] 待解密密文(base64):${encryptedData.toString('base64')}`); // 执行原生解密,保留原有结果(黑盒测试阶段不篡改,仅监控) const decryptBuffer = originalPublicDecrypt.apply(this, args); console.log(`[${getTime()}] [crypto.publicDecrypt成功] 解密后原始Buffer:${decryptBuffer.toString('base64')}`); return decryptBuffer; } catch (err) { console.error(`[${getTime()}] [crypto.publicDecrypt失败] ${err.message}`); throw err; } }; // ============================================== // 监控IPC:联动查看激活请求/响应,辅助验证结果 // ============================================== const originalIpcHandle = electron.ipcMain.handle; electron.ipcMain.handle = function (channel, listener) { return originalIpcHandle.call(this, channel, async (event, ...args) => { if (channel === 'offlineActivation') { console.log(`[${getTime()}] [IPC收到激活请求] 参数:${JSON.stringify(args)}`); try { const result = await Promise.resolve(listener(event, ...args)); console.log(`[${getTime()}] [IPC激活响应] 结果:${JSON.stringify(result)}`); return result; } catch (err) { console.error(`[${getTime()}] [IPC激活失败] ${err.message}`); throw err; } } return Promise.resolve(listener(event, ...args)); }); };
Typora v1.12.4 对解密后 Buffer 的处理逻辑为:RSA 密文长度前置校验 → 解密后 Buffer 转 UTF-8 字符串 → 字符串结构化校验(如 JSON 解析、关键字匹配等)→ 校验通过则激活成功,否则返回无效。
步骤1:从错误日志推断「密文长度前置校验」
日志中反复出现 RSA 解密错误:error:04000070:RSA routines:OPENSSL_internal:DATA_LEN_NOT_EQUAL_TO_MOD_LEN
- 该错误的核心原因:RSA 算法要求「待解密密文长度必须等于公钥模数长度」(例如 2048 位 RSA 公钥对应 256 字节密文)。
- 用户输入的激活令牌(如
3.33333333333333)对应的密文长度不满足要求,直接被 RSA 解密底层拒绝,未进入后续 Buffer 处理流程。 - 推断:Typora 在调用
crypto.publicDecrypt前/后,会隐含「密文长度校验」,只有密文长度与公钥模数一致,才会继续处理解密后的 Buffer;否则直接返回无效。
步骤2:从 Buffer.toString(utf-8) 调用日志推断「强制转字符串」
日志中多次触发 [检测到Buffer.toString(utf-8)调用],且转换后字符串均为合法 UTF-8 格式(如 fsPlus 模块代码、underscore 工具库代码、JSON 结构字符串):
- 无乱码日志,说明程序预期解密后的 Buffer 是「UTF-8 编码的字符串对应的 Buffer」,强制转换是固定步骤,无其他编码分支(如 base64、hex)。
- 结合已有测试结论(无 Buffer.compare/equals 调用),进一步确认:解密后不会直接操作 Buffer,所有后续处理均基于 UTF-8 字符串。
步骤3:从激活响应日志推断「字符串结构化校验」
用户多次输入无效令牌后,IPC 响应均为 [false,"Please input a valid license code"],且日志中无其他加密/哈希调用(排除二次哈希):
- 推断:转 UTF-8 字符串后,程序会进行「结构化校验」,可能包含:
- 字符串是否为合法 JSON 格式(激活相关信息通常以 JSON 存储,如
{"valid":true,"deviceId":"xxx","license":"lifetime"}); - JSON 中是否包含关键字段(如
valid: true、匹配的deviceId、合法的license类型); - 字符串是否包含特定关键字(如
typora-activation-valid这类校验标识)。
- 字符串是否为合法 JSON 格式(激活相关信息通常以 JSON 存储,如
- 用户输入的简单数字令牌,要么因密文长度错误未解密成功,要么解密后字符串不是预期的 JSON/结构化格式,导致校验失败。
步骤4:整合完整处理流程
结合日志细节和测试结论,串联出完整逻辑:
- 接收激活请求:渲染进程传入激活令牌(含密文部分),触发
offlineActivationIPC 调用; - 密文长度校验:检查令牌中的密文长度是否与内置 RSA 公钥模数长度一致,不一致则抛出
DATA_LEN_NOT_EQUAL_TO_MOD_LEN错误; - RSA 解密:长度校验通过后,调用
crypto.publicDecrypt解密得到 Buffer; - Buffer 转 UTF-8 字符串:强制调用
Buffer.toString('utf-8'),转换失败(如乱码)则视为无效; - 字符串结构化校验:解析字符串(如 JSON .parse),校验关键字段/关键字是否符合要求;
- 返回激活结果:校验通过则 IPC 返回
[true, "", "激活成功信息"],否则返回[false, "无效提示"]。
Typora 对解密后 Buffer 的处理逻辑核心是「以 UTF-8 字符串为核心的结构化校验 」,没有复杂的二次加密或 Buffer 直接比对,突破激活的关键在于:构造密文长度符合要求的激活令牌,使 RSA 解密后得到「包含合法校验字段的 UTF-8 字符串」(如 {"valid":true,"deviceId":"任意值","license":"终身"})。
-
在
launch.dist.js中添加以下代码(劫持 RSA 解密函数):javascript// 主进程入口最顶部执行!!! const crypto = require('crypto'); //const { ipcMain } = require('electron'); // 通用日志函数(替换writeLog,直接打印控制台,无依赖不报错) const log = (type, msg) => { const time = new Date().toLocaleString('zh-CN', { hour12: false }); console.log(`[${time}] [${type}] ${msg}`); }; // 保存crypto.publicDecrypt原生方法 const originalPublicDecrypt = crypto.publicDecrypt; // 重写crypto.publicDecrypt,返回带Proxy监控的自定义Buffer crypto.publicDecrypt = function (...args) { log('crypto.publicDecrypt', '触发解密,返回带监控的自定义Buffer'); // 1. 构造自定义Buffer(后续可替换为激活相关JSON,现在先测test) const originalBuffer = Buffer.from('test'); // 基础测试用,后续改 Buffer.from(JSON.stringify({valid:true}), 'utf-8') // 替换原来的 Buffer.from('test') //const activationJson = JSON.stringify({ // valid: true, // 核心:是否合法 // deviceId: "any-device-id", // 设备ID,填任意值 // license: "lifetime" // 许可证类型,终身授权 //}); //const originalBuffer = Buffer.from(activationJson, 'utf-8'); // 转UTF-8 Buffer,符合程序要求 // 2. 给原始Buffer加Proxy监控(核心修复版) const proxyBuffer = new Proxy(originalBuffer, { get(target, prop, receiver) { // 过滤Node.js内部Symbol字段,避免监控内部操作导致异常 if (typeof prop === 'symbol') { return Reflect.get(target, prop, receiver); } // 监控Buffer的属性读取(如length、toString等) log('👀 Buffer属性读取', `读取属性:${String(prop)}`); const result = Reflect.get(target, prop, receiver); // 若读取的是方法(如toString、slice),监控方法的调用和入参 if (typeof result === 'function') { return function (...args) { log('👀 Buffer方法调用', `调用方法:${String(prop)} | 入参:${JSON.stringify(args)}`); // 核心修复:方法执行时,this严格指向【原始Buffer实例target】,不指向Proxy return result.apply(target, args); }; } // 普通属性直接返回结果 return result; } }); // 3. 返回带监控的Proxy Buffer,替代原生解密结果 return proxyBuffer; }; // 🌟 通用日志函数(替换writeLog,无依赖不报错,带时间戳) const writeLog = (title, content) => { const time = new Date().toLocaleString('zh-CN', { hour12: false }); console.log(`[${time}] [${title}] ${content}`); }; // 🌟 1. 劫持crypto.publicDecrypt:返回构造的JSON Buffer,替代原生解密结果 // const originalPublicDecrypt = crypto.publicDecrypt; crypto.publicDecrypt = function (...args) { writeLog('crypto.publicDecrypt', '触发解密,返回自定义JSON Buffer(跳过原生解密)'); // 🌟 2. 构造自定义JSON:后续直接替换这里的字段,就能推导程序需要的激活字段 const customJson = JSON.stringify({ test: '123'.repeat(50) // 你原来的测试字段,后续替换为valid/deviceId等 // 进阶测试:先试简单激活字段 → { valid: true, deviceId: "任意值", license: "lifetime" } }); // 🌟 3. 构造Buffer:指定UTF-8编码(和程序预期一致,避免编码乱码) const result = Buffer.from(customJson, 'utf-8'); // 🌟 4. 重写JSON.parse:仅初始化一次,Proxy监控解析后的JSON对象属性访问 if (!JSON.originalParse) { JSON.originalParse = JSON.parse; // 保存原生parse方法 JSON.parse = function (text, ...args) { // 执行原生解析,保证JSON解析逻辑不变 const obj = JSON.originalParse.call(this, text, ...args); // 给解析后的JSON对象加Proxy,监控所有属性访问 return new Proxy(obj, { get(target, prop, receiver) { // 🐛 修复原有切片问题:避免text长度不足12报错,日志更美观 const logText = text.length > 12 ? text.slice(0, 12) + "..." : text; // 精准监控:哪个JSON字符串被访问了哪个属性 writeLog(`【👀 JSON监控】 ${logText} 被访问属性`, String(prop)); // 正常返回属性值,不干扰程序逻辑 return Reflect.get(target, prop, receiver); }, }); }; } // 🌟 5. 返回构造的Buffer,让程序走后续的JSON解析流程 return result; }; // 顺带保留IPC监控(可选,方便看激活请求响应) const originalIpcHandle = electron.ipcMain.handle; electron.ipcMain.handle = function (channel, listener) { return originalIpcHandle.call(this, channel, async (event, ...args) => { if (channel === 'offlineActivation') { log('IPC激活请求', `参数:${JSON.stringify(args, null, 2)}`); try { const res = await Promise.resolve(listener(event, ...args)); log('IPC激活响应', `结果:${JSON.stringify(res, null, 2)}`); return res; } catch (e) { log('IPC激活错误', e.message); throw e; } } return Promise.resolve(listener(event, ...args)); }); }; -
保存后启动,再次输入激活码
+test#点击激活,在日志文件中就能看到程序访问的 JSON 字段:deviceId、fingerprint、email、license、version、date、type(这些就是激活必需的字段)。
步骤6:实现离线激活劫持(核心目标)
-
构造合法激活数据 :
根据上一步推导的字段,构造真实的激活数据(
deviceId和fingerprint从 Machine Code 提取):-
打开 Typora 离线激活页面,复制"Machine Code"(比如
Y2Fxxxxxxxxxxxxxx==)。 -
把 Machine Code 解码(Base64 解码):用在线 Base64 解码工具(比如 https://base64.us/ ),解码后得到类似:
json{"v":"win|1.12.4","i":"CaXXXXXXXJ","l":"XXXXXXX | XXXXXXX | Windows"} -
其中:
l对应deviceId,i对应fingerprint,v对应version。
-
-
修改 RSA 解密劫持代码 :
把之前伪造的
fakeData换成真实的激活数据,让 RSA 解密直接返回这个数据:javascript// 替换之前的 crypto.publicDecrypt 劫持代码 crypto.publicDecrypt = function (key, buffer) { console.log('[RSA 解密被调用] 已返回伪造激活数据'); // 构造合法的激活 JSON(替换成你的 Machine Code 解码后的数据) const activationData = JSON.stringify({ deviceId: 'XXXXXXX | XXXXXXX | Windows', // 从 Machine Code 解码的 l 字段 fingerprint: 'CaXXXXXXXJ', // 从 Machine Code 解码的 i 字段 email: 'test@example.com', // 随便填 license: 'Cracked_By_Learn', // 随便填 version: 'win|1.12.4', // 从 Machine Code 解码的 v 字段 date: '01/04/2030', // 过期日期(填未来时间) type: 'Learn' // 随便填 }); return Buffer.from(activationData); // 返回伪造的解密结果 };``` -
拦截联网验证 :
激活后重启 Typora,激活状态会消失------因为程序会向
https://store.typora.io/api/client/renew发送请求验证,返回success:false就清除激活。继续在
launch.dist.js中添加代码(拦截网络请求):// 拦截联网验证请求,直接返回成功 electron.app.whenReady().then(() => { electron.protocol.handle('https', async (request) => { // 匹配目标验证地址 if (request.url === 'https://store.typora.io/api/client/renew') { console.log('[拦截联网验证] 已返回成功响应'); // 伪造成功响应 return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'content-type': 'application/json' } }); } // 其他网络请求正常转发 return electron.net.fetch(request, { bypassCustomProtocolHandlers: true }); }); }); -
最终测试:
- 启动 Typora → 帮助 → 离线激活 → 输入
+任意字符#(比如+abc123#)→ 点击激活。 - 看到"激活成功"提示,主界面左下角"未激活"图标消失,重启后激活状态仍在(成功)。
- 启动 Typora → 帮助 → 离线激活 → 输入
四、关键注意事项
- 全程备份 :所有原始文件(
app.asar、Typora.exe)都要备份,修改出错可恢复。 - 关闭自动更新:打开 Typora → 设置 → 通用 → 关闭"自动更新",否则更新后逆向失效。
- 仅用于学习:本文是Electron逆向技术研究,请勿用于商业用途,遵守软件许可协议。
- 命令路径不要错 :CMD 中执行命令时,先通过
cd 路径进入目标目录(比如解压app.asar时要在resources目录下)。 - 报错排查 :
- 启动闪退:大概率是
launch.dist.js代码写错(比如少括号、语法错误),检查代码格式。 - 激活失败:检查激活数据中的
deviceId、fingerprint是否和 Machine Code 一致。
- 启动闪退:大概率是
五、核心技术总结(0基础也能记住)
- asar 解压 :Electron 应用的核心代码在
app.asar中,需用asar工具解压。 - Fuses 配置:控制 Electron 应用的启动规则(比如是否允许加载解压后的源码)。
- API Hook :拦截 Node.js/Electron 的核心函数(
fs、crypto、ipcMain),修改其行为。 - 黑盒调试:不知道内部逻辑时,通过"伪造输入→监控输出"推导数据结构。
- 网络劫持:拦截远程验证请求,返回伪造的成功响应。
六、参考
https://www.52pojie.cn/thread-2084047-1-1.html
https://www.52pojie.cn/thread-2040749-1-1.html