From 63214cff5a5a0eb06240a7b286baa955bd094639 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 3 Jun 2026 16:43:49 +0800 Subject: [PATCH] refactor(wechat_plain/scripts): 按功能重组目录结构 --- wechat_plain/scripts/.gitignore | 6 ++++++ wechat_plain/scripts/GITEOF | 0 wechat_plain/scripts/HANDOFF.md | 159 --------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/README.md | 32 ++++++++++++++++++++++++++++++++ wechat_plain/scripts/README_Android.md | 156 ------------------------------------------------------------------------------------------------------------------------------------------------------------ wechat_plain/scripts/build_chat_stats.py | 79 ------------------------------------------------------------------------------- wechat_plain/scripts/db-decrypt/wechat-db-decrypt.html | 753 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/db-decrypt/wechat_android_attach.py | 342 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/db-decrypt/wechat_android_attach_v2.py | 501 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/db-decrypt/wechat_android_full_hunter.js | 549 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/db-decrypt/wechat_android_key_hunter.js | 486 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/db-decrypt/wechat_earlybird.py | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/db-decrypt/wechat_export_all.py | 305 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/db-decrypt/wechat_key_hunter.js | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/docs/HANDOFF.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/docs/README_Android.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/docs/notes/MEMORY.md | 4 ++++ wechat_plain/scripts/docs/notes/project-claude-code-termux.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/docs/notes/project-hermes-setup.md | 38 ++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/docs/notes/reference-wechat-android-re.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/docs/notes/user-profile.md | 32 ++++++++++++++++++++++++++++++++ wechat_plain/scripts/docs/notes/wechat-auto-reply-status.md | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/local-testkit.tar.gz | Bin 30876 -> 0 bytes wechat_plain/scripts/local-testkit.zip | Bin 32262 -> 0 bytes wechat_plain/scripts/md/MEMORY.md | 4 ---- wechat_plain/scripts/md/project-claude-code-termux.md | 52 ---------------------------------------------------- wechat_plain/scripts/md/project-hermes-setup.md | 38 -------------------------------------- wechat_plain/scripts/md/reference-wechat-android-re.md | 49 ------------------------------------------------- wechat_plain/scripts/md/user-profile.md | 32 -------------------------------- wechat_plain/scripts/md/wechat-auto-reply-status.md | 283 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/search/build_chat_stats.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/search/search_server.py | 800 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/search/search_server_android.py | 791 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/search_server.py | 800 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/search_server_android.py | 791 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/wechat-db-decrypt.html | 753 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/wechat_android_attach.py | 342 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ wechat_plain/scripts/wechat_android_attach_v2.py | 501 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/wechat_android_full_hunter.js | 549 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/wechat_android_key_hunter.js | 486 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ wechat_plain/scripts/wechat_android_toolkit.tar.gz | Bin 36377 -> 0 bytes wechat_plain/scripts/wechat_earlybird.py | 161 ----------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/wechat_export_all.py | 305 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/wechat_key_hunter.js | 52 ---------------------------------------------------- 44 files changed, 5635 insertions(+), 5592 deletions(-) create mode 100644 wechat_plain/scripts/.gitignore delete mode 100644 wechat_plain/scripts/GITEOF delete mode 100644 wechat_plain/scripts/HANDOFF.md create mode 100644 wechat_plain/scripts/README.md delete mode 100644 wechat_plain/scripts/README_Android.md delete mode 100644 wechat_plain/scripts/build_chat_stats.py create mode 100644 wechat_plain/scripts/db-decrypt/wechat-db-decrypt.html create mode 100644 wechat_plain/scripts/db-decrypt/wechat_android_attach.py create mode 100644 wechat_plain/scripts/db-decrypt/wechat_android_attach_v2.py create mode 100644 wechat_plain/scripts/db-decrypt/wechat_android_full_hunter.js create mode 100644 wechat_plain/scripts/db-decrypt/wechat_android_key_hunter.js create mode 100644 wechat_plain/scripts/db-decrypt/wechat_earlybird.py create mode 100755 wechat_plain/scripts/db-decrypt/wechat_export_all.py create mode 100644 wechat_plain/scripts/db-decrypt/wechat_key_hunter.js create mode 100644 wechat_plain/scripts/docs/HANDOFF.md create mode 100644 wechat_plain/scripts/docs/README_Android.md create mode 100644 wechat_plain/scripts/docs/notes/MEMORY.md create mode 100644 wechat_plain/scripts/docs/notes/project-claude-code-termux.md create mode 100644 wechat_plain/scripts/docs/notes/project-hermes-setup.md create mode 100644 wechat_plain/scripts/docs/notes/reference-wechat-android-re.md create mode 100644 wechat_plain/scripts/docs/notes/user-profile.md create mode 100644 wechat_plain/scripts/docs/notes/wechat-auto-reply-status.md delete mode 100644 wechat_plain/scripts/local-testkit.tar.gz delete mode 100644 wechat_plain/scripts/local-testkit.zip delete mode 100644 wechat_plain/scripts/md/MEMORY.md delete mode 100644 wechat_plain/scripts/md/project-claude-code-termux.md delete mode 100644 wechat_plain/scripts/md/project-hermes-setup.md delete mode 100644 wechat_plain/scripts/md/reference-wechat-android-re.md delete mode 100644 wechat_plain/scripts/md/user-profile.md delete mode 100644 wechat_plain/scripts/md/wechat-auto-reply-status.md create mode 100644 wechat_plain/scripts/search/build_chat_stats.py create mode 100644 wechat_plain/scripts/search/search_server.py create mode 100644 wechat_plain/scripts/search/search_server_android.py delete mode 100644 wechat_plain/scripts/search_server.py delete mode 100644 wechat_plain/scripts/search_server_android.py delete mode 100644 wechat_plain/scripts/wechat-db-decrypt.html delete mode 100644 wechat_plain/scripts/wechat_android_attach.py delete mode 100644 wechat_plain/scripts/wechat_android_attach_v2.py delete mode 100644 wechat_plain/scripts/wechat_android_full_hunter.js delete mode 100644 wechat_plain/scripts/wechat_android_key_hunter.js delete mode 100644 wechat_plain/scripts/wechat_android_toolkit.tar.gz delete mode 100644 wechat_plain/scripts/wechat_earlybird.py delete mode 100755 wechat_plain/scripts/wechat_export_all.py delete mode 100644 wechat_plain/scripts/wechat_key_hunter.js diff --git a/wechat_plain/scripts/.gitignore b/wechat_plain/scripts/.gitignore new file mode 100644 index 0000000..b400529 --- /dev/null +++ b/wechat_plain/scripts/.gitignore @@ -0,0 +1,6 @@ +# 可重新生成的打包产物(用 zip -r / tar 重新打) +*.tar.gz +*.zip + +# macOS 产物 +.DS_Store diff --git a/wechat_plain/scripts/GITEOF b/wechat_plain/scripts/GITEOF deleted file mode 100644 index e69de29..0000000 --- a/wechat_plain/scripts/GITEOF +++ /dev/null diff --git a/wechat_plain/scripts/HANDOFF.md b/wechat_plain/scripts/HANDOFF.md deleted file mode 100644 index 09ea90d..0000000 --- a/wechat_plain/scripts/HANDOFF.md +++ /dev/null @@ -1,159 +0,0 @@ -# HANDOFF: WeChat Android EnMicroMsg.db 解密方案 - -> 由 Claude 完成并交接给 Hermes。本文件位于 `~/.hermes/tools/wechat/` 目录下。 - ---- - -## 你的角色 - -你是 Hermes Agent,现在接手 WeChat Android 数据库解密和读取工作。 -用户(yibo)需要你在 Android 手机上(Termux + Debian proot 环境)执行微信 SQLite 数据库的提取、解密和读取。 - -## 文件位置(全部在 Termux 主目录下) - -``` -~/.hermes/tools/wechat/ -├── wechat_android_key_hunter.js # Frida 脚本:hook libWCDB.so 截获 SQLCipher key -├── wechat_android_attach.py # Python 控制脚本:attach / extract / decrypt -├── search_server_android.py # Web 搜索服务器:读取解密后的 EnMicroMsg.db -└── README_Android.md # 完整操作文档 -``` - -## 关键成果(已通过测试验证) - -| 项目 | 值 | -|------|-----| -| **目标** | WeChat Android 8.0.72 `EnMicroMsg.db` | -| **SQLCipher key** | `e03ce81`(7 字符字符串密码) | -| **cipher_compatibility** | `1` | -| **cipher_page_size** | `1024` | -| **获取方式** | Frida hook `libWCDB.so!sqlite3_key`,在打开第一个加密 DB 时截获 | -| **Java 层** | `Java.available = false`(微信有反注入),native hook 正常工作 | -| **解密验证** | ✅ `message` 表 183 条消息可读,`rcontact` 4481 个联系人 | - -## 三步操作(你必须能执行) - -### 步骤 1:捕获 key - -环境要求: -- frida-server 17.9.10 已在手机上运行(`ps -A | grep frida-server`) -- Python frida 包已安装:`pip install frida==17.9.10` -- 手机已 root(Magisk) - -执行: -```bash -cd ~/.hermes/tools/wechat -python3 wechat_android_attach.py -``` - -预期输出: -``` -[*] Killing WeChat... -[*] Starting WeChat... -[*] Attaching to PID xxxxx... -[KEY CAPTURED via libWCDB.so!sqlite3_key] - hex: 65303363653831 - len: 7 -``` - -Key 会自动保存到 `~/wxmoment/wechat_keys.json`。 - -### 步骤 2:提取 DB - -```bash -python3 wechat_android_attach.py --extract-db -``` - -这会复制: -- `EnMicroMsg.db` -- `EnMicroMsg.db-shm` -- `EnMicroMsg.db-wal` - -到 `~/wxmoment/dbs/`。**WAL 文件必须复制**,否则丢失最新数据。 - -### 步骤 3:解密 - -```bash -python3 wechat_android_attach.py --decrypt -``` - -脚本会自动: -1. 读取 `wechat_keys.json` 中的 key -2. 尝试字符串密码和 hex key 两种格式 -3. 尝试多种 cipher 配置组合 -4. 输出 `~/wxmoment/dbs/EnMicroMsg.db.decrypted.db` - -### 步骤 4:Web 查看 - -```bash -python3 search_server_android.py ~/wxmoment/dbs/EnMicroMsg.db.decrypted.db -``` - -访问 http://localhost:5002,支持: -- 会话列表(按最后消息时间排序) -- 联系人浏览(按拼音首字母排序) -- 聊天记录分页查看 -- 全局消息搜索 - -## 手动解密(备用) - -如果自动解密失败: -```bash -sqlcipher ~/wxmoment/dbs/EnMicroMsg.db -PRAGMA key = "e03ce81"; -PRAGMA cipher_compatibility = 1; -PRAGMA cipher_page_size = 1024; -ATTACH DATABASE 'plain.db' AS plain KEY ''; -SELECT sqlcipher_export('plain'); -DETACH DATABASE plain; -.quit -``` - -## DB 结构(Android 版) - -### message 表(所有消息在同一个表) - -| 字段 | 说明 | -|------|------| -| `msgId` | 消息 ID | -| `type` | 类型:1=文本, 3=图片, 49=链接/卡片, 10000=系统消息 | -| `isSend` | 0=收到, 1=自己发送 | -| `createTime` | 毫秒时间戳 | -| `talker` | 聊天对象:`wxid_xxx`(单聊)、`xxx@chatroom`(群聊) | -| `content` | 消息内容。群聊格式:`sender_wxid:\n` | - -### rcontact 表(联系人) - -| 字段 | 说明 | -|------|------| -| `username` | wxid | -| `nickname` | 昵称 | -| `conRemark` | 备注名 | -| `type` | 1=好友, 2=群成员, 3=公众号 | -| `deleteFlag` | 0=正常 | - -## 注意事项 - -1. **frida-server 版本必须匹配 Python frida 包**(当前 17.9.10) -2. **微信是多进程应用**,主进程 PID 取 `pgrep -x com.tencent.mm` 的最小值 -3. **spawn 模式会超时**,所以脚本使用 `am force-stop` + `am start` + `attach(pid)` 的 early-bird 模式 -4. **Java 层 hook 不可用**,所有 hook 在 native 层完成 -5. **DB 路径**:`/data/data/com.tencent.mm/MicroMsg//EnMicroMsg.db`,`` 因账号而异 -6. **Key 是动态捕获的**,每次微信启动可能变化,需重新捕获 - -## 故障排查 - -| 问题 | 解决 | -|------|------| -| `No USB Frida device found` | 确认 frida-server 在运行:`adb shell "ps -A \| grep frida-server"` | -| `unable to find process with name com.tencent.mm` | Frida 枚举不到主进程,脚本已自动 fallback 到 `pgrep` 取 PID | -| `Java is not defined` | 正常现象,脚本已降级到 native-only hook | -| `file is not a database` | Key 错误或 cipher 配置不对,检查 `wechat_keys.json` 和 cipher 配置 | -| `conversation` 表为空 | 正常现象,search_server_android.py 已 fallback 到用 `message` 表生成会话列表 | - -## 后续工作(你可能需要做的) - -1. **批量解密其他 DB**:微信还有 `WxFileIndex.db`、`FinderWCDB.db` 等,理论上共用同一个 key,可以批量解密验证 -2. **增量同步**:微信运行时 WAL 文件不断变化,需要定期重新提取和解密 -3. **适配更多消息类型**:当前 search_server 只完整处理了文本(type=1)和链接卡片(type=49),其他类型显示为 `[类型XX]` -4. **集成到 lemonPCRM**:把解密后的 DB 数据接入项目的数据管道 diff --git a/wechat_plain/scripts/README.md b/wechat_plain/scripts/README.md new file mode 100644 index 0000000..ff2f114 --- /dev/null +++ b/wechat_plain/scripts/README.md @@ -0,0 +1,32 @@ +# wechat_plain/scripts — 目录索引 + +Pixel 上微信 bot 的脚本与工具,按功能分目录管理。 + +| 目录 | 功能 | 状态 | +|------|------|------| +| [`db-decrypt/`](db-decrypt/) | Frida hook `libWCDB.so` 抓 SQLCipher key + 解密 `EnMicroMsg.db`(Android v1/v2 + Mac 旧路线 + 浏览器解密页) | 可用 | +| [`search/`](search/) | 解密库之上的 Web 搜索服务(`search_server*.py`)+ 聊天统计生成(`build_chat_stats.py`) | 可用 | +| [`lsposed-wechat-sendmsg/`](lsposed-wechat-sendmsg/) | **A11y 发送模块 = 当前唯一能把消息真正推进 `message` 表的主发送链路**(`HermesAccessibilityService`,纯 UI 自动化) | 主线 ✅ | +| [`local-testkit/`](local-testkit/) | Pixel 端部署 / 自启动 / 测试套件(hermes daemon、termux boot、各类 debug/test 脚本) | 可用 | +| [`platform-tools/`](platform-tools/) | adb / fastboot 等 Android 平台二进制 | 工具 | +| [`frida-daemon/`](frida-daemon/) | 早期 Frida 发送队列实现 | **已废弃**(注入大 bundle 触发反调试致微信崩溃) | +| [`archive/`](archive/) | 更早期的 Frida / lldb PoC 脚本 | 归档 | +| [`docs/`](docs/) | 文档:`HANDOFF.md`、`README_Android.md`、`notes/`(项目/参考笔记) | — | + +> 发送链路现状详见仓库根 `CLAUDE.md` §微信集成架构:只有 `lsposed-wechat-sendmsg` 的 A11y service 真正生效,frida-daemon 与 LSPosed 反射 API 两条路径均已废弃。 + +## 常见入口 + +```bash +# 以本目录 (wechat_plain/scripts/) 为工作目录 + +# 抓 key + 解密(Android) +python3 db-decrypt/wechat_android_attach.py # 抓 SQLCipher key +python3 db-decrypt/wechat_android_attach.py --extract-db +python3 db-decrypt/wechat_android_attach.py --decrypt + +# 启动搜索 Web UI +python3 search/search_server_android.py ~/wxmoment/dbs/EnMicroMsg.db.decrypted.db +``` + +详细操作见 [`docs/README_Android.md`](docs/README_Android.md) 与 [`docs/HANDOFF.md`](docs/HANDOFF.md)。 diff --git a/wechat_plain/scripts/README_Android.md b/wechat_plain/scripts/README_Android.md deleted file mode 100644 index 40fae48..0000000 --- a/wechat_plain/scripts/README_Android.md +++ /dev/null @@ -1,156 +0,0 @@ -# WeChat Android EnMicroMsg.db 解密读取工具链 - -## 文件清单 - -| 文件 | 用途 | 运行环境 | -|------|------|----------| -| `wechat_android_key_hunter.js` | Frida 脚本:hook `libWCDB.so` 的 `sqlite3_key` / `sqlite3_key_v2`,截获 SQLCipher 密码 | Android 微信进程 | -| `wechat_android_attach.py` | Python 控制脚本:attach 微信、收集 key、提取 DB、自动解密 | Termux / Debian proot / Mac | -| `search_server_android.py` | Web 搜索服务器:读取解密后的 `EnMicroMsg.db`,提供会话/联系人/聊天记录搜索 | 任意有 Python 的环境 | - -## 工作原理 - -``` -微信启动 - ↓ -打开第一个加密 DB (PreDownloadCheck.db) - ↓ -libWCDB.so!sqlite3_key(db, "e03ce81", 7) ← Frida 在这里截获 key - ↓ -后续所有 DB (EnMicroMsg.db, WxFileIndex.db, ...) 复用同一 key - ↓ -adb shell su -c cat 复制 DB 文件到本地 - ↓ -sqlcipher 解密:PRAGMA key="e03ce81"; PRAGMA cipher_compatibility=1; PRAGMA cipher_page_size=1024; - ↓ -SQLite 明文库 → search_server_android.py 提供 Web UI -``` - -## 关键发现(WeChat 8.0.72 on Android) - -| 项目 | 值 | -|------|-----| -| **SQLCipher key** | `e03ce81`(7 字符字符串密码,通过 `sqlite3_key` 传入) | -| **cipher_compatibility** | `1` | -| **cipher_page_size** | `1024` | -| **Key 设置时机** | 打开第一个加密 DB 时调用一次 `sqlite3_key`,后续所有 DB 复用 | -| **Java VM 可见性** | `Java.available = false`(微信有反注入,但 native hook 完全正常) | -| **Hook 目标 so** | `libWCDB.so`(内含 SQLCipher,`sqlite3_key` 符号可通过 `mod.findExportByName()` 定位) | - -## DB 结构(Android 版 vs macOS 版) - -| | Android `EnMicroMsg.db` | macOS `db_storage/` | -|--|------------------------|---------------------| -| 文件数 | 单文件 | 多个分库 | -| 消息表 | `message`(`talker` 字段区分对象) | `Msg_` 分表 | -| 联系人表 | `rcontact` | `contact_contact.db` 的 `contact` | -| 会话表 | `conversation`(可能为空) | `session_session.db` 的 `SessionTable` | - -### message 表关键字段 - -- `msgId` — 消息 ID -- `type` — 消息类型:1=文本, 3=图片, 49=链接/卡片, 10000=系统消息 -- `isSend` — 0=收到, 1=自己发送 -- `createTime` — 毫秒时间戳 -- `talker` — 聊天对象:`wxid_xxx`(单聊)、`xxx@chatroom`(群聊) -- `content` — 消息内容。群聊格式:`sender_wxid:\n` - -### rcontact 表关键字段 - -- `username` — wxid -- `nickname` — 昵称 -- `conRemark` — 备注名 -- `type` — 1=好友, 2=群成员, 3=公众号 -- `deleteFlag` — 0=正常 - -## 使用步骤 - -### 1. 环境准备 - -```bash -# 手机端:确认 frida-server 已运行(root) -adb shell "ps -A | grep frida-server" - -# Debian proot 里安装 frida Python 包(版本匹配手机 frida-server) -pip install frida==17.9.10 - -# 安装 sqlcipher(用于解密) -# Debian: apt-get install sqlcipher -# Mac: brew install sqlcipher -``` - -### 2. 捕获 SQLCipher key - -```bash -python3 wechat_android_attach.py -``` - -- 脚本会自动 kill 微信、重新启动、attach Frida -- 等待 `KEY CAPTURED` 输出 -- key 自动保存到 `~/wxmoment/wechat_keys.json` - -### 3. 提取 DB 文件 - -```bash -python3 wechat_android_attach.py --extract-db -``` - -- 复制 `EnMicroMsg.db` + `-shm` + `-wal` 到 `~/wxmoment/dbs/` -- **必须复制 wal 文件**,否则丢失最新数据 - -### 4. 解密 DB - -```bash -python3 wechat_android_attach.py --decrypt -``` - -- 自动尝试字符串密码 / hex key + 多种 cipher 配置组合 -- 输出:`~/wxmoment/dbs/EnMicroMsg.db.decrypted.db` - -### 5. 启动搜索服务器 - -```bash -python3 search_server_android.py ~/wxmoment/dbs/EnMicroMsg.db.decrypted.db -``` - -- 访问 http://localhost:5002 -- 支持:会话列表、联系人浏览、聊天记录分页、全局搜索 - -## 手动解密(如需自定义) - -```bash -sqlcipher EnMicroMsg.db -PRAGMA key = "e03ce81"; -PRAGMA cipher_compatibility = 1; -PRAGMA cipher_page_size = 1024; -ATTACH DATABASE 'EnMicroMsg_plain.db' AS plain KEY ''; -SELECT sqlcipher_export('plain'); -DETACH DATABASE plain; -.quit -``` - -## Frida 脚本 API 说明 - -`wechat_android_key_hunter.js` 的 Hook 点: - -| Hook 函数 | 模块 | 用途 | -|-----------|------|------| -| `sqlite3_key` | `libWCDB.so` | 捕获密码/key | -| `sqlite3_key_v2` | `libWCDB.so` | 捕获密码/key(带 db name) | -| `sqlite3_open_v2` | `libWCDB.so` | 记录 DB 文件路径 | -| `sqlite3_exec` | `libWCDB.so` | 捕获 `PRAGMA key` / `PRAGMA cipher_*` | - -发送的消息格式(JSON): - -```json -{"type": "key", "hex": "65303363653831", "len": 7, "via": "libWCDB.so!sqlite3_key", "path": "", "extra": {"native": true, "v2": false, "db": "0x77342ab938"}} -``` - -## 注意事项 - -1. **frida-server 版本必须匹配 Python frida 包版本**(当前:17.9.10) -2. **微信是多进程应用**,主进程 PID 需要通过 `pgrep -x com.tencent.mm` 获取最小 PID -3. **spawn 模式会超时**,所以使用 `am force-stop` + `am start` + `attach(pid)` 的 early-bird 模式 -4. **Java 层 hook 不可用**,因为微信主进程对 Frida 隐藏了 JVM。所有 hook 都在 native 层完成 -5. **DB 路径**:`/data/data/com.tencent.mm/MicroMsg//EnMicroMsg.db` -6. **`` 目录名**:基于微信账号生成,每个账号不同 diff --git a/wechat_plain/scripts/build_chat_stats.py b/wechat_plain/scripts/build_chat_stats.py deleted file mode 100644 index e8754b8..0000000 --- a/wechat_plain/scripts/build_chat_stats.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -""" -统计每个联系人的聊天记录数量和最近/最早消息时间 -输出: wechat_plain/chat_stats.json { username: {count, last_time, first_time} } -""" -import sqlite3, hashlib, json, os, time, sys - -DB_DIR = '/Users/yibo/Documents/wechat_plain' -CONTACT_DB = f'{DB_DIR}/contact_contact.db' -MSG_DBS = [f'{DB_DIR}/message_message_{i}.db' for i in range(11) - if os.path.exists(f'{DB_DIR}/message_message_{i}.db')] -OUT_FILE = f'{DB_DIR}/chat_stats.json' - -def md5(s): - return hashlib.md5(s.encode()).hexdigest() - -# ── 加载联系人 username,构建 md5 → username 反查表 ───────────────────────── -print('[1/3] 加载联系人...') -md5_to_user = {} -try: - con = sqlite3.connect(CONTACT_DB) - for (u,) in con.execute("SELECT username FROM contact WHERE delete_flag=0"): - md5_to_user[md5(u)] = u - con.close() - print(f' {len(md5_to_user)} 个联系人') -except Exception as e: - print(f'[!] 联系人读取失败: {e}') - sys.exit(1) - -# ── 遍历所有消息表,统计 count/last_time/first_time ───────────────────────── -print('[2/3] 统计消息表...') -stats = {} # username (or md5 hash) → {count, last_time, first_time} -total_tables = 0 -t0 = time.time() - -for db_path in MSG_DBS: - db_name = os.path.basename(db_path) - try: - con = sqlite3.connect(db_path) - tables = [r[0] for r in con.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" - )] - print(f' {db_name}: {len(tables)} 张表', flush=True) - for tbl in tables: - h = tbl[4:] - key = md5_to_user.get(h, h) # prefer real username, fallback to md5 - try: - row = con.execute( - f'SELECT count(*), max(create_time), min(create_time) FROM "{tbl}"' - ).fetchone() - count, last_t, first_t = row - if count and count > 0: - prev = stats.get(key, {'count': 0, 'last_time': 0, 'first_time': 0}) - stats[key] = { - 'count': prev['count'] + count, - 'last_time': max(prev['last_time'], last_t or 0), - 'first_time': min(prev['first_time'] or (first_t or 0), first_t or 0), - } - except Exception: - pass - total_tables += 1 - con.close() - except Exception as e: - print(f'[!] {db_name}: {e}') - -elapsed = time.time() - t0 -print(f' {total_tables} 张表, {len(stats)} 个聊天对象 ({elapsed:.1f}s)') - -# ── 写入 JSON ───────────────────────────────────────────────────────────────── -print(f'[3/3] 写入 {OUT_FILE}...') -with open(OUT_FILE, 'w') as f: - json.dump(stats, f, ensure_ascii=False) - -size_kb = os.path.getsize(OUT_FILE) // 1024 -print(f' 完成!{len(stats)} 条记录 ({size_kb} KB)') -print(f'\n前 10 名(消息最多):') -top = sorted(stats.items(), key=lambda x: x[1]['count'], reverse=True)[:10] -for key, v in top: - print(f' {key[:40]:<40} {v["count"]:>6} 条') diff --git a/wechat_plain/scripts/db-decrypt/wechat-db-decrypt.html b/wechat_plain/scripts/db-decrypt/wechat-db-decrypt.html new file mode 100644 index 0000000..9ef76ca --- /dev/null +++ b/wechat_plain/scripts/db-decrypt/wechat-db-decrypt.html @@ -0,0 +1,753 @@ + + + + + +macOS 微信数据库解密 — 完整记录 + + + + + + + + +
+ + +
+

macOS 微信数据库解密

+

+ 通过 Frida hook CCCryptorCreate,在运行时拦截 WeChat 传入 SQLCipher 的 AES enc_key,无需逆向二进制、无需 root、不触碰 Keychain。 +

+
+ ✓ 实测成功 + WeChat 4.1.7 (macOS) + SQLCipher v4 + Frida 17.9.10 + 2026-05-16 +
+
+ + +
+
+
解密数据库
+
3 / 30+
+
contact · message_0 · session
+
+
+
消息表数量
+
3,654
+
message_0.db 中的会话分表
+
+
+
联系人数量
+
26 表
+
contact.db 成功打开
+
+
+
关键工具
+
Frida + sqlcipher
+
brew install sqlcipher
+
+
+ + +

⚡ 三分钟速览(核心结论)

+ +
+
微信运行时
WeChat.app
+
→
+
打开数据库
WCDB/SQLCipher
+
→
+
AES 密钥设置
CCCryptorCreate
+
→
+
Frida 拦截
args[3] = key
+
→
+
sqlcipher
x'' 打开
+
+ +
+ 💡 +
+ 核心发现:WeChat 4.x on macOS 使用 Apple CommonCrypto(而非 BoringSSL)做 SQLCipher 的 AES 加解密。 + CCCryptorCreate(op, AES, options, key_ptr, 32, iv, ref) 的 args[3] + 就是 32 字节的 raw enc_key,直接以 x'<hex>' 格式传给 sqlcipher 即可打开数据库。 + 不需要 PBKDF2 反推,不需要逆向二进制。 +
+
+ + +

❌ 无效路线(踩过的坑)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
方案预期实际结果原因
lldb 内存搜索在堆内存暴力扫描 32 字节密钥✗ 0 MB 被读取WeChat 沙箱 + 进程状态问题,GetMemoryRegionInfo 全部失败
vmmap + salt 定位找到 salt 字节 → 在附近找 key✗ salt 未找到vmmap 区域地址与 lldb attach 后的地址不对应
hook 系统 sqlite3_key系统 libsqlite3.dylib 的 sqlite3_key✗ 从未被调用WeChat 静态链接 WCDB,不调用系统 sqlite3_key
hook EVP/BoringSSL微信可能用 BoringSSL 做 AES✗ 未测到SQLCipher 用的是 CommonCrypto,BoringSSL 只用于 TLS
HMAC Python 验证用 HMAC 校验候选 key✗ 全部 ❌验证逻辑看起来正确但有细节偏差;改用 sqlcipher 直接验证
+ + +

🔍 突破口:CCCryptorCreate

+

+ 扫描微信进程所有模块的导出符号时,发现 libsqlite3.dylib 的 sqlite3_key 在进程中存在,但从未被 WeChat 调用(静态链接的 WCDB 有自己的副本)。 + 随后转向 更底层的 AES 层,hook 了 CommonCrypto 的 CCCryptorCreate。 +

+

+ 这个函数在 WeChat 打开数据库(解密每一页)时被高频调用,alg=0(AES)、keyLen=32(AES-256),args[3] 直接就是 enc_key。 + 10 秒内捕获了 4 个唯一的 32 字节 AES-256 密钥,用 sqlcipher 逐一验证,成功匹配 3 个数据库。 +

+ + +

步骤一:定位数据库文件

+

微信 4.x 在 macOS 上的数据库路径(已知账号 yibooo_ac07):

+
~/Library/Containers/com.tencent.xinWeChat/Data/Documents/
+└── xwechat_files/
+    └── yibooo_ac07/
+        └── db_storage/
+            ├── contact/contact.db        ← 联系人
+            ├── message/message_0.db      ← 消息分片 0
+            ├── message/message_1.db      ← 消息分片 1
+            │   ...
+            ├── message/message_10.db     ← 消息分片 10
+            ├── session/session.db        ← 会话列表
+            ├── sns/sns.db                ← 朋友圈
+            └── general/general.db        ← 通用配置
+ +
+ ℹ️ +
所有 .db 文件均为 SQLCipher v4 加密,文件头不是标准 SQLite format 3,而是随机盐值(前 16 字节)。Page size = 4096,reserved = 48 字节(16 IV + 32 HMAC-SHA256)。
+
+ +

步骤二:确认 Frida 可用

+
# Frida 17.9.10 已通过 pip 安装
+python3 -c "import frida; print(frida.__version__)"
+# 17.9.10
+
+# CLI 工具路径
+~/Library/Python/3.9/bin/frida
+~/Library/Python/3.9/bin/frida-ps
+ +
+ ⚠️ +
必须先打开微信并登录,Frida 需要附加到运行中的进程。数据库在微信启动后即被打开,CCCryptorCreate 会在读取每一页时被调用。
+
+ +

步骤三:Hook CCCryptorCreate

+ +
+
+ /tmp/wechat_hook.js — Frida 注入脚本 +
+
/*
+ * macOS WeChat SQLCipher enc_key 拦截器
+ * 原理:hook CommonCrypto 的 CCCryptorCreate,捕获 AES-256 密钥
+ * CCCryptorCreate(op, alg, options, key, keyLength, iv, cryptorRef)
+ *   alg=0  → AES
+ *   op=1   → 解密 (decrypt)
+ *   key    → 32 字节 enc_key  ← 我们要的
+ */
+
+var found_keys = {};
+
+// 找到 CCCryptorCreate 函数地址
+var cc_create = Module.findExportByName(null, 'CCCryptorCreate');
+
+if (cc_create) {
+    Interceptor.attach(cc_create, {
+        onEnter: function(args) {
+            try {
+                var op     = args[0].toInt32();  // 0=encrypt, 1=decrypt
+                var alg    = args[1].toInt32();  // 0=AES
+                var keyLen = args[4].toInt32();
+
+                // 只关注 AES-256(keyLen=32)
+                if (alg === 0 && keyLen === 32) {
+                    var key = args[3].readByteArray(32);
+                    var hex = Array.from(new Uint8Array(key))
+                        .map(function(b) {
+                            return ('0' + b.toString(16)).slice(-2);
+                        }).join('');
+
+                    // 去重后发送
+                    if (!found_keys[hex]) {
+                        found_keys[hex] = true;
+                        send({ type: 'aes_key', op: op,
+                               len: keyLen, hex: hex });
+                    }
+                }
+            } catch(e) {}
+        }
+    });
+    send({ type: 'hooked', func: 'CCCryptorCreate',
+           addr: cc_create.toString() });
+}
+
+send({ type: 'ready' });
+ +

步骤四:Python 驱动 + 自动验证

+ +
+
+ /tmp/wechat_frida.py — 驱动脚本 +
+
#!/usr/bin/env python3
+import sys, os, time, subprocess
+
+# Frida 路径(pip 安装在用户目录)
+sys.path.insert(0, os.path.expanduser("~/Library/Python/3.9/lib/python/site-packages"))
+import frida
+
+DB_BASE = os.path.expanduser(
+    "~/Library/Containers/com.tencent.xinWeChat/Data/Documents/"
+    "xwechat_files/yibooo_ac07/db_storage/"
+)
+
+def test_key_with_sqlcipher(key_hex, db_path):
+    """用 sqlcipher 验证 key 是否能打开数据库"""
+    pragma = f"PRAGMA key = \"x'{key_hex}'\";"
+    r = subprocess.run(
+        ['sqlcipher', db_path],
+        input=f"{pragma}\nSELECT count(*) FROM sqlite_master;\n",
+        capture_output=True, text=True
+    )
+    lines = [l for l in r.stdout.split('\n') if l.strip() and l != 'ok']
+    if lines and lines[0].isdigit():
+        return int(lines[0])  # 返回表数量
+    return None
+
+# 获取微信 PID
+pid = int(subprocess.run(['pgrep', '-x', 'WeChat'],
+    capture_output=True, text=True).stdout.strip().split('\n')[0])
+print(f"[*] 附加到 WeChat PID={pid}")
+
+session = frida.attach(pid)
+script  = session.create_script(open('/tmp/wechat_hook.js').read())
+
+captured = []
+databases = {
+    'contact':  'contact/contact.db',
+    'message0': 'message/message_0.db',
+    'session':  'session/session.db',
+}
+
+def on_message(msg, data):
+    if msg['type'] != 'send': return
+    p = msg['payload']
+    if p.get('type') == 'aes_key':
+        k = p['hex']
+        print(f"  捕获 AES key: {k[:16]}...")
+        # 验证这个 key 能打开哪些数据库
+        for dbname, relpath in databases.items():
+            tables = test_key_with_sqlcipher(k, DB_BASE + relpath)
+            if tables is not None:
+                print(f"  ✅ {dbname}: key 匹配!表数量={tables}")
+                captured.append((k, dbname))
+                open('/tmp/wechat_key.hex', 'a').write(f"{dbname}:{k}\n")
+
+script.on('message', on_message)
+script.load()
+print("[*] 监听中,在微信里操作触发数据库读取... (Ctrl+C 退出)")
+
+try:
+    while True: time.sleep(1)
+except KeyboardInterrupt: pass
+session.detach()
+ +

步骤五:用 sqlcipher 打开数据库

+ +
+
+ Shell — 打开解密数据库 +
+
DB_BASE="~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/yibooo_ac07/db_storage"
+
+# 打开联系人数据库
+sqlcipher "$DB_BASE/contact/contact.db" <<SQL
+PRAGMA key = "x'1064e7dfb42cc3ea447da335ca848a2381e8738ad1ebc31e76e993456ba1fe2b'";
+SELECT username, nick_name, remark FROM contact WHERE local_type=3 LIMIT 20;
+SQL
+
+# 打开消息数据库
+sqlcipher "$DB_BASE/message/message_0.db" <<SQL
+PRAGMA key = "x'de0b8d3304e9940e96baa7c9ecfe33a226e68590fa0060d7d09ee088e1a104f8'";
+-- 消息表名 = "Msg_" + MD5(username)
+SELECT local_type, datetime(create_time,'unixepoch','localtime'), message_content
+FROM Msg_8f5f8034b0f757ef2701729beabc4770  -- MD5('dp543831577')
+WHERE local_type = 1
+ORDER BY create_time DESC LIMIT 20;
+SQL
+ +

步骤六:消息表名计算

+

WeChat 的每个聊天对话在 message_N.db 中对应一张表 Msg_<md5>,表名由会话 ID 的 MD5 决定:

+ +
+
+ Python — 计算消息表名 +
+
import hashlib
+
+def msg_table(username):
+    md5 = hashlib.md5(username.encode()).hexdigest()
+    return f"Msg_{md5}"
+
+# 例子
+print(msg_table("dp543831577"))
+# → Msg_8f5f8034b0f757ef2701729beabc4770
+
+print(msg_table("44170630061@chatroom"))
+# → Msg_99ce6fe9bc90c6320bb5e75714360da0
+ + +

密钥映射表

+

以下是本次成功捕获并验证的密钥(仅本机本账号有效,每次微信更新或重置后需重新提取):

+ +
+
contact / contact.db
+
1064e7dfb42cc3ea447da335ca848a2381e8738ad1ebc31e76e993456ba1fe2b
+
✓ sqlcipher 验证:26 张表
+
+ +
+
message / message_0.db
+
de0b8d3304e9940e96baa7c9ecfe33a226e68590fa0060d7d09ee088e1a104f8
+
✓ sqlcipher 验证:3654 张 Msg_* 表
+
+ +
+
session / session.db
+
f6b305477cda040af70a41f6321c3017b3400a3688e712bb1c9cf7aee50515b8
+
✓ sqlcipher 验证:14 张表
+
+ +
+
其他数据库(message_1~10, general, sns...)
+
e5364de757971b4ead1db0d667d9e78bd7a6612254f48a19b43ae01247c9d28f
+
待确认 — 滚动浏览对应聊天触发后再验证
+
+ + +

真实查询结果

+ +

联系人列表(contact.db)

+
+username | nick_name | remark +───────────────────────────────────────────────────────────── +wxid_9802458023412 | Kaki | Kaki 青藤 93 荷兰 +mali847944 | 马丽 | 您好,招行个贷马丽 +wxid_kb6z85qjjrrv21| 1.26 5 | 我禧瑞春秋物业前台 +wxid_0puuvbx9df1321| Qian | Qian 青藤 37 财务 +yuanhanweibo | 幸运 | 幸运 lulu 青藤 90摩羯 +... 更多联系人 ... +
+ +

最近会话(session.db)

+
+username | 时间 | 最新消息预览 +────────────────────────────────────────────────────────────────── +3164323182@chatroom | 15:31:16 | [強][強][強] +44170630061@chatroom| 15:30:55 | @Letty 戈壁大神[呲牙][玫瑰] +brandsessionholder | 15:31:26 | [38条] 新智元: 突发!OpenAI大规模重组... +gh_8391a4921226 | 15:29:52 | MiniCPM-V4.6登顶 Hugging Face Trending! +qqtech | 15:26:25 | 稚晖君抛出"三条曲线",讲了一套具身智能出海新逻辑 +... 共 20+ 活跃会话 ... +
+ +

聊天消息(message_0.db · 特定对话)

+
+type | sender | 时间 | 内容 +──────────────────────────────────────────────────────────── +1 | 543 | 2026-05-16 15:19 | 微信里"拿 DB 数据"之前其实分两类... +1 | 15 | 2026-05-15 12:39 | 生产力大于需求 +1 | 15 | 2026-05-15 12:39 | 短剧卷是肯定的 +1 | 543 | 2026-05-15 12:39 | 中国人干死中国人 +1 | 543 | 2026-05-15 12:38 | 短剧现在好卷 +1 | 15 | 2026-05-15 12:38 | 干不过 claude +... local_type=47 是图片/视频,local_type=10000 是系统消息 ... +
+ +

群聊消息(44170630061@chatroom)

+
+群消息格式:content = "sender_wxid:\n内容" +──────────────────────────────────────────── +a36269329: +@Letty 戈壁大神[呲牙][玫瑰][玫瑰] + +wxid_5194951949422: +一起共修 + +a36269329: +Web3 大神 @Ivy [呲牙][玫瑰][玫瑰] +
+ + +

后续扩展

+ +
    +
  • +

    获取其余消息分片的密钥

    +

    在微信里滚动浏览对应的聊天,触发 WeChat 读取 message_1.db~message_10.db,Frida hook 会自动捕获对应的 AES key,用同样的 sqlcipher 验证方法确认后记录。

    +
  • +
  • +

    写 Python 脚本全量导出

    +

    结合三个密钥,用 Python 的 apsw(支持 SQLCipher)或 subprocess 调 sqlcipher,批量读取所有消息写入自己的 SQLite 或 JSON。

    +
  • +
  • +

    与 Android Frida 方案对接

    +

    Android 侧已经有 hook h9.r9 / h9.A8 的消息拦截链路,macOS 侧则补充了历史数据的解密能力,两路数据可合并到同一个 SessionDB / read_history.db。

    +
  • +
  • +

    配置 Frida 持久化 Hook

    +

    将 hook 脚本和 Python 驱动写成 launchd plist 或 shell 别名,每次启动微信后自动运行 10 秒捕获密钥,无需手动操作。

    +
  • +
+ +

Android vs macOS 方案对比

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
维度Android(已实现)macOS(本次)
Hook 目标h9.r9 / h9.A8 (Java)CCCryptorCreate (C)
获取数据实时消息对象(D0/O0/j 字段)SQLCipher AES enc_key → 离线全量
数据库不直接解密 EnMicroMsg.db直接解密 contact/message/session.db
Frida 工具frida-server on Android + Python client in Debian prootfrida Python 17.9.10 on macOS
密钥格式Java passphrase(hook WCDB open 可拿)32 字节 raw AES enc_key → x'hex'
稳定性高(hook 消息对象,不触碰 DB)中(依赖 CCCryptorCreate,WeChat 更新后需重验)
+ +
+ ⚠️ +
+ 注意事项:密钥为 运行时动态值,不存储在任何磁盘文件中(Keychain 无记录)。 + WeChat 版本更新后 AES 密钥 可能 变化,需重新运行 Frida hook 提取。 + 本文档和密钥仅用于自己账号的个人数据访问,勿用于他人数据。 +
+
+ +
+

生成时间:2026-05-16 · 账号:yibooo_ac07 · WeChat 4.1.7 · Frida 17.9.10 · sqlcipher 4.16.0

+
+ +
+ + + + diff --git a/wechat_plain/scripts/db-decrypt/wechat_android_attach.py b/wechat_plain/scripts/db-decrypt/wechat_android_attach.py new file mode 100644 index 0000000..7c196e5 --- /dev/null +++ b/wechat_plain/scripts/db-decrypt/wechat_android_attach.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +WeChat Android SQLCipher Key Capture + DB Extraction +Runs in Termux / Debian proot, connects to frida-server on Android host. + +Usage: + python3 wechat_android_attach.py # Capture keys + python3 wechat_android_attach.py --extract-db # Pull DB from phone + python3 wechat_android_attach.py --decrypt # Decrypt with captured key + +Prerequisites: + - frida-server running on Android (as root) + - frida Python package installed in proot/Termux + - WeChat installed and logged in + - Phone has root access +""" +import sys, os, json, time, subprocess, re + +# ── Configuration ───────────────────────────────────────────────────────────── +WECHAT_PKG = "com.tencent.mm" +JS_PATH = os.path.join(os.path.dirname(__file__), "wechat_android_key_hunter.js") +OUT_DIR = "/root/wxmoment" # inside Debian proot +KEY_CACHE = os.path.join(OUT_DIR, "wechat_keys.json") +DB_OUT_DIR = os.path.join(OUT_DIR, "dbs") + +os.makedirs(OUT_DIR, exist_ok=True) +os.makedirs(DB_OUT_DIR, exist_ok=True) + +# ── Key storage ─────────────────────────────────────────────────────────────── +captured_keys = {} # hex_key -> {path, via, timestamp, ...} +cipher_specs = {} # field -> value + +# Try loading existing keys +def load_keys(): + if not os.path.exists(KEY_CACHE): + return + try: + with open(KEY_CACHE) as f: + data = json.load(f) + captured_keys.update(data.get("keys", {})) + cipher_specs.update(data.get("cipher_specs", {})) + print(f"[cache] Loaded {len(captured_keys)} known keys") + except Exception as e: + print(f"[!] Failed to load key cache: {e}") + +def save_keys(): + try: + with open(KEY_CACHE, "w") as f: + json.dump({ + "keys": captured_keys, + "cipher_specs": cipher_specs, + "updated": time.time() + }, f, indent=2, ensure_ascii=False) + except Exception as e: + print(f"[!] Failed to save keys: {e}") + +# ── Frida setup ─────────────────────────────────────────────────────────────── +try: + import frida +except ImportError: + print("[!] frida Python package not installed.") + print(" Install with: pip install frida") + sys.exit(1) + +def get_usb_device(): + """Get USB-connected Frida device (Android phone).""" + for _ in range(5): + try: + device = frida.get_usb_device(timeout=3) + return device + except Exception as e: + print(f"[!] Waiting for USB device... ({e})") + time.sleep(1) + raise RuntimeError("No USB Frida device found. Is frida-server running?") + +def spawn_or_attach(device, pkg): + """Try to attach to running process, or spawn a new one.""" + # Check if already running + try: + apps = device.enumerate_applications() + for app in apps: + if app.identifier == pkg: + if app.pid != 0: + print(f"[*] Attaching to running {pkg} (PID={app.pid})") + return device.attach(app.pid) + break + except Exception as e: + print(f"[!] enumerate_applications failed: {e}") + + # Try direct attach + try: + session = device.attach(pkg) + print(f"[*] Attached to {pkg}") + return session + except Exception as e: + print(f"[!] Direct attach failed: {e}") + + # Spawn new + print(f"[*] Spawning {pkg}...") + pid = device.spawn([pkg]) + session = device.attach(pid) + device.resume(pid) + print(f"[*] Spawned and attached (PID={pid})") + return session + +# ── Message handler ─────────────────────────────────────────────────────────── +def on_message(msg, data): + if msg.get("type") != "send": + return + payload = msg.get("payload", {}) + ptype = payload.get("type", "") + + if ptype == "key": + key_hex = payload.get("hex", "") + key_len = payload.get("len", 0) + via = payload.get("via", "") + path = payload.get("path", "") + extra = payload.get("extra", {}) + + # Deduplicate + cache_key = key_hex + "@" + path + if cache_key in captured_keys: + return + + captured_keys[cache_key] = { + "hex": key_hex, + "len": key_len, + "via": via, + "path": path, + "extra": extra, + "ts": time.time(), + "ts_fmt": time.strftime("%Y-%m-%d %H:%M:%S") + } + save_keys() + + print(f"\n [KEY CAPTURED via {via}]") + print(f" hex: {key_hex[:32]}{'...' if len(key_hex) > 32 else ''}") + print(f" len: {key_len}") + if path: + print(f" path: {path}") + if extra: + print(f" extra: {json.dumps(extra, ensure_ascii=False)}") + + # If this is EnMicroMsg.db, suggest extraction + if "EnMicroMsg" in path or not path: + print(f" => Run: python3 wechat_android_attach.py --extract-db") + + elif ptype == "cipher_spec": + field = payload.get("field", "") + value = payload.get("value", "") + cipher_specs[field] = value + save_keys() + print(f" [CIPHER SPEC] {field} = {value}") + + elif ptype == "info": + info_msg = payload.get("msg", "") + # Highlight DB path discoveries + if info_msg.startswith("FOUND_DB:"): + print(f"\n [DB FOUND] {info_msg}") + else: + print(f" [INFO] {info_msg}") + + elif ptype == "error": + print(f" [ERROR] {payload.get('msg', '')}") + +# ── DB Extraction ───────────────────────────────────────────────────────────── +def find_enmicromsg_db(): + """Find EnMicroMsg.db path on phone via adb.""" + print("[*] Scanning for EnMicroMsg.db on phone...") + result = subprocess.run( + ["adb", "shell", "su", "-c", + "find /data/data/com.tencent.mm/MicroMsg -name 'EnMicroMsg.db' -type f 2>/dev/null"], + capture_output=True, text=True + ) + paths = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()] + if not paths: + print("[!] EnMicroMsg.db not found. Is WeChat logged in?") + return None + print(f"[+] Found {len(paths)} EnMicroMsg.db:") + for i, p in enumerate(paths): + print(f" [{i}] {p}") + return paths[0] # Return first (usually the active account) + +def extract_db(db_path=None, out_dir=DB_OUT_DIR): + """Copy WeChat DB files from phone to local directory via adb shell su -c cat.""" + if db_path is None: + db_path = find_enmicromsg_db() + if not db_path: + return False + + # Also copy -shm and -wal files + files_to_copy = [db_path, db_path + "-shm", db_path + "-wal"] + + os.makedirs(out_dir, exist_ok=True) + + for src in files_to_copy: + dst = os.path.join(out_dir, os.path.basename(src)) + print(f"[*] Copying {src} -> {dst}") + r = subprocess.run( + ["adb", "shell", "su", "-c", f"cat '{src}'"], + capture_output=True + ) + if r.returncode == 0 and r.stdout: + with open(dst, "wb") as f: + f.write(r.stdout) + print(f" [OK] {dst} ({os.path.getsize(dst)} bytes)") + else: + print(f" [SKIP] {src} (not found or unreadable)") + + print(f"\n[+] DB files in: {out_dir}") + return True + +def try_decrypt(db_path, key_hex, out_path=None): + """Try to decrypt EnMicroMsg.db with captured key using sqlcipher. + Automatically tries string password and raw hex key formats, + with common cipher configurations.""" + if out_path is None: + out_path = db_path + ".decrypted.db" + + # Remove existing output + if os.path.exists(out_path): + os.remove(out_path) + + # Build candidate keys and configs + try: + key_str = bytes.fromhex(key_hex).decode('latin-1') + except ValueError: + key_str = key_hex + + candidates = [] + # String password format (most common for WCDB) + candidates.append(("string", f'PRAGMA key = "{key_str}";')) + # Raw hex key format + candidates.append(("hex", f"PRAGMA key = \"x'{key_hex}'\";")) + + # Cipher configs to try + page_size = cipher_specs.get("pageSize", 1024) + kdf_iter = cipher_specs.get("kdfIteration", None) + + configs = [] + base = f"PRAGMA cipher_compatibility = 1;\nPRAGMA cipher_page_size = {page_size};" + configs.append(("compat1", base)) + if kdf_iter: + configs.append(("compat1+kdf", f"{base}\nPRAGMA kdf_iter = {kdf_iter};")) + configs.append(("no_compat", f"PRAGMA cipher_page_size = {page_size};")) + + print(f"[*] Trying decryption of {os.path.basename(db_path)}...") + print(f" Key candidates: {len(candidates)} | Config candidates: {len(configs)}") + + for key_name, key_cmd in candidates: + for cfg_name, cfg_pragmas in configs: + sql = f"{key_cmd}\n{cfg_pragmas}\nATTACH DATABASE '{out_path}' AS plain KEY '';\nSELECT sqlcipher_export('plain');\nDETACH DATABASE plain;\n" + + r = subprocess.run( + ["sqlcipher", db_path], + input=sql, capture_output=True, text=True, timeout=300 + ) + + if r.returncode == 0 and os.path.exists(out_path) and os.path.getsize(out_path) > 4096: + sz = os.path.getsize(out_path) + print(f" [SUCCESS] Decrypted: {sz} bytes (key={key_name}, cfg={cfg_name})") + print(f" Use: sqlite3 {out_path}") + return True + + err = r.stderr.strip()[:200] if r.stderr else "unknown" + print(f" [FAILED] All combinations failed. Last error: {err}") + return False + +# ── Main interactive loop ───────────────────────────────────────────────────── +def run_capture(): + load_keys() + + print("=" * 60) + print("WeChat Android SQLCipher Key Capture") + print("=" * 60) + print(f"[*] JS script: {JS_PATH}") + print(f"[*] Key cache: {KEY_CACHE}") + print(f"[*] DB output: {DB_OUT_DIR}") + print() + + if not os.path.exists(JS_PATH): + print(f"[!] JS script not found: {JS_PATH}") + sys.exit(1) + + device = get_usb_device() + print(f"[*] Device: {device.name}") + + session = spawn_or_attach(device, WECHAT_PKG) + + with open(JS_PATH) as f: + js_code = f.read() + + script = session.create_script(js_code) + script.on("message", on_message) + script.load() + + print("[*] Script loaded. Hooks active.") + print("[*] Open WeChat, browse chats to trigger DB access.") + print("[*] Press Ctrl+C to stop.\n") + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\n[*] Stopping...") + finally: + session.detach() + save_keys() + print(f"\n[RESULT] Captured {len(captured_keys)} unique keys:") + for k, v in captured_keys.items(): + print(f" {v['hex'][:32]}... via={v['via']} path={v['path']}") + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == "--extract-db": + extract_db() + return + + if len(sys.argv) > 1 and sys.argv[1] == "--decrypt": + db_path = sys.argv[2] if len(sys.argv) > 2 else None + if not db_path: + # Find most recent extracted DB + dbs = [f for f in os.listdir(DB_OUT_DIR) if f.endswith(".db") and "decrypted" not in f] + if not dbs: + print("[!] No DB found. Run with --extract-db first.") + sys.exit(1) + db_path = os.path.join(DB_OUT_DIR, sorted(dbs, key=lambda x: os.path.getmtime(os.path.join(DB_OUT_DIR, x)))[-1]) + + load_keys() + if not captured_keys: + print("[!] No keys captured yet. Run capture first.") + sys.exit(1) + + # Try each key + for k, v in captured_keys.items(): + if try_decrypt(db_path, v["hex"]): + break + return + + run_capture() + +if __name__ == "__main__": + main() diff --git a/wechat_plain/scripts/db-decrypt/wechat_android_attach_v2.py b/wechat_plain/scripts/db-decrypt/wechat_android_attach_v2.py new file mode 100644 index 0000000..4b59d50 --- /dev/null +++ b/wechat_plain/scripts/db-decrypt/wechat_android_attach_v2.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +""" +WeChat Android Full Toolkit v2 +- Key capture from libWCDB.so +- DB extraction via adb +- Auto decryption +- Message monitoring (send/receive) +- Active message sending via Frida RPC +- ContentProvider auth bypass + +Usage: + python3 wechat_android_attach_v2.py # Capture keys + monitor + python3 wechat_android_attach_v2.py --extract-db # Pull DB from phone + python3 wechat_android_attach_v2.py --decrypt # Decrypt with captured key + python3 wechat_android_attach_v2.py --send-msg # Send message + python3 wechat_android_attach_v2.py --status # Check Frida session status +""" +import sys, os, json, time, subprocess, re + +# ── Configuration ───────────────────────────────────────────────────────────── +WECHAT_PKG = "com.tencent.mm" +JS_PATH = os.path.join(os.path.dirname(__file__), "wechat_android_full_hunter.js") +OUT_DIR = os.path.expanduser("~/wxmoment") +KEY_CACHE = os.path.join(OUT_DIR, "wechat_keys.json") +DB_OUT_DIR = os.path.join(OUT_DIR, "dbs") + +os.makedirs(OUT_DIR, exist_ok=True) +os.makedirs(DB_OUT_DIR, exist_ok=True) + +# ── Key storage ─────────────────────────────────────────────────────────────── +captured_keys = {} +cipher_specs = {} + +# Try loading existing keys +def load_keys(): + if not os.path.exists(KEY_CACHE): + return + try: + with open(KEY_CACHE) as f: + data = json.load(f) + captured_keys.update(data.get("keys", {})) + cipher_specs.update(data.get("cipher_specs", {})) + print(f"[cache] Loaded {len(captured_keys)} known keys") + except Exception as e: + print(f"[!] Failed to load key cache: {e}") + +def save_keys(): + try: + with open(KEY_CACHE, "w") as f: + json.dump({ + "keys": captured_keys, + "cipher_specs": cipher_specs, + "updated": time.time() + }, f, indent=2, ensure_ascii=False) + except Exception as e: + print(f"[!] Failed to save keys: {e}") + +# ── Frida setup ─────────────────────────────────────────────────────────────── +try: + import frida +except ImportError: + print("[!] frida Python package not installed.") + print(" Install with: pip install frida") + sys.exit(1) + +def get_usb_device(): + for _ in range(5): + try: + device = frida.get_usb_device(timeout=3) + return device + except Exception as e: + print(f"[!] Waiting for USB device... ({e})") + time.sleep(1) + raise RuntimeError("No USB Frida device found. Is frida-server running?") + +def get_wechat_pid(device): + """Get WeChat main process PID via pgrep fallback.""" + # Try with root first + for su_path in ["/debug_ramdisk/su", "/data/local/tmp/su2"]: + try: + result = subprocess.run( + ["adb", "shell", su_path, "-c", "pgrep -x com.tencent.mm"], + capture_output=True, text=True + ) + if result.returncode == 0 and result.stdout.strip(): + pids = sorted([int(p.strip()) for p in result.stdout.strip().split("\n") if p.strip()]) + if pids: + return pids[0] # Main process is the smallest PID + except Exception: + pass + + # Fallback without root + try: + result = subprocess.run( + ["adb", "shell", "pgrep", "-x", "com.tencent.mm"], + capture_output=True, text=True + ) + pids = sorted([int(p.strip()) for p in result.stdout.strip().split("\n") if p.strip()]) + if pids: + return pids[0] + except Exception as e: + print(f"[!] pgrep failed: {e}") + + try: + for proc in device.enumerate_processes(): + if proc.name == WECHAT_PKG: + return proc.pid + except Exception as e: + print(f"[!] enumerate_processes failed: {e}") + + return None + +def attach_wechat(device, wait_for_process=True): + """Attach to WeChat main process.""" + pid = get_wechat_pid(device) + + # Wait for process to appear if requested + if not pid and wait_for_process: + print("[*] Waiting for WeChat process to appear...") + for _ in range(30): # Wait up to 30 seconds + time.sleep(1) + pid = get_wechat_pid(device) + if pid: + print(f"[*] WeChat appeared (PID={pid})") + break + + if pid: + print(f"[*] Attaching to {WECHAT_PKG} (PID={pid})") + try: + return device.attach(pid) + except Exception as e: + print(f"[!] Attach to PID={pid} failed: {e}") + + # Fallback: try direct attach by name + try: + session = device.attach(WECHAT_PKG) + print(f"[*] Attached to {WECHAT_PKG} by name") + return session + except Exception as e: + print(f"[!] Direct attach failed: {e}") + + return None + +# ── Session management ──────────────────────────────────────────────────────── +active_session = None +active_script = None + +def load_script(session, js_path): + global active_script + with open(js_path) as f: + js_code = f.read() + script = session.create_script(js_code) + script.on("message", on_message) + script.load() + active_script = script + return script + +def on_message(msg, data): + if msg.get("type") != "send": + return + payload = msg.get("payload", {}) + ptype = payload.get("type", "") + + if ptype == "key": + key_hex = payload.get("hex", "") + key_len = payload.get("len", 0) + via = payload.get("via", "") + path = payload.get("path", "") + extra = payload.get("extra", {}) + + cache_key = key_hex + "@" + path + if cache_key in captured_keys: + return + + captured_keys[cache_key] = { + "hex": key_hex, + "len": key_len, + "via": via, + "path": path, + "extra": extra, + "ts": time.time(), + "ts_fmt": time.strftime("%Y-%m-%d %H:%M:%S") + } + save_keys() + + print(f"\n [KEY CAPTURED via {via}]") + print(f" hex: {key_hex[:32]}{'...' if len(key_hex) > 32 else ''}") + print(f" len: {key_len}") + if path: + print(f" path: {path}") + + elif ptype == "cipher_spec": + field = payload.get("field", "") + value = payload.get("value", "") + cipher_specs[field] = value + save_keys() + print(f" [CIPHER SPEC] {field} = {value}") + + elif ptype == "msg": + direction = payload.get("direction", "") + toUser = payload.get("toUser", "") + content = payload.get("content", "") + msgType = payload.get("msgType", 0) + print(f"\n [MSG {direction.upper()}] to={toUser} type={msgType}") + print(f" content: {content[:100]}{'...' if len(content) > 100 else ''}") + + elif ptype == "sql_insert": + table = payload.get("table", "") + sql = payload.get("sql", "") + print(f"\n [SQL INSERT] table={table}") + print(f" sql: {sql[:200]}{'...' if len(sql) > 200 else ''}") + + elif ptype == "info": + info_msg = payload.get("msg", "") + if info_msg.startswith("FOUND_DB:"): + print(f"\n [DB FOUND] {info_msg}") + elif info_msg.startswith("[JavaHook]") or info_msg.startswith("[Bypass]"): + print(f" {info_msg}") + else: + print(f" [INFO] {info_msg}") + + elif ptype == "error": + print(f" [ERROR] {payload.get('msg', '')}") + +# ── DB Extraction ───────────────────────────────────────────────────────────── +def get_su_cmd(): + for su_path in ["/debug_ramdisk/su", "/data/local/tmp/su2"]: + try: + result = subprocess.run( + ["adb", "shell", su_path, "-c", "id"], + capture_output=True, text=True + ) + if result.returncode == 0 and "uid=0" in result.stdout: + return ["adb", "shell", su_path, "-c"] + except Exception: + pass + return ["adb", "shell", "su", "-c"] + +def find_enmicromsg_db(): + print("[*] Scanning for EnMicroMsg.db on phone...") + su_cmd = get_su_cmd() + result = subprocess.run( + su_cmd + ["find /data/data/com.tencent.mm/MicroMsg -name 'EnMicroMsg.db' -type f 2>/dev/null"], + capture_output=True, text=True + ) + paths = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()] + if not paths: + print("[!] EnMicroMsg.db not found. Is WeChat logged in?") + return None + print(f"[+] Found {len(paths)} EnMicroMsg.db:") + for i, p in enumerate(paths): + print(f" [{i}] {p}") + return paths[0] + +def extract_db(db_path=None, out_dir=DB_OUT_DIR): + if db_path is None: + db_path = find_enmicromsg_db() + if not db_path: + return False + + files_to_copy = [db_path, db_path + "-shm", db_path + "-wal"] + os.makedirs(out_dir, exist_ok=True) + su_cmd = get_su_cmd() + + for src in files_to_copy: + dst = os.path.join(out_dir, os.path.basename(src)) + print(f"[*] Copying {src} -> {dst}") + r = subprocess.run( + su_cmd + [f"cat '{src}'"], + capture_output=True + ) + if r.returncode == 0 and r.stdout: + with open(dst, "wb") as f: + f.write(r.stdout) + print(f" [OK] {dst} ({os.path.getsize(dst)} bytes)") + else: + print(f" [SKIP] {src} (not found or unreadable)") + + print(f"\n[+] DB files in: {out_dir}") + return True + +# ── Decryption ──────────────────────────────────────────────────────────────── +def try_decrypt(db_path, key_hex, out_path=None): + if out_path is None: + out_path = db_path + ".decrypted.db" + + if os.path.exists(out_path): + os.remove(out_path) + + try: + key_str = bytes.fromhex(key_hex).decode('latin-1') + except ValueError: + key_str = key_hex + + candidates = [] + candidates.append(("string", f'PRAGMA key = "{key_str}";')) + candidates.append(("hex", f"PRAGMA key = \"x'{key_hex}'\";")) + + page_size = cipher_specs.get("pageSize", 1024) + kdf_iter = cipher_specs.get("kdfIteration", None) + + configs = [] + base = f"PRAGMA cipher_compatibility = 1;\nPRAGMA cipher_page_size = {page_size};" + configs.append(("compat1", base)) + if kdf_iter: + configs.append(("compat1+kdf", f"{base}\nPRAGMA kdf_iter = {kdf_iter};")) + configs.append(("no_compat", f"PRAGMA cipher_page_size = {page_size};")) + + print(f"[*] Trying decryption of {os.path.basename(db_path)}...") + print(f" Key candidates: {len(candidates)} | Config candidates: {len(configs)}") + + for key_name, key_cmd in candidates: + for cfg_name, cfg_pragmas in configs: + sql = f"{key_cmd}\n{cfg_pragmas}\nATTACH DATABASE '{out_path}' AS plain KEY '';\nSELECT sqlcipher_export('plain');\nDETACH DATABASE plain;\n" + + r = subprocess.run( + ["sqlcipher", db_path], + input=sql, capture_output=True, text=True, timeout=300 + ) + + if r.returncode == 0 and os.path.exists(out_path) and os.path.getsize(out_path) > 4096: + sz = os.path.getsize(out_path) + print(f" [SUCCESS] Decrypted: {sz} bytes (key={key_name}, cfg={cfg_name})") + print(f" Use: sqlite3 {out_path}") + return True + + err = r.stderr.strip()[:200] if r.stderr else "unknown" + print(f" [FAILED] All combinations failed. Last error: {err}") + return False + +# ── Message Sending ─────────────────────────────────────────────────────────── +def send_text_msg(script, to_user, content, method="auto"): + """Send text message via Frida RPC.""" + print(f"[*] Sending message to {to_user}...") + print(f" Content: {content[:50]}{'...' if len(content) > 50 else ''}") + + rpc = script.exports + + # Check status first + try: + status = rpc.get_status() + print(f" Status: pid={status.get('pid')}, javaAvailable={status.get('javaAvailable')}, javaHooksActive={status.get('javaHooksActive')}") + if not status.get("javaAvailable"): + print("[!] Java not available in target process. Cannot send messages via Java API.") + print(" Try: spawn a fresh process, or attach to a different WeChat sub-process.") + return False + except Exception as e: + print(f"[!] Failed to get status: {e}") + return False + + # Try sending + if method == "auto" or method == "rpc": + try: + result = rpc.send_text_msg(to_user, content) + print(f" [RPC Result] {json.dumps(result, ensure_ascii=False, indent=2)}") + if result.get("success"): + return True + except Exception as e: + print(f" [RPC Error] {e}") + + if method == "auto" or method == "provider": + try: + result = rpc.send_via_provider(to_user, content) + print(f" [Provider Result] {json.dumps(result, ensure_ascii=False, indent=2)}") + if result.get("success"): + return True + except Exception as e: + print(f" [Provider Error] {e}") + + return False + +# ── Main interactive loop ───────────────────────────────────────────────────── +def run_capture_and_monitor(): + load_keys() + + print("=" * 60) + print("WeChat Android Full Hunter v2") + print("=" * 60) + print(f"[*] JS script: {JS_PATH}") + print(f"[*] Key cache: {KEY_CACHE}") + print(f"[*] DB output: {DB_OUT_DIR}") + print() + + if not os.path.exists(JS_PATH): + print(f"[!] JS script not found: {JS_PATH}") + sys.exit(1) + + device = get_usb_device() + print(f"[*] Device: {device.name}") + + session = attach_wechat(device) + if not session: + print("[!] Failed to attach to WeChat") + sys.exit(1) + + global active_session + active_session = session + + script = load_script(session, JS_PATH) + + print("[*] Script loaded. Hooks active.") + print("[*] Operations:") + print(" - Open WeChat, browse chats to trigger DB access") + print(" - Send messages to monitor outgoing traffic") + print(" - Press Ctrl+C to stop") + print() + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\n[*] Stopping...") + finally: + session.detach() + save_keys() + print(f"\n[RESULT] Captured {len(captured_keys)} unique keys") + for k, v in captured_keys.items(): + print(f" {v['hex'][:32]}... via={v['via']}") + +def run_send_msg(to_user, content): + print("=" * 60) + print("WeChat Send Message via Frida") + print("=" * 60) + + if not os.path.exists(JS_PATH): + print(f"[!] JS script not found: {JS_PATH}") + sys.exit(1) + + device = get_usb_device() + session = attach_wechat(device) + if not session: + print("[!] Failed to attach to WeChat") + sys.exit(1) + + script = load_script(session, JS_PATH) + time.sleep(1) # Let hooks initialize + + success = send_text_msg(script, to_user, content) + + session.detach() + return success + +def run_status(): + device = get_usb_device() + session = attach_wechat(device) + if not session: + print("[!] Failed to attach") + sys.exit(1) + + script = load_script(session, JS_PATH) + time.sleep(0.5) + + try: + status = script.exports.get_status() + print(json.dumps(status, indent=2)) + except Exception as e: + print(f"[!] Error: {e}") + + session.detach() + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == "--extract-db": + extract_db() + return + + if len(sys.argv) > 1 and sys.argv[1] == "--decrypt": + db_path = sys.argv[2] if len(sys.argv) > 2 else None + if not db_path: + dbs = [f for f in os.listdir(DB_OUT_DIR) if f.endswith(".db") and "decrypted" not in f] + if not dbs: + print("[!] No DB found. Run with --extract-db first.") + sys.exit(1) + db_path = os.path.join(DB_OUT_DIR, sorted(dbs, key=lambda x: os.path.getmtime(os.path.join(DB_OUT_DIR, x)))[-1]) + + load_keys() + if not captured_keys: + print("[!] No keys captured yet. Run capture first.") + sys.exit(1) + + for k, v in captured_keys.items(): + if try_decrypt(db_path, v["hex"]): + break + return + + if len(sys.argv) > 1 and sys.argv[1] == "--send-msg": + if len(sys.argv) < 4: + print("Usage: python3 wechat_android_attach_v2.py --send-msg ") + print(" toUser: wxid_xxx or xxx@chatroom") + print(" content: message text") + sys.exit(1) + success = run_send_msg(sys.argv[2], sys.argv[3]) + sys.exit(0 if success else 1) + + if len(sys.argv) > 1 and sys.argv[1] == "--status": + run_status() + return + + run_capture_and_monitor() + +if __name__ == "__main__": + main() diff --git a/wechat_plain/scripts/db-decrypt/wechat_android_full_hunter.js b/wechat_plain/scripts/db-decrypt/wechat_android_full_hunter.js new file mode 100644 index 0000000..41f7cb0 --- /dev/null +++ b/wechat_plain/scripts/db-decrypt/wechat_android_full_hunter.js @@ -0,0 +1,549 @@ +/* + * WeChat Android Full Hunter (Frida 17.x) + * Targets WeChat 8.0.72 on Android + * + * Capabilities: + * 1. Capture SQLCipher key from libWCDB.so (native hook) + * 2. Monitor outgoing messages via NetSceneSendMsg constructor (Java hook) + * 3. Bypass ExtContentProviderBase auth check (Java hook) + * 4. Monitor message DB inserts via sqlite3_exec (native fallback) + * 5. RPC: sendTextMsg(toUser, content) - actively send messages + */ + +var TAG = "[WeChatFullHunter]"; +var seenKeys = {}; +var targetDbNames = ["EnMicroMsg.db", "EnMicroMsg"]; +var javaHooksActive = false; + +function isTargetDb(path) { + if (!path) return false; + var s = path.toString(); + for (var i = 0; i < targetDbNames.length; i++) { + if (s.indexOf(targetDbNames[i]) !== -1) return true; + } + return false; +} + +function bytesToHex(bytes, len) { + len = len || bytes.length; + var hex = ""; + for (var i = 0; i < len; i++) { + var b = bytes[i] & 0xFF; + hex += (b < 16 ? "0" : "") + b.toString(16); + } + return hex; +} + +function isPrintableKey(keyPtr, keyLen) { + if (keyLen < 4 || keyLen > 64) return false; + try { + var raw = keyPtr.readByteArray(keyLen); + if (!raw) return false; + var bytes = new Uint8Array(raw); + var map = {}, unique = 0; + for (var i = 0; i < bytes.length; i++) { + if (!map[bytes[i]]) { map[bytes[i]] = true; unique++; } + } + return unique >= 3; + } catch (e) { return false; } +} + +function reportKey(keyHex, keyLen, via, path, extra) { + var cacheKey = keyHex + "@" + (path || ""); + if (seenKeys[cacheKey]) return; + seenKeys[cacheKey] = true; + send({ type: "key", hex: keyHex, len: keyLen, via: via, path: path || "", extra: extra || {} }); +} + +function reportInfo(msg) { send({ type: "info", msg: msg }); } +function reportError(msg) { send({ type: "error", msg: msg }); } +function reportMsgEvent(direction, toUser, content, type, extra) { + send({ type: "msg", direction: direction, toUser: toUser, content: content, msgType: type, extra: extra || {} }); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// NATIVE LAYER: SQLCipher Key Capture + SQL Monitoring +// ═══════════════════════════════════════════════════════════════════════════════ + +function hookNativeSqlite3Key() { + var modules = Process.enumerateModules(); + var hooked = []; + for (var i = 0; i < modules.length; i++) { + var mod = modules[i]; + var name = mod.name.toLowerCase(); + if (name.indexOf("wcdb") === -1 && name.indexOf("sqlcipher") === -1 && + name.indexOf("wechat") === -1 && name.indexOf("sqlite") === -1) { + continue; + } + try { + var modObj = Process.findModuleByName(mod.name); + if (!modObj) continue; + + var keyAddr = modObj.findExportByName("sqlite3_key"); + if (keyAddr && !keyAddr.isNull()) { + var label = mod.name + "!sqlite3_key"; + (function (lbl, addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var keyPtr = args[1]; + var keyLen = args[2].toInt32(); + if (!isPrintableKey(keyPtr, keyLen)) return; + var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); + reportKey(hex, keyLen, lbl, null, { native: true, v2: false }); + } + }); + })(label, keyAddr); + hooked.push(label); + } + + var keyV2Addr = modObj.findExportByName("sqlite3_key_v2"); + if (keyV2Addr && !keyV2Addr.isNull()) { + var label2 = mod.name + "!sqlite3_key_v2"; + (function (lbl, addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var keyPtr = args[2]; + var keyLen = args[3].toInt32(); + if (!isPrintableKey(keyPtr, keyLen)) return; + var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); + reportKey(hex, keyLen, lbl, null, { native: true, v2: true }); + } + }); + })(label2, keyV2Addr); + hooked.push(label2); + } + } catch (e) {} + } + return hooked; +} + +function hookNativeSqliteExec() { + var modules = Process.enumerateModules(); + var hooked = []; + for (var i = 0; i < modules.length; i++) { + var mod = modules[i]; + var name = mod.name.toLowerCase(); + if (name.indexOf("wcdb") === -1 && name.indexOf("sqlcipher") === -1 && + name.indexOf("wechat") === -1 && name.indexOf("sqlite") === -1) { + continue; + } + try { + var modObj = Process.findModuleByName(mod.name); + if (!modObj) continue; + var execAddr = modObj.findExportByName("sqlite3_exec"); + if (execAddr && !execAddr.isNull()) { + (function (addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var sqlPtr = args[1]; + try { + var sql = sqlPtr.readUtf8String(); + if (!sql) return; + var upper = sql.toUpperCase(); + // Monitor PRAGMA key + if (upper.indexOf("PRAGMA KEY") !== -1) { + reportInfo("sqlite3_exec PRAGMA: " + sql); + } + // Monitor INSERT into message table (native fallback for msg monitoring) + if (upper.indexOf("INSERT INTO MESSAGE") !== -1 || + upper.indexOf("INSERT OR REPLACE INTO MESSAGE") !== -1 || + upper.indexOf("INSERT INTO \"MESSAGE\"") !== -1) { + send({ type: "sql_insert", table: "message", sql: sql }); + } + } catch (e) {} + } + }); + })(execAddr); + hooked.push(mod.name + "!sqlite3_exec"); + } + } catch (e) {} + } + return hooked; +} + +function hookNativeSqlitePrepare() { + var modules = Process.enumerateModules(); + for (var i = 0; i < modules.length; i++) { + var mod = modules[i]; + var name = mod.name.toLowerCase(); + if (name.indexOf("wcdb") === -1 && name.indexOf("sqlcipher") === -1 && + name.indexOf("wechat") === -1 && name.indexOf("sqlite") === -1) { + continue; + } + try { + var modObj = Process.findModuleByName(mod.name); + if (!modObj) continue; + var prepAddr = modObj.findExportByName("sqlite3_prepare_v2"); + if (prepAddr && !prepAddr.isNull()) { + (function (addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var sqlPtr = args[1]; + try { + var sql = sqlPtr.readUtf8String(); + if (sql && sql.toUpperCase().indexOf("PRAGMA CIPHER") !== -1) { + reportInfo("sqlite3_prepare_v2 cipher: " + sql); + } + } catch (e) {} + } + }); + })(prepAddr); + } + } catch (e) {} + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// JAVA LAYER: Message Sending + ContentProvider Bypass +// ═══════════════════════════════════════════════════════════════════════════════ + +function hookNetSceneSendMsg() { + try { + var NetSceneSendMsg = Java.use("u01.r0"); + + // Constructor: (String toUser, String content, int type, int flag, long time) + NetSceneSendMsg.$init.overload('java.lang.String', 'java.lang.String', 'int', 'int', 'long').implementation = function(toUser, content, type, flag, time) { + reportMsgEvent("outgoing", toUser, content, type, { flag: flag, time: time }); + return this.$init(toUser, content, type, flag, time); + }; + + // Constructor: (long msgId, int type, String toUser) - resend + try { + NetSceneSendMsg.$init.overload('long', 'int', 'java.lang.String').implementation = function(msgId, type, toUser) { + reportMsgEvent("outgoing_resend", toUser, "", type, { msgId: msgId }); + return this.$init(msgId, type, toUser); + }; + } catch (e) {} + + reportInfo("[JavaHook] NetSceneSendMsg constructors hooked"); + return true; + } catch (e) { + reportInfo("[JavaHook] NetSceneSendMsg not found: " + e); + return false; + } +} + +function hookContentProviderAuthBypass() { + try { + var ExtBase = Java.use("com.tencent.mm.plugin.ext.provider.ExtContentProviderBase"); + + // Method h() returns int (1 = success, others = error codes) + ExtBase.h.implementation = function() { + var original = this.h(); + if (original !== 1) { + reportInfo("[Bypass] ExtContentProviderBase.h() returned " + original + " -> forcing 1"); + return 1; + } + return original; + }; + + // Method b() returns boolean (isLogin) + ExtBase.b.implementation = function() { + return true; + }; + + // Method i() returns boolean (app validation) + try { + ExtBase.i.implementation = function(context) { + return true; + }; + } catch (e) {} + + reportInfo("[JavaHook] ExtContentProviderBase auth bypass installed"); + return true; + } catch (e) { + reportInfo("[JavaHook] ExtContentProviderBase not found: " + e); + return false; + } +} + +function hookMessageStorage() { + try { + // Hook MsgInfo (f9) setters to monitor message content being set + var MsgInfo = Java.use("com.tencent.mm.storage.f9"); + + // u1 sets talker (toUser) + try { + MsgInfo.u1.implementation = function(talker) { + this.u1(talker); + // Store temporarily for correlation + this._talker = talker; + }; + } catch (e) {} + + // c1 sets content + try { + MsgInfo.c1.implementation = function(content) { + this.c1(content); + if (this._talker) { + reportMsgEvent("storage", this._talker, content, this.getType ? this.getType() : 0, {}); + this._talker = null; + } + }; + } catch (e) {} + + reportInfo("[JavaHook] Message storage (f9) hooked"); + return true; + } catch (e) { + reportInfo("[JavaHook] storage.f9 not found: " + e); + return false; + } +} + +function hookSendMsgEvent() { + try { + var SendMsgEvent = Java.use("com.tencent.mm.autogen.events.SendMsgEvent"); + // Hook the event constructor to catch all send msg events + // This is a broader catch than NetSceneSendMsg + reportInfo("[JavaHook] SendMsgEvent class found (monitoring via constructor)"); + return true; + } catch (e) { + reportInfo("[JavaHook] SendMsgEvent not found: " + e); + return false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// RPC: Active Message Sending +// ═══════════════════════════════════════════════════════════════════════════════ + +rpc.exports = { + // Check if Java hooks are active + getStatus: function() { + return { + pid: Process.id, + arch: Process.arch, + javaAvailable: (typeof Java !== 'undefined' && Java.available), + javaHooksActive: javaHooksActive + }; + }, + + // Send text message via internal API + // Requires Java.available = true and correct process + sendTextMsg: function(toUser, content) { + if (typeof Java === 'undefined' || !Java.available) { + return { success: false, error: "Java not available in this process" }; + } + + return Java.perform(function() { + try { + // Method 1: Use the builder pattern (u01.q1 -> u01.m1) + var Builder = Java.use("u01.q1"); + var R1 = Java.use("u01.r1"); + + // Create builder via factory + var builder = R1.a(toUser); + builder.e(content); // set content + builder.h(0); // contactType (0 for normal contact) + builder.f.value = 0; // internal flag + builder.i.value = 4; // scene type + + // Build and submit + var result = builder.a(); // returns u01.m1 + if (result && result.a.value) { + // Submit via m1.a() which calls modelbase.r1.h() + var submitted = result.a(); + return { + success: submitted, + msgId: result.b.value.toString() + }; + } + + return { success: false, error: "Failed to build NetScene" }; + } catch (e) { + // Method 2: Direct NetSceneSendMsg construction + try { + var NetSceneSendMsg = Java.use("u01.r0"); + var now = Date.now(); + var scene = NetSceneSendMsg.$new(toUser, content, 1, 0, now); + + // Get dispatcher and submit + var Dispatcher = Java.use("com.tencent.mm.modelbase.r1"); + var dispatcher = Dispatcher.e(); // singleton getter + var ok = dispatcher.g(scene); + + return { success: ok, method: "direct_constructor" }; + } catch (e2) { + return { success: false, error: e2.toString(), fallbackError: e.toString() }; + } + } + }); + }, + + // Alternative: Send via ContentProvider (requires auth bypass) + sendViaProvider: function(toUser, content) { + if (typeof Java === 'undefined' || !Java.available) { + return { success: false, error: "Java not available" }; + } + + return Java.perform(function() { + try { + var Context = Java.use("android.app.ActivityThread").currentApplication(); + var ContentResolver = Context.getContentResolver(); + + // Build URI for sendTextMsg + // content://com.tencent.mm.plugin.ext.message/sendTextMsg?appid=wx123 + var Uri = Java.use("android.net.Uri"); + var uri = Uri.parse("content://com.tencent.mm.plugin.ext.message/sendTextMsg?appid=wx00000000000000&source=openapi"); + + // selectionArgs[0] = encrypted contact id, selectionArgs[1] = message + // For bypass mode, we try raw wxid + var selectionArgs = Java.array("java.lang.String", [toUser, content]); + + var cursor = ContentResolver.query(uri, null, null, selectionArgs, null); + var result = { success: cursor !== null, hasCursor: !!cursor }; + + if (cursor) { + try { + if (cursor.moveToFirst()) { + var msgIdIdx = cursor.getColumnIndex("msgId"); + var retCodeIdx = cursor.getColumnIndex("retCode"); + if (msgIdIdx >= 0) result.msgId = cursor.getString(msgIdIdx); + if (retCodeIdx >= 0) result.retCode = cursor.getString(retCodeIdx); + } + } finally { + cursor.close(); + } + } + return result; + } catch (e) { + return { success: false, error: e.toString() }; + } + }); + }, + + // Get available WeChat DB paths + getDbPaths: function() { + if (typeof Java === 'undefined' || !Java.available) { + return { error: "Java not available" }; + } + return Java.perform(function() { + try { + var File = Java.use("java.io.File"); + var basePath = "/data/data/com.tencent.mm/MicroMsg"; + var base = File.$new(basePath); + var paths = []; + if (base.exists()) { + var files = base.listFiles(); + if (files) { + for (var i = 0; i < files.length; i++) { + var f = files[i]; + if (f.isDirectory()) { + var dbFile = File.$new(f.getAbsolutePath() + "/EnMicroMsg.db"); + if (dbFile.exists()) { + paths.push(dbFile.getAbsolutePath()); + } + } + } + } + } + return { paths: paths }; + } catch (e) { + return { error: e.toString() }; + } + }); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// LATE-HOOK SYSTEM: Monitor dynamically loaded modules +// ═══════════════════════════════════════════════════════════════════════════════ + +function setupLateHook() { + var alreadyHooked = {}; + + function tryHookModule(modName) { + try { + var modObj = Process.findModuleByName(modName); + if (!modObj) return false; + + var hookedAny = false; + + var keyAddr = modObj.findExportByName("sqlite3_key"); + if (keyAddr && !keyAddr.isNull() && !alreadyHooked[modName + "!sqlite3_key"]) { + alreadyHooked[modName + "!sqlite3_key"] = true; + (function (lbl, addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var keyPtr = args[1]; + var keyLen = args[2].toInt32(); + if (!isPrintableKey(keyPtr, keyLen)) return; + var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); + reportKey(hex, keyLen, lbl, null, { native: true, v2: false, late: true }); + } + }); + })(modName + "!sqlite3_key", keyAddr); + hookedAny = true; + reportInfo("Late-hook " + modName + "!sqlite3_key"); + } + + return hookedAny; + } catch (e) { return false; } + } + + setInterval(function () { + var modules = Process.enumerateModules(); + for (var i = 0; i < modules.length; i++) { + var name = modules[i].name; + if (alreadyHooked[name]) continue; + alreadyHooked[name] = true; + + var lname = name.toLowerCase(); + if (lname.indexOf("wcdb") !== -1 || lname.indexOf("sqlcipher") !== -1 || + lname.indexOf("sqlite") !== -1) { + tryHookModule(name); + } + } + }, 2000); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MAIN +// ═══════════════════════════════════════════════════════════════════════════════ + +function main() { + reportInfo("=== WeChat Android Full Hunter started ==="); + reportInfo("PID=" + Process.id + " | Arch=" + Process.arch); + + var javaAvail = (typeof Java !== 'undefined' && Java.available); + reportInfo("Java.available: " + javaAvail); + + // 1. Native hooks (always work) + var nativeKeyHooks = hookNativeSqlite3Key(); + reportInfo("Native sqlite3_key hooks: " + nativeKeyHooks.length + + (nativeKeyHooks.length ? " -> " + nativeKeyHooks.join(", ") : " (modules may load later)")); + + var nativeExecHooks = hookNativeSqliteExec(); + reportInfo("Native sqlite3_exec hooks: " + nativeExecHooks.length); + + hookNativeSqlitePrepare(); + + // 2. Java hooks (if available) + if (javaAvail) { + Java.perform(function () { + var results = []; + results.push({ name: "NetSceneSendMsg", ok: hookNetSceneSendMsg() }); + results.push({ name: "ContentProviderBypass", ok: hookContentProviderAuthBypass() }); + results.push({ name: "MessageStorage", ok: hookMessageStorage() }); + results.push({ name: "SendMsgEvent", ok: hookSendMsgEvent() }); + + var activeCount = 0; + for (var i = 0; i < results.length; i++) { + if (results[i].ok) activeCount++; + reportInfo("[JavaHook] " + results[i].name + ": " + (results[i].ok ? "OK" : "FAILED")); + } + javaHooksActive = (activeCount > 0); + reportInfo("Java hooks active: " + javaHooksActive + "/" + results.length); + }); + } else { + reportInfo("Java VM not available. Using native-only mode (key capture + SQL monitoring)."); + reportInfo("To enable send-msg features, attach to a process where Java.available=true."); + } + + // 3. Late-hook for dynamically loaded modules + setupLateHook(); + + reportInfo("=== All hooks installed. Waiting for events... ==="); +} + +main(); diff --git a/wechat_plain/scripts/db-decrypt/wechat_android_key_hunter.js b/wechat_plain/scripts/db-decrypt/wechat_android_key_hunter.js new file mode 100644 index 0000000..b8baea5 --- /dev/null +++ b/wechat_plain/scripts/db-decrypt/wechat_android_key_hunter.js @@ -0,0 +1,486 @@ +/* + * WeChat Android SQLCipher Key Hunter (Frida 17.x) + * Targets WeChat 8.0.72 on Android with WCDB/SQLCipher + * + * Strategy: + * 1. Hook Java layer: com.tencent.wcdb.database.SQLiteDatabase.openDatabase variants + * 2. Hook native layer: sqlite3_key / sqlite3_key_v2 + * 3. Hook SQLiteConnection / SQLiteCipherSpec if needed + */ + +var TAG = "[WeChatKeyHunter]"; +var seenKeys = {}; +var targetDbNames = ["EnMicroMsg.db", "EnMicroMsg"]; + +function isTargetDb(path) { + if (!path) return false; + var s = path.toString(); + for (var i = 0; i < targetDbNames.length; i++) { + if (s.indexOf(targetDbNames[i]) !== -1) return true; + } + return false; +} + +function bytesToHex(bytes, len) { + len = len || bytes.length; + var hex = ""; + for (var i = 0; i < len; i++) { + var b = bytes[i] & 0xFF; + hex += (b < 16 ? "0" : "") + b.toString(16); + } + return hex; +} + +function isPrintableKey(keyPtr, keyLen) { + // SQLCipher keys are usually 7-32 bytes (passwords) or raw 32-byte AES keys + if (keyLen < 4 || keyLen > 64) return false; + try { + var raw = keyPtr.readByteArray(keyLen); + if (!raw) return false; + var bytes = new Uint8Array(raw); + // Skip trivial keys + var map = {}, unique = 0; + for (var i = 0; i < bytes.length; i++) { + if (!map[bytes[i]]) { map[bytes[i]] = true; unique++; } + } + return unique >= 3; + } catch (e) { return false; } +} + +function reportKey(keyHex, keyLen, via, path, extra) { + var cacheKey = keyHex + "@" + (path || ""); + if (seenKeys[cacheKey]) return; + seenKeys[cacheKey] = true; + + send({ + type: "key", + hex: keyHex, + len: keyLen, + via: via, + path: path || "", + extra: extra || {} + }); +} + +function reportInfo(msg) { + send({ type: "info", msg: msg }); +} + +function reportError(msg) { + send({ type: "error", msg: msg }); +} + +// ── Native layer: sqlite3_key / sqlite3_key_v2 ─────────────────────────────── + +function hookNativeSqlite3Key() { + var modules = Process.enumerateModules(); + var hooked = []; + + for (var i = 0; i < modules.length; i++) { + var mod = modules[i]; + var name = mod.name.toLowerCase(); + // WCDB / SQLCipher likely lives in libwcdb.so, libsqlcipher.so, + // or bundled inside libwechatcommon.so / libwechatmm.so + if (name.indexOf("wcdb") === -1 && + name.indexOf("sqlcipher") === -1 && + name.indexOf("wechat") === -1 && + name.indexOf("sqlite") === -1) { + continue; + } + + try { + var modObj = Process.findModuleByName(mod.name); + if (!modObj) continue; + + // Try direct findExportByName first (works even when enumerateExports misses symbols) + var keyAddr = modObj.findExportByName("sqlite3_key"); + var keyV2Addr = modObj.findExportByName("sqlite3_key_v2"); + + if (keyAddr && !keyAddr.isNull()) { + var label = mod.name + "!sqlite3_key"; + (function (lbl, addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var dbPtr = args[0]; + var keyPtr = args[1]; + var keyLen = args[2].toInt32(); + if (!isPrintableKey(keyPtr, keyLen)) return; + var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); + reportKey(hex, keyLen, lbl, null, { native: true, v2: false, db: dbPtr.toString() }); + } + }); + })(label, keyAddr); + hooked.push(label); + } + + if (keyV2Addr && !keyV2Addr.isNull()) { + var label2 = mod.name + "!sqlite3_key_v2"; + (function (lbl, addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var dbPtr = args[0]; + var keyPtr = args[2]; + var keyLen = args[3].toInt32(); + if (!isPrintableKey(keyPtr, keyLen)) return; + var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); + reportKey(hex, keyLen, lbl, null, { native: true, v2: true, db: dbPtr.toString() }); + } + }); + })(label2, keyV2Addr); + hooked.push(label2); + } + + // Also hook sqlite3_open_v2 to capture db paths + var openV2Addr = modObj.findExportByName("sqlite3_open_v2"); + if (openV2Addr && !openV2Addr.isNull()) { + (function (addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var pathPtr = args[0]; + var flags = args[1].toInt32(); + try { + var path = pathPtr.readUtf8String(); + if (path && path.indexOf("EnMicroMsg") !== -1) { + send({ type: "info", msg: "sqlite3_open_v2: " + path + " flags=" + flags }); + } + } catch (e) {} + } + }); + })(openV2Addr); + } + + // Hook sqlite3_exec to catch PRAGMA key + var execAddr = modObj.findExportByName("sqlite3_exec"); + if (execAddr && !execAddr.isNull()) { + (function (addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var sqlPtr = args[1]; + try { + var sql = sqlPtr.readUtf8String(); + if (sql && sql.toUpperCase().indexOf("PRAGMA KEY") !== -1) { + send({ type: "info", msg: "sqlite3_exec PRAGMA: " + sql }); + } + } catch (e) {} + } + }); + })(execAddr); + } + } catch (e) { + // ignore + } + } + + return hooked; +} + +// ── Java layer: WCDB SQLiteDatabase ─────────────────────────────────────────── + +function hookWcdbJava() { + var SQLiteDatabase; + try { + SQLiteDatabase = Java.use("com.tencent.wcdb.database.SQLiteDatabase"); + } catch (e) { + reportInfo("WCDB SQLiteDatabase class not found, trying androidx/sqlite..."); + try { + SQLiteDatabase = Java.use("android.database.sqlite.SQLiteDatabase"); + } catch (e2) { + reportError("Neither WCDB nor standard SQLiteDatabase found: " + e2); + return []; + } + } + + var hooked = []; + + // Overload 1: openDatabase(String path, CursorFactory factory, int flags) + try { + var open1 = SQLiteDatabase.openDatabase.overload( + "java.lang.String", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", "int" + ); + open1.implementation = function (path, factory, flags) { + if (isTargetDb(path)) { + reportInfo("openDatabase(path, factory, flags) -> " + path); + } + return open1.call(this, path, factory, flags); + }; + hooked.push("openDatabase(String,CursorFactory,int)"); + } catch (e) {} + + // Overload 2: openDatabase(String path, CursorFactory factory, int flags, String password) + try { + var open2 = SQLiteDatabase.openDatabase.overload( + "java.lang.String", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", + "int", "java.lang.String" + ); + open2.implementation = function (path, factory, flags, password) { + if (isTargetDb(path) && password) { + var pw = password.toString(); + reportKey(pw, pw.length, "WCDB.openDatabase(password)", path, + { flags: flags, type: "string_password" }); + } + return open2.call(this, path, factory, flags, password); + }; + hooked.push("openDatabase(String,CursorFactory,int,String)"); + } catch (e) {} + + // Overload 3: openDatabase(String path, CursorFactory factory, int flags, SQLiteDatabaseErrorHandler errorHandler, String password) + try { + var open3 = SQLiteDatabase.openDatabase.overload( + "java.lang.String", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", + "int", "com.tencent.wcdb.database.SQLiteDatabaseErrorHandler", "java.lang.String" + ); + open3.implementation = function (path, factory, flags, errorHandler, password) { + if (isTargetDb(path) && password) { + var pw = password.toString(); + reportKey(pw, pw.length, "WCDB.openDatabase(errHandler,password)", path, + { flags: flags, type: "string_password" }); + } + return open3.call(this, path, factory, flags, errorHandler, password); + }; + hooked.push("openDatabase(String,CursorFactory,int,ErrorHandler,String)"); + } catch (e) {} + + // Overload 4: openDatabase(String path, byte[] password, ...) + try { + var open4 = SQLiteDatabase.openDatabase.overload( + "java.lang.String", "[B", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", "int" + ); + open4.implementation = function (path, password, factory, flags) { + if (isTargetDb(path) && password) { + var bytes = Java.array("byte", password); + var hex = bytesToHex(bytes); + reportKey(hex, bytes.length, "WCDB.openDatabase(byte[])", path, + { flags: flags, type: "byte_array_password" }); + } + return open4.call(this, path, password, factory, flags); + }; + hooked.push("openDatabase(String,byte[],CursorFactory,int)"); + } catch (e) {} + + // Hook constructor that takes password + try { + var ctor1 = SQLiteDatabase.$init.overload( + "java.lang.String", "int", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", + "com.tencent.wcdb.support.CancellationSignal" + ); + ctor1.implementation = function (path, flags, factory, cancel) { + if (isTargetDb(path)) { + reportInfo("SQLiteDatabase constructor -> " + path); + } + ctor1.call(this, path, flags, factory, cancel); + }; + hooked.push("constructor(String,int,CursorFactory,CancellationSignal)"); + } catch (e) {} + + return hooked; +} + +// ── Java layer: SQLiteCipherSpec (cipher configuration) ────────────────────── + +function hookCipherSpec() { + var specs = []; + try { + var SQLiteCipherSpec = Java.use("com.tencent.wcdb.database.SQLiteCipherSpec"); + specs.push("SQLiteCipherSpec"); + + // Hook setters to capture cipher config + try { + SQLiteCipherSpec.setPageSize.implementation = function (size) { + send({ type: "cipher_spec", field: "pageSize", value: size }); + return this.setPageSize(size); + }; + } catch (e) {} + + try { + SQLiteCipherSpec.setKDFIteration.implementation = function (iter) { + send({ type: "cipher_spec", field: "kdfIteration", value: iter }); + return this.setKDFIteration(iter); + }; + } catch (e) {} + + try { + SQLiteCipherSpec.setHMACUse.implementation = function (use) { + send({ type: "cipher_spec", field: "hmacUse", value: use }); + return this.setHMACUse(use); + }; + } catch (e) {} + } catch (e) {} + + return specs; +} + +// ── Java layer: SQLiteOpenHelper (higher-level wrapper) ────────────────────── + +function hookOpenHelper() { + var helpers = []; + try { + var SQLiteOpenHelper = Java.use("com.tencent.wcdb.database.SQLiteOpenHelper"); + helpers.push("SQLiteOpenHelper"); + + try { + var getDB = SQLiteOpenHelper.getWritableDatabase.overload("java.lang.String"); + getDB.implementation = function (password) { + if (password) { + var pw = password.toString(); + send({ type: "info", msg: "SQLiteOpenHelper.getWritableDatabase(password=" + pw + ")" }); + } + return getDB.call(this, password); + }; + } catch (e) {} + + try { + var getDB2 = SQLiteOpenHelper.getWritableDatabase.overload("[B"); + getDB2.implementation = function (password) { + if (password) { + var bytes = Java.array("byte", password); + var hex = bytesToHex(bytes); + send({ type: "key", hex: hex, len: bytes.length, + via: "SQLiteOpenHelper.getWritableDatabase(byte[])", path: "" }); + } + return getDB2.call(this, password); + }; + } catch (e) {} + } catch (e) {} + + return helpers; +} + +// ── Auto-detect database path by scanning /data/data/com.tencent.mm ────────── + +function scanWechatDbPath() { + Java.perform(function () { + var File = Java.use("java.io.File"); + var basePath = "/data/data/com.tencent.mm/MicroMsg"; + try { + var base = File.$new(basePath); + if (!base.exists()) { + reportInfo("MicroMsg base path not found"); + return; + } + var files = base.listFiles(); + if (!files) { + reportInfo("No files in MicroMsg"); + return; + } + for (var i = 0; i < files.length; i++) { + var f = files[i]; + if (f.isDirectory()) { + var name = f.getName(); + var dbFile = File.$new(f.getAbsolutePath() + "/EnMicroMsg.db"); + if (dbFile.exists()) { + reportInfo("FOUND_DB: " + dbFile.getAbsolutePath()); + } + } + } + } catch (e) { + reportError("Scan error: " + e); + } + }); +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +function main() { + reportInfo("=== WeChat Android Key Hunter started ==="); + reportInfo("Process: " + Process.id + " | Arch: " + Process.arch); + reportInfo("Java.available: " + (typeof Java !== 'undefined' ? Java.available : false)); + + // 1. Native hooks (work immediately, no Java required) + var nativeHooks = hookNativeSqlite3Key(); + reportInfo("Native sqlite3_key hooks: " + nativeHooks.length + + (nativeHooks.length ? " -> " + nativeHooks.join(", ") : " (none found yet, modules may load later)")); + + // 2. Java hooks (require Java VM) + if (typeof Java !== 'undefined' && Java.available) { + Java.perform(function () { + var javaHooks = hookWcdbJava(); + reportInfo("WCDB Java hooks: " + javaHooks.length + + (javaHooks.length ? " -> " + javaHooks.join(", ") : " (none found)")); + + var cipherSpecs = hookCipherSpec(); + reportInfo("CipherSpec hooks: " + cipherSpecs.length); + + var helpers = hookOpenHelper(); + reportInfo("OpenHelper hooks: " + helpers.length); + + // Scan for DB path + scanWechatDbPath(); + }); + } else { + reportInfo("Java VM not available in this process, relying on native hooks only."); + } + + // 3. Monitor for new modules being loaded (WCDB/SQLCipher may load dynamically) + var alreadyHookedModules = {}; + function tryHookModuleSqlite3Key(modName) { + try { + var modObj = Process.findModuleByName(modName); + if (!modObj) return false; + + var hookedAny = false; + + var keyAddr = modObj.findExportByName("sqlite3_key"); + if (keyAddr && !keyAddr.isNull() && !alreadyHookedModules[modName + "!sqlite3_key"]) { + alreadyHookedModules[modName + "!sqlite3_key"] = true; + var label = modName + "!sqlite3_key"; + (function (lbl, addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var keyPtr = args[1]; + var keyLen = args[2].toInt32(); + if (!isPrintableKey(keyPtr, keyLen)) return; + var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); + reportKey(hex, keyLen, lbl, null, { native: true, v2: false, late: true }); + } + }); + })(label, keyAddr); + hookedAny = true; + reportInfo("Late-hook " + label + " at " + keyAddr); + } + + var keyV2Addr = modObj.findExportByName("sqlite3_key_v2"); + if (keyV2Addr && !keyV2Addr.isNull() && !alreadyHookedModules[modName + "!sqlite3_key_v2"]) { + alreadyHookedModules[modName + "!sqlite3_key_v2"] = true; + var label2 = modName + "!sqlite3_key_v2"; + (function (lbl, addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + var keyPtr = args[2]; + var keyLen = args[3].toInt32(); + if (!isPrintableKey(keyPtr, keyLen)) return; + var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); + reportKey(hex, keyLen, lbl, null, { native: true, v2: true, late: true }); + } + }); + })(label2, keyV2Addr); + hookedAny = true; + reportInfo("Late-hook " + label2 + " at " + keyV2Addr); + } + + return hookedAny; + } catch (e) { + return false; + } + } + + setInterval(function () { + var modules = Process.enumerateModules(); + for (var i = 0; i < modules.length; i++) { + var name = modules[i].name; + if (alreadyHookedModules[name]) continue; + alreadyHookedModules[name] = true; + + var lname = name.toLowerCase(); + if (lname.indexOf("wcdb") !== -1 || + lname.indexOf("sqlcipher") !== -1 || + lname.indexOf("sqlite") !== -1) { + reportInfo("New module loaded: " + name); + tryHookModuleSqlite3Key(name); + } + } + }, 2000); + + reportInfo("=== All hooks installed. Waiting for EnMicroMsg.db access... ==="); +} + +main(); diff --git a/wechat_plain/scripts/db-decrypt/wechat_earlybird.py b/wechat_plain/scripts/db-decrypt/wechat_earlybird.py new file mode 100644 index 0000000..da675c3 --- /dev/null +++ b/wechat_plain/scripts/db-decrypt/wechat_earlybird.py @@ -0,0 +1,161 @@ +""" +WeChat early-bird key capture: +1. Kill WeChat +2. Poll until new WeChat process appears +3. Attach Frida immediately +4. Capture CCCryptorCreate keys during login / DB open +""" +import sys, os, time, subprocess, threading +sys.path.insert(0, '/Users/yibo/Library/Python/3.9/lib/python/site-packages') +import frida + +SQLCIPHER = '/opt/homebrew/bin/sqlcipher' +DB_BASE = '/Users/yibo/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/yibooo_ac07/db_storage' + +JS = r""" +var seen = {}; + +function uniqueByteCount(arr) { + var map = {}; + var n = 0; + for (var i = 0; i < arr.length; i++) { + if (!map[arr[i]]) { map[arr[i]] = true; n++; } + } + return n; +} + +function tryCapture(keyPtr, keyLen, label) { + try { + if (keyLen !== 32) return; + var raw = keyPtr.readByteArray(32); + if (!raw) return; + var bytes = new Uint8Array(raw); + if (uniqueByteCount(bytes) < 8) return; + var hex = ''; + for (var i = 0; i < bytes.length; i++) { + hex += ('0' + bytes[i].toString(16)).slice(-2); + } + if (!seen[hex]) { + seen[hex] = true; + send({type:'key', hex:hex, len:keyLen, via:label}); + } + } catch(e) { + send({type:'err', msg: e.toString(), label: label}); + } +} + +var found = {}; + +var addr1 = Module.findGlobalExportByName('CCCryptorCreate'); +found['CCCryptorCreate'] = !!addr1; +if (addr1) { + Interceptor.attach(addr1, { onEnter: function(args) { + if (args[1].toInt32() === 0) tryCapture(args[3], args[4].toInt32(), 'CCCryptorCreate'); + }}); +} + +var addr2 = Module.findGlobalExportByName('CCCryptorCreateWithMode'); +found['CCCryptorCreateWithMode'] = !!addr2; +if (addr2) { + Interceptor.attach(addr2, { onEnter: function(args) { + if (args[2].toInt32() === 0) tryCapture(args[5], args[6].toInt32(), 'CCCryptorCreateWithMode'); + }}); +} + +var addr3 = Module.findGlobalExportByName('CCCrypt'); +found['CCCrypt'] = !!addr3; +if (addr3) { + Interceptor.attach(addr3, { onEnter: function(args) { + if (args[1].toInt32() === 0) tryCapture(args[3], args[4].toInt32(), 'CCCrypt'); + }}); +} + +send({type:'ready', found:found}); +""" + +def verify_key(key_hex, rel): + path = os.path.join(DB_BASE, rel) + if not os.path.exists(path): return False + r = subprocess.run([SQLCIPHER, path], + input=f"PRAGMA key=\"x'{key_hex}'\";\nSELECT count(*) FROM sqlite_master;\n", + capture_output=True, text=True, timeout=10) + lines = [l.strip() for l in r.stdout.split('\n') if l.strip() and l.strip() != 'ok'] + return bool(lines) and lines[0].isdigit() + +TEST_DBS = [ + 'contact/contact.db', + 'message/message_0.db', + 'session/session.db', + 'general/general.db', + 'message/message_1.db', +] + +confirmed = {} # key_hex -> [dbs] + +def on_key(key_hex, via, keylen): + print(f'\n [key] len={keylen} via={via} {key_hex[:24]}...') + matched = [] + for rel in TEST_DBS: + try: + if verify_key(key_hex, rel): + matched.append(rel) + print(f' ✓ matches {rel}') + except Exception as e: + pass + if not matched: + print(f' (no match in test set)') + else: + confirmed[key_hex] = matched + +# ── main ────────────────────────────────────────────────────────────────────── +print('[1] Killing WeChat...') +subprocess.run(['killall', 'WeChat'], capture_output=True) +time.sleep(2) + +print('[2] Waiting for WeChat to start (open 微信.app now)...') +print(' → 请现在手动打开微信.app') + +pid = None +for _ in range(120): # wait up to 2 min + out = subprocess.run(['pgrep','-x','WeChat'], capture_output=True, text=True).stdout.strip() + if out: + pid = int(out.split('\n')[0]) + break + time.sleep(0.5) + +if not pid: + print('[!] WeChat did not start within 2 minutes') + sys.exit(1) + +print(f'[3] WeChat PID={pid} detected, attaching Frida NOW...') +session = frida.attach(pid) +script = session.create_script(JS) + +def on_msg(msg, data): + if msg['type'] == 'error': + print(f'[JS ERROR] {msg["description"]}') + return + if msg['type'] != 'send': return + p = msg['payload'] + if p['type'] == 'ready': + print(f'[4] Hooks: {p["found"]}') + print(' → 请登录微信,然后慢慢浏览各个聊天窗口\n') + elif p['type'] == 'key': + on_key(p['hex'], p['via'], p['len']) + elif p['type'] == 'err': + print(f' [js-err] {p["label"]}: {p["msg"]}') + +script.on('message', on_msg) +script.load() +print('[*] Script loaded. Monitoring... (Ctrl+C to stop)\n') + +try: + while True: + time.sleep(1) +except KeyboardInterrupt: + pass + +print(f'\n[结果] 捕获到 {len(confirmed)} 个已验证 key:') +for k, dbs in confirmed.items(): + print(f' {k} → {dbs}') +session.detach() diff --git a/wechat_plain/scripts/db-decrypt/wechat_export_all.py b/wechat_plain/scripts/db-decrypt/wechat_export_all.py new file mode 100755 index 0000000..7c1b228 --- /dev/null +++ b/wechat_plain/scripts/db-decrypt/wechat_export_all.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +WeChat 全库解密导出工具 +步骤: + 1. Frida hook 持续捕获 AES key + 2. 每个新 key 试匹配所有未解锁的数据库 + 3. 匹配成功 → 立即用 sqlcipher_export() 导出为明文 SQLite + 4. 所有库解完 or 超时后退出 + +用法: + python3 ~/Downloads/wechat_export_all.py + +输出: + ~/Documents/wechat_plain/ 目录下各数据库的明文副本 +""" + +import sys, os, time, subprocess, threading, shutil + +# ── 路径 ────────────────────────────────────────────────────────────── +sys.path.insert(0, "/Users/yibo/Library/Python/3.9/lib/python/site-packages") +import frida + +DB_BASE = "/Users/yibo/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/yibooo_ac07/db_storage" +OUT_DIR = "/Users/yibo/Documents/wechat_plain" +KEY_CACHE = "/Users/yibo/Documents/wechat_plain/keys.txt" +SQLCIPHER = "/opt/homebrew/bin/sqlcipher" + +# ── 所有数据库(相对 DB_BASE)────────────────────────────────────────── +ALL_DBS = [ + # 消息(按大小降序,优先处理) + "message/message_1.db", + "message/message_2.db", + "message/message_3.db", + "message/message_4.db", + "message/message_5.db", + "message/message_6.db", + "message/message_7.db", + "message/message_8.db", + "message/message_9.db", + "message/message_10.db", + "message/message_0.db", + "message/message_fts.db", + "message/media_0.db", + "message/media_1.db", + "message/message_resource.db", + "message/biz_message_0.db", + "message/biz_message_1.db", + # 联系人 + "contact/contact.db", + "contact/contact_fts.db", + # 会话 + "session/session.db", + # 其他 + "sns/sns.db", + "general/general.db", + "favorite/favorite.db", + "favorite/favorite_fts.db", + "head_image/head_image.db", + "hardlink/hardlink.db", + "bizchat/bizchat.db", + "emoticon/emoticon.db", + "migrate/unspportmsg.db", + "solitaire/solitaire.db", +] + +os.makedirs(OUT_DIR, exist_ok=True) + +# ── 已知 key(上次运行结果)──────────────────────────────────────────── +known_keys = {} # hex_key → set of verified db relpath +pending_dbs = list(ALL_DBS) # 还没匹配到 key 的数据库 +exported_dbs = set() + +def load_key_cache(): + """从缓存文件加载已知 key""" + if not os.path.exists(KEY_CACHE): + return + with open(KEY_CACHE) as f: + for line in f: + line = line.strip() + if ':' in line: + relpath, key = line.split(':', 1) + known_keys[key] = known_keys.get(key, set()) + known_keys[key].add(relpath) + if relpath in pending_dbs: + pending_dbs.remove(relpath) + if known_keys: + print(f"[cache] 加载 {len(known_keys)} 个已知 key,{len(pending_dbs)} 个库待解") + +def save_key_cache(): + with open(KEY_CACHE, 'w') as f: + for key, dbs in known_keys.items(): + for db in dbs: + f.write(f"{db}:{key}\n") + +# ── sqlcipher 验证 ────────────────────────────────────────────────────── +def try_open(key_hex, relpath): + """返回 True 表示 key 能打开这个数据库""" + db_path = os.path.join(DB_BASE, relpath) + if not os.path.exists(db_path): + return False + r = subprocess.run( + [SQLCIPHER, db_path], + input=f"PRAGMA key = \"x'{key_hex}'\";\nSELECT count(*) FROM sqlite_master;\n", + capture_output=True, text=True, timeout=15 + ) + lines = [l for l in r.stdout.split('\n') if l.strip() and l.strip() != 'ok'] + return bool(lines) and lines[0].strip().isdigit() + +# ── 导出函数 ──────────────────────────────────────────────────────────── +export_lock = threading.Lock() + +def export_db(key_hex, relpath): + """用 sqlcipher_export 把加密库导出为明文 SQLite""" + src = os.path.join(DB_BASE, relpath) + name = relpath.replace('/', '_') + dst = os.path.join(OUT_DIR, name) + tmp = dst + '.tmp' + + # 跳过已导出 + if relpath in exported_dbs: + return + if os.path.exists(dst): + sz = os.path.getsize(dst) + if sz > 4096: + print(f" [skip] {name} 已存在 ({sz//1024//1024} MB)") + exported_dbs.add(relpath) + return + + src_sz = os.path.getsize(src) + print(f"\n [export] {name} ({src_sz//1024//1024} MB) → {dst}") + t0 = time.time() + + sql = "\n".join([ + f"PRAGMA key = \"x'{key_hex}'\";", + "PRAGMA cipher_page_size = 4096;", + f"ATTACH DATABASE '{tmp}' AS plain KEY '';", + "SELECT sqlcipher_export('plain');", + "DETACH DATABASE plain;", + ]) + + r = subprocess.run( + [SQLCIPHER, src], + input=sql, capture_output=True, text=True, timeout=600 + ) + + elapsed = time.time() - t0 + if r.returncode == 0 and os.path.exists(tmp) and os.path.getsize(tmp) > 4096: + os.rename(tmp, dst) + out_sz = os.path.getsize(dst) + print(f" [✓] {name}: {src_sz//1024//1024} MB → {out_sz//1024//1024} MB 明文 ({elapsed:.1f}s)") + exported_dbs.add(relpath) + else: + if os.path.exists(tmp): + os.remove(tmp) + err = r.stderr.strip()[:200] + print(f" [✗] {name}: 导出失败 {err}") + +def match_key(key_hex): + """用新 key 尝试匹配所有待解数据库,匹配成功后后台导出""" + global pending_dbs + matched = [] + for relpath in list(pending_dbs): + try: + if try_open(key_hex, relpath): + matched.append(relpath) + except Exception: + pass + + if matched: + known_keys[key_hex] = known_keys.get(key_hex, set()) | set(matched) + for relpath in matched: + pending_dbs.remove(relpath) + save_key_cache() + print(f"\n[key] {key_hex[:16]}... → 匹配 {len(matched)} 个库: {matched}") + # 后台并发导出 + for relpath in matched: + t = threading.Thread(target=export_db, args=(key_hex, relpath), daemon=True) + t.start() + return matched + +# ── 对已知 key 立即导出 ───────────────────────────────────────────────── +def export_known(): + threads = [] + for key_hex, dbs in list(known_keys.items()): + for relpath in dbs: + t = threading.Thread(target=export_db, args=(key_hex, relpath), daemon=True) + t.start() + threads.append(t) + return threads + +# ── Frida 主逻辑 ───────────────────────────────────────────────────────── +WECHAT_APP = '/Applications/微信.app/Contents/MacOS/WeChat' +WECHAT_BUNDLE = 'com.tencent.xinWeChat' + +def make_on_msg(): + def on_msg(msg, data): + if msg['type'] != 'send': return + p = msg['payload'] + if p.get('type') == 'ready': + hooks = [] + if p.get('cc1'): hooks.append('CCCryptorCreate') + if p.get('cc2'): hooks.append('CCCryptorCreateWithMode') + if p.get('cc3'): hooks.append('CCCrypt') + print(f"[Frida] hooks active: {', '.join(hooks) if hooks else '(none!)'}") + elif p.get('type') == 'key': + key_hex = p['hex'] + via = p.get('via', '?') + print(f"\n[key captured via {via}] {key_hex[:16]}...") + match_key(key_hex) + return on_msg + +def run_frida_earlybird(): + """关掉微信,等用户手动打开后立即 attach(earlybird 模式)""" + existing = subprocess.run(['pgrep', '-x', 'WeChat'], + capture_output=True, text=True).stdout.strip() + if existing: + print(f"[*] 关闭现有 WeChat (PID={existing.split()[0]})...") + subprocess.run(['killall', 'WeChat'], capture_output=True) + time.sleep(2) + + print("[*] 请手动打开微信.app(双击 Dock 图标)") + print(" 脚本会在进程出现时立即 attach...\n") + + pid = None + for _ in range(120): + out = subprocess.run(['pgrep', '-x', 'WeChat'], + capture_output=True, text=True).stdout.strip() + if out: + pid = int(out.split('\n')[0]) + break + time.sleep(0.5) + + if not pid: + print("[!] 2 分钟内未检测到 WeChat,退出") + return None + + print(f"[*] WeChat PID={pid},立即 attach...") + js_code = open('/Users/yibo/Downloads/wechat_key_hunter.js').read() + session = frida.attach(pid) + script = session.create_script(js_code) + script.on('message', make_on_msg()) + script.load() + print("[!] Hook 已就绪,请在微信里操作:") + print(" · 滚动浏览聊天列表(触发各消息分片)") + print(" · 打开朋友圈、收藏、搜索、表情、公众号") + print(" · 按 Ctrl+C 停止监听并等待导出完成\n") + return session + +# ── 主程序 ────────────────────────────────────────────────────────────── +if __name__ == '__main__': + print("=" * 60) + print("WeChat 全库解密导出工具") + print(f"输出目录: {OUT_DIR}") + print("=" * 60) + + load_key_cache() + + # 先导出已知 key 对应的数据库 + threads = export_known() + + # 启动 Frida 捕获新 key + if pending_dbs: + print(f"\n[*] 还有 {len(pending_dbs)} 个库需要 key:") + for db in pending_dbs: + print(f" {db}") + + session = run_frida_earlybird() + if session: + try: + while pending_dbs: + time.sleep(2) + remaining = len(pending_dbs) + if remaining: + print(f"\r[待解] {remaining} 个库还需 key: " + f"{', '.join(db.split('/')[-1] for db in pending_dbs[:3])}" + f"{'...' if remaining > 3 else ''} ", end='', flush=True) + except KeyboardInterrupt: + print("\n\n[*] 停止监听") + finally: + session.detach() + else: + print("\n[*] 所有 key 已从缓存加载,直接开始导出") + + # 等待所有导出线程完成 + print("\n[*] 等待导出任务完成...") + active = threading.active_count() + while threading.active_count() > 1: + done = len(exported_dbs) + total = len(ALL_DBS) - len(pending_dbs) + print(f"\r 已完成 {done}/{total} 个数据库... ", end='', flush=True) + time.sleep(2) + + print(f"\n\n{'='*60}") + print(f"完成!导出 {len(exported_dbs)} 个数据库") + print(f"输出目录: {OUT_DIR}") + print(f"{'='*60}") + + # 统计输出文件大小 + total_size = 0 + for f in os.listdir(OUT_DIR): + if f.endswith('.db'): + sz = os.path.getsize(os.path.join(OUT_DIR, f)) + total_size += sz + print(f" {f:<45} {sz//1024//1024:>5} MB") + print(f"\n 总计: {total_size//1024//1024} MB") diff --git a/wechat_plain/scripts/db-decrypt/wechat_key_hunter.js b/wechat_plain/scripts/db-decrypt/wechat_key_hunter.js new file mode 100644 index 0000000..785cc75 --- /dev/null +++ b/wechat_plain/scripts/db-decrypt/wechat_key_hunter.js @@ -0,0 +1,52 @@ +/* + * WeChat SQLCipher key hunter (Frida 17.x compatible) + * Hooks CCCryptorCreate + CCCryptorCreateWithMode + CCCrypt + */ + +var seen = {}; + +function captureKey(keyPtr, keyLen, label) { + try { + if (keyLen !== 32) return; + var raw = keyPtr.readByteArray(32); + if (!raw) return; + var bytes = new Uint8Array(raw); + // skip trivial keys (fewer than 8 unique byte values) + var map = {}, n = 0; + for (var i = 0; i < bytes.length; i++) { + if (!map[bytes[i]]) { map[bytes[i]] = true; n++; } + } + if (n < 8) return; + var hex = ''; + for (var j = 0; j < bytes.length; j++) { + hex += ('0' + bytes[j].toString(16)).slice(-2); + } + if (!seen[hex]) { + seen[hex] = true; + send({ type: 'key', hex: hex, via: label }); + } + } catch(e) {} +} + +var cc1 = Module.findGlobalExportByName('CCCryptorCreate'); +if (cc1) { + Interceptor.attach(cc1, { onEnter: function(args) { + if (args[1].toInt32() === 0) captureKey(args[3], args[4].toInt32(), 'CCCryptorCreate'); + }}); +} + +var cc2 = Module.findGlobalExportByName('CCCryptorCreateWithMode'); +if (cc2) { + Interceptor.attach(cc2, { onEnter: function(args) { + if (args[2].toInt32() === 0) captureKey(args[5], args[6].toInt32(), 'CCCryptorCreateWithMode'); + }}); +} + +var cc3 = Module.findGlobalExportByName('CCCrypt'); +if (cc3) { + Interceptor.attach(cc3, { onEnter: function(args) { + if (args[1].toInt32() === 0) captureKey(args[3], args[4].toInt32(), 'CCCrypt'); + }}); +} + +send({ type: 'ready', cc1: !!cc1, cc2: !!cc2, cc3: !!cc3 }); diff --git a/wechat_plain/scripts/docs/HANDOFF.md b/wechat_plain/scripts/docs/HANDOFF.md new file mode 100644 index 0000000..038b8d5 --- /dev/null +++ b/wechat_plain/scripts/docs/HANDOFF.md @@ -0,0 +1,162 @@ +# HANDOFF: WeChat Android EnMicroMsg.db 解密方案 + +> 由 Claude 完成并交接给 Hermes。本文件位于 `~/.hermes/tools/wechat/` 目录下。 + +--- + +## 你的角色 + +你是 Hermes Agent,现在接手 WeChat Android 数据库解密和读取工作。 +用户(yibo)需要你在 Android 手机上(Termux + Debian proot 环境)执行微信 SQLite 数据库的提取、解密和读取。 + +## 文件位置(全部在 Termux 主目录下) + +部署到手机后这些文件**平铺**在一个目录里(下方命令均假设当前目录就是它)。 +仓库源码则按功能分目录:`scripts/db-decrypt/`(解密套件)、`scripts/search/`(搜索服务)、`scripts/docs/`(本文档)。 + +``` +~/.hermes/tools/wechat/ +├── wechat_android_key_hunter.js # Frida 脚本:hook libWCDB.so 截获 SQLCipher key ← 源: db-decrypt/ +├── wechat_android_attach.py # Python 控制脚本:attach / extract / decrypt ← 源: db-decrypt/ +├── search_server_android.py # Web 搜索服务器:读取解密后的 EnMicroMsg.db ← 源: search/ +└── README_Android.md # 完整操作文档 ← 源: docs/ +``` + +## 关键成果(已通过测试验证) + +| 项目 | 值 | +|------|-----| +| **目标** | WeChat Android 8.0.72 `EnMicroMsg.db` | +| **SQLCipher key** | `e03ce81`(7 字符字符串密码) | +| **cipher_compatibility** | `1` | +| **cipher_page_size** | `1024` | +| **获取方式** | Frida hook `libWCDB.so!sqlite3_key`,在打开第一个加密 DB 时截获 | +| **Java 层** | `Java.available = false`(微信有反注入),native hook 正常工作 | +| **解密验证** | ✅ `message` 表 183 条消息可读,`rcontact` 4481 个联系人 | + +## 三步操作(你必须能执行) + +### 步骤 1:捕获 key + +环境要求: +- frida-server 17.9.10 已在手机上运行(`ps -A | grep frida-server`) +- Python frida 包已安装:`pip install frida==17.9.10` +- 手机已 root(Magisk) + +执行: +```bash +cd ~/.hermes/tools/wechat +python3 wechat_android_attach.py +``` + +预期输出: +``` +[*] Killing WeChat... +[*] Starting WeChat... +[*] Attaching to PID xxxxx... +[KEY CAPTURED via libWCDB.so!sqlite3_key] + hex: 65303363653831 + len: 7 +``` + +Key 会自动保存到 `~/wxmoment/wechat_keys.json`。 + +### 步骤 2:提取 DB + +```bash +python3 wechat_android_attach.py --extract-db +``` + +这会复制: +- `EnMicroMsg.db` +- `EnMicroMsg.db-shm` +- `EnMicroMsg.db-wal` + +到 `~/wxmoment/dbs/`。**WAL 文件必须复制**,否则丢失最新数据。 + +### 步骤 3:解密 + +```bash +python3 wechat_android_attach.py --decrypt +``` + +脚本会自动: +1. 读取 `wechat_keys.json` 中的 key +2. 尝试字符串密码和 hex key 两种格式 +3. 尝试多种 cipher 配置组合 +4. 输出 `~/wxmoment/dbs/EnMicroMsg.db.decrypted.db` + +### 步骤 4:Web 查看 + +```bash +python3 search_server_android.py ~/wxmoment/dbs/EnMicroMsg.db.decrypted.db +``` + +访问 http://localhost:5002,支持: +- 会话列表(按最后消息时间排序) +- 联系人浏览(按拼音首字母排序) +- 聊天记录分页查看 +- 全局消息搜索 + +## 手动解密(备用) + +如果自动解密失败: +```bash +sqlcipher ~/wxmoment/dbs/EnMicroMsg.db +PRAGMA key = "e03ce81"; +PRAGMA cipher_compatibility = 1; +PRAGMA cipher_page_size = 1024; +ATTACH DATABASE 'plain.db' AS plain KEY ''; +SELECT sqlcipher_export('plain'); +DETACH DATABASE plain; +.quit +``` + +## DB 结构(Android 版) + +### message 表(所有消息在同一个表) + +| 字段 | 说明 | +|------|------| +| `msgId` | 消息 ID | +| `type` | 类型:1=文本, 3=图片, 49=链接/卡片, 10000=系统消息 | +| `isSend` | 0=收到, 1=自己发送 | +| `createTime` | 毫秒时间戳 | +| `talker` | 聊天对象:`wxid_xxx`(单聊)、`xxx@chatroom`(群聊) | +| `content` | 消息内容。群聊格式:`sender_wxid:\n` | + +### rcontact 表(联系人) + +| 字段 | 说明 | +|------|------| +| `username` | wxid | +| `nickname` | 昵称 | +| `conRemark` | 备注名 | +| `type` | 1=好友, 2=群成员, 3=公众号 | +| `deleteFlag` | 0=正常 | + +## 注意事项 + +1. **frida-server 版本必须匹配 Python frida 包**(当前 17.9.10) +2. **微信是多进程应用**,主进程 PID 取 `pgrep -x com.tencent.mm` 的最小值 +3. **spawn 模式会超时**,所以脚本使用 `am force-stop` + `am start` + `attach(pid)` 的 early-bird 模式 +4. **Java 层 hook 不可用**,所有 hook 在 native 层完成 +5. **DB 路径**:`/data/data/com.tencent.mm/MicroMsg//EnMicroMsg.db`,`` 因账号而异 +6. **Key 是动态捕获的**,每次微信启动可能变化,需重新捕获 + +## 故障排查 + +| 问题 | 解决 | +|------|------| +| `No USB Frida device found` | 确认 frida-server 在运行:`adb shell "ps -A \| grep frida-server"` | +| `unable to find process with name com.tencent.mm` | Frida 枚举不到主进程,脚本已自动 fallback 到 `pgrep` 取 PID | +| `Java is not defined` | 正常现象,脚本已降级到 native-only hook | +| `file is not a database` | Key 错误或 cipher 配置不对,检查 `wechat_keys.json` 和 cipher 配置 | +| `conversation` 表为空 | 正常现象,search_server_android.py 已 fallback 到用 `message` 表生成会话列表 | + +## 后续工作(你可能需要做的) + +1. **批量解密其他 DB**:微信还有 `WxFileIndex.db`、`FinderWCDB.db` 等,理论上共用同一个 key,可以批量解密验证 +2. **增量同步**:微信运行时 WAL 文件不断变化,需要定期重新提取和解密 +3. **适配更多消息类型**:当前 search_server 只完整处理了文本(type=1)和链接卡片(type=49),其他类型显示为 `[类型XX]` +4. **集成到 lemonPCRM**:把解密后的 DB 数据接入项目的数据管道 diff --git a/wechat_plain/scripts/docs/README_Android.md b/wechat_plain/scripts/docs/README_Android.md new file mode 100644 index 0000000..5413d1c --- /dev/null +++ b/wechat_plain/scripts/docs/README_Android.md @@ -0,0 +1,158 @@ +# WeChat Android EnMicroMsg.db 解密读取工具链 + +> 下面命令均以 `wechat_plain/scripts/` 为工作目录。解密套件在 `db-decrypt/`,搜索服务在 `search/`。 + +## 文件清单 + +| 文件 | 用途 | 运行环境 | +|------|------|----------| +| `db-decrypt/wechat_android_key_hunter.js` | Frida 脚本:hook `libWCDB.so` 的 `sqlite3_key` / `sqlite3_key_v2`,截获 SQLCipher 密码 | Android 微信进程 | +| `db-decrypt/wechat_android_attach.py` | Python 控制脚本:attach 微信、收集 key、提取 DB、自动解密 | Termux / Debian proot / Mac | +| `search/search_server_android.py` | Web 搜索服务器:读取解密后的 `EnMicroMsg.db`,提供会话/联系人/聊天记录搜索 | 任意有 Python 的环境 | + +## 工作原理 + +``` +微信启动 + ↓ +打开第一个加密 DB (PreDownloadCheck.db) + ↓ +libWCDB.so!sqlite3_key(db, "e03ce81", 7) ← Frida 在这里截获 key + ↓ +后续所有 DB (EnMicroMsg.db, WxFileIndex.db, ...) 复用同一 key + ↓ +adb shell su -c cat 复制 DB 文件到本地 + ↓ +sqlcipher 解密:PRAGMA key="e03ce81"; PRAGMA cipher_compatibility=1; PRAGMA cipher_page_size=1024; + ↓ +SQLite 明文库 → search/search_server_android.py 提供 Web UI +``` + +## 关键发现(WeChat 8.0.72 on Android) + +| 项目 | 值 | +|------|-----| +| **SQLCipher key** | `e03ce81`(7 字符字符串密码,通过 `sqlite3_key` 传入) | +| **cipher_compatibility** | `1` | +| **cipher_page_size** | `1024` | +| **Key 设置时机** | 打开第一个加密 DB 时调用一次 `sqlite3_key`,后续所有 DB 复用 | +| **Java VM 可见性** | `Java.available = false`(微信有反注入,但 native hook 完全正常) | +| **Hook 目标 so** | `libWCDB.so`(内含 SQLCipher,`sqlite3_key` 符号可通过 `mod.findExportByName()` 定位) | + +## DB 结构(Android 版 vs macOS 版) + +| | Android `EnMicroMsg.db` | macOS `db_storage/` | +|--|------------------------|---------------------| +| 文件数 | 单文件 | 多个分库 | +| 消息表 | `message`(`talker` 字段区分对象) | `Msg_` 分表 | +| 联系人表 | `rcontact` | `contact_contact.db` 的 `contact` | +| 会话表 | `conversation`(可能为空) | `session_session.db` 的 `SessionTable` | + +### message 表关键字段 + +- `msgId` — 消息 ID +- `type` — 消息类型:1=文本, 3=图片, 49=链接/卡片, 10000=系统消息 +- `isSend` — 0=收到, 1=自己发送 +- `createTime` — 毫秒时间戳 +- `talker` — 聊天对象:`wxid_xxx`(单聊)、`xxx@chatroom`(群聊) +- `content` — 消息内容。群聊格式:`sender_wxid:\n` + +### rcontact 表关键字段 + +- `username` — wxid +- `nickname` — 昵称 +- `conRemark` — 备注名 +- `type` — 1=好友, 2=群成员, 3=公众号 +- `deleteFlag` — 0=正常 + +## 使用步骤 + +### 1. 环境准备 + +```bash +# 手机端:确认 frida-server 已运行(root) +adb shell "ps -A | grep frida-server" + +# Debian proot 里安装 frida Python 包(版本匹配手机 frida-server) +pip install frida==17.9.10 + +# 安装 sqlcipher(用于解密) +# Debian: apt-get install sqlcipher +# Mac: brew install sqlcipher +``` + +### 2. 捕获 SQLCipher key + +```bash +python3 db-decrypt/wechat_android_attach.py +``` + +- 脚本会自动 kill 微信、重新启动、attach Frida +- 等待 `KEY CAPTURED` 输出 +- key 自动保存到 `~/wxmoment/wechat_keys.json` + +### 3. 提取 DB 文件 + +```bash +python3 db-decrypt/wechat_android_attach.py --extract-db +``` + +- 复制 `EnMicroMsg.db` + `-shm` + `-wal` 到 `~/wxmoment/dbs/` +- **必须复制 wal 文件**,否则丢失最新数据 + +### 4. 解密 DB + +```bash +python3 db-decrypt/wechat_android_attach.py --decrypt +``` + +- 自动尝试字符串密码 / hex key + 多种 cipher 配置组合 +- 输出:`~/wxmoment/dbs/EnMicroMsg.db.decrypted.db` + +### 5. 启动搜索服务器 + +```bash +python3 search/search_server_android.py ~/wxmoment/dbs/EnMicroMsg.db.decrypted.db +``` + +- 访问 http://localhost:5002 +- 支持:会话列表、联系人浏览、聊天记录分页、全局搜索 + +## 手动解密(如需自定义) + +```bash +sqlcipher EnMicroMsg.db +PRAGMA key = "e03ce81"; +PRAGMA cipher_compatibility = 1; +PRAGMA cipher_page_size = 1024; +ATTACH DATABASE 'EnMicroMsg_plain.db' AS plain KEY ''; +SELECT sqlcipher_export('plain'); +DETACH DATABASE plain; +.quit +``` + +## Frida 脚本 API 说明 + +`db-decrypt/wechat_android_key_hunter.js` 的 Hook 点: + +| Hook 函数 | 模块 | 用途 | +|-----------|------|------| +| `sqlite3_key` | `libWCDB.so` | 捕获密码/key | +| `sqlite3_key_v2` | `libWCDB.so` | 捕获密码/key(带 db name) | +| `sqlite3_open_v2` | `libWCDB.so` | 记录 DB 文件路径 | +| `sqlite3_exec` | `libWCDB.so` | 捕获 `PRAGMA key` / `PRAGMA cipher_*` | + +发送的消息格式(JSON): + +```json +{"type": "key", "hex": "65303363653831", "len": 7, "via": "libWCDB.so!sqlite3_key", "path": "", "extra": {"native": true, "v2": false, "db": "0x77342ab938"}} +``` + +## 注意事项 + +1. **frida-server 版本必须匹配 Python frida 包版本**(当前:17.9.10) +2. **微信是多进程应用**,主进程 PID 需要通过 `pgrep -x com.tencent.mm` 获取最小 PID +3. **spawn 模式会超时**,所以使用 `am force-stop` + `am start` + `attach(pid)` 的 early-bird 模式 +4. **Java 层 hook 不可用**,因为微信主进程对 Frida 隐藏了 JVM。所有 hook 都在 native 层完成 +5. **DB 路径**:`/data/data/com.tencent.mm/MicroMsg//EnMicroMsg.db` +6. **`` 目录名**:基于微信账号生成,每个账号不同 diff --git a/wechat_plain/scripts/docs/notes/MEMORY.md b/wechat_plain/scripts/docs/notes/MEMORY.md new file mode 100644 index 0000000..b56e077 --- /dev/null +++ b/wechat_plain/scripts/docs/notes/MEMORY.md @@ -0,0 +1,4 @@ +- [User Profile](user-profile.md) — Android researcher using Pixel 10 Pro Fold with Termux, Hermes, and Claude Code +- [Hermes Termux Setup](project-hermes-setup.md) — Hermes AI agent configured for kimi-coding provider on Android Termux +- [Claude Code Termux Setup](project-claude-code-termux.md) — Claude Code running in Ubuntu proot-distro via claude-run wrapper +- [WeChat Android Reverse Engineering](reference-wechat-android-re.md) — Research notes on WeChat Android hooking and message interception diff --git a/wechat_plain/scripts/docs/notes/project-claude-code-termux.md b/wechat_plain/scripts/docs/notes/project-claude-code-termux.md new file mode 100644 index 0000000..fd5bd57 --- /dev/null +++ b/wechat_plain/scripts/docs/notes/project-claude-code-termux.md @@ -0,0 +1,52 @@ +--- +name: claude-code-termux-setup +description: "Claude Code CLI runs inside Ubuntu proot-distro container on Android Termux, configured with Kimi Coding Plan" +metadata: + node_type: memory + type: project + originSessionId: a0bf5811-73e6-4c69-beeb-9a2867ab56bf +--- + +**Why**: Claude Code binary is ELF/glibc compiled and cannot run directly on Android (Bionic libc). Solution is running it inside an Ubuntu proot-distro container. + +**Wrapper script**: `/data/data/com.termux/files/usr/bin/claude-run` +```sh +#!/data/data/com.termux/files/usr/bin/sh +exec proot-distro login --shared-home ubuntu -- claude "$@" +``` + +**Key flag**: `--shared-home` binds Termux home (`/data/data/com.termux/files/home`) as `/root` inside the container. Without this flag, Ubuntu's `/root` is isolated and Claude Code cannot access Termux project files. + +**Claude Code config** (inside Ubuntu container): +- Path: `/root/.claude/settings.json` +- **Kimi Coding Plan** configuration: +```json +{ + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-kimi-UkxAkZTnTK5dBxl6zjVFkiHJg3vubzNj3xbOk55aVKfIlDpYKUhDcUB80hWJOtZQ", + "ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-for-coding", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-for-coding", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-for-coding", + "ANTHROPIC_MODEL": "kimi-for-coding" + }, + "model": "opus[1m]", + "skipDangerousModePermissionPrompt": true, + "theme": "dark" +} +``` + +**proot-distro default binds**: +- `/system`, `/vendor`, `/product`, `/apex` — Android system dirs +- `/storage`, `/sdcard` — shared storage +- `--shared-home`: Termux home → `/root` + +**What does NOT pass through by default**: +- Environment variables from Termux shell (must use `--env=VAR=VALUE`) +- Other apps' `/data/data/` private directories +- Bionic-compiled Android binaries + +**How to apply**: +- Modify `claude-run` to add/remove `--env` flags or `--shared-home` +- Modify container config via `settings.json` inside Ubuntu's `/root/.claude/` +- Container's `.bashrc` and `.profile` are separate from Termux's diff --git a/wechat_plain/scripts/docs/notes/project-hermes-setup.md b/wechat_plain/scripts/docs/notes/project-hermes-setup.md new file mode 100644 index 0000000..cd8741b --- /dev/null +++ b/wechat_plain/scripts/docs/notes/project-hermes-setup.md @@ -0,0 +1,38 @@ +--- +name: hermes-termux-setup +description: "Hermes AI agent configuration on Android Termux, currently using kimi-coding provider" +metadata: + node_type: memory + type: project + originSessionId: a0bf5811-73e6-4c69-beeb-9a2867ab56bf +--- + +**Why**: User runs Hermes as primary AI agent on Android Termux. Initially attempted switching to Claude (anthropic provider) using CLAUDE_CODE_OAUTH_TOKEN, but the OAuth token was incompatible with Hermes' anthropic provider (which uses `x-api-key` header). Reverted to kimi-coding. + +**Current Config** (`~/.hermes/config.yaml`): +```yaml +model: + default: kimi-for-coding + provider: kimi-coding + base_url: https://api.kimi.com/coding +providers: + kimi: + name: kimi + base_url: https://api.kimi.com/coding/ + api_key: sk-kimi-VX9okzPdM8ToM8QYNgxDMjgWnNDilGPhxQzZcdfz6Xwn62aaWQEHNdM1pHbNC6lk + api_mode: chat_completions +``` + +**Critical Gotcha**: `.bashrc` previously contained `ANTHROPIC_BASE_URL=https://api.kimi.com/coding/` which overrode Hermes' anthropic provider base_url and caused 401 errors when testing Claude. **Must clean `.bashrc` of ANTHROPIC_* env vars before switching providers.** + +**Env vars** (in `~/.hermes/.env` and `~/.profile`): +- `CLAUDE_CODE_OAUTH_TOKEN` — retained for claude-run usage, but Hermes does not use it + +**Hermes binary**: `/data/data/com.termux/files/usr/bin/hermes` (bash wrapper script) +- Sources `~/.profile` +- Runs `python hermes` from `~/hermes-agent/` + +**Hermes home**: `/data/data/com.termux/files/home/.hermes/` +- Config: `config.yaml` +- History: `.hermes_history` +- State DB: `state.db` diff --git a/wechat_plain/scripts/docs/notes/reference-wechat-android-re.md b/wechat_plain/scripts/docs/notes/reference-wechat-android-re.md new file mode 100644 index 0000000..3495a52 --- /dev/null +++ b/wechat_plain/scripts/docs/notes/reference-wechat-android-re.md @@ -0,0 +1,49 @@ +--- +name: wechat-android-reverse-engineering +description: "Research notes on WeChat Android reverse engineering, hooking frameworks, and message interception projects" +metadata: + node_type: memory + type: reference + originSessionId: a0bf5811-73e6-4c69-beeb-9a2867ab56bf +--- + +**Context**: User is interested in reverse engineering WeChat Android to hook/intercept message send/receive interfaces. + +**Core Challenge**: WeChat Android has strong obfuscation (class/method names), signature verification, and version checks. Static decompilation is nearly unreadable; dynamic hooking is the current standard approach. + +## Android Projects + +| Project | Framework | Target | Status | Link | +|---------|-----------|--------|--------|------| +| **Hook_WeChat_FaaS** | Frida | Android WeChat cloud functions | Active (218⭐) | [GitHub](https://github.com/FourTwooo/Hook_WeChat_FaaS) | +| **frida_wechat_hook** | Frida | `receive_message` interception | Archived (Gitee) | [Gitee](https://gitee.com/zixing/frida_wechat_hook) | +| **XposedHookWeichat** | Xposed | GPS hook demo | Outdated | [GitHub](https://github.com/cansou/XposedHookWeichat) | + +## PC Projects (reference architecture) + +| Project | Approach | Features | Link | +|---------|----------|----------|------| +| **WeChatFerry** | DLL injection + RPC | Send/receive, groups, SQL, decrypt | [GitHub](https://github.com/forked-repositories/WeChatFerry) | +| **千寻框架** | HTTP API wrapper | Text, @mentions, emoji | [Gitee](https://gitee.com/xxxxsq/mp_-weixin2) | +| **wechat_hook** | Docker + WebSocket | WebSocket listen + HTTP send | [GitHub](https://github.com/wandaijin/wechat_hook) | +| **PyWxDump** | Protocol reverse | Message dump framework | [CSDN](https://blog.csdn.net/sinat_28461591/article/details/148504535) | + +## Technical Resources + +- [Hooking WeChat using Xposed — Stack Overflow](https://stackoverflow.com/questions/40375695/hooking-wechat-using-xposed-for-incoming-messages) +- [微信 smali 反编译查看撤回消息 — CSDN](https://blog.csdn.net/weixin_30338461/article/details/97364638) +- [微信安卓协议分析笔记 — CSDN](https://blog.csdn.net/qq_21051503/article/details/79746742) +- [LLMs + Frida automated WeChat RE — Wechaty](https://wechaty.js.org/2025/08/12/ai-powered-reverse-engineering-concept/) + +## Recommended Approach for Android + +1. **Frida dynamic hook** (no root needed for some setups) + - `frida -U -f com.tencent.mm` + - Hook message functions via symbol/bytecode signatures + - Scripts need updating per WeChat version + +2. **Xposed/LSPosed** (requires root) + - More stable hooks but harder to maintain across versions + - Reference: `findAndHookMethod` pattern + +**Warning**: WeChat updates frequently break hooks. Expect high maintenance. All projects are for educational/research purposes only. diff --git a/wechat_plain/scripts/docs/notes/user-profile.md b/wechat_plain/scripts/docs/notes/user-profile.md new file mode 100644 index 0000000..0c05d2a --- /dev/null +++ b/wechat_plain/scripts/docs/notes/user-profile.md @@ -0,0 +1,32 @@ +--- +name: user-profile +description: "User is an Android researcher/developer using Pixel 10 Pro Fold with Termux, interested in AI agents and reverse engineering" +metadata: + node_type: memory + type: user + originSessionId: a0bf5811-73e6-4c69-beeb-9a2867ab56bf +--- + +**Device**: Pixel 10 Pro Fold (Android), connected via ADB (device ID: `59131FDCG000YB`) + +**Primary Environment**: Termux on Android +- Shell: `/bin/sh` (dash), not bash +- Home: `/data/data/com.termux/files/home/` +- Ubuntu proot-distro container for running Linux-native tools + +**Tools & Stack**: +- **Hermes**: AI agent framework (hermes-agent) installed in Termux, Python-based +- **Claude Code**: Runs inside Ubuntu proot-distro container via `claude-run` wrapper +- **Frida**: Installed (`frida-inject` binary present in home) +- **ADB**: Device connected and accessible from macOS host + +**Interests**: +- AI agent orchestration (Hermes, Claude Code) +- Android reverse engineering (WeChat hooking, Frida, Xposed) +- Cross-platform development workflow on mobile + +**How to apply**: +- All file operations on Android go through `adb shell run-as com.termux` +- Hermes config lives at `~/.hermes/config.yaml` +- Claude Code config lives in Ubuntu container at `/root/.claude/settings.json` +- Shell init files are `.profile` and `.bashrc` (the latter only loaded by bash, not default sh) diff --git a/wechat_plain/scripts/docs/notes/wechat-auto-reply-status.md b/wechat_plain/scripts/docs/notes/wechat-auto-reply-status.md new file mode 100644 index 0000000..d0b9fb8 --- /dev/null +++ b/wechat_plain/scripts/docs/notes/wechat-auto-reply-status.md @@ -0,0 +1,283 @@ +# WeChat 自动回复项目 — 完整状态报告 + +> 日期:2026-05-22 +> 设备:Pixel 10 Pro Fold +> 微信版本:8.0.72 +> 环境:Termux + LSPosed (Zygisk) + Frida Server 17.9.10 + +--- + +## 1. 项目目标 + +在 Pixel 手机上实现微信自动回复守护进程: +- **单聊消息** → 自动回复 +- **群聊 @我** → 自动回复 +- **群聊引用我的消息** → 自动回复 +- 运行在 Termux 环境,作为持久 daemon + +--- + +## 2. 已验证可行的部分 + +### 2.1 发送消息(完全可用) + +**方案**:LSPosed 模块 hook `ExtContentProviderBase` 绕过认证,通过 broadcast 触发内部 NetSceneSendMsg API。 + +**状态**:✅ 已验证,可稳定发送文本消息到任意联系人/群聊。 + +**触发方式**: +```bash +adb shell "am broadcast -a com.hermes.wechat.SEND_MSG --es toUser 'filehelper' --es content 'hello'" +``` + +**技术细节**: +- Hook `ExtContentProviderBase.h()` → 返回 1(绕过权限检查) +- Hook `ExtContentProviderBase.b()` → 返回 true(绕过登录检查) +- Hook `AESUtil` → 跳过 contact ID 加密 +- NetSceneSendMsg 调用链:`u01.r1.a(toUser)` → builder → `builder.e(content)` → `builder.h(0)` → `builder.a()` → `m1.a()`(主线程提交) + +--- + +## 3. 已尝试但不可行的方案 + +### 3.1 直接查询 SQLite 数据库(失败) + +**方案**:通过 `sqlite3` 命令行直接查询 `EnMicroMsg.db` 的 message 表。 + +**结果**:❌ 完全失败 + +**原因**: +- 数据库使用 SQLCipher 加密,需要正确的 key 才能打开 +- 通过 Frida hook `sqlite3_key`(offset 0x2687d8)捕获到 5 个 key +- 确认 EnMicroMsg.db 使用的 key 为 `e03ce81` +- 但使用 sqlcipher CLI 尝试 11 种不同配置(不同 KDF 迭代次数、HMAC 设置、page size),全部返回 "file is not a database" +- 通过 Frida 直接调用 `libWCDB.so` 的 `sqlite3_key`/`sqlite3_key_v2` 后执行 `PRAGMA schema_version`,同样返回错误码 26 + +**结论**:捕获的 key 可能并非真正用于解密,或者 SQLCipher 参数(salt、KDF 算法、加密页大小等)与标准配置完全不同。 + +--- + +### 3.2 Frida Hook WCDB Java 层 insert(失败) + +**方案**:Hook `com.tencent.wcdb.database.SQLiteDatabase` 的 `insert()` / `insertWithOnConflict()` / `replace()` / `replaceOrThrow()` 方法。 + +**结果**:❌ 未捕获到任何 message 表写入 + +**原因**: +- WeChat 8.0.72 的消息写入**没有走这些 Java 层方法** +- 可能使用了 WCDB 的 C++ 底层 API(如 `SQLiteStatement`)直接操作 B-tree +- 或消息写入发生在 `:push` 子进程而非主进程 + +--- + +### 3.3 Frida Hook Native sqlite3_exec / sqlite3_prepare_v2(失败) + +**方案**:Hook `libWCDB.so` 中导出的 `sqlite3_exec`(offset 0x29a310)和 `sqlite3_prepare_v2`(offset 0x29a5d0)。 + +**结果**:❌ 未捕获到任何 message 相关 SQL + +**原因**: +- 虽然 `sqlite3_key` hook 成功并捕获了 key,但 `sqlite3_exec` 在消息收发时**没有被调用** +- WeChat 可能使用 WCDB 内部的 C++ API(`Statement::execute()` 等),绕过了标准 C API + +--- + +### 3.4 Frida Hook Notification.Builder(失败) + +**方案**:Hook `android.app.Notification$Builder.setContentText()` 和 `setContentTitle()` 捕获通知内容。 + +**结果**:❌ 未触发 + +**原因**: +- 测试时发送消息到 File Transfer Assistant,该场景**不产生通知** +- 实际收到外部消息时可能触发,但未验证 +- 即使能捕获,通知内容通常被截断,不包含完整消息和群聊信息 + +--- + +### 3.5 ContentProvider 外部查询(失败) + +**方案**:通过 `adb shell content query --uri content://com.tencent.mm.plugin.ext.message/` 查询消息。 + +**结果**:❌ Permission Denial + +**原因**: +- Android 系统层的权限检查无法通过 LSPosed 绕过 +- 需要 `com.tencent.mm.ext.permission.READ` 权限,adb shell (uid=2000) 没有该权限 + +--- + +### 3.6 WAL 文件监控(失败) + +**方案**:监控 `EnMicroMsg.db-wal` 文件大小变化来检测新消息。 + +**结果**:❌ WAL 文件不存在 + +**原因**: +- WeChat/WCDB 可能使用 DELETE 日志模式而非 WAL 模式 +- 或 WAL 已被 checkpoint 合并 + +--- + +### 3.7 LSPosed Hook MsgInfo String 方法(失败) + +**方案**:Hook `com.tencent.mm.storage.f9`(MsgInfo)的所有单参数 String 方法。 + +**结果**:❌ 方法在消息收发时均不触发 + +**原因**: +- WeChat 8.0.72 的消息处理流程可能已变更,不再通过这些 Java 方法设置字段 +- 消息数据可能通过 JNI / 反射 / protobuf 直接构造 + +--- + +## 4. 待验证 / 正在尝试的方案 + +### 4.1 LSPosed Hook WCDB insert(当前方向) + +**方案**:修改 LSPosed 模块,hook `com.tencent.wcdb.database.SQLiteDatabase.insert()`,当表名包含 "message" 时提取 ContentValues 并写入共享文件 `/sdcard/hermes/wechat_msgs.jsonl`。 + +**状态**:🔄 代码已修改,但**未重新编译 APK** + +**阻塞**:本地无 Java Runtime,无法编译 APK。需要用户在手机上(Android Studio)或找有 Java 环境的地方编译。 + +**备注**:此方案在 Frida 中测试时未捕获到写入,但 LSPosed 的 hook 时机和进程覆盖范围与 Frida 不同,仍有验证价值。 + +--- + +### 4.2 Frida Hook :push 进程(待尝试) + +**方案**:微信消息推送和存储可能发生在 `:push` 子进程(PID 18531)。尝试在 push 进程中 hook WCDB 或 sqlite3 方法。 + +**状态**:⏳ 待尝试 + +**风险**:push 进程可能频繁重启,Frida 需要持续 attach。 + +--- + +### 4.3 Android AccessibilityService(备选方案) + +**方案**:实现无障碍服务,监听微信消息列表界面的内容变化。当收到新消息时,微信 UI 会更新消息列表,AccessibilityService 可以捕获这些文本。 + +**状态**:⏳ 待评估 + +**优点**: +- 不依赖 hook,稳定性高 +- 可以获取发送者昵称、消息内容、群聊名称 + +**缺点**: +- 需要用户手动授权无障碍服务权限 +- 需要微信在前台或消息列表可见(或锁屏通知) +- 实现较复杂,需要解析 AccessibilityNodeInfo 树 + +--- + +### 4.4 dumpsys notification 轮询(备选方案) + +**方案**:定期执行 `adb shell dumpsys notification` 提取微信通知内容。 + +**状态**:⏳ 待验证 + +**问题**: +- 当前 `dumpsys notification` 输出中未找到微信通知的具体内容(标题、文本) +- 可能需要 `--noredact` 权限或其他参数 +- 通知内容通常被截断,不适合群聊场景 + +--- + +### 4.5 Frida Native Hook sqlite3BtreeInsert(高难度) + +**方案**:Hook `libWCDB.so` 中更底层的 `sqlite3BtreeInsert` 或 `sqlite3VdbeExec` 函数。 + +**状态**:⏳ 待研究 + +**难点**: +- 这些函数未导出,需要动态定位 +- 签名和参数随 SQLite 版本变化 +- 即使捕获到,也需要解析 B-tree 页数据,极为复杂 + +--- + +## 5. 当前状态总结 + +| 功能 | 状态 | 备注 | +|------|------|------| +| 发送消息 | ✅ 可用 | LSPosed broadcast | +| 消息追踪 | ❌ 阻塞 | 所有已知 hook 点均失败 | +| 数据库解密 | ❌ 阻塞 | Key 正确但参数不匹配 | +| 自动回复 daemon | ⏳ 待联调 | 发送端可用,但缺少输入源 | + +**核心瓶颈**:消息追踪。没有可靠的消息来源,auto-reply.js 无法判断何时回复、回复给谁。 + +--- + +## 6. 下一步计划 + +### 方案 A:LSPosed 模块扩展(推荐优先尝试) + +1. 在 Android Studio 中重新编译 LSPosed 模块(含 WCDB insert hook) +2. 安装新模块,重启 WeChat +3. 发送测试消息,检查 `/sdcard/hermes/wechat_msgs.jsonl` 是否有数据写入 +4. 如果有数据,修改 auto-reply.js 读取该文件实现消息追踪 + +### 方案 B:Frida :push 进程 hook + +1. 对 `:push` 进程(PID 18531)运行 Frida,hook WCDB insert +2. 发送消息到该设备,观察 push 进程是否有数据库操作 +3. 如果成功,考虑将 hook 逻辑转为 LSPosed 模块(持久化) + +### 方案 C:AccessibilityService + +1. 开发一个 Android AccessibilityService +2. 监听微信通知栏或消息列表的文本变化 +3. 将捕获的消息写入文件供 Termux 读取 +4. 需要用户手动授权无障碍权限 + +### 方案 D:妥协方案 — 基于通知的简化版 + +1. 使用 `dumpsys notification` 定期轮询 +2. 仅检测"有新消息"事件(不获取具体内容) +3. 收到通知后,通过 ContentProvider 或数据库查询尝试获取完整内容 +4. 如仍无法获取,可回退到"收到消息后固定回复"模式 + +--- + +## 7. 关键发现记录 + +### 7.1 SQLCipher Key +- 捕获到的 key:`e03ce81`(7 字节) +- 对应数据库路径:`/data/user/0/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf/EnMicroMsg.db` +- 该 key 无法通过 sqlcipher CLI 或 Frida 直接调用解密 + +### 7.2 libWCDB.so 导出函数偏移 +- `sqlite3_open`:0x29fe1c +- `sqlite3_open_v2`:0x2a03f8 +- `sqlite3_key`:0x2687d8 +- `sqlite3_key_v2`:0x26888c +- `sqlite3_exec`:0x29a310 +- `sqlite3_prepare_v2`:0x29a5d0 +- `sqlite3_close`:0x29fbcc +- `sqlite3_errmsg`:0x29efd8 + +### 7.3 NetSceneSendMsg 调用链(WeChat 8.0.72) +``` +u01.r1.a(toUser) // 创建 builder +builder.e(content) // 设置消息内容 +builder.h(0) // 设置 subtype +builder.a() // 构建 NetSceneSendMsg 对象 +m1.a() // 在主线程提交到 NetSceneQueue +``` + +### 7.4 进程结构 +- 主进程:`com.tencent.mm`(PID 18340)— UI + 消息发送 +- Push 进程:`com.tencent.mm:push`(PID 18531)— 消息接收 + 推送 +- AppBrand 进程:`com.tencent.mm:appbrand0/1` + +--- + +## 8. 待决策事项 + +1. **LSPosed 模块重新编译**:用户是否有 Android Studio 环境或可以安装 JDK? +2. **AccessibilityService 方案**:用户是否接受手动授权无障碍权限? +3. **:push 进程 hook**:用户是否愿意让 Frida 持续运行在后台? +4. **降级方案**:如果以上均不可行,是否接受仅检测"有新消息"而不获取内容的简化版? diff --git a/wechat_plain/scripts/local-testkit.tar.gz b/wechat_plain/scripts/local-testkit.tar.gz deleted file mode 100644 index b86a199..0000000 Binary files a/wechat_plain/scripts/local-testkit.tar.gz and /dev/null differ diff --git a/wechat_plain/scripts/local-testkit.zip b/wechat_plain/scripts/local-testkit.zip deleted file mode 100644 index 5a79e5c..0000000 Binary files a/wechat_plain/scripts/local-testkit.zip and /dev/null differ diff --git a/wechat_plain/scripts/md/MEMORY.md b/wechat_plain/scripts/md/MEMORY.md deleted file mode 100644 index b56e077..0000000 --- a/wechat_plain/scripts/md/MEMORY.md +++ /dev/null @@ -1,4 +0,0 @@ -- [User Profile](user-profile.md) — Android researcher using Pixel 10 Pro Fold with Termux, Hermes, and Claude Code -- [Hermes Termux Setup](project-hermes-setup.md) — Hermes AI agent configured for kimi-coding provider on Android Termux -- [Claude Code Termux Setup](project-claude-code-termux.md) — Claude Code running in Ubuntu proot-distro via claude-run wrapper -- [WeChat Android Reverse Engineering](reference-wechat-android-re.md) — Research notes on WeChat Android hooking and message interception diff --git a/wechat_plain/scripts/md/project-claude-code-termux.md b/wechat_plain/scripts/md/project-claude-code-termux.md deleted file mode 100644 index fd5bd57..0000000 --- a/wechat_plain/scripts/md/project-claude-code-termux.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: claude-code-termux-setup -description: "Claude Code CLI runs inside Ubuntu proot-distro container on Android Termux, configured with Kimi Coding Plan" -metadata: - node_type: memory - type: project - originSessionId: a0bf5811-73e6-4c69-beeb-9a2867ab56bf ---- - -**Why**: Claude Code binary is ELF/glibc compiled and cannot run directly on Android (Bionic libc). Solution is running it inside an Ubuntu proot-distro container. - -**Wrapper script**: `/data/data/com.termux/files/usr/bin/claude-run` -```sh -#!/data/data/com.termux/files/usr/bin/sh -exec proot-distro login --shared-home ubuntu -- claude "$@" -``` - -**Key flag**: `--shared-home` binds Termux home (`/data/data/com.termux/files/home`) as `/root` inside the container. Without this flag, Ubuntu's `/root` is isolated and Claude Code cannot access Termux project files. - -**Claude Code config** (inside Ubuntu container): -- Path: `/root/.claude/settings.json` -- **Kimi Coding Plan** configuration: -```json -{ - "env": { - "ANTHROPIC_AUTH_TOKEN": "sk-kimi-UkxAkZTnTK5dBxl6zjVFkiHJg3vubzNj3xbOk55aVKfIlDpYKUhDcUB80hWJOtZQ", - "ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-for-coding", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-for-coding", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-for-coding", - "ANTHROPIC_MODEL": "kimi-for-coding" - }, - "model": "opus[1m]", - "skipDangerousModePermissionPrompt": true, - "theme": "dark" -} -``` - -**proot-distro default binds**: -- `/system`, `/vendor`, `/product`, `/apex` — Android system dirs -- `/storage`, `/sdcard` — shared storage -- `--shared-home`: Termux home → `/root` - -**What does NOT pass through by default**: -- Environment variables from Termux shell (must use `--env=VAR=VALUE`) -- Other apps' `/data/data/` private directories -- Bionic-compiled Android binaries - -**How to apply**: -- Modify `claude-run` to add/remove `--env` flags or `--shared-home` -- Modify container config via `settings.json` inside Ubuntu's `/root/.claude/` -- Container's `.bashrc` and `.profile` are separate from Termux's diff --git a/wechat_plain/scripts/md/project-hermes-setup.md b/wechat_plain/scripts/md/project-hermes-setup.md deleted file mode 100644 index cd8741b..0000000 --- a/wechat_plain/scripts/md/project-hermes-setup.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: hermes-termux-setup -description: "Hermes AI agent configuration on Android Termux, currently using kimi-coding provider" -metadata: - node_type: memory - type: project - originSessionId: a0bf5811-73e6-4c69-beeb-9a2867ab56bf ---- - -**Why**: User runs Hermes as primary AI agent on Android Termux. Initially attempted switching to Claude (anthropic provider) using CLAUDE_CODE_OAUTH_TOKEN, but the OAuth token was incompatible with Hermes' anthropic provider (which uses `x-api-key` header). Reverted to kimi-coding. - -**Current Config** (`~/.hermes/config.yaml`): -```yaml -model: - default: kimi-for-coding - provider: kimi-coding - base_url: https://api.kimi.com/coding -providers: - kimi: - name: kimi - base_url: https://api.kimi.com/coding/ - api_key: sk-kimi-VX9okzPdM8ToM8QYNgxDMjgWnNDilGPhxQzZcdfz6Xwn62aaWQEHNdM1pHbNC6lk - api_mode: chat_completions -``` - -**Critical Gotcha**: `.bashrc` previously contained `ANTHROPIC_BASE_URL=https://api.kimi.com/coding/` which overrode Hermes' anthropic provider base_url and caused 401 errors when testing Claude. **Must clean `.bashrc` of ANTHROPIC_* env vars before switching providers.** - -**Env vars** (in `~/.hermes/.env` and `~/.profile`): -- `CLAUDE_CODE_OAUTH_TOKEN` — retained for claude-run usage, but Hermes does not use it - -**Hermes binary**: `/data/data/com.termux/files/usr/bin/hermes` (bash wrapper script) -- Sources `~/.profile` -- Runs `python hermes` from `~/hermes-agent/` - -**Hermes home**: `/data/data/com.termux/files/home/.hermes/` -- Config: `config.yaml` -- History: `.hermes_history` -- State DB: `state.db` diff --git a/wechat_plain/scripts/md/reference-wechat-android-re.md b/wechat_plain/scripts/md/reference-wechat-android-re.md deleted file mode 100644 index 3495a52..0000000 --- a/wechat_plain/scripts/md/reference-wechat-android-re.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: wechat-android-reverse-engineering -description: "Research notes on WeChat Android reverse engineering, hooking frameworks, and message interception projects" -metadata: - node_type: memory - type: reference - originSessionId: a0bf5811-73e6-4c69-beeb-9a2867ab56bf ---- - -**Context**: User is interested in reverse engineering WeChat Android to hook/intercept message send/receive interfaces. - -**Core Challenge**: WeChat Android has strong obfuscation (class/method names), signature verification, and version checks. Static decompilation is nearly unreadable; dynamic hooking is the current standard approach. - -## Android Projects - -| Project | Framework | Target | Status | Link | -|---------|-----------|--------|--------|------| -| **Hook_WeChat_FaaS** | Frida | Android WeChat cloud functions | Active (218⭐) | [GitHub](https://github.com/FourTwooo/Hook_WeChat_FaaS) | -| **frida_wechat_hook** | Frida | `receive_message` interception | Archived (Gitee) | [Gitee](https://gitee.com/zixing/frida_wechat_hook) | -| **XposedHookWeichat** | Xposed | GPS hook demo | Outdated | [GitHub](https://github.com/cansou/XposedHookWeichat) | - -## PC Projects (reference architecture) - -| Project | Approach | Features | Link | -|---------|----------|----------|------| -| **WeChatFerry** | DLL injection + RPC | Send/receive, groups, SQL, decrypt | [GitHub](https://github.com/forked-repositories/WeChatFerry) | -| **千寻框架** | HTTP API wrapper | Text, @mentions, emoji | [Gitee](https://gitee.com/xxxxsq/mp_-weixin2) | -| **wechat_hook** | Docker + WebSocket | WebSocket listen + HTTP send | [GitHub](https://github.com/wandaijin/wechat_hook) | -| **PyWxDump** | Protocol reverse | Message dump framework | [CSDN](https://blog.csdn.net/sinat_28461591/article/details/148504535) | - -## Technical Resources - -- [Hooking WeChat using Xposed — Stack Overflow](https://stackoverflow.com/questions/40375695/hooking-wechat-using-xposed-for-incoming-messages) -- [微信 smali 反编译查看撤回消息 — CSDN](https://blog.csdn.net/weixin_30338461/article/details/97364638) -- [微信安卓协议分析笔记 — CSDN](https://blog.csdn.net/qq_21051503/article/details/79746742) -- [LLMs + Frida automated WeChat RE — Wechaty](https://wechaty.js.org/2025/08/12/ai-powered-reverse-engineering-concept/) - -## Recommended Approach for Android - -1. **Frida dynamic hook** (no root needed for some setups) - - `frida -U -f com.tencent.mm` - - Hook message functions via symbol/bytecode signatures - - Scripts need updating per WeChat version - -2. **Xposed/LSPosed** (requires root) - - More stable hooks but harder to maintain across versions - - Reference: `findAndHookMethod` pattern - -**Warning**: WeChat updates frequently break hooks. Expect high maintenance. All projects are for educational/research purposes only. diff --git a/wechat_plain/scripts/md/user-profile.md b/wechat_plain/scripts/md/user-profile.md deleted file mode 100644 index 0c05d2a..0000000 --- a/wechat_plain/scripts/md/user-profile.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: user-profile -description: "User is an Android researcher/developer using Pixel 10 Pro Fold with Termux, interested in AI agents and reverse engineering" -metadata: - node_type: memory - type: user - originSessionId: a0bf5811-73e6-4c69-beeb-9a2867ab56bf ---- - -**Device**: Pixel 10 Pro Fold (Android), connected via ADB (device ID: `59131FDCG000YB`) - -**Primary Environment**: Termux on Android -- Shell: `/bin/sh` (dash), not bash -- Home: `/data/data/com.termux/files/home/` -- Ubuntu proot-distro container for running Linux-native tools - -**Tools & Stack**: -- **Hermes**: AI agent framework (hermes-agent) installed in Termux, Python-based -- **Claude Code**: Runs inside Ubuntu proot-distro container via `claude-run` wrapper -- **Frida**: Installed (`frida-inject` binary present in home) -- **ADB**: Device connected and accessible from macOS host - -**Interests**: -- AI agent orchestration (Hermes, Claude Code) -- Android reverse engineering (WeChat hooking, Frida, Xposed) -- Cross-platform development workflow on mobile - -**How to apply**: -- All file operations on Android go through `adb shell run-as com.termux` -- Hermes config lives at `~/.hermes/config.yaml` -- Claude Code config lives in Ubuntu container at `/root/.claude/settings.json` -- Shell init files are `.profile` and `.bashrc` (the latter only loaded by bash, not default sh) diff --git a/wechat_plain/scripts/md/wechat-auto-reply-status.md b/wechat_plain/scripts/md/wechat-auto-reply-status.md deleted file mode 100644 index d0b9fb8..0000000 --- a/wechat_plain/scripts/md/wechat-auto-reply-status.md +++ /dev/null @@ -1,283 +0,0 @@ -# WeChat 自动回复项目 — 完整状态报告 - -> 日期:2026-05-22 -> 设备:Pixel 10 Pro Fold -> 微信版本:8.0.72 -> 环境:Termux + LSPosed (Zygisk) + Frida Server 17.9.10 - ---- - -## 1. 项目目标 - -在 Pixel 手机上实现微信自动回复守护进程: -- **单聊消息** → 自动回复 -- **群聊 @我** → 自动回复 -- **群聊引用我的消息** → 自动回复 -- 运行在 Termux 环境,作为持久 daemon - ---- - -## 2. 已验证可行的部分 - -### 2.1 发送消息(完全可用) - -**方案**:LSPosed 模块 hook `ExtContentProviderBase` 绕过认证,通过 broadcast 触发内部 NetSceneSendMsg API。 - -**状态**:✅ 已验证,可稳定发送文本消息到任意联系人/群聊。 - -**触发方式**: -```bash -adb shell "am broadcast -a com.hermes.wechat.SEND_MSG --es toUser 'filehelper' --es content 'hello'" -``` - -**技术细节**: -- Hook `ExtContentProviderBase.h()` → 返回 1(绕过权限检查) -- Hook `ExtContentProviderBase.b()` → 返回 true(绕过登录检查) -- Hook `AESUtil` → 跳过 contact ID 加密 -- NetSceneSendMsg 调用链:`u01.r1.a(toUser)` → builder → `builder.e(content)` → `builder.h(0)` → `builder.a()` → `m1.a()`(主线程提交) - ---- - -## 3. 已尝试但不可行的方案 - -### 3.1 直接查询 SQLite 数据库(失败) - -**方案**:通过 `sqlite3` 命令行直接查询 `EnMicroMsg.db` 的 message 表。 - -**结果**:❌ 完全失败 - -**原因**: -- 数据库使用 SQLCipher 加密,需要正确的 key 才能打开 -- 通过 Frida hook `sqlite3_key`(offset 0x2687d8)捕获到 5 个 key -- 确认 EnMicroMsg.db 使用的 key 为 `e03ce81` -- 但使用 sqlcipher CLI 尝试 11 种不同配置(不同 KDF 迭代次数、HMAC 设置、page size),全部返回 "file is not a database" -- 通过 Frida 直接调用 `libWCDB.so` 的 `sqlite3_key`/`sqlite3_key_v2` 后执行 `PRAGMA schema_version`,同样返回错误码 26 - -**结论**:捕获的 key 可能并非真正用于解密,或者 SQLCipher 参数(salt、KDF 算法、加密页大小等)与标准配置完全不同。 - ---- - -### 3.2 Frida Hook WCDB Java 层 insert(失败) - -**方案**:Hook `com.tencent.wcdb.database.SQLiteDatabase` 的 `insert()` / `insertWithOnConflict()` / `replace()` / `replaceOrThrow()` 方法。 - -**结果**:❌ 未捕获到任何 message 表写入 - -**原因**: -- WeChat 8.0.72 的消息写入**没有走这些 Java 层方法** -- 可能使用了 WCDB 的 C++ 底层 API(如 `SQLiteStatement`)直接操作 B-tree -- 或消息写入发生在 `:push` 子进程而非主进程 - ---- - -### 3.3 Frida Hook Native sqlite3_exec / sqlite3_prepare_v2(失败) - -**方案**:Hook `libWCDB.so` 中导出的 `sqlite3_exec`(offset 0x29a310)和 `sqlite3_prepare_v2`(offset 0x29a5d0)。 - -**结果**:❌ 未捕获到任何 message 相关 SQL - -**原因**: -- 虽然 `sqlite3_key` hook 成功并捕获了 key,但 `sqlite3_exec` 在消息收发时**没有被调用** -- WeChat 可能使用 WCDB 内部的 C++ API(`Statement::execute()` 等),绕过了标准 C API - ---- - -### 3.4 Frida Hook Notification.Builder(失败) - -**方案**:Hook `android.app.Notification$Builder.setContentText()` 和 `setContentTitle()` 捕获通知内容。 - -**结果**:❌ 未触发 - -**原因**: -- 测试时发送消息到 File Transfer Assistant,该场景**不产生通知** -- 实际收到外部消息时可能触发,但未验证 -- 即使能捕获,通知内容通常被截断,不包含完整消息和群聊信息 - ---- - -### 3.5 ContentProvider 外部查询(失败) - -**方案**:通过 `adb shell content query --uri content://com.tencent.mm.plugin.ext.message/` 查询消息。 - -**结果**:❌ Permission Denial - -**原因**: -- Android 系统层的权限检查无法通过 LSPosed 绕过 -- 需要 `com.tencent.mm.ext.permission.READ` 权限,adb shell (uid=2000) 没有该权限 - ---- - -### 3.6 WAL 文件监控(失败) - -**方案**:监控 `EnMicroMsg.db-wal` 文件大小变化来检测新消息。 - -**结果**:❌ WAL 文件不存在 - -**原因**: -- WeChat/WCDB 可能使用 DELETE 日志模式而非 WAL 模式 -- 或 WAL 已被 checkpoint 合并 - ---- - -### 3.7 LSPosed Hook MsgInfo String 方法(失败) - -**方案**:Hook `com.tencent.mm.storage.f9`(MsgInfo)的所有单参数 String 方法。 - -**结果**:❌ 方法在消息收发时均不触发 - -**原因**: -- WeChat 8.0.72 的消息处理流程可能已变更,不再通过这些 Java 方法设置字段 -- 消息数据可能通过 JNI / 反射 / protobuf 直接构造 - ---- - -## 4. 待验证 / 正在尝试的方案 - -### 4.1 LSPosed Hook WCDB insert(当前方向) - -**方案**:修改 LSPosed 模块,hook `com.tencent.wcdb.database.SQLiteDatabase.insert()`,当表名包含 "message" 时提取 ContentValues 并写入共享文件 `/sdcard/hermes/wechat_msgs.jsonl`。 - -**状态**:🔄 代码已修改,但**未重新编译 APK** - -**阻塞**:本地无 Java Runtime,无法编译 APK。需要用户在手机上(Android Studio)或找有 Java 环境的地方编译。 - -**备注**:此方案在 Frida 中测试时未捕获到写入,但 LSPosed 的 hook 时机和进程覆盖范围与 Frida 不同,仍有验证价值。 - ---- - -### 4.2 Frida Hook :push 进程(待尝试) - -**方案**:微信消息推送和存储可能发生在 `:push` 子进程(PID 18531)。尝试在 push 进程中 hook WCDB 或 sqlite3 方法。 - -**状态**:⏳ 待尝试 - -**风险**:push 进程可能频繁重启,Frida 需要持续 attach。 - ---- - -### 4.3 Android AccessibilityService(备选方案) - -**方案**:实现无障碍服务,监听微信消息列表界面的内容变化。当收到新消息时,微信 UI 会更新消息列表,AccessibilityService 可以捕获这些文本。 - -**状态**:⏳ 待评估 - -**优点**: -- 不依赖 hook,稳定性高 -- 可以获取发送者昵称、消息内容、群聊名称 - -**缺点**: -- 需要用户手动授权无障碍服务权限 -- 需要微信在前台或消息列表可见(或锁屏通知) -- 实现较复杂,需要解析 AccessibilityNodeInfo 树 - ---- - -### 4.4 dumpsys notification 轮询(备选方案) - -**方案**:定期执行 `adb shell dumpsys notification` 提取微信通知内容。 - -**状态**:⏳ 待验证 - -**问题**: -- 当前 `dumpsys notification` 输出中未找到微信通知的具体内容(标题、文本) -- 可能需要 `--noredact` 权限或其他参数 -- 通知内容通常被截断,不适合群聊场景 - ---- - -### 4.5 Frida Native Hook sqlite3BtreeInsert(高难度) - -**方案**:Hook `libWCDB.so` 中更底层的 `sqlite3BtreeInsert` 或 `sqlite3VdbeExec` 函数。 - -**状态**:⏳ 待研究 - -**难点**: -- 这些函数未导出,需要动态定位 -- 签名和参数随 SQLite 版本变化 -- 即使捕获到,也需要解析 B-tree 页数据,极为复杂 - ---- - -## 5. 当前状态总结 - -| 功能 | 状态 | 备注 | -|------|------|------| -| 发送消息 | ✅ 可用 | LSPosed broadcast | -| 消息追踪 | ❌ 阻塞 | 所有已知 hook 点均失败 | -| 数据库解密 | ❌ 阻塞 | Key 正确但参数不匹配 | -| 自动回复 daemon | ⏳ 待联调 | 发送端可用,但缺少输入源 | - -**核心瓶颈**:消息追踪。没有可靠的消息来源,auto-reply.js 无法判断何时回复、回复给谁。 - ---- - -## 6. 下一步计划 - -### 方案 A:LSPosed 模块扩展(推荐优先尝试) - -1. 在 Android Studio 中重新编译 LSPosed 模块(含 WCDB insert hook) -2. 安装新模块,重启 WeChat -3. 发送测试消息,检查 `/sdcard/hermes/wechat_msgs.jsonl` 是否有数据写入 -4. 如果有数据,修改 auto-reply.js 读取该文件实现消息追踪 - -### 方案 B:Frida :push 进程 hook - -1. 对 `:push` 进程(PID 18531)运行 Frida,hook WCDB insert -2. 发送消息到该设备,观察 push 进程是否有数据库操作 -3. 如果成功,考虑将 hook 逻辑转为 LSPosed 模块(持久化) - -### 方案 C:AccessibilityService - -1. 开发一个 Android AccessibilityService -2. 监听微信通知栏或消息列表的文本变化 -3. 将捕获的消息写入文件供 Termux 读取 -4. 需要用户手动授权无障碍权限 - -### 方案 D:妥协方案 — 基于通知的简化版 - -1. 使用 `dumpsys notification` 定期轮询 -2. 仅检测"有新消息"事件(不获取具体内容) -3. 收到通知后,通过 ContentProvider 或数据库查询尝试获取完整内容 -4. 如仍无法获取,可回退到"收到消息后固定回复"模式 - ---- - -## 7. 关键发现记录 - -### 7.1 SQLCipher Key -- 捕获到的 key:`e03ce81`(7 字节) -- 对应数据库路径:`/data/user/0/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf/EnMicroMsg.db` -- 该 key 无法通过 sqlcipher CLI 或 Frida 直接调用解密 - -### 7.2 libWCDB.so 导出函数偏移 -- `sqlite3_open`:0x29fe1c -- `sqlite3_open_v2`:0x2a03f8 -- `sqlite3_key`:0x2687d8 -- `sqlite3_key_v2`:0x26888c -- `sqlite3_exec`:0x29a310 -- `sqlite3_prepare_v2`:0x29a5d0 -- `sqlite3_close`:0x29fbcc -- `sqlite3_errmsg`:0x29efd8 - -### 7.3 NetSceneSendMsg 调用链(WeChat 8.0.72) -``` -u01.r1.a(toUser) // 创建 builder -builder.e(content) // 设置消息内容 -builder.h(0) // 设置 subtype -builder.a() // 构建 NetSceneSendMsg 对象 -m1.a() // 在主线程提交到 NetSceneQueue -``` - -### 7.4 进程结构 -- 主进程:`com.tencent.mm`(PID 18340)— UI + 消息发送 -- Push 进程:`com.tencent.mm:push`(PID 18531)— 消息接收 + 推送 -- AppBrand 进程:`com.tencent.mm:appbrand0/1` - ---- - -## 8. 待决策事项 - -1. **LSPosed 模块重新编译**:用户是否有 Android Studio 环境或可以安装 JDK? -2. **AccessibilityService 方案**:用户是否接受手动授权无障碍权限? -3. **:push 进程 hook**:用户是否愿意让 Frida 持续运行在后台? -4. **降级方案**:如果以上均不可行,是否接受仅检测"有新消息"而不获取内容的简化版? diff --git a/wechat_plain/scripts/search/build_chat_stats.py b/wechat_plain/scripts/search/build_chat_stats.py new file mode 100644 index 0000000..e8754b8 --- /dev/null +++ b/wechat_plain/scripts/search/build_chat_stats.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +统计每个联系人的聊天记录数量和最近/最早消息时间 +输出: wechat_plain/chat_stats.json { username: {count, last_time, first_time} } +""" +import sqlite3, hashlib, json, os, time, sys + +DB_DIR = '/Users/yibo/Documents/wechat_plain' +CONTACT_DB = f'{DB_DIR}/contact_contact.db' +MSG_DBS = [f'{DB_DIR}/message_message_{i}.db' for i in range(11) + if os.path.exists(f'{DB_DIR}/message_message_{i}.db')] +OUT_FILE = f'{DB_DIR}/chat_stats.json' + +def md5(s): + return hashlib.md5(s.encode()).hexdigest() + +# ── 加载联系人 username,构建 md5 → username 反查表 ───────────────────────── +print('[1/3] 加载联系人...') +md5_to_user = {} +try: + con = sqlite3.connect(CONTACT_DB) + for (u,) in con.execute("SELECT username FROM contact WHERE delete_flag=0"): + md5_to_user[md5(u)] = u + con.close() + print(f' {len(md5_to_user)} 个联系人') +except Exception as e: + print(f'[!] 联系人读取失败: {e}') + sys.exit(1) + +# ── 遍历所有消息表,统计 count/last_time/first_time ───────────────────────── +print('[2/3] 统计消息表...') +stats = {} # username (or md5 hash) → {count, last_time, first_time} +total_tables = 0 +t0 = time.time() + +for db_path in MSG_DBS: + db_name = os.path.basename(db_path) + try: + con = sqlite3.connect(db_path) + tables = [r[0] for r in con.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" + )] + print(f' {db_name}: {len(tables)} 张表', flush=True) + for tbl in tables: + h = tbl[4:] + key = md5_to_user.get(h, h) # prefer real username, fallback to md5 + try: + row = con.execute( + f'SELECT count(*), max(create_time), min(create_time) FROM "{tbl}"' + ).fetchone() + count, last_t, first_t = row + if count and count > 0: + prev = stats.get(key, {'count': 0, 'last_time': 0, 'first_time': 0}) + stats[key] = { + 'count': prev['count'] + count, + 'last_time': max(prev['last_time'], last_t or 0), + 'first_time': min(prev['first_time'] or (first_t or 0), first_t or 0), + } + except Exception: + pass + total_tables += 1 + con.close() + except Exception as e: + print(f'[!] {db_name}: {e}') + +elapsed = time.time() - t0 +print(f' {total_tables} 张表, {len(stats)} 个聊天对象 ({elapsed:.1f}s)') + +# ── 写入 JSON ───────────────────────────────────────────────────────────────── +print(f'[3/3] 写入 {OUT_FILE}...') +with open(OUT_FILE, 'w') as f: + json.dump(stats, f, ensure_ascii=False) + +size_kb = os.path.getsize(OUT_FILE) // 1024 +print(f' 完成!{len(stats)} 条记录 ({size_kb} KB)') +print(f'\n前 10 名(消息最多):') +top = sorted(stats.items(), key=lambda x: x[1]['count'], reverse=True)[:10] +for key, v in top: + print(f' {key[:40]:<40} {v["count"]:>6} 条') diff --git a/wechat_plain/scripts/search/search_server.py b/wechat_plain/scripts/search/search_server.py new file mode 100644 index 0000000..964c66d --- /dev/null +++ b/wechat_plain/scripts/search/search_server.py @@ -0,0 +1,800 @@ +#!/usr/bin/env python3 +""" +WeChat 聊天记录搜索工具 +用法: python3 search_server.py +访问: http://localhost:5001 +""" +import sqlite3, json, os, hashlib, re, time +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +PORT = 5001 +DB_DIR = '/Users/yibo/Documents/wechat_plain' +CONTACT_DB = f'{DB_DIR}/contact_contact.db' +SESSION_DB = f'{DB_DIR}/session_session.db' +MSG_DBS = [f'{DB_DIR}/message_message_{i}.db' for i in range(11) + if os.path.exists(f'{DB_DIR}/message_message_{i}.db')] +MY_IDS = {15, 543} # real_sender_id values that mean "sent by me" + +TYPE_LABELS = { + 1: '文字', 3: '图片', 34: '语音', 43: '视频', 47: '表情', + 49: '链接/文件', 50: '视频通话', 10000: '系统消息', + 436207665: '红包', 469762048: '转账', +} + +# ── Data loading ─────────────────────────────────────────────────────────────── +contacts = {} # username → {nickname, remark, avatar} +md5_to_user = {} # md5(username) → username +table_index = {} # md5(username) → db_path +chat_stats = {} # username → {count, last_time, first_time} + +def md5(s): + return hashlib.md5(s.encode()).hexdigest() + +def load_contacts(): + try: + con = sqlite3.connect(CONTACT_DB) + con.row_factory = sqlite3.Row + for row in con.execute( + "SELECT username, nick_name, remark, small_head_url FROM contact WHERE delete_flag=0" + ): + u = row['username'] + contacts[u] = { + 'nickname': row['nick_name'] or '', + 'remark': row['remark'] or '', + 'avatar': row['small_head_url'] or '', + } + md5_to_user[md5(u)] = u + con.close() + print(f'[+] {len(contacts)} contacts') + except Exception as e: + print(f'[!] contacts: {e}') + +def build_index(): + for db_path in MSG_DBS: + try: + con = sqlite3.connect(db_path) + for (t,) in con.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" + ): + h = t[4:] + if h not in table_index: + table_index[h] = db_path + con.close() + except: pass + print(f'[+] {len(table_index)} message tables indexed') + +def load_chat_stats(): + path = f'{DB_DIR}/chat_stats.json' + if not os.path.exists(path): + print('[!] chat_stats.json not found, run build_chat_stats.py first') + return + with open(path) as f: + chat_stats.update(json.load(f)) + print(f'[+] {len(chat_stats)} chat stat entries loaded') + +def dname(username): + if not username: return '未知' + c = contacts.get(username, {}) + return c.get('remark') or c.get('nickname') or username + +def fmt_ts(ts): + try: + ts = int(ts) + if ts > 1e12: ts //= 1000 + return time.strftime('%Y-%m-%d %H:%M', time.localtime(ts)) + except: return '' + +def parse_msg(local_type, raw_content, real_sender_id): + """Return (sender_username_or_empty, clean_text, is_sent_by_me)""" + is_me = int(real_sender_id or 0) in MY_IDS + if local_type != 1: + label = TYPE_LABELS.get(local_type, f'[类型{local_type}]') + if local_type == 49 and isinstance(raw_content, str): + m = re.search(r'(.*?)', raw_content, re.S) + if m: label = f'[链接] {m.group(1)[:60]}' + return ('', label, is_me) + + if not raw_content: + return ('', '', is_me) + if isinstance(raw_content, bytes): + try: raw_content = raw_content.decode('utf-8', errors='replace') + except: return ('', '[二进制内容]', is_me) + + # Group/received messages: "username:\ntext" + if not is_me and ':\n' in raw_content[:100]: + idx = raw_content.index(':\n') + sender_name = raw_content[:idx] + text = raw_content[idx+2:] + return (sender_name, text, False) + + return ('', raw_content, is_me) + +# ── API ──────────────────────────────────────────────────────────────────────── + +def api_sessions(_p): + try: + con = sqlite3.connect(SESSION_DB) + con.row_factory = sqlite3.Row + total = con.execute("SELECT count(*) FROM SessionTable WHERE is_hidden=0").fetchone()[0] + rows = con.execute( + "SELECT * FROM SessionTable WHERE is_hidden=0 ORDER BY sort_timestamp DESC LIMIT 500" + ).fetchall() + result = [] + for row in rows: + d = dict(row) + u = d.get('username', '') + d['display_name'] = dname(u) + d['avatar'] = contacts.get(u, {}).get('avatar', '') + d['last_time_fmt'] = fmt_ts(d.get('last_timestamp') or d.get('sort_timestamp', 0)) + result.append(d) + con.close() + return {'total': total, 'sessions': result} + except Exception as e: + return {'total': 0, 'sessions': []} + +TYPE_FILTERS = { + 'friends': "local_type=0", + 'contacts': "local_type=1", + 'groups': "local_type IN (2,3) AND username LIKE '%@chatroom%'", + 'members': "local_type=3 AND username NOT LIKE '%@chatroom%'", + 'other': "local_type IN (5,6)", + 'all': "1=1", +} + +def api_stats(_p): + try: + con = sqlite3.connect(CONTACT_DB) + r = con.execute(""" + SELECT + sum(CASE WHEN local_type=0 THEN 1 ELSE 0 END), + sum(CASE WHEN local_type=1 THEN 1 ELSE 0 END), + sum(CASE WHEN local_type IN (2,3) AND username LIKE '%@chatroom%' THEN 1 ELSE 0 END), + sum(CASE WHEN local_type=3 AND username NOT LIKE '%@chatroom%' THEN 1 ELSE 0 END), + sum(CASE WHEN local_type IN (5,6) THEN 1 ELSE 0 END) + FROM contact WHERE delete_flag=0 + """).fetchone() + con.close() + return {'friends': r[0], 'contacts': r[1], 'groups': r[2], + 'members': r[3], 'other': r[4]} + except Exception as e: + return {'error': str(e)} + +def api_contacts(params): + q = params.get('q', [''])[0] + ctype = params.get('type', ['friends'])[0] + sort_by = params.get('sort', ['name'])[0] # name | count | time + page = int(params.get('page', ['1'])[0]) + per_page = int(params.get('per_page', ['200'])[0]) + + where = TYPE_FILTERS.get(ctype, TYPE_FILTERS['friends']) + + try: + con = sqlite3.connect(CONTACT_DB) + con.row_factory = sqlite3.Row + base_where = f"delete_flag=0 AND ({where})" + + if q: + like = f'%{q}%' + search_cond = ("AND (nick_name LIKE ? OR remark LIKE ? OR username LIKE ? " + "OR remark_pin_yin_initial LIKE ? OR pin_yin_initial LIKE ?)") + rows = con.execute( + f"""SELECT username, nick_name, remark, small_head_url, + remark_pin_yin_initial, pin_yin_initial + FROM contact WHERE {base_where} {search_cond}""", + (like,)*5).fetchall() + else: + rows = con.execute( + f"""SELECT username, nick_name, remark, small_head_url, + remark_pin_yin_initial, pin_yin_initial + FROM contact WHERE {base_where}""").fetchall() + con.close() + + items = [] + for row in rows: + name = row['remark'] or row['nick_name'] or row['username'] + if not name: continue + py = (row['remark_pin_yin_initial'] if row['remark'] else row['pin_yin_initial']) or '' + fl = py[0].upper() if py and py[0].isalpha() else '#' + s = chat_stats.get(row['username'], {}) + items.append({ + 'username': row['username'], + 'display_name': name, + 'avatar': row['small_head_url'] or '', + 'first_letter': fl, + 'msg_count': s.get('count', 0), + 'last_time': s.get('last_time', 0), + 'last_time_fmt': fmt_ts(s['last_time']) if s.get('last_time') else '', + }) + + # Sort + if sort_by == 'count': + items.sort(key=lambda x: x['msg_count'], reverse=True) + elif sort_by == 'time': + items.sort(key=lambda x: x['last_time'], reverse=True) + else: + items.sort(key=lambda x: (x['first_letter'], x['display_name'].lower())) + + total = len(items) + offset = (page - 1) * per_page + page_items = items[offset:offset + per_page] + + return {'total': total, 'page': page, 'per_page': per_page, + 'has_more': offset + len(page_items) < total, + 'sort': sort_by, 'contacts': page_items} + except Exception as e: + return {'total': 0, 'contacts': [], 'error': str(e)} + +def api_chat(params): + username = params.get('username', [''])[0] + page = int(params.get('page', ['1'])[0]) + limit = int(params.get('limit', ['50'])[0]) + offset = (page - 1) * limit + if not username: + return {'error': 'username required'} + + h = md5(username) + tbl = f'Msg_{h}' + db_path = table_index.get(h) + if not db_path: + return {'messages': [], 'total': 0, 'display_name': dname(username)} + + try: + con = sqlite3.connect(db_path) + con.row_factory = sqlite3.Row + total = con.execute(f'SELECT count(*) FROM "{tbl}"').fetchone()[0] + rows = con.execute( + f'SELECT * FROM "{tbl}" ORDER BY create_time DESC LIMIT ? OFFSET ?', + (limit, offset) + ).fetchall() + messages = [] + for row in rows: + d = dict(row) + lt = d.get('local_type', 1) + raw = d.get('message_content', '') or '' + sid = d.get('real_sender_id', 0) + sender_raw, text, is_me = parse_msg(lt, raw, sid) + sender_name = '我' if is_me else (dname(sender_raw) if sender_raw else dname(username)) + messages.append({ + 'local_id': d.get('local_id'), + 'local_type': lt, + 'create_time': d.get('create_time', 0), + 'time_fmt': fmt_ts(d.get('create_time', 0)), + 'is_me': is_me, + 'sender_name': sender_name, + 'text': text, + 'type_label': TYPE_LABELS.get(lt, f'[{lt}]'), + }) + con.close() + return { + 'username': username, + 'display_name': dname(username), + 'total': total, + 'page': page, + 'messages': messages, + } + except Exception as e: + return {'error': str(e), 'messages': [], 'total': 0} + +def api_search(params): + q = params.get('q', [''])[0] + limit = int(params.get('limit', ['60'])[0]) + if len(q) < 1: + return {'results': [], 'count': 0} + + results = [] + for db_path in MSG_DBS: + try: + con = sqlite3.connect(db_path) + con.row_factory = sqlite3.Row + tables = [r[0] for r in con.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" + )] + for tbl in tables: + try: + rows = con.execute( + f'SELECT * FROM "{tbl}" WHERE local_type=1 ' + f'AND message_content LIKE ? ORDER BY create_time DESC LIMIT 5', + (f'%{q}%',) + ).fetchall() + h = tbl[4:] + chat_user = md5_to_user.get(h, '') + for row in rows: + d = dict(row) + lt = d.get('local_type', 1) + raw = d.get('message_content', '') or '' + sid = d.get('real_sender_id', 0) + sender_raw, text, is_me = parse_msg(lt, raw, sid) + results.append({ + 'local_id': d.get('local_id'), + 'create_time': d.get('create_time', 0), + 'time_fmt': fmt_ts(d.get('create_time', 0)), + 'is_me': is_me, + 'sender_name': '我' if is_me else (dname(sender_raw) if sender_raw else dname(chat_user)), + 'chat_username': chat_user, + 'chat_name': dname(chat_user) if chat_user else tbl, + 'text': text, + }) + except: pass + con.close() + except: pass + if len(results) >= limit * 2: break + + results.sort(key=lambda x: x.get('create_time', 0), reverse=True) + return {'query': q, 'count': len(results[:limit]), 'results': results[:limit]} + +# ── HTML ─────────────────────────────────────────────────────────────────────── +HTML = r""" + + + + +微信聊天记录 + + + +
+

📱 微信聊天记录

+ + +
+
+ +
+
+

选择一个会话

+ +
+
← 从左侧选择会话,或在顶部搜索消息
+ +
+
+ + +""" + +# ── HTTP handler ─────────────────────────────────────────────────────────────── +ROUTES = { + '/api/sessions': lambda p: api_sessions(p), + '/api/contacts': api_contacts, + '/api/stats': api_stats, + '/api/chat': api_chat, + '/api/search': api_search, +} + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + if parsed.path in ('/', '/index.html'): + body = HTML.encode('utf-8') + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.end_headers() + self.wfile.write(body) + elif parsed.path in ROUTES: + try: data = ROUTES[parsed.path](params) + except Exception as e: data = {'error': str(e)} + body = json.dumps(data, ensure_ascii=False, default=str).encode('utf-8') + self.send_response(200) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404); self.end_headers() + + def log_message(self, fmt, *args): + print(f' {args[1]} {args[0]}') + +if __name__ == '__main__': + print('=' * 50) + print('WeChat 聊天记录搜索工具') + print('=' * 50) + load_contacts() + build_index() + load_chat_stats() + print(f'\n[*] 启动服务: http://localhost:{PORT}') + print(' Ctrl+C 停止\n') + HTTPServer(('localhost', PORT), Handler).serve_forever() diff --git a/wechat_plain/scripts/search/search_server_android.py b/wechat_plain/scripts/search/search_server_android.py new file mode 100644 index 0000000..48145fe --- /dev/null +++ b/wechat_plain/scripts/search/search_server_android.py @@ -0,0 +1,791 @@ +#!/usr/bin/env python3 +""" +WeChat Android EnMicroMsg.db 搜索服务器 +适配单库结构:message / rcontact / conversation 表 + +Usage: + python3 search_server_android.py /path/to/EnMicroMsg.db.decrypted.db + python3 search_server_android.py # 默认 ~/wxmoment/dbs/EnMicroMsg.db.decrypted.db +""" +import sqlite3, json, os, hashlib, re, time, sys +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +PORT = 5002 + +# ── DB path ─────────────────────────────────────────────────────────────────── +if len(sys.argv) > 1: + DB_PATH = sys.argv[1] +else: + DB_PATH = os.path.expanduser("~/wxmoment/dbs/EnMicroMsg.db.decrypted.db") + +if not os.path.exists(DB_PATH): + print(f"[!] DB not found: {DB_PATH}") + print("Usage: python3 search_server_android.py ") + sys.exit(1) + +# ── Type labels ─────────────────────────────────────────────────────────────── +TYPE_LABELS = { + 1: '文字', + 3: '图片', + 34: '语音', + 43: '视频', + 47: '表情', + 49: '链接/文件', + 50: '视频通话', + 10000: '系统消息', + 436207665: '红包', + 469762048: '转账', + 822083633: '引用回复', + 754974769: '拍一拍', + 570425393: '语音通话', + 1090519089: '文件', + 268445456: '位置', + 285212721: '合并转发', +} + +# ── Data loading ────────────────────────────────────────────────────────────── +contacts = {} # username -> {nickname, remark, avatar} +chat_stats = {} # username -> {count, last_time} + + +def load_contacts(): + try: + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + for row in con.execute( + "SELECT username, nickname, conRemark, alias FROM rcontact WHERE deleteFlag=0" + ): + u = row['username'] + contacts[u] = { + 'nickname': row['nickname'] or '', + 'remark': row['conRemark'] or '', + 'alias': row['alias'] or '', + } + con.close() + print(f'[+] {len(contacts)} contacts loaded') + except Exception as e: + print(f'[!] contacts: {e}') + + +def build_chat_stats(): + try: + con = sqlite3.connect(DB_PATH) + for row in con.execute( + "SELECT talker, COUNT(*) as cnt, MAX(createTime) as last_time FROM message GROUP BY talker" + ): + chat_stats[row[0]] = {'count': row[1], 'last_time': row[2] or 0} + con.close() + print(f'[+] {len(chat_stats)} chat stats loaded') + except Exception as e: + print(f'[!] chat_stats: {e}') + + +def dname(username): + if not username: + return '未知' + c = contacts.get(username, {}) + return c.get('remark') or c.get('nickname') or username + + +def fmt_ts(ts): + try: + ts = int(ts) + if ts > 1e12: + ts //= 1000 + return time.strftime('%Y-%m-%d %H:%M', time.localtime(ts)) + except: + return '' + + +def parse_msg(msg_type, raw_content, is_send): + """Return (sender_username_or_empty, clean_text, is_sent_by_me)""" + is_me = int(is_send or 0) == 1 + if msg_type != 1: + label = TYPE_LABELS.get(msg_type, f'[类型{msg_type}]') + if msg_type == 49 and isinstance(raw_content, str): + m = re.search(r'(.*?)', raw_content, re.S) + if m: + label = f'[链接] {m.group(1)[:60]}' + return ('', label, is_me) + + if not raw_content: + return ('', '', is_me) + if isinstance(raw_content, bytes): + try: + raw_content = raw_content.decode('utf-8', errors='replace') + except: + return ('', '[二进制内容]', is_me) + + # Group/received messages: "username:\nbody" or "username:\n\nbody" + if not is_me and ':' in raw_content[:100]: + idx = raw_content.index(':') + sender_name = raw_content[:idx] + text = raw_content[idx + 1:].lstrip('\n') + return (sender_name, text, False) + + return ('', raw_content, is_me) + + +# ── API ─────────────────────────────────────────────────────────────────────── + +def api_sessions(_p): + try: + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + # conversation table may be empty; fallback to message talkers + rows = con.execute( + """SELECT talker as username, MAX(createTime) as createTime, COUNT(*) as cnt + FROM message GROUP BY talker ORDER BY createTime DESC LIMIT 500""" + ).fetchall() + result = [] + for row in rows: + d = dict(row) + u = d.get('username', '') + d['display_name'] = dname(u) + s = chat_stats.get(u, {}) + d['msg_count'] = s.get('count', d.get('cnt', 0)) + d['last_time_fmt'] = fmt_ts(d.get('createTime', 0)) + result.append(d) + con.close() + return {'total': len(result), 'sessions': result} + except Exception as e: + return {'total': 0, 'sessions': [], 'error': str(e)} + + +def api_stats(_p): + try: + con = sqlite3.connect(DB_PATH) + # type: 1=好友, 2=群聊成员, 3=公众号, etc. + r = con.execute(""" + SELECT + SUM(CASE WHEN type=1 THEN 1 ELSE 0 END), + SUM(CASE WHEN type=3 THEN 1 ELSE 0 END), + SUM(CASE WHEN type=2 THEN 1 ELSE 0 END), + SUM(CASE WHEN type NOT IN (0,1,2,3,4,33) THEN 1 ELSE 0 END) + FROM rcontact WHERE deleteFlag=0 + """).fetchone() + con.close() + return {'friends': r[0] or 0, 'official': r[1] or 0, 'group_members': r[2] or 0, 'other': r[3] or 0} + except Exception as e: + return {'error': str(e)} + + +def api_contacts(params): + q = params.get('q', [''])[0] + ctype = params.get('type', ['friends'])[0] + sort_by = params.get('sort', ['name'])[0] + page = int(params.get('page', ['1'])[0]) + per_page = int(params.get('per_page', ['200'])[0]) + + type_map = { + 'friends': 'type=1', + 'official': 'type=3', + 'groups': "username LIKE '%@chatroom%'", + 'other': 'type NOT IN (0,1,2,3,4,33)', + 'all': '1=1', + } + where = type_map.get(ctype, type_map['friends']) + + try: + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + base_where = f"deleteFlag=0 AND ({where})" + + if q: + like = f'%{q}%' + rows = con.execute( + f"""SELECT username, nickname, conRemark, alias, pyInitial, quanPin + FROM rcontact WHERE {base_where} + AND (nickname LIKE ? OR conRemark LIKE ? OR username LIKE ? + OR alias LIKE ? OR pyInitial LIKE ? OR quanPin LIKE ?)""", + (like,) * 6 + ).fetchall() + else: + rows = con.execute( + f"""SELECT username, nickname, conRemark, alias, pyInitial, quanPin + FROM rcontact WHERE {base_where}""" + ).fetchall() + con.close() + + items = [] + for row in rows: + name = row['conRemark'] or row['nickname'] or row['username'] + if not name: + continue + py = row['pyInitial'] or '' + fl = py[0].upper() if py and py[0].isalpha() else '#' + s = chat_stats.get(row['username'], {}) + items.append({ + 'username': row['username'], + 'display_name': name, + 'first_letter': fl, + 'msg_count': s.get('count', 0), + 'last_time': s.get('last_time', 0), + 'last_time_fmt': fmt_ts(s.get('last_time', 0)), + }) + + if sort_by == 'count': + items.sort(key=lambda x: x['msg_count'], reverse=True) + elif sort_by == 'time': + items.sort(key=lambda x: x['last_time'], reverse=True) + else: + items.sort(key=lambda x: (x['first_letter'], x['display_name'].lower())) + + total = len(items) + offset = (page - 1) * per_page + page_items = items[offset:offset + per_page] + + return {'total': total, 'page': page, 'per_page': per_page, + 'has_more': offset + len(page_items) < total, + 'sort': sort_by, 'contacts': page_items} + except Exception as e: + return {'total': 0, 'contacts': [], 'error': str(e)} + + +def api_chat(params): + username = params.get('username', [''])[0] + page = int(params.get('page', ['1'])[0]) + limit = int(params.get('limit', ['50'])[0]) + offset = (page - 1) * limit + if not username: + return {'error': 'username required'} + + try: + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + total = con.execute('SELECT COUNT(*) FROM message WHERE talker = ?', (username,)).fetchone()[0] + rows = con.execute( + 'SELECT * FROM message WHERE talker = ? ORDER BY createTime DESC LIMIT ? OFFSET ?', + (username, limit, offset) + ).fetchall() + messages = [] + for row in rows: + d = dict(row) + lt = d.get('type', 1) + raw = d.get('content', '') or '' + sid = d.get('isSend', 0) + sender_raw, text, is_me = parse_msg(lt, raw, sid) + sender_name = '我' if is_me else (dname(sender_raw) if sender_raw else dname(username)) + messages.append({ + 'msgId': d.get('msgId'), + 'type': lt, + 'createTime': d.get('createTime', 0), + 'time_fmt': fmt_ts(d.get('createTime', 0)), + 'is_me': is_me, + 'sender_name': sender_name, + 'text': text, + 'type_label': TYPE_LABELS.get(lt, f'[{lt}]'), + }) + con.close() + return { + 'username': username, + 'display_name': dname(username), + 'total': total, + 'page': page, + 'messages': messages, + } + except Exception as e: + return {'error': str(e), 'messages': [], 'total': 0} + + +def api_search(params): + q = params.get('q', [''])[0] + limit = int(params.get('limit', ['60'])[0]) + if len(q) < 1: + return {'results': [], 'count': 0} + + results = [] + try: + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + rows = con.execute( + 'SELECT * FROM message WHERE type = 1 AND content LIKE ? ORDER BY createTime DESC LIMIT ?', + (f'%{q}%', limit * 2) + ).fetchall() + for row in rows: + d = dict(row) + lt = d.get('type', 1) + raw = d.get('content', '') or '' + sid = d.get('isSend', 0) + sender_raw, text, is_me = parse_msg(lt, raw, sid) + chat_user = d.get('talker', '') + results.append({ + 'msgId': d.get('msgId'), + 'createTime': d.get('createTime', 0), + 'time_fmt': fmt_ts(d.get('createTime', 0)), + 'is_me': is_me, + 'sender_name': '我' if is_me else (dname(sender_raw) if sender_raw else dname(chat_user)), + 'chat_username': chat_user, + 'chat_name': dname(chat_user), + 'text': text, + }) + con.close() + except Exception as e: + return {'error': str(e), 'results': [], 'count': 0} + + results.sort(key=lambda x: x.get('createTime', 0), reverse=True) + return {'query': q, 'count': len(results[:limit]), 'results': results[:limit]} + + +# ── HTML ────────────────────────────────────────────────────────────────────── +HTML = r""" + + + + +微信聊天记录 (Android) + + + +
+

📱 微信聊天记录

+ + +
+
+ +
+
+

选择一个会话

+ +
+
← 从左侧选择会话,或在顶部搜索消息
+ +
+
+ + +""" + +# ── HTTP handler ─────────────────────────────────────────────────────────────── +ROUTES = { + '/api/sessions': lambda p: api_sessions(p), + '/api/contacts': api_contacts, + '/api/stats': api_stats, + '/api/chat': api_chat, + '/api/search': api_search, +} + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + if parsed.path in ('/', '/index.html'): + body = HTML.encode('utf-8') + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.end_headers() + self.wfile.write(body) + elif parsed.path in ROUTES: + try: + data = ROUTES[parsed.path](params) + except Exception as e: + data = {'error': str(e)} + body = json.dumps(data, ensure_ascii=False, default=str).encode('utf-8') + self.send_response(200) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, fmt, *args): + print(f' {args[1]} {args[0]}') + + +if __name__ == '__main__': + print('=' * 50) + print('WeChat Android 聊天记录搜索工具') + print(f'DB: {DB_PATH}') + print('=' * 50) + load_contacts() + build_chat_stats() + print(f'\n[*] 启动服务: http://localhost:{PORT}') + print(' Ctrl+C 停止\n') + HTTPServer(('localhost', PORT), Handler).serve_forever() diff --git a/wechat_plain/scripts/search_server.py b/wechat_plain/scripts/search_server.py deleted file mode 100644 index 964c66d..0000000 --- a/wechat_plain/scripts/search_server.py +++ /dev/null @@ -1,800 +0,0 @@ -#!/usr/bin/env python3 -""" -WeChat 聊天记录搜索工具 -用法: python3 search_server.py -访问: http://localhost:5001 -""" -import sqlite3, json, os, hashlib, re, time -from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import urlparse, parse_qs - -PORT = 5001 -DB_DIR = '/Users/yibo/Documents/wechat_plain' -CONTACT_DB = f'{DB_DIR}/contact_contact.db' -SESSION_DB = f'{DB_DIR}/session_session.db' -MSG_DBS = [f'{DB_DIR}/message_message_{i}.db' for i in range(11) - if os.path.exists(f'{DB_DIR}/message_message_{i}.db')] -MY_IDS = {15, 543} # real_sender_id values that mean "sent by me" - -TYPE_LABELS = { - 1: '文字', 3: '图片', 34: '语音', 43: '视频', 47: '表情', - 49: '链接/文件', 50: '视频通话', 10000: '系统消息', - 436207665: '红包', 469762048: '转账', -} - -# ── Data loading ─────────────────────────────────────────────────────────────── -contacts = {} # username → {nickname, remark, avatar} -md5_to_user = {} # md5(username) → username -table_index = {} # md5(username) → db_path -chat_stats = {} # username → {count, last_time, first_time} - -def md5(s): - return hashlib.md5(s.encode()).hexdigest() - -def load_contacts(): - try: - con = sqlite3.connect(CONTACT_DB) - con.row_factory = sqlite3.Row - for row in con.execute( - "SELECT username, nick_name, remark, small_head_url FROM contact WHERE delete_flag=0" - ): - u = row['username'] - contacts[u] = { - 'nickname': row['nick_name'] or '', - 'remark': row['remark'] or '', - 'avatar': row['small_head_url'] or '', - } - md5_to_user[md5(u)] = u - con.close() - print(f'[+] {len(contacts)} contacts') - except Exception as e: - print(f'[!] contacts: {e}') - -def build_index(): - for db_path in MSG_DBS: - try: - con = sqlite3.connect(db_path) - for (t,) in con.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" - ): - h = t[4:] - if h not in table_index: - table_index[h] = db_path - con.close() - except: pass - print(f'[+] {len(table_index)} message tables indexed') - -def load_chat_stats(): - path = f'{DB_DIR}/chat_stats.json' - if not os.path.exists(path): - print('[!] chat_stats.json not found, run build_chat_stats.py first') - return - with open(path) as f: - chat_stats.update(json.load(f)) - print(f'[+] {len(chat_stats)} chat stat entries loaded') - -def dname(username): - if not username: return '未知' - c = contacts.get(username, {}) - return c.get('remark') or c.get('nickname') or username - -def fmt_ts(ts): - try: - ts = int(ts) - if ts > 1e12: ts //= 1000 - return time.strftime('%Y-%m-%d %H:%M', time.localtime(ts)) - except: return '' - -def parse_msg(local_type, raw_content, real_sender_id): - """Return (sender_username_or_empty, clean_text, is_sent_by_me)""" - is_me = int(real_sender_id or 0) in MY_IDS - if local_type != 1: - label = TYPE_LABELS.get(local_type, f'[类型{local_type}]') - if local_type == 49 and isinstance(raw_content, str): - m = re.search(r'(.*?)', raw_content, re.S) - if m: label = f'[链接] {m.group(1)[:60]}' - return ('', label, is_me) - - if not raw_content: - return ('', '', is_me) - if isinstance(raw_content, bytes): - try: raw_content = raw_content.decode('utf-8', errors='replace') - except: return ('', '[二进制内容]', is_me) - - # Group/received messages: "username:\ntext" - if not is_me and ':\n' in raw_content[:100]: - idx = raw_content.index(':\n') - sender_name = raw_content[:idx] - text = raw_content[idx+2:] - return (sender_name, text, False) - - return ('', raw_content, is_me) - -# ── API ──────────────────────────────────────────────────────────────────────── - -def api_sessions(_p): - try: - con = sqlite3.connect(SESSION_DB) - con.row_factory = sqlite3.Row - total = con.execute("SELECT count(*) FROM SessionTable WHERE is_hidden=0").fetchone()[0] - rows = con.execute( - "SELECT * FROM SessionTable WHERE is_hidden=0 ORDER BY sort_timestamp DESC LIMIT 500" - ).fetchall() - result = [] - for row in rows: - d = dict(row) - u = d.get('username', '') - d['display_name'] = dname(u) - d['avatar'] = contacts.get(u, {}).get('avatar', '') - d['last_time_fmt'] = fmt_ts(d.get('last_timestamp') or d.get('sort_timestamp', 0)) - result.append(d) - con.close() - return {'total': total, 'sessions': result} - except Exception as e: - return {'total': 0, 'sessions': []} - -TYPE_FILTERS = { - 'friends': "local_type=0", - 'contacts': "local_type=1", - 'groups': "local_type IN (2,3) AND username LIKE '%@chatroom%'", - 'members': "local_type=3 AND username NOT LIKE '%@chatroom%'", - 'other': "local_type IN (5,6)", - 'all': "1=1", -} - -def api_stats(_p): - try: - con = sqlite3.connect(CONTACT_DB) - r = con.execute(""" - SELECT - sum(CASE WHEN local_type=0 THEN 1 ELSE 0 END), - sum(CASE WHEN local_type=1 THEN 1 ELSE 0 END), - sum(CASE WHEN local_type IN (2,3) AND username LIKE '%@chatroom%' THEN 1 ELSE 0 END), - sum(CASE WHEN local_type=3 AND username NOT LIKE '%@chatroom%' THEN 1 ELSE 0 END), - sum(CASE WHEN local_type IN (5,6) THEN 1 ELSE 0 END) - FROM contact WHERE delete_flag=0 - """).fetchone() - con.close() - return {'friends': r[0], 'contacts': r[1], 'groups': r[2], - 'members': r[3], 'other': r[4]} - except Exception as e: - return {'error': str(e)} - -def api_contacts(params): - q = params.get('q', [''])[0] - ctype = params.get('type', ['friends'])[0] - sort_by = params.get('sort', ['name'])[0] # name | count | time - page = int(params.get('page', ['1'])[0]) - per_page = int(params.get('per_page', ['200'])[0]) - - where = TYPE_FILTERS.get(ctype, TYPE_FILTERS['friends']) - - try: - con = sqlite3.connect(CONTACT_DB) - con.row_factory = sqlite3.Row - base_where = f"delete_flag=0 AND ({where})" - - if q: - like = f'%{q}%' - search_cond = ("AND (nick_name LIKE ? OR remark LIKE ? OR username LIKE ? " - "OR remark_pin_yin_initial LIKE ? OR pin_yin_initial LIKE ?)") - rows = con.execute( - f"""SELECT username, nick_name, remark, small_head_url, - remark_pin_yin_initial, pin_yin_initial - FROM contact WHERE {base_where} {search_cond}""", - (like,)*5).fetchall() - else: - rows = con.execute( - f"""SELECT username, nick_name, remark, small_head_url, - remark_pin_yin_initial, pin_yin_initial - FROM contact WHERE {base_where}""").fetchall() - con.close() - - items = [] - for row in rows: - name = row['remark'] or row['nick_name'] or row['username'] - if not name: continue - py = (row['remark_pin_yin_initial'] if row['remark'] else row['pin_yin_initial']) or '' - fl = py[0].upper() if py and py[0].isalpha() else '#' - s = chat_stats.get(row['username'], {}) - items.append({ - 'username': row['username'], - 'display_name': name, - 'avatar': row['small_head_url'] or '', - 'first_letter': fl, - 'msg_count': s.get('count', 0), - 'last_time': s.get('last_time', 0), - 'last_time_fmt': fmt_ts(s['last_time']) if s.get('last_time') else '', - }) - - # Sort - if sort_by == 'count': - items.sort(key=lambda x: x['msg_count'], reverse=True) - elif sort_by == 'time': - items.sort(key=lambda x: x['last_time'], reverse=True) - else: - items.sort(key=lambda x: (x['first_letter'], x['display_name'].lower())) - - total = len(items) - offset = (page - 1) * per_page - page_items = items[offset:offset + per_page] - - return {'total': total, 'page': page, 'per_page': per_page, - 'has_more': offset + len(page_items) < total, - 'sort': sort_by, 'contacts': page_items} - except Exception as e: - return {'total': 0, 'contacts': [], 'error': str(e)} - -def api_chat(params): - username = params.get('username', [''])[0] - page = int(params.get('page', ['1'])[0]) - limit = int(params.get('limit', ['50'])[0]) - offset = (page - 1) * limit - if not username: - return {'error': 'username required'} - - h = md5(username) - tbl = f'Msg_{h}' - db_path = table_index.get(h) - if not db_path: - return {'messages': [], 'total': 0, 'display_name': dname(username)} - - try: - con = sqlite3.connect(db_path) - con.row_factory = sqlite3.Row - total = con.execute(f'SELECT count(*) FROM "{tbl}"').fetchone()[0] - rows = con.execute( - f'SELECT * FROM "{tbl}" ORDER BY create_time DESC LIMIT ? OFFSET ?', - (limit, offset) - ).fetchall() - messages = [] - for row in rows: - d = dict(row) - lt = d.get('local_type', 1) - raw = d.get('message_content', '') or '' - sid = d.get('real_sender_id', 0) - sender_raw, text, is_me = parse_msg(lt, raw, sid) - sender_name = '我' if is_me else (dname(sender_raw) if sender_raw else dname(username)) - messages.append({ - 'local_id': d.get('local_id'), - 'local_type': lt, - 'create_time': d.get('create_time', 0), - 'time_fmt': fmt_ts(d.get('create_time', 0)), - 'is_me': is_me, - 'sender_name': sender_name, - 'text': text, - 'type_label': TYPE_LABELS.get(lt, f'[{lt}]'), - }) - con.close() - return { - 'username': username, - 'display_name': dname(username), - 'total': total, - 'page': page, - 'messages': messages, - } - except Exception as e: - return {'error': str(e), 'messages': [], 'total': 0} - -def api_search(params): - q = params.get('q', [''])[0] - limit = int(params.get('limit', ['60'])[0]) - if len(q) < 1: - return {'results': [], 'count': 0} - - results = [] - for db_path in MSG_DBS: - try: - con = sqlite3.connect(db_path) - con.row_factory = sqlite3.Row - tables = [r[0] for r in con.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" - )] - for tbl in tables: - try: - rows = con.execute( - f'SELECT * FROM "{tbl}" WHERE local_type=1 ' - f'AND message_content LIKE ? ORDER BY create_time DESC LIMIT 5', - (f'%{q}%',) - ).fetchall() - h = tbl[4:] - chat_user = md5_to_user.get(h, '') - for row in rows: - d = dict(row) - lt = d.get('local_type', 1) - raw = d.get('message_content', '') or '' - sid = d.get('real_sender_id', 0) - sender_raw, text, is_me = parse_msg(lt, raw, sid) - results.append({ - 'local_id': d.get('local_id'), - 'create_time': d.get('create_time', 0), - 'time_fmt': fmt_ts(d.get('create_time', 0)), - 'is_me': is_me, - 'sender_name': '我' if is_me else (dname(sender_raw) if sender_raw else dname(chat_user)), - 'chat_username': chat_user, - 'chat_name': dname(chat_user) if chat_user else tbl, - 'text': text, - }) - except: pass - con.close() - except: pass - if len(results) >= limit * 2: break - - results.sort(key=lambda x: x.get('create_time', 0), reverse=True) - return {'query': q, 'count': len(results[:limit]), 'results': results[:limit]} - -# ── HTML ─────────────────────────────────────────────────────────────────────── -HTML = r""" - - - - -微信聊天记录 - - - -
-

📱 微信聊天记录

- - -
-
- -
-
-

选择一个会话

- -
-
← 从左侧选择会话,或在顶部搜索消息
- -
-
- - -""" - -# ── HTTP handler ─────────────────────────────────────────────────────────────── -ROUTES = { - '/api/sessions': lambda p: api_sessions(p), - '/api/contacts': api_contacts, - '/api/stats': api_stats, - '/api/chat': api_chat, - '/api/search': api_search, -} - -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - parsed = urlparse(self.path) - params = parse_qs(parsed.query) - if parsed.path in ('/', '/index.html'): - body = HTML.encode('utf-8') - self.send_response(200) - self.send_header('Content-Type', 'text/html; charset=utf-8') - self.end_headers() - self.wfile.write(body) - elif parsed.path in ROUTES: - try: data = ROUTES[parsed.path](params) - except Exception as e: data = {'error': str(e)} - body = json.dumps(data, ensure_ascii=False, default=str).encode('utf-8') - self.send_response(200) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.send_header('Access-Control-Allow-Origin', '*') - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404); self.end_headers() - - def log_message(self, fmt, *args): - print(f' {args[1]} {args[0]}') - -if __name__ == '__main__': - print('=' * 50) - print('WeChat 聊天记录搜索工具') - print('=' * 50) - load_contacts() - build_index() - load_chat_stats() - print(f'\n[*] 启动服务: http://localhost:{PORT}') - print(' Ctrl+C 停止\n') - HTTPServer(('localhost', PORT), Handler).serve_forever() diff --git a/wechat_plain/scripts/search_server_android.py b/wechat_plain/scripts/search_server_android.py deleted file mode 100644 index 48145fe..0000000 --- a/wechat_plain/scripts/search_server_android.py +++ /dev/null @@ -1,791 +0,0 @@ -#!/usr/bin/env python3 -""" -WeChat Android EnMicroMsg.db 搜索服务器 -适配单库结构:message / rcontact / conversation 表 - -Usage: - python3 search_server_android.py /path/to/EnMicroMsg.db.decrypted.db - python3 search_server_android.py # 默认 ~/wxmoment/dbs/EnMicroMsg.db.decrypted.db -""" -import sqlite3, json, os, hashlib, re, time, sys -from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import urlparse, parse_qs - -PORT = 5002 - -# ── DB path ─────────────────────────────────────────────────────────────────── -if len(sys.argv) > 1: - DB_PATH = sys.argv[1] -else: - DB_PATH = os.path.expanduser("~/wxmoment/dbs/EnMicroMsg.db.decrypted.db") - -if not os.path.exists(DB_PATH): - print(f"[!] DB not found: {DB_PATH}") - print("Usage: python3 search_server_android.py ") - sys.exit(1) - -# ── Type labels ─────────────────────────────────────────────────────────────── -TYPE_LABELS = { - 1: '文字', - 3: '图片', - 34: '语音', - 43: '视频', - 47: '表情', - 49: '链接/文件', - 50: '视频通话', - 10000: '系统消息', - 436207665: '红包', - 469762048: '转账', - 822083633: '引用回复', - 754974769: '拍一拍', - 570425393: '语音通话', - 1090519089: '文件', - 268445456: '位置', - 285212721: '合并转发', -} - -# ── Data loading ────────────────────────────────────────────────────────────── -contacts = {} # username -> {nickname, remark, avatar} -chat_stats = {} # username -> {count, last_time} - - -def load_contacts(): - try: - con = sqlite3.connect(DB_PATH) - con.row_factory = sqlite3.Row - for row in con.execute( - "SELECT username, nickname, conRemark, alias FROM rcontact WHERE deleteFlag=0" - ): - u = row['username'] - contacts[u] = { - 'nickname': row['nickname'] or '', - 'remark': row['conRemark'] or '', - 'alias': row['alias'] or '', - } - con.close() - print(f'[+] {len(contacts)} contacts loaded') - except Exception as e: - print(f'[!] contacts: {e}') - - -def build_chat_stats(): - try: - con = sqlite3.connect(DB_PATH) - for row in con.execute( - "SELECT talker, COUNT(*) as cnt, MAX(createTime) as last_time FROM message GROUP BY talker" - ): - chat_stats[row[0]] = {'count': row[1], 'last_time': row[2] or 0} - con.close() - print(f'[+] {len(chat_stats)} chat stats loaded') - except Exception as e: - print(f'[!] chat_stats: {e}') - - -def dname(username): - if not username: - return '未知' - c = contacts.get(username, {}) - return c.get('remark') or c.get('nickname') or username - - -def fmt_ts(ts): - try: - ts = int(ts) - if ts > 1e12: - ts //= 1000 - return time.strftime('%Y-%m-%d %H:%M', time.localtime(ts)) - except: - return '' - - -def parse_msg(msg_type, raw_content, is_send): - """Return (sender_username_or_empty, clean_text, is_sent_by_me)""" - is_me = int(is_send or 0) == 1 - if msg_type != 1: - label = TYPE_LABELS.get(msg_type, f'[类型{msg_type}]') - if msg_type == 49 and isinstance(raw_content, str): - m = re.search(r'(.*?)', raw_content, re.S) - if m: - label = f'[链接] {m.group(1)[:60]}' - return ('', label, is_me) - - if not raw_content: - return ('', '', is_me) - if isinstance(raw_content, bytes): - try: - raw_content = raw_content.decode('utf-8', errors='replace') - except: - return ('', '[二进制内容]', is_me) - - # Group/received messages: "username:\nbody" or "username:\n\nbody" - if not is_me and ':' in raw_content[:100]: - idx = raw_content.index(':') - sender_name = raw_content[:idx] - text = raw_content[idx + 1:].lstrip('\n') - return (sender_name, text, False) - - return ('', raw_content, is_me) - - -# ── API ─────────────────────────────────────────────────────────────────────── - -def api_sessions(_p): - try: - con = sqlite3.connect(DB_PATH) - con.row_factory = sqlite3.Row - # conversation table may be empty; fallback to message talkers - rows = con.execute( - """SELECT talker as username, MAX(createTime) as createTime, COUNT(*) as cnt - FROM message GROUP BY talker ORDER BY createTime DESC LIMIT 500""" - ).fetchall() - result = [] - for row in rows: - d = dict(row) - u = d.get('username', '') - d['display_name'] = dname(u) - s = chat_stats.get(u, {}) - d['msg_count'] = s.get('count', d.get('cnt', 0)) - d['last_time_fmt'] = fmt_ts(d.get('createTime', 0)) - result.append(d) - con.close() - return {'total': len(result), 'sessions': result} - except Exception as e: - return {'total': 0, 'sessions': [], 'error': str(e)} - - -def api_stats(_p): - try: - con = sqlite3.connect(DB_PATH) - # type: 1=好友, 2=群聊成员, 3=公众号, etc. - r = con.execute(""" - SELECT - SUM(CASE WHEN type=1 THEN 1 ELSE 0 END), - SUM(CASE WHEN type=3 THEN 1 ELSE 0 END), - SUM(CASE WHEN type=2 THEN 1 ELSE 0 END), - SUM(CASE WHEN type NOT IN (0,1,2,3,4,33) THEN 1 ELSE 0 END) - FROM rcontact WHERE deleteFlag=0 - """).fetchone() - con.close() - return {'friends': r[0] or 0, 'official': r[1] or 0, 'group_members': r[2] or 0, 'other': r[3] or 0} - except Exception as e: - return {'error': str(e)} - - -def api_contacts(params): - q = params.get('q', [''])[0] - ctype = params.get('type', ['friends'])[0] - sort_by = params.get('sort', ['name'])[0] - page = int(params.get('page', ['1'])[0]) - per_page = int(params.get('per_page', ['200'])[0]) - - type_map = { - 'friends': 'type=1', - 'official': 'type=3', - 'groups': "username LIKE '%@chatroom%'", - 'other': 'type NOT IN (0,1,2,3,4,33)', - 'all': '1=1', - } - where = type_map.get(ctype, type_map['friends']) - - try: - con = sqlite3.connect(DB_PATH) - con.row_factory = sqlite3.Row - base_where = f"deleteFlag=0 AND ({where})" - - if q: - like = f'%{q}%' - rows = con.execute( - f"""SELECT username, nickname, conRemark, alias, pyInitial, quanPin - FROM rcontact WHERE {base_where} - AND (nickname LIKE ? OR conRemark LIKE ? OR username LIKE ? - OR alias LIKE ? OR pyInitial LIKE ? OR quanPin LIKE ?)""", - (like,) * 6 - ).fetchall() - else: - rows = con.execute( - f"""SELECT username, nickname, conRemark, alias, pyInitial, quanPin - FROM rcontact WHERE {base_where}""" - ).fetchall() - con.close() - - items = [] - for row in rows: - name = row['conRemark'] or row['nickname'] or row['username'] - if not name: - continue - py = row['pyInitial'] or '' - fl = py[0].upper() if py and py[0].isalpha() else '#' - s = chat_stats.get(row['username'], {}) - items.append({ - 'username': row['username'], - 'display_name': name, - 'first_letter': fl, - 'msg_count': s.get('count', 0), - 'last_time': s.get('last_time', 0), - 'last_time_fmt': fmt_ts(s.get('last_time', 0)), - }) - - if sort_by == 'count': - items.sort(key=lambda x: x['msg_count'], reverse=True) - elif sort_by == 'time': - items.sort(key=lambda x: x['last_time'], reverse=True) - else: - items.sort(key=lambda x: (x['first_letter'], x['display_name'].lower())) - - total = len(items) - offset = (page - 1) * per_page - page_items = items[offset:offset + per_page] - - return {'total': total, 'page': page, 'per_page': per_page, - 'has_more': offset + len(page_items) < total, - 'sort': sort_by, 'contacts': page_items} - except Exception as e: - return {'total': 0, 'contacts': [], 'error': str(e)} - - -def api_chat(params): - username = params.get('username', [''])[0] - page = int(params.get('page', ['1'])[0]) - limit = int(params.get('limit', ['50'])[0]) - offset = (page - 1) * limit - if not username: - return {'error': 'username required'} - - try: - con = sqlite3.connect(DB_PATH) - con.row_factory = sqlite3.Row - total = con.execute('SELECT COUNT(*) FROM message WHERE talker = ?', (username,)).fetchone()[0] - rows = con.execute( - 'SELECT * FROM message WHERE talker = ? ORDER BY createTime DESC LIMIT ? OFFSET ?', - (username, limit, offset) - ).fetchall() - messages = [] - for row in rows: - d = dict(row) - lt = d.get('type', 1) - raw = d.get('content', '') or '' - sid = d.get('isSend', 0) - sender_raw, text, is_me = parse_msg(lt, raw, sid) - sender_name = '我' if is_me else (dname(sender_raw) if sender_raw else dname(username)) - messages.append({ - 'msgId': d.get('msgId'), - 'type': lt, - 'createTime': d.get('createTime', 0), - 'time_fmt': fmt_ts(d.get('createTime', 0)), - 'is_me': is_me, - 'sender_name': sender_name, - 'text': text, - 'type_label': TYPE_LABELS.get(lt, f'[{lt}]'), - }) - con.close() - return { - 'username': username, - 'display_name': dname(username), - 'total': total, - 'page': page, - 'messages': messages, - } - except Exception as e: - return {'error': str(e), 'messages': [], 'total': 0} - - -def api_search(params): - q = params.get('q', [''])[0] - limit = int(params.get('limit', ['60'])[0]) - if len(q) < 1: - return {'results': [], 'count': 0} - - results = [] - try: - con = sqlite3.connect(DB_PATH) - con.row_factory = sqlite3.Row - rows = con.execute( - 'SELECT * FROM message WHERE type = 1 AND content LIKE ? ORDER BY createTime DESC LIMIT ?', - (f'%{q}%', limit * 2) - ).fetchall() - for row in rows: - d = dict(row) - lt = d.get('type', 1) - raw = d.get('content', '') or '' - sid = d.get('isSend', 0) - sender_raw, text, is_me = parse_msg(lt, raw, sid) - chat_user = d.get('talker', '') - results.append({ - 'msgId': d.get('msgId'), - 'createTime': d.get('createTime', 0), - 'time_fmt': fmt_ts(d.get('createTime', 0)), - 'is_me': is_me, - 'sender_name': '我' if is_me else (dname(sender_raw) if sender_raw else dname(chat_user)), - 'chat_username': chat_user, - 'chat_name': dname(chat_user), - 'text': text, - }) - con.close() - except Exception as e: - return {'error': str(e), 'results': [], 'count': 0} - - results.sort(key=lambda x: x.get('createTime', 0), reverse=True) - return {'query': q, 'count': len(results[:limit]), 'results': results[:limit]} - - -# ── HTML ────────────────────────────────────────────────────────────────────── -HTML = r""" - - - - -微信聊天记录 (Android) - - - -
-

📱 微信聊天记录

- - -
-
- -
-
-

选择一个会话

- -
-
← 从左侧选择会话,或在顶部搜索消息
- -
-
- - -""" - -# ── HTTP handler ─────────────────────────────────────────────────────────────── -ROUTES = { - '/api/sessions': lambda p: api_sessions(p), - '/api/contacts': api_contacts, - '/api/stats': api_stats, - '/api/chat': api_chat, - '/api/search': api_search, -} - - -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - parsed = urlparse(self.path) - params = parse_qs(parsed.query) - if parsed.path in ('/', '/index.html'): - body = HTML.encode('utf-8') - self.send_response(200) - self.send_header('Content-Type', 'text/html; charset=utf-8') - self.end_headers() - self.wfile.write(body) - elif parsed.path in ROUTES: - try: - data = ROUTES[parsed.path](params) - except Exception as e: - data = {'error': str(e)} - body = json.dumps(data, ensure_ascii=False, default=str).encode('utf-8') - self.send_response(200) - self.send_header('Content-Type', 'application/json; charset=utf-8') - self.send_header('Access-Control-Allow-Origin', '*') - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, fmt, *args): - print(f' {args[1]} {args[0]}') - - -if __name__ == '__main__': - print('=' * 50) - print('WeChat Android 聊天记录搜索工具') - print(f'DB: {DB_PATH}') - print('=' * 50) - load_contacts() - build_chat_stats() - print(f'\n[*] 启动服务: http://localhost:{PORT}') - print(' Ctrl+C 停止\n') - HTTPServer(('localhost', PORT), Handler).serve_forever() diff --git a/wechat_plain/scripts/wechat-db-decrypt.html b/wechat_plain/scripts/wechat-db-decrypt.html deleted file mode 100644 index 9ef76ca..0000000 --- a/wechat_plain/scripts/wechat-db-decrypt.html +++ /dev/null @@ -1,753 +0,0 @@ - - - - - -macOS 微信数据库解密 — 完整记录 - - - - - - - - -
- - -
-

macOS 微信数据库解密

-

- 通过 Frida hook CCCryptorCreate,在运行时拦截 WeChat 传入 SQLCipher 的 AES enc_key,无需逆向二进制、无需 root、不触碰 Keychain。 -

-
- ✓ 实测成功 - WeChat 4.1.7 (macOS) - SQLCipher v4 - Frida 17.9.10 - 2026-05-16 -
-
- - -
-
-
解密数据库
-
3 / 30+
-
contact · message_0 · session
-
-
-
消息表数量
-
3,654
-
message_0.db 中的会话分表
-
-
-
联系人数量
-
26 表
-
contact.db 成功打开
-
-
-
关键工具
-
Frida + sqlcipher
-
brew install sqlcipher
-
-
- - -

⚡ 三分钟速览(核心结论)

- -
-
微信运行时
WeChat.app
-
→
-
打开数据库
WCDB/SQLCipher
-
→
-
AES 密钥设置
CCCryptorCreate
-
→
-
Frida 拦截
args[3] = key
-
→
-
sqlcipher
x'' 打开
-
- -
- 💡 -
- 核心发现:WeChat 4.x on macOS 使用 Apple CommonCrypto(而非 BoringSSL)做 SQLCipher 的 AES 加解密。 - CCCryptorCreate(op, AES, options, key_ptr, 32, iv, ref) 的 args[3] - 就是 32 字节的 raw enc_key,直接以 x'<hex>' 格式传给 sqlcipher 即可打开数据库。 - 不需要 PBKDF2 反推,不需要逆向二进制。 -
-
- - -

❌ 无效路线(踩过的坑)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
方案预期实际结果原因
lldb 内存搜索在堆内存暴力扫描 32 字节密钥✗ 0 MB 被读取WeChat 沙箱 + 进程状态问题,GetMemoryRegionInfo 全部失败
vmmap + salt 定位找到 salt 字节 → 在附近找 key✗ salt 未找到vmmap 区域地址与 lldb attach 后的地址不对应
hook 系统 sqlite3_key系统 libsqlite3.dylib 的 sqlite3_key✗ 从未被调用WeChat 静态链接 WCDB,不调用系统 sqlite3_key
hook EVP/BoringSSL微信可能用 BoringSSL 做 AES✗ 未测到SQLCipher 用的是 CommonCrypto,BoringSSL 只用于 TLS
HMAC Python 验证用 HMAC 校验候选 key✗ 全部 ❌验证逻辑看起来正确但有细节偏差;改用 sqlcipher 直接验证
- - -

🔍 突破口:CCCryptorCreate

-

- 扫描微信进程所有模块的导出符号时,发现 libsqlite3.dylib 的 sqlite3_key 在进程中存在,但从未被 WeChat 调用(静态链接的 WCDB 有自己的副本)。 - 随后转向 更底层的 AES 层,hook 了 CommonCrypto 的 CCCryptorCreate。 -

-

- 这个函数在 WeChat 打开数据库(解密每一页)时被高频调用,alg=0(AES)、keyLen=32(AES-256),args[3] 直接就是 enc_key。 - 10 秒内捕获了 4 个唯一的 32 字节 AES-256 密钥,用 sqlcipher 逐一验证,成功匹配 3 个数据库。 -

- - -

步骤一:定位数据库文件

-

微信 4.x 在 macOS 上的数据库路径(已知账号 yibooo_ac07):

-
~/Library/Containers/com.tencent.xinWeChat/Data/Documents/
-└── xwechat_files/
-    └── yibooo_ac07/
-        └── db_storage/
-            ├── contact/contact.db        ← 联系人
-            ├── message/message_0.db      ← 消息分片 0
-            ├── message/message_1.db      ← 消息分片 1
-            │   ...
-            ├── message/message_10.db     ← 消息分片 10
-            ├── session/session.db        ← 会话列表
-            ├── sns/sns.db                ← 朋友圈
-            └── general/general.db        ← 通用配置
- -
- ℹ️ -
所有 .db 文件均为 SQLCipher v4 加密,文件头不是标准 SQLite format 3,而是随机盐值(前 16 字节)。Page size = 4096,reserved = 48 字节(16 IV + 32 HMAC-SHA256)。
-
- -

步骤二:确认 Frida 可用

-
# Frida 17.9.10 已通过 pip 安装
-python3 -c "import frida; print(frida.__version__)"
-# 17.9.10
-
-# CLI 工具路径
-~/Library/Python/3.9/bin/frida
-~/Library/Python/3.9/bin/frida-ps
- -
- ⚠️ -
必须先打开微信并登录,Frida 需要附加到运行中的进程。数据库在微信启动后即被打开,CCCryptorCreate 会在读取每一页时被调用。
-
- -

步骤三:Hook CCCryptorCreate

- -
-
- /tmp/wechat_hook.js — Frida 注入脚本 -
-
/*
- * macOS WeChat SQLCipher enc_key 拦截器
- * 原理:hook CommonCrypto 的 CCCryptorCreate,捕获 AES-256 密钥
- * CCCryptorCreate(op, alg, options, key, keyLength, iv, cryptorRef)
- *   alg=0  → AES
- *   op=1   → 解密 (decrypt)
- *   key    → 32 字节 enc_key  ← 我们要的
- */
-
-var found_keys = {};
-
-// 找到 CCCryptorCreate 函数地址
-var cc_create = Module.findExportByName(null, 'CCCryptorCreate');
-
-if (cc_create) {
-    Interceptor.attach(cc_create, {
-        onEnter: function(args) {
-            try {
-                var op     = args[0].toInt32();  // 0=encrypt, 1=decrypt
-                var alg    = args[1].toInt32();  // 0=AES
-                var keyLen = args[4].toInt32();
-
-                // 只关注 AES-256(keyLen=32)
-                if (alg === 0 && keyLen === 32) {
-                    var key = args[3].readByteArray(32);
-                    var hex = Array.from(new Uint8Array(key))
-                        .map(function(b) {
-                            return ('0' + b.toString(16)).slice(-2);
-                        }).join('');
-
-                    // 去重后发送
-                    if (!found_keys[hex]) {
-                        found_keys[hex] = true;
-                        send({ type: 'aes_key', op: op,
-                               len: keyLen, hex: hex });
-                    }
-                }
-            } catch(e) {}
-        }
-    });
-    send({ type: 'hooked', func: 'CCCryptorCreate',
-           addr: cc_create.toString() });
-}
-
-send({ type: 'ready' });
- -

步骤四:Python 驱动 + 自动验证

- -
-
- /tmp/wechat_frida.py — 驱动脚本 -
-
#!/usr/bin/env python3
-import sys, os, time, subprocess
-
-# Frida 路径(pip 安装在用户目录)
-sys.path.insert(0, os.path.expanduser("~/Library/Python/3.9/lib/python/site-packages"))
-import frida
-
-DB_BASE = os.path.expanduser(
-    "~/Library/Containers/com.tencent.xinWeChat/Data/Documents/"
-    "xwechat_files/yibooo_ac07/db_storage/"
-)
-
-def test_key_with_sqlcipher(key_hex, db_path):
-    """用 sqlcipher 验证 key 是否能打开数据库"""
-    pragma = f"PRAGMA key = \"x'{key_hex}'\";"
-    r = subprocess.run(
-        ['sqlcipher', db_path],
-        input=f"{pragma}\nSELECT count(*) FROM sqlite_master;\n",
-        capture_output=True, text=True
-    )
-    lines = [l for l in r.stdout.split('\n') if l.strip() and l != 'ok']
-    if lines and lines[0].isdigit():
-        return int(lines[0])  # 返回表数量
-    return None
-
-# 获取微信 PID
-pid = int(subprocess.run(['pgrep', '-x', 'WeChat'],
-    capture_output=True, text=True).stdout.strip().split('\n')[0])
-print(f"[*] 附加到 WeChat PID={pid}")
-
-session = frida.attach(pid)
-script  = session.create_script(open('/tmp/wechat_hook.js').read())
-
-captured = []
-databases = {
-    'contact':  'contact/contact.db',
-    'message0': 'message/message_0.db',
-    'session':  'session/session.db',
-}
-
-def on_message(msg, data):
-    if msg['type'] != 'send': return
-    p = msg['payload']
-    if p.get('type') == 'aes_key':
-        k = p['hex']
-        print(f"  捕获 AES key: {k[:16]}...")
-        # 验证这个 key 能打开哪些数据库
-        for dbname, relpath in databases.items():
-            tables = test_key_with_sqlcipher(k, DB_BASE + relpath)
-            if tables is not None:
-                print(f"  ✅ {dbname}: key 匹配!表数量={tables}")
-                captured.append((k, dbname))
-                open('/tmp/wechat_key.hex', 'a').write(f"{dbname}:{k}\n")
-
-script.on('message', on_message)
-script.load()
-print("[*] 监听中,在微信里操作触发数据库读取... (Ctrl+C 退出)")
-
-try:
-    while True: time.sleep(1)
-except KeyboardInterrupt: pass
-session.detach()
- -

步骤五:用 sqlcipher 打开数据库

- -
-
- Shell — 打开解密数据库 -
-
DB_BASE="~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/yibooo_ac07/db_storage"
-
-# 打开联系人数据库
-sqlcipher "$DB_BASE/contact/contact.db" <<SQL
-PRAGMA key = "x'1064e7dfb42cc3ea447da335ca848a2381e8738ad1ebc31e76e993456ba1fe2b'";
-SELECT username, nick_name, remark FROM contact WHERE local_type=3 LIMIT 20;
-SQL
-
-# 打开消息数据库
-sqlcipher "$DB_BASE/message/message_0.db" <<SQL
-PRAGMA key = "x'de0b8d3304e9940e96baa7c9ecfe33a226e68590fa0060d7d09ee088e1a104f8'";
--- 消息表名 = "Msg_" + MD5(username)
-SELECT local_type, datetime(create_time,'unixepoch','localtime'), message_content
-FROM Msg_8f5f8034b0f757ef2701729beabc4770  -- MD5('dp543831577')
-WHERE local_type = 1
-ORDER BY create_time DESC LIMIT 20;
-SQL
- -

步骤六:消息表名计算

-

WeChat 的每个聊天对话在 message_N.db 中对应一张表 Msg_<md5>,表名由会话 ID 的 MD5 决定:

- -
-
- Python — 计算消息表名 -
-
import hashlib
-
-def msg_table(username):
-    md5 = hashlib.md5(username.encode()).hexdigest()
-    return f"Msg_{md5}"
-
-# 例子
-print(msg_table("dp543831577"))
-# → Msg_8f5f8034b0f757ef2701729beabc4770
-
-print(msg_table("44170630061@chatroom"))
-# → Msg_99ce6fe9bc90c6320bb5e75714360da0
- - -

密钥映射表

-

以下是本次成功捕获并验证的密钥(仅本机本账号有效,每次微信更新或重置后需重新提取):

- -
-
contact / contact.db
-
1064e7dfb42cc3ea447da335ca848a2381e8738ad1ebc31e76e993456ba1fe2b
-
✓ sqlcipher 验证:26 张表
-
- -
-
message / message_0.db
-
de0b8d3304e9940e96baa7c9ecfe33a226e68590fa0060d7d09ee088e1a104f8
-
✓ sqlcipher 验证:3654 张 Msg_* 表
-
- -
-
session / session.db
-
f6b305477cda040af70a41f6321c3017b3400a3688e712bb1c9cf7aee50515b8
-
✓ sqlcipher 验证:14 张表
-
- -
-
其他数据库(message_1~10, general, sns...)
-
e5364de757971b4ead1db0d667d9e78bd7a6612254f48a19b43ae01247c9d28f
-
待确认 — 滚动浏览对应聊天触发后再验证
-
- - -

真实查询结果

- -

联系人列表(contact.db)

-
-username | nick_name | remark -───────────────────────────────────────────────────────────── -wxid_9802458023412 | Kaki | Kaki 青藤 93 荷兰 -mali847944 | 马丽 | 您好,招行个贷马丽 -wxid_kb6z85qjjrrv21| 1.26 5 | 我禧瑞春秋物业前台 -wxid_0puuvbx9df1321| Qian | Qian 青藤 37 财务 -yuanhanweibo | 幸运 | 幸运 lulu 青藤 90摩羯 -... 更多联系人 ... -
- -

最近会话(session.db)

-
-username | 时间 | 最新消息预览 -────────────────────────────────────────────────────────────────── -3164323182@chatroom | 15:31:16 | [強][強][強] -44170630061@chatroom| 15:30:55 | @Letty 戈壁大神[呲牙][玫瑰] -brandsessionholder | 15:31:26 | [38条] 新智元: 突发!OpenAI大规模重组... -gh_8391a4921226 | 15:29:52 | MiniCPM-V4.6登顶 Hugging Face Trending! -qqtech | 15:26:25 | 稚晖君抛出"三条曲线",讲了一套具身智能出海新逻辑 -... 共 20+ 活跃会话 ... -
- -

聊天消息(message_0.db · 特定对话)

-
-type | sender | 时间 | 内容 -──────────────────────────────────────────────────────────── -1 | 543 | 2026-05-16 15:19 | 微信里"拿 DB 数据"之前其实分两类... -1 | 15 | 2026-05-15 12:39 | 生产力大于需求 -1 | 15 | 2026-05-15 12:39 | 短剧卷是肯定的 -1 | 543 | 2026-05-15 12:39 | 中国人干死中国人 -1 | 543 | 2026-05-15 12:38 | 短剧现在好卷 -1 | 15 | 2026-05-15 12:38 | 干不过 claude -... local_type=47 是图片/视频,local_type=10000 是系统消息 ... -
- -

群聊消息(44170630061@chatroom)

-
-群消息格式:content = "sender_wxid:\n内容" -──────────────────────────────────────────── -a36269329: -@Letty 戈壁大神[呲牙][玫瑰][玫瑰] - -wxid_5194951949422: -一起共修 - -a36269329: -Web3 大神 @Ivy [呲牙][玫瑰][玫瑰] -
- - -

后续扩展

- -
    -
  • -

    获取其余消息分片的密钥

    -

    在微信里滚动浏览对应的聊天,触发 WeChat 读取 message_1.db~message_10.db,Frida hook 会自动捕获对应的 AES key,用同样的 sqlcipher 验证方法确认后记录。

    -
  • -
  • -

    写 Python 脚本全量导出

    -

    结合三个密钥,用 Python 的 apsw(支持 SQLCipher)或 subprocess 调 sqlcipher,批量读取所有消息写入自己的 SQLite 或 JSON。

    -
  • -
  • -

    与 Android Frida 方案对接

    -

    Android 侧已经有 hook h9.r9 / h9.A8 的消息拦截链路,macOS 侧则补充了历史数据的解密能力,两路数据可合并到同一个 SessionDB / read_history.db。

    -
  • -
  • -

    配置 Frida 持久化 Hook

    -

    将 hook 脚本和 Python 驱动写成 launchd plist 或 shell 别名,每次启动微信后自动运行 10 秒捕获密钥,无需手动操作。

    -
  • -
- -

Android vs macOS 方案对比

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
维度Android(已实现)macOS(本次)
Hook 目标h9.r9 / h9.A8 (Java)CCCryptorCreate (C)
获取数据实时消息对象(D0/O0/j 字段)SQLCipher AES enc_key → 离线全量
数据库不直接解密 EnMicroMsg.db直接解密 contact/message/session.db
Frida 工具frida-server on Android + Python client in Debian prootfrida Python 17.9.10 on macOS
密钥格式Java passphrase(hook WCDB open 可拿)32 字节 raw AES enc_key → x'hex'
稳定性高(hook 消息对象,不触碰 DB)中(依赖 CCCryptorCreate,WeChat 更新后需重验)
- -
- ⚠️ -
- 注意事项:密钥为 运行时动态值,不存储在任何磁盘文件中(Keychain 无记录)。 - WeChat 版本更新后 AES 密钥 可能 变化,需重新运行 Frida hook 提取。 - 本文档和密钥仅用于自己账号的个人数据访问,勿用于他人数据。 -
-
- -
-

生成时间:2026-05-16 · 账号:yibooo_ac07 · WeChat 4.1.7 · Frida 17.9.10 · sqlcipher 4.16.0

-
- -
- - - - diff --git a/wechat_plain/scripts/wechat_android_attach.py b/wechat_plain/scripts/wechat_android_attach.py deleted file mode 100644 index 7c196e5..0000000 --- a/wechat_plain/scripts/wechat_android_attach.py +++ /dev/null @@ -1,342 +0,0 @@ -#!/usr/bin/env python3 -""" -WeChat Android SQLCipher Key Capture + DB Extraction -Runs in Termux / Debian proot, connects to frida-server on Android host. - -Usage: - python3 wechat_android_attach.py # Capture keys - python3 wechat_android_attach.py --extract-db # Pull DB from phone - python3 wechat_android_attach.py --decrypt # Decrypt with captured key - -Prerequisites: - - frida-server running on Android (as root) - - frida Python package installed in proot/Termux - - WeChat installed and logged in - - Phone has root access -""" -import sys, os, json, time, subprocess, re - -# ── Configuration ───────────────────────────────────────────────────────────── -WECHAT_PKG = "com.tencent.mm" -JS_PATH = os.path.join(os.path.dirname(__file__), "wechat_android_key_hunter.js") -OUT_DIR = "/root/wxmoment" # inside Debian proot -KEY_CACHE = os.path.join(OUT_DIR, "wechat_keys.json") -DB_OUT_DIR = os.path.join(OUT_DIR, "dbs") - -os.makedirs(OUT_DIR, exist_ok=True) -os.makedirs(DB_OUT_DIR, exist_ok=True) - -# ── Key storage ─────────────────────────────────────────────────────────────── -captured_keys = {} # hex_key -> {path, via, timestamp, ...} -cipher_specs = {} # field -> value - -# Try loading existing keys -def load_keys(): - if not os.path.exists(KEY_CACHE): - return - try: - with open(KEY_CACHE) as f: - data = json.load(f) - captured_keys.update(data.get("keys", {})) - cipher_specs.update(data.get("cipher_specs", {})) - print(f"[cache] Loaded {len(captured_keys)} known keys") - except Exception as e: - print(f"[!] Failed to load key cache: {e}") - -def save_keys(): - try: - with open(KEY_CACHE, "w") as f: - json.dump({ - "keys": captured_keys, - "cipher_specs": cipher_specs, - "updated": time.time() - }, f, indent=2, ensure_ascii=False) - except Exception as e: - print(f"[!] Failed to save keys: {e}") - -# ── Frida setup ─────────────────────────────────────────────────────────────── -try: - import frida -except ImportError: - print("[!] frida Python package not installed.") - print(" Install with: pip install frida") - sys.exit(1) - -def get_usb_device(): - """Get USB-connected Frida device (Android phone).""" - for _ in range(5): - try: - device = frida.get_usb_device(timeout=3) - return device - except Exception as e: - print(f"[!] Waiting for USB device... ({e})") - time.sleep(1) - raise RuntimeError("No USB Frida device found. Is frida-server running?") - -def spawn_or_attach(device, pkg): - """Try to attach to running process, or spawn a new one.""" - # Check if already running - try: - apps = device.enumerate_applications() - for app in apps: - if app.identifier == pkg: - if app.pid != 0: - print(f"[*] Attaching to running {pkg} (PID={app.pid})") - return device.attach(app.pid) - break - except Exception as e: - print(f"[!] enumerate_applications failed: {e}") - - # Try direct attach - try: - session = device.attach(pkg) - print(f"[*] Attached to {pkg}") - return session - except Exception as e: - print(f"[!] Direct attach failed: {e}") - - # Spawn new - print(f"[*] Spawning {pkg}...") - pid = device.spawn([pkg]) - session = device.attach(pid) - device.resume(pid) - print(f"[*] Spawned and attached (PID={pid})") - return session - -# ── Message handler ─────────────────────────────────────────────────────────── -def on_message(msg, data): - if msg.get("type") != "send": - return - payload = msg.get("payload", {}) - ptype = payload.get("type", "") - - if ptype == "key": - key_hex = payload.get("hex", "") - key_len = payload.get("len", 0) - via = payload.get("via", "") - path = payload.get("path", "") - extra = payload.get("extra", {}) - - # Deduplicate - cache_key = key_hex + "@" + path - if cache_key in captured_keys: - return - - captured_keys[cache_key] = { - "hex": key_hex, - "len": key_len, - "via": via, - "path": path, - "extra": extra, - "ts": time.time(), - "ts_fmt": time.strftime("%Y-%m-%d %H:%M:%S") - } - save_keys() - - print(f"\n [KEY CAPTURED via {via}]") - print(f" hex: {key_hex[:32]}{'...' if len(key_hex) > 32 else ''}") - print(f" len: {key_len}") - if path: - print(f" path: {path}") - if extra: - print(f" extra: {json.dumps(extra, ensure_ascii=False)}") - - # If this is EnMicroMsg.db, suggest extraction - if "EnMicroMsg" in path or not path: - print(f" => Run: python3 wechat_android_attach.py --extract-db") - - elif ptype == "cipher_spec": - field = payload.get("field", "") - value = payload.get("value", "") - cipher_specs[field] = value - save_keys() - print(f" [CIPHER SPEC] {field} = {value}") - - elif ptype == "info": - info_msg = payload.get("msg", "") - # Highlight DB path discoveries - if info_msg.startswith("FOUND_DB:"): - print(f"\n [DB FOUND] {info_msg}") - else: - print(f" [INFO] {info_msg}") - - elif ptype == "error": - print(f" [ERROR] {payload.get('msg', '')}") - -# ── DB Extraction ───────────────────────────────────────────────────────────── -def find_enmicromsg_db(): - """Find EnMicroMsg.db path on phone via adb.""" - print("[*] Scanning for EnMicroMsg.db on phone...") - result = subprocess.run( - ["adb", "shell", "su", "-c", - "find /data/data/com.tencent.mm/MicroMsg -name 'EnMicroMsg.db' -type f 2>/dev/null"], - capture_output=True, text=True - ) - paths = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()] - if not paths: - print("[!] EnMicroMsg.db not found. Is WeChat logged in?") - return None - print(f"[+] Found {len(paths)} EnMicroMsg.db:") - for i, p in enumerate(paths): - print(f" [{i}] {p}") - return paths[0] # Return first (usually the active account) - -def extract_db(db_path=None, out_dir=DB_OUT_DIR): - """Copy WeChat DB files from phone to local directory via adb shell su -c cat.""" - if db_path is None: - db_path = find_enmicromsg_db() - if not db_path: - return False - - # Also copy -shm and -wal files - files_to_copy = [db_path, db_path + "-shm", db_path + "-wal"] - - os.makedirs(out_dir, exist_ok=True) - - for src in files_to_copy: - dst = os.path.join(out_dir, os.path.basename(src)) - print(f"[*] Copying {src} -> {dst}") - r = subprocess.run( - ["adb", "shell", "su", "-c", f"cat '{src}'"], - capture_output=True - ) - if r.returncode == 0 and r.stdout: - with open(dst, "wb") as f: - f.write(r.stdout) - print(f" [OK] {dst} ({os.path.getsize(dst)} bytes)") - else: - print(f" [SKIP] {src} (not found or unreadable)") - - print(f"\n[+] DB files in: {out_dir}") - return True - -def try_decrypt(db_path, key_hex, out_path=None): - """Try to decrypt EnMicroMsg.db with captured key using sqlcipher. - Automatically tries string password and raw hex key formats, - with common cipher configurations.""" - if out_path is None: - out_path = db_path + ".decrypted.db" - - # Remove existing output - if os.path.exists(out_path): - os.remove(out_path) - - # Build candidate keys and configs - try: - key_str = bytes.fromhex(key_hex).decode('latin-1') - except ValueError: - key_str = key_hex - - candidates = [] - # String password format (most common for WCDB) - candidates.append(("string", f'PRAGMA key = "{key_str}";')) - # Raw hex key format - candidates.append(("hex", f"PRAGMA key = \"x'{key_hex}'\";")) - - # Cipher configs to try - page_size = cipher_specs.get("pageSize", 1024) - kdf_iter = cipher_specs.get("kdfIteration", None) - - configs = [] - base = f"PRAGMA cipher_compatibility = 1;\nPRAGMA cipher_page_size = {page_size};" - configs.append(("compat1", base)) - if kdf_iter: - configs.append(("compat1+kdf", f"{base}\nPRAGMA kdf_iter = {kdf_iter};")) - configs.append(("no_compat", f"PRAGMA cipher_page_size = {page_size};")) - - print(f"[*] Trying decryption of {os.path.basename(db_path)}...") - print(f" Key candidates: {len(candidates)} | Config candidates: {len(configs)}") - - for key_name, key_cmd in candidates: - for cfg_name, cfg_pragmas in configs: - sql = f"{key_cmd}\n{cfg_pragmas}\nATTACH DATABASE '{out_path}' AS plain KEY '';\nSELECT sqlcipher_export('plain');\nDETACH DATABASE plain;\n" - - r = subprocess.run( - ["sqlcipher", db_path], - input=sql, capture_output=True, text=True, timeout=300 - ) - - if r.returncode == 0 and os.path.exists(out_path) and os.path.getsize(out_path) > 4096: - sz = os.path.getsize(out_path) - print(f" [SUCCESS] Decrypted: {sz} bytes (key={key_name}, cfg={cfg_name})") - print(f" Use: sqlite3 {out_path}") - return True - - err = r.stderr.strip()[:200] if r.stderr else "unknown" - print(f" [FAILED] All combinations failed. Last error: {err}") - return False - -# ── Main interactive loop ───────────────────────────────────────────────────── -def run_capture(): - load_keys() - - print("=" * 60) - print("WeChat Android SQLCipher Key Capture") - print("=" * 60) - print(f"[*] JS script: {JS_PATH}") - print(f"[*] Key cache: {KEY_CACHE}") - print(f"[*] DB output: {DB_OUT_DIR}") - print() - - if not os.path.exists(JS_PATH): - print(f"[!] JS script not found: {JS_PATH}") - sys.exit(1) - - device = get_usb_device() - print(f"[*] Device: {device.name}") - - session = spawn_or_attach(device, WECHAT_PKG) - - with open(JS_PATH) as f: - js_code = f.read() - - script = session.create_script(js_code) - script.on("message", on_message) - script.load() - - print("[*] Script loaded. Hooks active.") - print("[*] Open WeChat, browse chats to trigger DB access.") - print("[*] Press Ctrl+C to stop.\n") - - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - print("\n[*] Stopping...") - finally: - session.detach() - save_keys() - print(f"\n[RESULT] Captured {len(captured_keys)} unique keys:") - for k, v in captured_keys.items(): - print(f" {v['hex'][:32]}... via={v['via']} path={v['path']}") - -def main(): - if len(sys.argv) > 1 and sys.argv[1] == "--extract-db": - extract_db() - return - - if len(sys.argv) > 1 and sys.argv[1] == "--decrypt": - db_path = sys.argv[2] if len(sys.argv) > 2 else None - if not db_path: - # Find most recent extracted DB - dbs = [f for f in os.listdir(DB_OUT_DIR) if f.endswith(".db") and "decrypted" not in f] - if not dbs: - print("[!] No DB found. Run with --extract-db first.") - sys.exit(1) - db_path = os.path.join(DB_OUT_DIR, sorted(dbs, key=lambda x: os.path.getmtime(os.path.join(DB_OUT_DIR, x)))[-1]) - - load_keys() - if not captured_keys: - print("[!] No keys captured yet. Run capture first.") - sys.exit(1) - - # Try each key - for k, v in captured_keys.items(): - if try_decrypt(db_path, v["hex"]): - break - return - - run_capture() - -if __name__ == "__main__": - main() diff --git a/wechat_plain/scripts/wechat_android_attach_v2.py b/wechat_plain/scripts/wechat_android_attach_v2.py deleted file mode 100644 index 4b59d50..0000000 --- a/wechat_plain/scripts/wechat_android_attach_v2.py +++ /dev/null @@ -1,501 +0,0 @@ -#!/usr/bin/env python3 -""" -WeChat Android Full Toolkit v2 -- Key capture from libWCDB.so -- DB extraction via adb -- Auto decryption -- Message monitoring (send/receive) -- Active message sending via Frida RPC -- ContentProvider auth bypass - -Usage: - python3 wechat_android_attach_v2.py # Capture keys + monitor - python3 wechat_android_attach_v2.py --extract-db # Pull DB from phone - python3 wechat_android_attach_v2.py --decrypt # Decrypt with captured key - python3 wechat_android_attach_v2.py --send-msg # Send message - python3 wechat_android_attach_v2.py --status # Check Frida session status -""" -import sys, os, json, time, subprocess, re - -# ── Configuration ───────────────────────────────────────────────────────────── -WECHAT_PKG = "com.tencent.mm" -JS_PATH = os.path.join(os.path.dirname(__file__), "wechat_android_full_hunter.js") -OUT_DIR = os.path.expanduser("~/wxmoment") -KEY_CACHE = os.path.join(OUT_DIR, "wechat_keys.json") -DB_OUT_DIR = os.path.join(OUT_DIR, "dbs") - -os.makedirs(OUT_DIR, exist_ok=True) -os.makedirs(DB_OUT_DIR, exist_ok=True) - -# ── Key storage ─────────────────────────────────────────────────────────────── -captured_keys = {} -cipher_specs = {} - -# Try loading existing keys -def load_keys(): - if not os.path.exists(KEY_CACHE): - return - try: - with open(KEY_CACHE) as f: - data = json.load(f) - captured_keys.update(data.get("keys", {})) - cipher_specs.update(data.get("cipher_specs", {})) - print(f"[cache] Loaded {len(captured_keys)} known keys") - except Exception as e: - print(f"[!] Failed to load key cache: {e}") - -def save_keys(): - try: - with open(KEY_CACHE, "w") as f: - json.dump({ - "keys": captured_keys, - "cipher_specs": cipher_specs, - "updated": time.time() - }, f, indent=2, ensure_ascii=False) - except Exception as e: - print(f"[!] Failed to save keys: {e}") - -# ── Frida setup ─────────────────────────────────────────────────────────────── -try: - import frida -except ImportError: - print("[!] frida Python package not installed.") - print(" Install with: pip install frida") - sys.exit(1) - -def get_usb_device(): - for _ in range(5): - try: - device = frida.get_usb_device(timeout=3) - return device - except Exception as e: - print(f"[!] Waiting for USB device... ({e})") - time.sleep(1) - raise RuntimeError("No USB Frida device found. Is frida-server running?") - -def get_wechat_pid(device): - """Get WeChat main process PID via pgrep fallback.""" - # Try with root first - for su_path in ["/debug_ramdisk/su", "/data/local/tmp/su2"]: - try: - result = subprocess.run( - ["adb", "shell", su_path, "-c", "pgrep -x com.tencent.mm"], - capture_output=True, text=True - ) - if result.returncode == 0 and result.stdout.strip(): - pids = sorted([int(p.strip()) for p in result.stdout.strip().split("\n") if p.strip()]) - if pids: - return pids[0] # Main process is the smallest PID - except Exception: - pass - - # Fallback without root - try: - result = subprocess.run( - ["adb", "shell", "pgrep", "-x", "com.tencent.mm"], - capture_output=True, text=True - ) - pids = sorted([int(p.strip()) for p in result.stdout.strip().split("\n") if p.strip()]) - if pids: - return pids[0] - except Exception as e: - print(f"[!] pgrep failed: {e}") - - try: - for proc in device.enumerate_processes(): - if proc.name == WECHAT_PKG: - return proc.pid - except Exception as e: - print(f"[!] enumerate_processes failed: {e}") - - return None - -def attach_wechat(device, wait_for_process=True): - """Attach to WeChat main process.""" - pid = get_wechat_pid(device) - - # Wait for process to appear if requested - if not pid and wait_for_process: - print("[*] Waiting for WeChat process to appear...") - for _ in range(30): # Wait up to 30 seconds - time.sleep(1) - pid = get_wechat_pid(device) - if pid: - print(f"[*] WeChat appeared (PID={pid})") - break - - if pid: - print(f"[*] Attaching to {WECHAT_PKG} (PID={pid})") - try: - return device.attach(pid) - except Exception as e: - print(f"[!] Attach to PID={pid} failed: {e}") - - # Fallback: try direct attach by name - try: - session = device.attach(WECHAT_PKG) - print(f"[*] Attached to {WECHAT_PKG} by name") - return session - except Exception as e: - print(f"[!] Direct attach failed: {e}") - - return None - -# ── Session management ──────────────────────────────────────────────────────── -active_session = None -active_script = None - -def load_script(session, js_path): - global active_script - with open(js_path) as f: - js_code = f.read() - script = session.create_script(js_code) - script.on("message", on_message) - script.load() - active_script = script - return script - -def on_message(msg, data): - if msg.get("type") != "send": - return - payload = msg.get("payload", {}) - ptype = payload.get("type", "") - - if ptype == "key": - key_hex = payload.get("hex", "") - key_len = payload.get("len", 0) - via = payload.get("via", "") - path = payload.get("path", "") - extra = payload.get("extra", {}) - - cache_key = key_hex + "@" + path - if cache_key in captured_keys: - return - - captured_keys[cache_key] = { - "hex": key_hex, - "len": key_len, - "via": via, - "path": path, - "extra": extra, - "ts": time.time(), - "ts_fmt": time.strftime("%Y-%m-%d %H:%M:%S") - } - save_keys() - - print(f"\n [KEY CAPTURED via {via}]") - print(f" hex: {key_hex[:32]}{'...' if len(key_hex) > 32 else ''}") - print(f" len: {key_len}") - if path: - print(f" path: {path}") - - elif ptype == "cipher_spec": - field = payload.get("field", "") - value = payload.get("value", "") - cipher_specs[field] = value - save_keys() - print(f" [CIPHER SPEC] {field} = {value}") - - elif ptype == "msg": - direction = payload.get("direction", "") - toUser = payload.get("toUser", "") - content = payload.get("content", "") - msgType = payload.get("msgType", 0) - print(f"\n [MSG {direction.upper()}] to={toUser} type={msgType}") - print(f" content: {content[:100]}{'...' if len(content) > 100 else ''}") - - elif ptype == "sql_insert": - table = payload.get("table", "") - sql = payload.get("sql", "") - print(f"\n [SQL INSERT] table={table}") - print(f" sql: {sql[:200]}{'...' if len(sql) > 200 else ''}") - - elif ptype == "info": - info_msg = payload.get("msg", "") - if info_msg.startswith("FOUND_DB:"): - print(f"\n [DB FOUND] {info_msg}") - elif info_msg.startswith("[JavaHook]") or info_msg.startswith("[Bypass]"): - print(f" {info_msg}") - else: - print(f" [INFO] {info_msg}") - - elif ptype == "error": - print(f" [ERROR] {payload.get('msg', '')}") - -# ── DB Extraction ───────────────────────────────────────────────────────────── -def get_su_cmd(): - for su_path in ["/debug_ramdisk/su", "/data/local/tmp/su2"]: - try: - result = subprocess.run( - ["adb", "shell", su_path, "-c", "id"], - capture_output=True, text=True - ) - if result.returncode == 0 and "uid=0" in result.stdout: - return ["adb", "shell", su_path, "-c"] - except Exception: - pass - return ["adb", "shell", "su", "-c"] - -def find_enmicromsg_db(): - print("[*] Scanning for EnMicroMsg.db on phone...") - su_cmd = get_su_cmd() - result = subprocess.run( - su_cmd + ["find /data/data/com.tencent.mm/MicroMsg -name 'EnMicroMsg.db' -type f 2>/dev/null"], - capture_output=True, text=True - ) - paths = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()] - if not paths: - print("[!] EnMicroMsg.db not found. Is WeChat logged in?") - return None - print(f"[+] Found {len(paths)} EnMicroMsg.db:") - for i, p in enumerate(paths): - print(f" [{i}] {p}") - return paths[0] - -def extract_db(db_path=None, out_dir=DB_OUT_DIR): - if db_path is None: - db_path = find_enmicromsg_db() - if not db_path: - return False - - files_to_copy = [db_path, db_path + "-shm", db_path + "-wal"] - os.makedirs(out_dir, exist_ok=True) - su_cmd = get_su_cmd() - - for src in files_to_copy: - dst = os.path.join(out_dir, os.path.basename(src)) - print(f"[*] Copying {src} -> {dst}") - r = subprocess.run( - su_cmd + [f"cat '{src}'"], - capture_output=True - ) - if r.returncode == 0 and r.stdout: - with open(dst, "wb") as f: - f.write(r.stdout) - print(f" [OK] {dst} ({os.path.getsize(dst)} bytes)") - else: - print(f" [SKIP] {src} (not found or unreadable)") - - print(f"\n[+] DB files in: {out_dir}") - return True - -# ── Decryption ──────────────────────────────────────────────────────────────── -def try_decrypt(db_path, key_hex, out_path=None): - if out_path is None: - out_path = db_path + ".decrypted.db" - - if os.path.exists(out_path): - os.remove(out_path) - - try: - key_str = bytes.fromhex(key_hex).decode('latin-1') - except ValueError: - key_str = key_hex - - candidates = [] - candidates.append(("string", f'PRAGMA key = "{key_str}";')) - candidates.append(("hex", f"PRAGMA key = \"x'{key_hex}'\";")) - - page_size = cipher_specs.get("pageSize", 1024) - kdf_iter = cipher_specs.get("kdfIteration", None) - - configs = [] - base = f"PRAGMA cipher_compatibility = 1;\nPRAGMA cipher_page_size = {page_size};" - configs.append(("compat1", base)) - if kdf_iter: - configs.append(("compat1+kdf", f"{base}\nPRAGMA kdf_iter = {kdf_iter};")) - configs.append(("no_compat", f"PRAGMA cipher_page_size = {page_size};")) - - print(f"[*] Trying decryption of {os.path.basename(db_path)}...") - print(f" Key candidates: {len(candidates)} | Config candidates: {len(configs)}") - - for key_name, key_cmd in candidates: - for cfg_name, cfg_pragmas in configs: - sql = f"{key_cmd}\n{cfg_pragmas}\nATTACH DATABASE '{out_path}' AS plain KEY '';\nSELECT sqlcipher_export('plain');\nDETACH DATABASE plain;\n" - - r = subprocess.run( - ["sqlcipher", db_path], - input=sql, capture_output=True, text=True, timeout=300 - ) - - if r.returncode == 0 and os.path.exists(out_path) and os.path.getsize(out_path) > 4096: - sz = os.path.getsize(out_path) - print(f" [SUCCESS] Decrypted: {sz} bytes (key={key_name}, cfg={cfg_name})") - print(f" Use: sqlite3 {out_path}") - return True - - err = r.stderr.strip()[:200] if r.stderr else "unknown" - print(f" [FAILED] All combinations failed. Last error: {err}") - return False - -# ── Message Sending ─────────────────────────────────────────────────────────── -def send_text_msg(script, to_user, content, method="auto"): - """Send text message via Frida RPC.""" - print(f"[*] Sending message to {to_user}...") - print(f" Content: {content[:50]}{'...' if len(content) > 50 else ''}") - - rpc = script.exports - - # Check status first - try: - status = rpc.get_status() - print(f" Status: pid={status.get('pid')}, javaAvailable={status.get('javaAvailable')}, javaHooksActive={status.get('javaHooksActive')}") - if not status.get("javaAvailable"): - print("[!] Java not available in target process. Cannot send messages via Java API.") - print(" Try: spawn a fresh process, or attach to a different WeChat sub-process.") - return False - except Exception as e: - print(f"[!] Failed to get status: {e}") - return False - - # Try sending - if method == "auto" or method == "rpc": - try: - result = rpc.send_text_msg(to_user, content) - print(f" [RPC Result] {json.dumps(result, ensure_ascii=False, indent=2)}") - if result.get("success"): - return True - except Exception as e: - print(f" [RPC Error] {e}") - - if method == "auto" or method == "provider": - try: - result = rpc.send_via_provider(to_user, content) - print(f" [Provider Result] {json.dumps(result, ensure_ascii=False, indent=2)}") - if result.get("success"): - return True - except Exception as e: - print(f" [Provider Error] {e}") - - return False - -# ── Main interactive loop ───────────────────────────────────────────────────── -def run_capture_and_monitor(): - load_keys() - - print("=" * 60) - print("WeChat Android Full Hunter v2") - print("=" * 60) - print(f"[*] JS script: {JS_PATH}") - print(f"[*] Key cache: {KEY_CACHE}") - print(f"[*] DB output: {DB_OUT_DIR}") - print() - - if not os.path.exists(JS_PATH): - print(f"[!] JS script not found: {JS_PATH}") - sys.exit(1) - - device = get_usb_device() - print(f"[*] Device: {device.name}") - - session = attach_wechat(device) - if not session: - print("[!] Failed to attach to WeChat") - sys.exit(1) - - global active_session - active_session = session - - script = load_script(session, JS_PATH) - - print("[*] Script loaded. Hooks active.") - print("[*] Operations:") - print(" - Open WeChat, browse chats to trigger DB access") - print(" - Send messages to monitor outgoing traffic") - print(" - Press Ctrl+C to stop") - print() - - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - print("\n[*] Stopping...") - finally: - session.detach() - save_keys() - print(f"\n[RESULT] Captured {len(captured_keys)} unique keys") - for k, v in captured_keys.items(): - print(f" {v['hex'][:32]}... via={v['via']}") - -def run_send_msg(to_user, content): - print("=" * 60) - print("WeChat Send Message via Frida") - print("=" * 60) - - if not os.path.exists(JS_PATH): - print(f"[!] JS script not found: {JS_PATH}") - sys.exit(1) - - device = get_usb_device() - session = attach_wechat(device) - if not session: - print("[!] Failed to attach to WeChat") - sys.exit(1) - - script = load_script(session, JS_PATH) - time.sleep(1) # Let hooks initialize - - success = send_text_msg(script, to_user, content) - - session.detach() - return success - -def run_status(): - device = get_usb_device() - session = attach_wechat(device) - if not session: - print("[!] Failed to attach") - sys.exit(1) - - script = load_script(session, JS_PATH) - time.sleep(0.5) - - try: - status = script.exports.get_status() - print(json.dumps(status, indent=2)) - except Exception as e: - print(f"[!] Error: {e}") - - session.detach() - -def main(): - if len(sys.argv) > 1 and sys.argv[1] == "--extract-db": - extract_db() - return - - if len(sys.argv) > 1 and sys.argv[1] == "--decrypt": - db_path = sys.argv[2] if len(sys.argv) > 2 else None - if not db_path: - dbs = [f for f in os.listdir(DB_OUT_DIR) if f.endswith(".db") and "decrypted" not in f] - if not dbs: - print("[!] No DB found. Run with --extract-db first.") - sys.exit(1) - db_path = os.path.join(DB_OUT_DIR, sorted(dbs, key=lambda x: os.path.getmtime(os.path.join(DB_OUT_DIR, x)))[-1]) - - load_keys() - if not captured_keys: - print("[!] No keys captured yet. Run capture first.") - sys.exit(1) - - for k, v in captured_keys.items(): - if try_decrypt(db_path, v["hex"]): - break - return - - if len(sys.argv) > 1 and sys.argv[1] == "--send-msg": - if len(sys.argv) < 4: - print("Usage: python3 wechat_android_attach_v2.py --send-msg ") - print(" toUser: wxid_xxx or xxx@chatroom") - print(" content: message text") - sys.exit(1) - success = run_send_msg(sys.argv[2], sys.argv[3]) - sys.exit(0 if success else 1) - - if len(sys.argv) > 1 and sys.argv[1] == "--status": - run_status() - return - - run_capture_and_monitor() - -if __name__ == "__main__": - main() diff --git a/wechat_plain/scripts/wechat_android_full_hunter.js b/wechat_plain/scripts/wechat_android_full_hunter.js deleted file mode 100644 index 41f7cb0..0000000 --- a/wechat_plain/scripts/wechat_android_full_hunter.js +++ /dev/null @@ -1,549 +0,0 @@ -/* - * WeChat Android Full Hunter (Frida 17.x) - * Targets WeChat 8.0.72 on Android - * - * Capabilities: - * 1. Capture SQLCipher key from libWCDB.so (native hook) - * 2. Monitor outgoing messages via NetSceneSendMsg constructor (Java hook) - * 3. Bypass ExtContentProviderBase auth check (Java hook) - * 4. Monitor message DB inserts via sqlite3_exec (native fallback) - * 5. RPC: sendTextMsg(toUser, content) - actively send messages - */ - -var TAG = "[WeChatFullHunter]"; -var seenKeys = {}; -var targetDbNames = ["EnMicroMsg.db", "EnMicroMsg"]; -var javaHooksActive = false; - -function isTargetDb(path) { - if (!path) return false; - var s = path.toString(); - for (var i = 0; i < targetDbNames.length; i++) { - if (s.indexOf(targetDbNames[i]) !== -1) return true; - } - return false; -} - -function bytesToHex(bytes, len) { - len = len || bytes.length; - var hex = ""; - for (var i = 0; i < len; i++) { - var b = bytes[i] & 0xFF; - hex += (b < 16 ? "0" : "") + b.toString(16); - } - return hex; -} - -function isPrintableKey(keyPtr, keyLen) { - if (keyLen < 4 || keyLen > 64) return false; - try { - var raw = keyPtr.readByteArray(keyLen); - if (!raw) return false; - var bytes = new Uint8Array(raw); - var map = {}, unique = 0; - for (var i = 0; i < bytes.length; i++) { - if (!map[bytes[i]]) { map[bytes[i]] = true; unique++; } - } - return unique >= 3; - } catch (e) { return false; } -} - -function reportKey(keyHex, keyLen, via, path, extra) { - var cacheKey = keyHex + "@" + (path || ""); - if (seenKeys[cacheKey]) return; - seenKeys[cacheKey] = true; - send({ type: "key", hex: keyHex, len: keyLen, via: via, path: path || "", extra: extra || {} }); -} - -function reportInfo(msg) { send({ type: "info", msg: msg }); } -function reportError(msg) { send({ type: "error", msg: msg }); } -function reportMsgEvent(direction, toUser, content, type, extra) { - send({ type: "msg", direction: direction, toUser: toUser, content: content, msgType: type, extra: extra || {} }); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// NATIVE LAYER: SQLCipher Key Capture + SQL Monitoring -// ═══════════════════════════════════════════════════════════════════════════════ - -function hookNativeSqlite3Key() { - var modules = Process.enumerateModules(); - var hooked = []; - for (var i = 0; i < modules.length; i++) { - var mod = modules[i]; - var name = mod.name.toLowerCase(); - if (name.indexOf("wcdb") === -1 && name.indexOf("sqlcipher") === -1 && - name.indexOf("wechat") === -1 && name.indexOf("sqlite") === -1) { - continue; - } - try { - var modObj = Process.findModuleByName(mod.name); - if (!modObj) continue; - - var keyAddr = modObj.findExportByName("sqlite3_key"); - if (keyAddr && !keyAddr.isNull()) { - var label = mod.name + "!sqlite3_key"; - (function (lbl, addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var keyPtr = args[1]; - var keyLen = args[2].toInt32(); - if (!isPrintableKey(keyPtr, keyLen)) return; - var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); - reportKey(hex, keyLen, lbl, null, { native: true, v2: false }); - } - }); - })(label, keyAddr); - hooked.push(label); - } - - var keyV2Addr = modObj.findExportByName("sqlite3_key_v2"); - if (keyV2Addr && !keyV2Addr.isNull()) { - var label2 = mod.name + "!sqlite3_key_v2"; - (function (lbl, addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var keyPtr = args[2]; - var keyLen = args[3].toInt32(); - if (!isPrintableKey(keyPtr, keyLen)) return; - var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); - reportKey(hex, keyLen, lbl, null, { native: true, v2: true }); - } - }); - })(label2, keyV2Addr); - hooked.push(label2); - } - } catch (e) {} - } - return hooked; -} - -function hookNativeSqliteExec() { - var modules = Process.enumerateModules(); - var hooked = []; - for (var i = 0; i < modules.length; i++) { - var mod = modules[i]; - var name = mod.name.toLowerCase(); - if (name.indexOf("wcdb") === -1 && name.indexOf("sqlcipher") === -1 && - name.indexOf("wechat") === -1 && name.indexOf("sqlite") === -1) { - continue; - } - try { - var modObj = Process.findModuleByName(mod.name); - if (!modObj) continue; - var execAddr = modObj.findExportByName("sqlite3_exec"); - if (execAddr && !execAddr.isNull()) { - (function (addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var sqlPtr = args[1]; - try { - var sql = sqlPtr.readUtf8String(); - if (!sql) return; - var upper = sql.toUpperCase(); - // Monitor PRAGMA key - if (upper.indexOf("PRAGMA KEY") !== -1) { - reportInfo("sqlite3_exec PRAGMA: " + sql); - } - // Monitor INSERT into message table (native fallback for msg monitoring) - if (upper.indexOf("INSERT INTO MESSAGE") !== -1 || - upper.indexOf("INSERT OR REPLACE INTO MESSAGE") !== -1 || - upper.indexOf("INSERT INTO \"MESSAGE\"") !== -1) { - send({ type: "sql_insert", table: "message", sql: sql }); - } - } catch (e) {} - } - }); - })(execAddr); - hooked.push(mod.name + "!sqlite3_exec"); - } - } catch (e) {} - } - return hooked; -} - -function hookNativeSqlitePrepare() { - var modules = Process.enumerateModules(); - for (var i = 0; i < modules.length; i++) { - var mod = modules[i]; - var name = mod.name.toLowerCase(); - if (name.indexOf("wcdb") === -1 && name.indexOf("sqlcipher") === -1 && - name.indexOf("wechat") === -1 && name.indexOf("sqlite") === -1) { - continue; - } - try { - var modObj = Process.findModuleByName(mod.name); - if (!modObj) continue; - var prepAddr = modObj.findExportByName("sqlite3_prepare_v2"); - if (prepAddr && !prepAddr.isNull()) { - (function (addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var sqlPtr = args[1]; - try { - var sql = sqlPtr.readUtf8String(); - if (sql && sql.toUpperCase().indexOf("PRAGMA CIPHER") !== -1) { - reportInfo("sqlite3_prepare_v2 cipher: " + sql); - } - } catch (e) {} - } - }); - })(prepAddr); - } - } catch (e) {} - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// JAVA LAYER: Message Sending + ContentProvider Bypass -// ═══════════════════════════════════════════════════════════════════════════════ - -function hookNetSceneSendMsg() { - try { - var NetSceneSendMsg = Java.use("u01.r0"); - - // Constructor: (String toUser, String content, int type, int flag, long time) - NetSceneSendMsg.$init.overload('java.lang.String', 'java.lang.String', 'int', 'int', 'long').implementation = function(toUser, content, type, flag, time) { - reportMsgEvent("outgoing", toUser, content, type, { flag: flag, time: time }); - return this.$init(toUser, content, type, flag, time); - }; - - // Constructor: (long msgId, int type, String toUser) - resend - try { - NetSceneSendMsg.$init.overload('long', 'int', 'java.lang.String').implementation = function(msgId, type, toUser) { - reportMsgEvent("outgoing_resend", toUser, "", type, { msgId: msgId }); - return this.$init(msgId, type, toUser); - }; - } catch (e) {} - - reportInfo("[JavaHook] NetSceneSendMsg constructors hooked"); - return true; - } catch (e) { - reportInfo("[JavaHook] NetSceneSendMsg not found: " + e); - return false; - } -} - -function hookContentProviderAuthBypass() { - try { - var ExtBase = Java.use("com.tencent.mm.plugin.ext.provider.ExtContentProviderBase"); - - // Method h() returns int (1 = success, others = error codes) - ExtBase.h.implementation = function() { - var original = this.h(); - if (original !== 1) { - reportInfo("[Bypass] ExtContentProviderBase.h() returned " + original + " -> forcing 1"); - return 1; - } - return original; - }; - - // Method b() returns boolean (isLogin) - ExtBase.b.implementation = function() { - return true; - }; - - // Method i() returns boolean (app validation) - try { - ExtBase.i.implementation = function(context) { - return true; - }; - } catch (e) {} - - reportInfo("[JavaHook] ExtContentProviderBase auth bypass installed"); - return true; - } catch (e) { - reportInfo("[JavaHook] ExtContentProviderBase not found: " + e); - return false; - } -} - -function hookMessageStorage() { - try { - // Hook MsgInfo (f9) setters to monitor message content being set - var MsgInfo = Java.use("com.tencent.mm.storage.f9"); - - // u1 sets talker (toUser) - try { - MsgInfo.u1.implementation = function(talker) { - this.u1(talker); - // Store temporarily for correlation - this._talker = talker; - }; - } catch (e) {} - - // c1 sets content - try { - MsgInfo.c1.implementation = function(content) { - this.c1(content); - if (this._talker) { - reportMsgEvent("storage", this._talker, content, this.getType ? this.getType() : 0, {}); - this._talker = null; - } - }; - } catch (e) {} - - reportInfo("[JavaHook] Message storage (f9) hooked"); - return true; - } catch (e) { - reportInfo("[JavaHook] storage.f9 not found: " + e); - return false; - } -} - -function hookSendMsgEvent() { - try { - var SendMsgEvent = Java.use("com.tencent.mm.autogen.events.SendMsgEvent"); - // Hook the event constructor to catch all send msg events - // This is a broader catch than NetSceneSendMsg - reportInfo("[JavaHook] SendMsgEvent class found (monitoring via constructor)"); - return true; - } catch (e) { - reportInfo("[JavaHook] SendMsgEvent not found: " + e); - return false; - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// RPC: Active Message Sending -// ═══════════════════════════════════════════════════════════════════════════════ - -rpc.exports = { - // Check if Java hooks are active - getStatus: function() { - return { - pid: Process.id, - arch: Process.arch, - javaAvailable: (typeof Java !== 'undefined' && Java.available), - javaHooksActive: javaHooksActive - }; - }, - - // Send text message via internal API - // Requires Java.available = true and correct process - sendTextMsg: function(toUser, content) { - if (typeof Java === 'undefined' || !Java.available) { - return { success: false, error: "Java not available in this process" }; - } - - return Java.perform(function() { - try { - // Method 1: Use the builder pattern (u01.q1 -> u01.m1) - var Builder = Java.use("u01.q1"); - var R1 = Java.use("u01.r1"); - - // Create builder via factory - var builder = R1.a(toUser); - builder.e(content); // set content - builder.h(0); // contactType (0 for normal contact) - builder.f.value = 0; // internal flag - builder.i.value = 4; // scene type - - // Build and submit - var result = builder.a(); // returns u01.m1 - if (result && result.a.value) { - // Submit via m1.a() which calls modelbase.r1.h() - var submitted = result.a(); - return { - success: submitted, - msgId: result.b.value.toString() - }; - } - - return { success: false, error: "Failed to build NetScene" }; - } catch (e) { - // Method 2: Direct NetSceneSendMsg construction - try { - var NetSceneSendMsg = Java.use("u01.r0"); - var now = Date.now(); - var scene = NetSceneSendMsg.$new(toUser, content, 1, 0, now); - - // Get dispatcher and submit - var Dispatcher = Java.use("com.tencent.mm.modelbase.r1"); - var dispatcher = Dispatcher.e(); // singleton getter - var ok = dispatcher.g(scene); - - return { success: ok, method: "direct_constructor" }; - } catch (e2) { - return { success: false, error: e2.toString(), fallbackError: e.toString() }; - } - } - }); - }, - - // Alternative: Send via ContentProvider (requires auth bypass) - sendViaProvider: function(toUser, content) { - if (typeof Java === 'undefined' || !Java.available) { - return { success: false, error: "Java not available" }; - } - - return Java.perform(function() { - try { - var Context = Java.use("android.app.ActivityThread").currentApplication(); - var ContentResolver = Context.getContentResolver(); - - // Build URI for sendTextMsg - // content://com.tencent.mm.plugin.ext.message/sendTextMsg?appid=wx123 - var Uri = Java.use("android.net.Uri"); - var uri = Uri.parse("content://com.tencent.mm.plugin.ext.message/sendTextMsg?appid=wx00000000000000&source=openapi"); - - // selectionArgs[0] = encrypted contact id, selectionArgs[1] = message - // For bypass mode, we try raw wxid - var selectionArgs = Java.array("java.lang.String", [toUser, content]); - - var cursor = ContentResolver.query(uri, null, null, selectionArgs, null); - var result = { success: cursor !== null, hasCursor: !!cursor }; - - if (cursor) { - try { - if (cursor.moveToFirst()) { - var msgIdIdx = cursor.getColumnIndex("msgId"); - var retCodeIdx = cursor.getColumnIndex("retCode"); - if (msgIdIdx >= 0) result.msgId = cursor.getString(msgIdIdx); - if (retCodeIdx >= 0) result.retCode = cursor.getString(retCodeIdx); - } - } finally { - cursor.close(); - } - } - return result; - } catch (e) { - return { success: false, error: e.toString() }; - } - }); - }, - - // Get available WeChat DB paths - getDbPaths: function() { - if (typeof Java === 'undefined' || !Java.available) { - return { error: "Java not available" }; - } - return Java.perform(function() { - try { - var File = Java.use("java.io.File"); - var basePath = "/data/data/com.tencent.mm/MicroMsg"; - var base = File.$new(basePath); - var paths = []; - if (base.exists()) { - var files = base.listFiles(); - if (files) { - for (var i = 0; i < files.length; i++) { - var f = files[i]; - if (f.isDirectory()) { - var dbFile = File.$new(f.getAbsolutePath() + "/EnMicroMsg.db"); - if (dbFile.exists()) { - paths.push(dbFile.getAbsolutePath()); - } - } - } - } - } - return { paths: paths }; - } catch (e) { - return { error: e.toString() }; - } - }); - } -}; - -// ═══════════════════════════════════════════════════════════════════════════════ -// LATE-HOOK SYSTEM: Monitor dynamically loaded modules -// ═══════════════════════════════════════════════════════════════════════════════ - -function setupLateHook() { - var alreadyHooked = {}; - - function tryHookModule(modName) { - try { - var modObj = Process.findModuleByName(modName); - if (!modObj) return false; - - var hookedAny = false; - - var keyAddr = modObj.findExportByName("sqlite3_key"); - if (keyAddr && !keyAddr.isNull() && !alreadyHooked[modName + "!sqlite3_key"]) { - alreadyHooked[modName + "!sqlite3_key"] = true; - (function (lbl, addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var keyPtr = args[1]; - var keyLen = args[2].toInt32(); - if (!isPrintableKey(keyPtr, keyLen)) return; - var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); - reportKey(hex, keyLen, lbl, null, { native: true, v2: false, late: true }); - } - }); - })(modName + "!sqlite3_key", keyAddr); - hookedAny = true; - reportInfo("Late-hook " + modName + "!sqlite3_key"); - } - - return hookedAny; - } catch (e) { return false; } - } - - setInterval(function () { - var modules = Process.enumerateModules(); - for (var i = 0; i < modules.length; i++) { - var name = modules[i].name; - if (alreadyHooked[name]) continue; - alreadyHooked[name] = true; - - var lname = name.toLowerCase(); - if (lname.indexOf("wcdb") !== -1 || lname.indexOf("sqlcipher") !== -1 || - lname.indexOf("sqlite") !== -1) { - tryHookModule(name); - } - } - }, 2000); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// MAIN -// ═══════════════════════════════════════════════════════════════════════════════ - -function main() { - reportInfo("=== WeChat Android Full Hunter started ==="); - reportInfo("PID=" + Process.id + " | Arch=" + Process.arch); - - var javaAvail = (typeof Java !== 'undefined' && Java.available); - reportInfo("Java.available: " + javaAvail); - - // 1. Native hooks (always work) - var nativeKeyHooks = hookNativeSqlite3Key(); - reportInfo("Native sqlite3_key hooks: " + nativeKeyHooks.length + - (nativeKeyHooks.length ? " -> " + nativeKeyHooks.join(", ") : " (modules may load later)")); - - var nativeExecHooks = hookNativeSqliteExec(); - reportInfo("Native sqlite3_exec hooks: " + nativeExecHooks.length); - - hookNativeSqlitePrepare(); - - // 2. Java hooks (if available) - if (javaAvail) { - Java.perform(function () { - var results = []; - results.push({ name: "NetSceneSendMsg", ok: hookNetSceneSendMsg() }); - results.push({ name: "ContentProviderBypass", ok: hookContentProviderAuthBypass() }); - results.push({ name: "MessageStorage", ok: hookMessageStorage() }); - results.push({ name: "SendMsgEvent", ok: hookSendMsgEvent() }); - - var activeCount = 0; - for (var i = 0; i < results.length; i++) { - if (results[i].ok) activeCount++; - reportInfo("[JavaHook] " + results[i].name + ": " + (results[i].ok ? "OK" : "FAILED")); - } - javaHooksActive = (activeCount > 0); - reportInfo("Java hooks active: " + javaHooksActive + "/" + results.length); - }); - } else { - reportInfo("Java VM not available. Using native-only mode (key capture + SQL monitoring)."); - reportInfo("To enable send-msg features, attach to a process where Java.available=true."); - } - - // 3. Late-hook for dynamically loaded modules - setupLateHook(); - - reportInfo("=== All hooks installed. Waiting for events... ==="); -} - -main(); diff --git a/wechat_plain/scripts/wechat_android_key_hunter.js b/wechat_plain/scripts/wechat_android_key_hunter.js deleted file mode 100644 index b8baea5..0000000 --- a/wechat_plain/scripts/wechat_android_key_hunter.js +++ /dev/null @@ -1,486 +0,0 @@ -/* - * WeChat Android SQLCipher Key Hunter (Frida 17.x) - * Targets WeChat 8.0.72 on Android with WCDB/SQLCipher - * - * Strategy: - * 1. Hook Java layer: com.tencent.wcdb.database.SQLiteDatabase.openDatabase variants - * 2. Hook native layer: sqlite3_key / sqlite3_key_v2 - * 3. Hook SQLiteConnection / SQLiteCipherSpec if needed - */ - -var TAG = "[WeChatKeyHunter]"; -var seenKeys = {}; -var targetDbNames = ["EnMicroMsg.db", "EnMicroMsg"]; - -function isTargetDb(path) { - if (!path) return false; - var s = path.toString(); - for (var i = 0; i < targetDbNames.length; i++) { - if (s.indexOf(targetDbNames[i]) !== -1) return true; - } - return false; -} - -function bytesToHex(bytes, len) { - len = len || bytes.length; - var hex = ""; - for (var i = 0; i < len; i++) { - var b = bytes[i] & 0xFF; - hex += (b < 16 ? "0" : "") + b.toString(16); - } - return hex; -} - -function isPrintableKey(keyPtr, keyLen) { - // SQLCipher keys are usually 7-32 bytes (passwords) or raw 32-byte AES keys - if (keyLen < 4 || keyLen > 64) return false; - try { - var raw = keyPtr.readByteArray(keyLen); - if (!raw) return false; - var bytes = new Uint8Array(raw); - // Skip trivial keys - var map = {}, unique = 0; - for (var i = 0; i < bytes.length; i++) { - if (!map[bytes[i]]) { map[bytes[i]] = true; unique++; } - } - return unique >= 3; - } catch (e) { return false; } -} - -function reportKey(keyHex, keyLen, via, path, extra) { - var cacheKey = keyHex + "@" + (path || ""); - if (seenKeys[cacheKey]) return; - seenKeys[cacheKey] = true; - - send({ - type: "key", - hex: keyHex, - len: keyLen, - via: via, - path: path || "", - extra: extra || {} - }); -} - -function reportInfo(msg) { - send({ type: "info", msg: msg }); -} - -function reportError(msg) { - send({ type: "error", msg: msg }); -} - -// ── Native layer: sqlite3_key / sqlite3_key_v2 ─────────────────────────────── - -function hookNativeSqlite3Key() { - var modules = Process.enumerateModules(); - var hooked = []; - - for (var i = 0; i < modules.length; i++) { - var mod = modules[i]; - var name = mod.name.toLowerCase(); - // WCDB / SQLCipher likely lives in libwcdb.so, libsqlcipher.so, - // or bundled inside libwechatcommon.so / libwechatmm.so - if (name.indexOf("wcdb") === -1 && - name.indexOf("sqlcipher") === -1 && - name.indexOf("wechat") === -1 && - name.indexOf("sqlite") === -1) { - continue; - } - - try { - var modObj = Process.findModuleByName(mod.name); - if (!modObj) continue; - - // Try direct findExportByName first (works even when enumerateExports misses symbols) - var keyAddr = modObj.findExportByName("sqlite3_key"); - var keyV2Addr = modObj.findExportByName("sqlite3_key_v2"); - - if (keyAddr && !keyAddr.isNull()) { - var label = mod.name + "!sqlite3_key"; - (function (lbl, addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var dbPtr = args[0]; - var keyPtr = args[1]; - var keyLen = args[2].toInt32(); - if (!isPrintableKey(keyPtr, keyLen)) return; - var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); - reportKey(hex, keyLen, lbl, null, { native: true, v2: false, db: dbPtr.toString() }); - } - }); - })(label, keyAddr); - hooked.push(label); - } - - if (keyV2Addr && !keyV2Addr.isNull()) { - var label2 = mod.name + "!sqlite3_key_v2"; - (function (lbl, addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var dbPtr = args[0]; - var keyPtr = args[2]; - var keyLen = args[3].toInt32(); - if (!isPrintableKey(keyPtr, keyLen)) return; - var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); - reportKey(hex, keyLen, lbl, null, { native: true, v2: true, db: dbPtr.toString() }); - } - }); - })(label2, keyV2Addr); - hooked.push(label2); - } - - // Also hook sqlite3_open_v2 to capture db paths - var openV2Addr = modObj.findExportByName("sqlite3_open_v2"); - if (openV2Addr && !openV2Addr.isNull()) { - (function (addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var pathPtr = args[0]; - var flags = args[1].toInt32(); - try { - var path = pathPtr.readUtf8String(); - if (path && path.indexOf("EnMicroMsg") !== -1) { - send({ type: "info", msg: "sqlite3_open_v2: " + path + " flags=" + flags }); - } - } catch (e) {} - } - }); - })(openV2Addr); - } - - // Hook sqlite3_exec to catch PRAGMA key - var execAddr = modObj.findExportByName("sqlite3_exec"); - if (execAddr && !execAddr.isNull()) { - (function (addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var sqlPtr = args[1]; - try { - var sql = sqlPtr.readUtf8String(); - if (sql && sql.toUpperCase().indexOf("PRAGMA KEY") !== -1) { - send({ type: "info", msg: "sqlite3_exec PRAGMA: " + sql }); - } - } catch (e) {} - } - }); - })(execAddr); - } - } catch (e) { - // ignore - } - } - - return hooked; -} - -// ── Java layer: WCDB SQLiteDatabase ─────────────────────────────────────────── - -function hookWcdbJava() { - var SQLiteDatabase; - try { - SQLiteDatabase = Java.use("com.tencent.wcdb.database.SQLiteDatabase"); - } catch (e) { - reportInfo("WCDB SQLiteDatabase class not found, trying androidx/sqlite..."); - try { - SQLiteDatabase = Java.use("android.database.sqlite.SQLiteDatabase"); - } catch (e2) { - reportError("Neither WCDB nor standard SQLiteDatabase found: " + e2); - return []; - } - } - - var hooked = []; - - // Overload 1: openDatabase(String path, CursorFactory factory, int flags) - try { - var open1 = SQLiteDatabase.openDatabase.overload( - "java.lang.String", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", "int" - ); - open1.implementation = function (path, factory, flags) { - if (isTargetDb(path)) { - reportInfo("openDatabase(path, factory, flags) -> " + path); - } - return open1.call(this, path, factory, flags); - }; - hooked.push("openDatabase(String,CursorFactory,int)"); - } catch (e) {} - - // Overload 2: openDatabase(String path, CursorFactory factory, int flags, String password) - try { - var open2 = SQLiteDatabase.openDatabase.overload( - "java.lang.String", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", - "int", "java.lang.String" - ); - open2.implementation = function (path, factory, flags, password) { - if (isTargetDb(path) && password) { - var pw = password.toString(); - reportKey(pw, pw.length, "WCDB.openDatabase(password)", path, - { flags: flags, type: "string_password" }); - } - return open2.call(this, path, factory, flags, password); - }; - hooked.push("openDatabase(String,CursorFactory,int,String)"); - } catch (e) {} - - // Overload 3: openDatabase(String path, CursorFactory factory, int flags, SQLiteDatabaseErrorHandler errorHandler, String password) - try { - var open3 = SQLiteDatabase.openDatabase.overload( - "java.lang.String", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", - "int", "com.tencent.wcdb.database.SQLiteDatabaseErrorHandler", "java.lang.String" - ); - open3.implementation = function (path, factory, flags, errorHandler, password) { - if (isTargetDb(path) && password) { - var pw = password.toString(); - reportKey(pw, pw.length, "WCDB.openDatabase(errHandler,password)", path, - { flags: flags, type: "string_password" }); - } - return open3.call(this, path, factory, flags, errorHandler, password); - }; - hooked.push("openDatabase(String,CursorFactory,int,ErrorHandler,String)"); - } catch (e) {} - - // Overload 4: openDatabase(String path, byte[] password, ...) - try { - var open4 = SQLiteDatabase.openDatabase.overload( - "java.lang.String", "[B", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", "int" - ); - open4.implementation = function (path, password, factory, flags) { - if (isTargetDb(path) && password) { - var bytes = Java.array("byte", password); - var hex = bytesToHex(bytes); - reportKey(hex, bytes.length, "WCDB.openDatabase(byte[])", path, - { flags: flags, type: "byte_array_password" }); - } - return open4.call(this, path, password, factory, flags); - }; - hooked.push("openDatabase(String,byte[],CursorFactory,int)"); - } catch (e) {} - - // Hook constructor that takes password - try { - var ctor1 = SQLiteDatabase.$init.overload( - "java.lang.String", "int", "com.tencent.wcdb.database.SQLiteDatabase$CursorFactory", - "com.tencent.wcdb.support.CancellationSignal" - ); - ctor1.implementation = function (path, flags, factory, cancel) { - if (isTargetDb(path)) { - reportInfo("SQLiteDatabase constructor -> " + path); - } - ctor1.call(this, path, flags, factory, cancel); - }; - hooked.push("constructor(String,int,CursorFactory,CancellationSignal)"); - } catch (e) {} - - return hooked; -} - -// ── Java layer: SQLiteCipherSpec (cipher configuration) ────────────────────── - -function hookCipherSpec() { - var specs = []; - try { - var SQLiteCipherSpec = Java.use("com.tencent.wcdb.database.SQLiteCipherSpec"); - specs.push("SQLiteCipherSpec"); - - // Hook setters to capture cipher config - try { - SQLiteCipherSpec.setPageSize.implementation = function (size) { - send({ type: "cipher_spec", field: "pageSize", value: size }); - return this.setPageSize(size); - }; - } catch (e) {} - - try { - SQLiteCipherSpec.setKDFIteration.implementation = function (iter) { - send({ type: "cipher_spec", field: "kdfIteration", value: iter }); - return this.setKDFIteration(iter); - }; - } catch (e) {} - - try { - SQLiteCipherSpec.setHMACUse.implementation = function (use) { - send({ type: "cipher_spec", field: "hmacUse", value: use }); - return this.setHMACUse(use); - }; - } catch (e) {} - } catch (e) {} - - return specs; -} - -// ── Java layer: SQLiteOpenHelper (higher-level wrapper) ────────────────────── - -function hookOpenHelper() { - var helpers = []; - try { - var SQLiteOpenHelper = Java.use("com.tencent.wcdb.database.SQLiteOpenHelper"); - helpers.push("SQLiteOpenHelper"); - - try { - var getDB = SQLiteOpenHelper.getWritableDatabase.overload("java.lang.String"); - getDB.implementation = function (password) { - if (password) { - var pw = password.toString(); - send({ type: "info", msg: "SQLiteOpenHelper.getWritableDatabase(password=" + pw + ")" }); - } - return getDB.call(this, password); - }; - } catch (e) {} - - try { - var getDB2 = SQLiteOpenHelper.getWritableDatabase.overload("[B"); - getDB2.implementation = function (password) { - if (password) { - var bytes = Java.array("byte", password); - var hex = bytesToHex(bytes); - send({ type: "key", hex: hex, len: bytes.length, - via: "SQLiteOpenHelper.getWritableDatabase(byte[])", path: "" }); - } - return getDB2.call(this, password); - }; - } catch (e) {} - } catch (e) {} - - return helpers; -} - -// ── Auto-detect database path by scanning /data/data/com.tencent.mm ────────── - -function scanWechatDbPath() { - Java.perform(function () { - var File = Java.use("java.io.File"); - var basePath = "/data/data/com.tencent.mm/MicroMsg"; - try { - var base = File.$new(basePath); - if (!base.exists()) { - reportInfo("MicroMsg base path not found"); - return; - } - var files = base.listFiles(); - if (!files) { - reportInfo("No files in MicroMsg"); - return; - } - for (var i = 0; i < files.length; i++) { - var f = files[i]; - if (f.isDirectory()) { - var name = f.getName(); - var dbFile = File.$new(f.getAbsolutePath() + "/EnMicroMsg.db"); - if (dbFile.exists()) { - reportInfo("FOUND_DB: " + dbFile.getAbsolutePath()); - } - } - } - } catch (e) { - reportError("Scan error: " + e); - } - }); -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -function main() { - reportInfo("=== WeChat Android Key Hunter started ==="); - reportInfo("Process: " + Process.id + " | Arch: " + Process.arch); - reportInfo("Java.available: " + (typeof Java !== 'undefined' ? Java.available : false)); - - // 1. Native hooks (work immediately, no Java required) - var nativeHooks = hookNativeSqlite3Key(); - reportInfo("Native sqlite3_key hooks: " + nativeHooks.length + - (nativeHooks.length ? " -> " + nativeHooks.join(", ") : " (none found yet, modules may load later)")); - - // 2. Java hooks (require Java VM) - if (typeof Java !== 'undefined' && Java.available) { - Java.perform(function () { - var javaHooks = hookWcdbJava(); - reportInfo("WCDB Java hooks: " + javaHooks.length + - (javaHooks.length ? " -> " + javaHooks.join(", ") : " (none found)")); - - var cipherSpecs = hookCipherSpec(); - reportInfo("CipherSpec hooks: " + cipherSpecs.length); - - var helpers = hookOpenHelper(); - reportInfo("OpenHelper hooks: " + helpers.length); - - // Scan for DB path - scanWechatDbPath(); - }); - } else { - reportInfo("Java VM not available in this process, relying on native hooks only."); - } - - // 3. Monitor for new modules being loaded (WCDB/SQLCipher may load dynamically) - var alreadyHookedModules = {}; - function tryHookModuleSqlite3Key(modName) { - try { - var modObj = Process.findModuleByName(modName); - if (!modObj) return false; - - var hookedAny = false; - - var keyAddr = modObj.findExportByName("sqlite3_key"); - if (keyAddr && !keyAddr.isNull() && !alreadyHookedModules[modName + "!sqlite3_key"]) { - alreadyHookedModules[modName + "!sqlite3_key"] = true; - var label = modName + "!sqlite3_key"; - (function (lbl, addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var keyPtr = args[1]; - var keyLen = args[2].toInt32(); - if (!isPrintableKey(keyPtr, keyLen)) return; - var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); - reportKey(hex, keyLen, lbl, null, { native: true, v2: false, late: true }); - } - }); - })(label, keyAddr); - hookedAny = true; - reportInfo("Late-hook " + label + " at " + keyAddr); - } - - var keyV2Addr = modObj.findExportByName("sqlite3_key_v2"); - if (keyV2Addr && !keyV2Addr.isNull() && !alreadyHookedModules[modName + "!sqlite3_key_v2"]) { - alreadyHookedModules[modName + "!sqlite3_key_v2"] = true; - var label2 = modName + "!sqlite3_key_v2"; - (function (lbl, addr) { - Interceptor.attach(addr, { - onEnter: function (args) { - var keyPtr = args[2]; - var keyLen = args[3].toInt32(); - if (!isPrintableKey(keyPtr, keyLen)) return; - var hex = bytesToHex(new Uint8Array(keyPtr.readByteArray(keyLen))); - reportKey(hex, keyLen, lbl, null, { native: true, v2: true, late: true }); - } - }); - })(label2, keyV2Addr); - hookedAny = true; - reportInfo("Late-hook " + label2 + " at " + keyV2Addr); - } - - return hookedAny; - } catch (e) { - return false; - } - } - - setInterval(function () { - var modules = Process.enumerateModules(); - for (var i = 0; i < modules.length; i++) { - var name = modules[i].name; - if (alreadyHookedModules[name]) continue; - alreadyHookedModules[name] = true; - - var lname = name.toLowerCase(); - if (lname.indexOf("wcdb") !== -1 || - lname.indexOf("sqlcipher") !== -1 || - lname.indexOf("sqlite") !== -1) { - reportInfo("New module loaded: " + name); - tryHookModuleSqlite3Key(name); - } - } - }, 2000); - - reportInfo("=== All hooks installed. Waiting for EnMicroMsg.db access... ==="); -} - -main(); diff --git a/wechat_plain/scripts/wechat_android_toolkit.tar.gz b/wechat_plain/scripts/wechat_android_toolkit.tar.gz deleted file mode 100644 index 263643f..0000000 Binary files a/wechat_plain/scripts/wechat_android_toolkit.tar.gz and /dev/null differ diff --git a/wechat_plain/scripts/wechat_earlybird.py b/wechat_plain/scripts/wechat_earlybird.py deleted file mode 100644 index da675c3..0000000 --- a/wechat_plain/scripts/wechat_earlybird.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -WeChat early-bird key capture: -1. Kill WeChat -2. Poll until new WeChat process appears -3. Attach Frida immediately -4. Capture CCCryptorCreate keys during login / DB open -""" -import sys, os, time, subprocess, threading -sys.path.insert(0, '/Users/yibo/Library/Python/3.9/lib/python/site-packages') -import frida - -SQLCIPHER = '/opt/homebrew/bin/sqlcipher' -DB_BASE = '/Users/yibo/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/yibooo_ac07/db_storage' - -JS = r""" -var seen = {}; - -function uniqueByteCount(arr) { - var map = {}; - var n = 0; - for (var i = 0; i < arr.length; i++) { - if (!map[arr[i]]) { map[arr[i]] = true; n++; } - } - return n; -} - -function tryCapture(keyPtr, keyLen, label) { - try { - if (keyLen !== 32) return; - var raw = keyPtr.readByteArray(32); - if (!raw) return; - var bytes = new Uint8Array(raw); - if (uniqueByteCount(bytes) < 8) return; - var hex = ''; - for (var i = 0; i < bytes.length; i++) { - hex += ('0' + bytes[i].toString(16)).slice(-2); - } - if (!seen[hex]) { - seen[hex] = true; - send({type:'key', hex:hex, len:keyLen, via:label}); - } - } catch(e) { - send({type:'err', msg: e.toString(), label: label}); - } -} - -var found = {}; - -var addr1 = Module.findGlobalExportByName('CCCryptorCreate'); -found['CCCryptorCreate'] = !!addr1; -if (addr1) { - Interceptor.attach(addr1, { onEnter: function(args) { - if (args[1].toInt32() === 0) tryCapture(args[3], args[4].toInt32(), 'CCCryptorCreate'); - }}); -} - -var addr2 = Module.findGlobalExportByName('CCCryptorCreateWithMode'); -found['CCCryptorCreateWithMode'] = !!addr2; -if (addr2) { - Interceptor.attach(addr2, { onEnter: function(args) { - if (args[2].toInt32() === 0) tryCapture(args[5], args[6].toInt32(), 'CCCryptorCreateWithMode'); - }}); -} - -var addr3 = Module.findGlobalExportByName('CCCrypt'); -found['CCCrypt'] = !!addr3; -if (addr3) { - Interceptor.attach(addr3, { onEnter: function(args) { - if (args[1].toInt32() === 0) tryCapture(args[3], args[4].toInt32(), 'CCCrypt'); - }}); -} - -send({type:'ready', found:found}); -""" - -def verify_key(key_hex, rel): - path = os.path.join(DB_BASE, rel) - if not os.path.exists(path): return False - r = subprocess.run([SQLCIPHER, path], - input=f"PRAGMA key=\"x'{key_hex}'\";\nSELECT count(*) FROM sqlite_master;\n", - capture_output=True, text=True, timeout=10) - lines = [l.strip() for l in r.stdout.split('\n') if l.strip() and l.strip() != 'ok'] - return bool(lines) and lines[0].isdigit() - -TEST_DBS = [ - 'contact/contact.db', - 'message/message_0.db', - 'session/session.db', - 'general/general.db', - 'message/message_1.db', -] - -confirmed = {} # key_hex -> [dbs] - -def on_key(key_hex, via, keylen): - print(f'\n [key] len={keylen} via={via} {key_hex[:24]}...') - matched = [] - for rel in TEST_DBS: - try: - if verify_key(key_hex, rel): - matched.append(rel) - print(f' ✓ matches {rel}') - except Exception as e: - pass - if not matched: - print(f' (no match in test set)') - else: - confirmed[key_hex] = matched - -# ── main ────────────────────────────────────────────────────────────────────── -print('[1] Killing WeChat...') -subprocess.run(['killall', 'WeChat'], capture_output=True) -time.sleep(2) - -print('[2] Waiting for WeChat to start (open 微信.app now)...') -print(' → 请现在手动打开微信.app') - -pid = None -for _ in range(120): # wait up to 2 min - out = subprocess.run(['pgrep','-x','WeChat'], capture_output=True, text=True).stdout.strip() - if out: - pid = int(out.split('\n')[0]) - break - time.sleep(0.5) - -if not pid: - print('[!] WeChat did not start within 2 minutes') - sys.exit(1) - -print(f'[3] WeChat PID={pid} detected, attaching Frida NOW...') -session = frida.attach(pid) -script = session.create_script(JS) - -def on_msg(msg, data): - if msg['type'] == 'error': - print(f'[JS ERROR] {msg["description"]}') - return - if msg['type'] != 'send': return - p = msg['payload'] - if p['type'] == 'ready': - print(f'[4] Hooks: {p["found"]}') - print(' → 请登录微信,然后慢慢浏览各个聊天窗口\n') - elif p['type'] == 'key': - on_key(p['hex'], p['via'], p['len']) - elif p['type'] == 'err': - print(f' [js-err] {p["label"]}: {p["msg"]}') - -script.on('message', on_msg) -script.load() -print('[*] Script loaded. Monitoring... (Ctrl+C to stop)\n') - -try: - while True: - time.sleep(1) -except KeyboardInterrupt: - pass - -print(f'\n[结果] 捕获到 {len(confirmed)} 个已验证 key:') -for k, dbs in confirmed.items(): - print(f' {k} → {dbs}') -session.detach() diff --git a/wechat_plain/scripts/wechat_export_all.py b/wechat_plain/scripts/wechat_export_all.py deleted file mode 100755 index 7c1b228..0000000 --- a/wechat_plain/scripts/wechat_export_all.py +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env python3 -""" -WeChat 全库解密导出工具 -步骤: - 1. Frida hook 持续捕获 AES key - 2. 每个新 key 试匹配所有未解锁的数据库 - 3. 匹配成功 → 立即用 sqlcipher_export() 导出为明文 SQLite - 4. 所有库解完 or 超时后退出 - -用法: - python3 ~/Downloads/wechat_export_all.py - -输出: - ~/Documents/wechat_plain/ 目录下各数据库的明文副本 -""" - -import sys, os, time, subprocess, threading, shutil - -# ── 路径 ────────────────────────────────────────────────────────────── -sys.path.insert(0, "/Users/yibo/Library/Python/3.9/lib/python/site-packages") -import frida - -DB_BASE = "/Users/yibo/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/yibooo_ac07/db_storage" -OUT_DIR = "/Users/yibo/Documents/wechat_plain" -KEY_CACHE = "/Users/yibo/Documents/wechat_plain/keys.txt" -SQLCIPHER = "/opt/homebrew/bin/sqlcipher" - -# ── 所有数据库(相对 DB_BASE)────────────────────────────────────────── -ALL_DBS = [ - # 消息(按大小降序,优先处理) - "message/message_1.db", - "message/message_2.db", - "message/message_3.db", - "message/message_4.db", - "message/message_5.db", - "message/message_6.db", - "message/message_7.db", - "message/message_8.db", - "message/message_9.db", - "message/message_10.db", - "message/message_0.db", - "message/message_fts.db", - "message/media_0.db", - "message/media_1.db", - "message/message_resource.db", - "message/biz_message_0.db", - "message/biz_message_1.db", - # 联系人 - "contact/contact.db", - "contact/contact_fts.db", - # 会话 - "session/session.db", - # 其他 - "sns/sns.db", - "general/general.db", - "favorite/favorite.db", - "favorite/favorite_fts.db", - "head_image/head_image.db", - "hardlink/hardlink.db", - "bizchat/bizchat.db", - "emoticon/emoticon.db", - "migrate/unspportmsg.db", - "solitaire/solitaire.db", -] - -os.makedirs(OUT_DIR, exist_ok=True) - -# ── 已知 key(上次运行结果)──────────────────────────────────────────── -known_keys = {} # hex_key → set of verified db relpath -pending_dbs = list(ALL_DBS) # 还没匹配到 key 的数据库 -exported_dbs = set() - -def load_key_cache(): - """从缓存文件加载已知 key""" - if not os.path.exists(KEY_CACHE): - return - with open(KEY_CACHE) as f: - for line in f: - line = line.strip() - if ':' in line: - relpath, key = line.split(':', 1) - known_keys[key] = known_keys.get(key, set()) - known_keys[key].add(relpath) - if relpath in pending_dbs: - pending_dbs.remove(relpath) - if known_keys: - print(f"[cache] 加载 {len(known_keys)} 个已知 key,{len(pending_dbs)} 个库待解") - -def save_key_cache(): - with open(KEY_CACHE, 'w') as f: - for key, dbs in known_keys.items(): - for db in dbs: - f.write(f"{db}:{key}\n") - -# ── sqlcipher 验证 ────────────────────────────────────────────────────── -def try_open(key_hex, relpath): - """返回 True 表示 key 能打开这个数据库""" - db_path = os.path.join(DB_BASE, relpath) - if not os.path.exists(db_path): - return False - r = subprocess.run( - [SQLCIPHER, db_path], - input=f"PRAGMA key = \"x'{key_hex}'\";\nSELECT count(*) FROM sqlite_master;\n", - capture_output=True, text=True, timeout=15 - ) - lines = [l for l in r.stdout.split('\n') if l.strip() and l.strip() != 'ok'] - return bool(lines) and lines[0].strip().isdigit() - -# ── 导出函数 ──────────────────────────────────────────────────────────── -export_lock = threading.Lock() - -def export_db(key_hex, relpath): - """用 sqlcipher_export 把加密库导出为明文 SQLite""" - src = os.path.join(DB_BASE, relpath) - name = relpath.replace('/', '_') - dst = os.path.join(OUT_DIR, name) - tmp = dst + '.tmp' - - # 跳过已导出 - if relpath in exported_dbs: - return - if os.path.exists(dst): - sz = os.path.getsize(dst) - if sz > 4096: - print(f" [skip] {name} 已存在 ({sz//1024//1024} MB)") - exported_dbs.add(relpath) - return - - src_sz = os.path.getsize(src) - print(f"\n [export] {name} ({src_sz//1024//1024} MB) → {dst}") - t0 = time.time() - - sql = "\n".join([ - f"PRAGMA key = \"x'{key_hex}'\";", - "PRAGMA cipher_page_size = 4096;", - f"ATTACH DATABASE '{tmp}' AS plain KEY '';", - "SELECT sqlcipher_export('plain');", - "DETACH DATABASE plain;", - ]) - - r = subprocess.run( - [SQLCIPHER, src], - input=sql, capture_output=True, text=True, timeout=600 - ) - - elapsed = time.time() - t0 - if r.returncode == 0 and os.path.exists(tmp) and os.path.getsize(tmp) > 4096: - os.rename(tmp, dst) - out_sz = os.path.getsize(dst) - print(f" [✓] {name}: {src_sz//1024//1024} MB → {out_sz//1024//1024} MB 明文 ({elapsed:.1f}s)") - exported_dbs.add(relpath) - else: - if os.path.exists(tmp): - os.remove(tmp) - err = r.stderr.strip()[:200] - print(f" [✗] {name}: 导出失败 {err}") - -def match_key(key_hex): - """用新 key 尝试匹配所有待解数据库,匹配成功后后台导出""" - global pending_dbs - matched = [] - for relpath in list(pending_dbs): - try: - if try_open(key_hex, relpath): - matched.append(relpath) - except Exception: - pass - - if matched: - known_keys[key_hex] = known_keys.get(key_hex, set()) | set(matched) - for relpath in matched: - pending_dbs.remove(relpath) - save_key_cache() - print(f"\n[key] {key_hex[:16]}... → 匹配 {len(matched)} 个库: {matched}") - # 后台并发导出 - for relpath in matched: - t = threading.Thread(target=export_db, args=(key_hex, relpath), daemon=True) - t.start() - return matched - -# ── 对已知 key 立即导出 ───────────────────────────────────────────────── -def export_known(): - threads = [] - for key_hex, dbs in list(known_keys.items()): - for relpath in dbs: - t = threading.Thread(target=export_db, args=(key_hex, relpath), daemon=True) - t.start() - threads.append(t) - return threads - -# ── Frida 主逻辑 ───────────────────────────────────────────────────────── -WECHAT_APP = '/Applications/微信.app/Contents/MacOS/WeChat' -WECHAT_BUNDLE = 'com.tencent.xinWeChat' - -def make_on_msg(): - def on_msg(msg, data): - if msg['type'] != 'send': return - p = msg['payload'] - if p.get('type') == 'ready': - hooks = [] - if p.get('cc1'): hooks.append('CCCryptorCreate') - if p.get('cc2'): hooks.append('CCCryptorCreateWithMode') - if p.get('cc3'): hooks.append('CCCrypt') - print(f"[Frida] hooks active: {', '.join(hooks) if hooks else '(none!)'}") - elif p.get('type') == 'key': - key_hex = p['hex'] - via = p.get('via', '?') - print(f"\n[key captured via {via}] {key_hex[:16]}...") - match_key(key_hex) - return on_msg - -def run_frida_earlybird(): - """关掉微信,等用户手动打开后立即 attach(earlybird 模式)""" - existing = subprocess.run(['pgrep', '-x', 'WeChat'], - capture_output=True, text=True).stdout.strip() - if existing: - print(f"[*] 关闭现有 WeChat (PID={existing.split()[0]})...") - subprocess.run(['killall', 'WeChat'], capture_output=True) - time.sleep(2) - - print("[*] 请手动打开微信.app(双击 Dock 图标)") - print(" 脚本会在进程出现时立即 attach...\n") - - pid = None - for _ in range(120): - out = subprocess.run(['pgrep', '-x', 'WeChat'], - capture_output=True, text=True).stdout.strip() - if out: - pid = int(out.split('\n')[0]) - break - time.sleep(0.5) - - if not pid: - print("[!] 2 分钟内未检测到 WeChat,退出") - return None - - print(f"[*] WeChat PID={pid},立即 attach...") - js_code = open('/Users/yibo/Downloads/wechat_key_hunter.js').read() - session = frida.attach(pid) - script = session.create_script(js_code) - script.on('message', make_on_msg()) - script.load() - print("[!] Hook 已就绪,请在微信里操作:") - print(" · 滚动浏览聊天列表(触发各消息分片)") - print(" · 打开朋友圈、收藏、搜索、表情、公众号") - print(" · 按 Ctrl+C 停止监听并等待导出完成\n") - return session - -# ── 主程序 ────────────────────────────────────────────────────────────── -if __name__ == '__main__': - print("=" * 60) - print("WeChat 全库解密导出工具") - print(f"输出目录: {OUT_DIR}") - print("=" * 60) - - load_key_cache() - - # 先导出已知 key 对应的数据库 - threads = export_known() - - # 启动 Frida 捕获新 key - if pending_dbs: - print(f"\n[*] 还有 {len(pending_dbs)} 个库需要 key:") - for db in pending_dbs: - print(f" {db}") - - session = run_frida_earlybird() - if session: - try: - while pending_dbs: - time.sleep(2) - remaining = len(pending_dbs) - if remaining: - print(f"\r[待解] {remaining} 个库还需 key: " - f"{', '.join(db.split('/')[-1] for db in pending_dbs[:3])}" - f"{'...' if remaining > 3 else ''} ", end='', flush=True) - except KeyboardInterrupt: - print("\n\n[*] 停止监听") - finally: - session.detach() - else: - print("\n[*] 所有 key 已从缓存加载,直接开始导出") - - # 等待所有导出线程完成 - print("\n[*] 等待导出任务完成...") - active = threading.active_count() - while threading.active_count() > 1: - done = len(exported_dbs) - total = len(ALL_DBS) - len(pending_dbs) - print(f"\r 已完成 {done}/{total} 个数据库... ", end='', flush=True) - time.sleep(2) - - print(f"\n\n{'='*60}") - print(f"完成!导出 {len(exported_dbs)} 个数据库") - print(f"输出目录: {OUT_DIR}") - print(f"{'='*60}") - - # 统计输出文件大小 - total_size = 0 - for f in os.listdir(OUT_DIR): - if f.endswith('.db'): - sz = os.path.getsize(os.path.join(OUT_DIR, f)) - total_size += sz - print(f" {f:<45} {sz//1024//1024:>5} MB") - print(f"\n 总计: {total_size//1024//1024} MB") diff --git a/wechat_plain/scripts/wechat_key_hunter.js b/wechat_plain/scripts/wechat_key_hunter.js deleted file mode 100644 index 785cc75..0000000 --- a/wechat_plain/scripts/wechat_key_hunter.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * WeChat SQLCipher key hunter (Frida 17.x compatible) - * Hooks CCCryptorCreate + CCCryptorCreateWithMode + CCCrypt - */ - -var seen = {}; - -function captureKey(keyPtr, keyLen, label) { - try { - if (keyLen !== 32) return; - var raw = keyPtr.readByteArray(32); - if (!raw) return; - var bytes = new Uint8Array(raw); - // skip trivial keys (fewer than 8 unique byte values) - var map = {}, n = 0; - for (var i = 0; i < bytes.length; i++) { - if (!map[bytes[i]]) { map[bytes[i]] = true; n++; } - } - if (n < 8) return; - var hex = ''; - for (var j = 0; j < bytes.length; j++) { - hex += ('0' + bytes[j].toString(16)).slice(-2); - } - if (!seen[hex]) { - seen[hex] = true; - send({ type: 'key', hex: hex, via: label }); - } - } catch(e) {} -} - -var cc1 = Module.findGlobalExportByName('CCCryptorCreate'); -if (cc1) { - Interceptor.attach(cc1, { onEnter: function(args) { - if (args[1].toInt32() === 0) captureKey(args[3], args[4].toInt32(), 'CCCryptorCreate'); - }}); -} - -var cc2 = Module.findGlobalExportByName('CCCryptorCreateWithMode'); -if (cc2) { - Interceptor.attach(cc2, { onEnter: function(args) { - if (args[2].toInt32() === 0) captureKey(args[5], args[6].toInt32(), 'CCCryptorCreateWithMode'); - }}); -} - -var cc3 = Module.findGlobalExportByName('CCCrypt'); -if (cc3) { - Interceptor.attach(cc3, { onEnter: function(args) { - if (args[1].toInt32() === 0) captureKey(args[3], args[4].toInt32(), 'CCCrypt'); - }}); -} - -send({ type: 'ready', cc1: !!cc1, cc2: !!cc2, cc3: !!cc3 }); -- libgit2 0.21.4