微信小程序逆向分析

微信小程序逆向分析

WeChatAppEx.exe 版本:2.0.6609.4

以融智云考学生端为例。

网上已经有关于微信小程序解密的非常优秀的文章,本着学习的目的便不参考相关内容。

笔者水平实在有限,如发现纰漏,还请读者不吝赐教。
如涉及侵权,请联系作者处理。

行为监控

工具:火绒剑

首先看看打开一个小程序微信做了点什么,对微信进行火绒行为监控。因为小程序最初在PC端运行,必然会相关文件在客户机上释放,所以我们主要关注微信的文件读写行为。

image-20230220190645549

注意到这里有类似文件释放的行为,在监控上访其实同样有读取此文件夹的行为,根据经验这其实就是一个简单的读取相关目录,发现没有相关程序逻辑文件后,主动请求服务器下载相关文件。

那我们的关注点来到\__APP__.wxapkg,根据前人的经验,这就是小程序的主要逻辑所在的地方。

其中可以监控到很多关于调用堆栈的信息,不过这些堆栈附近大概是文件释放相关逻辑,这并不是我们关注的重点。

image-20230220191050137

分析文件特征

我们用010Editor打开文件,任意一个二进制编辑器都可以。

image-20230220191351868

可以看到明显程序逻辑被加密那么我们关注点就来到了这个文件的解密操作。

wxapkg解密思路

那么有两种思路

  1. 很经典的思路,既然文件被加密,那么微信客户端装载此程序的时候必然要进行解密,那么必然要进行打开文件的操作,我们对CreateFile下断点,应该是可以找到打开文件的操作,记录打开的句柄,同时微信也需要对文件进行读取的操作。我们在ReadFile下条件断点,当传入的的句柄是我们获得的小程序文件的句柄时断下,调用堆栈附近应该就有相关解密的操作,这种操作可行性很大,但是相对比较麻烦,微信打开的文件很多,在CreateFile下断点可能比较麻烦。
  2. 如果微信使用的是比较常规的加密 算法,那么可以通过IDA的插件Findcrypt看看有没有比较明显的特征。

**需要注意到的是,在WeChat中并没用打开这个wxapkg的相关操作。那么斗胆猜测可能是微信重新启动一个加载器对wxapkg进行装载。**那么我们进行火绒剑全局监控,看看有没对wxapkg进行读取的操作。

重新监控

image-20230223081053737

可以观察到名为WeChatAppEx.exe对文件做了两次读取的操作,根据经验我们关注第二次读取,双击File_read操作,查看调用栈

image-20230223081312164

解密分析

跟随堆栈,我们在IDA和X64dbg中分别定位此位置。RVA:0x678353d

image-20230227192457113

可以看到此位置对文件进行了读取,我们在dbg中看看有没有文件的相关数据。附加到WeChatAppEx.exe,在对应位置下断点,并运行一个小程序。

image-20230227193059707

轻而易举的断下,并且我们观察到文件大小非常接近打开的加密文件大小,并且在堆栈中出现了相关文件信息。

我们步过查看readBuf内的数据。

image-20230227193456990image-20230227193548099

可以看到WeChatAppEx.exe读取了加密后的文件。那么我们不难理解WeChatAppEx.exe类似一个加载器,在运行时对文件进行解密装载。利用程序要对这一片内存进行读写的特征,我们对这篇内存区域下硬件访问断点(Xdbg的内存断点是针对内存页的,可能会断在奇怪的地方,可能是笔者不太会使用这种特性)

image-20230227194050235

可以看到在RVA:2784F7A处断下,比较奇怪的是此处对文件的前8字节赋值为0,不太能理解,索性我们对没有修改的内存再次下硬件访问断点(不要忘记卸载之前的断点)。

image-20230227194334132

再次断下时对之后的8个字节赋值为-1,同样比较难解,我们再次对剩余区域下硬件断点。

image-20230227194512906

再次在RVA:676C91E处断下,可以看到这里有对字符串操作的相关指令,根据movsb指令的功能

指令:                                          
     MOVSB, MOVSW, MOVSD
                        
     描述:
     移动字符串数据,复制由ESI寄存器寻址的内存地址处的数据至EDI寻址的内存地址处。

拓展的,我们分别观察RSI与RDI指向的内存区域

image-20230227194922432

尾部部分解密

