一、起因
一直比较喜欢Typora的简洁与美观(尝试过用 vscode 搭配插件编辑 markdown 文件,体验还是要差一些的),突然发现自己windows机器上很久前安装的typora不让用了,提示:
幸好原始安装文件还在,对比一下版本,原始安装文件是 0.9.93 版本,而不让用的是 0.10.11。
在我的 ubuntu22.04 机器上安装有 1.7.4 版本,win10上安装了 1.7.6版本。貌似最新的版本开始收费了,还分国内/国外两个版本。(typora.io 这个官网貌似被墙了,真不知道为了个啥。)
本着一个cracker无知者无畏的折腾到底的精神,开始瞎整!
二、摸底、知识和工具准备
😀 逆向分析的第一步就是要了解目标软件是用啥开发的,架构是啥。
一看 typora.exe 好家伙,100多个M,这么大的可执行程序还分析个啥?IDA分析一下都不知道要多久。
由于目前主用 ubuntu22.04 系统,想着顺便从 0 开始学习使用 ghidra 进行 Linux 平台的逆向分析工作。于是从一个小目标开始,需要瞎搞的知识点有了第一次扩充。
- ubuntu平台,用ghidra分析 1.7.4 版本;
- win7平台,用IDA6.8+x64DBG分析0.10.11版本;
- win10平台,用IDA6.8+x64DBG分析 1.7.6版本。(1.7.6 在我的win7下会出现不能在kernel32里面定位 discardVirtualMemory的错误)
悲催的是用 ghidra 静态分析 1.7.4 版本的 typora 有160多M,分析了10个小时也没结束。这个弯路绕太远了,毫无意义。直接放弃。
😀 正确的打开方式:直接网上搜索
有大神直接用 IDA 分析 typora.exe,提示了一些有用的东东。结合两位成功破解的大神的文章,可以确定 typora 是使用 electron + nodejs 框架,用 javascript 开发的跨平台的桌面程序。(和vscode类似)
所以,啥是 electron/nodejs/javascript?0 基础的暴走,奇怪的知识点需要进行第二次扩充。
这几个每一个都是大部头,越底层的难度越大。被V8引擎绕进去花了不少时间(虽然了解一下好处很多,带着VM的思路去了解,能有不少收获),但就typora的破解来说,只需要涉猎以下内容:
- electron app 的目录结构。
- asar 打包/解压工具 (npm i -g asar)
- node.js: 模块/API/运行环境构建/npm工具;node 命令行环境下运行 js 代码(类似python命令行环境)
- V8 引擎:几乎用不到,因为用到了 Addon N-API 接口库。当然了解V8的好处还是很多的。真实的托管类数据结构,都是在V8引擎里面定义的,有详细的参考手册。
- node.js Addon:通过 C++ 编写的扩展模块,用于扩展 Node.js 的功能。这个是重点,需要知道怎么写扩展模块的基本逻辑。(至少了解 V8 和 C++ 相互调用变量/函数的一两个例子)
- N-API(Node-API)是一组稳定的 C 语言 API,用于编写跨平台的 Node.js Addon。N-API 提供了一套抽象层,使得开发者可以更轻松地编写可移植的扩展模块,而无需担心不同版本的 Node.js 或不同平台之间的兼容性问题。
这个是需要重点把握的,熟练查阅 nods.js 中 N-API 的文档。
N-API 实际上是 V8 接口的封装,进行了抽象,但对逆向增加了困难。因为其参数都是一系列 void** 之类的指针,具体在 V8 层进行实际的数据类型转换。所以逆向时,对于托管对象的指针,就别想着查看其数据了。重点应该放在 C++ 的本地数据结构上。
三、聚焦 main.node
node.js Addon 可以在 js 层直接 require 一个 dll/so。electron 入口可能也是 require 引入 package.json 及里面定义的入口 js 文件的。
0.10.11 版本:PEid 查看算法常数,发现 sbox/rsbox,基本可以确定使用了AES算法。只是windows版本去符号,要从 rbox 逆推找到 AES KEY 和取定其 mode 难度还是比较大的。网上也有人分析到这一步放弃的。当然大神可以直接找到 mode CBC iv 和 AES KEY。
1.7.4 linux 版本:神奇的是没有去除符号,但与0.10.11版本不同,KEY 和 mode 的地方进行了混淆处理,不知道把 CBC 的 iv 怎么藏起来了。懒得去反混淆分析了(主要是能力不够)
有大神直接给出了 main.node 保护electron app的概念验证项目:https://github.com/toyobayashi/electron-asar-encrypt-demo
。
有了第二部分提到的知识准备后,就可以尝试看一下这个项目。当然像我一样 0 基础的,也可以边看边学,借助 N-API 文档和chatGPT的帮助。
简单总结一下这个项目的思路:
- 由于 node.js 在 js 层可以 hook api,任意隐藏加解密的手段都会失效,所以要想办法将加解密放到 C++ 层去做。(这里可以只将 license 模块放到 addon 里面实现,估计是太过于明显直接暴露攻击目标,所以选择隐藏和加密入口)
- 通过重载 V8::_compile() 函数,实现针对性的解密。具体实现有 global 和 this module 的发现/遍历/重建require函数/require原始入口等问题,不是专家,也没搞懂,就破解来说,也不需要搞懂,根据关键字定位 main.node 里的关键函数就行。
结合V8执行js的基本逻辑来理解上面的思路:
// 定义一个 JavaScript 代码字符串
V8::Local<V8::String> source_code =
V8::String::NewFromUtf8(isolate, "'Hello, ' + 'World!'").ToLocalChecked();
// 编译和运行 JavaScript 代码
V8::Local< V8::Script> script = Script::Compile(context, source_code).ToLocalChecked();
V8::Local< V8::Value> result = script->Run(context).ToLocalChecked();
V8执行js代码有两个过程,Compile 和 Run。Compile 只接受 V8 的托管字符串(js代码),而 C++ 字符串需要在 isolate(V8的一个独立的运行时虚拟机容器)内创建一个托管的 V8::String,用 N-API 库的话,相应函数是 napi_create_string_utf8(...)。
所以,既然把入口 js 的代码放到 C++ 层加解密,那么要执行它,比然经过上面的三步。重载 _compile 函数就是为了在这一步,给传入的 C++ string(js code)进行解密并创建对应的托管String,以便传给原来的 compile 函数。
理解了这个过程,那么最快、最容易的获取解密后的入口 js code,就在这个过程中。有两个思路,后面介绍。
四、分析 main.node
1、找入口:
- 0.10.11 windows版本,用IDA查看 exports 导出表,然后一路跟踪下来:
addon 开发最后一个宏 NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init) 实现的导出绑定,实际上就是调用 napi_module_register 注册了一个module,这个module结构包含一些源代码开发环境的一些信息,和一个__napi_main,在__napi_main中传递了 init 函数的地址。
- 1.7.6 win10版本,导出表里面只有一个 DllEntryPoint。需要手动在 imports 里面找 napi_module_register,剩下的流程和上面一样。可以定位到 sub_18000492B 就是 init 入口。
- 1.7.4 linux版本,用ghidra查看发现 exports 几乎导出了所有的符号名。不过作者还是遵循了
_init
这个函数名。
实际上找入口这一步是多余的,有了源代码只要结合关键字符串 _compile 和相应的函数调用,就能很快定位到 init 函数。只是作为一个cracker对流程跟踪的执念,瞎整。
2、找_compile
的重载函数:跟着源代码中的关键字符串找,很容易定位
module_prototype.DefineProperty(
Napi::PropertyDescriptor::Function(env,
Napi::Object::New(env),
"_compile",
ModulePrototypeCompile, // 就是这个函数
napi_enumerable, // 实际这个传递参数的地方做了一个wrapper。
addon_data));
3、作者的 trick 一。
疑似的ModulePrototypeCompile
这个函数往里面看的时候,和源代码完全不一致:
当时也懵了,因为自己是在windows系统上用IDA完全对照源码静态分析。使用静态分析工具查看 refrence to 和 refrence from 都连不上。无奈之下启用 x64DBG 调试。(找到真实的ModulePrototypeCompile
函数还是很容易的,因为根据源代码,这个函数里面有非常明显的字符串"app.asar",而且是一个对称的if ... else ...
结构。只是想弄清楚开发者到底怎么魔改的。)最后发现真实的ModulePrototypeCompile
地址被打包在了上面替换函数的参数里面。调用变成了 call qword ptr [r8]
,从而避免了静态分析时候被一撸到底。实际的trick是这样的:
直到分析 1.7.4 linux版本的时候,就看的很清楚了。
两者结合可以清楚看到多了一层 Wrapper 把真实函数地址隐藏在 Wrapper的参数里面。
4、AES解密
后面的部分对于 0.10.11 windows版本来说,和源代码几乎一模一样,就是AES解密的过程,没有源代码那么多函数层级,中间的函数调用估计被 inline 了。但基本结构是一样的。CBC 模式的 iv 被藏在了实际chunck的最前面16个字节。
对于 1.7.4 Linux 版本整体架构不变,但 getKey 和 getIV 做了混淆,IV 怎么藏的也没看明白。尝试用函数的返回值,参照老版本的格式进行解密,发现是错误的。对混淆没什么能力,还是绕路吧。毕竟最终目标是去 main.node 运行。
五、获取源代码/解密app.asar
1. 0.10.11 windows版本:
可以直接解密 app.asar(因为打包了几乎所有重要 js 文件,所以一个个调试获取源码也麻烦。)
其AES KEY就编码在指令中,无论是静态获取还是调试 getKey 处获取都可以。
IV 是放在chunck前的16个字节。根据这两个条件,可以直接编写代码对 app.asar 进行解密。
2. 1.7.4 linux 和 1.7.6 win10版本:
因为看不懂 key 和 iv 的混淆,只能骚操作——直接调试获取 atom.js 的源代码。(只加密了这一个文件,估计是要保证启动速度)
在第三部分的最后,卖了一个关子,说到有两种方式可以获取解密后的 atom.js 入口,这里介绍一下:
(1)其实这个main.node加密框架本身就提供了获取方法,既然能重载_compile
,在调用原始_compile
之前对js code 进行解密,那么这个框架本身,也可以被cracker用来再次重载_compile
,等着后续的main.node将解密好的 js code 传过来,然后 console.log 就可以了。
我不是搞开发的,看这个框架要配置编译环境貌似挺复杂的,留给有electron开发经验的朋友吧。这个方法的好处是:一次投入,终身享受。只要还使用这个框架,点下鼠标就能获得源代码。(当然typora作者也是有备而来,后面会讲到)
(2)napi_create_string_utf8,这个函数接受 C++ 字符串,可以直接从 GDB 或者 x64DBG 中把参数的值 dump 出来。不过因为调用这个函数的地方很多,需要手动定位 ModulePrototypeCompile 函数解密分支里面的那个,下断点。Decrypt
函数直接返回了托管字符串,所以还需要深入进去找到正确的返回前最后一个调用 napi_create_string_utf8 的地方。
iVar4 = napi_create_string_utf8(pnVar15,&DAT_00145b42,0,ppvVar14);
当然大神使用 frida 动态挂钩 napi_create_string_utf8 这种骚操作,咱小白也不懂。
六、去 main.node 运行
优点是可以保持最快的加载速度,相当于完全开源了。
1、0.10.11版本
根据0.9.93版本的目录架构,将解密后的文件丢到 typora/resources/app/
目录下,其他保持不变,就可以直接运行了。
2、1.7.4 linux 和 1.7.6 win10版本
使用解密后的 atom.js 按照 0.10.11 版本那样直接运行 win10 会弹窗提示 scheme 变量没有定义;linux 下有 typora 进程,不提示错误,需要用 GDB 调试运行才能知道,错误是一样的。这又是闹哪一出?
- 找不到暗桩怎么办?
一开始不能确定导致变量没定义的原因,可能在 js 层,也可能在 main.node 层。js 层搜索了一圈没啥发现,感觉暗桩及有可能还是在 main.node 里面。想个办法验证一下:
- patch 函数 ModulePrototypeCompile 里面对于加密/非加密 js code 的
if ... else...
逻辑,都转向非加密的逻辑。
- 将解密出来的 atom.js 打包成 app.asar。
- 可以成功运行
其实完成这一步,也可以实现破解了,后面注册逻辑就都是 js 层面的事情了。不过对于一个喜欢瞎折腾的 cracker,做到这个程度,不是很满意,于是继续瞎整。。。
- 作者的 trick 二
在 js 层搜索一下 scheme 没有特殊的用法,对照 0.10.11版本,scheme 是定义一个自定义协议解析的字段,其值为 typora,可以使用 typora://xxx 这样的协议进行处理。
在 main.node 里面全局搜索 scheme 啥也没发现,一时陷入僵局,作者显然是埋了一个深坑。因为是在 win10 下分析的去符号的版本,所以一时没有头绪。后来决定换个思路,atom.js 最后启动窗口肯定是要加载一个 html 的,这就是 typora 程序的入口。按照这个逻辑 发现了 “entry” 字符串/变量,其值应该是 window.html。现在有了两个键值对 scheme --> typora 和 entry --> 包含window.html。用这4个字符串去IDA搜索交叉引用,可以很快找到一个函数 sub_180012290。分析这个函数,实际一共埋了5个暗桩:addonPath,entry,scheme,shost,shostc。
这5个暗桩,有的是隐藏了键名,有的是隐藏了键值。
- 对键名的隐藏:
这是键值对 addonPath :app.asar。键名 addonPath 的ascii码有些字符做了 + 0x0a 处理。对键值 app.asar 没有处理,就是字符串引用。
- 对键值的隐藏,1.7.6 win10版本似乎是花了功夫,进行了比较复杂的处理,似乎还加了混淆。(懒得去深入研究,主要是能力不够)还是看 1.7.4 linux版本的比较直观。
这是键值对 entry :typora://app/typemark/window.html。键名没有加密,键值的处理方式很直观。当然 shost 的值藏的更复杂一些,主要是将单字节的 ascii 变成了4直接的 int。不一一列出来了。
- 最后的问题,这些键值对加到哪里去了?
安装函数linux版本名字是 _initGlobal。当然无论哪个版本,这个函数一开始的 napi_get_global 函数说明了一切。都加到 js 层的全局对象 global 里面了。由于我们要去 main.node 运行,5个暗桩实际上只有后面4个有用。
napi_get_global(env, global);
global['addonPath'] = 'app.asar'
global['entry'] = 'typora://app/typemark/window.html'
global['scheme']='typora'
global['shost'] = 'https://store.typora.io'
global['shostc'] = 'https://dian.typora.com.cn'
1.7 版本的 js 使用了 webpack 打包,main.node init 最后的入口调用有两步:
const a = require('./atom.js'); // require js 貌似一定要带路径,不然找不到模块,js 也不懂,唉
a(); // 从 webpack 打包的 atom.js 看,这是一个空函数。反正main.node 调用了,那就照葫芦画瓢吧。
有了上面这些信息,单独写一个 main.js 把4个全局变量加进去,按步骤调用入口就能实现去 main.node 运行了。
七、注册思路
这些属于 js 层面的东东,还是经过 webpack 打包和混淆过的。对于 javascript 小白来说,看这种代码和天书差不多。找了个debundle工具,能还原未加密的 dist/static/js/ 里面的文件,但还原 atom.js 报错。最后找了个 webcrack 工具,效果是比较好的格式化了 atom.js,比vscode js fommatter 之类的强。果断祭出大杀器——chatGPT。一开始心太黑,直接把整个 atom.js 复制给 chatGPT,想让它给格式化代码,并给出解释。结果 chatGPT 直接给了一个长长的菜谱,真的是用心良苦😂。
1、0.10.11版本
0.x版本应该都是作者发布的开发测试版本。而0.10版本很可能是作者第一个使用 main.node 进行加密的验证版本,因为 js 里面连个注册页面都没有,只有简单的验证逻辑和时间校验,超时不让用。
让 0.10.11版本重新跑起来也很间,直接将 License.js 里面表述注册与否的变量的初始值从 null 改成 true 就可以了。
hasLicense = true, // null,
2、1.7.4 linux 和 1.7.6 win10版本。
这两个版本在 main.node 里面对 AES KEY 和 IV,以及暗桩的处理上,有一些差异,win10版本的更复杂和强化了一些。js 层上,关于注册的 445 类,对win32系统有着更加强化的校验。1.7.4linux版本,似乎是可以无限试用的,超时验证逻辑存在bug。
js 分析后,确定 445 类是处理所有注册/校验相关的。联网校验优先,涉及数据结构:
SLicense 的初步结构:base64(RSA.encrypt(json2str({license:xxx, email:xxx, type:xxx...})))#failCounts#lastRetryDate
注册信息会用服务端私钥加密,本地用公钥解密。再进行 email 和 license 的校验。本地注册也是这个解密验证过程。网络验证不成功,会清空 SLicense,回到未注册版本。
由于公钥解密的介入,想要完美本地注册已经没有意义。想持续使用的思路大致两个(没仔细研究,可能还有坑,看个大概齐吧):
(1)无限试用:
手动修改 IDate(install date) 时间。
- win10 版本在注册表 HKCU\Software\Typora\IDate。
- linux 版本在 ~/.config/typora/{hash_machine_id} 文件里面。文件格式只是将 json 文件的 ascii 每个字节编成 16 进制文本保存。用 Buffer.from(dataStr, 'hex').toString() 就可以解码。单独改这里貌似也会有问题的,因为会用 ~/.config/typora/profile.data 文件的创建时间恢复 IDate。不过由于时间校验对linux版本的bug存在,似乎不用改,也可以一直免费用。(有可能是我对 js 的理解不够全面,不保证对)
(2)patch js code:
- 去掉RSA解密,其他保持不变,可以实现手动注册;然后将 shostc 改成和 shost 一样,利用GFW让网络注册失效。
- 直接 patch 掉整个注册校验过程。反正要patch,不如彻底一些,一劳永逸。
八、加固建议
由于采用 main.node 这种框架把入口的解密放到 C++ 层,那么不可避免的,框架本身可以用来直接获取解密后的 js code,或者是绕不开字符串从 C++ 到 V8容器托管的转换过程。这是 electron 项目保护最大的难点。
作者也进行了很多努力:RSA 关键的 key 和 iv 进行了隐藏和混淆;埋了一些暗桩并加密。但用前面的各种骚操作都可以绕过。
个人建议既然埋暗桩,与其花经历在几个全局变量的名字和值上进行各种花式操作,还不如把 445 注册校验类整体/部分放到 main.node
中。至少尽可能让不能脱离 main.node
运行。底层还能使用各种成熟的二进制保护技术,甚至可以引入虚拟机保护,增加逆向成本。
九、后记
typora风格的markdown编辑器,好像github上有开源的了,有道笔记的 markdown笔记似乎也改成typora这种所见即所得风格了(不过上图要付费,或者自己注册一个免费图床)。选择面变多了。不过typora依然是桌面最优雅的。
整个逆向分析过程学到了不少东西,毕竟几乎整个是从 0 开始学起的。
在 ubuntu 平台使用 ghidra 进行静态分析和动态调试;
对面向对象有了更深层次的理解;
托管与非托管的相互调用,从C#、V8 js、python,有点理解python作为胶水语言在这种场景下为什么强大;发现 js 后面的 AST 是一条不归路啊。
对框架的理解,在逆向中的作用越来越重要了。现在的程序似乎都依赖于某个框架。
参考文章:
《向Typora学习electron安全攻防》
《Typora 授权解密与剖析》
《Typora解密之跳动的二进制》
《Electron程序逆向(asar归档解包)》
《electron开发、打包与逆向分析》