我之前写的文章(https://www.52pojie.cn/thread-2068774-1-1.html) 讨论了在Windows微信4.0版本中手机备份文件的解密方法,但是新版备份文件的密钥只存在于手机端,无法从Windows微信上提取,而要从安卓手机中读取密钥又必然要拿到管理员权限,步骤上很繁琐,操作也存在风险。所以,对新版微信来说,不如直接从Windows微信自己的数据库(下称主数据库)中读取信息来得更方便。
与解开备份数据文件相比,解开微信主数据库的方法讨论已经相当多了。早在微信4.0公测的时候,0xlane 就已经研究出了解开Windows微信4.0主数据库的方法。可是,按文章所述方法操作,总是拿不到关键信息,直接拿配套的程序运行,也找不出密钥。可能是为老版本开发的方法在新版本中失效了。另外,原文的逻辑分析也讲得较为含糊。
所以,我这次自立更生,再给大家分享另一种提取并解开Windows微信4.0版本主数据库密钥的思路,另一并详解 用于存储聊天图片的 DAT 文件的解密方法。
这里使用的微信版本是 Weixin.exe = 4.0.5.18。
主数据库解密
微信主数据库位于C:\Users\[用户名]\Documents\xwechat_files\[微信号]\db_storage,其数据库文件形如 contact/contact.db。
寻找数据库密钥流转的蛛丝马迹
幸亏微信开源了自己数据库框架 WCDB,不然逆向分析如此大型C++项目中的复杂逻辑简直难如登天。
开始按照 0xlane 和WCDB文档的指引,从WCDB::Database::setCipherKey函数切入。函数中变量CipherConfigName值总为字符串"com.Tencent.WCDB.Config.Cipher";还有常量Configs::Priority::Highest 为数值 0x8000000。这两个显著的标志足以定位函数。
void Database::setCipherKey(const UnsafeData& cipherKey, int cipherPageSize, CipherVersion cipherVersion) {
if (cipherKey.size() > 0) {
m_innerDatabase->setConfig(
CipherConfigName,
std::static_pointer_cast<Config>(std::make_shared<CipherConfig>(
cipherKey, cipherPageSize, cipherVersion)),
Configs::Priority::Highest);
} else {
m_innerDatabase->removeConfig(CipherConfigName);
}
}
使用x64dbg启动weixin.exe调试,先不登录。来到“符号”面板,其中的weixin.dll是体积最大的,其它看名字估计是第三方库或是负责其它小功能的。所以进到weixin.dll模块,先右键菜单扫一遍当前模块的字符串,搜索"com.Tencent.WCDB.Config.Cipher"有好几个结果。逐个跳转至对应的汇编,挑出附近出现0x8000000的,即定位到setCipherKey函数的具体位置。在该函数开头设断点。
包括上面的"com.Tencent.WCDB.Config.Cipher"在内,部分字符串常量在程序运行后动态加载,用IDA等做静态分析无法搜到,需换用 x64dbg、CheatEngine 等动态分析工具。后文同理。
回到微信界面正常登录,程序在断点处暂停。setCipherKey函数仅四个参数(类实例自身算第一个参数),故Windows x64下参数均由寄存器传值。第四个参数cipherVersion对应R9值为4,第三个参数cipherPageSize对应R8值为4096。现在知道了调用SQLCipher 的 ==文件版本== 和 ==页面大小==。
第二个参数cipherKey对应RD是一个地址,里面理应有具体密钥的地址。尝试使用SQLStudio打开数据库,不成。
只分析到这里还是不好蒙,只得在WCDB源代码中,沿着setCipherKey函数继续探索。setCipherKey 创建了新的 CipherConfig 对象,定位到CipherConfig类,类的主要逻辑在CipherConfig::invoke里,再深入CipherHandle::setCipherKey方法。
CipherHandle::setCipherKey里面有丰富的常量,有数值99、66、67,也有字符串“'x”,但拿它们定位都太模糊。定位起来最方便的,还得是 buffer[66] = '\'' 这条赋值语句。几乎可以肯定它对应的汇编形如 mov BYTE PTR [???], 0x27。
bool CipherHandle::setCipherKey(const UnsafeData &data) {
if (m_isInitializing && data.size() == 99) {
UnsafeStringView cipher = UnsafeStringView(data.buffer(), data.size());
if (cipher.hasPrefix("x'") && cipher.hasSuffix("'")) {
void *buffer = malloc(67 * sizeof(unsigned char));
memcpy(buffer, data.buffer(), 66 * sizeof(unsigned char));
((char *) buffer)[66] = '\'';
bool ret = InnerHandle::setCipherKey(UnsafeData(buffer, 67));
return ret;
}
}
在IDA中打开weixin.dll,耐心等待分析完成。在汇编页面中使用 “Search”-“Immediate Value” 搜索立即数0x27。结果窗口中 右键-“Modify Filter” 筛出Address以.text开头的,也就是限定可执行区。接着类似地选出指令内容形如 mov BYTE PTR [???], 0x27 的,只有11条。IDA自己的筛选不好用我用的是万能的Excel。
11条不算多,逐条在IDA中定位,在附近凭其它常量(如66、67等数值)判断出哪个对应CipherHandle::setCipherKey方法,这里是 mov byte ptr ds:[rbx+0x42],0x27 。把断点打在这条mov指令处,重复登录微信的操作,正常命中后“步进”一次执行这条mov指令。跳转至RBX寄存器所存地址,存储的是形如 x'37ba........2ef'的字符串,中间的十六进制有64个位表示32字节。
该格式恰恰匹配 SQLCipher 中用于指定密钥的 PRAGMA key 指令,表示32字节的密钥。断点会命中多次,RBX密钥也出现了好几种,不必准确地判断哪个密钥是用于解开哪个文件的,能确定其格式特征就足够了。
在进程内存空间中搜索密钥
可以肯定,密钥常驻在weixin.exe进程的内存空间中。既然知道了密钥的格式,便能暴力扫描内存,列出所有密钥,再逐一尝试,总会有一个能解开数据库。
很多软件都能搜索进程中的特定字节序列,但这里还是自己编写 Python 脚本,实现更复杂的搜索规则。先搜索前缀x',然后检查后继字符格式判定是否为完整密钥。若十六进制字符串表示了48字节(含盐的SQLCipher PRAGMA key)则只取前32字节。<!--搜索密钥同时累计相同密钥出现的次数。-->
内存扫描加逐个尝试来寻找密钥的办法,虽然暴力,但胜在逻辑足够简单,且脚本执行用时实测并不长。(不过先前那个 wechat-dump-rs 的暴力扫描确实过于暴力了。)运行脚本可找出多个密钥,仍需逐个尝试,看看到底哪个是我们需要的。
import pymem, psutil
def find_process():
processes = [
(p.pid, p.info['cmdline'])
for p in psutil.process_iter(['pid', 'name', 'cmdline'])
if p.info['name'].lower() == 'weixin.exe'
]
pid = min(processes, key=lambda _: len(_[1]))[0]
return pid
def find_keys(pid):
keys = []
p = pymem.Pymem(pid)
for a in p.pattern_scan_all(b'x\'', return_multiple=True):
b = p.read_bytes(a, 3+64+32)
if b[66] != ord('\'') and b[98] != ord('\''):
continue
found_key = b[0:66]
for existed_key in keys:
if existed_key[0] == found_key:
existed_key[1] += 1
break
else:
keys.append([found_key, 1])
keys.sort(key=lambda _: _[1], reverse=True)
return keys
if __name__ == '__main__':
pid = find_process()
keys = find_keys(pid)
print(*keys, sep='\n')
================ RESTART: F:\wechat\Wechat_Qt_40518\scan_keys.py ===============
[b"x'09d67210196934ad620b9a9676f96ba16302d3a0cbd6af3fada41288ce08a47d", 19]
[b"x'ec93e6219cbb800ffd9006063282102d531f7fbf435950559928e7dc7958c34b", 9]
[b"x'1a45f8ed2ab98f477e0ed550057b679041f08c744661c456caa437517bdb25d4", 7]
[b"x'c837bad1c891f62b73ffa679ac8ffe78db6f79b3cbc385dcc21decf82ec35aab", 6]
[b"x'b12d48b1a13912fcf63400ff7f4482cd8b9f515595c4c019f5d5fbb2130cbafb", 5]
[b"x'03cae888c4d5e24a9a5fbdb359cfd6d1eff879c15a128da47c7fc5b3ab798533", 5]
用SQLiteStudio验证数据库密钥
打开 SQLiteStudio,“连接数据库”指定文件,“类型”设“SQLCipher”,“密码”留空,“加密算法配置”中按上述分析指定密钥、版本和分页大小参数。若“测试连接”正常则密钥对了,否则换下个密钥再试。经验来看,先从出现次数最多的密钥试起。
<!-- sql
PRAGMA KEY = "x'09d67210196934ad620b9a9676f96ba16302d3a0cbd6af3fada41288ce08a47d'";
PRAGMA cipher_page_size = 4096;
PRAGMA cipher_compatibility = 4;
-->
编写Python脚本解密数据库——SQLCipher 4 格式分析
用SQLiteStudio逐个打开数据库文件太麻烦,还是想用脚本实现自动运行。我在先前发表的文章中摘录过解密3.9版本Windows微信数据库的Python脚本,但新版微信已升级至SQLiteCipher 4版本,密钥形式亦有变化,直接拿老代码解密自然是行不通的。没有在网上找到有关 SQLCipher 4 文件结构的资料,还是得自力更生。
了解 SQLCipher 文件结构的最好方式还是读源代码,SQLCipher应用广泛,但代码结构却不复杂。新的文件结构是在 4.0.0版本引入的,对应的CHANGELOG记录也有参考价值。
包括SQLCipher在内的各类SQLite加密扩展均基于SQLite自身提供的“编解码器”接口实现,所以从 sqlite3Codec函数的重定义入手逐层深入,尤其从指针的加减操作推断出各字段的长度顺序和含义。思路简要提示如下:
sqlite3Codec 是SQLite专门提供的拓展接口
关注offset取值,对第一页要排除文件前16字节的盐值。
if(pgno == 1)
offset = ctx->plaintext_header_sz ? ctx->plaintext_header_sz : FILE_HEADER_SZ;
进入多个分支只关注解密分支 case CODEC_READ_OP
拷贝SQLite文件头
if(pgno == 1) /* copy initial part of file header to buffer */
memcpy(ctx->buffer, plaintext_header_sz ? pData : SQLITE_FILE_HEADER, offset)
调用下一层函数,注意此处据offset调整指针偏移和长度标识
sqlcipher_page_cipher(ctx, cctx, pgno, SQLCIPHER_DECRYPT, ctx->page_sz - offset, pData + offset, buffer + offset)
——————————————
继续深入sqlcipher_page_cipher
从代码推断IV、HMAC的长度和相对顺序。
size = page_sz - ctx->reserve_sz;
iv_in = in + size;
hmac_in = in + size + ctx->iv_sz;
后继只关注执行解密的分支,注意IV原样拷贝为输出
memcpy(iv_out, iv_in, ctx->iv_sz); /* copy the iv from the input to output buffer */
也进行了HMAC校验(@细节分析略)
sqlcipher_page_hmac(ctx, c_ctx, pgno, in, size + ctx->iv_sz, hmac_out)
注意密钥进行了变换
sqlcipher_shield(c_ctx->key, ctx->key_sz)
来到真正执行解密运算函数调用
ctx->provider->cipher(ctx->provider_ctx, mode, key, key_sz, iv_out, in, size, out);
——————————————
最终来到sqlcipher_openssl_cipher
ctx->provider->cipher 实为 sqlcipher_openssl_cipher
里面调用了 OpenSSL EVP 接口,未见对key、iv和密文做修改,且不进行填充。
EVP_CipherInit_ex(ectx, NULL, NULL, key, iv, mode)
EVP_CIPHER_CTX_set_padding(ectx, 0))) /* no padding */
EVP_CipherInit_ex(ectx, OPENSSL_CIPHER, NULL, NULL, NULL, mode)
所以,第一页结构含盐 『 盐(16)、数据(*)、IV(16)、HMAC(64) 』,而其它页无盐 『 数据(*)、IV(16)、HMAC(64) 』。生成HMAC的算法是 SHA-512。
按此页面结构更新Python代码,主要是修正各结构的长度位置,并直接使用原始密钥而非先前的密码派生。运行脚本即可得到解密的数据库文件了。
import hmac
import hashlib
from Crypto.Cipher import AES
def decrypt_msg(path, rawkey):
KEY_SZ = 32
PAGE_SZ = 4096
SQLITE_FILE_HEADER = bytes("SQLite format 3", encoding="ASCII") + bytes(1)
SALT_SZ= 16
IV_SZ = 16
HMAC_SZ = 64
RESERVE_SZ = (IV_SZ + HMAC_SZ + 15) // 16 * 16
with open(path, "rb") as f:
blist = f.read()
salt = blist[:SALT_SZ]
# key = hashlib.pbkdf2_hmac
key = rawkey
page1 = blist[SALT_SZ:PAGE_SZ]
mac_salt = bytes(x ^ 0x3a for x in salt)
mac_key = hashlib.pbkdf2_hmac("sha512", key, mac_salt, 2, KEY_SZ)
hash_mac = hmac.new(mac_key, digestmod="sha512")
hash_mac.update(page1[:-RESERVE_SZ+IV_SZ])
hash_mac.update(bytes.fromhex('01 00 00 00'))
if hash_mac.digest() != page1[-RESERVE_SZ+IV_SZ:][:HMAC_SZ]:
raise RuntimeError("Wrong HMAC")
pages = [page1]
pages += (blist[i:i+PAGE_SZ] for i in range(PAGE_SZ, len(blist), PAGE_SZ))
outpath = f"{path[0:-3]}.decrypted.db"
with open(outpath, "wb") as f:
f.write(SQLITE_FILE_HEADER)
for i in pages:
t = AES.new(key, AES.MODE_CBC, i[-RESERVE_SZ:][:IV_SZ])
f.write(t.decrypt(i[:-RESERVE_SZ]))
f.write(i[-RESERVE_SZ:])
return outpath
if __name__ == "__main__":
path = "./SampleFiles/contact.db"
key = bytes.fromhex("09d67210196934ad620b9a9676f96ba16302d3a0cbd6af3fada41288ce08a47d")
decrypt_msg(path, key)
数据库结构分析
现在已经拿到了解密的数据库文件,这里感兴趣的主要是联系人数据库 contact.db 和 消息数据库 message_?.db。打开这些 SQLite 数据库,从字段名称即可大概了解具体数值数据含义。
WCDB 中 SQL 语句执行过程分析
在执行SQL语句的相关函数上打断点,也是数据库逆向中常见的需求。这里简单分析下WCDB中相关代码逻辑。考虑到要配置 SQLCipher 的页面大小等参数是要通过SQL语句进行的,所以回到Database::setCipherKey方法,沿setConfig调用深入InnerDatabase::setConfig并在同个文件中注意到一个名字很有特点的方法InnerDatabase::execute,再到InnerHandle::execute,这里还是没出现什么标志性的常量。再逐层深入InnerHandle::step、HandleStatement::step,直到HandleStatement::tryReportSQL 才出现一些独特的字符串,可用这些字符串在IDA中定位该函数位置。借助查找交叉引用功能并对比WCDB源码和IDA反编译伪代码的结构,便可逐层上溯定位各父函数的位置。亦可在 x64dbg 中打断点看函数参数看SQL字符串。
bool HandleStatement::step()
{
int rc = sqlite3_step(m_stmt);
m_done = rc == SQLITE_DONE;
if (m_done) {
tryReportSQL();
} else {
m_stepCount++;
}
const char *sql = nullptr;
if (isPrepared()) {
sql = sqlite3_sql(m_stmt);
}
return APIExit(rc, sql);
}
void HandleStatement::tryReportSQL() {
UnsafeStringView sql = sqlite3_sql(m_stmt);
if (sql.hasPrefix("SELECT")) {
m_stream << "RowCount:" << m_stepCount;
} else if (sql.hasPrefix("INSERT")) {
m_stream << "LastInsertedId:" << getHandle()->getLastInsertedRowID();
// 标志性的字符串常量!
消息数据库message_0.db结构分析
消息数据库message_0.db中许多名如Msg_[会话ID的MD5摘要]的表,如Msg_e667c,分别存储了不同会话下的聊天记录。Name2Id 表保存了该数据库存储中涉及的所有会话ID。
表Msg_e667c中message_content 存储了具体消息内容,文本消息以明文存储,然而不少消息存储的是二进制格式且不含有意义的字串,猜测可能是二次加密也可能是压缩,没有头绪。二进制处理如果是在WCDB外面做的那分析起来就更困难了。反正,先找到SQL语句执行处,起码能看调用栈。
按上面分析在执行SQL语句的相关函数上打断点,断点最好是打在可以方便以字符串形式体现SQL语句的地方。这里打在HandleStatement::step中的字符串赋值 char *sql = sqlite3_sql(m_stmt) 后、APIExit()前,恰能借助函数调用约定寄存器定位SQL语句的字符串形式char *sql。仍是对照WCDB源码与IDA反编译伪码,对应到 x64dbg 中下断点。
回到微信界面,切换到表Msg_e667c对应的聊天会话,断点命中。类方法APIExit对应反汇编jmp_sub_183F9F1F0,类实例占其第一参数,根据 Windows x64 调用约定,字符指针变量sql作为第三参数对应寄存器R8,存储了字符串形式的SQL语句。恢复运行,断点将反复命中,但仅关注涉及表Msg_e667c的SQL语句。
<!--
SELECT local_id, server_id, ..., server_seq, origin_source, wcdb_decompress(source, WCDB_CT_source), wcdb_decompress(message_content, WCDB_CT_message_content), packed_info_data FROM Msg_e667cb1b609e930befc6983a9a3a8051
-->
注意到wcdb_decompress(message_content, WCDB_CT_message_content)这个函数明显是自行注册进SQLite解释器中的,因此在WCDB代码库中搜索此函数名,据符号引用逐层深入:
WCDBLiteralStringDefine(DecompressFunctionName, "wcdb_decompress")
registerScalarFunction(DecompressFunctionName, ScalarFunctionTemplate<DecompressFunction>::specialize(2));
functionObject.process(apiObj);
void DecompressFunction::process(ScalarFunctionAPI& apiObj)
CompressionCenter::shared().decompressContent()
分析CompressionCenter::decompressContent()函数,第一眼看去是ZSTD,再看逻辑相对复杂。不过,既然是ZSTD算法,无需研究这些细节,Python里直接以默认参数解压即可:
import pyzstd
data = bytes.fromhex('28b52ffd20e07d0500...')
text = pyzstd.decompress(data).decode()
print(text)
表Msg_e667c中的其它字段也有使用Protobuf编码的,直接解码即可。
消息图片存储格式 DAT 文件解密
数据库 message_0.db 中packed_info_data列存储了protobuf结构,对于图片消息,其包含了图片文件名,可在msg/attach目录下找到。文件以.dat扩展名,并非常见的图片格式,又是微信自己的一套。用3.9的dat文件解密方法没什么用。网上也有解密的项目 recarto404/WxDatDecrypt.git,可惜下载运行程序提示“未找到 AES 密钥”。还得自立更生,但可知 DAT 格式貌似涉及 AES 加密。
首先,观察DAT文件其开头固定07 08 56 32 08 07 00 04 00 00,不妨从这里入手。用 CheatEngine 全局搜,发现旁边有个十六进制串“cfcd20....”,猜测是密钥。这块内存在.data节内,也可用C语言的术语称之为全局变量。我这里将这两段内存分别称呼为 “密钥全局变量” 和 “文件头全局变量”,下面的截图可以看得很清楚。
首先,从密钥全局变量入手。在IDA中转到这个密钥全局变量,再转到唯一一个引用它的函数 sub_1808DCF70 atexit,反编译发现其内if条件判断有两个分支,上述的密钥地址是if判断的第二个分支返回的。我们不知道两个分支的区别,但这个函数一定有读取并返回密钥的功能。
在IDA中看 sub_1808DCF70 的交叉引用,有多达四个函数调用了该函数,然而不知道上级函数是哪一个。可以借助动态调试分辨其调用来源。用 x64dbg 在 sub_1808DCF70 开头下断点,然后在微信中任意点开一张新图片,断点命中。查看此处x64dbg“调用堆栈”-“返回到”,比对前面的IDA交叉引用进行换算,确定“返回到”地址落在sub_1808E6BD0中。
<!-- @需要配图吗 -->
此外,从文件头全局变量入手再分析一下。sub_1808DC590和sub_1808DCC60是唯二的两个引用DAT文件头全局变量的函数,到 x64dbg 在两函数开头处分别下断点。
看sub_1808DC590断点处x64dbg调用堆栈,从顶开始,计算每个“返回到”地址落在IDA中的哪个sub_???函数中。推断发现sub_1808E6BD0同样位列其调用栈中。换言之,『sub_1808DC590->文件头全局变量』 和 『sub_1808DCF70->密钥全局变量』 两条线都是由sub_1808E6BD0这个公共父函数生出来的。
<!--
线程 ID
11996 - 主线程
地址 返回到 返回自 大小 方
00000014D94F91B8 00007FFEF8216C0D* 00007FFEF820CF70 3F0 用户模块
00000014D94F95A8 00007FFEF806F42F 00007FFEF8216C0D 70 用户模块
00000014D94F9618 00007FFEF806F384 00007FFEF806F42F 60 用户模块
线程 ID
11996 - 主线程
地址 返回到 返回自 大小 方 注释
00000014D94F8FC8 00007FFEF820C87E 00007FFEF820C590 1F0 用户模块 weixin.public:
00000014D94F91B8 00007FFEF8216D19 00007FFEF820C87E 3F0 用户模块 weixin.public:
00000014D94F95A8 00007FFEF806F42F 00007FFEF8216D19 70 用户模块 weixin.public:
00000014D94F9618 00007FFEF806F384 00007FFEF806F42F 60 用户模块 weixin.SetWeixinCallbackFunc+404F3F
-->
__是时候深入公共父函数 sub_1808E6BD0了。__在x64dbg中断点在调用 sub_1808DCF70的位置 call 0x7FFD8C9ACF70,然后一点一点步过。每到其它 call 处就停下来看一看,特别是从寄存器看它步过前的传入参数和步过后的传出返回值等。
大致的流转过程如下图所示。在 IDA 的 Graph View 视图下标记颜色也有助于理清流程。
█ 第一个重点子函数是call 0x7FFD8DCCF260(IDA中sub_181BFF260)
|
|
|
call前寄存器 |
RDI |
&"C:\\Users\\...\\xwechat…\\2025-11\\Img\\13_1763360399_t.dat" 是文件路径 |
RCX |
函数传参:对应文件内容前 0x400 字节 |
RDX |
函数传参: L"А" = 410 |
R8 |
函数传参:不知 |
R9 |
函数传参:&"5c99084c46e005c5f5d3dcde8fe5f547" 似乎是密钥 |
|
call后寄存器 |
RAX |
函数返回值:不知 |
RBX |
函数返回值:内存跳转过去显然有PNG格式头,整块字节大概0x400字节 |
这个函数的功能是AES加密,估计0x400字节明文,填充16字节,形成0x410字节密文,是AES-ECB模式的特征。IDA进入函数反编译也出现了AesEcbDecrypt字样。用Python脚本可以验证,密钥需要试一试(是使用字符串直接对应的字节序列/解析字符串为字节的序列;密钥长度多少128/192/256)。
█ 第二个需要关注的是 call 0x7FFD8DCCF6C0 (IDA中sub_181BFF6C0)
|
|
|
call前寄存器 |
RCX |
传参:剩下的文件内容字节 |
RDX |
传参:剩下的文件内容长度 |
R8 |
传参:某个立即数?后续分析是异或加密的字节密钥 |
|
call后寄存器 |
RBX |
返回值:解密出的PNG格式二进制,可见PNG格式结尾特征字节 |
用 IDA 反编译这个函数sub_181BFF6C0,AI分析说是异或加密功能,那第三参数R8是单字节密钥。
写Python脚本解密。读入DAT原始文件字节并去除DAT文件头后,拆成前0x400字节部分和剩下的部分,前者做AES-ECB解密,后者逐字节异或回明文,再重新拼起来即完整明文。
def decrypt_dat_file(path):
with open(path, 'rb') as f:
blist = f.read()
aespage = blist[15:15+0x410]
xorpage = blist[15+0x410:]
t = AES.new(AESKEY[:16], AES.MODE_ECB)
aesplaintext = t.decrypt(aespage)[:-16]
xorplaintext = bytes(_ ^ XORKEY for _ in xorpage)
with open('decrypted.png', 'wb') as f:
f.write(aesplaintext)
f.write(xorplaintext)
if __name__ == "__main__":
AESKEY = b'5c990805c5f6e03d' + b'cde5d4c48fe5f547'
# AESKEY = bytes.fromhex('5c990805c5f6e03d' 'cde5d4c48fe5f547')
XORKEY = 0xD7
decrypt_dat_file("./1_1762869315_t.dat")
WXGF 文件解码
明文可能是PNG格式也可能是JPEG格式,但更可能是微信的新推出的 WXGF 私有格式。WXGF格式开头有wxgf字样。有关WXGF格式的解码,文章 《WXAM文件格式解析》 已经讲得很详细了。WXGF 解析的相关程序位于动态链接库 VoipEngine.dll ,可以使用其暴露的wxam_dec_wxam2pic_5函数,这个函数的功能是解码并重新编码为JPEG数据。
这里直接经 Python 调用 VoipEngine.dll 重编码JPEG,如下。其中写死的常量可以通过给DLL x64bdg 动态打点确定,暂不关心其实际含义。
def decode_wxgf_data(raw_bytes):
from ctypes import byref, WinDLL, c_uint32, c_ubyte
#raw_file_path = r'F:\wechat\Wechat_Qt_40518\DatDec\dat_files\3d096023fac86c06fc290fb9d428fd78.dat'
#with open(raw_file_path, 'rb') as f:
# raw_bytes = f.read()
assert raw_bytes[:4] == b'wxgf'
raw_array = (c_ubyte * len(raw_bytes)).from_buffer_copy(raw_bytes)
raw_size = len(raw_bytes)
plain_array = (c_ubyte * (8 << 20))()
plain_size = c_uint32(0x7cfb00)
call_arg5_t = bytes.fromhex('00 00 00 00 00 00 00 00 00 00 00 00 AA AA AA AA'
'00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00')
call_arg5 = (c_ubyte * len(call_arg5_t)).from_buffer_copy(call_arg5_t)
dll = WinDLL(r'C:\Program Files\Tencent\Weixin\4.0.5.18\VoipEngine.dll')
dll.wxam_dec_wxam2pic_5(raw_array, c_uint32(raw_size),
plain_array, byref(plain_size),
call_arg5)
plain_bytes = bytes(plain_array[:plain_size.value])
#print(plain_bytes[:128], plain_bytes[-128:])
return plain_bytes
总结
逆向分析4.0主数据库的思路清晰,也可以说中规中矩,不像之前分析3.9或4.0版本微信备份时连蒙带猜。首先在WCDB的C++源码中寻找标志性的常量,继而定位到关键函数的汇编,再通过动态调试打断点的方式,确定密钥的存储格式特征;然后用脚本在进程内存空间中按该特征扫描出可能的密钥后,逐个密钥尝试解密。DAT文件的解密也是从标志性常量到切入动态调试开展逻辑分析的思路。微信4.0的二进制.data和.rdata区已加壳,因此几乎不可能仅从静态分析理清内存布局,微信开发团队越来越重视反逆向了。
另,不像3.9版本,微信4.0的主数据库密钥并非每次登录都会换,所以还是应安全妥当地保管好自己的密钥!
到现在,我们分析了微信4.0的主数据库和备份文件机制,以及Windows微信3.9版本的备份文件机制。再加上已经广为流传的3.9版本主数据库逆向,至此我们对微信关键用户数据的存储机理和提取方法的分析算是全面了。
<!--
到现在3.9版本微信的主数据库和备份数据,以及4.0版本的主数据库和备份数据,都已经有了解决方案。这一鱼四吃,到这里也算吃干净了。-->
参考
| 使用的工具
| 源码
- WCDB、SQLCipher - 都在GitHub上
| 资料