文章总结: 本文深入分析Typorav1.12.4安全机制,演示Electron应用逆向流程。通过修改Fuses配置绕过加载限制,利用Hook劫持fs模块与进程退出规避完整性校验。详细阐述监控IPC与劫持加密函数伪造离线激活数据,并拦截网络请求欺骗续期验证,最终实现激活。 综合评分: 90 文章分类: 逆向分析,漏洞分析,应用安全
Typora v1.12.4 安全分析:反反调试与激活劫持
原创
吾爱pojie
吾爱破解论坛
2026年1月4日 15:04 北京
作者论坛账号:steven026
Typora v1.12.4 安全分析:反反调试与激活劫持
前言
本文旨在以 Typora v1.12.4 为例,探讨 Electron 应用的安全机制与逆向分析思路。 本文默认读者已熟悉 Node.js 及 Electron 的基础知识,故不再赘述相关概念。 本文JavaScript友好,主要从 Node.js/Electron 层面切入,无需具备任何二进制/C++ 层面知识。
提示:本篇文章及包含的代码含有经过 AI 润色/优化部分。
环境准备
从官网下载截至本文发布时的最新版本 Typora(v1.12.4)。
默认安装路径:
C:\Program Files\Typora
初步调试尝试
首先尝试通过 --debug 或 --inspect 参数启动 Typora.exe。
会发现程序设置了反调试机制,当检测到命令行包含调试选项时,程序会自动拒绝启动并抛出错误。
定位入口文件
在 resources 目录下找到 package.json,其定义的入口文件为 "main": "launch.dist.js",但该目录下并不存在此文件。
观察同目录下的 app.asar 文件,可以确定入口文件已被打包在 Electron 的 ASAR 归档中。
根据 Electron 默认的加载策略,app 文件夹的优先级高于 app.asar 文件。因此,我们可以通过解压 app.asar 并将其重命名为 app 目录来提高加载优先级。
安装 asar 工具
复制代码 隐藏代码
npm i -g asar
解压并备份资源
复制代码 隐藏代码
asar extract app.asar app
robocopy app app.bak /E
rename app.asar app.asar.bak
由于暂时无需对 node_modules 或其他依赖库进行操作,此处仅解压 app.asar。
提示:Typora 对核心文件存在完整性校验机制。因此需要保留所有经过修改的原始文件的备份,以备后用。
分析入口代码
进入解压后的 app 目录,格式化并保存 launch.dist.js。
阅读代码后发现,该入口文件自定义了一个 V8 环境,并将 .jsc 字节码文件作为 Node 模块进行 require 加载。
除了初始化V8环境外,该文件不包含任何具体业务逻辑。
初步结论:
核心逻辑被编译在 atom.compiled.dist.jsc 字节码中,该 .jsc 文件本质上仍是一个 Node 模块。
坏消息:我们无法直接阅读或修改 .jsc 字节码。
好消息:.jsc 的运行完全依赖 Node.js/Electron 环境,无法脱离 JavaScript 代码去直接执行 C++ 逻辑。
虽然代码开头看上去很吓人,又是 VM 虚拟机、又是 V8 引擎,以为我们需要手撕二进制 / C++ 了,但是最后的require暴露了.jsc的本质还是一个Node模块。
这意味着我们可以通过 Hook(劫持)Node.js 或 Electron 的底层 API,间接分析、调试并修改其行为逻辑。
调试准备与环境劫持
修改 Electron Fuses 配置
基于上述简单入口分析后,我们准备开始进行调试。
首先直接运行Typora.exe,发现没有任何反应,回忆我们之前的操作,发现我们仅仅是将app.asar解压为了app
尝试还原 app.asar再次运行程序,发现恢复正常,这说明应用对app与app.asar加载优先级进行了限制。
查阅相关资料发现,Electron 应用可以通过 @electron/fuses 查询与配置应用配置。
Typora 的配置如下:
复制代码 隐藏代码
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
配置项 OnlyLoadAppFromAsar is Enabled 限制了程序只能从 app.asar 启动。
我们需要修改此配置,使 Electron 恢复默认的文件加载策略(优先加载 app 文件夹)。
(创建一个临时 .cjs 文件运行以下代码)
复制代码 隐藏代码
const { flipFuses, FuseV1Options, FuseVersion } = require('@electron/fuses');
const fs = require('fs');
const fullPath = 'C:\\Program Files\\Typora\\Typora.exe';
// 修改前先备份
fs.copyFileSync(fullPath, `${fullPath}.bak`);
// 修改fuse配置(同时会修改程序hash)
flipFuses(fullPath, {
version: FuseVersion.V1,
[FuseV1Options.OnlyLoadAppFromAsar]: false,
});
再次将 app.asar 重命名备份,此时 Typora.exe 已能正常运行。
然而,一旦修改 launch.dist.js,程序虽然能启动,但几秒后会自动退出。这表明存在完整性校验。
绕过完整性校验
校验逻辑显然位于 atom.compiled.dist.jsc 中。
(完整性校验代码位于Typora.exe可能性非常低,不利于维护;且如果位于,程序一般会立即退出而不是过了几秒才退出)
完整性校验显然分为两个部分,校验&退出
通过Node.js去检测一个文件的完整性,无非就是原生fs/http/fetch等模块,不管是哪个模块我们都有能力去劫持与欺骗
Electron应用的主动退出,无非是app.quit() / app.exit()或者process.exit() / process.kill()等,我们可以尝试将这几个函数全部拦截,就能做到即使完整性校验劫持失败也能使应用不主动退出,从而让我们有更多机会去调试
完整性校验分析结论 (TL;DR)
经排查,校验逻辑调用了 fs/promises 模块的 readFile 函数,分别读取以下 4 个文件并一一比对 Hash 值。一旦有任何不匹配,立即调用 app.quit() 退出程序。
复制代码 隐藏代码
C:\Program Files\Typora\resources\app/package.json
C:\Program Files\Typora\resources\app/launch.dist.js
C:\Program Files\Typora\resources\app/../page-dist/license.html
C:\Program Files\Typora\resources\app/../page-dist/static/js/LicenseIndex.180dd4c7.5789633d.js
绕过策略:我们可以劫持 fs.promises.readFile,当检测到路径中含有 resources\app/ 时,将其重定向到原始文件的备份目录 resources/app.bak/。
注入调试与劫持代码
在 launch.dist.js 中,将以下代码插入到 require 基础模块之后、加载 .jsc 字节码之前,确保调试与劫持的代码优先于核心逻辑生效。
复制代码 隐藏代码
// 输出调试日志
constLOG_PATH = 'D:\\Typora_Log.txt';
//fs.rmSync(LOG_PATH, { force: true });
functionwriteLog(...data) {
const log = `[${newDate().toLocaleString()}] [Log] ${data.join(' ')}\n------------------\n`;
fs.appendFileSync(LOG_PATH, log);
}
复制代码 隐藏代码
// Node模块require后会进行缓存,即使再次require会指向同一个对象
const electron = require('electron');
Object.defineProperty(electron.app, 'quit', {
value: function () {
writeLog('[🛡️ 拦截] 程序试图调用 app.quit(),已阻止。');
},
writable: true,
configurable: true,
});
electron.app.on('browser-window-created', (_event, win) => {
writeLog('【👀 监控】检测到 BrowserWindow 实例化!');
// 确保dom-ready后再打开DevTools 否则第一个窗口可能会无法打开
win.webContents.once('dom-ready', () => {
writeLog('【🔧】打开 DevTools...');
win.webContents.openDevTools({ mode: 'detach' });
});
});
提示:劫持
electron.app.quit会导致用户也无法正常关闭程序,需使用任务管理器强制结束。 当成功完成后续的文件校验劫持后,建议移除electron.app.quit劫持。
复制代码 隐藏代码
// resources/app/ → resources/app.bak/
const fsPathFrom = /resources[\\/]app[\\/]/i;
const fsPathTo = 'resources\\app.bak\\';
const fsHook = {};
['readFileSync', 'readFile', 'statSync', 'stat', 'Stats', 'StatsFs', 'open', 'openSync'].forEach((property) => {
fsHook[property] = fs[property];
fs[property] = function (filePath, ...args) {
if (typeof filePath == 'string' && fsPathFrom.test(filePath)) {
const redirectPath = filePath.replace(fsPathFrom, fsPathTo);
writeLog(`[🛡️ fsHook] 程序试图 fs.${property} 重定向 ${filePath} --> ${redirectPath}`);
return fsHook[property].call(this, redirectPath, ...args);
}
writeLog(`[🛡️ fsHook] 程序试图 fs.${property}${filePath}`);
return fsHook[property].call(this, filePath, ...args);
};
});
const fsPromisesHook = {};
['readFile', 'open', 'stat'].forEach((property) => {
fsPromisesHook[property] = fs.promises[property];
fs.promises[property] = asyncfunction (filePath, ...args) {
if (typeof filePath == 'string' && fsPathFrom.test(filePath)) {
const redirectPath = filePath.replace(fsPathFrom, fsPathTo);
writeLog(`[🛡️ fsHook/Promises] 程序试图 fs.promises.${property} 重定向 ${filePath} --> ${redirectPath}`);
return fsPromisesHook[property].call(this, redirectPath, ...args);
}
writeLog(`[🛡️ fsHook/Promises] 程序试图 fs.promises.${property}${filePath}`);
return fsPromisesHook[property].call(this, filePath, ...args);
};
});
离线激活逻辑分析
本节参考了文章:Typora 1.10.8公钥替换
吾爱pojie,公众号:吾爱破解论坛Typora 1.10.8公钥替换
Typora激活分为在线激活以及离线激活,虽然作者有劫持在线激活思路,但由于缺少在线请求响应样本,故无法给出相应的代码。 作者通过上述参考文章中的离线激活样本,成功劫持了离线激活代码,故本文只对离线激活进行分析与调试。
前端逻辑定位
通过上文[注入调试与劫持代码]开启 DevTools 后,进入“离线激活”页面。输入任意字符并点击激活,发现界面无任何响应,包括激活失败提示,说明存在前端格式校验。 利用 DevTools 的断点调试功能,监听激活按钮点击事件,我们定位到了 React 状态机中的关键逻辑:
代码未混淆,逻辑如下:
复制代码 隐藏代码
if ("+" == t[0] || "#" == t[t.length - 1])
// 激活码必须以 "+" 开头,或以 "#" 结尾
t = t.substr(1, t.length - 2)
// 去除激活码首&尾字符
// (注:Windows 环境下 window.webkit 为 false,后续逻辑可以忽略)
window.Setting.invokeWithCallback("offlineActivation", t);
// 核心:通过 Electron IPC 将处理后的激活码发送至主进程的 `offlineActivation` 频道
前端仅负责基础格式校验和 IPC 通信,真正的激活验证逻辑位于后端(主进程)。
为深入分析,我们对 IPC 通信进行监控:
复制代码 隐藏代码
// IPC通信监控: invoke <-> handle
const invokeFilter = ['document.addSnapAndLastSync', 'document.setContent'];
const originalIpcMainHandle = electron.ipcMain.handle;
electron.ipcMain.handle = function (channel, listener) {
// writeLog(`[IPC 注册] .handle 监听频道: "${channel}"`);
const filter = !invokeFilter.includes(channel);
return originalIpcMainHandle.call(this, channel, async (event, ...args) => {
filter && writeLog(`[👀IPC 请求] 收到 .invoke("${channel}") 参数:`, JSON.stringify(args));
try {
const result = awaitlistener(event, ...args);
filter && writeLog(`[👀IPC 响应] .handle("${channel}") 返回结果:`, JSON.stringify(result));
return result;
} catch (error) {
filter && writeLog(`[👀IPC 错误] .handle("${channel}") 执行出错:`, error);
throw error;
}
});
};
RSA 公钥解密分析
通过参考Typora 1.10.8公钥替换这篇文章,可以得知 .jsc 内部预置了 RSA 公钥,用于解密传入的激活码。
由于缺乏私钥,我们无法生成合法的加密激活码。但只要能定位到解密函数,我们就能通过 劫持返回值 的方式,直接伪造解密后的明文数据,从而绕过解密过程。
经测试,v1.12.4 版本依旧使用 Node.js 原生 crypto 模块的 publicDecrypt 方法。我们可以对此进行劫持:
复制代码 隐藏代码
const crypto = require('crypto');
const originalPublicDecrypt = crypto.publicDecrypt;
crypto.publicDecrypt = function (key, buffer) {
writeLog('-------------------------------------------');
writeLog('【👀 监控】 crypto.publicDecrypt 被调用');
writeLog('Key:', key);
writeLog('Buffer (Hex):', buffer.toString('hex'));
writeLog('-------------------------------------------');
return originalPublicDecrypt.call(this, key, buffer);
};
输入符合前端规则的激活码(+ 开头,# 结尾)后,日志显示 crypto.publicDecrypt 确实被调用。这验证了我们的切入点是正确的。
黑盒调试:推导解密后数据结构
根据 crypto.publicDecrypt API类型发现,只有在公钥与密文匹配时才会返回 Buffer,否则会抛出错误。随便输入的激活码会导致程序返回 Please input a valid license code。
为了探究程序期望的解密结果,我们不再调用原始公钥解密函数,而是直接强制返回一个我们自己构造的 Buffer。
通过黑盒测试,我们尝试推断程序如何处理解密后的 Buffer:
-
假设一
:直接比对 Buffer?(经测试,无
Buffer.compare / Buffer.equals等调用) -
假设二
:二次哈希验证?(经测试,无
crypto.verify / crypto.createHash等调用) -
假设三
:转换为字符串再处理?(命中,检测到
Buffer.toString('utf-8')调用)
复制代码 隐藏代码
returnnewProxy(Buffer.from('test'), {
get(t, p, r) {
writeLog('【👀 监控】 Buffer get', String(p));
const result = Reflect.get(t, p, r);
// 如果结果为函数,二次监控其函数传参
if (typeof result == 'function') {
returnnewProxy(result, {
apply(fn, thisArg, args) {
writeLog(`【👀 监控】 Buffer.${String(p)} apply args=${JSON.stringify(args)}`);
try {
// 尝试先指向 Proxy
returnReflect.apply(fn, r, args);
} catch (e) {
// 再指向 Buffer
returnReflect.apply(fn, t, args);
}
},
});
} else {
return result;
}
},
});
日志显示 Buffer 被转为 UTF-8 字符串,并被读取了长度
复制代码 隐藏代码
[Log] 【👀 监控】 Buffer get toString
[Log] 【👀 监控】 Buffer.toString apply args=["utf8"]
[Log] 【👀 监控】 Buffer get length
[Log] [👀IPC 响应] .handle("offlineActivation") 返回结果: [false,"Please input a valid license code"]
尝试将字符串转变为Machine Code,发现结果仍不对,经过多轮尝试,剩余可能性已不多
进一步猜测,代码可能会将字符串通过JSON.parse解析为对象,然后对对象进行取值。
我们这次劫持 JSON.parse 去进行验证:
复制代码 隐藏代码
const result = Buffer.from(JSON.stringify({ test: '123'.repeat(50) }));
if (!JSON.originalParse) {
JSON.originalParse = JSON.parse;
JSON.parse = function (text, ...args) {
const obj = JSON.originalParse.call(this, text, ...args);
returnnewProxy(obj, {
get(t, p, r) {
writeLog(`【👀 JSON监控】 ${text.slice(0, 12)}..."} 被访问属性`, p);
returnReflect.get(t, p, r);
},
});
};
}
return result;
通过日志发现,我们终于命中了方法,并成功提取出了激活所需的关键字段。
复制代码 隐藏代码
[Log] 【👀 JSON监控】 {"test":"123..."} 被访问属性 deviceId
[Log] 【👀 JSON监控】 {"test":"123..."} 被访问属性 fingerprint
[Log] 【👀 JSON监控】 {"test":"123..."} 被访问属性 email
[Log] 【👀 JSON监控】 {"test":"123..."} 被访问属性 license
[Log] 【👀 JSON监控】 {"test":"123..."} 被访问属性 version
[Log] 【👀 JSON监控】 {"test":"123..."} 被访问属性 date
[Log] 【👀 JSON监控】 {"test":"123..."} 被访问属性 type
离线激活劫持
解码 Machine Code
离线激活界面显示的 Machine Code 显然是 Base64 编码。将其atob解密后得到以下内容:
复制代码 隐藏代码
{
"v":"win|1.12.4",
"i":"CaXXXXXXXJ",
"l":"XXXXXXX | XXXXXXX | Windows"
}
推测:v 为 version,i 为 fingerprint,l 可能对应 deviceId。
构造离线激活码
查阅相关文章后,我们大致确定了离线激活码可以是以下形式(部分字段可以随便填):
复制代码 隐藏代码
{
"deviceId":"XXXXXXX | XXXXXXX | Windows",
"fingerprint":"CaXXXXXXXJ",
"email":"[email protected]",
"license":"Cracked_By_DreamNya",
"version":"win|1.12.4",
"date":"01/04/2026",
"type":"DreamNya"
}
劫持公钥解密函数返回值
修改 crypto.publicDecrypt 的 Hook 逻辑,直接返回上述 JSON 的 Buffer:
复制代码 隐藏代码
crypto.publicDecrypt = function (key, buffer) {
writeLog('-------------------------------------------');
writeLog('【👀 监控】 crypto.publicDecrypt 被调用');
writeLog('Key:', key);
writeLog('Buffer (Hex):', buffer.toString('hex'));
// return originalPublicDecrypt.call(this, key, buffer);
// 直接返回伪造的明文 Buffer
returnBuffer.from(
JSON.stringify({
deviceId: 'XXXXXXX | XXXXXXX | Windows',
fingerprint: 'CaXXXXXXXJ',
email: '[email protected]',
license: 'Cracked_By_DreamNya',
version: 'win|1.12.4',
date: '01/04/2026',
type: 'DreamNya',
}),
);
};
查看 IPC 日志,响应终于从 false 变为 true 了,同时主界面左下角的“未激活”图标消失。
说明我们劫持crypto.publicDecrypt的方法确实有效,初步激活成功。
劫持联网验证
重启 Typora 后发现激活状态失效。分析日志发现,程序在启动时会再次调用公钥解密函数,由于该函数已被我们完全劫持,故本地校验仍通过了。
即使如此激活状态仍失效了,说明程序可能还存在远程验证。
我们可以通过抓包、劫持请求的方式去调试远程请求
经各种远程请求模块调试,最终发现 Typora 几乎均在用 electron.net.request 发送核心请求,
对此,我们可以利用 electron.protocol.handle 进行处理。
复制代码 隐藏代码
// 请求日志&拦截
electron.app.whenReady().then(() => {
electron.protocol.handle('https', async (request) => {
writeLog(`[👀electron.net Request] ${request.method}${request.url}`);
// 尝试打印 Request Body
try {
const reqClone = request.clone();
const reqBody = await reqClone.text();
if (reqBody) {
writeLog('[electron.net Request Body]:', reqBody);
}
} catch {}
const response = await electron.net.fetch(request, { bypassCustomProtocolHandlers: true });
// 克隆响应用于劫持 原始响应后续直接转发
const resClone = response.clone();
resClone
.text()
.then((resText) => {
writeLog(`[👀electron.net Response] ${response.status}${request.url}`);
writeLog('[electron.net Response Body]:', resText.substring(0, 500));
})
.catch((err) => {
console.error('[electron.net Response Error]:', err);
});
// 转发原始响应
return response;
});
});
经调试后发现,Typora在离线激活状态时,运行程序会自动将离线注册信息POST给https://store.typora.io/api/client/renew进行联网验证,
当响应结果为{success:false}时则自动清除之前的激活信息。
故我们直接通过请求url判断,拦截该url的请求,直接立即响应{success:true},即可骗过验证。
复制代码 隐藏代码
// 拦截目标请求,伪造响应
if (request.url == 'https://store.typora.io/api/client/renew') {
returnnewResponse(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
再次执行离线激活流程,更新代码、重启程序后,可以发现激活状态不会再掉了。 (建议在设置中关闭自动更新,并在最终成品中移除调试日志等不必要的代码)。
完结撒花
至此,我们仅凭 JavaScript 技术,就完成了Electron应用的逆向安全分析与实战应用。 本文展示了从反转 Fuses 配置限制、绕过文件完整性校验,到黑盒推导数据结构及网络请求劫持的完整流程。 但本文的目的不是为了分析、破解、激活特定软件,更多是一种通用的 Electron 应用安全分析思路。 旨在通过逆向分析的手段,挖掘到平时可能注意不到的安全漏洞、盲区,以便未来更好的正向。
-官方论坛
www.52pojie.cn
👆👆👆
公众号设置“星标”,您不会错过新的消息通知
如开放注册、精华文章和周边活动等公告
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:吾爱破解论坛 吾爱pojie《Typora v1.12.4 安全分析:反反调试与激活劫持》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论