解密例程分析

可以看到RSI指向的内存中有非完整的加密文件的十六进制形式(前8字节,即V1MMWXß被忽略),有意思的是,复制操作的指针并没有指向文件头部,而是指向了距离除去V1MMWXß之后的1024字节之后,这里有分块加密的特征,但是为什么程序没有在解密前1024字节处停下呢,可能是笔者疏忽或者程序先解密尾部部分在解密首部,既然已经到这里,我们不妨顺藤摸瓜。

image-20230227195444637

可以看到正在向RDI中赋值数据,步过至字符串操作完成,我们再对这片内存区域下硬件访问断点。

之后再次在RVA:676C91E出断下,类似的,字符串操作完之后,我们再次对RDI下硬件访问断点。

再次运行之后再RVA:302772F处断下

image-20230227200017372

观察到我们下断点的内存区域已经出现了../image字样,这无疑是非常让人兴奋的,可能这就是文件解密的位置。事实确实如此,我们取消硬件断点,在上一步断下出的循环中循环几次简单分析就可以确定,这确实是一个解密点,而且是非常简单的异或解密。事实上,这一部分解密过程与微信图片解密相同。

image-20230227200537597

现在,我们找到了密文的1024字节之后部分的解密例程。我们默认忽略前8字节的处理,读者可自行分析,装载器并没有对前8字节做过多处理,在解密过程中只是简单的忽略。

找到xorKey

那么我们rbp所对应的秘钥0x34从何而来呢,追踪异或Key使我们接下来要做的事情。

有请尊敬的IDA先生,我们在IDA转到RVA:302772A,即异或解密位置追踪Key从何而来。

image-20230227201450775

根据分析,a3即是xorKey,至于a3*0x1010101010… 目的是用int8类型的值a3填满rbp至8个字节,进行8个字节分组异或。我们对a3进行简单的重命名—>xorKey_

image-20230227201804254

可以看到xorKey作为参数被传入(为提高辨识度,笔者对此函数进行简单的重命名)

image-20230227201934770

查看对此函数的引用,有两个运行时调用,还有一个直接调用,处于防止跟飞情况,我们对此函数头下断来找到调用点。

image-20230227202307226

文件再次断下,并在堆栈中回溯,我们来到调用点RVA:302759B

在IDA中我们了解到xorKey在异或解密函数ContextDecode中作为第三个参数传递,且类型为int8,根据fastcall调用约定,我们关注寄存器R8:000000000014EE34,最后一个字节,即0x34。他是怎么来的呢,我们向上分析

VA:00007FF632C97589处对r8最后一个字节进行了赋值,我们看[rax+rcx-0x2]是什么。

image-20230227203353743

可以看到rcx指向一个字符串,实际上这是微信小程序的ID,即AppID,进行简单的分析,rax是AppID的长度,而减去0x2后,[rax+rcx-0x2]指向的是AppID字符串的倒数第二个字符,将字符所对应的ascii码赋值给r8,这样,我们xorKey就拿到了。我们在everything找搜索对应AppId:wxf2a0156c0235fc4c

image-20230227203857299

可以证实上面的说法。至此,尾部部分解密告一段落。

首部1024字节解密

解密例程分析

我们再次来到RVA:0x678353d上方的ReadFile处下断,因为根据之前分析,此处有全部密文出现,同样我们忽视前8字节,对剩余内存内容下硬件访问断点,尝试寻找前1024字节解密位置。

image-20230228074305148

重新在此处断下(RVA:676C91E),同之前分析,补过字符串操作之灵后,我们跟随目的地(RDI)内存区域,对其下硬件访问断点。

image-20230228095838590

再次在此处断下(RVA:676C91E)断下,重复上述步骤,在目的地地址下断。

运行之后再RVA:40DFE处断下

image-20230228100244971

这里就有比较令人兴奋的字段:the iv:16bytes,部分加密需要一个向量,我们不妨猜测,这里就是加密函数,我们在IDA中来到对应位置。

image-20230228100551868

之前笔者已经对一些变量进行分析并且重命名,所以看起来似乎一目了然,这些变量的命名,我们之后逐步分析但不是现在的关注的重点。

引起我们注意的是类似的汇编指令aesdeclast xmm2, xmm1,注意到字样“aes”就可以怀疑密文首部采用的是aes加密,事实上确实采用的是这用加密,从学习的角度,我们假设并不知情相关特征。

