本文是观学论坛的一篇优秀文章
观看雪地论坛作者ID:yumoqaq
目前最流行的md文件编辑器是Typora,它免费,简单,令人上瘾,但今年突然开始收费,让人不知所措。所以昨晚用一个简单的技巧,带大家体验破解第一个视角。
一
磨砺 Hoho : 准备
开发环境识别
打开 Typora.exe 用 IDA(不推荐,可能是我的笔记本有问题,IDA 剖析了三四个小时),打开它,发现程序中有电子,V8,因为前几天恰好剖析了一个 IE 漏洞,V8 关联了 JavaScript 引擎,此时猜测是和 JS 语言有关。
打开程序目录,检查有没有JS代码,找了找,看到asar文件和word节点,感觉那里有点熟悉。问杜尼昂,百度搜索ASAR,电子,节点模块
我
不知道不知道,我被一个搜索震惊了,原来这是一个使用 NodeJs 的 electron 框架开发的桌面应用程序,而 JS 也可以编写桌面程序。
继续搜索相关信息并获取以下信息:
1.electron使用Microsoft的V8引擎和渲染引擎(这意味着这些程序就像浏览器)。
2.电子有主要工艺和渲染工艺,以及
信息通过IPC交换,渲染进程只负责渲染(难怪我有几个进程附加调试。
3.Typora.exe是一个电子框架,基本上和开发者的代码无关(修改框架的复杂度太高,普通人不会动,所以不要反转)。
4.检查目录,发现app.asar.unpacked中有一个main.node,名字很奇怪,.node文件是什么
5.app.asar是打包的JS代码,而且是简单的打包,没有任何加密措施,电子也没有代码保护措施(圈子来测试)。
根据第五条信息,笔者尝试使用工具解压 asar,不知道解包失败的触发因素是什么(也许你必须使用 NodeJS 自带的解包工具?但是解包也是加密文件,我懒得下载nodejs,所以需要的时候我会解决),放到010editor查看,获取文件名和一些密文,此时我陷入了深深的两难境地。
尝试调试
打开typora进程,x64dbg附加,检查是否有有用的信息(一开始并不知道这是NodeJS开发,想着通过注册窗口跟踪程序进程,好家伙差点反转到引擎),发现有几个进程,下面的命令行参数可以看到渲染GPU等关键词, 目录参数指向 app.asar。
根据上面得到的信息,这些进程是由主进程
创建的,程序逻辑是由主进程处理的,所以附上主进程去看(没有参数的那种)。
看到主进程加载了main.node模块,什么.node也可以作为dll加载?也许它最初是一个DLL(不排除杀后手动加载)。
然后使用 PE 工具检查 VS2017 编译的 64 位 DLL。
二
乐谱:逻辑剖析
此时,信息是已知的:
1.JS 文件已加密
2.框架是电子
3. 框架加载主节点模块
4. 解析JS脚本的是V8引擎
5.C++ 支持节点 API 开发
根据我们的已知信息,让我们对程序的整体逻辑进行简单的分析。
1. 框架对代码没有保护,修改框架的难度太低,V8引擎不支持解析加密的JS代码,那么JS代码是怎么运行的呢?
从开发人员的角度来看,我可能会想到一个框架加载我的揭示代码,揭示JS代码,并将其发送到JS引擎执行。
2. 那么解密代码在哪里合适?
由于框架已编译
变成二进制,根据前面的分析,适当的C++开发,如果你想要最简单的方法来实现揭秘,加载同样针对 C/C++编译的二进制代码。在 Windows 平台上,如果要加载代码执行,则只剩下动态链接库。由此可以推断,之前找到的主节点可能是秘密模块。
逻辑摘要
三
追求卓越:主节点剖析
1. 根据
之前的猜想,main.node负责揭秘,根据程序员的开发习惯,Ctrl CV实现,即使用一些暴露的算法
2.IDA 加载 main.node,并按照反向约定,首先搜索一波字符串
在这一点上,我听到了关键字buffer,base64,app.asar等,猜猜,app.asar被加载到缓冲区中,然后显示base64。
3.继续保持搜索工作,base64太简单了,使用FindCrypt3插件,搜索算法常量。
此时,找到了 AES 的算法常数,前两个是重复的,可能是插件问题。
4.好吧,现在我面临一个问题,我不懂算法,如何揭开神秘面纱...我只能问杜娘,搜索AES加密揭秘原理和C实现代码。
5. 根据搜索通知:
6.反驳跟踪揭秘常数,找到这个函数后,继续交叉引用函数。
经过大约三四次跟踪,我找到了这个函数,在这个函数中,F5 查看了反编译的代码,并找到了对 app.asar 字符串的引用。
7.此时,猜测,此函数加载app.asar的内容并调用SUB_180003E40以揭示机密。
8.后续SUB_180003E40检查,此时找到了base64字符串引用,想必base64透露了缓冲区。
9.看到很多不知道的API,百度搜索得知这是一个Node API,简单看看函数#napi_call_function
NAPI_EXTERN napi_status napi_call_function(napi_env env, //环境
napi_value recv, //名为global的值
napi_value func, //要调用的javascript函数
size_t argc, //JavaScript函数的参数个数 类似argc
const napi_value* argv, //JavaScript函数的参数数组 类似argv
napi_value* result); //返回的JavaScript对象
10.根据文档中获取的信息,参考 Node API 的这一部分,得到了以下信息(猜这个函数被调用了)笔者看不懂这个句子类型,程序的目的是对对象进行base64编码?但是查看此代码,base64 也没有作为参数传递
Buffer.from( object, encoding )
object:此参数可以包含字符串,缓冲区,数组或arrayBuffer。
encoding:如果对象是字符串,则用于指定其编码。它是可选参数。其默认值为utf8。
Buffer.from(string[, encoding]):返回一个被 string 的值初始化的新的 Buffer 实例
11. 暂时忽略节点 API 的句型和功能,继续阅读。
看到这一部分
函数调用javascript调用dll,进入视图,发现 C 实现 AES 算法的结构相似,推测这部分是 AES 揭秘的。
分析摘要
根据当前的分析,获得以下信息
此时,如果你想继续破解,你必须首先获取js代码,有两种方法和你面临的问题。
1.分析算法,找到
key,如果是CBC模式,需要找到IV,然后使用揭秘算法解密app.asar的js代码
2.分析程序执行过程,发现后找到缓冲区,直接复制,完全揭示后得到js代码。
面临的问题:
四
大海捞针:寻找JS代码
1.根据前面的分析,我选择了第二种方式来获取js代码,并在程序执行过程显露后对JS代码进行分析。
2.分析前的js函数调用被揭示出来,从前面的分析中得知有两个参数,在V27的位置,V27是从参数a3+8得到的,动态尺度是一个波。
3.x64dbg 打开 typora.exe,下一个 DLL 断点,按照前面的分析,他是动态加载的(更多的 A 懒得换图片)。
4、设置断点后,在负载库中中断,进入模块入口,到IDA估算偏斜,定位后面分析的函数位置。
顺便说一下,记得使用 x64dbg 自带的 PEB 隐藏功能,忽略所有异常,这个模块有简单的反调试手段(看导出表,不分析反调试手段太简单了)。
定位函数的位置,直接看最后4位,这也是模块倾斜674A的位置。
5.休息后,检查解密(方便命名)函数的参数,在x64框架下,函数参数为rcx rdx r8 r9 rsp+0x20
前四个从左到右,超过四个在堆栈上,rsp+0x20 是起始地址,详情请参考 Google x64 调用约定。
6.R8 = a3 看到 *(a3 + 8) 的值,这个值就是之前剖析的 v27 地址,即 argv 继续查看针尖内容。
现在我从js函数中得到了buffer.的两个参数,第一个像密文,第二个不是预期的base64,所以前面的推理实际上是错误的吗?
7.注意这两个地址 0000079908482119 00000799083CFEA5 调用js函数后会发生哪些变化,直接到调用的位置,调试器同步来到这个位置。
来到这个位置再次确认参数,第五个参数是rsp+20,也就是rax的值,
rax 是 argv,输入显存查看上面的相同地址,即 (A3 + 8),单步完成这个函数,看看参数的变化。
8.执行后,检查刚刚记录的位置,好了,好了,玩我,没什么变化,后续没有调用相关数据(可能是最后一个参数返回了一个对象,忘了看,如果返回的话应该是和密文有关的东西,放在一个数据结构中,所以没看到IDA中直接使用的行为)。
继续分析,到AES解密代码部分,既然是揭秘,就必须取密文的缓冲区。
我首先看到的是一串十进制参数,以 v46 开头的链表正好是 32 字节,也就是 256 位,有点像 AES-256。然后我看到我申请了 32 字节的视频内存,v32
之后,调用 sub_18000B060 函数在 v46 和 v32 上运行,参数直观地(目标地址、源地址、大小)
进入sub_18000B060函数查看,很多操作,根本不想看,按照前面的猜想,可能是笔者觉得直接把密钥放进程序里不合适,所以密钥类似于揭秘或者哈希操作工作(不展开分析)。
9. 继续解剖
你可以看到sub_180007000函数,参数 v45 IDA 提示我是 char[256] 链表,v32 是一个 32 字节的地址,v10 是一串神秘的数据。你可以得到一个推论,在 v46
中经过一系列操作后,你得到一个相同大小的 32 字节数据,然后用 v10 神秘数据操作数据,放入 v45 的 256 字节链表中(好家伙,这是关键吗?
跟进sub_180007000视图,将 v10 放在 v45 链表的0xF0位置
调用sub_180007800函数对自己的PE文件做了一些操作后,简单看了一下上面的汇编,主要内容是,把v32放进v45,大小为32字节
10.继续解剖
接下来我们来看看sub_180005c00函数,使用 v27,之前分析的密文地址在 v27 中,v30 没有往下看,应该在传出参数之前使用。猜
猜这个函数对密文执行一波操作,看看返回值是用来干什么的,这个伪代码看头疼javascript调用dll,组装起来。
.text:0000000180004021 call sub_180005C00
.text:0000000180004026 mov rbx, rax
.text:0000000180004029 mov r14, [rax+8] ;返回值+8的内容给 r14
.text:000000018000402D sub r14, [rax] ; r14 - 返回值的内容
.text:0000000180004030 mov rcx, r14 ; 得到一个大小 Size
首先确保 rax 是一个
手表指针*(RAX+8) - *RAX是这个地址上存储了两个值,取第二个值减去第一个值得到一个大小。
此时,猜测这两个值可能是密文的起始地址和结束地址?
然后用这个大小申请一块显存,IDA叫Block,sub_18000B060之前分析过,操作在*v12上,结果给块
替代 V12
转换为 rax,*(V12+8) - *V12,将结束地址除以起始地址得到大小
由此,我们可以验证*v12是密文起始地址,V13是密文大小的猜想
11.继续剖析第三个盒子的内容
大小为 v13 + 1,应用一块视频内存 v14,sub_18000B060再次操作该块,结果为 v14。
v14 的最后一个字节设置为 0,大概已经将密文转换为字符串,需要 NULL 结尾。
;v15 = r8d rcx = v13 rbx = v14
.text:0000000180004094 movsxd rcx, r14d
.text:0000000180004097 movzx r8d, byte ptr [rcx+rbx-1]
v15 = v14[v13-1],即从 v14 中取一个字节值,位于空字符之前。
12. 继续剖析sub_180006AC0
可以看到sub_180006AC0、v45(256字节链表)、block、v13的参数。
结合前面对sub_180007000的分析可以看出,v45 的当前状态,v45[0-31] 是一个 32 字节键状的东西,v45[0xF0] 是 v10 的神秘数据,v13 是区块大小。
简单回顾一下,看了又觉得可读性不好后,笔者针对汇编代码再次修改了反编译代码。
__int64 __fastcall sub_180006AC0(v45,block,block_size)
{
if ( block_size )
{
v3 = block;
v5 = v45 + 0xF0 - (_QWORD)block; //v45+0xF0的地址 减去 block的地址得到v5
v6 = ((block_size - 1) >> 4) + 1; //做为外圈循环的次数
do
{
v7 = *v3;
//v7为 xmmword 16字节浮点寄存器 ,把block的内容取16字节给v7 16字节符合AES块大小
//由此推测block是真正的密文,将在这个函数中进行解密操作
sub_180007320(v3, v45); //用到了AES解密常量 应该是解密相关 并且对推测的key 也就是前32字节有一些操作
v8 = 16i64; //内圈循环16次
do
{
result = *((char*)(v3 + v5)); //block地址 + v5偏移 取一个字节内容
*(char*)v3 ^= result; //取block的1字节数据,与block地址 + v5偏移 进行异或
v3 = (__int128 *)((char *)v3 + 1); //block += 1
--v8; //总共16次 也就是16个字节异或
}
while ( v8 );
v5 -= 16i64; //外圈循环 v5 每次-16 也就是每次异或 异或的值都会变化 范围为-16字节
v45 + 0xF0 = v7; //block的16字节内容 给到v45+0xF0
--v6; //外圈循环次数
}
while ( v6 );
}
return result;
}
根据目前的分析,可以推断出sub_180006AC0函数是主要的揭秘算法函数,它看起来像AES CBC模型,因为它不熟悉算法,大胆推测。
密钥存储在 v45 中,前 32 个字节为 256 位,iv 存储在 block+v5 中(不确定这是否正确)
)。
13.继续剖析其余部分
好了,好了,看起来有点
头疼,后面的代码大致意思是神秘之后对数据进行一系列操作,最后返回一个缓冲区。
有兴趣的读者可以自己分析,但实在写不出来。
五
大锅抽奖:获取JS代码
1.根据上面的分析,我们已经大致了解了程序流程,到了调用secret函数的函数,我们只需要在彻底公开后获取透露出来的JS代码,并在执行时发送到JS引擎即可。
2. 根据
下层调用代码,可以获取、解密并返回一个值,作为参数调用JS函数,定位到678F偏移,x64dbg同步定位。
3.突破后,查看v28的内容,RSP+20的位置,然后继续查看这手牌的内容,最后在揭晓后得到unicode方法的JS代码。
4.复制内容,获取010editor,删除00,将其转换为ASCII方法,并检查获得的数据。
一串字符编码数据,看起来像是与键相关:
搜索许可证相关数据,找到很多,看起来也像代码,应该没问题
六
将鹿称为马:裂解可行性分析
修改文件破解
如果你懂算法和 NodeJS,可以通过分析找到关键键等数据,解包并揭示 app.asar 上的秘密操作,获取 JS 代码更改,然后打包回家
可能的问题:app.asar的完整性校准。
内存破解
简单说几个想法,因为main.node是一个后装模块,所以破解显存有些困难。
调试器加载:参考以上手段,在模块加载通知中断下,定位秘密功能破解并修改视频内存中的JS代码
导出表 HOOK:参考病毒木马使用的进程替换(puppet 进程)技术,创建进程后挂起,因为 main.node 中的节点 API 使用框架中的导入 API,所以可以将导入函数替换为自己的函数,调用时确定参数,如果是 JS 代码就改
DLL劫持:替换main.node,自己加载真正的main.node并调用,调用时,定位揭秘函数和钩子,等待JS代码再更改PE
代码注入:修改框架的PE文件,加载自己的DLL,加载后导入表钩子
可能出现的问题:主节点或框架的完整性校准,更强大的反调试手段。
方法还有很多,不再一一列举,这里只能提出思路
七点
对点 : 总结
通过这次逆向分析,我踩了很多坑,学到了很多东西,加深了逆向技术的基础。
作为逆向练习生,遇到不懂的,就不能,要正视困难,扬长避短,不要轻易放弃。
遇到苦恼的地方,不要徘徊太多,逆向分析应该是分析大方向,站在开发者的角度,根据分析功能推测作者的意图,找到关键的突破点。
结论
由于笔者不熟悉算法和 Node Js 开发,所以没有办法获取密钥等秘密数据(文中对算法的一些操作都是猜想,相信熟悉算法的掠夺者可以看到密钥在哪里)。没有
办法判断JS代码是完整还是不完整,是否还有未公开的部分,所以只能到此为止(好像是完整的)。
经过一段时间的努力,笔者已经成功实现了显存破解,详见上一部分。
见雪 ID:尤莫卡克
*本文原创由Yumoqaq创建,请注明来源于观雪社区
#往期推荐
1.
阿拉伯数字。
3.
4.
5.
6.
JavaScript调用栈、尾递归和自动优化详解
这里有新发布的Javascript教程,赶紧查看吧!
JavaScript 客户端脚本语言
Javascript 是一种基于对象、动态类型、区分大小写的客户端脚本语言,由 Netscape 的 LiveScript 开发而成。 提供更流畅的浏览效果。
本文主要介绍了JavaScript调用栈、尾递归和自动优化的解读,具有一定的参考价值。 有兴趣的朋友可以参考一下
调用栈
调用栈(Call Stack)是一个计算机基本概念,这里有一个概念:栈帧。
堆栈帧是指为函数调用单独分配的堆栈空间的一部分。
当正在运行的程序从当前函数调用另一个函数时,它将为下一个函数构建一个新的堆栈帧,并单步进入这个堆栈帧,该堆栈帧称为当前帧。 原始函数也有一个相应的堆栈帧,称为调用帧。 每个堆栈帧都会存储当前函数的局部变量。
当一个函数被调用时,它被添加到调用栈的底部,执行完毕后,该函数被从调用栈的底部移除。 并把程序的运行权(帧指针)交给此时栈顶的栈帧。 这种后进后出的结构就是函数的调用栈。
在JavaScript中,可以通过console.trace()轻松查看当前函数的调用帧
尾调用
在讲尾递归之前,首先要了解什么是尾调用。 简单来说,一个函数的最后一步就是调用另一个函数并返回它。
下面是一个正确的例子:
- // 尾调用正确示范1.0
- function f(x) {
- return g(x);
- }
- // 尾调用正确示范2.0
- function f(x) {
- if (x > 0) {
- return m(x)
- }
- return n(x);
- }
1.0程序的最后一步是执行函数g并同时返回其返回值。 2.0中,尾部调用不必写在最后一行,只要执行的是最后一步即可。
以下是该错误的示例:
- // 尾调用错误示范1.0
- function f(x) {
- let y = g(x);
- return y;
- }
- // 尾调用错误示范2.0
- function f(x) {
- return g(x) + 1;
- }
- // 尾调用错误示范3.0
- function f(x) {
- g(x); // 这一步相当于g(x) return undefined
- }
1.0最后一步是参数运算,2.0最后一步是乘法运算,3.0隐式有一个return undefined
尾调用优化
在调用栈部分,我们知道,当一个函数A调用另一个函数B时,会产生一个栈帧,调用栈中同时存在调用帧A和当前帧B。 这是因为当函数B执行时,还需要将执行权返回给A,那么函数A内部的变量、调用函数B的位置等信息都必须保存在调用帧A中。否则,当函数B执行完毕再继续执行函数A,就会乱了。
那么现在,我们把函数B放在函数A的最后一次调用(即尾调用)中,是否需要保留函数A的栈帧呢? 当然不会,因为它的调用位置和内部变量以后就不会再被使用了。 所以直接将A的栈帧替换为函数B的栈帧即可。 当然,如果外层函数使用了内层函数的变量,那么总是需要保留函数A的栈帧,典型的例子就是闭包。
网上有很多关于尾部调用的博客文章,其中流传最广的一篇就有这么一段话。 我不太同意。
- function f() {
- let m = 1;
- let n = 2;
- return g(m + n);
- }
- f();
- // 等同于
- function f() {
- return g(3);
- }
- f();
- // 等同于
- g(3);
以下为博客原文: 上面的代码中,如果函数g不是尾调用,函数f需要保存内部变量m和n的值、g的调用位置等信息。 但由于函数f在调用g之后结束,所以f()的调用记录可以在最后一步执行时完全删除,只保留g(3)的调用记录。
但我觉得第一个也是先进行m+n的操作,然后同时调用函数g返回。 这应该是尾调用。 同时m+n的值也是通过参数传递到函数g中的,并不是直接引用的,所以不能说需要保存f内部变量的值。
一般来说,如果所有函数调用都是尾调用,那么调用栈的宽度就会小很多,这样所需的显存就会大大减少。 这就是尾调用优化的含义。
尾递归
递归是一种在定义中使用函数本身的方法。 函数调用本身称为递归,函数在尾部调用自身称为尾递归。
最常见的递归,斐波那契数列,普通递归的写法:
- function f(n) {
- if (n === 0 || n === 1) return n
- else return f(n - 1) + f(n - 2)
- }
这种写法简单粗暴,但是存在一个严重的问题。 调用堆栈随着n的减少而线性减少。 当n很大的时候(我测了一下,n为100的时候,浏览器窗口会卡住..),就会爆栈(栈溢出,栈溢出)。 这是因为在这些递归操作中,同时保存了大量的栈帧,并且调用栈极长,消耗了大量的显存。
接下来,将普通递归升级为尾递归,瞧。
- function fTail(n, a = 0, b = 1) {
- if (n === 0) return a
- return fTail(n - 1, b, a + b)
- }
显然,它的调用栈是
- fTail(5) => fTail(4, 1, 1) => fTail(3, 1, 2) => fTail(2, 2, 3) => fTail(1, 3, 5) => fTail(0, 5, 8) => return 5
通过尾递归重写后,调用栈会一直更新当前栈帧,完全避免了栈爆炸的危险。
不过想法是好的,从尾调用优化到尾递归优化的出发点也是正确的,只是不一样:),我们来看看V8引擎官方团队的解释
适当的尾部调用已经实现,但尚未交付,因为目前 TC39 正在讨论对该功能的更改。
意思是人家已经做出来了,但是还得给你用:)嘿嘿,气死我了。
当然,人家肯定有他的正当理由:
在引擎级别清除尾递归是一种隐式行为。 程序员在写代码的时候可能没有意识到自己写了一个尾递归的无限循环,并且出现无限循环后不会报栈溢出错误,很难识别。 优化过程中堆栈信息会丢失,给开发者调试带来很大困难。
道理我都懂,但是如果我不信邪的话,我用nodeJs(v6.9.5)手动测试了一下:
好了,我完成了
手动优化
虽然我们暂时不需要ES6的尾递归高端优化,但递归优化的本质是减少调用堆栈,避免显存占用过多和堆栈爆炸的危险。 而且老话说,凡是能用递归写的函数javascript递归函数,都可以用循环写——Niklas Xiajavascript递归函数,如果把递归改成循环,这些调用栈问题不就迎刃而解了吗?
方案一:直接修改函数内部,循环执行
- function fLoop(n, a = 0, b = 1) {
- while (n--) {
- [a, b] = [b, a + b]
- }
- return a
- }
这种方案简单粗暴,缺点是不用递归的写法更容易理解。
选项2:Trampolining(蹦床功能)
- function trampoline(f) {
- while (f && f instanceof Function) {
- f = f()
- }
- return f
- }
- function f(n, a = 0, b = 1) {
- if (n > 0) { [a, b] = [b, a + b]
- return f.bind(null, n - 1, a, b)
- } else {
- return a
- }
- }
- trampoline(f(5)) // return 5
这种写法比较容易理解,但是trampoline函数的作用需要仔细看一下。 另一个缺点是需要改变原来函数的内部写法。
解决方案3:尾递归函数转为循环模式
- function tailCallOptimize(f) {
- let value
- let active = false
- const accumulated = []
- return function accumulator() {
- accumulated.push(arguments)
- if (!active) {
- active = true
- while (accumulated.length) {
- value = f.apply(this, accumulated.shift())
- }
- active = false
- return value
- }
- }
- }
- const f = tailCallOptimize(function(n, a = 0, b = 1) {
- if (n === 0) return a
- return f(n - 1, b, a + b)
- })
- f(5) // return 5
经过tailCallOptimize封装后,返回一个新的函数累加器,这个函数在执行f的时候才真正执行。 该方法不需要改变原有的递归函数,只需要在调用递归时使用该方法进行转置即可解决递归调用的问题。
总结
尾递归优化是个好东西,但由于暂时不用,所以我们在平时编码过程中应该对用到递归的地方非常敏感,时刻防止死循环、栈爆炸等危险。 毕竟,好的工具不如好习惯。