尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

微信聊天记录恢复实战:SQLCipher解密与EnMicroMsg.db导出全流程

微信聊天记录恢复实战:SQLCipher解密与EnMicroMsg.db导出全流程 去年冬天我手机掉水里了屏幕彻底歇菜去售后换屏的时候维修师傅说数据不保证能保住。这台手机里的微信有我和家里老人五年多的聊天记录我妈不在了那些语音和照片成了唯一的东西。当时我脑子里只有一个念头就算手机报废这些记录我也得抠出来。那是我第一次认真研究微信本地数据。以前一直以为聊天记录就是存在服务器上换个手机登录就能全部同步回来后来发现微信长久以来就没做过真正的云端同步——本地数据库才是大头。而这份本地数据库也就是大家常说的EnMicroMsg.db在 Android 设备里是加密存储的直接打开就是乱码。网上搜了一圈教程五花八门有的说得云里雾里有的给的工具下载下来直接报毒还有的根本就没把原理讲清楚照着做也是白折腾。后来我自己把整套流程从 AES 加解密原理到数据库密钥的生成算法、再从命令行跑通到写脚本批量恢复一步不落走了好几遍中间踩了一堆坑。这篇文章就把完整链路摊开讲适合两类人看一类是手机有故障、想抢救微信聊天记录的非技术用户后面的步骤照着抄就行一类是做取证、做数据恢复的工程师可以重点看密钥派生逻辑和 SQLCipher 的参数细节。先说清楚边界下面所有操作都只能用于自己名下设备、自己账号的数据备份与恢复。未经授权查看他人数据既不合规也不道德我不做任何形式的技术支持。1.1 先搞明白微信到底把数据放在哪Android 微信的数据目录默认在/data/data/com.tencent.mm/下面有个MicroMsg文件夹里面是一个以 32 位 MD5 哈希命名的子目录EnMicroMsg.db就在这个子目录里。和你聊天最相关的几个文件我整理了一下文件/目录作用是否加密EnMicroMsg.db核心数据库存储联系人、聊天消息、会话列表是SQLCipherMicroMsg/hash/avatar/联系人头像部分加密MicroMsg/hash/voice2/语音消息amr 格式封装部分加密MicroMsg/hash/image2/聊天图片旧版为 .dat 加密文件是XOR 混淆appbrand/小程序相关数据部分加密shared_prefs/保存登录态、uin、设备信息等配置不加密很多人第一次拿到EnMicroMsg.db就直接用 SQLite 工具打开结果报file is not a database其实不是文件坏了而是它压根不是裸的 SQLite是被 SQLCipher 加密过的 SQLite。SQLite 的数据库文件好比一本明文日记SQLCipher 相当于给这本日记的每一页都上了锁没有钥匙就没法翻页。1.2 为什么微信不直接用“登录密码”当钥匙你可能第一反应是直接用微信登录密码做密钥不就行了微信的密码都是经过哈希后传输的本地并不存明文密码而且登录密码的熵虽然尚可但一旦用户设置过简单密码用字典暴力破解的风险就上来了。微信的实际做法是把设备硬件相关的特征值老版本是 IMEI和账号相关的特征值uin拼在一起做 MD5再截取前 7 位作为 SQLCipher 的密钥。这样同一个账号在不同手机上生成的密钥不同就算数据库文件泄露只要拿不到对应设备也无法解开。这也是为什么换手机时聊天记录不能直接同步——新设备的 IMEI 变了密钥就变了。等等你可能听过另一种说法微信的密钥就是MD5(imei uin)的前 7 位。这句话只对了一半我在后面专门解释不同版本的差异。AES 是当前应用最广的对称加密算法。对称加密的意思就是加密和解密用的是同一把钥匙。微信在实际加密EnMicroMsg.db时底层走的是 SQLCipherSQLCipher 在 SQLite 之上做了一层透明加密默认采用 AES-256 算法。这里面的几个概念必须先理清否则你后面设参数的时候会一头雾水。2.1 AES 的三个关键要素密钥、分组和填充AES 是一个分组加密算法它把数据切成固定长度的块block来处理AES 的块长固定 128 位16 字节密钥长度可以是 128 位、192 位或 256 位。微信用的 SQLCipher 默认密钥长度是 256 位也就是 32 字节。如果明文长度不是 16 的整数倍怎么办这就需要填充padding。常见的填充方案是 PKCS7缺几个字节就补几个字节每个字节的值就是缺的字节数。举个例子最后一块还差 5 字节满 16 字节就补 5 个0x05。解密时根据最后一个字节的值就能准确去掉填充。这里有个直观的比喻AES 就像给一张长图盖章每 16 厘米盖一个章最后不足 16 厘米的部分拿空白纸补齐再盖盖章的印泥就是密钥。同一个图章在不同模式下盖出来的整体效果还不一样这就引出了加密模式。2.2 CBC、ECB 和初始向量IV到底在干什么AES 本身只定义了一个块怎么加密但多个块连起来怎么处理叫分组模式。最常见的两种ECB每个块独立加密同样的明文块会得到同样的密文块。直观、但信息泄露严重一张图片用 ECB 加密后轮廓还能看出来。微信不用这种模式。CBC每个明文块先和前一个密文块做异或再加密。第一个块没有前一个块就用一个固定的初始值参与运算这个初始值就是 IV初始向量。IV 的作用简单说就是让相同明文在不同上下文里产生不同的密文避免模式暴露。SQLCipher 里每个数据库页面的 IV 是根据页号动态生成的不是全库用同一个 IV这是它比普通 AES-CBC 更安全的原因之一。开微信数据库时常需要显式指定一些 SQLCipher 参数比如页面大小cipher_page_size。这是因为微信老版本用的是 1024 字节的页面而 SQLCipher 新版默认是 4096 字节。参数不对即使密钥正确也打不开。这个坑我后面在实操章节会专门演示。2.3 SQLCipher 与普通 AES 的区别它不是一锤子买卖SQLCipher 没有简单地把整个数据库文件塞给 AES 加密而是按页page处理。每个页独立用 AES-256-CBC 加密页面级还有消息认证码HMAC做完整性校验。这样做的好处是读数据库时只需要解密涉及的页面性能和安全性兼顾坏处是如果你想用“一条 Python 脚本直接 AEs 解密整个文件”基本走不通——你实际上是在跟 SQLCipher 的页格式打交道。所以最稳妥的实操路线不是自己实现 SQLCipher 解密而是用 SQLCipher 官方命令行工具把加密库先导出成普通 SQLite 库然后再随便用 Python 或数据库工具分析。这就像你不需要自己造一把钥匙去开保险柜而是让锁匠SQLCipher 工具用正确参数打开柜子再让你把里面的文件搬走。但密钥呢SQLCipher 工具本身不知道微信的钥匙是什么得先把钥匙拿出来。下面进入重头戏。3.1 老版本微信密钥算法IMEI uin 的 MD5 截断2017 年之前的微信 6.x 版本Android 端密钥生成逻辑比较经典公式如下key MD5(imei_lowercase uin_lowercase) 的十六进制字符串取前 7 位其中imei是手机 IMEI 号uin是微信账号在本地的一个数字标识可以在/data/data/com.tencent.mm/shared_prefs/下的配置里找到。这段逻辑最早被大量逆向分析确认过现在也有不少教程还在用。我当年恢复数据时用的正是这个公式。注意公式里的是字符串拼接不是加法。imei_lowercase和uin_lowercase都要求转成小写后再拼。比如 IMEI 是860123456789012uin 是123456789那么就是import hashlib imei 860123456789012 uin 123456789 raw imei.lower() uin.lower() key hashlib.md5(raw.encode()).hexdigest()[:7] print(key)输出会是类似a1b2c3d这样的 7 位十六进制字符串。这就是老版本微信的 SQLCipher 密钥。怎么拿到 IMEI 和 uin正常使用情况下非 root 手机很难直接读/data/data/com.tencent.mm/。但如果是做数据恢复一般有两种场景一是手机还能开机能操作这时候可以通过 adb 备份或者 root 之后直接复制二是手机已经进不了系统但能通过 recovery 模式做全分区镜像。无论哪种本质上都是先拿到文件系统的访问权。ADB 命令查看 IMEIAndroid 10 以下adb shell service call iphonesubinfo 1 | grep -o [0-9a-fA-F]\{8\} | tr -d .Android 10 以上拿 IMEI 会受限这时候可以尝试从系统设置的备份文件里找或者直接从微信备份恢复场景出发优先考虑其他路径见下节。uin 在shared_prefs目录下多个 xml 文件中搜索int nameuin value-123456789 /即可找到有时候 uin 是负数直接作为字符串参与拼接即可。3.2 新版微信的密钥变化IMEI 不再是唯一依据从微信 7.0 开始特别是 Android 10 对 IMEI 权限收紧之后微信不再单纯依赖 IMEI 生成密钥。我看到一些实际案例里密钥生成还会混入设备特定标识类似 Android ID或者直接用系统生成的随机 UUID 保存在本地配置里。这种情况下如果沿用老公式算出来的密钥必错。怎么确认你该用哪套逻辑有一个很实用的路过式验证方法先按老公式算一个 key去解数据库如果报file is not a database或者SqliteException再检查shared_prefs里有没有device_info、uuid之类的字段。一般来说新版微信的数据库/data/data/com.tencent.mm/MicroMsg/hash/EnMicroMsg.db所在的hash本身就是某个设备标识的 MD5可以对比一下。还有一种情况微信在 8.0 某些版本里只要检测到 root 环境或模拟器就会改用随机密钥并把密钥写入本地 so 库或者通过 JNI 调用生成。这种情况别说手算连 Hook 都得花不少时间。我的建议是如果只是恢复自己的数据优先保证手机环境干净、没有 root 过这样生成逻辑更可预测如果是研究用途再考虑动态调试。3.3 实在找不到密钥怎么办Frida Hook 与日志定位假设你手上的微信版本很新密钥生成逻辑变了也不存在明文配置字段怎么办如果你是开发者或安全研究者可以尝试 Frida 定位。微信底层调用 SQLCipher 时需要把 key 传给sqlite3_key()函数。在 so 库比如libwechatcommon.so里搜sqlite3_key或sqlcipher_key然后在调用处 Hook 参数就能直接读到密钥。这个思路不限于微信所有用 SQLCipher 的应用都可以用类似方式定位。如果没条件跑 Frida还有一个笨办法先备份整个/data/data/com.tencent.mm目录把数据库和配置项原样打包然后在电脑上写脚本暴力枚举“设备标识”和“uin 组合”。设备标识的候选集可以从shared_prefs里的若干字段取值生成虽然效率不高但字段不多时完全可行。当年我在一个老版本上就靠这个枚举找到了新密钥相当于把算法细节绕过去了。实操阶段环境准备很关键。很多人卡在“工具装不上”而不是“不会解”。我用的是 Ubuntu 20.04 官方 SQLCipher 命令行 Python 的方式整个过程可复现性很高。4.1 从源码编译 SQLCipher 还是直接装包sqlcipher 的发行策略比较奇葩很多 Linux 发行版的软件源里是sqlcipher但自带的版本往往比较老。而且默认编译不带-DSQLCIPHER_CRYPTO_OPENSSL或者参数静态链接有问题打开数据库时会报错。所以我的建议是要么用官方预编译包要么自己编译。编译命令按官方 README 操作即可git clone https://github.com/sqlcipher/sqlcipher.git cd sqlcipher ./configure --enable-tempstoreyes CFLAGS-DSQLITE_HAS_CODEC -DSQLCIPHER_CRYPTO_OPENSSL LDFLAGS-lcrypto make sudo make install编译完检查一下版本和 codec 是否生效sqlcipher --version正常编译后命令行启动不会打印 codec 相关信息但当你输入PRAGMA cipher_version;时如果返回版本号就说明 codec 可用sqlcipher sqlite PRAGMA cipher_version; 3.4.2这一步能避开很多“密钥明明对但打不开”的诡异问题。如果你用的是 Ubuntu 包管理器直接装的旧版建议卸载重编。4.2 一条命令解密整个数据库假设你已经拿到了EnMicroMsg.db并且算出了 key。解密的标准过程其实是“导出”用 SQLCipher 打开加密库再通过sqlite3的备份机制生成一个新的明文库。命令如下sqlcipher EnMicroMsg.db PRAGMA key 你的key; # 老版本可能需要额外指定页面大小 PRAGMA cipher_page_size 1024; PRAGMA cipher_use_hmac OFF; -- 测试是否打开成功下面这条如果返回正常说明 key 正确 SELECT count(*) FROM sqlite_master; -- 导出成明文 SQLite ATTACH DATABASE plain.db AS plain KEY ; SELECT sqlcipher_export(plain); DETACH DATABASE plain;这里有几个参数组合我必须多写几句因为它们是高频坑场景需要设置的参数说明微信 6.x / 老版本cipher_page_size 1024数据库页面大小是 1024新版默认 4096不设置会报错微信 7.0 及以后部分版本cipher_page_size 4096有些版本已经切换到大页面数据库文件头带特定格式cipher_use_hmac OFF老版本微信初始化 SQLCipher 时没开 HMAC默认开会导致读不了密钥是 7 位 hex 字符串PRAGMA key a1b2c3d微信直接把密钥当作“口令”传入 SQLCipher而不是 32 字节二进制 key第一组参数组合不对的主要现象是执行SELECT报file is not a database或者只返回乱码。如果你遇到这种情况不要怀疑 key优先怀疑页面大小和 HMAC 开关。这两个参数是无数人卡壳的地方。4.3 参数组合不对时的排查顺序SQLCipher 报错信息很模糊很多时候就是一句file is not a database根本不给原因。我给一个我自己的排查顺序能省一大半时间先用PRAGMA cipher_page_size试 1024 和 4096 两种取值。再试PRAGMA cipher_use_hmac OFF和默认状态不执行这条语句。如果还是不行检查 key 是不是真的传对了——key 两端有没有多余空格是不是大小写错仍然不行可以考虑用十六进制密钥格式PRAGMA key x你的密钥hex。注意这种格式对长度有严格要求。可以写一个循环测试脚本把几组参数全部自动跑一遍。很多“解不开”的库其实只是没有找到正确的一组参数。4.4 常见报错速查表报错信息大概率原因解决方法file is not a databasekey 错误 / 页面大小错误 / HMAC 开关错误按上述顺序试参数SqliteException: SQLITE_NOTADB同上同上database disk image is malformed备份时文件不完整或 WAL 文件没合并恢复时连同-wal和-shm一起拷贝unsupported file format数据库文件根本就不是 SQLite/SQLCipher确认是不是拿错了文件4.5 备选方案用 Python 的 pysqlcipher3如果你不想用命令行Python 生态里也有现成绑定pysqlcipher3可以像连接普通数据库一样连接加密库。安装时同样要注意底层依赖。大致用法如下from pysqlcipher3 import dbapi2 as sqlite conn sqlite.connect(EnMicroMsg.db) cursor conn.cursor() cursor.execute(PRAGMA key 你的key) cursor.execute(PRAGMA cipher_page_size 1024) cursor.execute(PRAGMA cipher_use_hmac OFF) cursor.execute(SELECT name FROM sqlite_master WHERE typetable) print(cursor.fetchall())和命令行导出相比这种方式更适合写自动化脚本比如批量处理多个微信号的数据库。解密之后你会发现一个全新的世界。SQLite 数据库里有几十张表但真正和你聊天记录强相关的表没有想象中那么多。5.1 EnMicroMsg.db 里的核心表与字段EnMicroMsg.db里最重要的表大概是这些表名用途关键字段rcontact联系人信息username微信ID、nickname、conRemark备注名、typemessage聊天消息主表msgId、msgSvrId、type消息类型、isSend、createTime、talker、contentchatroom群聊信息chatroomname、memberlistappmessage应用消息msgId、contentimg_flag图片标记imgType、cdnUrl等不同版本有差异message表里的content字段非常核心。文本消息直接存的是内容图片语音等类型的消息content往往存的是文件路径或 CDN 链接type字段区分消息类型常见类型有1 文本、3 图片、34 语音、43 视频、47 表情、49 文件/链接。5.2 一个能立刻上手的 SQL 查询拉出自己的聊天记录假设你要导出和某个联系人的全部文本聊天记录SQL 可以这样写SELECT datetime(createTime / 1000, unixepoch, localtime) AS time, CASE isSend WHEN 1 THEN 我 ELSE 对方 END AS direction, content FROM message WHERE talker wxid_xxxx AND type 1 ORDER BY createTime ASC;createTime的单位是毫秒所以要先除以 1000 再转成可读时间。isSend 1表示自己发出的消息0表示对方发的。talker是聊天对象在 rcontact 表里的 username一般是wxid_xxx或者某个自定义微信号群聊的话是一串类似xxxchatroom的字符串。5.3 数据恢复时最容易忽略的文件WAL 和 SHMSQLite 的 WALWrite-Ahead Logging模式会把新写入的数据先放在-wal文件里-shm是共享内存索引。如果你备份数据库时只拷了EnMicroMsg.db而没有带上同目录下的EnMicroMsg.db-wal和EnMicroMsg.db-shm恢复出来的库大概率是旧状态最近几天的消息可能全部丢失。正确做法备份时把三个文件一起拷走。用adb backup或者 root 导出时确认目标目录里有没有-wal和-shm后缀的文件。如果有一并保留。解密后如果数据不完整检查一下这三个文件是否齐全。如果你有旧库和新库想合并数据也可以利用 SQLCipher先各自解密再用 ATTACH 的方式按表合并。不过合并rcontact这种有唯一键的表需要额外去重风险较高建议只是做只读导出不要贸然回写。数据库解开只是第一步。聊天记录里的图片、语音、小视频很多并不存在数据库里而是以独立文件形式躺在文件系统里。老版本微信的图片文件通常是.dat结尾不是标准的 JPG/PNG直接改扩展名也打不开。它们不是用 AES 加密的而是用一种更轻量的 XOR 混淆。这个我之前提到过现在具体展开。6.1 微信图片 .dat 文件的加密方式推断JPG 图片的二进制开头是固定的FF D8 FF E0或FF D8 FF E1。微信对图片做混淆时一般思路是把原文件每个字节和某个固定字节密钥做异或。因为你看到的加密文件开头也是某个字节而明文开头是FF所以理论上对第一个字节做0xFF ^ 第一个密文字节就能推出异或密钥。不过微信的实现并不是直接拿单字节固定 key 对整个文件异或网上更常见的说法是它用了一个 2 字节甚至更多字节的映射表做轮换。实际逆向结果通常是微信把明文转换成密文的方式是密文 明文 XOR 0x06还是0x07并没有统一答案因为不同版本有所调整。但有一个好消息无论它怎么映射只要文件头明文是FF D8 FF你就能按下面思路暴力还原。6.2 一个可用的 Python 爆破脚本原理读取.dat文件的前 3 个字节分别与0xFF 0xD8 0xFF异或得到 3 个候选“密钥偏移值”。如果 3 个值一致说明是单字节异或如果不一致说明是多字节循环异或需要按循环周期处理。我写的脚本支持这两种情况import os import struct def xor_key_from_header(dat_path): with open(dat_path, rb) as f: head f.read(3) # JPG 文件头FF D8 FF ks [head[i] ^ ref for i, ref in enumerate([0xFF, 0xD8, 0xFF])] return ks def decrypt_dat(dat_path, out_path): ks xor_key_from_header(dat_path) with open(dat_path, rb) as fin, open(out_path, wb) as fout: data fin.read() if len(set(ks)) 1: # 单字节异或 k ks[0] fout.write(bytes([b ^ k for b in data])) else: # 按 3 字节周期异或可适当扩展周期 period len(ks) res bytearray(len(data)) for i, b in enumerate(data): res[i] b ^ ks[i % period] fout.write(bytes(res)) # 使用 decrypt_dat(mmexport123.dat, output.jpg)这个脚本的思路对绝大多数“数据恢复场景”够用。如果你是批量恢复可以遍历整个图片目录对每个文件先探测文件头再决定目标扩展名JPG、PNG、GIF 都有固定文件头。这样一通操作下来几百张图片几分钟就能全部还原。语音文件也类似但文件头识别需要参照 AMR 格式且部分版本语音直接是明文这里不展开。6.3 新版图片存储的变化微信 8.0 之后部分图片文件不再以.dat后缀存在而是改成了一串乱序文件名没有扩展名内容依然是混淆后的数据原理和.dat相同的可能性非常高。你可以先用十六进制编辑器看一眼文件头如果第一个字节不是标准图片文件头且所有文件都呈规律性偏移照上面的爆破逻辑处理就能还原。同理很多视频类文件也是文件头混淆只是文件头明文变成00 00 00 18 66 74 79 70这类 MP4 盒子结构。到了这一步数据库和图片都已经能读出来了。最后做一个稍微正式点的导出工具把聊天记录整理成可读的 HTML/CSV/JSON才算完成完整恢复。7.1 把 message 表导成 JSON 的脚本思路解密后的plain.db已经是一个普通 SQLite 库用 Python 内置的sqlite3就能操作。一个最小可用的导出脚本长这样import sqlite3, json conn sqlite3.connect(plain.db) conn.row_factory sqlite3.Row cur conn.cursor() rows cur.execute( SELECT msgId, createTime, isSend, talker, type, content FROM message ORDER BY createTime ASC ).fetchall() messages [] for r in rows: messages.append({ msgId: r[msgId], time: r[createTime], isSend: r[isSend], talker: r[talker], type: r[type], content: r[content], }) with open(wechat_messages.json, w, encodingutf-8) as f: json.dump(messages, f, ensure_asciiFalse, indent2)表情、图片、语音这类非文本消息type不是 1content 里往往带路径你可以根据路径去对应目录找回文件。如果想导出 HTML 方便浏览原理一样只不过把每条消息渲染成div标签。7.2 多账号与多数据库的去重细节如果你一个手机号绑过两个微信号MicroMsg目录下会有多个 32 位哈希子目录每个对应一个账号EnMicroMsg.db存在于各自子目录下。导出时别搞混了。还有一点值得注意message表里同一个对话的消息在msgSvrId上可能有重复记录某些场景下同步和本地写入都会插入导出时最好以msgId或msgSvrId去重。我自己写脚本时会先GROUP BY msgSvrId再做排序。7.3 恢复成功后的验证方法解密导出后怎么确认数据是完整的一个可用的验证方法看数据库里message表总行数和微信界面里的聊天记录总数对比差距大说明 WAL 没带上或者解的是旧备份。随机抽一条消息检查createTime对应的日期是否正确。检查图片目录下还原出的 JPG 是否能正常打开有的还原后文件头对但中间有坏块说明原文件本身已损坏不是脚本问题。最后再分享几个我在实战中摸出来的小经验不一定都写在文档里但能帮你少掉头发。第一备份微信数据时如果能 root 就用 root 直接拷整个/data/data/com.tencent.mm如果没 root 就优先用手机厂商自带的全量备份方案切忌在微信运行中直接复制数据库文件——WAL 模式下运行中拷贝很容易出现数据不一致。第二在 Windows 上使用 sqlcipher 时如果你用 PowerShell 执行PRAGMA key单引号和中文输入法状态容易造成隐秘的不可见字符建议用英文输入法或者把 SQL 写成脚本文件再执行肉眼排查时注意 key 末尾是否有空格。第三PRAGMA cipher_use_hmac OFF这个参数我在不同微信版本上的实测结果不一致有的版本必须设置成 OFF有的设置了反而打不开。千万不要只凭一篇教程就锁死参数组合多试几种组合本身就是恢复工作的一部分。第四如果手机已经无法开机而且没有 root可以考虑拆芯片读闪存或者走售后维修这些属于硬件级恢复的范畴。拿到完整镜像后微信数据还是在/data/data/com.tencent.mm下仍然可以按这篇文章的流程处理。数据这东西平时不觉得真丢了才意识到有多重要。尤其是聊天记录里那些带着口音的长语音、随手拍的照片丢了就是真丢了。希望你用不上这篇教程但真到了那一天至少有一条路可以走。再次强调以上所有方法请只用于自己名下设备、自己账号的数据备份与恢复。未经授权破解或访问他人数据不在本文讨论范围内也不应该出现在任何实操中。
返回列表