Electron应用逆向分析思路

一、逆向目标与核心思路

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 并初步测试反调试

  1. 安装 Typora :默认路径 C:\Program Files\Typora,安装后先正常启动一次,确认能打开(然后关闭)。
  2. 测试反调试
    • 打开 CMD,输入命令(启动 Typora 并尝试调试):

      cmd 复制代码
      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:定位入口文件

  1. 优先检查 resources 目录下的 package.json
    有些应用会把 package.json 直接放在 resources 目录下(而非打包进 asar),可以直接查看其中的 main 字段。
    比如 Typora 在 resources 目录下的 package.json 中,"main": "launch.dist.js",但这个文件实际在 app.asar 内。
  2. 替换加载优先级
    按照 Electron 的规则,resources/app 目录的优先级高于 app.asar
    你可以解压 app.asar 并重命名为 app 目录,这样 Electron 启动时会优先加载 app 目录中的源码,你就能直接修改入口文件(比如绕过反调试)。

步骤3:解压 Electron 归档文件(app.asar)

Electron 会把核心代码打包成 app.asar(类似压缩包),我们需要解压它才能看到源码。

  1. 安装 asar 解压工具
    打开 CMD,输入命令(全局安装解压工具):

    cmd 复制代码
    npm i -g asar
  2. 备份并解压 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
  3. 验证结果
    打开 C:\Program Files\Typora\resources,会看到新增 app(解压后的源码)、app.bak(备份的源码)、app.asar.bak(备份的原始归档)三个文件/文件夹。

步骤4:修改 Electron 配置(Fuses),允许加载解压后的源码

  1. 问题现象
    直接双击 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 才能修改加载优先级。

  1. 修改配置的方法
    • 新建一个文本文件,重命名为 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:绕过文件完整性校验(核心步骤)

  1. 问题现象

    只要修改 app 文件夹里的 launch.dist.js,启动 Typora 后几秒就闪退。

    原因:程序会校验 4 个核心文件的完整性(Hash 值),不匹配就调用 app.quit() 退出。

  2. 绕过原理

    劫持 Node.js 的 fs 模块(文件操作模块),当程序试图读取这 4 个文件时,让它去读我们备份的原始文件(app.bak 文件夹),这样 Hash 就匹配了。

  3. 实现方法

    • 打开 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:分析离线激活逻辑(黑盒推导)

  1. 前端格式校验
    打开 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字符串|签名字符串」** 的格式,按|分割为两部分;
  • 关键操作:对第一部分awindow.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个关键点:

  1. 主进程的offlineActivation方法 :渲染进程只是把处理后的令牌t传给主进程,主进程才是真正做令牌校验、签名验证、许可证有效性判断的地方,这段代码里没有任何验证逻辑,只是传参和接收结果;
  2. 激活状态的持久化_(!0)只是设置了内存中的全局状态P,Typora必然会把激活状态/许可证信息持久化到本地文件/注册表(比如Windows的注册表、macOS的plist文件),重启后读取该文件判断是否激活,这是破解的核心(比如直接修改本地持久化的激活状态)。
三、总结:Typora离线激活的完整闭环
复制代码
用户点击激活按钮 → 传入激活令牌t → 渲染进程做格式校验/解析 → 处理为标准JSON参数 → IPC调用主进程offlineActivation方法 → 主进程执行核心验证 → 返回激活结果s → 渲染进程根据s更新本地状态/页面 → 激活成功(P=true)/失败(弹框)

简单来说:渲染进程只做「令牌的格式处理、页面交互、状态更新」,主进程做「真正的激活验证」,激活的核心是主进程offlineActivation方法的返回结果s是否为true

后续逆向的核心方向就是:找到主进程中offlineActivation方法的实现代码,破解其令牌校验逻辑,或者强制让其返回s=true的结果

  1. 监控 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"],激活失败)。

  2. 推导激活数据结构

    程序会用 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 字符串后,程序会进行「结构化校验」,可能包含:
    1. 字符串是否为合法 JSON 格式(激活相关信息通常以 JSON 存储,如 {"valid":true,"deviceId":"xxx","license":"lifetime"});
    2. JSON 中是否包含关键字段(如 valid: true、匹配的 deviceId、合法的 license 类型);
    3. 字符串是否包含特定关键字(如 typora-activation-valid 这类校验标识)。
  • 用户输入的简单数字令牌,要么因密文长度错误未解密成功,要么解密后字符串不是预期的 JSON/结构化格式,导致校验失败。