既然到了这一步,不妨运行看看相关内存区域有没有明文信息。

image-20230228101635112

运行若干步之后,我们在RSI所指内存区域中发现明文特征,这与之前分析尾部解密中得出的明文十分类似,至此可以确定,这一部分逻辑即是对前1024字节进行解密的逻辑。

那么我们下一步要解决的问题是:”这是什么加密“,以便我们能找出秘钥,自行写出解密脚本。

我们百度aesdeclast xmm2, xmm1,看看能不能收获一些有用的信息。

下面是来自于互联网的一些资料:

AESDECLAST — Perform Last Round of an AES Decryption Flow

Opcode/InstructionOp/En64/32-bit ModeCPUID Feature FlagDescription
66 0F 38 DF /r AESDECLAST xmm1, xmm2/m128RMV/VAESPerform the last round of an AES decryption flow, using the Equivalent Inverse Cipher, operating on a 128-bit data (state) from xmm1 with a 128-bit round key from xmm2/m128.
VEX.128.66.0F38.WIG DF /r VAESDECLAST xmm1, xmm2, xmm3/m128RVMV/VBoth AES and AVX flagsPerform the last round of an AES decryption flow, using the Equivalent Inverse Cipher, operating on a 128-bit data (state) from xmm2 with a 128-bit round key from xmm3/m128; store the result in xmm1.

Description

This instruction performs the last round of the AES decryption flow using the Equivalent Inverse Cipher, with the round key from the second source operand, operating on a 128-bit data (state) from the first source operand, and store the result in the destination operand.

128-bit Legacy SSE version: The first source operand and the destination operand are the same and must be an XMM register. The second source operand can be an XMM register or a 128-bit memory location. Bits (MAXVL-1:128) of the corresponding YMM destination register remain unchanged.

VEX.128 encoded version: The first source operand and the destination operand are XMM registers. The second source operand can be an XMM register or a 128-bit memory location. Bits (MAXVL-1:128) of the destination YMM register are zeroed.

请注意描述中的加粗部分,其大概意思是aesdeclast xmm2, xmm1执行的是反向解密的最后一轮解密过程,xmm1是round Key(拓展秘钥,aes将用户设置的秘钥进行拓展以便于运算),而xmm2即是最后一轮解密的数据。

现在我们可以确定这一部分加密使用的是AES加密,我们正在分析的是其对应的解密部分。根据AES加密的对应的解密过程,最后一轮解密使用的round Key正是用户设定的秘钥,关于AES使用类似指令的介绍以及加解密的细节问题,笔者收集到一篇优质文章:

Intel AES-NI使用入门 - 被遺忘的海灘 | Nagi’s Blog (x-nagi.com)

AES_Key生成例程分析

我们再次来到RVA:40DFE处

结合引用文章的介绍,我们大概可以得出:image-20230228110101401

那么我们接下来的关注点放到了拓展秘钥缓冲区,我们跟随秘钥缓冲区的生成会进入到秘钥拓展例程,在那里,我们大概率可以拿到Key,我们在IDA中追踪秘钥缓冲区

image-20230228110451042

v33对应的是Rcx,而拓展秘钥缓冲区即keyArry来自于函数外。我们对此函数头下断进行栈回溯。

函数头:

image-20230228110948500

再次断下后(前几次断下并不能得到我们要的调用栈,因为相关参数中找不到密文缓冲区等特征),根据fastcall约定,秘钥应该是r9所指缓冲区,实际上,秘钥缓冲区最后十六个字节作为原始Key的一部分(16字节),即未拓展的Key,秘钥拓展例程通过原始Key进行秘钥拓展,不过不注意这个细节也没有关系,这在之后的分析中将会体现。

image-20230228111415930

我们回溯到RVA:2811EB5

image-20230228111830755

在此函数中,keyArry已经生成,那么我们继续栈回溯,来到VA:00007FF6444C1137

image-20230228112514947

[rcx+0x10]即是keyArry,同样分析此函数,发现keyArry同样作为参数传入此函数,那么我们继续进行调用栈回溯。

回退到第二次调用栈,我们来到VA:00007FF6444C1398,如下图

image-20230228134042986

