声明:本文仅供学习交流;所有分析均在本人设备、本人账号上进行,请勿用于任何非法用途。
此文章由AI整理生成,不理解的地方可以在下方留言,看到会一一解答
一、起因:实现查看指定联系人图库后导出备份
需求很具体:不联网,拿名称换 wxid,得到wxid 的 md5 值定位图片路径、解密目录下的图片。信息都在主数据库:
xwechat_files\<wxid>\db_storage\contact\contact.db
SQLite 格式,但文件头不是 SQLite format 3,前 16 字节是随机 salt——整库被 SQLCipher 加密,一个库一把 32 字节 key。key 拿不到,库就是一块密文。所以第一件事:从正在运行的微信进程里把 key 拿出来。
二、 论坛内xuxinhang的方法
xuxinhang 的思路(帖子里有完整过程,建议先读)(https://www.52pojie.cn/forum.php?mod=viewthread&tid=2105908 ):
- 幸亏微信开源了数据库框架 WCDB,从
Database::setCipherKey 切入——配置名 "com.Tencent.WCDB.Config.Cipher" 和优先级常量 0x8000000 是现成的定位特征;
- 源码往下追
CipherConfig::invoke → CipherHandle::setCipherKey,拿 buffer[66] = '\''(对应汇编 mov byte ptr, 0x27)当断点;
- x64dbg 断点命中后,RBX 指向
x'37ba…2ef' 形态的字符串——密钥以明文串常驻内存;
- 写脚本全内存扫
x' 前缀 + 后继 hex 校验,把所有密钥列出来,逐个尝试。
(他文中也提到,更早的 0xlane 方法在他那代已经失效——这类方法可能因为微信更新都要重新找一次。)
在 4.1.15.11 上复现:零命中
照做,扫 x' 前缀——零命中。那就把能想到的密钥形态全部试一遍:
| 扫描特征 |
结果 |
x' + 48/96hex 完整密钥串(老方法本体) |
0 命中 |
| 64hex 裸串正则(无包装的密钥形态) |
0 命中 |
| 32B 高熵二进制段(密钥的裸形态) |
候选全验失败 |
零命中。密钥在内存里的形态变了。变成什么样了?
三、密钥现在长什么样:从 WCDB 源码到 IDA
3.1 WCDB 源码:密钥的持有者
本地把 WCDB 源码拉下来翻,经过查找分析,最终定位到四处:
src/common/core/CoreConst.h 第 80 行,配置的名字是个明文字符串常量:
// CoreConst.h:80
WCDBLiteralStringDefine(CipherConfigName, "com.Tencent.WCDB.Config.Cipher");
src/cpp/core/Database.cpp 第 455 行,setCipherKey 拿这个名字注册配置:
// Database.cpp:455
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);
}
}
src/common/core/config/CipherConfig.cpp 第 38 行,配置对象持有密钥本体,invoke 时喂给每个新 handle:
// CipherConfig.cpp:38
bool CipherConfig::invoke(InnerHandle* handle)
{
...
ret = handle->setCipherKey(m_key); // ← m_key 即密钥本体
...
}
src/common/core/config/Configs.hpp 第 34 行,这张表的本体:
// Configs.hpp:34
class Configs final : public UniqueList<StringView, std::shared_ptr<Config>> {};
四处连起来读:Database 持有一张名字→配置对象的表,"Cipher" 这一格挂着 CipherConfig 对象,密钥就在它的 m_key 里。
源码能给的到这就到头了:密钥明文进 m_key,invoke 原样转手,看不出任何花样。
3.2 IDA:微信在骨架外加了 XOR
那编译进 Weixin.dll 之后呢?IDA Strings 搜 com.Tencent.WCDB.Config.Cipher,.rdata 仅一处——配置机制在。持有者的函数簇挂在这片常量区里,逐个 F5,管密钥注入的那个(sub_181482920)反编译出来是这样:
// sub_181482920(a1=持有者this, a2=框架句柄)
if ( sub_18086A940(a1 + 136) ) // 取串:+136
v4 = sub_1802A2130(a1 + 8);
else
v4 = sub_1802A2130(a1 + 136);
v5 = sub_1874A9DF0(v4); // 开一块等长缓冲
...
if ( v8 >= 0x20 ) // XOR,长度够 32B 走 SIMD
{
v28 = _mm_xor_ps(*(__m128 *)(v5 + 16), (__m128)xmmword_18901B0D0);
*(__m128 *)v5 = _mm_xor_ps(*(__m128 *)v5, (__m128)xmmword_18901B0C0);
}
...
do
*(_BYTE *)(v5 + v9) ^= byte_18901B160[v9 & 0x1F]; // XOR:data[i] ^= MASK[i % 32]
while ( v8 > v9 );
sub_18145B7D0(v32, v5, v12); // 注入:还原后的明文喂给 setCipher
v13 = (*(...)(a2->vtable + 104))(a2, v32);
sub_18145BD80(v32);
LOBYTE(v15) = -69; // 抹除 0xBB
sub_1875426F0(v5, v15, v14);
...
LOBYTE(v24) = -86; // 抹除 0xAA
sub_1875426F0(v5, v24, v23);
这段伪代码说白了就是四句话:
- 密钥放在这个对象的两个成员里(+8 和 +136,新成员优先用);
- 放进去之前,先和一段 32 字节的常量挨个字节做异或,存的是"打乱过的";
- 要用的时候,临时拷一份、异或回来、喂给 sqlite,用完立刻把临时那份抹掉——所以内存里从头到尾找不到明文密钥,老方法就死在这;
- 但原来存的那份不动,一直躺在那——微信自己随用随取,我们也就随时能来读。
那 32 字节长这样,双击伪代码里的 xmmword_18901B0C0(或 byte_18901B160)就能看到:
D2 C7 44 24 58 02 00 00 00 48 89 44 24 50 48 8B
45 00 48 84 4C 24 48 48 89 44 25 40 48 58 4C 24
有个细节值得记一笔:这 32 字节本身是一串合法的 x86-64 指令(48 89 44 24 50 就是 mov [rsp+50h], rax),在常量区里毫不起眼——想在二进制里直接把它搜出来,不容易。
3.3 老方法失效的原因
- 明文是瞬态的——还原只发生在"用"的那一刻,用完缓冲立即擦除;4.0.5.18 那代明文串常驻,4.1.15.11 这代明文只闪一下。
- 驻留态是异或的——常驻内存的那份是异或过的,
x' 正则对异或字节完全失效。
四、方法:搜容器名,不搜密钥
密钥本体搜不到,但装密钥的对象跑不掉:它必须能被框架按名字找到(前面源码那套机制),所以指向它的配置名是明文字符串。在内存里搜这个字符串,就等于在搜"密钥持有者在这"的路标——
① 映射区(DLL 常量区)搜 "com.Tencent.WCDB.Config.Cipher"——静态串,全进程就 2 处
② 私有区(堆)搜 {ptr, 31} 的 qword 对定位 std::string 节点,节点验证:+0x10 回指 needle、+0x18 == 31
③ node+0x28 → config 对象
④ config+0x88 → {+0x8 data_ptr, +0x10 data_len},读出 blob(≤1KB),和 32B 掩码逐字节异或
⑤ 正则 [xX]'([0-9a-fA-F]{64,192})' 抓字面量:前 64hex=密钥,后 32hex=内嵌 salt
⑥ 拿库文件第一页做 HMAC-SHA512 离线验证,开得动就是对的
大白话解释就是:
- 搜路标。 打个比方:微信把所有房间的钥匙串成一串,锁进一个带锁的盒子。明文钥匙只在使用的一瞬间闪现,平时盒子里锁着。撬不开盒子,但盒子主人登记名字的那块名牌是明文的——名牌钉在 DLL 常量区的公告栏上,全进程就两块。先到公告栏把名牌的位置记下来;
- 认名牌。 再到堆里搜"谁登记了这个名字":挨家看有没有 {指向名牌的指针, 31} 这样一对值,对上了就是一个持有者的门牌——喊一声应答 178 个;
- 顺线走。 字符串对象往后 0x28 处藏着一个指针,顺着它就走到装密钥的对象;
- 开盒子。 密钥对象内部 +0x88 处是一对"指针+长度",指向真正存密钥的那坨数据,读出来和 32 字节掩码挨个异或——盒子开了,开出来是一张清单,每行一把钥匙加一段盐;
- 读清单。 清单是
x'....' 格式的文本,前 64 个 hex 是密钥,后 32 个是盐;
- 试钥匙。 每把钥匙去开对应的库文件,开得动就是对的。
五、验证:
5.1 完整脚本(可直接运行)
# scan_keys_simple.py — 精简版 key 提取
# 用法: python scan_keys_simple.py <db目录> 例: python scan_keys_simple.py xwechat_files
# 原理: 全内存扫 "com.Tencent.WCDB.Config.Cipher" -> 搜 {串地址,31} 对 -> node+0x28=config
# -> config+0x88={+8 ptr,+0x10 len} -> blob XOR 32B 掩码解码 -> 抓 x'<key><salt>' -> salt 对库名
import ctypes, os, re, struct
from ctypes import wintypes as wt
NEEDLE = b'com.Tencent.WCDB.Config.Cipher'
XOR = bytes.fromhex("d2c7442458020000004889442450488b450048844c2448488944254048584c24")
k32 = ctypes.windll.kernel32
class MBI(ctypes.Structure):
_fields_ = [("BaseAddress", ctypes.c_uint64), ("AllocationBase", ctypes.c_uint64),
("AllocationProtect", wt.DWORD), ("_pad", wt.DWORD),
("RegionSize", ctypes.c_uint64), ("State", wt.DWORD),
("Protect", wt.DWORD), ("Type", wt.DWORD)]
def rpm(h, addr, sz):
buf = ctypes.create_string_buffer(sz); n = ctypes.c_size_t()
if k32.ReadProcessMemory(h, ctypes.c_uint64(addr), buf, sz, ctypes.byref(n)):
return buf.raw[:n.value]
return None
def weixin_pid():
class PE(ctypes.Structure):
_fields_ = [("dwSize", wt.DWORD), ("cnt", wt.DWORD), ("pid", wt.DWORD),
("heap", ctypes.c_void_p), ("mod", wt.DWORD), ("thr", wt.DWORD),
("ppid", wt.DWORD), ("pri", wt.LONG), ("flags", wt.DWORD),
("exe", ctypes.c_char * 260)]
snap = k32.CreateToolhelp32Snapshot(2, 0); pe = PE(); pe.dwSize = ctypes.sizeof(PE)
pid = 0
if k32.Process32First(snap, ctypes.byref(pe)):
while True:
if pe.exe.decode(errors='replace').lower() == 'weixin.exe':
pid = pe.pid; break
if not k32.Process32Next(snap, ctypes.byref(pe)): break
k32.CloseHandle(snap)
return pid
def regions(h):
out, addr, m = [], 0, MBI()
while addr < 0x7FFFFFFFFFFF:
if not k32.VirtualQueryEx(h, ctypes.c_uint64(addr), ctypes.byref(m), ctypes.sizeof(m)):
break
if m.State == 0x1000 and m.Protect in (2, 4, 8, 0x10, 0x20, 0x40, 0x80) and m.RegionSize < 0x20000000:
out.append((m.BaseAddress, m.RegionSize))
addr = m.BaseAddress + m.RegionSize if m.RegionSize else addr + 0x1000
return out
def u64(b, o): return struct.unpack_from("<Q", b, o)[0]
def scan_blobs(h):
regs = regions(h)
addrs = set()
for base, size in regs: # pass1: 找 needle 地址
d = rpm(h, base, size)
if d:
p = d.find(NEEDLE)
while p >= 0:
addrs.add(base + p); p = d.find(NEEDLE, p + 1)
if not addrs:
return []
pat = re.compile(b'|'.join(re.escape(struct.pack('<Q', a)) for a in addrs))
len31 = struct.pack('<Q', len(NEEDLE))
blobs = []
for base, size in regs: # pass2: {ptr,31} 对 -> 顺指针取 blob
d = rpm(h, base, size)
if not d: continue
for m in pat.finditer(d): # 所有地址一条正则一次扫完
if d[m.end():m.end() + 8] != len31: continue
cfg = u64(d, m.start() + 0x18) # pair 即 node+0x10,cfg=node+0x28 就在手里
if cfg <= 0x10000: continue
obj = rpm(h, cfg + 0x88, 0x18)
if not obj: continue
dp, dl = u64(obj, 8), u64(obj, 0x10)
if 0 < dl <= 4096:
blob = rpm(h, dp, dl)
if blob: blobs.append(blob)
return blobs
def keys_from_blob(blob):
dec = bytes(v ^ XOR[i % 32] for i, v in enumerate(blob))
out = []
for m in re.finditer(rb"[xX]'([0-9a-fA-F]{64,192})'", dec):
run = m.group(1).decode().lower()
# 长串可能是多把 key 拼接,按 32 hex 滑窗切
starts = [0] if len(run) <= 96 else list(range(0, len(run) - 63, 32)) + [len(run) - 64]
for s in dict.fromkeys(starts):
if 0 <= s and s + 64 <= len(run):
k = run[s:s + 64]
salt = run[s + 64:s + 96] if s + 96 <= len(run) else ''
if (k, salt) not in out: out.append((k, salt))
return out
def main():
import sys
root = sys.argv[1] if len(sys.argv) > 1 else r'xwechat_files'
salt2name = {}
for dp, _, fns in os.walk(root):
for fn in fns:
if not fn.endswith('.db'): continue
p = os.path.join(dp, fn)
try: page1 = open(p, 'rb').read(4096)
except OSError: continue
if len(page1) == 4096 and page1[:15] != b'SQLite format 3':
salt2name.setdefault(page1[:16].hex(), os.path.relpath(p, root))
# print('加密库 %d 个' % len(salt2name))
pid = weixin_pid()
h = k32.OpenProcess(0x410, False, pid)
if not h:
print('OpenProcess 失败 pid=%d err=%d' % (pid, k32.GetLastError())); return
n, seen = 0, set()
for blob in scan_blobs(h):
for key, salt in keys_from_blob(blob):
if key in seen: continue
seen.add(key)
name = salt2name.get(salt)
if not name: # 无内嵌 salt,跳过不输出
continue
print('%s %s' % (name, key)); n += 1
print('共 %d 个 key' % n)
main()
拿 contact.db 的 key 打开库:
# query_db.py — 微信4x 加密库直查(key 已在手,纯离线版)
# 用法:
# python query_db.py <db路径> <64hex key> 列全部表名
# python query_db.py <db路径> <64hex key> contact 表结构 + 前 10 行
# python query_db.py <db路径> <64hex key> "SELECT ..." 任意 SQL
# "SELECT username, nick_name, remark FROM contact WHERE nick_name = '名称' OR remark = '名称'"
import hashlib, hmac as hm, os, re, struct, sys, tempfile
from Crypto.Cipher import AES
PS, KS, SS, IV, HS = 4096, 32, 16, 16, 64
RS = (IV + HS + 15) // 16 * 16
def hmac_ok(raw, key):
salt = raw[:SS]
mk = hashlib.pbkdf2_hmac('sha512', key, bytes(b ^ 0x3A for b in salt), 2, dklen=32)
h = hm.new(mk, raw[SS:PS-RS+IV], hashlib.sha512); h.update(struct.pack('<I', 1))
return h.digest() == raw[PS-RS+IV:PS-RS+IV+HS]
def decrypt(raw, key):
out = [b'SQLite format 3\x00']
for i in range(0, len(raw), PS):
pg = raw[i:i+PS]
if len(pg) < PS: break
if i == 0: pg = pg[SS:] # page1 去盐
out.append(AES.new(key, AES.MODE_CBC, pg[-RS:][:IV]).decrypt(pg[:-RS]) + pg[-RS:])
return b''.join(out)
def show(cur):
print('\t'.join(d[0] for d in cur.description))
for row in cur.fetchall()[:100]:
print('\t'.join('' if v is None else str(v)[:120] for v in row))
def main():
if len(sys.argv) < 3:
print('用法: python query_db.py <db路径> <64hex key|-> [表名|SQL]'); return
path, key_hex = sys.argv[1], sys.argv[2]
arg = sys.argv[3] if len(sys.argv) > 3 else None
raw = open(path, 'rb').read()
if raw[:15] == b'SQLite format 3':
tmp = path # 明文库直接查
else:
key = bytes.fromhex(key_hex)
if not hmac_ok(raw[:PS], key):
print('key 配对失败(HMAC 不匹配)'); return
tmp = os.path.join(tempfile.gettempdir(), 'wxq_' + os.path.basename(path))
open(tmp, 'wb').write(decrypt(raw, key))
import sqlite3
cur = sqlite3.connect(tmp).cursor()
if arg is None:
for (t,) in cur.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"):
print(t)
elif re.match(r'(?i)^(select|with|pragma)', arg.strip()):
show(cur.execute(arg))
else:
show(cur.execute("SELECT * FROM [%s] LIMIT 10" % arg))
main()
其实拿联系人列表还有一种更简单的实现链路(读联系人管理器 → 全局链表)读取后保存到本地文件
1. GetMgr 拿服务定位器 → 2. 探测:遍历候选 val,走 val+208 链表数 (type|4)==5 命中 → 唯一定位 X
3. head = *(X+208)(同构全局链表头)
4. 沿 node+0x00 走到哨兵终止,每节点 +0x30 = Contact*
六、拿图片解密的两把 key(aes和xor),
图片格式是 .dat 文件,版头 6 字节 07 08 56 32 08 07,前段 AES-128-ECB,尾段一段单字节 XOR。key 是账号级的两样:aeskey(16 字符)和 xorkey(1 字节)。这两样不用像库密钥那样翻内存找持有者——微信内部就有两个现成函数,调用即返回。
xorkey 的 getter(sub_180A72660):
__int64 sub_180A72660()
{
...
return result;
}
aeskey 的 getter(sub_180A72710,出参 std::string):
__int64 __fastcall sub_180A72710(__int64 a1, unsigned int a2)
{
...
return a1;
}
怎么调不展开,路子很多:进程内直调这两个函数、直接读上面那两处缓存,离线推——%APPDATA%\Tencent\xwechat 的 kvcomm 目录里,统计上报文件的文件名中就埋着 key 的种子,配上 wxid 一个 MD5 就能把两把 key 推出来(实测 10 张图全中)。按自己顺手的路子取就行。
七、图片 dat 到成图:MMEncryptOrDecryptFile 的复刻
拿到两把 key 只是入场券,.dat 文件本身的壳是 MMEncryptOrDecryptFile 加密的。通过解密的字段串表,顺藤摸瓜两步就到:
- 符号串定位:字符串表里有
kernel::securefileutil::MMEncryptOrDecryptFile。字符串表本身的获取方法不在这里展开——上一篇文章《从零开始解开微信 xlog 加密日志》里已经写过完整流程 https://www.52pojie.cn/thread-2130855-1-1.html
- xref 锚到本体:对这条符号串做交叉引用,直接锁到函数本体,
sub_180A75F80(_QWORD *a1, _QWORD *a2, unsigned int a3, unsigned int a4) 这样的现成形态,后两个参数是模式位(加密取 0, 1)。
整个函数就是三段:一个 15 字节的头部解析,一段 AES-128-ECB,一段单字节 XOR。头部解析里最关键的两个魔数 0x07085632、0x0807,加上"加密时 AES 段取前 1024 字节、XOR 尾段不足 1MB 全量"这两个边界条件,都能从反编译的分支里直接读出来。照着写,十几行就能复刻加密解密:
import struct
from Crypto.Cipher import AES
HDR_SZ, AES_MAX, XOR_CAP = 15, 1024, 1 << 20
def decrypt_v2(dat, key16, xorkey):
assert dat[:3] == b"\x07\x08\x56" and dat[4:6] == b"\x08\x07" # dat[3] 是版本号 '1'/'2'
aes_len, xor_len = struct.unpack_from("<II", dat, 6) # 小端: AES 明文长 / XOR 尾段长
aes_pad = (aes_len & ~0xF) + 16 # PKCS7 恒多出一整块
head = AES.new(key16, AES.MODE_ECB).decrypt(dat[HDR_SZ:HDR_SZ + aes_pad])[:aes_len]
rest = dat[HDR_SZ + aes_pad:]
mid = rest[:len(rest) - xor_len]
return head + mid + bytes(b ^ xorkey for b in rest[len(rest) - xor_len:])
文件里 AES 段的实际长度是补齐后的 aes_pad,不是头里记的 aes_len——这是复刻时最容易踩的坑,解出来取前 aes_len 字节正好把 padding 丢掉。加密是它的镜像:明文前 min(尺寸, 1024) 字节按 PKCS7 补到 16 的倍数做 AES-ECB,尾段按 xorkey 逐字节异或,套上 15 字节头。解密再加密回去,与原 dat 逐字节一致——复刻正确性的判据就这么定。
剥开壳,载荷分两种:普通图片(JPEG/PNG/GIF),存下来就能看;还有微信自家的 wxgf。后者看着神秘,其实 F5 它的解码函数族(wxam_dec_*)会发现整个流程就是:校验头、拿元数据、喂 H.265 解码器、取位图。把一张真实 wxgf 的字节摊开看,38 字节头之后紧跟的就是标准 HEVC 裸流的 NAL 起始码——wxgf 不是私有编码,就是"38 字节头 + 标准 H.265 单帧",所以任何 HEVC 解码器都行,PyAV 几行搞定:
import io
import av
def wxgf_to_image(payload):
"""wxgf(38B头 + 标准HEVC单帧) -> PIL Image;非 wxgf 返回 None"""
if not payload.startswith(b"wxgf"):
return None
i = payload.find(b"\x00\x00\x00\x01") # 找第一个 HEVC NAL 起始码, 剥掉头
if i < 0:
return None
container = av.open(io.BytesIO(payload[i:]), format="hevc") # 当 HEVC 裸流解
try:
for frame in container.decode(video=0):
return frame.to_image() # 第一帧即整图
finally:
container.close()
def save_image(img_bytes, path):
"""载荷按实际格式落盘: wxgf 转成 JPEG, 其他格式原样保存"""
pic = wxgf_to_image(img_bytes)
if pic is not None:
out = path.rsplit(".", 1)[0] + ".jpg"
pic.save(out, quality=88)
return out, "wxgf->JPEG (PyAV)"
open(path, "wb").write(img_bytes)
return path, "raw"
# 一张 dat 从解壳到成图, 两行:
img = decrypt_v2(open(dat_path, "rb").read(), key16, xorkey)
save_image(img, out_path)
从 .dat 到能在看图软件里打开的图片,整条链纯 Python离线闭环(不依赖VoipEngine 导出函数):dat 壳(AES+XOR 复刻)→ wxgf 头(38 字节剥除)→ HEVC 帧(PyAV 解码)→ JPEG/PNG 落盘。