吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 7449|回复: 203
收起左侧

[Web逆向] Typora v1.12.4 安全分析:反反调试与激活劫持

    [复制链接]
steven026 发表于 2026-1-4 03:52
本帖最后由 steven026 于 2026-1-4 16:24 编辑

Typora v1.12.4 安全分析:反反调试与激活劫持

前言

本文旨在以 Typora v1.12.4 为例,探讨 Electron 应用的安全机制与逆向分析思路。
本文默认读者已熟悉 Node.js 及 Electron 的基础知识,故不再赘述相关概念。
本文JavaScript友好,主要从 Node.js/Electron 层面切入,无需具备任何二进制/C++ 层面知识。

提示:本篇文章及包含的代码含有经过 AI 润色/优化部分。

免责声明

本文内容仅用于Electron应用安全研究与技术交流,旨在帮助读者理解 Electron 应用在安全分析、被逆向调试、被攻击面识别与防护以及加固方面的常见思路与潜在的风险点。
本文所涉分析过程、截图、日志与结论均以教育与防御目的呈现,不构成对任何特定软件、服务或厂商的恶意攻击建议。
本文仅提供安全研究分析与思路,可能存在漏洞或不足之处,作者不提供任何保证,请勿尽信本文内容,任何读者/用户因使用、引用或传播本文内容造成的一切可能直接或间接后果均由读者/用户自行承担。
本文不会提供任何成品,相关技术的使用须严格遵守适用的法律法规与行业规范,严禁将本文内容用于任何未经授权的测试、商业化侵权、破坏他人系统或其他违法违规行为。
如无法遵守上述声明,请勿阅读或使用本文内容。

环境准备

从官网下载截至本文发布时的最新版本 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再次运行程序,发现恢复正常,这说明应用对appapp.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 字节码之前,确保调试与劫持的代码优先于核心逻辑生效。

// 输出调试日志
const LOG_PATH = 'D:\\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 实例化!');

    // 确保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] = async function (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公钥替换

Typora激活分为在线激活以及离线激活,虽然作者有劫持在线激活思路,但由于缺少在线请求响应样本,故无法给出相应的代码。
作者通过上述参考文章中的离线激活样本,成功劫持了离线激活代码,故本文只对离线激活进行分析与调试。

前端逻辑定位

通过上文[注入调试与劫持代码]开启 DevTools 后,进入“离线激活”页面。输入任意字符并点击激活,发现界面无任何响应,包括激活失败提示,说明存在前端格式校验。
利用 DevTools 的断点调试功能,监听激活按钮点击事件,我们定位到了 React 状态机中的关键逻辑:

1.png

代码未混淆,逻辑如下:

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 = await listener(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);
};

2.png