同样的,[rcx+0x10]即是keyArry,同样是作为参数传递进来的,再次进行堆栈回溯,来到RVA:00000000027F15AB,如下图,这里再次进行了简单的转发,再次进行栈回溯。

image-20230228135636609

来到RVA:285B20C,如下图image-20230228135936531

我们所说的keyArry是aes实例化的一个对象,里面存储有拓展秘钥。再此函数进行简单分析后发现,key同样来自函数外通过参数传递进来。

image-20230228142716246

继续堆栈回溯-_-||,来到RVA:000000000285AE26

image-20230228143005750

继续回溯,来到RVA:000000000285AED8,同样是一个简单的转发,继续回溯,来到RVA:0000000003026BF2

如下图

image-20230228145203926

如图,[[v18]+0x8]指向秘钥,终于要计算秘钥了-_-||,本函数上方有对v18的相关操作,如下图

image-20230228145835411

其实看到‘salt’这几个字符,对秘钥拓展熟悉的朋友应该能马上反应过来这里应该就是拓展秘钥的地方了。我们进到函数里

如图

image-20230228153739226

这就非常明确了,我们百度Pbkdf2

PBKDF的全称是Password-Based Key Derivation Function,简单的说,PBKDF就是一个密码衍生的工具。既然有PBKDF2那么就肯定有PBKDF1,那么他们两个的区别是什么呢?PBKDF2是PKCS系列的标准之一,具体来说他是PKCS#5的2.0版本,同样被作为RFC 2898发布。它是PBKDF1的替代品,为什么会替代PBKDF1呢?那是因为PBKDF1只能生成160bits长度的key,在计算机性能快速发展的今天,已经不能够满足我们的加密需要了。所以被PBKDF2替换了。在2017年发布的RFC 8018(PKCS #5 v2.1)中,是建议是用PBKDF2作为密码hashing的标准。PBKDF2和PBKDF1主要是用来防止密码暴力破解的,所以在设计中加入了对算力的自动调整,从而抵御暴力破解的可能性。

PBKDF2的工作流程

PBKDF2实际上就是将伪散列函数PRF(pseudorandom function)应用到输入的密码、salt中,生成一个散列值,然后将这个散列值作为一个加密key,应用到后续的加密过程中,以此类推,将这个过程重复很多次,从而增加了密码破解的难度,这个过程也被称为是密码加强。

稍微阅读以上引用内容 ,对比此函数参数不难得出:

  1. 盐值:saltiest
  2. 秘钥:小程序ID
  3. 秘钥拓展算法:PBKDF2
  4. 迭代次数:1000
  5. 秘钥长度:256位

既然秘钥长度是256位,那么可以推测出加密算法是AES_256。

注意到的是,它的伪散列算法是可以替换的,那么我们下一步要找出的是它使用的是哪种散列算法

这里笔者简单使用js选几种常见的散列算法试一试

const crypto = require('crypto');

let appid = "wxf2a0156c0235fc4c";
    crypto.pbkdf2(appid,"saltiest",1000,32,'sha1',(err, derivedKey) => 
    { 
      if (err) throw err; 
      console.log("The password is ",derivedKey.toString('hex'));
    });

image-20230228155330855

对比拓展秘钥函数运行之后的返回值[[rax]+0x8]中的值:

image-20230228161415221

可以验证散列算法是‘sha1’,对应我们在分析过程中的拓展秘钥的最后字节部分。

