WeChat-Data-Analysis[1]
1. 打开电脑端微信(不要登陆)
2. 在Terminal输入命令lldb -p $(pgrep WeChat)
3. br set -n sqlite3_key
设置断点
4. 输入c
,回车(继续运行
5. 登陆电脑端微信
6. 输入memory read --size 1 --format x --count 32 $rsi
,回车
1. arm 上替换为 memory read --size 1 --format x --count 32 $x1
7. 将返回的原始key粘贴到下面的字符串中,用如下代码解析获取密钥:
ori_key = """
0x60000241e920: 0x11 0x22 0x33 0x44 0x55 0xaa 0xbb 0xcc
0x60000241e928: 0x11 0x22 0x33 0x44 0x55 0xaa 0xbb 0xcc
0x60000241e930: 0x11 0x22 0x33 0x44 0x55 0xaa 0xbb 0xcc
0x60000241e938: 0x11 0x22 0x33 0x44 0x55 0xaa 0xbb 0xcc
"""key = '0x' + ''.join(i.partition(':')[2].replace('0x', '').replace(' ', '') for i in ori_key.split('\n')[1:5])
print(key)
本地聊天数据库存储路径:~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/[version]/[uuid]/Message/*.db
App: DB Browser for SQLite
选择如下配置,复制密钥,即可打开浏览:
Tencent的开源项目WCDB[2]是一个高效、完整、易用的移动数据库框架,基于SQLCipher[3],支持iOS, macOS和Android。
SQLCipher[4] 中使用 sqlite3_key[5] 函数打开加密的数据库,wcdb 将其封装在setCipherKey[6]方法下:
int sqlite3_key(sqlite3 *db, const void *pKey, int nKey)
使用 br set -n sqlite3_key
设置其断点。再使用memory read --size 1 --format x --count 32 $rsi
获取 pKey 传参的值:
x86-64 ; 中函数调用时的参数存储如下寄存器中:
%rdi
%rsi
%rdx
%rcx
%r8
%r9
; 六个寄存器用于存储函数调用时的6个参数(如果有6个或6个以上参数)。
%rsi ; 寄存器存储的为第二个参数,即对应 sqlite3_key 函数的 *pKey 传参。
如果在只能读文件的权限环境下就能拿到消息数据库密钥那就无敌啦🐮
从 wcdb wiki 找到打开加密数据库oc代码:
WCTDatabase *database = [[WCTDatabase alloc] initWithPath:path];
NSData *password = [@"MyPassword" dataUsingEncoding:NSASCIIStringEncoding];
[database setCipherKey:password];
直接运行 frida-trace -m "*[WCTDatabase *]" "微信"
开始动态分析,可以看到WCTDatabase出初始化数据日志:
3643 ms -[WCTDatabase initWithPath:0x6000029d4540]
3643 ms -[WCTDatabase setTag:0x5]
3643 ms -[WCTDatabase setCipherKey:0x600001246190 andCipherPageSize:0x400 andRaw:0x1]
3643 ms -[WCTDatabase createTableAndIndexesOfName:0x1071fa9f8 withClass:0x107713dc8]
3645 ms -[WCTDatabase getTableOfName:0x1071fa9f8 withClass:0x107713dc8]
...
4044 ms -[WCTDatabase initWithPath:0x115058b60]
4044 ms -[WCTDatabase setTag:0xd]
4044 ms -[WCTDatabase setCipherKey:0x600001246190 andCipherPageSize:0x400 andRaw:0x1]
4044 ms -[WCTDatabase createTableAndIndexesOfName:0x107207a58 withClass:0x10771f628]
4045 ms -[WCTDatabase getTableOfName:0x107207a58 withClass:0x10771f628]
4182 ms -[WCTDatabase isTableExists:0x60000075b400]
...
79122 ms -[WCTDatabase backupWithCipher:0x600001246190]
读取setCipherKey
入参的值,从example
的代码可以知道0x600001246190
是NSData
对象,在frida 中读取到内容:
// 修改文件 WCTDatabase/setCipherKey_andCipherPageSize_andRaw_.js
...
onEnter(log, args, state) {
log(`-[WCTDatabase setCipherKey:${args[2]} andCipherPageSize:${args[3]} andRaw:${args[4]}]`); var nsd = new ObjC.Object(args[2]); // objc 对象
log(`key ==> nsdata:=${nsd}=`);
// nsdata.bytes 2 hex string
log(hexdump(nsd.bytes(), {
offset: 0,
length: nsd.length(),
header: true,
ansi: true
}));
},
...
雀氏和使用 lldb 的方式捕获到的数据一致:
丢到hopper 一通糊搜乱搜,找到 MessageDB.setupDB
看名字就知道是配置消息数据库的:
r0 = @class(WCDBHelper);
r0 = [r0 CipherKey];
...
[*(r21 + 0x8) setTag:*(int32_t *)(r21 + 0x18)];
[*(r21 + 0x8) setCipherKey:var_78 andCipherPageSize:r28 andRaw:0x1];
...
密钥是从 WCDBHelper.CipherKey
得到的,巴斯简化后的伪代码如下:
a = [[MMServiceCenter defaultCenter] getService: [AccountStorage class]]
i = [[a GetDBEncryptInfo] m_dbEncryptKey]
分析 AccountStorage
,有个 init 方法获取数据库的文件路径,使用 PBCoder 从文件解码 DBEncryptInfo
:
rax = [PathUtility GetAccountSettingDbPath];
rax = [rax retain];
rcx = *ivar_offset(m_dbEncryptInfoPath);
...
rax = [PBCoder decodeObjectOfClass:[DBEncryptInfo class] fromFile:r13->m_dbEncryptInfoPath];
rax = [rax retain];
rbx = *ivar_offset(m_dbEncryptInfo);
使用 frida hook 找到消息数据库的配置文件,脚本如下:
// 修改文件 PathUtility/GetAccountSettingDbPath.js
onLeave(log, retval, state) {
var ret = new ObjC.Object(retval); // objc 对象
log(`return value: ${ret}==`);
}
得到消息数据库的配置文件路径:~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/[version]/[uuid]/Account/setting_db.data
。
那么PBCoder
是啥?又是如何解码呢?google 到 腾讯开源的MMKV中的一条issue[7],知道了pbcoding
是基于 protobuf
进行归档对象的。用 protoc --decode_raw < setting_db.data
命令解码看下,有三个属性,第一个属性应该密钥的东西,第二个不知道,第三是时间戳:
这时脑海中有个想法:不去纠结其内部解码的实现,直接用 frida 指定setting_db.data 文件用 pbcoder 解密钥不就行了?(此时没有意识到问题的难度:
// debug.js
var path = ObjC.classes.NSString.stringWithString_("wechatOE/setting_db.data");
var key = ObjC.classes.PBCoder["+ decodeObjectOfClass:fromFile:"](ObjC.classes.DBEncryptInfo, path)
var data = key['- m_dbEncryptKey']();
hexdump(data.bytes(), { offset: 0, length: data.length() });
frida 微信 --debug -l tests/debug.js
执行代码。失败!这里返回的 data 为空:
那就先得验证是不是这个函数能直接解出密钥还是有什么额外的验证机制,在 pdcoder decodeObjectOfClass
返回处打了日志:
// 修改 decodeObjectOfClass_fromData_.js
...
onLeave(log, retval, state) {
var dinfo = new ObjC.Object(retval); // objc 对象
if (dinfo.$className == "DBEncryptInfo") {
log(`================[out]DBEncryptInfo================`);
log(`dinfo=${dinfo}=`);
log(`dinfo=${dinfo.$ivars}=${dinfo.m_dbEncryptKeyInfo}=${dinfo._m_dbEncryptKey}==${dinfo.m_dbEncryptKey}==${dinfo.m_dbEncryptKeyInfo}==${dinfo.copyFromServerObj}==${dinfo.reset}=`);
// var data = dinfo.m_dbEncryptKey();
log(`================[out]DBEncryptInfo================`);
}
}
其结果还真是各种 m_dbEncryptKey 属性都为空,但是内存中 DBEncryptInfo 的实例只有一个,和这里返回的是同一个地址,就是说pdcoder decodeObjectOfClass
后还是有设置密钥的操作的…
随后巴斯又跟了几遍微信运行流程都没找到详细的内容(只能先放弃此方案。
经过上面研究分析,从内存获取密钥还是非常容易的,使用frida在内存中搜索 DBEncryptInfo
的实例(通过逆向和多次测试肯定这是个单例),再 dump m_dbEncryptKey(NSData)
的值,运行成功:
但在目标机器上装一个frida-tools,还是略显笨拙了,巴斯决定用 frida-go[8] 将这个脚本打包成可执行文件:
package mainimport (
"encoding/json"
"fmt"
"strings"
"github.com/frida/frida-go/frida"
)
func main() {
fmt.Println(Key())
}
var js = `
var key = ObjC.chooseSync(ObjC.classes.DBEncryptInfo)[0];
var data = key['- m_dbEncryptKey']();
console.log(hexdump(data.bytes(), { offset: 0, length: data.length(), header: false, ansi: false }));
`
type Log struct {
Type string `json:"type,omitempty"`
Level string `json:"level,omitempty"`
Payload string `json:"payload,omitempty"`
}
func Key() (string, error) {
var key string
c := make(chan struct{}, 1)
mgr := frida.NewDeviceManager()
dev, err := mgr.LocalDevice()
if err != nil {
return "", err
}
session, err := dev.Attach("微信", nil)
if err != nil {
return "", err
}
script, err := session.CreateScript(js)
if err != nil {
return "", err
}
script.On("message", func(msg string) {
defer func() {
c <- struct{}{}
}()
m := Log{}
err := json.Unmarshal([]byte(msg), &m)
if err == nil {
key = parse(m.Payload)
}
})
if err := script.Load(); err != nil {
return "", err
}
<-c
return key, nil
}
func parse(payload string) string {
var r strings.Builder
r.WriteString("0x")
data := strings.Split(payload, "\n")
if len(data) == 0 {
return ""
}
for i := range data {
v := strings.Split(data[i], " ")
if len(v) != 3 {
continue
}
key := strings.ReplaceAll(v[1], " ", "")
r.WriteString(key)
}
if r.Len() == 2 {
return ""
}
return r.String()
}
运行成功:
因为内嵌了 frida 动态库,编译的文件非常大:
$ ll wechatoe
-rwxr-xr-x 1 whoami staff 75M 1 17 17:27 wechatoe
这么大的文件在实战中很碍事,想着怎么优化下文件大小,研究 frida ObjC.chooseSync
的原理,是基于 frida-objc-bridge[9] 库实现的,emmm 要是抄下来用c或者objc实现的话,工作量相当大了。
这时我想到了kk[10]他对内存扫描器非常有研究,当我把分析过程和问题抛给kk[10]时,只要把“相信”打在公屏上即可。在下班前就发了 demo 给我,效果非常好:
他没有像frida一样塞个调试器进去,而是找到一组指针路径(arm
):105705c90 + 0 > 600001b04190 + 8 > 6000018910e0 + 16 > 600001891120 + 32 > 600003c7d160
,对就是在开启ALSR的情况下,无论哪次启动App都能靠这组指针路径找到key值。
那么原理是什么呢?
比如采用最暴力的方法(速度非常慢),假设路径是 1-2-3-4-5-6
,我们需要的地址是6
,那就遍历程序中所有可读写,8字节对齐的地址,找出所有储存6
的地址5
,再找出所有储存5
点地址4
,以此类推,会找出大量路径,丢弃虚拟内存范围外的路径,保留开头尽量接近于vmmap -w $(pgrep WeChat)
中Load Address
的地址,然后看运气慢慢测试,最终就能得到结果。想要优化的话方法很多,例如提前将程序全部内存dump一份分块读取之类的,可以省去n次syscall,也可以使用一些算法技巧数据结构,反汇编之类的优化查找精度。
查看Load Address
:
fuzz 出的指针路径:
kk已将 dumpkey[12] 工具开源(目前仅支持arm,x86需要重新fuzz找到对应的一组指针路径)。
还有个细节此工具使用 task_for_pid
api,需要赋予 sudo 权限,运行起来很碍事,需要给工具签名并添加 com.apple.security.cs.debugger
,entitlements.plist
文件内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.debugger</key>
<true/>
</dict>
</plist>
操作如下:
$ security find-identity -v -p codesigning # 查看本机开发者证书信息&或者创建新证书
$ codesign -f -s zznQ --entitlements entitlements.plist dumpkey # 设置签名
运行&测试,成功✅:
除此之外还要注意 macOS Hardened Runtime安全机制[13]的限制,开启的话使用 task_for_pid
无法控制目标进程(应该关闭sip
好像可行?没有实践过)。但好在很多macOS用户都会给微信安装了插件,而安装插件后需要对应用进行重新签名,此时就会去除 runtime
flag:
但也不是无解,重签名时添加 --options runtime
选项设置强化运行时。
自动化获取Sqlite3
密钥思路就分享到这了,从 setting_db.data
解码部分有点难度最终放弃了。从内存读取密钥还是稳健的,实现的方式也比较多了,无论是 frida 脚本还是 kk 专属优化过的工具。
本文是巴斯[14]和kk[10]利用业余时间研究的,时间不多x86架构和其他版本的微信都没来得及测试,如果文章有遗漏或者错误的地方,可以一起沟通&交流。
如果师傅们对 iOS&macOS 安全有兴趣的话可以关注下巴斯[14]和kk[10]。
[1]
WeChat-Data-Analysis: https://github.com/allen1881996/WeChat-Data-Analysis[2]
WCDB: https://github.com/Tencent/wcdb[3]
SQLCipher: https://github.com/sqlcipher/sqlcipher[4]
SQLCipher: https://github.com/sqlcipher/sqlcipher[5]
sqlite3_key: https://github.com/sqlcipher/sqlcipher/blob/master/src/crypto.c#L914[6]
setCipherKey: https://github.com/Tencent/wcdb/wiki/iOS-macOS%e4%bd%bf%e7%94%a8%e6%95%99%e7%a8%8b#%E5%8A%A0%E5%AF%86[7]
腾讯开源的MMKV中的一条issue: https://github.com/Tencent/MMKV/issues/42#issuecomment-424976201[8]
frida-go: https://github.com/frida/frida-go[9]
frida-objc-bridge: https://github.com/frida/frida-objc-bridge/blob/main/lib/fastpaths.js[10]
kk: https://github.com/kekeimiku[12]
dumpkey: https://github.com/kekeimiku/dumpkey[13]
Hardened Runtime安全机制: https://red.macoder.tech/1-%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/2/#flags[14]
巴斯: https://macoder.tech