跳到主要内容

微信界面逆向分析

缘由

微信的白色背景页面实在是对像笔者这样眼睛不太好的用户不太友好,所以就有自己给他改改界面的打算,就改个颜色,应该不会太麻烦,所以就写了这篇文章记录一下过程。受限于笔者水平以及时间问题,这篇文章最终没有实现目的,但是理论上文章中所讲方法是可以实现的,并可以提取大部分微信界面UI布局,并且对其他功能的分析也有帮助。笔者将在文中给出使用 frida 进行简单内存 dump 的方式获取 xml 以及静态解密的方式拿到 XML

一些已掌握的信息

微信界面使用了Duilib库

  • Duilib 实现了从 XML 中加载页面布局的能力
  • 本质上是封装了一层 MFC 并自写了解析器来完成相关布局设置
  • 微信的主要功能模块是 WeChatWIn.dll

比较笼统的资料即以上四点,这对我们帮助还是比较多的:

  • Duilib 库是开源的,官方已经停止了对 Duilib 的维护,现在的版本基本上都是由个人维护的
  • Duilib 从XML中加载布局,这给了我们很好的着手点,我们可以试着找到 XML 文件对其更改来达到更改背景颜色的目的
  • 本质是一层 MFC 封装,那么免不了从资源中加载文件,会不会它吧 XML 文件放到 rc 里的呢?
  • 我们的主要分析应该是在 WeChatWIn 中进行。

准备冻手

微信团队之前开源过 Duilib 的源码,但是现在 Github 上应该是找不到了,笔者这里有一份,出于学习的目的需要请联系笔者。拿到源码后笔者简单看了一下,对比官方原来来说改动不算太大。

资源加载方式:

因为我们的关注点是微信是从哪里获取 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 文件的明文信息),找到明文出现的位置逆向回溯找出密文解密点应该是比正着死磕分析要简单一点。

从源码入手

笔者结合网上对 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 后,可以发现很多搜索项,如下图:

可以推测,部分资源的部分内容没有被加密。

那么我们怎么找到解密点呢?

找到解密点

一种比较朴素的想法是,在没进行解压之前对密文下访问断点,进行解密时必然要访问这篇内存

存在的问题是我们需要找到一个合适的位置,在没进行解压之前下断点,根据之前的分析我们似乎不能很好的确定这个位置,因为我们似乎除了在 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 文件也给我们分析一些微信的功能提供了一种思路。

References