00001D5A002CD188     3D9D6AB5E94DDDE8 èÝMéµj.= 
00001D5A002CD190     71122C7B6FFE09D6 Ö.þo{,.q 
00001D5A002CD198     C3981F7A8828924E N.(.z..Ã 
00001D5A002CD1A0     BD14C8D6E69FEA98 .ê.æÖÈ.½ 
00001D5A002CD1A8     3336977C3609F094 .ð.6|.63 
00001D5A002CD1B0     1969D6DC01305252 RR0.ÜÖi. 

至此,我们找到了秘钥的生成算法。

分组模式以及iv

分组模式以及iv的寻找相对简单,只要对AES加密流程以及几种加密模式的区别熟悉就可以在加密函数(RVA:0000000000040C30)中分析出加密模式以及向量。这里笔者不再赘述。

经过简单分析,总结之前分析成果,有如下清单:

除去文件头8个字节,剩余1024解密算法:
    秘钥算法:PBKDF2
    盐值:saltiest
    秘钥:小程序ID,wxf2a0156c0235fc4c
    摘要算法:sha1
    秘钥长度:32字节

    解密算法:aes-256-cbc模式
    初始化向量iv:74 68 65 20 69 76 3A 20 31 36 20 62 79 74 65 73 对应字符串:“the iv: 16 bytes” -_-||
1024字节之后的数据处理方式:
    解密方式:异或解密
    异或Key:微信appid字符串的第二个字符对应的ASCII码形式。

拿到解密出的文件后可以用相应的解包脚本进行解压,网上不乏解压脚本,遗憾的是笔者并没有找到能够彻底解压并且还原出微信开发者工具能够识别的对应各式的文件(微信开发者工具对js等的样式做了一层封装,想要能够调试源代码需要将解包后的文件还原成其能够识别的格式,网上确实有相关脚本,但大多比较老,微信开发工具对样式进行了更新,格式化出现了一些问题,笔者水平有限,就不去修复。)

下面给出不成熟的C++解密脚本

#include "PKCS7.h"
#include <openssl/evp.h>
#include<openssl/aes.h>
#include <openssl/sha.h>
#include <openssl/crypto.h>
#include <iostream>
#include <string>
#include <fstream>
#include <filesystem>

using namespace std;

unsigned char iv[] = { 0x74,0x68,0x65,0x20,0x69,0x76,0x3A,0x20,0x31,0x36,0x20,0x62,0x79,0x74,0x65,0x73 };//iv
unsigned char recursive_keys[32] = { 0 };//计算AES秘钥
const unsigned char  salt[] = "saltiest";//盐值

int main()
{
	string app_id("wxf2a0156c0235fc4c");
	/*cout << "Plz enter the AppID:" << endl;
	cin >> app_id;*/

	//计算递归秘钥
	PKCS5_PBKDF2_HMAC_SHA1(app_id.c_str(),app_id.length(),salt,strlen((const char*)salt),1000,32, recursive_keys);
	//cout << recursive_keys << endl;
	
	string file_name("__APP__.wxapkg");
	/*cout << "Plz enter the name of the file you want to decrypt :" << endl;
	cin >> file_name;*/
	//读取文件
	int file_size = std::filesystem::file_size(file_name);
	char* file_buf = new char[file_size] {0};
	fstream fp(file_name.c_str(),std::ios::in|ios::binary);
	if (!fp.is_open())
	{
		cout << "Sorry,please check that you entered the correct file name" << endl;
		delete[] file_buf;
		file_buf = nullptr;
		return 0;
	}

	fp.read(file_buf, file_size);
	fp.close();

	//AES解密前1024字节内容(忽略文件头6个字节)
	AES_KEY aes_key;
	AES_set_decrypt_key((const unsigned char*)recursive_keys, 256, &aes_key);
	AES_cbc_encrypt((const unsigned char*)file_buf+0x6, (unsigned char*)file_buf+0x6, 1024, &aes_key, iv, AES_DECRYPT);
	PKCS7_unPadding* padding_result = removePadding(file_buf + 0x6, 1024);//解除填充

	size_t diff = 1024 - padding_result->dataLengthWithoutPadding;//得到解除填充后与保持填充时明文的差值

	//1024字节之后的密文解密
	//找到异或Key
	char xor_key = app_id.c_str()[app_id.length() - 2];

	for (int i = 0; i < file_size - 0x6 - 0x400-diff; ++i)
	{
		file_buf[0x406 + i - diff] = file_buf[0x406 + i] ^ xor_key;
	}

	//将解密后的数据写入
	fstream fp_out(file_name+"_plaintext",ios::out | ios::binary);
	if (!fp_out.is_open())
	{
		cout << "File create failed." << endl;
		fp_out.close();
		delete[] file_buf;
		file_buf = nullptr;
        freeUnPaddingResult(padding_result);
		return 0;
	}

	fp_out.write(file_buf+0x6,file_size-0x6-diff);
	fp_out.close();
	cout << "The file decryption is successful." << endl;

	delete[] file_buf;
	file_buf = nullptr;
	freeUnPaddingResult(padding_result);
	return 0;
}

WeChatAppEx_2.0.6609.4下载:
链接:https://pan.baidu.com/s/1N1f6DwOiIoElt9m1aN4uSw?pwd=vp03
提取码:vp03
供学习使用

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值