微信小程序 Storage 是怎么存的,又是怎么被解密的
从「想看看数据存哪了」到「把所有小程序 Storage 全部解密出来」的完整过程。
前言
微信小程序提供了一套简洁的本地存储 API------wx.setStorage / wx.getStorage。对用户来说,这不过是个普通的键值存储;但你有没有好奇过:这些数据落盘后长什么样?能直接读出来吗?
本文以 macOS 上的微信 4.x(PC 端)为研究对象,记录我从 0 到 1 把本机所有小程序 Storage 解密出来的过程------包括加密方案分析、密钥捕获方法,以及一个意外发现的「快捷规律」。
一、数据存在哪
先找文件。微信 4.x 在 macOS 上的小程序数据统一放在:
javascript
~/Library/Containers/com.tencent.xinWeChat/Data/Documents/
app_data/radium/users/<user_hash>/applet/local/<wxid>/usrmmkvstorage0/
每个小程序(由 wxid 标识,形如 wx1a2b3c4d5e6f7a8b)在这个目录下有两个文件:
| 文件 | 作用 |
|---|---|
wx1a2b3c4d5e6f7a8b |
加密主数据文件,预分配 128KB |
wx1a2b3c4d5e6f7a8b.crc |
元数据文件,存 CRC32、IV 等信息 |
打开主文件,全是乱码。显然是加密了的。
用 file 命令识别不出格式,但看着文件名和目录结构,搜一下关键词------这其实是 MMKV,腾讯开源的高性能键值存储库。微信把 Storage 的落盘层直接交给了它,并且开启了加密。
二、MMKV 文件格式拆解
主文件
结构极为紧凑:
arduino
offset 0: u32 actualSize // 有效密文长度
offset 4: byte[actualSize] // AES-128-CFB 加密的 payload
(后续全为 0x00 padding)
元数据文件(.crc)
前 40 字节的 layout:
rust
offset 0: u32 m_crcDigest // payload 的 CRC32
offset 4: u32 m_version // 实测值为 4
offset 8: u32 m_sequence
offset 12: byte[16] m_vector // AES-CFB IV ← 关键
offset 28: u32 m_actualSize
offset 32: u32 m_lastConfirmedMMKVFileSize
offset 36: u32 m_lastActualUpdateCRCDigest
重点 :IV 存在
.crc文件里,不在主文件里。这意味着只要拿到 IV 和 cryptKey,就能完整解密。
解密方式
MMKV 使用 AES-128-CFB128(全块反馈模式):
python
def aes_cfb128_decrypt(key16: bytes, iv16: bytes, ct: bytes) -> bytes:
return AES.new(key16, AES.MODE_CFB, iv=iv16, segment_size=128).decrypt(ct)
cryptKey 经过零填充变成 16 字节的 AES key(对应 MMKV 源码里的 memset + memcpy):
python
def derive_aes_key(crypt_key: bytes) -> bytes:
return crypt_key[:16].ljust(16, b"\x00")
明文结构
解密后的 payload 是一段 protobuf-like 的 append-only 日志:
scss
[4字节 hash] (varint key_len)(key)(varint val_len)(value) ...
MMKV 每次 setObjectForKey: 不覆盖旧值,而是直接追加一条新记录。读取时取同一 key 最后出现的值为当前值。这也是 MMKV 高性能的来源------写入永远是 O(1) 的顺序追加。
微信的值包装格式
解密出来的 value 还套了一层微信自己的 JSON 包装:
json
{"data": "true", "dataType": "Boolean"}
{"data": "42.5", "dataType": "Number"}
{"data": "{\"key\":\"v\"}", "dataType": "Object"}
Object 类型的 data 是再次 JSON 序列化的字符串,需要二次解析才能还原成对象。
三、问题核心:cryptKey 从哪里来
格式分析完了。解密唯一缺的就是 cryptKey。
查了微信的通信协议,cryptKey 由后端服务(mmbizwxasyncappsvr.WxaSyncIssueDecryptKeyCmd)在小程序初始化时实时下发,不持久化到磁盘,每个 user × wxid 独立一份。
换句话说:静态分析文件系统是拿不到 cryptKey 的,必须从运行时进程里捞。
这也是为什么大多数人打开这个目录,看到一堆乱码就放弃了。
四、进阶方法:用 LLDB 实时捕获密钥
选哪个切入点
MMKV 的打开接口签名是:
cpp
static MMKV* mmkvWithID(
const std::string &mmapID, // X0: wxid(小程序 ID)
MMKVMode mode, // W1: 1 = SINGLE_PROCESS
std::string *cryptKey, // X2: 密钥指针 ← 目标
std::string *rootPath, // X3
size_t expectedCapacity // X4
);
每次小程序启动,微信都会调用这个函数并把 cryptKey 作为第三个参数传入。在 ARM64 调用约定下,第三个参数就在 X2 寄存器里。
只要在这个函数入口设断点,在小程序打开的瞬间读出 X0(wxid)和 X2(cryptKey),就大功告成了。
跨版本自动定位函数偏移
微信每次更新,framework 的偏移都会变,不能硬编码。用静态特征来定位:
第一步 :MMKV 源码里有一条内部日志 MMKVInfo("mmkvWithID", ...),这个字符串字面量在 __cstring 段里全二进制只出现一次。先找到它的 vmaddr。
第二步 :扫描 __text 段,找所有 ADRP+ADD 指令对,筛出引用该 vmaddr 的位置------这就是函数内部引用这个字符串的位置。
第三步 :从引用点向前扫描,找最近的 SUB SP, SP, #imm(ARM64 函数 prologue)------这就是函数入口。
python
s_vma = find_string_in_cstring(binary, b"mmkvWithID\x00")
xrefs = find_adrp_add_references(binary, s_vma)
entry = find_prologue_before(xrefs[0]) # 向前扫描找 SUB SP
不依赖任何硬编码偏移,跨微信版本稳定可用。
绕过 hardened runtime
WeChat.app 以 Apple Team 签名的 hardened runtime 运行,直接 lldb -p <pid> 会被系统拒绝。
解决方案是对 WeChat.app 做一次 ad-hoc 重签,加上允许调试的 entitlements:
bash
sudo codesign --force --deep --sign - \
--entitlements wechat.entitlements \
/Applications/WeChat.app
wechat.entitlements 里的关键项:
xml
<key>com.apple.security.cs.debugger</key><true/>
<key>get-task-allow</key><true/>
<key>com.apple.security.cs.disable-library-validation</key><true/>
重签后 attach 到 WeChatAppEx 子进程,加载断点脚本,打开目标小程序------密钥立刻落到磁盘上,解密随即完成。
这套进阶方法能 100% 捕获密钥,但需要重签微信、需要管理员密码,用户还得手动打开小程序------体验不够优雅。能不能绕过这一步?
五、一个意外发现的规律
在用进阶方法采集了一批样本之后,我注意到一个规律:
对每个测试过的 wxid,其 usrmmkvstorage0 的 cryptKey 都满足:
ini
cryptKey = "w" + wxid[2::2]
即:去掉 wx 前缀,取剩余 16 个十六进制字符中下标为偶数 (0、2、4......)的字符,前面加一个 "w",得到 9 字节的 ASCII 字符串。
举个例子:
makefile
wxid: w x 1 a 2 b 3 c 4 d 5 e 6 f 7 a 8 b
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
index: 0 2 4 6 8 10 12 14
cryptKey = "w" + "1" + "2" + "3" + "4" + "5" + "6" + "7" + "8"
= "w12345678" (9 字节)
这个规律在手上所有样本中全部验证通过。
这意味着:只需要读磁盘上现有的文件,不需要 attach 任何进程,就能完成解密。 整个流程变成了:
python
def derive_crypt_key(wxid: str) -> bytes:
return ("w" + wxid[2::2]).encode("ascii")
一行代码,数秒完成。
为什么是这个规律?
我没有在微信的代码里找到明确的生成逻辑,猜测可能是后端在下发密钥时对 wxid 做了某种简单的字符选取,或者早期版本就是这样生成的并延续了下来。
但不管原因是什么,规律本身是实测有效的。这个规律可能随微信版本变化;如果哪天推导失败(解密后一条 key 都读不出来),回退到进阶方法即可。
六、完整解密流程
综合以上,最终的完整流程:
sql
扫描 applet/local/ 下所有 wxid
↓
对每个 wxid 用规律推导 cryptKey(快速,纯离线,无需操作微信)
↓
读取 .crc 文件,提取 IV 和 actualSize
读取主文件,截取有效密文
↓
AES-128-CFB128 解密
↓
解析 MMKV append-only payload
取每个 key 最后一次出现的值
↓
展开微信的 JSON 包装层,还原原始类型
↓
输出 out/<user_hash>/<wxid>.json
(如有失败条目 且 在 macOS 上)
↓
提示用户确认 → 重签微信 → attach LLDB
→ 实时捕获真实 cryptKey → 再次解密
实测:在我的机器上,58 个小程序全部在 3 秒内解密完成,无需触碰微信进程。
七、开源
整套工具已整理开源,支持 macOS / Windows / Linux:
bash
# macOS / Linux
make setup
make run
# Windows
python -m pip install -r requirements.txt
python src\decrypt.py --offline-only
结果写入 out/ 目录,按账号和小程序 ID 分文件存放,格式为 JSON。
结语
整个研究过程里最有意思的部分,不是解密本身,而是那个推导规律------它把一个「必须 attach 进程」的问题变成了一个一行代码的纯函数。不知道腾讯是有意为之还是历史遗留,但结果是:你用 wx.setStorage 存的所有数据,其实并没有你以为的那么不透明。
免责声明 :本文及相关工具仅供对自己设备、自己账号下数据进行研究和备份,禁止用于任何非自有数据。解密结果可能包含 token 等敏感凭证,请妥善保管
out/目录。对 WeChat.app 重签会移除官方签名,请自行评估风险。