输入符合前端规则的激活码(+ 开头,# 结尾)后,日志显示 crypto.publicDecrypt 确实被调用。这验证了我们的切入点是正确的。

黑盒调试:推导解密后数据结构

根据 crypto.publicDecrypt API类型发现,只有在公钥与密文匹配时才会返回 Buffer,否则会抛出错误。随便输入的激活码会导致程序返回 Please input a valid license code
为了探究程序期望的解密结果,我们不再调用原始公钥解密函数,而是直接强制返回一个我们自己构造的 Buffer。

通过黑盒测试,我们尝试推断程序如何处理解密后的 Buffer:

  1. 假设一:直接比对 Buffer?(经测试,无 Buffer.compare / Buffer.equals 等调用)
  2. 假设二:二次哈希验证?(经测试,无 crypto.verify / crypto.createHash 等调用)
  3. 假设三:转换为字符串再处理?(命中,检测到 Buffer.toString('utf-8') 调用)
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 字符串,并被读取了长度

[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);
            return new Proxy(obj, {
                get(t, p, r) {
                    writeLog(`【👀 JSON监控】 ${text.slice(0, 12)}..."} 被访问属性`, p);
                    return Reflect.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"
}

推测:vversionifingerprintl 可能对应 deviceId

构造离线激活码

查阅相关文章后,我们大致确定了离线激活码可以是以下形式(部分字段可以随便填):

{
  "deviceId": "XXXXXXX | XXXXXXX | Windows",
  "fingerprint": "CaXXXXXXXJ",
  "email": "DreamNya@Dream.Nya",
  "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
    return Buffer.from(
        JSON.stringify({
            deviceId: 'XXXXXXX | XXXXXXX | Windows',
            fingerprint: 'CaXXXXXXXJ',
            email: 'DreamNya@Dream.Nya',
            license: 'Cracked_By_DreamNya',
            version: 'win|1.12.4',
            date: '01/04/2026',
            type: 'DreamNya',
        }),
    );
};

3.png
4.png

查看 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') {
            return new Response(JSON.stringify({ success: true }), {
                status: 200,
                headers: { 'content-type': 'application/json' },
            });
        }

再次执行离线激活流程,更新代码、重启程序后,可以发现激活状态不会再掉了。
(建议在设置中关闭自动更新,并在最终成品中移除调试日志等不必要的代码)。

5.png

完结撒花

至此,我们仅凭 JavaScript 技术,就完成了Electron应用的逆向安全分析与实战应用。
本文展示了从反转 Fuses 配置限制、绕过文件完整性校验,到黑盒推导数据结构及网络请求劫持的完整流程。
但本文的目的不是为了分析、破解、激活特定软件,更多是一种通用的 Electron 应用安全分析思路。
旨在通过逆向分析的手段,挖掘到平时可能注意不到的安全漏洞、盲区,以便未来更好的正向开发。

免费评分

参与人数 106吾爱币 +107 热心值 +102 收起 理由
绿雪羚羊 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
XGrin + 1 + 1 谢谢@Thanks!
Raven95676 + 1 + 1 谢谢@Thanks!
ats20251111 + 1 + 1 我很赞同!
丶诺熙 + 1 + 1 我很赞同!
houshao + 1 + 1 广告贴,请遵守论坛版规!
lyq87 + 1 + 1 我很赞同!
Zoziee + 1 + 1 用心讨论,共获提升!
dachui666 + 1 + 1 用心讨论,共获提升!
beibeibei + 1 + 1 我很赞同!
RestoreJustice + 1 + 1 谢谢@Thanks!
l_xj + 1 太厉害了,学习了
DShadow_cn + 1 + 1 用心讨论,共获提升!
safumo + 1 + 1 我很赞同!
vLove0 + 1 + 1 谢谢@Thanks!
wulitaotao + 1 + 1 谢谢@Thanks!
smallchop + 1 + 1 谢谢@Thanks!
mengaily + 1 + 1 谢谢@Thanks!
Tonyha7 + 2 + 1 谢谢@Thanks!
ioyr5995 + 1 + 1 热心回复!
三十二变 + 1 + 1 我很赞同!
slmgr.vbs + 1 + 1 厉害!
0ling + 1 用心讨论,共获提升!
Leegh + 1 + 1 鼓励转贴优秀软件安全工具和文档!
qvqovo + 1 + 1 谢谢@Thanks!
梦旅意中人 + 1 + 1 用心讨论,共获提升!
wasd71 + 1 + 1 我很赞同!
nuanyy110 + 1 + 1 谢谢@Thanks!
Tiniaual + 1 + 1 我很赞同!
KnowledgeARI + 1 + 1 我很赞同!
Zhaowa + 1 热心回复!
laotun + 1 + 1 我很赞同!
ly0809 + 1 + 1 我很赞同!
allspark + 1 + 1 用心讨论,共获提升!
KEKAI2025 + 1 + 1 谢谢@Thanks!
Small-Bao + 1 + 1 谢谢@Thanks!
Hslim + 1 + 1 我很赞同!
IcePlume + 1 + 1 我很赞同!
pineapplex + 1 + 1 鼓励转贴优秀软件安全工具和文档!
江阳小道 + 1 + 1 用心讨论,共获提升!
1yttoni + 1 + 1 我很赞同!
xiaojuqs + 1 我很赞同!膜大佬
9324 + 1 我很赞同!
5omggx + 1 + 1 用心讨论,共获提升!
jiyu0418 + 1 + 1 谢谢@Thanks!
wangzhizhuo + 1 + 1 热心回复!
zhyyt + 1 + 1 谢谢@Thanks!
海的那边 + 1 + 1 用心讨论,共获提升!
timeni + 1 + 1 谢谢@Thanks!
疯狼的救赎 + 1 + 1 用心讨论,共获提升!
HSMX + 1 谢谢@Thanks!
Cleopatra + 1 + 1 我很赞同!
ming1332236 + 1 + 1 我很赞同!
m1y3ll0w + 1 + 1 就冲熬夜凌晨3点多发布,评分必须给!
Junhetianxia + 1 + 1 用心讨论,共获提升!
buluo533 + 1 + 1 用心讨论,共获提升!
sunyboy + 1 + 1 谢谢@Thanks!
ChiefWin + 1 + 1 谢谢@Thanks!
cloudkm + 1 + 1 感谢发布原创作品,并提供详细思路!
Leaf08 + 1 我很赞同!
LoveCode + 3 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
mr88fang + 2 + 1 太强了,漂亮
trueyao + 1 + 1 用心讨论,共获提升!
Ares11 + 1 谢谢@Thanks!
exluku + 1 + 1 热心回复!
BayMax2911 + 1 + 1 我很赞同!
longaotian123 + 1 + 1 用心讨论,共获提升!
ymwfish + 1 + 1 我很赞同!
feiben + 1 很详细,学到了
ABuSiDeLuoYin + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
FengYou + 1 + 1 谢谢@Thanks!
playboysen + 2 + 1 用心讨论,共获提升!
zhonglimh + 1 + 1 谢谢@Thanks!
zzzs1991 + 1 + 1 用心讨论,共获提升!
fengbolee + 1 + 1 我很赞同!
玩梦幻的大叔 + 1 + 1 我很赞同!
KKei + 1 + 1 我很赞同!
HOWMP + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
anning666 + 1 + 1 我很赞同!
Jial63 + 1 + 1 谢谢@Thanks!
qqycra + 3 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
杨辣子 + 1 + 1 谢谢@Thanks!
一只清纯的泰迪 + 1 + 1 热心回复!
NortonIt + 1 + 1 用心讨论,共获提升!
xxwlcm520 + 1 + 1 用心讨论,共获提升!
小朋友呢 + 2 + 1 热心回复!
cdroad + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
scz + 1 + 1 谢谢
dawnlh + 1 + 1 我很赞同!
jaffa + 1 谢谢@Thanks!
MFC + 1 + 1 谢谢@Thanks!
hewok + 1 + 1 谢谢@Thanks!
skywalkicer + 1 + 1 谢谢@Thanks!
Dickxie + 1 + 1 我很赞同!
max2012 + 1 + 1 谢谢@Thanks!
leweishang + 1 + 1 热心回复!
Liona + 1 + 1 谢谢@Thanks!
Forgetten + 1 + 1 谢谢@Thanks!
照片依旧 + 1 + 1 我很赞同!
snowfox + 1 谢谢@Thanks!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| steven026 发表于 2026-1-6 20:13

激活劫持的补充

关于仍然掉激活的情况,可以阅读 #79 以及 #121的研究分析
分析过程可以直接去看原帖,这里就直接放结论了,根据以下操作可以保持激活状态

更新拦截在线校验代码

经测试,在线校验不是一次性的,而是每12小时进行一次。
(程序运行一段时间后似乎会有二次随机校验)
在拦截https://store.typora.io/api/client/renew的请求时,响应JSON内容需要新增一个字段msg,这个字段和离线激活码相同,内容无所谓,但最好是个Base64字符串。
例如:

            return new Response(JSON.stringify({ success: true, msg: btoa('DreamNya') }), {
                status: 200,
                headers: { 'content-type': 'application/json' },
            });

提示:https://store.typora.io/api/client/renew是默认的官方服务器,如果在设置中勾选了使用国内服务器,域名可能需要根据日志中实际请求的服务器域名进行修改。

手动修改注册表

激活信息存储在注册表 HKEY_CURRENT_USER\Software\Typora
Typora在进行上一节提到的在线校验时,会根据响应结果修改注册表中的SLicense字段内容。

IDate字段为15天试用的开始日期,修改该字段会影响剩余试用天数。

SLicense字段为激活信息,以#分割;
第一段内容为上一节提到的响应中的msg
第二段0目前暂时不清楚有什么作用,可能代表激活成功;
第三段的日期经测试可能是存储的上一次在线校验时间。

因此在激活完毕后可以,手动修改一下SLicense的内容
例如,可以改为RHJlYW1OeWE=#0#1/1/2029

 楼主| steven026 发表于 2026-1-5 09:44
Cleopatra 发表于 2026-1-5 08:55
真的强,想问一下反调试这个花了多少的时间才能推导出来

这篇文章断断续续总共写了3天吧
反反调试/拦截分析,都是通用的原生JS技术,说难不难、说简单也不简单,至少要非常熟悉正向开发,才能知道去哪里埋点
Typora逆向最核心的地方在于jsc字节码,所有核心逻辑都写在这里面,我尝试过好久的反编译jsc结果连静态字符串都看不到……
所以无奈只能从JS层面去做黑盒调试,一点一点去猜出来他的逻辑
studied 发表于 2026-1-8 20:05
跟着文章的流程走了一遍最后成功了,之前c和java的hook代码太难了,感谢楼主出的这期js的文章,受教了。

附一个我整理好的一键自动脚本,懒人可以直接运行

[JavaScript] 纯文本查看 复制代码
const asar = require("asar");
const chalk = require("chalk");
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const readlineSync = require("readline-sync");
const WinReg = require("winreg");
const { flipFuses, FuseV1Options, FuseVersion } = require("@electron/fuses");

function getInsertCode(EnableHookDebug, atobMachineCode, email, nowDateStr) {
    return `
/** Hook破解开始 */
const electron = require("electron");

// 是否启用劫持调试
const HookDebug = ${EnableHookDebug ? "true" : "false"};

// 调试日志定义
const LOG_PATH = ".\\\\Typora_Hook_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);
}

// 开启调试窗口,阻止关闭 app.quit 调用
// Hook Electron 模块,监控 BrowserWindow 实例化及阻止 app.quit 调用
// Node模块require后会进行缓存,即使再次require会指向同一个对象
if (HookDebug) {
    Object.defineProperty(electron.app, "quit", {
        value: function () {
            writeLog("[&#128737;&#65039; 拦截] 程序试图调用 app.quit(),已阻止。");
        },
        writable: true,
        configurable: true,
    });
    electron.app.on("browser-window-created", (_event, win) => {
        writeLog("【&#128064; 监控】检测到 BrowserWindow 实例化!");

        // 确保dom-ready后再打开DevTools 否则第一个窗口可能会无法打开
        win.webContents.once("dom-ready", () => {
            writeLog("【&#128295;】打开 DevTools...");
            win.webContents.openDevTools({ mode: "detach" });
        });
    });
}

// Hook fs 模块,重定向对 resources/app 目录的访问
// 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(
                \`[&#128737;&#65039; fsHook] 程序试图 fs.\${property} 重定向 \${filePath} --> \${redirectPath}\`
            );
            return fsHook[property].call(this, redirectPath, ...args);
        }
        writeLog(\`[&#128737;&#65039; 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] = async function (filePath, ...args) {
        if (typeof filePath == "string" && fsPathFrom.test(filePath)) {
            const redirectPath = filePath.replace(fsPathFrom, fsPathTo);
            writeLog(
                \`[&#128737;&#65039; fsHook/Promises] 程序试图 fs.promises.\${property} 重定向 \${filePath} --> \${redirectPath}\`
            );
            return fsPromisesHook[property].call(this, redirectPath, ...args);
        }
        writeLog(
            \`[&#128737;&#65039; fsHook/Promises] 程序试图 fs.promises.\${property} \${filePath}\`
        );
        return fsPromisesHook[property].call(this, filePath, ...args);
    };
});

// IPC 通信进行监控
if (HookDebug) {
    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(
                    \`[&#128064;IPC 请求] 收到 .invoke("\${channel}") 参数:\`,
                    JSON.stringify(args)
                );
            try {
                const result = await listener(event, ...args);
                filter &&
                    writeLog(
                        \`[&#128064;IPC 响应] .handle("\${channel}") 返回结果:\`,
                        JSON.stringify(result)
                    );
                return result;
            } catch (error) {
                filter && writeLog(\`[&#128064;IPC 错误] .handle("\${channel}") 执行出错:\`, error);
                throw error;
            }
        });
    };
}


const crypto = require("crypto");

const originalPublicDecrypt = crypto.publicDecrypt;
crypto.publicDecrypt = function (key, buffer) {
    if (HookDebug) {
        writeLog("-------------------------------------------");
        writeLog("【&#128064; 监控】 crypto.publicDecrypt 被调用");
        writeLog("Key:", key);
        writeLog("Buffer (Hex):", buffer.toString("hex"));
    }
    // return originalPublicDecrypt.call(this, key, buffer);
    // 直接返回伪造的明文 Buffer
    return Buffer.from(
        JSON.stringify({
            deviceId: "${atobMachineCode.l}",
            fingerprint: "${atobMachineCode.i}",
            email: "${email}",
            license: "Cracked_By_DreamNya",
            version: "${atobMachineCode.v}",
            date: "${nowDateStr}",
            type: "DreamNya",
        })
    );
};

// 劫持联网验证
electron.app.whenReady().then(() => {
    electron.protocol.handle("https", async (request) => {
        writeLog(\`[&#128064;electron.net Request] \${request.method} \${request.url}\`);

        writeLog("request.url typeof:", typeof request.url, "value:", request.url);
        // 拦截目标请求,伪造响应
        if (request.url === "https://store.typora.io/api/client/renew") {
            if (HookDebug){
                writeLog(\`[&#128737;&#65039; 拦截] 伪造激活验证响应: {success:true, msg: \${btoa("DreamNya")}}\`);
            }
            return new Response(
                JSON.stringify({ success: true, msg: btoa("DreamNya") }),
                {
                    status: 200,
                    headers: { "content-type": "application/json" },
                }
            );
        }

        if (HookDebug) {
            // 尝试打印 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(\`[&#128064;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;
        }


    });
});
/** Hook破解结束 */
`;
}

let EnableBackup = false; // 是否备份原始文件
let EnableHookDebug = false; // 是否启用调试日志

const Typora_Installation_Path = "D:\\software\\Typora";
const resourcesPath = path.join(Typora_Installation_Path, "resources");
const asarPath = path.join(resourcesPath, "app.asar");
const appDir = path.join(resourcesPath, "app");
const appBakDir = path.join(resourcesPath, "app.bak");
const asarBakPath = path.join(resourcesPath, "app.asar.bak");
const TyporaEXE = path.join(Typora_Installation_Path, "Typora.exe");
const LaunchDistJS = path.join(appDir, "launch.dist.js");

// 随机生成一个符合前端验证格式的注册码
function generateRegCode() {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    let code = '+';
    for (let i = 0; i < 8; i++) {
        code += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    code += '#';
    return code;
}

function closeTyporaProcesses() {
    try {
        execSync("taskkill /F /IM Typora.exe");
        console.log(chalk.green("已关闭所有 Typora.exe 进程"));
    } catch (e) {
        console.log(chalk.red("Typora.exe 未运行或关闭失败,请手动关闭后继续。"));
    }

    console.log(
        chalk.yellow(
            "已尝试自动关闭所有 Typora.exe 进程,如果未关闭请手动关闭后再运行此程序。"
        )
    );

    // 回车继续
    console.log(chalk.cyan("请按回车键继续..."));
    readlineSync.question();
}

function setRegValue(regKey, name, value) {
    return new Promise((resolve, reject) => {
        regKey.set(name, WinReg.REG_SZ, value, function (err) {
            if (err) reject(err);
            else resolve();
        });
    });
}

function getNowDateStr() {
    const now = new Date();
    const dd = String(now.getDate()).padStart(2, "0");
    const mm = String(now.getMonth() + 1).padStart(2, "0");
    const yyyy = now.getFullYear();
    return `${mm}/${dd}/${yyyy}`;
}
const nowDateStr = getNowDateStr();

// 要求输入机器码和邮箱
console.log(chalk.cyan("请输入机器码: "));
const machineCode = readlineSync.question();
console.log(chalk.cyan("请输入邮箱: "));
const email = readlineSync.question();
// 询问是否开启备份(默认开启)与调试(默认关闭)
console.log(chalk.cyan("请选择是否开启备份与调试选项:"));
console.log(chalk.cyan("【建议开启】是否开启备份?(Y/N): "));
const backupAnswer = readlineSync.question();
console.log(chalk.cyan("【建议关闭】是否开启调试?(Y/N): "));
const debugAnswer = readlineSync.question();
EnableBackup = backupAnswer.toLowerCase() === "y";
EnableHookDebug = debugAnswer.toLowerCase() === "y";

// Base64 解码
function atob(str) {
    return Buffer.from(str, "base64").toString("utf-8");
}
const atobMachineCode = JSON.parse(atob(machineCode));

console.log(chalk.yellow("deviceId: " + atobMachineCode.l));
console.log(chalk.yellow("fingerprint: " + atobMachineCode.i));
console.log(chalk.yellow("version: " + atobMachineCode.v));

// 关闭所有 Typora.exe 进程
closeTyporaProcesses();
console.log(chalk.green("==== 开始破解... ===="));

async function main() {
    // 一、反反调试

    console.log(chalk.yellow("一、正在进行反反调试操作..."));
    console.log(chalk.yellow("解包 asar"));
    await asar.extractAll(asarPath, appDir);

    console.log(
        chalk.yellow("复制 app 到 app.bak(递归复制)【应对完整性校验】")
    );
    // 2. 复制 app 到 app.bak(递归复制)
    function copyDir(src, dest) {
        if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
        for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
            const srcPath = path.join(src, entry.name);
            const destPath = path.join(dest, entry.name);
            if (entry.isDirectory()) {
                copyDir(srcPath, destPath);
            } else {
                fs.copyFileSync(srcPath, destPath);
            }
        }
    }
    copyDir(appDir, appBakDir);

    console.log(chalk.yellow("移除 app.asar 文件"));
    // 3. 重命名 app.asar 为 app.asar.bak
    if (EnableBackup) {
        fs.renameSync(asarPath, asarBakPath);
    } else {
        fs.rmSync(asarPath, { force: true });
    }

    console.log(
        chalk.yellow("修改 Typora.exe 的 fuse 配置,允许加载未打包的 app 目录")
    );

    if (EnableBackup) {
        // 修改前先备份
        fs.copyFileSync(TyporaEXE, `${TyporaEXE}.bak`);
    }

    // 修改fuse配置(同时会修改程序hash)
    flipFuses(TyporaEXE, {
        version: FuseVersion.V1,
        [FuseV1Options.OnlyLoadAppFromAsar]: false,
    });

    console.log(chalk.green("反反调试操作完成!"));

    // 二、注入破解代码
    console.log(chalk.yellow("二、正在注入破解代码到 launch.dist.js..."));

    // 读取原文件内容
    let content = fs.readFileSync(LaunchDistJS, "utf-8");

    // 查找第一个require语句后的分号
    const requireRegex = /require\([^)]+\);/;
    const match = requireRegex.exec(content);

    if (match) {
        const insertPos = match.index + match[0].length;
        const insertCode = getInsertCode(
            EnableHookDebug,
            atobMachineCode,
            email,
            nowDateStr
        );
        // 插入代码
        content =
            content.slice(0, insertPos) + insertCode + content.slice(insertPos);
        fs.writeFileSync(LaunchDistJS, content, "utf-8");
        console.log(chalk.green("成功插入破解代码到 launch.dist.js"));
    } else {
        console.log(
            chalk.red("未找到 require 语句,破解代码未插入launch.dist.js。")
        );
    }

    console.log(chalk.green("注入破解代码完成!"));

    // // 三、注册激活
    // console.log(chalk.yellow("三、正在注册激活..."));
    // try {
    //     execSync(`start "" "${TyporaEXE}"`);
    //     console.log(chalk.green("Typora 已启动!"));
    // } catch (e) {
    //     console.log(chalk.red("Typora 启动失败,请手动打开。"));
    // }

    // const regCode = generateRegCode();
    // console.log(chalk.green(`您的注册码为:${regCode}`));
    // console.log(chalk.yellow("请复制并用于激活。"));
    // console.log(chalk.cyan("请按回车键继续..."));


    // // 关闭Typora进程
    // closeTyporaProcesses();

    // 三、修改注册表
    console.log(chalk.yellow("三、正在修改注册表以关闭联网验证..."));
    // 修改注册表,尽量关闭联网验证
    // 注册表路径
    const regKey = new WinReg({
        hive: WinReg.HKCU,
        key: "\\Software\\Typora",
    });

    try {
        await setRegValue(regKey, "SLicense", "RHJlYW1OeWE=#0#1/1/2029");
        console.log(chalk.green("SLicense 注册表字段写入成功"));
        await setRegValue(regKey, "IDate", nowDateStr);
        console.log(chalk.green("IDate 注册表字段写入成功"));
    } catch (err) {
        console.log(chalk.red("写入注册表失败:"), err);
    }

    console.log(chalk.green("==== 破解完成!使用愉快!===="));
    console.log(chalk.yellow("后续操作建议:\n"));
    const regCode = generateRegCode();
    console.log(chalk.green(`\t1.您的注册码为:${regCode} 请复制并用于激活。`));
    console.log(chalk.yellow("\t2. 关闭【自动检查更新】功能,防止被覆盖。"));
    console.log(
        chalk.yellow(
            "\t3. 关闭【Typora服务器使用国内服务器】功能,避免绕过联网验证失败。"
        )
    );
}

main();

免费评分

参与人数 2吾爱币 +3 热心值 +2 收起 理由
tracky + 2 + 1 谢谢@Thanks!
二元 + 1 + 1 懒人拿走了,感谢

查看全部评分

 楼主| steven026 发表于 2026-1-4 19:37
我凌晨的时候测试,同样的代码,无论多少次重新启动都不会掉激活
现在重新打开又会掉激活了,怀疑可能是激活码有时效性?

经测试:
第1次打开时未激活,离线激活后成功
第2次打开时已激活,本地校验、远程校验均成功
第3次打开时未激活,再次离线激活后成功
之后会一直循环下去2~3
说明第2此打开时可能存在一个未被发现的校验,或者是远程校验不仅需要返回success:true,还需要返回其他未知字段

观察`AppData\Roaming\Typora\typora.log`发现了一段日志内容
程序会从注册表`HKEY_CURRENT_USER\Software\Typora`读取2个字段`IDate`和`SLicense`
`IDate`显然是试用日期
`SLicense`显然是离线激活的注册信息
通过`[renewLicense] license renewed in 12h`可以判断,程序会每12小时远程校验一次,这也许就对上了我开头说的凌晨成功,晚上失败
至于远程校验的时间可能和`1/4/2026`以及时区有关

尝试将`SLicense`中的日期改成2029年,`IDate`改成15天前已失效日期
再次反复关闭再运行程序,发现激活状态又不会掉了
可以推断`SLicense`中的日期修改可能起作用了

INFO 2026/1/4 19:24:04  55  OS is win32 10.0.26100
INFO 2026/1/4 19:24:04  55  start LM in devVersion=false
INFO 2026/1/4 19:24:04  58  [watch L]
DEBUG 2026/1/4 19:24:04  58  [WindowsLicenseLocalStore] SLicense : aaaa#0#1/4/2029
DEBUG 2026/1/4 19:24:04  58  pure = undefined
INFO 2026/1/4 19:24:04  59  renew
DEBUG 2026/1/4 19:24:04  59  [renewLicense] license renewed in 12h
DEBUG 2026/1/4 19:24:04  59  [L] pass
DEBUG 2026/1/4 19:24:04  59  [WindowsLicenseLocalStore] IDate : 12/1/2025
DEBUG 2026/1/4 19:24:04  59  trailRemains is 0
INFO 2026/1/4 19:24:04  59  [L] installDate is 12/1/2025, trail remains: 0 days
DEBUG 2026/1/4 19:24:04  59  after setting
INFO 2026/1/4 19:24:04  59  typora version: 1.12.4
INFO 2026/1/4 19:24:04  84  [watch L] hasL: true


因此我们在完成js替换并成功激活后,可以尝试修改下注册表中的激活日期
至于上述推测是否正确,可能要明天才能再次测试了
justfly99 发表于 2026-1-4 15:22
不错,下载个1.12.4的试试。跑去官网下载的注意了,现在最新的是1.12.6的了,提示是修复了激活问题,估计是修复了这个的
linyuuki 发表于 2026-1-4 07:37
很厉害很详细
iamjjt 发表于 2026-1-4 08:45
牛了,向大拿致敬
zhzhx 发表于 2026-1-4 09:06
有没有成品哦
snowfox 发表于 2026-1-4 09:09
感谢分享, 有思路有原理,
花下野死鬼 发表于 2026-1-4 09:14
666,真是厉害啊
ydafu168 发表于 2026-1-4 09:17
什么样的水平才能看懂?
联盟少侠 发表于 2026-1-4 09:18
认真看完了,逆向就是一个熟悉的过程,对工具越熟悉真的就越快!大佬写的步骤和思路都非常清晰,真的是小白入门佳品!!
张向华 发表于 2026-1-4 09:20
技术牛人长见识了
飘浮 发表于 2026-1-4 09:24
写的不错 受教了。
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-1-13 09:30

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表