Typora 最新版本1.14.10 离线激活逆向分析
前言
本文以 Typora v1.14.10 版本例,探讨 Electron 应用的逆向思路
免责声明
本文内容仅用于Electron应用安全研究与技术交流 ,旨在帮助读者理解 Electron 应用在安全分析、被逆向调试、被攻击面识别与防护以及加固方面的常见思路与潜在的风险点。 本文所涉分析过程、截图、日志与结论均以教育与防御目的呈现,不构成对任何特定软件、服务或厂商的恶意攻击建议。 本文仅提供安全研究分析与思路,可能存在漏洞或不足之处,作者不提供任何保证,请勿尽信本文内容,任何读者/用户因使用、引用或传播本文内容造成的一切可能直接或间接后果均由读者/用户自行承担。 本文不会提供任何成品,相关技术的使用须严格遵守适用的法律法规与行业规范,严禁将本文内容用于任何未经授权的测试、商业化侵权、破坏他人系统或其他违法违规行为。 如无法遵守上述声明,请勿阅读或使用本文内容。
本文参考论坛:bbs.tampermonkey.net.cn
1.程序入口定位
在 resources 目录下找到 app.asar , 它是 Electron的归档文件,因此我们可以用官方的工具解压,查看其入口文件
1.安装 asar 工具
bash
npm i -g asar
2.解压
bash
npx asar extract app.asar app
3.解压后可以看到如下界面

