idapython获取微信GIF动图

老版微信如何保存GIF
做这件事情的起因就是发现我PC上的微信自己更新了,那么就先安利一下老版本如何截取GIF图片。我使用的是PC版的微信客户端,有关Android版的不在讨论范围内。

首先我们找到PC版的用户配置文件夹。一般在文档文件夹下,以微信id表示账号。如下图所示。

微信手机版2018 v6.6.3 官方安卓最新版

PC版微信2.6.3.78防撤回多开版

微信iPhone版 V6.7.0 官方苹果版


其中的CustomEmotions就是老版本微信缓存的GIF位置,当我们登陆PC版微信,然后用手机给别人发图的时候。消息同会同步到PC上,其中的GIF图片被处理后保存到这个文件夹内。具体是怎样处理的我也忘了,好像就是改GIF文件头部,其他没有变化。这样我们就能得到GIF了。

新版微信对GIF缓存做了什么处理
但是微信更新后,此时CustomEmotions文件夹内不会保存任何东西,甚至删除该文件夹也不会影响微信接受消息。这时所有的GIF被保存到CustomEmoV1文件夹内,而且均被加密处理。为了演示效果,我首先将该文件夹清空,然后用手机给别人发图(手动滑稽),效果如下图所示。



此时PC上同步显示图片,同时GIF被保存到CustomEmoV1文件夹内,如下图所示。



很明显,一张GIF对于一个文件,文件名32位长,可能是MD5。然后我们选择一个文件打开。



可以看到文件头被修改成V1MMWX,WX肯定指的就是微信了,V1MM不知道是什么,应该是微信开发人员定义的格式。不仅如此,熟悉GIF格式的童鞋也不难发现,除了文件头,下面的内容也被加密了。

下面我就讲一下我是怎么分析这个文件的。

如何分析加密数据
首先看一下文件的大小是否发生变化。这里我要解释一下的是,我以前曾经从老版的微信上获取过GIF,这些GIF我都保存了,所以可以直接用该GIF和加密后的GIF进行对比,就不截图了。对比的结果是:加密后的大小比加密前的大小多7字节。

如何看待这7个字节
我认为其中有6个字节是V1MMWX这个文件头造成的影响。
对100KB左右的文件来说,加密是需要时间的,但是微信是即时通讯工具,开发人员应该不会使用复杂的加密算法。
整体上来说,加密前后数据量大小是一致的。
基于以上三点,我猜测就是一个简单的亦或加密。然后我用python的xortool分析了一下该文件。



然后自动分析出极大可能解,这里它给出了一个key是0x2b('+')。



最后我将得到的文件和原始文件比对。



惊人的发现!除了前0x3ff字节不一样,后面的字节都是一样的,也就是说,加密的方式其中之一就是亦或。具体就是对文件从0x400开始的字节亦或0x2b。

到现在为止,我已经知道了文件后面一部分的加密方式,如何解密前面一部分呢?我认为还是要抓住V1MM这个文件头,因为这个头是开发人员固定的,那么这个值应该是个明文,或者说是应该是某个exe或者dll中.rdata段的(我只能期望它不是SMC之类动态生成的了)于是我就去微信安装目录下找这个字符串在哪。
结合exe和dll的名称、以及更新日期,我很幸运的找到了。



找到之后很显然要干什么了。开IDA吧。

开始分析WeChatWin.dll
由于这个dll相当的大,IDA分析了很久才完,我也是第二次分析这么大的东西(第一次是某CTF的tensorflow),话不多说,直接shift+f12找V1MM字符串的位置。然后我就惊了,ida没找到,估计是类型不对吧。无奈,只好手工定位了。第一次失败了,忘了是FOA了,要转成RVA。算了,直接定位到.rdata基址,直接用偏移,终于发现了V1MM的位置。



然后就交叉引用,发现有三处,其中2处由同一个函数引用(另外一处应该是CRT或者编译器函数)进入这个函数分析。直接f5后,能发现关键的几行代码,如下图所示。



这几行就是GIF加密后保存的地方了,首先将6字节的V1MMMX复制到cipher指针指向的首地址,然后是2个move函数,也可以理解是memcpy。而且第一个是从cipher指针指向首地址向后移动6字节开始的,那肯定就是上文中未解密的部分了。第二个move又是从第一个move结束的地方开始的,那就是上文提到的异或算法了。

由于这段是最后部分,加密都处理完了,我们需要往上看,同时整体把握这个函数的作用。



可以发现,该函数应该是一个BOOL类型的函数,若处理成功则返回1,否则返回0。还能发现的是,cipher虽然是个局部变量,但在函数第一行可以发现它被指向了该函数的第二个参数,考虑到是BOOL类型的函数,函数只能返回一个值,那么对这个处理的函数而言,只有可能将文件句柄作为参数传入,修改后才能有效。也就是说该函数的第一个参数应该是文件句柄,而指向加密内容的第二个参数应该是加密后的指针所指向的地方。
还有一些地方指的思考,比如a1[1]这个地方的值是什么。

