Electron应用逆向分析有哪些具体步骤和技巧?
摘要:一、逆向目标与核心思路 1. 目标 不是为了“破解软件”,而是学习 Electron 应用的逆向通用思路: 绕过反调试机制 突破文件完整性校验 劫持核心 API 分析逻辑 实现离线激活流程劫持 2. 核心概念 Electron 应用基于 N
一、逆向目标与核心思路
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)」,输入以下命令,能显示版本号就是安装成功:
node -v # 显示 v18+ 即可
npm -v # 显示 8+ 即可
三、详细逆向步骤(按顺序来,一步都不能漏)
步骤1:安装 Typora 并初步测试反调试
安装 Typora:默认路径 C:\Program Files\Typora,安装后先正常启动一次,确认能打开(然后关闭)。
测试反调试:
打开 CMD,输入命令(启动 Typora 并尝试调试):cd 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,输入命令(全局安装解压工具):npm i -g asar
备份并解压 app.asar:
依次在 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 打开这个文件,粘贴以下代码(复制完整,不要漏行):// 引入修改 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,输入以下命令运行这个脚本:# 先安装依赖工具 @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(解压后的源码入口文件)。
在文件最顶部,粘贴以下代码(拦截文件读取,重定向到备份目录):// 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:激活令牌格式校验(必过前置条件)
if ("+" == t[0] || "#" == t[t.length - 1]) {
e.next = 2; break
}
return e.abrupt("return"); // 格式不满足直接终止执行
要求激活令牌开头是+ 或者结尾是#,不满足则直接退出激活流程,无任何提示;
这是Typora离线激活令牌的专属格式标识,过滤无效的乱输入。
Step 2:令牌格式清洗,截取有效部分
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))中,解析失败直接弹错:
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:解析失败的异常处理
e.t0 = e.catch(3),
window.alert("Invalid Activation Token"), // 弹框提示「无效的激活令牌」
e.abrupt("return"); // 终止激活流程
任何解析错误(base64无效、JSON格式错误、分割后无数据)都会走到这里,弹框后直接退出;
这是用户最常遇到的「激活失败」提示的原因之一。
Step 5:显示加载状态,向主进程发起离线激活IPC请求
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,渲染进程按结果分支处理:
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):// 监控 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 监控:联动查看激活请求 / 响应结果,辅助验证。
// 主进程入口最顶部执行!!!黑盒测试全监控代码
// 通用时间戳函数,所有日志统一格式
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/结构化格式,导致校验失败。
步骤4:整合完整处理流程
结合日志细节和测试结论,串联出完整逻辑:
接收激活请求:渲染进程传入激活令牌(含密文部分),触发 offlineActivation IPC 调用;
密文长度校验:检查令牌中的密文长度是否与内置 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 解密函数):
// 主进程入口最顶部执行!!!
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/ ),解码后得到类似:{"v":"win|1.12.4","i":"CaXXXXXXXJ","l":"XXXXXXX | XXXXXXX | Windows"}
其中:l 对应 deviceId,i 对应 fingerprint,v 对应 version。
修改 RSA 解密劫持代码:
把之前伪造的 fakeData 换成真实的激活数据,让 RSA 解密直接返回这个数据:
// 替换之前的 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#)→ 点击激活。
看到“激活成功”提示,主界面左下角“未激活”图标消失,重启后激活状态仍在(成功)。
四、关键注意事项
全程备份:所有原始文件(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