找到 package.json,其定义的入口文件为 "main": "launch.dist.js"
2.分析入口代码 (launch.dist.js)
首先格式化代码,方便阅读。然后发现 其中加载了 [atom](https://zhida.zhihu.com/search?content_id=286175342&content_type=Article&match_order=1&q=atom&zhida_source=entity).compiled.dist.jsc 字节码文件,并使用Node 模块进行 require 加载。 除了初始化V8环境外,该文件不包含任何具体业务逻辑。
字节码文件我们没办法直接反编译,依赖特定版本的v8引擎,感兴趣的可以试试 github上面的 ,https://github.com/xqy2006/jsc2js 可以反编译为近似javascript源码,然后交给AI阅读逻辑 ,可以在typroa里面 按住 shfit +f12 打开开发者控制台,查看v8引擎版本,可以看到这里使用的版本为:14.8.178.14-electron.0

可以看到业务逻辑都在jsc 本质都是 node的模块,这里我可以hook劫持 node 的api 或者electron 的底层 API,修改程序逻辑,完成hook.
3.准备调试环境
我们都知道electron 应用默认加载路径为 /app , 其次是 app.asar , 但是我们调试发现 即使我们 在第2步解压了 app.asar 到 app文件夹,程序还是没有加载app文件夹,可能是 Typora做了限制,查阅相关资料发现,Electron 应用可以通过 @electron/fuses 查询与配置应用配置
执行名命令:
bash
C:\Users\zc>npx @electron/fuses read --app "C:\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
WasmTrapHandlers is Enabled
可以看到 OnlyLoadAppFromAsar 为开启状态,我们需要修改为true
执行名命令:
bash
npx @electron/fuses write --app "C:\Program Files\Typora\Typora.exe" OnlyLoadAppFromAsar=off
此时Typora 程序加载的就是 app目录,我们可以愉快的进行hook ,先备份 launch.dist.js 为 launch.dist.js.bak
4.注入hook
将以下代码插入到 require 基础模块之后、加载 .jsc 字节码之前,确保调试与劫持的代码优先于核心逻辑生效。
bash
// 输出调试日志
const LOG_PATH = 'C:\Program Files\Typora\resources\app\Typora_Log.txt';
//fs.rmSync(LOG_PATH, { force: true });
function writeLog(...data) {
const log = `[${new Date().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 实例化!');
win.webContents.once('dom-ready', () => {
writeLog('【🔧】打开 DevTools...');
win.webContents.openDevTools({ mode: 'detach' });
});
});
//这里劫持退出事件,是因为Typora 有反调试机制,文件效验hash通不过会直接退出程序。
1.离线激活hook分析
对atom.compiled.dist.jsc进行反编译后得到的文件喂给AI 然后来摸清楚大概授权思路,或者直接使用最近比较火的REA,直接分析授权逻辑

那么我们是不是可以对解密进行劫持进而绕过验证,经测试,v1.14.10 版本依旧使用 Node.js 原生 crypto 模块的 publicDecrypt 方法。我们可以对此进行劫持:
bash
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);
};

输入符合前端规则( AI分析)的激活码(+ 开头,# 结尾)后,日志显示 crypto.publicDecrypt 确实被调用。这验证了我们的切入点是正确的。
2. 黑盒调试:推导解密后数据结构
根据 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')调用)
bash
return new Proxy(Buffer.from('test'), {
get(t, p, r) {
writeLog('【👀 监控】 Buffer get', String(p));
const result = Reflect.get(t, p, r);
// 如果结果为函数,二次监控其函数传参
if (typeof result == 'function') {
return new Proxy(result, {
apply(fn, thisArg, args) {
writeLog(`【👀 监控】 Buffer.${String(p)} apply args=${JSON.stringify(args)}`);
try {
// 尝试先指向 Proxy
return Reflect.apply(fn, r, args);
} catch (e) {
// 再指向 Buffer
return Reflect.apply(fn, t, args);
}
},
});
} else {
return result;
}
},
});
日志显示 Buffer 被转为 UTF-8 字符串,并被读取了长度
bash
[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"]
进一步猜测,代码可能会将字符串通过JSON.parse解析为对象,然后对对象进行取值。 我们这次劫持 JSON.parse 去进行验证:
通过日志发现,我们终于命中了方法,并成功提取出了激活所需的关键字段。
bash
[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
3.离线激活劫持
离线激活界面显示的 Machine Code 显然是 Base64 编码。将其atob解密后得到以下内容:
bash
{
"v": "win|1.12.4",
"i": "CaXXXXXXXJ",
"l": "XXXXXXX | XXXXXXX | Windows"
}
| 字段 | 含义 | 生成来源(.jsc 常量池 0x1c300--0x1c680) |
|---|---|---|
| v | 平台 | 版本 |
| i | 设备唯一标识(设备指纹)(10 字符) | 0x1c416(ComputerName);sha256 池中的readFile |
| l | 设备标签 | Windows: `COMPUTERNAME + " |
哈希管线:createHash('sha256') → update → digest (0x15c80),带[/=+-] 字符过滤(0x17ad1)和original 选项(原始值 vs 哈希值二选一)。
AI分析 Machine Code 生成逻辑如下:

AI分析 设备指纹 生成逻辑如下:

4.构造离线激活码
bash
/**
* 生成 RFC4122 v4 格式的随机 UUID(Typora 主进程内置实现)
* 模板与正则来自 .jsc 0x27e9f/0x27ed4 常量池,输出格式经本机
* profile.data 中的 uuid 实测验证(版本位 4、变体位 9 均吻合)
*/
function generateLicense() {
// 模板:小写 x 为随机位,'4' 固定版本号,y 为变体位(结果限定 8/9/a/b)
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
// 每个占位符取 0-15 的随机整数
var r = (Math.random() * 16) | 0;
// 'x' 位直接用随机值;'y' 位强制落入 RFC4122 变体区间 (r & 0x3) | 0x8
var v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
//获取机器ID
function getMachineGuidWin() {
const out = execSync(
'reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
{ encoding: "utf8" }
);
const m = out.match(/MachineGuid\s+REG_SZ\s+(\S+)/i);
if (!m) throw new Error("MachineGuid not found");
return m[1].trim();
}
//获取指纹
function typoraFingerprint(machineId) {
return crypto
.createHash("sha256")
.update(machineId)
.update("typora")
.digest("base64")
.slice(0, 10)
.replace(/[+/=]/g, "a");
}
const payload = {
"deviceId": `${hostname} | ${username} | Windows`,
"fingerprint": fingerprint,
"email": "suxx10@qq.com",
"license": license,
"version": "win|1.14.10",
"date": "10/23/2026",
"type": "DreamNya"
};
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);
// return new Proxy(Buffer.from('test'), {
// get(t, p, r) {
// writeLog('【👀 监控】 Buffer get', String(p));
// const result = Reflect.get(t, p, r);
// // 如果结果为函数,二次监控其函数传参
// if (typeof result == 'function') {
// return new Proxy(result, {
// apply(fn, thisArg, args) {
// writeLog(`【👀 监控】 Buffer.${String(p)} apply args=${JSON.stringify(args)}`);
// try {
// // 尝试先指向 Proxy
// return Reflect.apply(fn, r, args);
// } catch (e) {
// // 再指向 Buffer
// return Reflect.apply(fn, t, args);
// }
// },
// });
// } else {
// return result;
// }
// },
// });
// 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);
// return new Proxy(obj, {
// get(t, p, r) {
// writeLog(`【👀 JSON监控】 ${text.slice(0, 12)}..."} 被访问属性`, p);
// return Reflect.get(t, p, r);
// },
// });
// };
// }
// return result;
// 直接返回伪造的明文 Buffer
return Buffer.from(
JSON.stringify(payload),
);

说明我们劫持crypto.publicDecrypt的方法确实有效,初步激活成功。
5. 劫持联网验证
重启 Typora 后发现激活状态失效。分析日志发现,程序在启动时会再次调用公钥解密函数,由于该函数已被我们完全劫持,故本地校验仍通过了。 即使如此激活状态仍失效了,说明程序可能还存在远程验证。 我们可以通过抓包、劫持请求的方式去调试远程请求 经各种远程请求模块调试,最终发现 Typora 几乎均在用 electron.net.request 发送核心请求, 对此,我们可以利用 electron.protocol.handle 进行处理。
bash
// 请求日志&拦截
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 {
writeLog('[electron.net Request Error]:', err);
}
// 拦截目标请求,伪造响应
if (request.url == 'https://store.typora.io/api/client/renew') {
writeLog('[electron.net Request Intercept]:', request.url);
return new Response(JSON.stringify({ success: true, msg: btoa('DreamNya') }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
try {
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) => {
writeLog('[electron.net Response Error]:', err);
});
// 转发原始响应
return response;
}catch (err) {
writeLog('[electron.net Request Error]:', err);
throw err;
}
});
});
6. 二次本地验证
测试发现还是有机会掉,说明肯定还是有二次验证的地方,直接AI分析:

可以看到Typroa有三层机制,前2层我们已经解决了,最后一层,typroa 要效验文件hash,要么hook 计算hash的地方, 要么hook 读取文件
这里我们直接hook 文件api:
bash
const fsPromisesHook = {};
['readFile', 'open', 'stat'].forEach((property) => {
fsPromisesHook[property] = fs.promises[property];
fs.promises[property] = async function (filePath, ...args) {
if (typeof filePath === 'string' && filePath.includes('launch.dist.js') && !filePath.endsWith('.bak')) {
const newPath = filePath.replace('launch.dist.js', 'launch.dist.js.bak');
writeLog(`[🛡️ fsHook/Promises] 程序试图 fs.promises.${property} ${filePath}替换为${newPath}\n调用堆栈:\n` + new Error().stack);
filePath = newPath;
return fsPromisesHook[property].call(this, 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}`);
// 全量日志:字符串路径按扩展名过滤(含 asarbak 盲区修复),非字符串路径(Buffer/URL)一律记录
if (typeof filePath === 'string') {
if (/\.(asar|asarbak|json|jsc|js)$/i.test(filePath) && !filePath.includes('node_modules.asar')) {
writeLog(`[👀fs.promises.${property}] ${filePath}`);
}
} else if (filePath) {
writeLog(`[👀fs.promises.${property}] [非字符串路径:${filePath.constructor && filePath.constructor.name}] ${String(filePath)}`);
}
return fsPromisesHook[property].call(this, filePath, ...args);
};
});
// 监控哈希比对目标:记录 Buffer.equals / timingSafeEqual / createHmac
// typroa 验证写死的一段密钥
const origEquals = Buffer.prototype.equals;
Buffer.prototype.equals = function (other) {
try {
if (this.length >= 16 && other && other.length >= 16) {
var thisHash = this.toString('hex').slice(0, 64);
if(thisHash ==="7b226465766963654964223a227a686f756368616f207c207a63207c2057696e"){
return true;
}
writeLog(`[👀equals] A(${this.length}B)=${this.toString('hex').slice(0, 64)} B(${other.length}B)=${other.toString('hex').slice(0, 64)} => ${origEquals.call(this, other)}\n[B 出生地] ${bufOrigins.get(other) || '未知(原生/未追踪渠道创建)'}\n[A 出生地] ${bufOrigins.get(this) || '未知(原生/未追踪渠道创建)'}`);
}
} catch {}
return origEquals.call(this, other);
};
5.结尾:
至此,我们仅凭 JavaScript 技术,就完成了Electron应用的逆向安全分析与实战应用。
发布于 2026-10-26 13:31・重庆・信息来源于 官方网站・包含 AI 辅助创作 作者对内容负责