文章目录
- 微信界面逆向分析
- 一、缘由
- 二、一些已掌握的信息
- 三、准备冻手
- 1)搞一下源码
- 2)资源加载方式
- 3)从源码入手
- 4)找到解密点
- 四、解密
- 五、总结
微信界面逆向分析
微信版本:3.9.0.28
下载链接:链接:https://pan.baidu.com/s/1eUR9hoaJ8o_NMRybJ-MYpA?pwd=iofu
提取码:iofu
duilib:链接:https://pan.baidu.com/s/1_cuB7e2bBbIZrKexfmKBag?pwd=qx7x
提取码:qx7x
一、缘由
微信的白色背景页面实在是对像笔者这样眼睛不太好的用户不太友好,所以就有自己给他改改界面的打算,就改个颜色,应该不会太麻烦,所以就写了这篇文章记录一下过程。受限于笔者水平以及时间问题,这篇文章最终没有实现目的,但是理论上文章中所讲方法是可以实现的,并可以提取大部分微信界面UI布局,并且对其他功能的分析也有帮助。笔者将在文中给出使用frida进行简单内存dump的方式获取xml以及静态解密的方式拿到XML
二、一些已掌握的信息
- 微信界面使用了Duilib库
- Duilib实现了从XML中加载页面布局的能力
- 本质上是封装了一层MFC并自写了解析器来完成相关布局设置
- 微信的主要功能模块是WeChatWIn.dll
比较笼统的资料即以上四点,这对我们帮助还是比较多的:
- Duilib库是开源的,官方已经停止了对Duilib的维护,现在的版本基本上都是由个人维护的
- Duilib从XML中加载布局,这给了我们很好的着手点,我们可以试着找到XML文件对其更改来达到更改背景颜色的目的
- 本质是一层MFC封装,那么免不了从资源中加载文件,会不会它吧XML文件放到rc里的呢?
- 我们的主要分析应该是在WeChatWIn中进行。
三、准备冻手
1)搞一下源码
微信团队之前开源过Duilib的源码,但是现在Github上应该是找不到了,笔者这里有一份,出于学习的目的需要请联系笔者。拿到源码后笔者简单看了一下,对比官方原来来说改动不算太大。
2)资源加载方式
因为我们的关注点是微信是从哪里获取XML资源的,所以我们需要大致了解一下Duilib的资源加载方式,微信之前开源的Duilib中有四种资源加载方式:
enum UILIB_RESTYPE
{
UILIB_FILE=1, // 来自磁盘文件
UILIB_ZIP, // 来自磁盘zip压缩包
UILIB_RESOURCE, // 来自资源
UILIB_ZIPRESOURCE, // 来自资源的zip压缩包
};
从磁盘上以及从ZIP中加载资源基本上是不可能的,我们在微信的根目录里没有发现相关信息,保险一点,我们通过火绒剑来监控一下文件操作,防止搞错方向。
我们只关注文件操作,设置好相关过滤后,我们记录微信启动完成的相关信息,搜索关键字“.xml”,”zip”,,没有发现什么有用的信息,但是在我们搜索“Resource”时,我们留意到
微信加载了这两个非常可疑的dll,我们索性用Resource Hacker看看里面有什么
首先WeChatResource.dll里面有一些较为简单的资源以及一个不明所以的奇怪资源,资源类型为WXZ,如下图:
WeUIResource.dll中同样含有此种资源类型,如下图:
其中,在其十六进制流中我们可以看到有PNG格式文件头如下
我们在010Editor中进行简单的提取
可以看到其中确实隐藏着png图片,但是我们没有发现xml的影子,应该是有加密的(不然也不会有这篇文章)
我们留意资源的类型为wxz
,尝试在IDA中搜索相关字符串,并查找相关引用,如下图
可以看到加载了这种资源,但是如果从这里分析的话并不优雅,一般资源的加载是在初始化过程中,从这里跟下去不一定能找到解密点。
比较幸运的是我们有源码,我们可以试着从源码中找到一个xml文件明文出现的位置(对xml文件进行解析渲染,一定在某一个时刻会出现xml文件的明文信息),找到明文出现的位置逆向回溯找出密文解密点应该是比正着死磕分析要简单一点。
3)从源码入手
笔者结合网上对duilib的一些介绍进行了一些简单分析,我们关注点来到函数签名为 LRESULT WindowImplBase::OnCreate(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL& bHandled)
的函数中,这个函数所属的类被创建窗口的类继承,并且创建窗口时应当调用OnCreate()
可以看到关键的函数行为是,分析当前加载的方式选择合适的加载例程,最终会调用Create()
,而GetSkinFile().GetData()
是将返回xml的一些信息,在某些情况下,是xml内容本身,这里也是比较好定位,我们可以看到字符串“Duilib”,尝试在IDA中搜索,查找引用,我们来到VA:01C80DC5处,如下图:
对比源码,我们很容易确定这就是OnCreate函数内部,而117行对应位置就是Create,我们对其下断,重启微信。
成功断下后跟进,来到VA:1C81B13处,如下图
IDA对这一部分的分析比较模糊,并没有把其视为一个函数,我们可以进行简单的强制函数化,但也没必要fix相关内容,如下图
笔者对其进行了简单的分析,并根据函数行为进行了简单的重命名,这里是OnCreate内部嘛,跟源码进行简单对比,很容易发现并不是,如下
CControlUI* CDialogBuilder::Create(STRINGorID xml, LPCTSTR type, IDialogBuilderCallback* pCallback,
CPaintManagerUI* pManager, CControlUI* pParent)
{
//资源ID为0-65535,两个字节;字符串指针为4个字节
//字符串以<开头认为是XML字符串,否则认为是XML文件
if(HIWORD(xml.m_lpstr) != NULL && *(xml.m_lpstr) != _T('<')) {
LPCTSTR xmlpath = CResourceManager::GetInstance()->GetXmlPath(xml.m_lpstr);
if (xmlpath != NULL) {
xml = xmlpath;
}
}
if( HIWORD(xml.m_lpstr) != NULL ) {
if( *(xml.m_lpstr) == _T('<') ) {
if( !m_xml.Load(xml.m_lpstr) ) return NULL;
}
else {
if( !m_xml.LoadFromFile(xml.m_lpstr) ) return NULL;
}
}
else {
HINSTANCE dll_instence = NULL;
if (m_instance)
{
dll_instence = m_instance;
}
else
{
dll_instence = CPaintManagerUI::GetResourceDll();
}
HRSRC hResource = ::FindResource(dll_instence, xml.m_lpstr, type);
if( hResource == NULL ) return NULL;
HGLOBAL hGlobal = ::LoadResource(dll_instence, hResource);
if( hGlobal == NULL ) {
FreeResource(hResource);
return NULL;
}
m_pCallback = pCallback;
if( !m_xml.LoadFromMem((BYTE*)::LockResource(hGlobal), ::SizeofResource(dll_instence, hResource) )) return NULL;
::FreeResource(hResource);
m_pstrtype = type;
}
return Create(pCallback, pManager, pParent);
}
但是我们注意到调用了LoadFromMem,而其内部调用了MultiByteToWideChar
,笨点的方法是,我们对这个函数下断,然后触发微信的图形操作,试着找找。
可以看到轻而易举的就断到了我们想要的地方,断下后栈回溯,就找到了比较明显的特征,我们在IDA中转到RVA :1C9F508
对比源码的LoadFromMem:
可以确定这就是LoadFromMem了,对此函数进行简单的重命名。
在LoadFromMem函数头部断下,查看第一个参数(esp+0x4)的值,xml内容已经作为参数传递进来,如下图,说明微信已经完成了对xml内容的解密。请留意xml内容缓冲区的地址。
我们进行回溯,查找第一个参数的值。
来到RVA:01C9D5D6,如下图
可以看到笔者已经根据分析进行了部分注解,我们观察第一个参数*((LPCCH *)v7 + 2048)
(thiscall调用约定,第一个参数为对象本身),v7作为stream2xmlbuf
的返回值,对于这些重命名的由来,我们可以去源码中查看LoadFromMem
的相关调用函数,对比可以确定这个函数的相关信息。如下图:
我们观察stream2xmlbuf
的行为,在调用他的地方下断
可以看到对应的xml文件名作为参数传入
对应的xml内容作为返回值被传出,我们可以猜测,这个函数完成了对xml内容的读取以及解密,我们跟进这个函数。
需要留意的是stream2xmlbuf
的返回值同样为5CC98D60,这与我们之前观察到的xml缓冲区的地址相同,也就是说,duilib极有可能申请了一个公共缓冲区来临时存储xml的内容,那么我们的突破点就找到了,既然用了公共缓冲区,待xml内容解密完成后势必会对这个缓冲区进行写的操作,我们可以对着个缓冲区下硬件写入断点(dbg的内存访问断点是针对整个内存页的,通常情况下这是我们不需要的)。如下图,成功在写入的地方断下,RVA:1F7D053
我们在IDA中同步。
我们在源码中搜索字符串:invalid literal/length code,定位到inflate_codes
,其实这里已经比较明显了,inflate是zlib库唯一支持的压缩和解压缩算法,我们在这个函数头部下断,进行回溯。来到RVA:1F76282,如下图:
同样有比较有特征的字符串,同样在源码中搜索。推荐关键字“incorrect header check”如下图
现在看来我们在inflate内部,算法我们不需要分析,我们在inflate函数头部下断,RVA:01F75340,非常容易的断下。
我们需要留意inflate
函数签名的结构体z_streamp
,其结构如下:
typedef struct z_stream_s {
Byte *next_in; // next input byte
uInt avail_in; // number of bytes available at next_in
uLong total_in; // total nb of input bytes read so far
Byte *next_out; // next output byte should be put there
uInt avail_out; // remaining free space at next_out
uLong total_out; // total nb of bytes output so far
char *msg; // last error message, NULL if no error
struct internal_state *state; // not visible by applications
alloc_func zalloc; // used to allocate the internal state
free_func zfree; // used to free the internal state
voidpf opaque; // private data object passed to zalloc and zfree
int data_type; // best guess about the data type: ascii or binary
uLong adler; // adler32 value of the uncompressed data
uLong reserved; // reserved for future use
} z_stream;
typedef z_stream *z_streamp;
比较关键的字段是
-
next_in
,即将要进行解压的缓冲区 -
next_out
,此缓冲区将被解压后的内容写入。 -
opaque
,这个zlib的源码中的说明不太容易让人理解,但根据之后分析,这个指向的是未解压的数据,也同样是我们之后分析解密过程的突破点
我们在inflate的函数头断下,对应的我们查看第一个参数,即z_streamp
结构,如下:
可以看到基本上对应了z_streamp
的结构,其中有我们非常眼熟的缓冲区5CC98D60
,对应的就是next_out
,而opaque
指向的是WeChatresource中的数据,我们跟进
复制相应的十六进制流,在WeChatResource.dll中搜索,成功搜索到了对应的十六进制内容,如下图
可见,opaque
保存的的是资源文件中的内容。
不要忘了字段next_in
,按照解释,其应该指向的是将要解压的内容的缓冲区,我们跟进,如下图
理论上将,将要解压的内容应该与opaque
里的内容一致,但这里却不同。经过笔者猜测,这里应该是又一层加密,原因是,当微信加载PNG类型图片时,opaque
内容与next_in
内容一致,或者在wechatresource.dll中搜索78 9C
后,可以发现很多搜索项,如下图:
可以推测,部分资源的部分内容没有被加密。
那么我们怎么找到解密点呢?
4)找到解密点
一种比较朴素的想法是,在没进行解压之前对密文下访问断点,进行解密时必然要访问这篇内存
存在的问题是我们需要找到一个合适的位置,在没进行解压之前下断点,根据之前的分析我们似乎不能很好的确定这个位置,因为我们似乎除了在inflate例程中再没有发现密文出现的地方
一种或许可行但不太优雅的方法是记录一下加载某个页面时的密文缓冲区,在wechatresources中定位,然后在加载资源的地方(LoadResource)找到资源缓冲区,继而定位到加载那个指定界面时对应的密文缓冲区,对其下硬件访问断点,再次触发其页面加载,理论上可以断在解密的地方。
这里笔者使用的是一种非常笨拙的方法,赌解密的地方离解压的地方不远,即回溯,根据源码定位z_streamp
结构的组装过程,根据内存访问关系找到解密资源的点,这个过程相当笨拙且没什么技术含量,所以这里笔者不再赘述
最后,笔者定位到RVA:01E103F1,如下图:
根据函数名读者可以猜到,密文用的是异或加密,我们跟进这个函数
根据伪代码分析,这段代码主要是对a1所指向的内存区域进行加密。下面是一些推测:
变量v4是一个密文缓冲区,初始值为a1+32,即指向a1之后的第32个字节的地址。
加密算法中使用了SSE指令集中的_XMMINTRIN_H头文件中的_mm_xor_si128()函数进行异或操作,xmmword_2B01A80是一个128位的常量值,可能是加密算法中使用的密钥。
在主循环中,每次处理64字节的数据,并且对这64字节中的每个128位(即16字节)进行加密。加密的方式是将128位的数据与xmmword_2B01A80进行异或操作,并将结果存储回内存中。
当a2的大小小于64字节时,程序会跳到LABEL_9处,进入另一个加密算法中,该算法对剩下的不足64字节的数据进行单字节异或加密。
函数的返回值为result,表示加密完成的数据的字节数。
综上所述,这段代码可能是一种基于SSE指令集的异或加密算法,密钥为xmmword_2B01A80
在这里秘钥为0x63,即字符‘c’,这个秘钥在不同wxid下是否不同笔者没有测试。
四、解密
下面我们测试一下结论是否正确
我们在资源中赋值一个加密chunk,chunk以1B FF
作为文件头,转储到一个名为“dump.xml”的文件中,然后尝试解密解压,并将其写入“decode.txt”中下面给出不成熟的脚本:
#include <iostream>
#include "zconf.h"
#include "zlib.h"//包含zlib
#include <filesystem>//需要c++17标准支持
#include <fstream>
using namespace std;
void UnCompress()
{
uLong file_size = std::filesystem::file_size("dump.xml");
uLong unfile_size = file_size * 10;
std::fstream fp("dump.xml", ios::in | ios::binary);
char* buf = new char[file_size] {0};
fp.read(buf, file_size);
for (int i = 0; i < file_size; i++)
{
buf[i] ^= 0x63;
}
char* de_buf = new char[unfile_size] {0};
int x = uncompress((Bytef *)de_buf, &unfile_size, (Bytef *)buf, file_size);
std::fstream de_fp("decode.txt", ios::out | ios::binary);
de_fp.write(de_buf, unfile_size);
de_fp.close();
fp.close();
delete[] buf;
delete[] de_buf;
buf = nullptr;
de_buf = nullptr;
}
void Compress()
{
uLong file_size = std::filesystem::file_size("decode.txt");
uLong unfile_size = file_size * 10;
std::fstream fp("decode.txt", ios::in | ios::binary);
char* buf = new char[file_size] {0};
fp.read(buf, file_size);
char* de_buf = new char[unfile_size] {0};
int x = compress((Bytef *)de_buf, &unfile_size, (Bytef *)buf, file_size);
for (int i = 0; i < unfile_size; i++)
{
de_buf[i] ^= 0x63;
}
std::fstream de_fp("dump.xml", ios::out | ios::binary);
de_fp.write(de_buf, unfile_size);
de_fp.close();
fp.close();
delete[] buf;
delete[] de_buf;
buf = nullptr;
de_buf = nullptr;
}
int main()
{
//Compress();
UnCompress();
return 0;
}
可以看到我们拿到了xml的内容,证实我们之前的分析时正确的。
至此,静态解密得到xml的目的已经实现,出于笔者水平原因,并不能拿到xml对应的文件名字。有兴趣的读者可以继续向下分析,如果想要拿到xml的名字,比较简单的方法是内存dump。下面笔者将使用frida来HOOK相关函数,缺点也是比较明显,我们通过HOOK很那实现完全的dump出所有的xml,因为解密渲染例程只有在被调用时我们才能拿到xml。
这里,我们选择的HOOK点为调用LoadFromMem的点上方,对应RVA:1C9D5C3,这里有xml缓冲区,缓冲区+0x2004指明了其大小,并有xml对应的名字,如下图
给出一下frida脚本(需要frida环境):
let file_name;
let file_size=0;
function GetVAByModuleNM(moduleName,offset)
{
var base=Module.findBaseAddress(moduleName);
if(base==null)
{
base=enum_to_find+moudle(moduleName);
};
console.log(moduleName + ":" + base);
var target_addr=base.add(offset);
return target_addr;
}
let Addr=GetVAByModuleNM("WeChatWin.dll",0x1C9D5C3);
Interceptor.attach(Addr,{
onEnter:function(arg)
{
file_name=ptr(this.context.esp).readPointer().readUtf16String();
var f = new File("XML\\"+file_name.toString().replace(/\\/g,"_")+'.txt', 'wb');
f.write(ptr(ptr(this.context.eax).add(0x3)).readByteArray(this.context.eax.add(0x2004).readU32()-0x3));
f.flush();
f.close();
console.log(file_name);
}
});
调用命令:
frida WeChat.exe -l 脚本名字
效果如下:
五、总结
- 微信的资源放在
WeUIResource.dll
和WeChatResource.dll
中 - xml资源使用了异或加密,需要注意的是资源里的部分PNG没有加密,里面也有zip文件
- xml资源使用zlib解压缩
- 在内存中修改xml中的内容可以实现修改微信UI,也就是说,我们可以通过frida脚本定位到需要修改UI,然后再资源里查找,解密,修改,再加密替换理论上可以实现更改UI的目的,但是限制比较多,理想的方法是能够完全解压资源文件修改后再根据文件格式打包回去,受限于技术原因,笔者暂时无法实现,非常乐意有想法的朋友交流,也期望各位师傅指点。
- 类似于electron这种框架或者java虚拟机,将必要文件在运行时加载解析的先天诟病就是很容易在内存中拿到相关信息,而frida就是这方面的瑞士军刀。
- 这些xml布局中有很多关于功能,例如在布局中有“send_btn”,那么在代码中将会有`if(tmp==“send_btn”),这离具体功能应该相当近,也就是说,了解微信的xml文件也给我们分析一些微信的功能提供了一种思路。