if ( a1[1] <= 0 )
return 0;在函数一开始,就比较a1[1]这个地方的值,小于0则直接返回,我猜测是是文件大小的意思。后面的代码直接验证了我的猜想。



看到了熟悉的0x3ff,上文已经提到了,加密的GIF前面0x3ff的加密方式还是未知的,这里直接比较a1[1]和0x3ff的大小,其实就是判断GIF的大小,如果比0x3ff小,那么v7变量就不能想0x3ff,而是文件的大小。也就是说如果GIF本身大小不超过0x3ff,就不会使用第二部分的异或加密。

struct a
{
int* file;
int size;
}分析完这部分,我们可以将该函数的第一个参数转换成结构体,第一个值是文件内容指针,第二个值是文件的大小。关于这个值怎么得到并传入该函数的,不是我们分析的重点。



回过头来再看最后一部分传入的第二个参数,其实我们就能分别找到那2个加密的函数。其中绿色框中的2个函数就是加密的2个函数。
我们先看第二个函数,也就是异或那个。

第二段加密-异或


进入这个函数就能看到,很明显的取了一个byte(0x2b),然后循环异或,具体的过程我就不分析了,只是验证一下,因为最开始的时候我已经用xortool自动分析出来了。有兴趣的可以分析一下,或者动态调试一下看看是怎么加密的。

第一段加密-rsa
这个第一段加密很复杂,emm看着就不想分析了,看见一串明文crypto\rsa\rsa_lib.c更是让我感到绝望。





哇,是真的绝望。。。
然后就没有然后了,我是没有分析下去。应该是解密不了的。毕竟输出的是一个缓存文件,如果要分析微信是否能读取GIF缓存,或者说读取的格式是怎样的,我的功力还不够。。。(我要弃坑,我要转web)
讲道理,上面的路已经不通了,下面开始想想别的方法。

如何获取GIF
其实方法我刚刚已经提示了,因为这个函数接受的参数就是完整的GIF,这个结构体上面也解释了,只要获取这个结构体就能拿到完整的GIF。因此,我们返回到原来那个函数的入口点,看什么时候结构体参数被引用。



显然就是这里了,在将参数传给edi后,后面就判断结构体的第二个属性,即文件长度是否大于0。如果我们能在dll每次运行到这里的时候获取这两个数据,然后dump指定内存,就能拖出这个GIF图,也就不需要解密了。然后我动态调试了很多次,幸运的是并没有触发什么反调试和异常,使用的方式是idapython,首先是手动加载。

location = GetRegValue('edi')
sizeptr = GetRegValue('edi') + 0x4
start = Dword(location)
n = Dword(sizeptr)
f = open(os.path.join(savepath, str(count)) + '.gif', 'wb')
for i in xrange(n):
f.write(chr(Byte(start + i)))
f.close()然后每次都发个图给别人,PC就会缓存,然后GIF就会被保存下来了。如果每次都是手动加载的话,我记得这段代码是没问题的。但是不想每次都由我自己来下断点然后dump,为了实现自动化,自己也是学习了一波idapython来自定义Debugger Hook。

idapython的Debugger Hooks
主要用于Hook IDA 内部的调试器,同时可以自定义调试功能。结构如下

class DbgHook(DBG_Hooks):
def dbg_process_start(self, pid, tid, ea, name, base, size):
return
def dbg_process_exit(self, pid, tid, ea, code):
return
def dbg_library_load(self, pid, tid, ea, name, base, size):
return
def dbg_bpt(self, tid, ea):
return安装hook的方式如下

debugger = DbgHook()
debugger.hook()一开始我是在dbg_process_start来自动插入断点的,但是后来发现效果并不会,这一点我之后会说明。

我通过定义了MyDbgHook来继承DBG_Hooks,再次载入上面的脚本运行,结果却是这样的。



文件里写的都是0xff。除了这种情况还会发生发送多个图片,获取的GIF均是同一个的离奇事件。查了半天不知道哪里出了原因。最后翻idapython的资料,最后发现是api使用不当,但是具体是为什么也没有资料,感觉Dword(ea)和DbgDword(ea)之类的都差不多,可能一个前面有dbg所以是dbg专用的吗。
更新后的脚本。

location = GetRegValue('edi')
sizeptr = GetRegValue('edi') + 0x4
start = DbgDword(location)
n = DbgDword(sizeptr)
f = open(os.path.join(savepath, str(count))+'.gif','wb')
for i in xrange(n):
f.write(chr(Byte(start+i)))
f.close()运行结果如下图。



更新后,效果好了很多,但是还是有问题,有的能显示,有的GIF显示不了,于是又查了一波idapython资料,最后发现还是api使用不当。。。Byte(ea)、DbgByte(ea)还有DbgRead(ea,n),其实功能都是一样的,可能是Dbg的方式影响了某些API的实现,导致出现了很多问题。
再次更新脚本。