步骤4:整合完整处理流程

结合日志细节和测试结论,串联出完整逻辑:

  1. 接收激活请求:渲染进程传入激活令牌(含密文部分),触发 offlineActivation IPC 调用;
  2. 密文长度校验:检查令牌中的密文长度是否与内置 RSA 公钥模数长度一致,不一致则抛出 DATA_LEN_NOT_EQUAL_TO_MOD_LEN 错误;
  3. RSA 解密:长度校验通过后,调用 crypto.publicDecrypt 解密得到 Buffer;
  4. Buffer 转 UTF-8 字符串:强制调用 Buffer.toString('utf-8'),转换失败(如乱码)则视为无效;
  5. 字符串结构化校验:解析字符串(如 JSON .parse),校验关键字段/关键字是否符合要求;
  6. 返回激活结果:校验通过则 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 字段:deviceIdfingerprintemaillicenseversiondatetype(这些就是激活必需的字段)。

步骤6:实现离线激活劫持(核心目标)

  1. 构造合法激活数据

    根据上一步推导的字段,构造真实的激活数据(deviceIdfingerprint 从 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 对应 deviceIdi 对应 fingerprintv 对应 version

  2. 修改 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); // 返回伪造的解密结果
    };```
  3. 拦截联网验证

    激活后重启 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 });
     });
    });
  4. 最终测试

    • 启动 Typora → 帮助 → 离线激活 → 输入 +任意字符#(比如 +abc123#)→ 点击激活。
    • 看到"激活成功"提示,主界面左下角"未激活"图标消失,重启后激活状态仍在(成功)。

四、关键注意事项

  1. 全程备份 :所有原始文件(app.asarTypora.exe)都要备份,修改出错可恢复。
  2. 关闭自动更新:打开 Typora → 设置 → 通用 → 关闭"自动更新",否则更新后逆向失效。
  3. 仅用于学习:本文是Electron逆向技术研究,请勿用于商业用途,遵守软件许可协议。
  4. 命令路径不要错 :CMD 中执行命令时,先通过 cd 路径 进入目标目录(比如解压 app.asar 时要在 resources 目录下)。
  5. 报错排查
    • 启动闪退:大概率是 launch.dist.js 代码写错(比如少括号、语法错误),检查代码格式。
    • 激活失败:检查激活数据中的 deviceIdfingerprint 是否和 Machine Code 一致。

五、核心技术总结(0基础也能记住)

  1. asar 解压 :Electron 应用的核心代码在 app.asar 中,需用 asar 工具解压。
  2. Fuses 配置:控制 Electron 应用的启动规则(比如是否允许加载解压后的源码)。
  3. API Hook :拦截 Node.js/Electron 的核心函数(fscryptoipcMain),修改其行为。
  4. 黑盒调试:不知道内部逻辑时,通过"伪造输入→监控输出"推导数据结构。
  5. 网络劫持:拦截远程验证请求,返回伪造的成功响应。

六、参考

https://www.52pojie.cn/thread-2084047-1-1.html
https://www.52pojie.cn/thread-2040749-1-1.html

相关推荐
嫂子的姐夫3 天前
25-jsl:gov公安(全扣补环境版)
爬虫·逆向·加速乐
嫂子的姐夫3 天前
24-MD5:红人点集登录+凡客网登录
爬虫·python·逆向·小白逆向练手
clown_YZ5 天前
KnightCTF2026--WP
网络安全·逆向·ctf·漏洞利用
这样の我11 天前
某海外上报接口 strData 纯算逆向
逆向
蔡霸霸i12 天前
掌上高考招生计划逆向爬虫爬取
逆向
嫂子的姐夫15 天前
017-续集-贝壳登录(剩余三个参数)
爬虫·python·逆向
热心市民老八19 天前
010editor 最新版破解
逆向·reverse
嫂子的姐夫21 天前
010-sha1+base64:爬虫练习网站
爬虫·python·逆向
嫂子的姐夫21 天前
012-AES加解密:某勾网(参数data和响应密文)
javascript·爬虫·python·逆向·加密算法