location = GetRegValue('edi')
sizeptr = GetRegValue('edi') + 0x4
print "[*] Gif location:[%08x],sizeptr:[%08x]"% (location, sizeptr)
start = DbgDword(location)
n = DbgDword(sizeptr)
print "[*] Gif start:[%08x],n:[%08x]"% (start, n)
dump = DbgRead(start,n)
f = open(os.path.join(self.savepath, str(self.count))+'.gif','wb')
f.write(dump)
f.close()
self.count += 1ida日志输出如下图。



运行结果如下图。



完整代码及说明
代码的几点说明:

dbg_process_start是最先加载的,在这里下断点会导致问题。
Modules()用于遍历整个环境中的模块,一开始我是想通过这个下断点的,后来发现DBG_Hooks已经提供了类似的函数,就是dbg_library_load,由于我的目标是WeChatWin.dll,当其加载的时候下断点就行了。
下断点的方式是通过偏移量来实现的,考虑到ASLR或者其他不可抗因素,直接VA下断不可行,通过模块BA+0x1000+offsite来实现,0x1000就是.text的VirtualAddress,详细可以参照PE中的节表。
Dbg模式下请使用ida python对应的API,否则如上文所述会导致未知情况。如将Dword(ea)替换成DbgDword(ea)。
# coding:utf-8
__author__='zjgcjy'

from idaapi import *
from idautils import *
from idc import *
import os

# 没用到,效果不好,或者说dbg_library_load更方便
def Modules():
mod = idaapi.module_info_t()
result = idaapi.get_first_module(mod)
while result:
yield idaapi.object_t(name=mod.name, size=mod.size, base=mod.base, rebase_to=mod.rebase_to)
result = idaapi.get_next_module(mod)

class MyDbgHook(DBG_Hooks):
#GIF计数
count = 0
#保存目录
savepath = "C:\Users\xxxxxx\Desktop\emotion"
keyLocation = 0

def dbg_process_start(self, pid, tid, ea, name, base, size):
#不要在这里插入断点
#for i in Modules():
# if 'WeChatWin.dll' in i.name:
# print "module:[%s] size:[%#x] base:[%#x] end:[%#x]" %(i.name, i.size, i.base, i.rebase_to)
# self.keyLocation = i.base
#self.keyLocation += 0x247970
#AddBpt(self.keyLocation)
#print 'keybreakpoint:[%#x]' % self.keyLocation
print "MyDbgHook : Process started, pid=%d tid=%d name=%s" % (pid, tid, name)

def dbg_process_exit(self, pid, tid, ea, code):
print "MyDbgHook : Process exited pid=%d tid=%d ea=0x%x code=%d" % (pid, tid, ea, code)

def dbg_library_load(self, pid, tid, ea, name, base, size):
print "MyDbgHook : Library loaded: pid=%d tid=%d name=%s base=%x" % (pid, tid, name, base)
# 对WeChatWin.dll下断点
if 'WeChatWin.dll' in name:
self.keyLocation = base
self.keyLocation = self.keyLocation + 0x1000 + 0x247970
AddBpt(self.keyLocation)
print 'keybreakpoint:[%#x]' % self.keyLocation

def dbg_library_unload(self, pid, tid, ea, info):
print "MyDbgHook : Library unloaded: pid=%d tid=%d ea=0x%x info=%s" % (pid, tid, ea, info)
return 0

def dbg_bpt(self, tid, ea):
print "MyDbgHook : Break point at %s[0x%x] pid=%d" % (GetFunctionName(ea), ea, tid)
#是否到了关键的地址
if GetRegValue('eip') == self.keyLocation :
location = GetRegValue('edi')
sizeptr = GetRegValue('edi') + 0x4
print "[*] Gif location:[%08x],sizeptr:[%08x]"% (location, sizeptr)
start = DbgDword(location)
n = DbgDword(sizeptr)
print "[*] Gif start:[%08x],n:[%08x]"% (start, n)
dump = DbgRead(start,n)
f = open(os.path.join(self.savepath, str(self.count))+'.gif','wb')
f.write(dump)
f.close()
self.count = self.count + 1
else:
print "[%x] - [%x]" %(GetRegValue('eip'),self.keyLocation)

idaapi.continue_process()
return 0

def dbg_suspend_process(self):
print "MyDbgHook : Process suspended"

def dbg_step_into(self):
print "MyDbgHook : Step into"
self.dbg_step_over()

debughook = MyDbgHook()
debughook.hook()

print "ok"写在最后
通过ida python实现了获取微信GIF的功能,自动化脚本,加载模块自动下断,dump文件。只需要在手机上发送GIF,保存目录下就能得到对应的GIF。速度可以说是很快的。