mirror of https://github.com/jackwener/wx-cli.git
chore: 删除旧项目文件(MCP、Web UI、monitor 等)
parent
0d0ae22a98
commit
0d5ac82349
234
USAGE.md
234
USAGE.md
|
|
@ -1,234 +0,0 @@
|
|||
# Usage Examples / 使用案例
|
||||
|
||||
WeChat MCP Server 注册到 Claude Code 后,可以直接在对话中查询微信数据。以下是真实使用截图(敏感信息已打码)。
|
||||
|
||||
## 1. 查看最近会话
|
||||
|
||||
```
|
||||
> 帮我看看微信最近的会话
|
||||
```
|
||||
|
||||
Claude 自动调用 `get_recent_sessions`:
|
||||
|
||||
```
|
||||
最近 10 个会话:
|
||||
|
||||
[02-28 16:26] A]██交流群 [群] (7条未读)
|
||||
文本: 阿██: 打开就是那么大的一个
|
||||
|
||||
[02-28 16:26] ██俱乐部 [群] (105条未读)
|
||||
文本: S██: 死了就换另外一个
|
||||
|
||||
[02-28 16:26] ██Sol██社区 [群] (3条未读)
|
||||
文本: 洋██: 加密币已成中登资产,还没避险属性
|
||||
|
||||
[02-28 16:25] ██健身群 [群] (18条未读)
|
||||
文本: 风██: 不强求,多的就留着。刚需就洗了。
|
||||
|
||||
[02-28 16:24] ██社保 (24条未读)
|
||||
链接/文件: @年后换工作的你,社保怎么处理?看这里!
|
||||
|
||||
[02-28 16:22] 文件传输助手
|
||||
图片: (无内容)
|
||||
```
|
||||
|
||||
## 2. 查看群聊记录
|
||||
|
||||
```
|
||||
> 帮我看看██群在聊啥
|
||||
```
|
||||
|
||||
Claude 调用 `get_chat_history`,然后自动分析总结:
|
||||
|
||||
```
|
||||
██群今日话题:
|
||||
|
||||
1. AI工具讨论 (上午)
|
||||
群友对微信消息监听工具感兴趣,问了用什么模型、token贵不贵。
|
||||
|
||||
2. A股炒股 (下午)
|
||||
██想让朋友帮操作A股做T,群友一致劝阻——"赚了分他,亏了算你的"
|
||||
|
||||
3. 基金持仓 (傍晚)
|
||||
██手痒又买了有色基金
|
||||
|
||||
活跃度排名:
|
||||
| 排名 | 昵称 | 发言数 |
|
||||
|------|------|--------|
|
||||
| 1 | ██ | ~35条 |
|
||||
| 2 | ██ | ~15条 |
|
||||
| 3 | ██ | ~15条 |
|
||||
```
|
||||
|
||||
## 3. 搜索消息
|
||||
|
||||
```
|
||||
> 搜一下谁提过"claude"
|
||||
```
|
||||
|
||||
Claude 调用 `search_messages(keyword="claude")`:
|
||||
|
||||
```
|
||||
搜索 "claude" 找到 20 条结果:
|
||||
|
||||
[2026-02-28 10:25] [██AI群] ██: max套餐,就用opus模型
|
||||
[2026-02-28 10:31] [██AI群] ██: 我小龙虾也没用api接入的
|
||||
[2026-02-24 21:58] [██AI群] ██: Claude max 套餐给我干没了
|
||||
[2026-02-24 22:16] [██AI群] ██: 最简单的就是装claude
|
||||
...
|
||||
```
|
||||
|
||||
## 4. 时间范围 + 分页查看聊天记录
|
||||
|
||||
```
|
||||
> 帮我看一下██群 3 月 1 日到 3 月 7 日的聊天,先给我前 20 条
|
||||
```
|
||||
|
||||
Claude 可以调用:
|
||||
|
||||
```python
|
||||
get_chat_history(
|
||||
chat_name="██群",
|
||||
start_time="2026-03-01",
|
||||
end_time="2026-03-07",
|
||||
limit=20,
|
||||
offset=0,
|
||||
)
|
||||
```
|
||||
|
||||
下一页:
|
||||
|
||||
```python
|
||||
get_chat_history(
|
||||
chat_name="██群",
|
||||
start_time="2026-03-01",
|
||||
end_time="2026-03-07",
|
||||
limit=20,
|
||||
offset=20,
|
||||
)
|
||||
```
|
||||
|
||||
## 5. 搜索指定联系人/群聊在某个时间段内的消息
|
||||
|
||||
```
|
||||
> 帮我搜一下██群这周谁提到过 Claude
|
||||
```
|
||||
|
||||
Claude 可以调用统一接口:
|
||||
|
||||
```python
|
||||
search_messages(
|
||||
keyword="Claude",
|
||||
chat_name="██群",
|
||||
start_time="2026-03-01",
|
||||
end_time="2026-03-07",
|
||||
limit=20,
|
||||
offset=0,
|
||||
)
|
||||
```
|
||||
|
||||
## 6. 多个联系人/群聊联合搜索
|
||||
|
||||
```
|
||||
> 帮我看看联系人A、联系人B 和 ██项目群 这周谁提到过“项目”
|
||||
```
|
||||
|
||||
Claude 可以调用统一接口:
|
||||
|
||||
```python
|
||||
search_messages(
|
||||
keyword="项目",
|
||||
chat_name=["联系人A", "联系人B", "██项目群"],
|
||||
start_time="2026-03-01",
|
||||
end_time="2026-03-07",
|
||||
limit=20,
|
||||
offset=0,
|
||||
)
|
||||
```
|
||||
|
||||
如果某些名字没匹配到联系人,或没有对应消息表,结果里会单独说明。
|
||||
|
||||
## 7. 搜索联系人
|
||||
|
||||
```
|
||||
> 帮我找一下姓张的联系人
|
||||
```
|
||||
|
||||
Claude 调用 `get_contacts(query="张")`:
|
||||
|
||||
```
|
||||
找到 12 个联系人(搜索: 张):
|
||||
|
||||
wxid_████ 备注: 张██ 昵称: ██
|
||||
wxid_████ 备注: 张██ 昵称: 小██
|
||||
████@chatroom 昵称: 张██的工作群
|
||||
...
|
||||
```
|
||||
|
||||
## 8. 获取新消息
|
||||
|
||||
```
|
||||
> 有没有新消息
|
||||
```
|
||||
|
||||
Claude 调用 `get_new_messages()`:
|
||||
|
||||
```
|
||||
当前 5 个未读会话:
|
||||
|
||||
[16:26] ██交流群 [群] (7条未读): 打开就是那么大的一个
|
||||
[16:26] ██俱乐部 [群] (105条未读): 死了就换另外一个
|
||||
[16:25] ██健身群 [群] (18条未读): 不强求,多的就留着
|
||||
[16:24] ██社保 (24条未读): @年后换工作的你...
|
||||
[16:22] ██群 [群] (19条未读): (图片)
|
||||
```
|
||||
|
||||
## 9. 高级用法:群聊分析
|
||||
|
||||
Claude 可以获取大量消息后自动分析活跃度、话题分布、关键人物:
|
||||
|
||||
```
|
||||
> 帮我分析一下██群最近一周的情况
|
||||
```
|
||||
|
||||
Claude 会调用 `get_chat_history(chat_name="██群", limit=500)` 获取消息,然后输出。消息很多时,也可以把 `limit` 设得更大,或配合 `offset` 分页读取:
|
||||
|
||||
```
|
||||
## ██群最近一周分析
|
||||
|
||||
### 话题分布
|
||||
| 话题 | 占比 |
|
||||
|----------|-------|
|
||||
| AI工具 | ~40% |
|
||||
| 币圈行情 | ~25% |
|
||||
| 时事讨论 | ~20% |
|
||||
| 闲聊 | ~15% |
|
||||
|
||||
### 活跃度排名
|
||||
| 排名 | 昵称 | 发言数 | 角色 |
|
||||
|------|------|--------|------------|
|
||||
| 1 | ██ | ~90条 | 技术分享者 |
|
||||
| 2 | ██ | ~55条 | 深度讨论 |
|
||||
| 3 | ██ | ~25条 | 新闻搬运 |
|
||||
|
||||
### 群氛围
|
||||
这个群正在从██交流群转型成AI工具交流群...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup / 配置方法
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. 注册到 Claude Code
|
||||
claude mcp add wechat -- python C:\path\to\mcp_server.py
|
||||
|
||||
# 3. 在 Claude Code 中直接对话
|
||||
claude
|
||||
> 看看微信最近谁找我了
|
||||
```
|
||||
|
||||
前置条件:需要先运行 `find_all_keys.py` 提取密钥,并配置 `config.json`。详见 [README.md](README.md)。
|
||||
464
decode_image.py
464
decode_image.py
|
|
@ -1,464 +0,0 @@
|
|||
r"""
|
||||
微信图片 .dat 文件解密模块
|
||||
|
||||
支持两种加密格式:
|
||||
- 旧格式: 单字节 XOR 加密,key 通过对比文件头与已知图片 magic bytes 自动检测
|
||||
- V2 格式 (2025-08+): AES-128-ECB + XOR 混合加密,需要从微信进程内存提取 AES key
|
||||
|
||||
V2 文件结构:
|
||||
[6B signature: 07 08 V2 08 07] [4B aes_size LE] [4B xor_size LE] [1B padding]
|
||||
[aligned_aes_size bytes AES-ECB] [raw_data] [xor_size bytes XOR]
|
||||
|
||||
文件路径格式:
|
||||
D:\xwechat_files\<wxid>\msg\attach\<md5(username)>\<YYYY-MM>\Img\<file_md5>[_t|_h].dat
|
||||
|
||||
映射链:
|
||||
message_*.db (local_id) → message_resource.db (packed_info 含 MD5) → .dat 文件 → 解密
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import struct
|
||||
|
||||
# V2 格式完整 magic (6 bytes)
|
||||
V2_MAGIC = b'\x07\x08\x56\x32' # 前 4 字节用于快速检测
|
||||
V2_MAGIC_FULL = b'\x07\x08V2\x08\x07' # 完整 6 字节签名
|
||||
V1_MAGIC_FULL = b'\x07\x08V1\x08\x07' # V1 签名 (固定 key)
|
||||
|
||||
# 常见图片格式的 magic bytes (按长度降序排列,避免短 magic 假阳性)
|
||||
IMAGE_MAGIC = {
|
||||
'png': [0x89, 0x50, 0x4E, 0x47],
|
||||
'gif': [0x47, 0x49, 0x46, 0x38],
|
||||
'tif': [0x49, 0x49, 0x2A, 0x00], # little-endian TIFF
|
||||
'webp': [0x52, 0x49, 0x46, 0x46], # RIFF header
|
||||
'jpg': [0xFF, 0xD8, 0xFF],
|
||||
# BMP 只有 2 字节 magic,容易假阳性,需要额外验证
|
||||
}
|
||||
|
||||
|
||||
def is_v2_format(dat_path):
|
||||
"""检测是否是微信 V2 加密格式 (2025-08+)"""
|
||||
try:
|
||||
with open(dat_path, 'rb') as f:
|
||||
magic = f.read(4)
|
||||
return magic == V2_MAGIC
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
|
||||
|
||||
def detect_xor_key(dat_path):
|
||||
"""通过对比文件头和已知图片 magic bytes 自动检测 XOR key
|
||||
|
||||
返回 key (int) 或 None。V2 格式文件返回 None。
|
||||
"""
|
||||
with open(dat_path, 'rb') as f:
|
||||
header = f.read(16)
|
||||
|
||||
if len(header) < 4:
|
||||
return None
|
||||
|
||||
# V2 新格式无法用 XOR 解密
|
||||
if header[:4] == V2_MAGIC:
|
||||
return None
|
||||
|
||||
# 先尝试 3+ 字节 magic 的格式(可靠匹配)
|
||||
for fmt, magic in IMAGE_MAGIC.items():
|
||||
key = header[0] ^ magic[0]
|
||||
match = True
|
||||
for i in range(1, len(magic)):
|
||||
if i >= len(header):
|
||||
break
|
||||
if (header[i] ^ key) != magic[i]:
|
||||
match = False
|
||||
break
|
||||
if match:
|
||||
return key
|
||||
|
||||
# 最后尝试 BMP (2 字节 magic,需要额外验证)
|
||||
bmp_magic = [0x42, 0x4D]
|
||||
key = header[0] ^ bmp_magic[0]
|
||||
if len(header) >= 2 and (header[1] ^ key) == bmp_magic[1]:
|
||||
# 额外验证: XOR 解密后检查 BMP file size 和 offset 字段
|
||||
if len(header) >= 14:
|
||||
dec = bytes(b ^ key for b in header[:14])
|
||||
bmp_size = struct.unpack_from('<I', dec, 2)[0]
|
||||
bmp_offset = struct.unpack_from('<I', dec, 10)[0]
|
||||
file_size = os.path.getsize(dat_path)
|
||||
# BMP file_size 字段应与实际文件大小接近,offset 应在合理范围
|
||||
if (abs(bmp_size - file_size) < 1024 and 14 <= bmp_offset <= 1078):
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_image_format(header_bytes):
|
||||
"""根据解密后的文件头检测图片格式"""
|
||||
if header_bytes[:3] == bytes([0xFF, 0xD8, 0xFF]):
|
||||
return 'jpg'
|
||||
if header_bytes[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
|
||||
return 'png'
|
||||
if header_bytes[:3] == b'GIF':
|
||||
return 'gif'
|
||||
if header_bytes[:2] == b'BM':
|
||||
return 'bmp'
|
||||
if header_bytes[:4] == b'RIFF' and len(header_bytes) >= 12 and header_bytes[8:12] == b'WEBP':
|
||||
return 'webp'
|
||||
if header_bytes[:4] == bytes([0x49, 0x49, 0x2A, 0x00]):
|
||||
return 'tif'
|
||||
return 'bin'
|
||||
|
||||
|
||||
def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
|
||||
"""解密 V2 格式 .dat 文件 (AES-ECB + XOR)
|
||||
|
||||
Args:
|
||||
dat_path: V2 .dat 文件路径
|
||||
out_path: 输出路径 (None 则自动命名)
|
||||
aes_key: 16 字节 AES key (bytes 或 str)
|
||||
xor_key: XOR key (int, 默认 0x88)
|
||||
|
||||
Returns:
|
||||
(output_path, format) 或 (None, None)
|
||||
"""
|
||||
if aes_key is None:
|
||||
return None, None
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util import Padding
|
||||
|
||||
# 确保 key 是 16 字节 bytes
|
||||
if isinstance(aes_key, str):
|
||||
aes_key = aes_key.encode('ascii')[:16]
|
||||
if len(aes_key) < 16:
|
||||
return None, None
|
||||
|
||||
with open(dat_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
if len(data) < 15:
|
||||
return None, None
|
||||
|
||||
# 解析 header
|
||||
sig = data[:6]
|
||||
if sig not in (V2_MAGIC_FULL, V1_MAGIC_FULL):
|
||||
return None, None
|
||||
|
||||
aes_size, xor_size = struct.unpack_from('<LL', data, 6)
|
||||
|
||||
# V1 用固定 key
|
||||
if sig == V1_MAGIC_FULL:
|
||||
aes_key = b'cfcd208495d565ef' # md5("0")[:16]
|
||||
|
||||
# AES 对齐: PKCS7 填充使实际密文 >= aes_size,向上对齐到 16
|
||||
# 当 aes_size 是 16 的倍数时,还需要加 16 (完整填充块)
|
||||
aligned_aes_size = aes_size
|
||||
aligned_aes_size -= ~(~aligned_aes_size % 16) # 同 wx-dat 的公式
|
||||
|
||||
offset = 15
|
||||
if offset + aligned_aes_size > len(data):
|
||||
return None, None
|
||||
|
||||
# AES-ECB 解密
|
||||
aes_data = data[offset:offset + aligned_aes_size]
|
||||
try:
|
||||
cipher = AES.new(aes_key[:16], AES.MODE_ECB)
|
||||
dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size)
|
||||
except (ValueError, KeyError):
|
||||
return None, None
|
||||
offset += aligned_aes_size
|
||||
|
||||
# Raw 部分 (不加密)
|
||||
raw_end = len(data) - xor_size
|
||||
raw_data = data[offset:raw_end] if offset < raw_end else b''
|
||||
offset = raw_end
|
||||
|
||||
# XOR 部分
|
||||
xor_data = data[offset:]
|
||||
dec_xor = bytes(b ^ xor_key for b in xor_data)
|
||||
|
||||
decrypted = dec_aes + raw_data + dec_xor
|
||||
fmt = detect_image_format(decrypted[:16])
|
||||
|
||||
# wxgf (HEVC 裸流) 格式
|
||||
if decrypted[:4] == b'wxgf':
|
||||
fmt = 'hevc'
|
||||
|
||||
if out_path is None:
|
||||
base = os.path.splitext(dat_path)[0]
|
||||
for suffix in ('_t', '_h'):
|
||||
if base.endswith(suffix):
|
||||
base = base[:-len(suffix)]
|
||||
break
|
||||
out_path = f"{base}.{fmt}"
|
||||
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(decrypted)
|
||||
|
||||
return out_path, fmt
|
||||
|
||||
|
||||
def xor_decrypt_file(dat_path, out_path=None, key=None):
|
||||
"""解密单个 .dat 文件,返回 (output_path, format)"""
|
||||
if key is None:
|
||||
key = detect_xor_key(dat_path)
|
||||
if key is None:
|
||||
return None, None
|
||||
|
||||
with open(dat_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
decrypted = bytes(b ^ key for b in data)
|
||||
fmt = detect_image_format(decrypted[:16])
|
||||
|
||||
if out_path is None:
|
||||
base = os.path.splitext(dat_path)[0]
|
||||
# 去掉 _t, _h 后缀
|
||||
for suffix in ('_t', '_h'):
|
||||
if base.endswith(suffix):
|
||||
base = base[:-len(suffix)]
|
||||
break
|
||||
out_path = f"{base}.{fmt}"
|
||||
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(decrypted)
|
||||
|
||||
return out_path, fmt
|
||||
|
||||
|
||||
def decrypt_dat_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
|
||||
"""智能解密 .dat 文件 (自动检测格式)
|
||||
|
||||
Args:
|
||||
dat_path: .dat 文件路径
|
||||
out_path: 输出路径
|
||||
aes_key: V2 格式的 AES key (str 或 bytes, 16 字节)
|
||||
xor_key: XOR key (int)
|
||||
|
||||
Returns:
|
||||
(output_path, format) 或 (None, None)
|
||||
"""
|
||||
with open(dat_path, 'rb') as f:
|
||||
head = f.read(6)
|
||||
|
||||
# V2 新格式
|
||||
if head == V2_MAGIC_FULL:
|
||||
return v2_decrypt_file(dat_path, out_path, aes_key, xor_key)
|
||||
|
||||
# V1 格式 (固定 AES key)
|
||||
if head == V1_MAGIC_FULL:
|
||||
return v2_decrypt_file(dat_path, out_path, b'cfcd208495d565ef', xor_key)
|
||||
|
||||
# 旧 XOR 格式
|
||||
return xor_decrypt_file(dat_path, out_path)
|
||||
|
||||
|
||||
def extract_md5_from_packed_info(blob):
|
||||
"""从 message_resource.db 的 packed_info (protobuf) 中提取文件 MD5
|
||||
|
||||
格式: ... \\x12\\x22\\x0a\\x20 + 32 字节 ASCII hex MD5 ...
|
||||
"""
|
||||
if not blob or not isinstance(blob, bytes):
|
||||
return None
|
||||
|
||||
# 查找 protobuf 标记
|
||||
marker = b'\x12\x22\x0a\x20'
|
||||
idx = blob.find(marker)
|
||||
if idx >= 0 and idx + len(marker) + 32 <= len(blob):
|
||||
md5_bytes = blob[idx + len(marker): idx + len(marker) + 32]
|
||||
try:
|
||||
md5_str = md5_bytes.decode('ascii')
|
||||
# 验证是合法的 hex 字符串
|
||||
int(md5_str, 16)
|
||||
return md5_str
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# 备用方案:扫描 32 字节连续 hex 字符
|
||||
hex_chars = set(b'0123456789abcdef')
|
||||
i = 0
|
||||
while i <= len(blob) - 32:
|
||||
if blob[i] in hex_chars:
|
||||
candidate = blob[i:i+32]
|
||||
if all(b in hex_chars for b in candidate):
|
||||
try:
|
||||
return candidate.decode('ascii')
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
i += 32
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ImageResolver:
|
||||
"""封装从 local_id 到图片文件的完整解析链"""
|
||||
|
||||
def __init__(self, wechat_base_dir, decoded_image_dir, cache):
|
||||
"""
|
||||
Args:
|
||||
wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\<wxid>)
|
||||
decoded_image_dir: 解密图片输出目录
|
||||
cache: DBCache 实例,用于解密 message_resource.db
|
||||
"""
|
||||
self.base_dir = wechat_base_dir
|
||||
self.attach_dir = os.path.join(wechat_base_dir, "msg", "attach")
|
||||
self.out_dir = decoded_image_dir
|
||||
self.cache = cache
|
||||
|
||||
def get_image_md5(self, local_id):
|
||||
"""通过 local_id 查 message_resource.db 获取图片文件 MD5"""
|
||||
path = self.cache.get("message/message_resource.db")
|
||||
if not path:
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT packed_info FROM MessageResourceInfo WHERE local_id = ?",
|
||||
(local_id,)
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
return extract_md5_from_packed_info(row[0])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return None
|
||||
|
||||
def find_dat_files(self, username, file_md5):
|
||||
"""在 attach 目录下查找对应的 .dat 文件
|
||||
|
||||
路径: attach/<md5(username)>/<YYYY-MM>/Img/<file_md5>[_t|_h].dat
|
||||
"""
|
||||
username_hash = hashlib.md5(username.encode()).hexdigest()
|
||||
search_base = os.path.join(self.attach_dir, username_hash)
|
||||
|
||||
if not os.path.isdir(search_base):
|
||||
return []
|
||||
|
||||
# 在所有月份目录下搜索
|
||||
results = []
|
||||
pattern = os.path.join(search_base, "*", "Img", f"{file_md5}*.dat")
|
||||
for p in glob.glob(pattern):
|
||||
results.append(p)
|
||||
|
||||
return sorted(results)
|
||||
|
||||
def decode_image(self, username, local_id):
|
||||
"""完整流程:local_id → MD5 → .dat → 解密
|
||||
|
||||
Returns:
|
||||
dict with keys: success, path, format, md5, error
|
||||
"""
|
||||
# 1. 获取 MD5
|
||||
file_md5 = self.get_image_md5(local_id)
|
||||
if not file_md5:
|
||||
return {'success': False, 'error': f'无法从 message_resource.db 找到 local_id={local_id} 的图片信息'}
|
||||
|
||||
# 2. 找 .dat 文件
|
||||
dat_files = self.find_dat_files(username, file_md5)
|
||||
if not dat_files:
|
||||
return {'success': False, 'error': f'找不到 .dat 文件 (MD5={file_md5})', 'md5': file_md5}
|
||||
|
||||
# 优先选标准版(非 _t/_h),然后高清 _h,最后缩略图 _t
|
||||
selected = dat_files[0]
|
||||
for f in dat_files:
|
||||
fname = os.path.basename(f)
|
||||
if not fname.startswith(file_md5 + '_'):
|
||||
selected = f
|
||||
break
|
||||
for f in dat_files:
|
||||
if f.endswith('_h.dat'):
|
||||
selected = f
|
||||
break
|
||||
|
||||
# 3. 解密
|
||||
out_name = f"{file_md5}"
|
||||
out_path_base = os.path.join(self.out_dir, out_name)
|
||||
|
||||
result_path, fmt = xor_decrypt_file(selected, f"{out_path_base}.tmp")
|
||||
if not result_path:
|
||||
return {'success': False, 'error': f'无法检测 XOR key (文件: {selected})', 'md5': file_md5}
|
||||
|
||||
# 重命名为正确扩展名
|
||||
final_path = f"{out_path_base}.{fmt}"
|
||||
if os.path.exists(final_path):
|
||||
os.unlink(final_path)
|
||||
os.rename(result_path, final_path)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'path': final_path,
|
||||
'format': fmt,
|
||||
'md5': file_md5,
|
||||
'source': selected,
|
||||
'size': os.path.getsize(final_path),
|
||||
}
|
||||
|
||||
def list_chat_images(self, db_path, table_name, username, limit=20):
|
||||
"""列出某个聊天中的所有图片消息"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(f"""
|
||||
SELECT local_id, create_time
|
||||
FROM [{table_name}]
|
||||
WHERE local_type = 3
|
||||
ORDER BY create_time DESC
|
||||
LIMIT ?
|
||||
""", (limit,)).fetchall()
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return []
|
||||
conn.close()
|
||||
|
||||
results = []
|
||||
for local_id, create_time in rows:
|
||||
file_md5 = self.get_image_md5(local_id)
|
||||
info = {
|
||||
'local_id': local_id,
|
||||
'create_time': create_time,
|
||||
'md5': file_md5,
|
||||
}
|
||||
if file_md5:
|
||||
dat_files = self.find_dat_files(username, file_md5)
|
||||
if dat_files:
|
||||
info['dat_file'] = dat_files[0]
|
||||
try:
|
||||
info['size'] = os.path.getsize(dat_files[0])
|
||||
except OSError:
|
||||
pass
|
||||
results.append(info)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ============ CLI 测试 ============
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python decode_image.py <dat_file> [output_file]")
|
||||
print(" 解密单个 .dat 文件")
|
||||
sys.exit(1)
|
||||
|
||||
dat_file = sys.argv[1]
|
||||
out_file = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
if not os.path.exists(dat_file):
|
||||
print(f"文件不存在: {dat_file}")
|
||||
sys.exit(1)
|
||||
|
||||
result_path, fmt = decrypt_dat_file(dat_file, out_file)
|
||||
if result_path:
|
||||
size = os.path.getsize(result_path)
|
||||
print(f"解密成功: {result_path}")
|
||||
print(f"格式: {fmt}, 大小: {size:,} bytes")
|
||||
else:
|
||||
print("解密失败")
|
||||
sys.exit(1)
|
||||
185
decrypt_db.py
185
decrypt_db.py
|
|
@ -1,185 +0,0 @@
|
|||
"""
|
||||
WeChat 4.0 数据库解密器
|
||||
|
||||
使用从进程内存提取的per-DB enc_key解密SQLCipher 4加密的数据库
|
||||
参数: SQLCipher 4, AES-256-CBC, HMAC-SHA512, reserve=80, page_size=4096
|
||||
密钥来源: all_keys.json (由find_all_keys.py从内存提取)
|
||||
"""
|
||||
import hashlib, struct, os, sys, json
|
||||
import hmac as hmac_mod
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
PAGE_SZ = 4096
|
||||
KEY_SZ = 32
|
||||
SALT_SZ = 16
|
||||
IV_SZ = 16
|
||||
HMAC_SZ = 64
|
||||
RESERVE_SZ = 80 # IV(16) + HMAC(64)
|
||||
SQLITE_HDR = b'SQLite format 3\x00'
|
||||
|
||||
from config import load_config
|
||||
from key_utils import get_key_info, strip_key_metadata
|
||||
_cfg = load_config()
|
||||
DB_DIR = _cfg["db_dir"]
|
||||
OUT_DIR = _cfg["decrypted_dir"]
|
||||
KEYS_FILE = _cfg["keys_file"]
|
||||
|
||||
|
||||
def derive_mac_key(enc_key, salt):
|
||||
"""从enc_key派生HMAC密钥"""
|
||||
mac_salt = bytes(b ^ 0x3a for b in salt)
|
||||
return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ)
|
||||
|
||||
|
||||
def decrypt_page(enc_key, page_data, pgno):
|
||||
"""解密单个页面,输出4096字节的标准SQLite页面"""
|
||||
iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ]
|
||||
|
||||
if pgno == 1:
|
||||
encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
page = bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ)
|
||||
# 保留 reserve=80, B-tree 基于 usable_size=4016 构建
|
||||
return bytes(page)
|
||||
else:
|
||||
encrypted = page_data[:PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return decrypted + b'\x00' * RESERVE_SZ
|
||||
|
||||
|
||||
def decrypt_database(db_path, out_path, enc_key):
|
||||
"""解密整个数据库文件"""
|
||||
file_size = os.path.getsize(db_path)
|
||||
total_pages = file_size // PAGE_SZ
|
||||
|
||||
if file_size % PAGE_SZ != 0:
|
||||
print(f" [WARN] 文件大小 {file_size} 不是 {PAGE_SZ} 的倍数")
|
||||
total_pages += 1
|
||||
|
||||
with open(db_path, 'rb') as fin:
|
||||
page1 = fin.read(PAGE_SZ)
|
||||
|
||||
if len(page1) < PAGE_SZ:
|
||||
print(f" [ERROR] 文件太小")
|
||||
return False
|
||||
|
||||
# 提取salt并派生mac_key, 验证page 1
|
||||
salt = page1[:SALT_SZ]
|
||||
mac_key = derive_mac_key(enc_key, salt)
|
||||
p1_hmac_data = page1[SALT_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ]
|
||||
p1_stored_hmac = page1[PAGE_SZ - HMAC_SZ : PAGE_SZ]
|
||||
hm = hmac_mod.new(mac_key, p1_hmac_data, hashlib.sha512)
|
||||
hm.update(struct.pack('<I', 1))
|
||||
if hm.digest() != p1_stored_hmac:
|
||||
print(f" [ERROR] Page 1 HMAC验证失败! salt: {salt.hex()}")
|
||||
return False
|
||||
|
||||
print(f" HMAC OK, {total_pages} pages")
|
||||
|
||||
# 解密所有页面
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(db_path, 'rb') as fin, open(out_path, 'wb') as fout:
|
||||
for pgno in range(1, total_pages + 1):
|
||||
page = fin.read(PAGE_SZ)
|
||||
if len(page) < PAGE_SZ:
|
||||
if len(page) > 0:
|
||||
page = page + b'\x00' * (PAGE_SZ - len(page))
|
||||
else:
|
||||
break
|
||||
|
||||
decrypted = decrypt_page(enc_key, page, pgno)
|
||||
fout.write(decrypted)
|
||||
|
||||
if pgno == 1:
|
||||
if decrypted[:16] != SQLITE_HDR:
|
||||
print(f" [WARN] 解密后header不匹配!")
|
||||
|
||||
if pgno % 10000 == 0:
|
||||
print(f" 进度: {pgno}/{total_pages} ({100*pgno/total_pages:.1f}%)")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" WeChat 4.0 数据库解密器")
|
||||
print("=" * 60)
|
||||
|
||||
# 加载密钥
|
||||
if not os.path.exists(KEYS_FILE):
|
||||
print(f"[ERROR] 密钥文件不存在: {KEYS_FILE}")
|
||||
print("请先运行 find_all_keys.py")
|
||||
sys.exit(1)
|
||||
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
|
||||
keys = strip_key_metadata(keys)
|
||||
print(f"\n加载 {len(keys)} 个数据库密钥")
|
||||
print(f"输出目录: {OUT_DIR}")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
# 收集所有DB文件
|
||||
db_files = []
|
||||
for root, dirs, files in os.walk(DB_DIR):
|
||||
for f in files:
|
||||
if f.endswith('.db') and not f.endswith('-wal') and not f.endswith('-shm'):
|
||||
path = os.path.join(root, f)
|
||||
rel = os.path.relpath(path, DB_DIR)
|
||||
sz = os.path.getsize(path)
|
||||
db_files.append((rel, path, sz))
|
||||
|
||||
db_files.sort(key=lambda x: x[2]) # 从小到大
|
||||
|
||||
print(f"找到 {len(db_files)} 个数据库文件\n")
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
total_bytes = 0
|
||||
|
||||
for rel, path, sz in db_files:
|
||||
key_info = get_key_info(keys, rel)
|
||||
if not key_info:
|
||||
print(f"SKIP: {rel} (无密钥)")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
enc_key = bytes.fromhex(key_info["enc_key"])
|
||||
out_path = os.path.join(OUT_DIR, rel)
|
||||
|
||||
print(f"解密: {rel} ({sz/1024/1024:.1f}MB) ...", end=" ")
|
||||
|
||||
ok = decrypt_database(path, out_path, enc_key)
|
||||
if ok:
|
||||
# SQLite验证
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(out_path)
|
||||
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
conn.close()
|
||||
table_names = [t[0] for t in tables]
|
||||
print(f" OK! 表: {', '.join(table_names[:5])}", end="")
|
||||
if len(table_names) > 5:
|
||||
print(f" ...共{len(table_names)}个", end="")
|
||||
print()
|
||||
success += 1
|
||||
total_bytes += sz
|
||||
except Exception as e:
|
||||
print(f" [WARN] SQLite验证失败: {e}")
|
||||
failed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"结果: {success} 成功, {failed} 失败, 共 {len(db_files)} 个")
|
||||
print(f"解密数据量: {total_bytes/1024/1024/1024:.1f}GB")
|
||||
print(f"解密文件在: {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
616
decrypt_images.c
616
decrypt_images.c
|
|
@ -1,616 +0,0 @@
|
|||
/*
|
||||
* decrypt_images.c — WeChat V2 image batch decryptor (multi-key)
|
||||
*
|
||||
* Decrypts all V2 encrypted .dat files in the WeChat image cache.
|
||||
* Supports multiple keys via image_keys.json (CT block → AES key mapping).
|
||||
*
|
||||
* V2 format:
|
||||
* [15B header] [AES-128-ECB ciphertext] [XOR encrypted tail]
|
||||
* Header: \x07\x08V2\x08\x07 (6B) + aes_size:u32LE + xor_size:u32LE + 1B pad
|
||||
* AES region: ceil(aes_size/16)*16 bytes of AES-128-ECB ciphertext
|
||||
* XOR tail: xor_size bytes, each XOR'd with a single-byte key
|
||||
*
|
||||
* Build:
|
||||
* cc -O3 -o decrypt_images decrypt_images.c -framework Security
|
||||
*
|
||||
* Usage:
|
||||
* ./decrypt_images # auto from config + image_keys.json
|
||||
* ./decrypt_images <key_hex> <image_dir> <out_dir> # single-key manual
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
#include <CommonCrypto/CommonCryptor.h>
|
||||
|
||||
#define MAX_PATH 4096
|
||||
#define V2_MAGIC "\x07\x08V2\x08\x07"
|
||||
#define V2_MAGIC_LEN 6
|
||||
#define HEADER_SIZE 15
|
||||
#define MAX_KEYS 4096
|
||||
|
||||
/* ---- Key mapping: CT block hex → AES key ---- */
|
||||
typedef struct {
|
||||
unsigned char ct[16]; /* CT block 0 pattern */
|
||||
unsigned char key[16]; /* AES key for this pattern */
|
||||
} key_map_t;
|
||||
|
||||
static key_map_t key_map[MAX_KEYS];
|
||||
static int n_keys = 0;
|
||||
|
||||
/* ---- Utility ---- */
|
||||
|
||||
static int hex2bytes(const char *hex, unsigned char *out, int maxlen) {
|
||||
int len = 0;
|
||||
while (*hex && *(hex + 1) && len < maxlen) {
|
||||
unsigned int b;
|
||||
if (sscanf(hex, "%2x", &b) != 1) break;
|
||||
out[len++] = (unsigned char)b;
|
||||
hex += 2;
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
/* Minimal JSON string extractor (for simple unescaped string values only). */
|
||||
static int json_get_string(const char *json, const char *key,
|
||||
char *value, int maxlen) {
|
||||
char pattern[256];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (!p) return 0;
|
||||
p = strchr(p + strlen(pattern), '"');
|
||||
if (!p) return 0;
|
||||
p++;
|
||||
const char *end = strchr(p, '"');
|
||||
if (!end) return 0;
|
||||
int len = (int)(end - p);
|
||||
if (len >= maxlen) len = maxlen - 1;
|
||||
memcpy(value, p, len);
|
||||
value[len] = '\0';
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Load image_keys.json: { "ct_hex": "key_hex", ... } */
|
||||
static int load_key_map(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return 0;
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
if (sz <= 0) { fclose(f); return 0; }
|
||||
fseek(f, 0, SEEK_SET);
|
||||
char *json = malloc((size_t)sz + 1);
|
||||
if (!json) { fclose(f); return 0; }
|
||||
size_t rd = fread(json, 1, (size_t)sz, f);
|
||||
if (rd != (size_t)sz) {
|
||||
fclose(f);
|
||||
free(json);
|
||||
return 0;
|
||||
}
|
||||
json[rd] = '\0';
|
||||
fclose(f);
|
||||
|
||||
/* Simple parser: find all "32hex": "32hex" pairs */
|
||||
const char *p = json;
|
||||
int warned_capacity = 0;
|
||||
while ((p = strchr(p, '"')) != NULL) {
|
||||
if (n_keys >= MAX_KEYS) {
|
||||
if (!warned_capacity) {
|
||||
fprintf(stderr, "Warning: image_keys.json exceeds MAX_KEYS=%d, extra keys ignored\n",
|
||||
MAX_KEYS);
|
||||
warned_capacity = 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
p++;
|
||||
const char *end = strchr(p, '"');
|
||||
if (!end) break;
|
||||
int klen = (int)(end - p);
|
||||
if (klen != 32) { p = end + 1; continue; }
|
||||
|
||||
char ct_hex[33];
|
||||
memcpy(ct_hex, p, 32);
|
||||
ct_hex[32] = '\0';
|
||||
const char *colon = end + 1;
|
||||
while (*colon == ' ' || *colon == '\t' || *colon == '\r' || *colon == '\n')
|
||||
colon++;
|
||||
if (*colon != ':') { p = end + 1; continue; }
|
||||
p = colon + 1;
|
||||
|
||||
/* Find next quoted string (the value) */
|
||||
p = strchr(p, '"');
|
||||
if (!p) break;
|
||||
p++;
|
||||
end = strchr(p, '"');
|
||||
if (!end) break;
|
||||
int vlen = (int)(end - p);
|
||||
if (vlen != 32) { p = end + 1; continue; }
|
||||
|
||||
char key_hex[33];
|
||||
memcpy(key_hex, p, 32);
|
||||
key_hex[32] = '\0';
|
||||
p = end + 1;
|
||||
|
||||
if (hex2bytes(ct_hex, key_map[n_keys].ct, 16) != 16 ||
|
||||
hex2bytes(key_hex, key_map[n_keys].key, 16) != 16) {
|
||||
continue;
|
||||
}
|
||||
n_keys++;
|
||||
}
|
||||
free(json);
|
||||
return n_keys;
|
||||
}
|
||||
|
||||
/* Find AES key for a given CT block */
|
||||
static const unsigned char *find_key_for_ct(const unsigned char *ct) {
|
||||
for (int i = 0; i < n_keys; i++)
|
||||
if (memcmp(key_map[i].ct, ct, 16) == 0) return key_map[i].key;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Create directory and parents */
|
||||
static void mkdirs(const char *path) {
|
||||
char tmp[MAX_PATH];
|
||||
snprintf(tmp, sizeof(tmp), "%s", path);
|
||||
for (char *p = tmp + 1; *p; p++) {
|
||||
if (*p == '/') {
|
||||
*p = '\0';
|
||||
mkdir(tmp, 0755);
|
||||
*p = '/';
|
||||
}
|
||||
}
|
||||
mkdir(tmp, 0755);
|
||||
}
|
||||
|
||||
static int has_parent_segment(const char *path) {
|
||||
if (!path || !path[0]) return 1;
|
||||
if (path[0] == '/' || path[0] == '\\') return 1;
|
||||
|
||||
const char *p = path;
|
||||
while (*p) {
|
||||
while (*p == '/' || *p == '\\') p++;
|
||||
if (!*p) break;
|
||||
const char *seg = p;
|
||||
while (*p && *p != '/' && *p != '\\') p++;
|
||||
if ((p - seg) == 2 && seg[0] == '.' && seg[1] == '.') return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Detect image type from magic bytes */
|
||||
static const char *detect_ext(const unsigned char *data, size_t len) {
|
||||
if (len < 4) return ".bin";
|
||||
if (data[0] == 0xFF && data[1] == 0xD8) return ".jpg";
|
||||
if (data[0] == 0x89 && data[1] == 0x50 &&
|
||||
data[2] == 0x4E && data[3] == 0x47) return ".png";
|
||||
if (data[0] == 'G' && data[1] == 'I' &&
|
||||
data[2] == 'F' && data[3] == '8') return ".gif";
|
||||
if (data[0] == 'R' && data[1] == 'I' &&
|
||||
data[2] == 'F' && data[3] == 'F') return ".webp";
|
||||
if (data[0] == 0x00 && data[1] == 0x00 &&
|
||||
data[2] == 0x00 && (data[3] == 0x18 || data[3] == 0x1C ||
|
||||
data[3] == 0x20 || data[3] == 0x14)) return ".mp4";
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
/* Auto-detect XOR key */
|
||||
static unsigned char detect_xor_key(const unsigned char *xor_data, size_t xor_size) {
|
||||
if (xor_size == 0) return 0;
|
||||
unsigned char candidates[] = {0x80, 0xDC, 0x00};
|
||||
for (int i = 0; i < (int)(sizeof(candidates)/sizeof(candidates[0])); i++) {
|
||||
/* We want a candidate that doesn't produce a leading NUL byte after XOR. */
|
||||
unsigned char test = xor_data[0] ^ candidates[i];
|
||||
if (test != 0x00 || candidates[i] == 0x00)
|
||||
return candidates[i];
|
||||
}
|
||||
return 0x80;
|
||||
}
|
||||
|
||||
/* ---- Decrypt one V2 file ---- */
|
||||
|
||||
static int decrypt_v2_file(const char *input_path, const char *output_dir,
|
||||
const char *rel_path, const unsigned char *aes_key,
|
||||
unsigned char xor_key, int auto_xor,
|
||||
int *out_xor_detected) {
|
||||
FILE *fin = fopen(input_path, "rb");
|
||||
if (!fin) return -1;
|
||||
|
||||
unsigned char header[HEADER_SIZE];
|
||||
if (fread(header, 1, HEADER_SIZE, fin) != HEADER_SIZE) {
|
||||
fclose(fin); return -1;
|
||||
}
|
||||
if (memcmp(header, V2_MAGIC, V2_MAGIC_LEN) != 0) {
|
||||
fclose(fin); return -2;
|
||||
}
|
||||
|
||||
uint32_t aes_size, xor_size;
|
||||
memcpy(&aes_size, header + 6, 4);
|
||||
memcpy(&xor_size, header + 10, 4);
|
||||
|
||||
if ((uint64_t)aes_size > 100u * 1024u * 1024u ||
|
||||
(uint64_t)xor_size > 100u * 1024u * 1024u) {
|
||||
fclose(fin);
|
||||
return -6;
|
||||
}
|
||||
|
||||
/* PKCS7: when aes_size is already 16-byte aligned, an extra 16-byte
|
||||
* padding block is present in the ciphertext */
|
||||
size_t aes_ct_size = (aes_size % 16 == 0)
|
||||
? (size_t)aes_size + 16
|
||||
: ((size_t)aes_size + 15) / 16 * 16;
|
||||
|
||||
/* Get total file size and validate header claims fit within it */
|
||||
long cur_pos = ftell(fin);
|
||||
fseek(fin, 0, SEEK_END);
|
||||
long file_size = ftell(fin);
|
||||
fseek(fin, cur_pos, SEEK_SET);
|
||||
|
||||
if ((long)aes_ct_size + (long)xor_size > file_size - HEADER_SIZE) {
|
||||
fclose(fin);
|
||||
return -6; /* header claims more data than file contains */
|
||||
}
|
||||
|
||||
unsigned char *aes_ct = malloc(aes_ct_size);
|
||||
if (!aes_ct) { fclose(fin); return -1; }
|
||||
size_t rd = fread(aes_ct, 1, aes_ct_size, fin);
|
||||
if (rd != aes_ct_size) {
|
||||
free(aes_ct);
|
||||
fclose(fin);
|
||||
return -8;
|
||||
}
|
||||
|
||||
/* V2 may have unencrypted raw_data between AES and XOR sections */
|
||||
long raw_data_size = file_size - HEADER_SIZE - (long)aes_ct_size - (long)xor_size;
|
||||
if (raw_data_size < 0) raw_data_size = 0;
|
||||
|
||||
unsigned char *raw_data = NULL;
|
||||
if (raw_data_size > 0) {
|
||||
raw_data = malloc((size_t)raw_data_size);
|
||||
if (!raw_data) { free(aes_ct); fclose(fin); return -1; }
|
||||
rd = fread(raw_data, 1, (size_t)raw_data_size, fin);
|
||||
if (rd != (size_t)raw_data_size) {
|
||||
free(aes_ct); free(raw_data); fclose(fin); return -8;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned char *xor_data = NULL;
|
||||
if (xor_size > 0) {
|
||||
xor_data = malloc(xor_size);
|
||||
if (!xor_data) { free(aes_ct); free(raw_data); fclose(fin); return -1; }
|
||||
rd = fread(xor_data, 1, xor_size, fin);
|
||||
if (rd != xor_size) {
|
||||
free(aes_ct); free(raw_data); free(xor_data);
|
||||
fclose(fin); return -8;
|
||||
}
|
||||
}
|
||||
fclose(fin);
|
||||
|
||||
/* Try multi-key lookup (image_keys.json) first, then fall back to provided key */
|
||||
if (aes_ct_size >= 16) {
|
||||
const unsigned char *mk = find_key_for_ct(aes_ct);
|
||||
if (mk) aes_key = mk;
|
||||
}
|
||||
if (!aes_key) { free(aes_ct); free(raw_data); free(xor_data); return -5; }
|
||||
|
||||
unsigned char *aes_pt = malloc(aes_ct_size);
|
||||
if (!aes_pt) { free(aes_ct); free(raw_data); free(xor_data); return -1; }
|
||||
|
||||
size_t moved = 0;
|
||||
CCCryptorStatus st = CCCrypt(
|
||||
kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode,
|
||||
aes_key, 16, NULL,
|
||||
aes_ct, aes_ct_size, aes_pt, aes_ct_size, &moved);
|
||||
free(aes_ct);
|
||||
|
||||
if (st != kCCSuccess) {
|
||||
free(aes_pt); free(raw_data); free(xor_data); return -3;
|
||||
}
|
||||
|
||||
if (auto_xor && xor_data && xor_size > 0) {
|
||||
xor_key = detect_xor_key(xor_data, xor_size);
|
||||
if (out_xor_detected) *out_xor_detected = xor_key;
|
||||
}
|
||||
|
||||
if (xor_data && xor_size > 0) {
|
||||
for (uint32_t i = 0; i < xor_size; i++)
|
||||
xor_data[i] ^= xor_key;
|
||||
}
|
||||
|
||||
const char *ext = detect_ext(aes_pt, aes_size);
|
||||
|
||||
/* Skip unrecognized formats — avoids writing garbage .bin files */
|
||||
if (strcmp(ext, ".bin") == 0) {
|
||||
free(aes_pt); free(raw_data); free(xor_data);
|
||||
return -9; /* unrecognized image type */
|
||||
}
|
||||
|
||||
char out_path[MAX_PATH];
|
||||
char rel_noext[MAX_PATH];
|
||||
snprintf(rel_noext, sizeof(rel_noext), "%s", rel_path);
|
||||
char *dot = strrchr(rel_noext, '.');
|
||||
if (dot) *dot = '\0';
|
||||
if (has_parent_segment(rel_noext)) {
|
||||
free(aes_pt); free(raw_data); free(xor_data);
|
||||
return -7;
|
||||
}
|
||||
snprintf(out_path, sizeof(out_path), "%s/%s%s", output_dir, rel_noext, ext);
|
||||
|
||||
/* Skip if already decrypted */
|
||||
struct stat st_out;
|
||||
if (stat(out_path, &st_out) == 0 && st_out.st_size > 0) {
|
||||
free(aes_pt); free(raw_data); free(xor_data);
|
||||
return 1; /* already exists */
|
||||
}
|
||||
|
||||
char parent[MAX_PATH];
|
||||
snprintf(parent, sizeof(parent), "%s", out_path);
|
||||
char *last_slash = strrchr(parent, '/');
|
||||
if (last_slash) { *last_slash = '\0'; mkdirs(parent); }
|
||||
|
||||
FILE *fout = fopen(out_path, "wb");
|
||||
if (!fout) { free(aes_pt); free(raw_data); free(xor_data); return -4; }
|
||||
|
||||
fwrite(aes_pt, 1, aes_size, fout);
|
||||
if (raw_data && raw_data_size > 0) fwrite(raw_data, 1, (size_t)raw_data_size, fout);
|
||||
if (xor_data && xor_size > 0) fwrite(xor_data, 1, xor_size, fout);
|
||||
|
||||
fclose(fout);
|
||||
free(aes_pt);
|
||||
free(raw_data);
|
||||
free(xor_data);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Directory walking ---- */
|
||||
|
||||
typedef struct {
|
||||
const unsigned char *fallback_key; /* single key from config.json (or NULL) */
|
||||
int multi_key; /* 1 if using image_keys.json */
|
||||
unsigned char xor_key;
|
||||
int auto_xor;
|
||||
const char *output_dir;
|
||||
const char *base_dir;
|
||||
int success;
|
||||
int skipped;
|
||||
int existed; /* already decrypted */
|
||||
int no_key; /* V2 files with no matching key */
|
||||
int failed;
|
||||
} walk_ctx;
|
||||
|
||||
static void walk_dir(const char *dir, walk_ctx *ctx) {
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) return;
|
||||
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(d))) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
|
||||
char path[MAX_PATH];
|
||||
snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name);
|
||||
|
||||
struct stat st;
|
||||
if (lstat(path, &st) != 0) continue;
|
||||
if (S_ISLNK(st.st_mode)) continue;
|
||||
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
walk_dir(path, ctx);
|
||||
} else if (S_ISREG(st.st_mode)) {
|
||||
size_t nlen = strlen(ent->d_name);
|
||||
if (nlen < 5 || strcmp(ent->d_name + nlen - 4, ".dat") != 0)
|
||||
continue;
|
||||
|
||||
const char *rel = path + strlen(ctx->base_dir);
|
||||
if (*rel == '/') rel++;
|
||||
|
||||
int xor_detected = -1;
|
||||
/* In multi-key mode, pass fallback_key — decrypt_v2_file tries
|
||||
* image_keys.json lookup first, falls back to this key if provided */
|
||||
const unsigned char *key = ctx->fallback_key;
|
||||
int ret = decrypt_v2_file(path, ctx->output_dir, rel,
|
||||
key, ctx->xor_key,
|
||||
ctx->auto_xor, &xor_detected);
|
||||
|
||||
if (ret == 0) {
|
||||
ctx->success++;
|
||||
if (ctx->auto_xor && xor_detected >= 0) {
|
||||
ctx->xor_key = (unsigned char)xor_detected;
|
||||
ctx->auto_xor = 0;
|
||||
printf(" Auto-detected XOR key: 0x%02X\n", ctx->xor_key);
|
||||
}
|
||||
if (ctx->success <= 5 || ctx->success % 1000 == 0) {
|
||||
printf(" [%d] %s\n", ctx->success, rel);
|
||||
}
|
||||
} else if (ret == 1) {
|
||||
ctx->existed++;
|
||||
} else if (ret == -2) {
|
||||
ctx->skipped++;
|
||||
} else if (ret == -5) {
|
||||
ctx->no_key++;
|
||||
} else {
|
||||
ctx->failed++;
|
||||
if (ctx->failed <= 5)
|
||||
printf(" FAIL(%d): %s\n", ret, rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
/* ---- Main ---- */
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
unsigned char aes_key[16];
|
||||
char image_dir[MAX_PATH] = "";
|
||||
char output_dir[MAX_PATH] = "";
|
||||
char key_hex[64] = "";
|
||||
int have_single_key = 0;
|
||||
|
||||
printf("=== WeChat V2 Image Decryptor ===\n\n");
|
||||
|
||||
/* Determine exe directory for config file lookup */
|
||||
char exe_dir[MAX_PATH] = ".";
|
||||
const char *last_slash = strrchr(argv[0], '/');
|
||||
if (last_slash) {
|
||||
int len = (int)(last_slash - argv[0]);
|
||||
snprintf(exe_dir, sizeof(exe_dir), "%.*s", len, argv[0]);
|
||||
}
|
||||
|
||||
if (argc >= 4) {
|
||||
/* Manual single-key mode */
|
||||
strncpy(key_hex, argv[1], sizeof(key_hex) - 1);
|
||||
key_hex[sizeof(key_hex) - 1] = '\0';
|
||||
strncpy(image_dir, argv[2], sizeof(image_dir) - 1);
|
||||
image_dir[sizeof(image_dir) - 1] = '\0';
|
||||
strncpy(output_dir, argv[3], sizeof(output_dir) - 1);
|
||||
output_dir[sizeof(output_dir) - 1] = '\0';
|
||||
have_single_key = (key_hex[0] != '\0');
|
||||
} else {
|
||||
/* Load image_keys.json first (multi-key) */
|
||||
char keys_path[MAX_PATH];
|
||||
snprintf(keys_path, sizeof(keys_path), "%s/image_keys.json", exe_dir);
|
||||
int loaded = load_key_map(keys_path);
|
||||
if (loaded > 0)
|
||||
printf("Loaded %d key mappings from %s\n", loaded, keys_path);
|
||||
|
||||
/* Read config.json for paths (and fallback single key) */
|
||||
char cfg_path[MAX_PATH];
|
||||
snprintf(cfg_path, sizeof(cfg_path), "%s/config.json", exe_dir);
|
||||
FILE *cf = fopen(cfg_path, "r");
|
||||
if (!cf) {
|
||||
fprintf(stderr, "ERROR: Cannot open %s\n", cfg_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
fseek(cf, 0, SEEK_END);
|
||||
long sz = ftell(cf);
|
||||
if (sz <= 0) { fclose(cf); return 1; }
|
||||
fseek(cf, 0, SEEK_SET);
|
||||
char *json = malloc((size_t)sz + 1);
|
||||
if (!json) { fclose(cf); return 1; }
|
||||
size_t rd = fread(json, 1, (size_t)sz, cf);
|
||||
if (rd != (size_t)sz) {
|
||||
free(json);
|
||||
fclose(cf);
|
||||
return 1;
|
||||
}
|
||||
json[sz] = '\0';
|
||||
fclose(cf);
|
||||
|
||||
if (json_get_string(json, "image_key", key_hex, sizeof(key_hex)) &&
|
||||
key_hex[0] != '\0')
|
||||
have_single_key = 1;
|
||||
else
|
||||
have_single_key = 0;
|
||||
|
||||
char db_dir[MAX_PATH] = "";
|
||||
json_get_string(json, "db_dir", db_dir, sizeof(db_dir));
|
||||
|
||||
char out_rel[MAX_PATH] = "decrypted_images";
|
||||
json_get_string(json, "decrypted_images_dir", out_rel, sizeof(out_rel));
|
||||
if (out_rel[0] == '/')
|
||||
strncpy(output_dir, out_rel, sizeof(output_dir) - 1);
|
||||
else
|
||||
snprintf(output_dir, sizeof(output_dir), "%s/%s", exe_dir, out_rel);
|
||||
output_dir[sizeof(output_dir) - 1] = '\0';
|
||||
|
||||
if (db_dir[0]) {
|
||||
char *s = strrchr(db_dir, '/');
|
||||
if (!s) s = strrchr(db_dir, '\\');
|
||||
if (s) {
|
||||
int plen = (int)(s - db_dir);
|
||||
snprintf(image_dir, sizeof(image_dir),
|
||||
"%.*s/msg", plen, db_dir);
|
||||
}
|
||||
}
|
||||
free(json);
|
||||
}
|
||||
|
||||
/* Parse single key if available (used as fallback or sole key) */
|
||||
if (have_single_key && key_hex[0]) {
|
||||
if (hex2bytes(key_hex, aes_key, 16) == 16) {
|
||||
/* If no image_keys.json loaded, add single key to key_map
|
||||
* by discovering its CT block at runtime */
|
||||
} else {
|
||||
have_single_key = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (n_keys == 0 && !have_single_key) {
|
||||
fprintf(stderr, "ERROR: No keys available.\n");
|
||||
fprintf(stderr, "Run find_image_key first, or set image_key in config.json\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Auto-detect: scan ~/Library/Containers/com.tencent.xinWeChat */
|
||||
if (image_dir[0] == '\0') {
|
||||
const char *home = getenv("HOME");
|
||||
if (!home) home = "/Users";
|
||||
char base[MAX_PATH];
|
||||
snprintf(base, sizeof(base),
|
||||
"%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files",
|
||||
home);
|
||||
DIR *d = opendir(base);
|
||||
if (d) {
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(d))) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
char candidate[MAX_PATH];
|
||||
snprintf(candidate, sizeof(candidate), "%s/%s/msg", base, ent->d_name);
|
||||
struct stat st2;
|
||||
if (stat(candidate, &st2) == 0 && S_ISDIR(st2.st_mode)) {
|
||||
strncpy(image_dir, candidate, sizeof(image_dir) - 1);
|
||||
printf("Auto-detected image directory:\n %s\n\n", image_dir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
}
|
||||
|
||||
if (image_dir[0] == '\0') {
|
||||
fprintf(stderr, "ERROR: Cannot determine image directory.\n");
|
||||
fprintf(stderr, "Tried: command line, config.json, auto-detect.\n");
|
||||
fprintf(stderr, "Set db_dir in config.json or pass image_dir as argument.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Mode: %s\n", n_keys > 0 ? "multi-key" : "single-key");
|
||||
if (n_keys > 0) printf("Keys: %d pattern→key mappings\n", n_keys);
|
||||
if (have_single_key) printf("Fallback: %s\n", key_hex);
|
||||
printf("Image dir: %s\n", image_dir);
|
||||
printf("Output: %s\n\n", output_dir);
|
||||
|
||||
mkdirs(output_dir);
|
||||
|
||||
walk_ctx ctx = {
|
||||
.fallback_key = have_single_key ? aes_key : NULL,
|
||||
.multi_key = (n_keys > 0),
|
||||
.xor_key = 0,
|
||||
.auto_xor = 1,
|
||||
.output_dir = output_dir,
|
||||
.base_dir = image_dir,
|
||||
.success = 0,
|
||||
.skipped = 0,
|
||||
.existed = 0,
|
||||
.no_key = 0,
|
||||
.failed = 0,
|
||||
};
|
||||
|
||||
walk_dir(image_dir, &ctx);
|
||||
|
||||
printf("\n==================================================\n");
|
||||
printf("Results:\n");
|
||||
printf(" Decrypted: %d\n", ctx.success);
|
||||
printf(" Existed: %d (already decrypted, skipped)\n", ctx.existed);
|
||||
printf(" No key: %d (run find_image_key to discover more keys)\n", ctx.no_key);
|
||||
printf(" Skipped: %d (non-V2)\n", ctx.skipped);
|
||||
printf(" Failed: %d\n", ctx.failed);
|
||||
printf("Output: %s\n", output_dir);
|
||||
printf("==================================================\n");
|
||||
|
||||
return (ctx.success > 0) ? 0 : 1;
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import functools
|
||||
import platform
|
||||
import sys
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _load_impl():
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
import find_all_keys_windows as impl
|
||||
return impl
|
||||
if system == "linux":
|
||||
import find_all_keys_linux as impl
|
||||
return impl
|
||||
raise RuntimeError(
|
||||
f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}\n"
|
||||
f"macOS 请使用 find_all_keys_macos.c (C 版扫描器)"
|
||||
)
|
||||
|
||||
|
||||
def get_pids():
|
||||
return _load_impl().get_pids()
|
||||
|
||||
|
||||
def main():
|
||||
return _load_impl().main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as exc:
|
||||
print(f"\n[ERROR] {exc}")
|
||||
sys.exit(1)
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
"""
|
||||
Linux 版微信数据库密钥提取
|
||||
|
||||
原理: 与 Windows/macOS 相同 — 扫描微信进程内存,查找
|
||||
WCDB 缓存的 x'<64hex_enc_key><32hex_salt>' 模式,
|
||||
通过匹配数据库 salt + HMAC 校验确认密钥。
|
||||
|
||||
读取方式: /proc/<pid>/maps + /proc/<pid>/mem
|
||||
权限要求: root 或 CAP_SYS_PTRACE
|
||||
"""
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from key_scan_common import (
|
||||
collect_db_files, scan_memory_for_keys, cross_verify_keys, save_results,
|
||||
)
|
||||
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
|
||||
def _safe_readlink(path):
|
||||
try:
|
||||
return os.path.realpath(os.readlink(path))
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
_KNOWN_COMMS = {"wechat", "wechatappex", "weixin"}
|
||||
_INTERPRETER_PREFIXES = ("python", "bash", "sh", "zsh", "node", "perl", "ruby")
|
||||
|
||||
|
||||
def _is_wechat_process(pid):
|
||||
"""检查 pid 是否为微信进程。
|
||||
|
||||
优先精确匹配 comm 名称(wechat、WeChatAppEx 等),
|
||||
再用 exe 路径子串匹配作为 fallback,同时排除解释器进程。
|
||||
"""
|
||||
if pid == os.getpid():
|
||||
return False
|
||||
try:
|
||||
with open(f"/proc/{pid}/comm") as f:
|
||||
comm = f.read().strip()
|
||||
# 优先精确匹配 comm(最可靠)
|
||||
if comm.lower() in _KNOWN_COMMS:
|
||||
return True
|
||||
exe_path = _safe_readlink(f"/proc/{pid}/exe")
|
||||
exe_name = os.path.basename(exe_path)
|
||||
# 排除脚本解释器进程(避免匹配 python3.11 wechat-decrypt 等)
|
||||
if any(exe_name.lower().startswith(p) for p in _INTERPRETER_PREFIXES):
|
||||
return False
|
||||
# fallback: exe 名称子串匹配
|
||||
return "wechat" in exe_name.lower() or "weixin" in exe_name.lower()
|
||||
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
||||
return False
|
||||
|
||||
|
||||
def get_pids():
|
||||
"""返回所有疑似微信主进程的 (pid, rss_kb) 列表,按内存降序。"""
|
||||
pids = []
|
||||
for pid_str in os.listdir("/proc"):
|
||||
if not pid_str.isdigit():
|
||||
continue
|
||||
pid = int(pid_str)
|
||||
try:
|
||||
if not _is_wechat_process(pid):
|
||||
continue
|
||||
with open(f"/proc/{pid}/statm") as f:
|
||||
rss_pages = int(f.read().split()[1])
|
||||
rss_kb = rss_pages * 4
|
||||
pids.append((pid, rss_kb))
|
||||
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
||||
continue
|
||||
|
||||
if not pids:
|
||||
raise RuntimeError("未检测到 Linux 微信进程")
|
||||
|
||||
pids.sort(key=lambda item: item[1], reverse=True)
|
||||
for pid, rss_kb in pids:
|
||||
exe_path = _safe_readlink(f"/proc/{pid}/exe")
|
||||
print(f"[+] WeChat PID={pid} ({rss_kb // 1024}MB) {exe_path}")
|
||||
return pids
|
||||
|
||||
|
||||
_SKIP_MAPPINGS = {"[vdso]", "[vsyscall]", "[vvar]"}
|
||||
_SKIP_PATH_PREFIXES = ("/usr/lib/", "/lib/", "/usr/share/")
|
||||
|
||||
|
||||
def _get_readable_regions(pid):
|
||||
"""解析 /proc/<pid>/maps,返回可读内存区域列表。
|
||||
|
||||
跳过 [vdso]、[vsyscall] 等特殊映射和系统库映射,
|
||||
聚焦匿名映射和堆区(WCDB 密钥缓存所在位置)。
|
||||
"""
|
||||
regions = []
|
||||
with open(f"/proc/{pid}/maps") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
if "r" not in parts[1]:
|
||||
continue
|
||||
# 跳过特殊映射和无关系统库,但保留 wcdb/wechat 相关库
|
||||
if len(parts) >= 6:
|
||||
mapping_name = parts[5]
|
||||
if mapping_name in _SKIP_MAPPINGS:
|
||||
continue
|
||||
mapping_lower = mapping_name.lower()
|
||||
if (any(mapping_name.startswith(p) for p in _SKIP_PATH_PREFIXES)
|
||||
and "wcdb" not in mapping_lower
|
||||
and "wechat" not in mapping_lower
|
||||
and "weixin" not in mapping_lower):
|
||||
continue
|
||||
start_s, end_s = parts[0].split("-")
|
||||
start = int(start_s, 16)
|
||||
size = int(end_s, 16) - start
|
||||
if 0 < size < 500 * 1024 * 1024:
|
||||
regions.append((start, size))
|
||||
return regions
|
||||
|
||||
|
||||
def _check_permissions():
|
||||
"""检查是否有读取进程内存的权限(root 或 CAP_SYS_PTRACE)。"""
|
||||
if os.geteuid() == 0:
|
||||
return
|
||||
# 检查 CAP_SYS_PTRACE: 读取 /proc/self/status 中的 CapEff
|
||||
try:
|
||||
with open("/proc/self/status") as f:
|
||||
for line in f:
|
||||
if line.startswith("CapEff:"):
|
||||
cap_eff = int(line.split(":")[1].strip(), 16)
|
||||
CAP_SYS_PTRACE = 1 << 19
|
||||
if cap_eff & CAP_SYS_PTRACE:
|
||||
return
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
print("[!] 需要 root 权限或 CAP_SYS_PTRACE 才能读取进程内存")
|
||||
print(" 请使用: sudo python3 find_all_keys.py")
|
||||
print(" 或授予 capability: sudo setcap cap_sys_ptrace=ep $(which python3)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
from config import load_config
|
||||
_cfg = load_config()
|
||||
db_dir = _cfg["db_dir"]
|
||||
out_file = _cfg["keys_file"]
|
||||
|
||||
_check_permissions()
|
||||
|
||||
print("=" * 60)
|
||||
print(" 提取 Linux 微信数据库密钥(内存扫描)")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 收集 DB 文件和 salt
|
||||
db_files, salt_to_dbs = collect_db_files(db_dir)
|
||||
if not db_files:
|
||||
raise RuntimeError(f"在 {db_dir} 未找到可解密的 .db 文件")
|
||||
|
||||
print(f"\n找到 {len(db_files)} 个数据库, {len(salt_to_dbs)} 个不同的 salt")
|
||||
for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
print(f" salt {salt_hex}: {', '.join(dbs)}")
|
||||
|
||||
# 2. 找到微信进程
|
||||
pids = get_pids()
|
||||
|
||||
hex_re = re.compile(rb"x'([0-9a-fA-F]{64,192})'")
|
||||
key_map = {} # salt_hex -> enc_key_hex
|
||||
remaining_salts = set(salt_to_dbs.keys())
|
||||
all_hex_matches = 0
|
||||
t0 = time.time()
|
||||
|
||||
for pid, rss_kb in pids:
|
||||
try:
|
||||
regions = _get_readable_regions(pid)
|
||||
except PermissionError:
|
||||
print(f"[WARN] 无法读取 /proc/{pid}/maps,权限不足,跳过")
|
||||
continue
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
print(f"[WARN] PID {pid} 已退出,跳过")
|
||||
continue
|
||||
|
||||
total_bytes = sum(s for _, s in regions)
|
||||
total_mb = total_bytes / 1024 / 1024
|
||||
print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)")
|
||||
|
||||
scanned_bytes = 0
|
||||
try:
|
||||
mem = open(f"/proc/{pid}/mem", "rb")
|
||||
except PermissionError:
|
||||
print(f"[WARN] 无法打开 /proc/{pid}/mem,权限不足,跳过")
|
||||
continue
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
print(f"[WARN] PID {pid} 已退出,跳过")
|
||||
continue
|
||||
|
||||
# 防御 TOCTOU: 打开 mem 后再次确认仍为微信进程
|
||||
if not _is_wechat_process(pid):
|
||||
print(f"[WARN] PID {pid} 已不是微信进程,跳过")
|
||||
mem.close()
|
||||
continue
|
||||
|
||||
try:
|
||||
for reg_idx, (base, size) in enumerate(regions):
|
||||
try:
|
||||
mem.seek(base)
|
||||
data = mem.read(size)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
scanned_bytes += len(data)
|
||||
|
||||
all_hex_matches += scan_memory_for_keys(
|
||||
data, hex_re, db_files, salt_to_dbs,
|
||||
key_map, remaining_salts, base, pid, print,
|
||||
)
|
||||
|
||||
if (reg_idx + 1) % 200 == 0:
|
||||
elapsed = time.time() - t0
|
||||
progress = scanned_bytes / total_bytes * 100 if total_bytes else 100
|
||||
print(
|
||||
f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, "
|
||||
f"{all_hex_matches} hex patterns, {elapsed:.1f}s"
|
||||
)
|
||||
finally:
|
||||
mem.close()
|
||||
|
||||
if not remaining_salts:
|
||||
print(f"\n[+] 所有密钥已找到,跳过剩余进程")
|
||||
break
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, {all_hex_matches} hex 模式")
|
||||
|
||||
cross_verify_keys(db_files, salt_to_dbs, key_map, print)
|
||||
save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as exc:
|
||||
print(f"\n[ERROR] {exc}")
|
||||
sys.exit(1)
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
"""
|
||||
从微信进程内存中提取所有数据库的缓存raw key
|
||||
|
||||
WCDB为每个DB缓存: x'<64hex_enc_key><32hex_salt>'
|
||||
salt嵌在hex字符串中,可以直接匹配DB文件的salt
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import os, sys, time, re
|
||||
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
from key_scan_common import (
|
||||
collect_db_files, scan_memory_for_keys, cross_verify_keys, save_results,
|
||||
)
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
MEM_COMMIT = 0x1000
|
||||
READABLE = {0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80}
|
||||
|
||||
|
||||
class MBI(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_uint64), ("AllocationBase", ctypes.c_uint64),
|
||||
("AllocationProtect", wt.DWORD), ("_pad1", wt.DWORD),
|
||||
("RegionSize", ctypes.c_uint64), ("State", wt.DWORD),
|
||||
("Protect", wt.DWORD), ("Type", wt.DWORD), ("_pad2", wt.DWORD),
|
||||
]
|
||||
|
||||
|
||||
def get_pids():
|
||||
"""返回所有 Weixin.exe 进程的 (pid, mem_kb) 列表,按内存降序"""
|
||||
import subprocess
|
||||
r = subprocess.run(["tasklist", "/FI", "IMAGENAME eq Weixin.exe", "/FO", "CSV", "/NH"],
|
||||
capture_output=True, text=True)
|
||||
pids = []
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
p = line.strip('"').split('","')
|
||||
if len(p) >= 5:
|
||||
pid = int(p[1])
|
||||
mem = int(p[4].replace(',', '').replace(' K', '').strip() or '0')
|
||||
pids.append((pid, mem))
|
||||
if not pids:
|
||||
raise RuntimeError("Weixin.exe 未运行")
|
||||
pids.sort(key=lambda x: x[1], reverse=True)
|
||||
for pid, mem in pids:
|
||||
print(f"[+] Weixin.exe PID={pid} ({mem // 1024}MB)")
|
||||
return pids
|
||||
|
||||
|
||||
def read_mem(h, addr, sz):
|
||||
buf = ctypes.create_string_buffer(sz)
|
||||
n = ctypes.c_size_t(0)
|
||||
if kernel32.ReadProcessMemory(h, ctypes.c_uint64(addr), buf, sz, ctypes.byref(n)):
|
||||
return buf.raw[:n.value]
|
||||
return None
|
||||
|
||||
|
||||
def enum_regions(h):
|
||||
regs = []
|
||||
addr = 0
|
||||
mbi = MBI()
|
||||
while addr < 0x7FFFFFFFFFFF:
|
||||
if kernel32.VirtualQueryEx(h, ctypes.c_uint64(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)) == 0:
|
||||
break
|
||||
if mbi.State == MEM_COMMIT and mbi.Protect in READABLE and 0 < mbi.RegionSize < 500 * 1024 * 1024:
|
||||
regs.append((mbi.BaseAddress, mbi.RegionSize))
|
||||
nxt = mbi.BaseAddress + mbi.RegionSize
|
||||
if nxt <= addr:
|
||||
break
|
||||
addr = nxt
|
||||
return regs
|
||||
|
||||
|
||||
def main():
|
||||
from config import load_config
|
||||
_cfg = load_config()
|
||||
db_dir = _cfg["db_dir"]
|
||||
out_file = _cfg["keys_file"]
|
||||
|
||||
print("=" * 60)
|
||||
print(" 提取所有微信数据库密钥")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 收集所有DB文件及其salt
|
||||
db_files, salt_to_dbs = collect_db_files(db_dir)
|
||||
|
||||
print(f"\n找到 {len(db_files)} 个数据库, {len(salt_to_dbs)} 个不同的salt")
|
||||
for salt_hex, dbs in sorted(salt_to_dbs.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
print(f" salt {salt_hex}: {', '.join(dbs)}")
|
||||
|
||||
# 2. 打开所有微信进程
|
||||
pids = get_pids()
|
||||
|
||||
hex_re = re.compile(b"x'([0-9a-fA-F]{64,192})'")
|
||||
key_map = {}
|
||||
remaining_salts = set(salt_to_dbs.keys())
|
||||
all_hex_matches = 0
|
||||
t0 = time.time()
|
||||
|
||||
for pid, mem_kb in pids:
|
||||
h = kernel32.OpenProcess(0x0010 | 0x0400, False, pid)
|
||||
if not h:
|
||||
print(f"[WARN] 无法打开进程 PID={pid},跳过")
|
||||
continue
|
||||
|
||||
try:
|
||||
regions = enum_regions(h)
|
||||
total_bytes = sum(s for _, s in regions)
|
||||
total_mb = total_bytes / 1024 / 1024
|
||||
print(f"\n[*] 扫描 PID={pid} ({total_mb:.0f}MB, {len(regions)} 区域)")
|
||||
|
||||
scanned_bytes = 0
|
||||
for reg_idx, (base, size) in enumerate(regions):
|
||||
data = read_mem(h, base, size)
|
||||
scanned_bytes += size
|
||||
if not data:
|
||||
continue
|
||||
|
||||
all_hex_matches += scan_memory_for_keys(
|
||||
data, hex_re, db_files, salt_to_dbs,
|
||||
key_map, remaining_salts, base, pid, print,
|
||||
)
|
||||
|
||||
if (reg_idx + 1) % 200 == 0:
|
||||
elapsed = time.time() - t0
|
||||
progress = scanned_bytes / total_bytes * 100 if total_bytes else 100
|
||||
print(
|
||||
f" [{progress:.1f}%] {len(key_map)}/{len(salt_to_dbs)} salts matched, "
|
||||
f"{all_hex_matches} hex patterns, {elapsed:.1f}s"
|
||||
)
|
||||
finally:
|
||||
kernel32.CloseHandle(h)
|
||||
|
||||
if not remaining_salts:
|
||||
print(f"\n[+] 所有密钥已找到,跳过剩余进程")
|
||||
break
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, {all_hex_matches} hex模式")
|
||||
|
||||
cross_verify_keys(db_files, salt_to_dbs, key_map, print)
|
||||
save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as e:
|
||||
print(f"\n[ERROR] {e}")
|
||||
sys.exit(1)
|
||||
917
find_image_key.c
917
find_image_key.c
|
|
@ -1,917 +0,0 @@
|
|||
/*
|
||||
* find_image_key.c — WeChat V2 image key continuous scanner (macOS)
|
||||
*
|
||||
* Discovers all unique V2 encryption patterns from the image cache,
|
||||
* then continuously scans WeChat process memory to find AES keys.
|
||||
* User just keeps browsing images in WeChat — the scanner catches
|
||||
* keys as they transiently appear in memory.
|
||||
*
|
||||
* Uses multi-block CCCrypt: one key setup decrypts ALL unsolved
|
||||
* patterns in a single call (~1.5 min per full scan with 20 patterns).
|
||||
*
|
||||
* Build:
|
||||
* cc -O3 -o find_image_key find_image_key.c -framework Security
|
||||
*
|
||||
* Usage:
|
||||
* sudo ./find_image_key # auto-discover from config.json
|
||||
* sudo ./find_image_key <image_dir> # explicit image directory
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <signal.h>
|
||||
#include <time.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/sysctl.h>
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_vm.h>
|
||||
#include <unistd.h>
|
||||
#include <CommonCrypto/CommonCryptor.h>
|
||||
|
||||
#define MAX_PATH 4096
|
||||
#define MAX_PATTERNS 8192
|
||||
#define V2_MAGIC "\x07\x08V2\x08\x07"
|
||||
#define V2_MAGIC_LEN 6
|
||||
#define REGION_MAX (200 * 1024 * 1024)
|
||||
#define DEEP_PRIORITY_MAX 10 /* byte-by-byte scan for top N unsolved patterns */
|
||||
|
||||
/* ---- Strict image magic detection (16 bytes available from decrypted block) ---- */
|
||||
static int is_image_magic(const unsigned char *pt) {
|
||||
if (pt[0] == 0xFF && pt[1] == 0xD8 && pt[2] == 0xFF &&
|
||||
pt[3] >= 0xC0 && pt[3] != 0xFF) {
|
||||
/* JFIF: verify "JF" at offset 6 */
|
||||
if (pt[3] == 0xE0) return (pt[6] == 'J' && pt[7] == 'F');
|
||||
/* EXIF: verify "Ex" at offset 6 */
|
||||
if (pt[3] == 0xE1) return (pt[6] == 'E' && pt[7] == 'x');
|
||||
/* Other markers: verify length field is sane (big-endian, 2..32767) */
|
||||
uint16_t len = ((uint16_t)pt[4] << 8) | pt[5];
|
||||
return (len >= 2 && len < 0x8000);
|
||||
}
|
||||
/* PNG: full 8-byte signature */
|
||||
if (pt[0]==0x89 && pt[1]==0x50 && pt[2]==0x4E && pt[3]==0x47 &&
|
||||
pt[4]==0x0D && pt[5]==0x0A && pt[6]==0x1A && pt[7]==0x0A) return 1;
|
||||
/* GIF: "GIF89a" or "GIF87a" */
|
||||
if (pt[0]=='G' && pt[1]=='I' && pt[2]=='F' && pt[3]=='8' &&
|
||||
(pt[4]=='9' || pt[4]=='7') && pt[5]=='a') return 1;
|
||||
/* WebP: "RIFF....WEBP" */
|
||||
if (pt[0]=='R' && pt[1]=='I' && pt[2]=='F' && pt[3]=='F' &&
|
||||
pt[8]=='W' && pt[9]=='E' && pt[10]=='B' && pt[11]=='P') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Pattern tracking ---- */
|
||||
typedef struct {
|
||||
unsigned char ct[16]; /* CT block 0 (first 16 encrypted bytes) */
|
||||
unsigned char key[16]; /* found AES key */
|
||||
int solved;
|
||||
int file_count; /* how many .dat files use this pattern */
|
||||
char sample_path[MAX_PATH];
|
||||
} pattern_t;
|
||||
|
||||
static pattern_t patterns[MAX_PATTERNS];
|
||||
static int npatterns = 0;
|
||||
static int total_v2_files = 0;
|
||||
|
||||
/* ---- Rejected key blacklist (false positives) ---- */
|
||||
#define MAX_REJECTED 256
|
||||
static unsigned char rejected_keys[MAX_REJECTED][16];
|
||||
static int n_rejected = 0;
|
||||
|
||||
static int is_rejected(const unsigned char *key) {
|
||||
for (int i = 0; i < n_rejected; i++)
|
||||
if (memcmp(rejected_keys[i], key, 16) == 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
static void add_rejected(const unsigned char *key) {
|
||||
if (n_rejected < MAX_REJECTED && !is_rejected(key)) {
|
||||
memcpy(rejected_keys[n_rejected], key, 16);
|
||||
n_rejected++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Global scan mode ---- */
|
||||
static int g_deep_mode = 0;
|
||||
|
||||
/* ---- Graceful shutdown ---- */
|
||||
static volatile sig_atomic_t stop_flag = 0;
|
||||
static void sigint_handler(int sig) { (void)sig; stop_flag = 1; }
|
||||
|
||||
/* ---- Utility ---- */
|
||||
static void bytes2hex(const unsigned char *d, int n, char *out) {
|
||||
for (int i = 0; i < n; i++) sprintf(out + i*2, "%02x", d[i]);
|
||||
out[n*2] = '\0';
|
||||
}
|
||||
static int hex2bytes(const char *h, unsigned char *o, int max) {
|
||||
int n = 0;
|
||||
while (n < max) {
|
||||
if (!h[0] || !h[1]) return 0;
|
||||
if (!((h[0] >= '0' && h[0] <= '9') || (h[0] >= 'a' && h[0] <= 'f') ||
|
||||
(h[0] >= 'A' && h[0] <= 'F'))) return 0;
|
||||
if (!((h[1] >= '0' && h[1] <= '9') || (h[1] >= 'a' && h[1] <= 'f') ||
|
||||
(h[1] >= 'A' && h[1] <= 'F'))) return 0;
|
||||
|
||||
unsigned int b = 0;
|
||||
if (sscanf(h, "%2x", &b) != 1) return 0;
|
||||
o[n++] = (unsigned char)b; h += 2;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Minimal JSON string extractor */
|
||||
static int json_get_string(const char *json, const char *key,
|
||||
char *val, int maxlen) {
|
||||
char pat[256];
|
||||
snprintf(pat, sizeof(pat), "\"%s\"", key);
|
||||
const char *p = strstr(json, pat);
|
||||
if (!p) return 0;
|
||||
p = strchr(p + strlen(pat), '"');
|
||||
if (!p) return 0;
|
||||
p++;
|
||||
const char *end = strchr(p, '"');
|
||||
if (!end || (int)(end - p) >= maxlen) return 0;
|
||||
memcpy(val, p, end - p);
|
||||
val[end - p] = '\0';
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- Pattern discovery ---- */
|
||||
static int find_pattern_index(const unsigned char *ct) {
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (memcmp(patterns[i].ct, ct, 16) == 0) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void discover_dir(const char *dir) {
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) return;
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(d))) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
char path[MAX_PATH];
|
||||
snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name);
|
||||
struct stat st;
|
||||
if (lstat(path, &st) != 0) continue;
|
||||
if (S_ISLNK(st.st_mode)) continue;
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
discover_dir(path);
|
||||
continue;
|
||||
}
|
||||
if (!S_ISREG(st.st_mode)) continue;
|
||||
size_t nlen = strlen(ent->d_name);
|
||||
if (nlen < 5 || strcmp(ent->d_name + nlen - 4, ".dat") != 0) continue;
|
||||
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) continue;
|
||||
unsigned char hdr[31];
|
||||
size_t rd = fread(hdr, 1, 31, f);
|
||||
fclose(f);
|
||||
if (rd < 31 || memcmp(hdr, V2_MAGIC, V2_MAGIC_LEN) != 0) continue;
|
||||
|
||||
unsigned char *ct = hdr + 15;
|
||||
total_v2_files++;
|
||||
int idx = find_pattern_index(ct);
|
||||
if (idx >= 0) {
|
||||
patterns[idx].file_count++;
|
||||
} else if (npatterns < MAX_PATTERNS) {
|
||||
memcpy(patterns[npatterns].ct, ct, 16);
|
||||
patterns[npatterns].file_count = 1;
|
||||
patterns[npatterns].solved = 0;
|
||||
strncpy(patterns[npatterns].sample_path, path,
|
||||
sizeof(patterns[npatterns].sample_path) - 1);
|
||||
patterns[npatterns].sample_path[sizeof(patterns[npatterns].sample_path) - 1] = '\0';
|
||||
npatterns++;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
|
||||
/* Sort patterns by file_count descending */
|
||||
static int cmp_patterns(const void *a, const void *b) {
|
||||
return ((pattern_t*)b)->file_count - ((pattern_t*)a)->file_count;
|
||||
}
|
||||
|
||||
/* ---- Process discovery ---- */
|
||||
static int get_wechat_pids(pid_t *pids, int max) {
|
||||
int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0};
|
||||
size_t sz = 0;
|
||||
if (sysctl(mib, 4, NULL, &sz, NULL, 0) != KERN_SUCCESS || sz == 0)
|
||||
return 0;
|
||||
|
||||
size_t alloc_sz = sz + (sz >> 2);
|
||||
struct kinfo_proc *procs = malloc(alloc_sz);
|
||||
if (!procs) return 0;
|
||||
|
||||
if (sysctl(mib, 4, procs, &alloc_sz, NULL, 0) != KERN_SUCCESS) {
|
||||
free(procs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int n = (int)(alloc_sz / sizeof(struct kinfo_proc)), cnt = 0;
|
||||
for (int i = 0; i < n && cnt < max; i++)
|
||||
if (strstr(procs[i].kp_proc.p_comm, "WeChat"))
|
||||
pids[cnt++] = procs[i].kp_proc.p_pid;
|
||||
free(procs);
|
||||
return cnt;
|
||||
}
|
||||
|
||||
/* ---- Verification: decrypt sample file, validate JPEG marker chain ---- */
|
||||
|
||||
/* Validate JPEG structure: check marker chain (SOI → markers → SOS/EOI) */
|
||||
static int verify_jpeg_chain(const unsigned char *data, size_t len) {
|
||||
if (len < 4 || data[0] != 0xFF || data[1] != 0xD8) return 0;
|
||||
size_t pos = 2;
|
||||
int markers = 0;
|
||||
while (pos + 4 <= len) {
|
||||
if (data[pos] != 0xFF) return markers >= 2;
|
||||
unsigned char m = data[pos + 1];
|
||||
/* Skip fill bytes (FF FF...) */
|
||||
if (m == 0xFF) { pos++; continue; }
|
||||
if (m == 0x00) return 0; /* stuffed byte outside scan = invalid */
|
||||
if (m == 0xD9) return markers >= 1; /* EOI */
|
||||
if (m == 0xDA) return markers >= 1; /* SOS = scan data follows */
|
||||
if (m < 0xC0) return 0;
|
||||
uint16_t mlen = ((uint16_t)data[pos+2] << 8) | data[pos+3];
|
||||
if (mlen < 2) return 0;
|
||||
pos += 2 + mlen;
|
||||
markers++;
|
||||
}
|
||||
/* Ran out of data (first marker spans past AES region): accept if >= 1 valid marker */
|
||||
return markers >= 1;
|
||||
}
|
||||
|
||||
/* Validate PNG: 8-byte sig + IHDR chunk */
|
||||
static int verify_png_chain(const unsigned char *data, size_t len) {
|
||||
static const unsigned char sig[8] = {0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A};
|
||||
if (len < 24 || memcmp(data, sig, 8) != 0) return 0;
|
||||
/* IHDR chunk at offset 8: length(4) + "IHDR"(4) + data(13) + CRC(4) */
|
||||
return (data[12]=='I' && data[13]=='H' && data[14]=='D' && data[15]=='R');
|
||||
}
|
||||
|
||||
static int verify_key(int pat_idx) {
|
||||
pattern_t *p = &patterns[pat_idx];
|
||||
FILE *f = fopen(p->sample_path, "rb");
|
||||
if (!f) return 1; /* can't verify, assume ok */
|
||||
|
||||
unsigned char hdr[15];
|
||||
if (fread(hdr, 1, 15, f) != 15) { fclose(f); return 1; }
|
||||
uint32_t aes_size;
|
||||
memcpy(&aes_size, hdr + 6, 4);
|
||||
/* PKCS7: extra padding block when aes_size is 16-byte aligned */
|
||||
uint32_t ct_size = (aes_size % 16 == 0)
|
||||
? aes_size + 16
|
||||
: ((aes_size + 15) / 16) * 16;
|
||||
if (ct_size > 10 * 1024 * 1024) { fclose(f); return 1; }
|
||||
|
||||
unsigned char *ct = malloc(ct_size);
|
||||
size_t rd = fread(ct, 1, ct_size, f);
|
||||
fclose(f);
|
||||
if (rd < ct_size) { free(ct); return 1; }
|
||||
|
||||
unsigned char *pt = malloc(ct_size);
|
||||
size_t moved;
|
||||
CCCryptorStatus st = CCCrypt(kCCDecrypt, kCCAlgorithmAES128,
|
||||
kCCOptionECBMode, p->key, 16, NULL,
|
||||
ct, ct_size, pt, ct_size, &moved);
|
||||
free(ct);
|
||||
|
||||
if (st != kCCSuccess || moved < 16) { free(pt); return 0; }
|
||||
|
||||
/* Deep validation based on image type */
|
||||
int ok = 0;
|
||||
if (pt[0] == 0xFF && pt[1] == 0xD8)
|
||||
ok = verify_jpeg_chain(pt, moved);
|
||||
else if (pt[0] == 0x89 && pt[1] == 0x50)
|
||||
ok = verify_png_chain(pt, moved);
|
||||
else if (pt[0] == 'G' && pt[1] == 'I' && pt[2] == 'F')
|
||||
ok = (moved >= 6 && pt[3] == '8' && (pt[4]=='9'||pt[4]=='7') && pt[5]=='a');
|
||||
else if (pt[0] == 'R' && pt[1] == 'I')
|
||||
ok = (moved >= 12 && pt[8]=='W' && pt[9]=='E' && pt[10]=='B' && pt[11]=='P');
|
||||
|
||||
free(pt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* ---- Memory scanning ---- */
|
||||
|
||||
/*
|
||||
* Multi-block scan: for each candidate key, decrypt ALL unsolved
|
||||
* CT blocks in one CCCrypt call (ECB processes blocks independently).
|
||||
*/
|
||||
static int g_task_fail_warned = 0;
|
||||
|
||||
static int scan_pid(pid_t pid) {
|
||||
mach_port_t task;
|
||||
kern_return_t kr = task_for_pid(mach_task_self(), pid, &task);
|
||||
if (kr != KERN_SUCCESS) {
|
||||
if (!g_task_fail_warned) {
|
||||
g_task_fail_warned = 1;
|
||||
fprintf(stderr,
|
||||
" WARNING: task_for_pid(%d) failed (kr=%d).\n"
|
||||
" Cannot read WeChat memory. Checklist:\n"
|
||||
" 1. Run with sudo\n"
|
||||
" 2. Enable Developer Mode: Settings > Privacy & Security > Developer Mode\n"
|
||||
" 3. Grant Terminal Full Disk Access: Settings > Privacy & Security > Full Disk Access\n"
|
||||
" 4. If still failing, try: sudo DevToolsSecurity -enable\n"
|
||||
" 5. Last resort: disable SIP (boot to Recovery, run: csrutil disable)\n",
|
||||
pid, kr);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Build batch CT buffer for unsolved patterns */
|
||||
int unsolved_idx[MAX_PATTERNS];
|
||||
int n_unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved) unsolved_idx[n_unsolved++] = i;
|
||||
if (n_unsolved == 0) {
|
||||
mach_port_deallocate(mach_task_self(), task);
|
||||
return 0;
|
||||
}
|
||||
|
||||
unsigned char *batch_ct = malloc(n_unsolved * 16);
|
||||
unsigned char *batch_pt = malloc(n_unsolved * 16);
|
||||
if (!batch_ct || !batch_pt) {
|
||||
free(batch_ct);
|
||||
free(batch_pt);
|
||||
mach_port_deallocate(mach_task_self(), task);
|
||||
return 0;
|
||||
}
|
||||
for (int i = 0; i < n_unsolved; i++)
|
||||
memcpy(batch_ct + i*16, patterns[unsolved_idx[i]].ct, 16);
|
||||
|
||||
mach_vm_address_t addr = 0;
|
||||
mach_vm_size_t rsize;
|
||||
vm_region_basic_info_data_64_t info;
|
||||
mach_msg_type_number_t count;
|
||||
mach_port_t obj = MACH_PORT_NULL;
|
||||
|
||||
long regions = 0, found_this_pid = 0;
|
||||
long long total_bytes = 0, tests = 0;
|
||||
|
||||
while (!stop_flag) {
|
||||
count = VM_REGION_BASIC_INFO_COUNT_64;
|
||||
kr = mach_vm_region(task, &addr, &rsize, VM_REGION_BASIC_INFO_64,
|
||||
(vm_region_info_t)&info, &count, &obj);
|
||||
if (kr != KERN_SUCCESS) break;
|
||||
regions++;
|
||||
if (obj != MACH_PORT_NULL) {
|
||||
mach_port_deallocate(mach_task_self(), obj);
|
||||
obj = MACH_PORT_NULL;
|
||||
}
|
||||
|
||||
if ((info.protection & VM_PROT_READ) && rsize > 0 && rsize < REGION_MAX) {
|
||||
vm_offset_t data;
|
||||
mach_msg_type_number_t data_cnt;
|
||||
kr = mach_vm_read(task, addr, rsize, &data, &data_cnt);
|
||||
if (kr == KERN_SUCCESS) {
|
||||
unsigned char *buf = (unsigned char *)data;
|
||||
total_bytes += data_cnt;
|
||||
|
||||
/* Method 1: every 16-byte aligned position (raw binary keys) */
|
||||
for (mach_msg_type_number_t j = 0;
|
||||
j + 16 <= data_cnt && !stop_flag; j += 16) {
|
||||
tests++;
|
||||
size_t moved;
|
||||
CCCryptorStatus st = CCCrypt(
|
||||
kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode,
|
||||
buf + j, 16, NULL,
|
||||
batch_ct, n_unsolved * 16,
|
||||
batch_pt, n_unsolved * 16, &moved);
|
||||
if (st != kCCSuccess) continue;
|
||||
|
||||
for (int p = 0; p < n_unsolved; p++) {
|
||||
if (is_image_magic(batch_pt + p*16)) {
|
||||
if (is_rejected(buf + j)) continue;
|
||||
int idx = unsolved_idx[p];
|
||||
memcpy(patterns[idx].key, buf + j, 16);
|
||||
patterns[idx].solved = 1;
|
||||
|
||||
char kh[33]; bytes2hex(buf + j, 16, kh);
|
||||
char ch[33]; bytes2hex(patterns[idx].ct, 16, ch);
|
||||
printf("\n *** FOUND KEY: %s ***\n", kh);
|
||||
printf(" Pattern: %s (%d files)\n",
|
||||
ch, patterns[idx].file_count);
|
||||
printf(" PID %d, addr=0x%llx+0x%x\n",
|
||||
pid, addr, j);
|
||||
|
||||
/* Cross-check: does this key solve OTHER patterns? */
|
||||
for (int q = 0; q < n_unsolved; q++) {
|
||||
if (q == p || patterns[unsolved_idx[q]].solved)
|
||||
continue;
|
||||
unsigned char tpt[16];
|
||||
size_t tm;
|
||||
CCCrypt(kCCDecrypt, kCCAlgorithmAES128,
|
||||
kCCOptionECBMode, buf + j, 16, NULL,
|
||||
patterns[unsolved_idx[q]].ct, 16,
|
||||
tpt, 16, &tm);
|
||||
if (is_image_magic(tpt)) {
|
||||
int qi = unsolved_idx[q];
|
||||
memcpy(patterns[qi].key, buf + j, 16);
|
||||
patterns[qi].solved = 1;
|
||||
char qch[33];
|
||||
bytes2hex(patterns[qi].ct, 16, qch);
|
||||
printf(" Also solves: %s (%d files)\n",
|
||||
qch, patterns[qi].file_count);
|
||||
}
|
||||
}
|
||||
|
||||
found_this_pid++;
|
||||
/* Rebuild batch for remaining unsolved */
|
||||
n_unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved)
|
||||
unsolved_idx[n_unsolved++] = i;
|
||||
for (int i = 0; i < n_unsolved; i++)
|
||||
memcpy(batch_ct + i*16,
|
||||
patterns[unsolved_idx[i]].ct, 16);
|
||||
if (n_unsolved == 0) goto done;
|
||||
break; /* restart block check with new batch */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Method 2: hex string [0-9a-f]{16+} at unaligned positions.
|
||||
* WeChat may store the AES key as a hex-encoded ASCII string
|
||||
* in memory (e.g. "cfcd208495d565ef" = 16 ASCII bytes).
|
||||
* We use the raw ASCII bytes directly as the 16-byte AES key,
|
||||
* since the key is arbitrary bytes and the hex representation
|
||||
* itself is 16 bytes for a 64-bit key half. */
|
||||
int run = 0, run_start = 0;
|
||||
for (mach_msg_type_number_t j = 0;
|
||||
j <= data_cnt && !stop_flag; j++) {
|
||||
int is_hex = (j < data_cnt) &&
|
||||
((buf[j]>='a' && buf[j]<='f') ||
|
||||
(buf[j]>='0' && buf[j]<='9'));
|
||||
if (is_hex) {
|
||||
if (!run) run_start = j;
|
||||
run++;
|
||||
} else {
|
||||
if (run >= 16) {
|
||||
for (int k = run_start; k+16 <= run_start+run; k++) {
|
||||
if (k % 16 == 0) continue; /* already tested */
|
||||
tests++;
|
||||
size_t moved;
|
||||
CCCrypt(kCCDecrypt, kCCAlgorithmAES128,
|
||||
kCCOptionECBMode, buf+k, 16, NULL,
|
||||
batch_ct, n_unsolved*16,
|
||||
batch_pt, n_unsolved*16, &moved);
|
||||
for (int p = 0; p < n_unsolved; p++) {
|
||||
if (is_image_magic(batch_pt + p*16)) {
|
||||
if (is_rejected(buf+k)) continue;
|
||||
int idx = unsolved_idx[p];
|
||||
memcpy(patterns[idx].key, buf+k, 16);
|
||||
patterns[idx].solved = 1;
|
||||
char kh[33]; bytes2hex(buf+k, 16, kh);
|
||||
char ch[33];
|
||||
bytes2hex(patterns[idx].ct, 16, ch);
|
||||
printf("\n *** FOUND KEY: %s ***\n", kh);
|
||||
printf(" Pattern: %s (%d files)\n",
|
||||
ch, patterns[idx].file_count);
|
||||
int ctx_len = data_cnt - run_start;
|
||||
if (ctx_len > 32) ctx_len = 32;
|
||||
printf(" ASCII context: %.*s\n",
|
||||
ctx_len, buf + run_start);
|
||||
found_this_pid++;
|
||||
/* Rebuild */
|
||||
n_unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved)
|
||||
unsolved_idx[n_unsolved++] = i;
|
||||
for (int i = 0; i < n_unsolved; i++)
|
||||
memcpy(batch_ct + i*16,
|
||||
patterns[unsolved_idx[i]].ct, 16);
|
||||
if (n_unsolved == 0) goto done;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Method 3 (deep mode): byte-by-byte scan for top priority patterns */
|
||||
if (g_deep_mode && n_unsolved > 0) {
|
||||
/* Build priority batch: top N unsolved by file_count */
|
||||
int prio_idx[DEEP_PRIORITY_MAX];
|
||||
int n_prio = 0;
|
||||
for (int i = 0; i < n_unsolved && n_prio < DEEP_PRIORITY_MAX; i++) {
|
||||
int pi = unsolved_idx[i];
|
||||
if (patterns[pi].file_count >= 10)
|
||||
prio_idx[n_prio++] = pi;
|
||||
}
|
||||
if (n_prio > 0) {
|
||||
unsigned char prio_ct[DEEP_PRIORITY_MAX * 16];
|
||||
unsigned char prio_pt[DEEP_PRIORITY_MAX * 16];
|
||||
for (int i = 0; i < n_prio; i++)
|
||||
memcpy(prio_ct + i*16, patterns[prio_idx[i]].ct, 16);
|
||||
|
||||
for (mach_msg_type_number_t j = 0;
|
||||
j + 16 <= data_cnt && !stop_flag; j++) {
|
||||
if (j % 16 == 0) continue; /* already tested in Method 1 */
|
||||
tests++;
|
||||
size_t moved;
|
||||
CCCryptorStatus st = CCCrypt(
|
||||
kCCDecrypt, kCCAlgorithmAES128, kCCOptionECBMode,
|
||||
buf + j, 16, NULL,
|
||||
prio_ct, n_prio * 16,
|
||||
prio_pt, n_prio * 16, &moved);
|
||||
if (st != kCCSuccess) continue;
|
||||
|
||||
for (int p = 0; p < n_prio; p++) {
|
||||
if (!is_image_magic(prio_pt + p*16)) continue;
|
||||
if (is_rejected(buf + j)) continue;
|
||||
int idx = prio_idx[p];
|
||||
if (patterns[idx].solved) continue;
|
||||
memcpy(patterns[idx].key, buf + j, 16);
|
||||
patterns[idx].solved = 1;
|
||||
|
||||
char kh[33]; bytes2hex(buf + j, 16, kh);
|
||||
char ch[33]; bytes2hex(patterns[idx].ct, 16, ch);
|
||||
printf("\n *** FOUND KEY (deep): %s ***\n", kh);
|
||||
printf(" Pattern: %s (%d files)\n",
|
||||
ch, patterns[idx].file_count);
|
||||
printf(" PID %d, addr=0x%llx+0x%x (unaligned)\n",
|
||||
pid, addr, j);
|
||||
found_this_pid++;
|
||||
|
||||
/* Cross-check against all unsolved */
|
||||
for (int q = 0; q < n_unsolved; q++) {
|
||||
int qi = unsolved_idx[q];
|
||||
if (qi == idx || patterns[qi].solved) continue;
|
||||
unsigned char tpt[16];
|
||||
size_t tm;
|
||||
CCCrypt(kCCDecrypt, kCCAlgorithmAES128,
|
||||
kCCOptionECBMode, buf + j, 16, NULL,
|
||||
patterns[qi].ct, 16, tpt, 16, &tm);
|
||||
if (is_image_magic(tpt)) {
|
||||
memcpy(patterns[qi].key, buf + j, 16);
|
||||
patterns[qi].solved = 1;
|
||||
char qch[33];
|
||||
bytes2hex(patterns[qi].ct, 16, qch);
|
||||
printf(" Also solves: %s (%d files)\n",
|
||||
qch, patterns[qi].file_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rebuild main batch */
|
||||
n_unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved)
|
||||
unsolved_idx[n_unsolved++] = i;
|
||||
for (int i = 0; i < n_unsolved; i++)
|
||||
memcpy(batch_ct + i*16,
|
||||
patterns[unsolved_idx[i]].ct, 16);
|
||||
/* Rebuild priority batch */
|
||||
n_prio = 0;
|
||||
for (int i = 0; i < n_unsolved && n_prio < DEEP_PRIORITY_MAX; i++) {
|
||||
int pi2 = unsolved_idx[i];
|
||||
if (patterns[pi2].file_count >= 10)
|
||||
prio_idx[n_prio++] = pi2;
|
||||
}
|
||||
for (int i = 0; i < n_prio; i++)
|
||||
memcpy(prio_ct + i*16, patterns[prio_idx[i]].ct, 16);
|
||||
if (n_unsolved == 0) goto done;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
done:
|
||||
mach_vm_deallocate(mach_task_self(), data, data_cnt);
|
||||
if (n_unsolved == 0) break;
|
||||
}
|
||||
}
|
||||
addr += rsize;
|
||||
if (regions % 500 == 0) {
|
||||
printf(" [%ld regions, %lld MB, %lld tests]\r",
|
||||
regions, total_bytes/(1024*1024), tests);
|
||||
fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
printf(" PID %d: %ld regions, %lld MB, %lld tests, %ld keys found \n",
|
||||
pid, regions, total_bytes/(1024*1024), tests, found_this_pid);
|
||||
|
||||
free(batch_ct);
|
||||
free(batch_pt);
|
||||
mach_port_deallocate(mach_task_self(), task);
|
||||
return (int)found_this_pid;
|
||||
}
|
||||
|
||||
/* ---- Save results ---- */
|
||||
static void save_keys(const char *dir) {
|
||||
char path[MAX_PATH];
|
||||
snprintf(path, sizeof(path), "%s/image_keys.json", dir);
|
||||
|
||||
int solved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (patterns[i].solved) solved++;
|
||||
if (solved == 0) return;
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) { fprintf(stderr, "Cannot write %s\n", path); return; }
|
||||
|
||||
fprintf(f, "{\n");
|
||||
int first = 1;
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (!patterns[i].solved) continue;
|
||||
char ct_hex[33], key_hex[33];
|
||||
bytes2hex(patterns[i].ct, 16, ct_hex);
|
||||
bytes2hex(patterns[i].key, 16, key_hex);
|
||||
fprintf(f, "%s \"%s\": \"%s\"",
|
||||
first ? "" : ",\n", ct_hex, key_hex);
|
||||
first = 0;
|
||||
}
|
||||
fprintf(f, "\n}\n");
|
||||
fclose(f);
|
||||
printf("\nSaved %d keys to %s\n", solved, path);
|
||||
}
|
||||
|
||||
/* ---- Load existing keys from image_keys.json ---- */
|
||||
static int load_keys(const char *dir) {
|
||||
char path[MAX_PATH];
|
||||
snprintf(path, sizeof(path), "%s/image_keys.json", dir);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return 0;
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
if (sz <= 0) { fclose(f); return 0; }
|
||||
fseek(f, 0, SEEK_SET);
|
||||
char *json = malloc((size_t)sz + 1);
|
||||
if (!json) { fclose(f); return 0; }
|
||||
size_t rd = fread(json, 1, (size_t)sz, f);
|
||||
if (rd != (size_t)sz) {
|
||||
free(json);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
fclose(f);
|
||||
json[rd] = '\0';
|
||||
|
||||
int loaded = 0;
|
||||
/* Parse "ct_hex": "key_hex" pairs */
|
||||
const char *p = json;
|
||||
while ((p = strchr(p, '"')) != NULL) {
|
||||
p++;
|
||||
const char *ct_end = strchr(p, '"');
|
||||
if (!ct_end || ct_end - p != 32) { p = ct_end ? ct_end + 1 : p; continue; }
|
||||
char ct_str[33]; memcpy(ct_str, p, 32); ct_str[32] = '\0';
|
||||
unsigned char ct[16];
|
||||
if (hex2bytes(ct_str, ct, 16) != 16) { p = ct_end + 1; continue; }
|
||||
|
||||
p = ct_end + 1;
|
||||
p = strchr(p, '"');
|
||||
if (!p) break;
|
||||
p++;
|
||||
const char *key_end = strchr(p, '"');
|
||||
if (!key_end || key_end - p != 32) { p = key_end ? key_end + 1 : p; continue; }
|
||||
char key_str[33]; memcpy(key_str, p, 32); key_str[32] = '\0';
|
||||
unsigned char key[16];
|
||||
if (hex2bytes(key_str, key, 16) != 16) { p = key_end + 1; continue; }
|
||||
|
||||
/* Match to pattern */
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (!patterns[i].solved && memcmp(patterns[i].ct, ct, 16) == 0) {
|
||||
memcpy(patterns[i].key, key, 16);
|
||||
patterns[i].solved = 1;
|
||||
loaded++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
p = key_end + 1;
|
||||
}
|
||||
free(json);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/* ---- Main ---- */
|
||||
int main(int argc, char *argv[]) {
|
||||
signal(SIGINT, sigint_handler);
|
||||
|
||||
printf("=== WeChat V2 Image Key Scanner ===\n\n");
|
||||
if (getuid() != 0) {
|
||||
fprintf(stderr, "ERROR: Run with sudo!\n"); return 1;
|
||||
}
|
||||
|
||||
/* Determine image directory */
|
||||
char image_dir[MAX_PATH] = "";
|
||||
char exe_dir[MAX_PATH] = ".";
|
||||
int deep_mode = 0;
|
||||
const char *last_slash = strrchr(argv[0], '/');
|
||||
if (last_slash) {
|
||||
int len = (int)(last_slash - argv[0]);
|
||||
snprintf(exe_dir, sizeof(exe_dir), "%.*s", len, argv[0]);
|
||||
}
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--deep") == 0)
|
||||
deep_mode = 1;
|
||||
else if (image_dir[0] == '\0') {
|
||||
strncpy(image_dir, argv[i], sizeof(image_dir) - 1);
|
||||
image_dir[sizeof(image_dir) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
if (image_dir[0] == '\0') {
|
||||
/* Read config.json */
|
||||
char cfg_path[MAX_PATH];
|
||||
snprintf(cfg_path, sizeof(cfg_path), "%s/config.json", exe_dir);
|
||||
FILE *cf = fopen(cfg_path, "r");
|
||||
if (cf) {
|
||||
fseek(cf, 0, SEEK_END);
|
||||
long sz = ftell(cf);
|
||||
if (sz <= 0) { fclose(cf); return 1; }
|
||||
fseek(cf, 0, SEEK_SET);
|
||||
char *json = malloc((size_t)sz + 1);
|
||||
if (!json) { fclose(cf); return 1; }
|
||||
size_t rd = fread(json, 1, (size_t)sz, cf);
|
||||
if (rd != (size_t)sz) {
|
||||
free(json);
|
||||
fclose(cf);
|
||||
return 1;
|
||||
}
|
||||
json[rd] = '\0';
|
||||
fclose(cf);
|
||||
char db_dir[MAX_PATH];
|
||||
if (json_get_string(json, "db_dir", db_dir, sizeof(db_dir))) {
|
||||
char *s = strrchr(db_dir, '/');
|
||||
if (!s) s = strrchr(db_dir, '\\');
|
||||
if (s) {
|
||||
int plen = (int)(s - db_dir);
|
||||
snprintf(image_dir, sizeof(image_dir),
|
||||
"%.*s/msg", plen, db_dir);
|
||||
}
|
||||
}
|
||||
free(json);
|
||||
}
|
||||
}
|
||||
|
||||
/* Auto-detect: scan ~/Library/Containers/com.tencent.xinWeChat */
|
||||
if (image_dir[0] == '\0') {
|
||||
const char *home = getenv("HOME");
|
||||
if (!home) home = "/Users";
|
||||
char base[MAX_PATH];
|
||||
snprintf(base, sizeof(base),
|
||||
"%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files",
|
||||
home);
|
||||
DIR *d = opendir(base);
|
||||
if (d) {
|
||||
struct dirent *ent;
|
||||
while ((ent = readdir(d))) {
|
||||
if (ent->d_name[0] == '.') continue;
|
||||
char candidate[MAX_PATH];
|
||||
snprintf(candidate, sizeof(candidate), "%s/%s/msg", base, ent->d_name);
|
||||
struct stat st;
|
||||
if (stat(candidate, &st) == 0 && S_ISDIR(st.st_mode)) {
|
||||
strncpy(image_dir, candidate, sizeof(image_dir) - 1);
|
||||
printf("Auto-detected image directory:\n %s\n\n", image_dir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
}
|
||||
|
||||
if (image_dir[0] == '\0') {
|
||||
fprintf(stderr, "ERROR: Cannot determine image directory.\n");
|
||||
fprintf(stderr, "Tried:\n");
|
||||
fprintf(stderr, " 1. Command line argument\n");
|
||||
fprintf(stderr, " 2. config.json db_dir\n");
|
||||
fprintf(stderr, " 3. Auto-detect ~/Library/Containers/com.tencent.xinWeChat/...\n\n");
|
||||
fprintf(stderr, "Usage: sudo %s [--deep] [image_dir]\n", argv[0]);
|
||||
fprintf(stderr, " image_dir: path to .../xwechat_files/<wxid>/msg\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Phase 1: Discover patterns */
|
||||
printf("Discovering encryption patterns in:\n %s\n\n", image_dir);
|
||||
discover_dir(image_dir);
|
||||
if (npatterns == 0) {
|
||||
fprintf(stderr, "No V2 .dat files found!\n"); return 1;
|
||||
}
|
||||
qsort(patterns, npatterns, sizeof(pattern_t), cmp_patterns);
|
||||
|
||||
int total_covered = 0;
|
||||
printf("Found %d patterns across %d V2 files:\n", npatterns, total_v2_files);
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
char ch[33]; bytes2hex(patterns[i].ct, 16, ch);
|
||||
printf(" #%-2d %s (%d files)\n", i+1, ch, patterns[i].file_count);
|
||||
total_covered += patterns[i].file_count;
|
||||
}
|
||||
if (total_covered < total_v2_files)
|
||||
printf(" ... and %d files in overflow patterns\n",
|
||||
total_v2_files - total_covered);
|
||||
|
||||
/* Load previously found keys */
|
||||
int preloaded = load_keys(exe_dir);
|
||||
if (preloaded > 0)
|
||||
printf("\nLoaded %d existing keys from image_keys.json\n", preloaded);
|
||||
|
||||
if (deep_mode) {
|
||||
g_deep_mode = 1;
|
||||
printf("\n*** DEEP MODE: byte-by-byte scan for top %d unsolved patterns ***\n",
|
||||
DEEP_PRIORITY_MAX);
|
||||
}
|
||||
|
||||
/* Phase 2: Continuous scanning */
|
||||
printf("\nScanning WeChat memory — keep browsing images! (Ctrl+C to stop)\n");
|
||||
int round = 0;
|
||||
while (!stop_flag) {
|
||||
int unsolved = 0;
|
||||
for (int i = 0; i < npatterns; i++)
|
||||
if (!patterns[i].solved) unsolved++;
|
||||
if (unsolved == 0) break;
|
||||
|
||||
round++;
|
||||
pid_t pids[64];
|
||||
int npids = get_wechat_pids(pids, 64);
|
||||
if (npids == 0) {
|
||||
printf(" No WeChat processes found, waiting...\n");
|
||||
sleep(3);
|
||||
continue;
|
||||
}
|
||||
|
||||
printf("\n--- Round %d: %d unsolved / %d total, %d PIDs ---\n",
|
||||
round, unsolved, npatterns, npids);
|
||||
|
||||
int found_round = 0;
|
||||
for (int i = 0; i < npids && !stop_flag; i++) {
|
||||
found_round += scan_pid(pids[i]);
|
||||
}
|
||||
|
||||
unsolved = 0;
|
||||
int solved_files = 0;
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (patterns[i].solved) solved_files += patterns[i].file_count;
|
||||
else unsolved++;
|
||||
}
|
||||
|
||||
if (found_round > 0) {
|
||||
printf("\n Progress: %d/%d patterns solved (%d/%d files)\n",
|
||||
npatterns - unsolved, npatterns,
|
||||
solved_files, total_v2_files);
|
||||
/* Verify newly found keys */
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (patterns[i].solved && !verify_key(i)) {
|
||||
char kh[33]; bytes2hex(patterns[i].key, 16, kh);
|
||||
printf(" REJECTED: %s (failed verification)\n", kh);
|
||||
add_rejected(patterns[i].key);
|
||||
patterns[i].solved = 0;
|
||||
memset(patterns[i].key, 0, 16);
|
||||
}
|
||||
}
|
||||
/* Save after each find */
|
||||
save_keys(exe_dir);
|
||||
}
|
||||
|
||||
if (unsolved > 0 && !stop_flag) {
|
||||
printf(" Keep browsing images in different chats...\n");
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 3: Summary */
|
||||
save_keys(exe_dir);
|
||||
|
||||
int solved = 0, solved_files = 0;
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (patterns[i].solved) {
|
||||
solved++;
|
||||
solved_files += patterns[i].file_count;
|
||||
}
|
||||
}
|
||||
|
||||
printf("\n==================================================\n");
|
||||
if (solved == npatterns) {
|
||||
printf("ALL %d patterns solved! (%d files)\n", npatterns, total_v2_files);
|
||||
} else {
|
||||
printf("%d/%d patterns solved (%d/%d files)\n",
|
||||
solved, npatterns, solved_files, total_v2_files);
|
||||
printf("Unsolved:\n");
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (patterns[i].solved) continue;
|
||||
char ch[33]; bytes2hex(patterns[i].ct, 16, ch);
|
||||
printf(" %s (%d files)\n", ch, patterns[i].file_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* Count unique keys */
|
||||
int unique_keys = 0;
|
||||
for (int i = 0; i < npatterns; i++) {
|
||||
if (!patterns[i].solved) continue;
|
||||
int dup = 0;
|
||||
for (int j = 0; j < i; j++)
|
||||
if (patterns[j].solved &&
|
||||
memcmp(patterns[i].key, patterns[j].key, 16) == 0) { dup = 1; break; }
|
||||
if (!dup) unique_keys++;
|
||||
}
|
||||
printf("%d unique key(s) found.\n", unique_keys);
|
||||
printf("==================================================\n");
|
||||
|
||||
return (solved > 0) ? 0 : 1;
|
||||
}
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
"""从微信进程内存中提取图片 AES 密钥 (V2 .dat 格式)
|
||||
|
||||
V2 .dat 文件结构:
|
||||
[6B signature: 07 08 V2 08 07] [4B aes_size LE] [4B xor_size LE] [1B padding]
|
||||
[aes_size bytes AES-ECB encrypted] [raw_data unencrypted] [xor_size bytes XOR encrypted]
|
||||
|
||||
AES key: 16-byte ASCII string found in Weixin.exe process memory
|
||||
XOR key: single byte, same as old format (derived from JPEG FF D9 ending)
|
||||
|
||||
Usage:
|
||||
1. 打开微信, 进入聊天/朋友圈, 点击查看 2-3 张图片
|
||||
2. 立即运行: python find_image_key.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import struct
|
||||
import glob
|
||||
import json
|
||||
import time
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util import Padding
|
||||
|
||||
# Windows API constants
|
||||
PROCESS_ALL_ACCESS = 0x1F0FFF
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT = 0x1000
|
||||
PAGE_NOACCESS = 0x01
|
||||
PAGE_GUARD = 0x100
|
||||
PAGE_READWRITE = 0x04
|
||||
PAGE_WRITECOPY = 0x08
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
PAGE_EXECUTE_WRITECOPY = 0x80
|
||||
|
||||
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_void_p),
|
||||
("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wintypes.DWORD),
|
||||
("RegionSize", ctypes.c_size_t),
|
||||
("State", wintypes.DWORD),
|
||||
("Protect", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
]
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# 正则: 精确 32 字符 alphanum (前后是非 alphanum 或边界)
|
||||
RE_KEY32 = re.compile(rb'(?<![a-zA-Z0-9])[a-zA-Z0-9]{32}(?![a-zA-Z0-9])')
|
||||
# 正则: 精确 16 字符 alphanum
|
||||
RE_KEY16 = re.compile(rb'(?<![a-zA-Z0-9])[a-zA-Z0-9]{16}(?![a-zA-Z0-9])')
|
||||
|
||||
|
||||
def get_wechat_pids():
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
['tasklist.exe', '/FI', 'IMAGENAME eq Weixin.exe', '/FO', 'CSV', '/NH'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
pids = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if 'Weixin.exe' in line:
|
||||
parts = line.strip('"').split('","')
|
||||
if len(parts) >= 2:
|
||||
pids.append(int(parts[1]))
|
||||
return pids
|
||||
|
||||
|
||||
def find_v2_ciphertext(attach_dir):
|
||||
"""从多个 V2 .dat 文件中提取第一个 AES 密文块 (16 bytes)"""
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
|
||||
# Search _t.dat (thumbnails, likely JPEG)
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
|
||||
for f in dat_files[:100]:
|
||||
try:
|
||||
with open(f, 'rb') as fp:
|
||||
header = fp.read(31)
|
||||
if header[:6] == v2_magic and len(header) >= 31:
|
||||
return header[15:31], os.path.basename(f)
|
||||
except:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def find_xor_key(attach_dir):
|
||||
"""从缩略图文件末尾推导 XOR key (JPEG 结尾 FF D9)"""
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
|
||||
tail_counts = {}
|
||||
for f in dat_files[:32]:
|
||||
try:
|
||||
sz = os.path.getsize(f)
|
||||
with open(f, 'rb') as fp:
|
||||
head = fp.read(6)
|
||||
fp.seek(sz - 2)
|
||||
tail = fp.read(2)
|
||||
if head == v2_magic and len(tail) == 2:
|
||||
key = (tail[0], tail[1])
|
||||
tail_counts[key] = tail_counts.get(key, 0) + 1
|
||||
except:
|
||||
continue
|
||||
|
||||
if not tail_counts:
|
||||
return None
|
||||
|
||||
most_common = max(tail_counts, key=tail_counts.get)
|
||||
x, y = most_common
|
||||
xor_key = x ^ 0xFF
|
||||
check = y ^ 0xD9
|
||||
|
||||
if xor_key == check:
|
||||
return xor_key
|
||||
return xor_key # return best guess anyway
|
||||
|
||||
|
||||
def try_key(key_bytes, ciphertext):
|
||||
"""Try decrypting ciphertext with key, return format name if successful"""
|
||||
try:
|
||||
cipher = AES.new(key_bytes, AES.MODE_ECB)
|
||||
dec = cipher.decrypt(ciphertext)
|
||||
if dec[:3] == b'\xFF\xD8\xFF':
|
||||
return 'JPEG'
|
||||
if dec[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
|
||||
return 'PNG'
|
||||
if dec[:4] == b'RIFF':
|
||||
return 'WEBP'
|
||||
if dec[:4] == b'wxgf':
|
||||
return 'WXGF'
|
||||
if dec[:3] == b'GIF':
|
||||
return 'GIF'
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def is_rw_protect(protect):
|
||||
"""Check if memory region is readable/writable (where string keys live)"""
|
||||
rw_flags = (PAGE_READWRITE | PAGE_WRITECOPY |
|
||||
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)
|
||||
return (protect & rw_flags) != 0
|
||||
|
||||
|
||||
def scan_memory_for_aes_key(pid, ciphertext):
|
||||
"""扫描微信进程内存寻找 AES key (regex 加速版)"""
|
||||
access = PROCESS_VM_READ | PROCESS_QUERY_INFORMATION
|
||||
h_process = kernel32.OpenProcess(access, False, pid)
|
||||
if not h_process:
|
||||
print(f" 无法打开进程 {pid} (尝试以管理员运行)", flush=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Enumerate memory regions
|
||||
address = 0
|
||||
mbi = MEMORY_BASIC_INFORMATION()
|
||||
rw_regions = []
|
||||
all_regions = []
|
||||
|
||||
while address < 0x7FFFFFFFFFFF:
|
||||
result = kernel32.VirtualQueryEx(
|
||||
h_process, ctypes.c_void_p(address),
|
||||
ctypes.byref(mbi), ctypes.sizeof(mbi)
|
||||
)
|
||||
if result == 0:
|
||||
break
|
||||
if (mbi.State == MEM_COMMIT and
|
||||
mbi.Protect != PAGE_NOACCESS and
|
||||
(mbi.Protect & PAGE_GUARD) == 0 and
|
||||
mbi.RegionSize <= 50 * 1024 * 1024):
|
||||
region = (mbi.BaseAddress, mbi.RegionSize, mbi.Protect)
|
||||
all_regions.append(region)
|
||||
if is_rw_protect(mbi.Protect):
|
||||
rw_regions.append(region)
|
||||
next_addr = address + mbi.RegionSize
|
||||
if next_addr <= address:
|
||||
break
|
||||
address = next_addr
|
||||
|
||||
rw_mb = sum(r[1] for r in rw_regions) / 1024 / 1024
|
||||
all_mb = sum(r[1] for r in all_regions) / 1024 / 1024
|
||||
print(f" RW 区域: {len(rw_regions)} ({rw_mb:.0f} MB), 总计: {len(all_regions)} ({all_mb:.0f} MB)", flush=True)
|
||||
|
||||
# Phase 1: 只扫描 RW 区域 (key 字符串最可能在这里)
|
||||
print(" === Phase 1: 扫描 RW 内存 ===", flush=True)
|
||||
result = _scan_regions(h_process, rw_regions, ciphertext)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# Phase 2: 扫描所有可读区域
|
||||
print(" === Phase 2: 扫描所有内存 ===", flush=True)
|
||||
# 排除已扫描的 RW 区域
|
||||
rw_set = set((r[0], r[1]) for r in rw_regions)
|
||||
other_regions = [r for r in all_regions if (r[0], r[1]) not in rw_set]
|
||||
result = _scan_regions(h_process, other_regions, ciphertext)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
finally:
|
||||
kernel32.CloseHandle(h_process)
|
||||
|
||||
|
||||
def _scan_regions(h_process, regions, ciphertext):
|
||||
"""扫描指定内存区域列表,返回找到的 key 或 None"""
|
||||
candidates_32 = 0
|
||||
candidates_16 = 0
|
||||
t0 = time.time()
|
||||
|
||||
for idx, (base_addr, region_size, _protect) in enumerate(regions):
|
||||
if idx % 100 == 0:
|
||||
elapsed = time.time() - t0
|
||||
print(f" 扫描 {idx}/{len(regions)} ({elapsed:.1f}s)", end='\r', flush=True)
|
||||
|
||||
buffer = ctypes.create_string_buffer(region_size)
|
||||
bytes_read = ctypes.c_size_t(0)
|
||||
ok = kernel32.ReadProcessMemory(
|
||||
h_process, ctypes.c_void_p(base_addr),
|
||||
buffer, region_size, ctypes.byref(bytes_read)
|
||||
)
|
||||
if not ok or bytes_read.value < 32:
|
||||
continue
|
||||
|
||||
data = buffer.raw[:bytes_read.value]
|
||||
|
||||
# 用正则找 32 字符 alphanum (C 级速度)
|
||||
for m in RE_KEY32.finditer(data):
|
||||
key_bytes = m.group()
|
||||
candidates_32 += 1
|
||||
|
||||
# 前 16 字符作为 AES-128 key
|
||||
fmt = try_key(key_bytes[:16], ciphertext)
|
||||
if fmt:
|
||||
key_str = key_bytes.decode('ascii')
|
||||
print(f"\n*** 找到 AES key (32-char)! → {fmt} ***", flush=True)
|
||||
print(f" 完整: {key_str}", flush=True)
|
||||
print(f" AES key: {key_str[:16]}", flush=True)
|
||||
return key_str[:16]
|
||||
|
||||
# 也试完整 32 字节作 AES-256
|
||||
fmt = try_key(key_bytes, ciphertext)
|
||||
if fmt:
|
||||
key_str = key_bytes.decode('ascii')
|
||||
print(f"\n*** 找到 AES key (32-byte)! → {fmt} ***", flush=True)
|
||||
print(f" 完整: {key_str}", flush=True)
|
||||
return key_str
|
||||
|
||||
# 也找独立的 16 字符 alphanum
|
||||
for m in RE_KEY16.finditer(data):
|
||||
key_bytes = m.group()
|
||||
candidates_16 += 1
|
||||
|
||||
fmt = try_key(key_bytes, ciphertext)
|
||||
if fmt:
|
||||
key_str = key_bytes.decode('ascii')
|
||||
print(f"\n*** 找到 AES key (16-char)! → {fmt} ***", flush=True)
|
||||
print(f" AES key: {key_str}", flush=True)
|
||||
return key_str
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n 测试: {candidates_32} x 32-char + {candidates_16} x 16-char ({elapsed:.1f}s)", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def verify_and_decrypt(attach_dir, aes_key_str, xor_key):
|
||||
"""完整解密一个 V2 文件作为验证"""
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
key = aes_key_str.encode('ascii')[:16]
|
||||
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
|
||||
for f in dat_files[:10]:
|
||||
try:
|
||||
with open(f, 'rb') as fp:
|
||||
data = fp.read()
|
||||
if data[:6] != v2_magic:
|
||||
continue
|
||||
|
||||
sig, aes_size, xor_size = struct.unpack_from('<6sLL', data)
|
||||
|
||||
# AES 对齐: 向上取整到 16 的倍数 (PKCS7 填充)
|
||||
aligned_aes_size = aes_size
|
||||
aligned_aes_size -= ~(~aligned_aes_size % 16)
|
||||
|
||||
offset = 15
|
||||
aes_data = data[offset:offset + aligned_aes_size]
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size)
|
||||
offset += aligned_aes_size
|
||||
|
||||
# Raw portion
|
||||
raw_data = data[offset:len(data) - xor_size]
|
||||
offset += len(raw_data)
|
||||
|
||||
# XOR portion
|
||||
xor_data = data[offset:]
|
||||
dec_xor = bytes(b ^ xor_key for b in xor_data) if xor_key is not None else xor_data
|
||||
|
||||
result = dec_aes + raw_data + dec_xor
|
||||
|
||||
fmt = "unknown"
|
||||
ext = ".bin"
|
||||
if result[:3] == b'\xFF\xD8\xFF':
|
||||
fmt, ext = "JPEG", ".jpg"
|
||||
elif result[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
|
||||
fmt, ext = "PNG", ".png"
|
||||
elif result[:4] == b'RIFF':
|
||||
fmt, ext = "WEBP", ".webp"
|
||||
elif result[:4] == b'wxgf':
|
||||
fmt, ext = "WXGF", ".hevc"
|
||||
|
||||
print(f" {os.path.basename(f)} -> {fmt} ({len(result):,}B)", flush=True)
|
||||
|
||||
if fmt != "unknown":
|
||||
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(f))[0] + ext)
|
||||
with open(out_path, 'wb') as fp:
|
||||
fp.write(result)
|
||||
print(f" saved: {out_path}", flush=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
db_dir = config['db_dir']
|
||||
base_dir = os.path.dirname(db_dir)
|
||||
attach_dir = os.path.join(base_dir, 'msg', 'attach')
|
||||
|
||||
# 1. XOR key
|
||||
print("=== XOR Key ===", flush=True)
|
||||
xor_key = find_xor_key(attach_dir)
|
||||
if xor_key is not None:
|
||||
print(f"XOR key: 0x{xor_key:02x}", flush=True)
|
||||
|
||||
# 2. V2 ciphertext
|
||||
print("\n=== V2 ciphertext ===", flush=True)
|
||||
ciphertext, ct_file = find_v2_ciphertext(attach_dir)
|
||||
if ciphertext is None:
|
||||
print("No V2 .dat files found")
|
||||
return
|
||||
print(f"File: {ct_file}", flush=True)
|
||||
print(f"Cipher: {ciphertext.hex()}", flush=True)
|
||||
|
||||
# 3. Check if already have key in config
|
||||
if config.get('image_aes_key'):
|
||||
print(f"\nExisting image_aes_key: {config['image_aes_key']}", flush=True)
|
||||
fmt = try_key(config['image_aes_key'].encode('ascii')[:16], ciphertext)
|
||||
if fmt:
|
||||
print(f"Key valid! -> {fmt}", flush=True)
|
||||
print("\n=== Verify decrypt ===", flush=True)
|
||||
verify_and_decrypt(attach_dir, config['image_aes_key'], xor_key)
|
||||
return
|
||||
else:
|
||||
print("Saved key invalid, re-scanning...", flush=True)
|
||||
|
||||
# 4. Scan memory
|
||||
print("\n=== Scanning WeChat process memory ===", flush=True)
|
||||
pids = get_wechat_pids()
|
||||
if not pids:
|
||||
print("WeChat not running!")
|
||||
return
|
||||
print(f"PIDs: {pids}", flush=True)
|
||||
print("Tip: View 2-3 images in WeChat first, then run this script immediately\n", flush=True)
|
||||
|
||||
aes_key = None
|
||||
for pid in pids:
|
||||
print(f"Scanning PID {pid}...", flush=True)
|
||||
aes_key = scan_memory_for_aes_key(pid, ciphertext)
|
||||
if aes_key:
|
||||
break
|
||||
|
||||
if aes_key:
|
||||
print(f"\n=== Result ===", flush=True)
|
||||
print(f"AES key: {aes_key}", flush=True)
|
||||
print(f"XOR key: 0x{xor_key:02x}" if xor_key is not None else "XOR key: unknown", flush=True)
|
||||
|
||||
config['image_aes_key'] = aes_key
|
||||
if xor_key is not None:
|
||||
config['image_xor_key'] = xor_key
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
print(f"Saved to {config_path}", flush=True)
|
||||
|
||||
print("\n=== Verify decrypt ===", flush=True)
|
||||
verify_and_decrypt(attach_dir, aes_key, xor_key)
|
||||
else:
|
||||
print("\nAES key not found!", flush=True)
|
||||
print("Steps:", flush=True)
|
||||
print(" 1. Login WeChat and keep it running", flush=True)
|
||||
print(" 2. Open Moments or a chat, view 2-3 images (tap to open full size)", flush=True)
|
||||
print(" 3. Immediately re-run this script", flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
"""持续监控微信进程内存,捕获图片 AES 密钥
|
||||
|
||||
运行此脚本后,在微信中打开查看几张图片。
|
||||
脚本会自动检测到 key 并保存到 config.json。
|
||||
|
||||
按 Ctrl+C 退出。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import struct
|
||||
import glob
|
||||
import json
|
||||
import time
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util import Padding
|
||||
|
||||
# Windows API constants
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
MEM_COMMIT = 0x1000
|
||||
PAGE_NOACCESS = 0x01
|
||||
PAGE_GUARD = 0x100
|
||||
PAGE_READWRITE = 0x04
|
||||
PAGE_WRITECOPY = 0x08
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
PAGE_EXECUTE_WRITECOPY = 0x80
|
||||
|
||||
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_void_p),
|
||||
("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wintypes.DWORD),
|
||||
("RegionSize", ctypes.c_size_t),
|
||||
("State", wintypes.DWORD),
|
||||
("Protect", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
]
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# Regex for key patterns
|
||||
RE_KEY32 = re.compile(rb'(?<![a-zA-Z0-9])[a-zA-Z0-9]{32}(?![a-zA-Z0-9])')
|
||||
RE_KEY16 = re.compile(rb'(?<![a-zA-Z0-9])[a-zA-Z0-9]{16}(?![a-zA-Z0-9])')
|
||||
|
||||
|
||||
def get_wechat_pids():
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
['tasklist.exe', '/FI', 'IMAGENAME eq Weixin.exe', '/FO', 'CSV', '/NH'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
pids = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if 'Weixin.exe' in line:
|
||||
parts = line.strip('"').split('","')
|
||||
if len(parts) >= 2:
|
||||
pids.append(int(parts[1]))
|
||||
return pids
|
||||
|
||||
|
||||
def find_v2_ciphertext(attach_dir):
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
for f in dat_files[:100]:
|
||||
try:
|
||||
with open(f, 'rb') as fp:
|
||||
header = fp.read(31)
|
||||
if header[:6] == v2_magic and len(header) >= 31:
|
||||
return header[15:31], os.path.basename(f)
|
||||
except:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def find_xor_key(attach_dir):
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
tail_counts = {}
|
||||
for f in dat_files[:32]:
|
||||
try:
|
||||
sz = os.path.getsize(f)
|
||||
with open(f, 'rb') as fp:
|
||||
head = fp.read(6)
|
||||
fp.seek(sz - 2)
|
||||
tail = fp.read(2)
|
||||
if head == v2_magic and len(tail) == 2:
|
||||
key = (tail[0], tail[1])
|
||||
tail_counts[key] = tail_counts.get(key, 0) + 1
|
||||
except:
|
||||
continue
|
||||
if not tail_counts:
|
||||
return None
|
||||
most_common = max(tail_counts, key=tail_counts.get)
|
||||
return most_common[0] ^ 0xFF
|
||||
|
||||
|
||||
def try_key(key_bytes, ciphertext):
|
||||
try:
|
||||
cipher = AES.new(key_bytes, AES.MODE_ECB)
|
||||
dec = cipher.decrypt(ciphertext)
|
||||
if dec[:3] == b'\xFF\xD8\xFF': return 'JPEG'
|
||||
if dec[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): return 'PNG'
|
||||
if dec[:4] == b'RIFF': return 'WEBP'
|
||||
if dec[:4] == b'wxgf': return 'WXGF'
|
||||
if dec[:3] == b'GIF': return 'GIF'
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def is_rw_protect(protect):
|
||||
rw_flags = (PAGE_READWRITE | PAGE_WRITECOPY |
|
||||
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)
|
||||
return (protect & rw_flags) != 0
|
||||
|
||||
|
||||
def get_rw_regions(h_process):
|
||||
"""Get RW committed memory regions"""
|
||||
address = 0
|
||||
mbi = MEMORY_BASIC_INFORMATION()
|
||||
regions = []
|
||||
while address < 0x7FFFFFFFFFFF:
|
||||
result = kernel32.VirtualQueryEx(
|
||||
h_process, ctypes.c_void_p(address),
|
||||
ctypes.byref(mbi), ctypes.sizeof(mbi)
|
||||
)
|
||||
if result == 0:
|
||||
break
|
||||
if (mbi.State == MEM_COMMIT and
|
||||
mbi.Protect != PAGE_NOACCESS and
|
||||
(mbi.Protect & PAGE_GUARD) == 0 and
|
||||
mbi.RegionSize <= 50 * 1024 * 1024 and
|
||||
is_rw_protect(mbi.Protect)):
|
||||
regions.append((mbi.BaseAddress, mbi.RegionSize))
|
||||
next_addr = address + mbi.RegionSize
|
||||
if next_addr <= address:
|
||||
break
|
||||
address = next_addr
|
||||
return regions
|
||||
|
||||
|
||||
def quick_scan(h_process, regions, ciphertext):
|
||||
"""Fast scan of RW regions, return key or None"""
|
||||
for base_addr, region_size in regions:
|
||||
buffer = ctypes.create_string_buffer(region_size)
|
||||
bytes_read = ctypes.c_size_t(0)
|
||||
ok = kernel32.ReadProcessMemory(
|
||||
h_process, ctypes.c_void_p(base_addr),
|
||||
buffer, region_size, ctypes.byref(bytes_read)
|
||||
)
|
||||
if not ok or bytes_read.value < 32:
|
||||
continue
|
||||
|
||||
data = buffer.raw[:bytes_read.value]
|
||||
|
||||
# 32-char keys (first 16 as AES-128)
|
||||
for m in RE_KEY32.finditer(data):
|
||||
key_bytes = m.group()
|
||||
fmt = try_key(key_bytes[:16], ciphertext)
|
||||
if fmt:
|
||||
return key_bytes.decode('ascii')[:16], fmt
|
||||
fmt = try_key(key_bytes, ciphertext)
|
||||
if fmt:
|
||||
return key_bytes.decode('ascii'), fmt
|
||||
|
||||
# Standalone 16-char keys
|
||||
for m in RE_KEY16.finditer(data):
|
||||
key_bytes = m.group()
|
||||
fmt = try_key(key_bytes, ciphertext)
|
||||
if fmt:
|
||||
return key_bytes.decode('ascii'), fmt
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def verify_and_decrypt(attach_dir, aes_key_str, xor_key):
|
||||
"""Decrypt one V2 file as verification"""
|
||||
v2_magic = b'\x07\x08V2\x08\x07'
|
||||
key = aes_key_str.encode('ascii')[:16]
|
||||
pattern = os.path.join(attach_dir, "*", "*", "Img", "*_t.dat")
|
||||
dat_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
|
||||
|
||||
for f in dat_files[:10]:
|
||||
try:
|
||||
with open(f, 'rb') as fp:
|
||||
data = fp.read()
|
||||
if data[:6] != v2_magic:
|
||||
continue
|
||||
sig, aes_size, xor_size = struct.unpack_from('<6sLL', data)
|
||||
aligned_aes_size = aes_size
|
||||
aligned_aes_size -= ~(~aligned_aes_size % 16)
|
||||
offset = 15
|
||||
aes_data = data[offset:offset + aligned_aes_size]
|
||||
cipher = AES.new(key, AES.MODE_ECB)
|
||||
dec_aes = Padding.unpad(cipher.decrypt(aes_data), AES.block_size)
|
||||
offset += aligned_aes_size
|
||||
raw_data = data[offset:len(data) - xor_size]
|
||||
offset += len(raw_data)
|
||||
xor_data = data[offset:]
|
||||
dec_xor = bytes(b ^ xor_key for b in xor_data) if xor_key is not None else xor_data
|
||||
result = dec_aes + raw_data + dec_xor
|
||||
|
||||
fmt, ext = "unknown", ".bin"
|
||||
if result[:3] == b'\xFF\xD8\xFF': fmt, ext = "JPEG", ".jpg"
|
||||
elif result[:4] == bytes([0x89, 0x50, 0x4E, 0x47]): fmt, ext = "PNG", ".png"
|
||||
elif result[:4] == b'RIFF': fmt, ext = "WEBP", ".webp"
|
||||
elif result[:4] == b'wxgf': fmt, ext = "WXGF", ".hevc"
|
||||
|
||||
if fmt != "unknown":
|
||||
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(f))[0] + ext)
|
||||
with open(out_path, 'wb') as fp:
|
||||
fp.write(result)
|
||||
print(f" Verified: {os.path.basename(f)} -> {fmt} ({len(result):,}B)", flush=True)
|
||||
print(f" Saved: {out_path}", flush=True)
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
db_dir = config['db_dir']
|
||||
base_dir = os.path.dirname(db_dir)
|
||||
attach_dir = os.path.join(base_dir, 'msg', 'attach')
|
||||
|
||||
xor_key = find_xor_key(attach_dir)
|
||||
print(f"XOR key: 0x{xor_key:02x}" if xor_key else "XOR key: unknown", flush=True)
|
||||
|
||||
ciphertext, ct_file = find_v2_ciphertext(attach_dir)
|
||||
if ciphertext is None:
|
||||
print("No V2 .dat files found")
|
||||
return
|
||||
print(f"V2 cipher: {ciphertext.hex()} ({ct_file})", flush=True)
|
||||
|
||||
# Check existing key
|
||||
if config.get('image_aes_key'):
|
||||
fmt = try_key(config['image_aes_key'].encode('ascii')[:16], ciphertext)
|
||||
if fmt:
|
||||
print(f"Existing key valid: {config['image_aes_key']} -> {fmt}", flush=True)
|
||||
return
|
||||
|
||||
pids = get_wechat_pids()
|
||||
if not pids:
|
||||
print("WeChat not running!")
|
||||
return
|
||||
|
||||
# Find the main PID (largest memory footprint)
|
||||
main_pid = pids[0]
|
||||
print(f"\nMonitoring PID {main_pid} (main WeChat process)", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
print("NOW: Open WeChat and tap to view 2-3 images (full size)", flush=True)
|
||||
print("The script will automatically detect the key...", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
access = PROCESS_VM_READ | PROCESS_QUERY_INFORMATION
|
||||
h_process = kernel32.OpenProcess(access, False, main_pid)
|
||||
if not h_process:
|
||||
print(f"Cannot open process {main_pid} (run as admin?)", flush=True)
|
||||
return
|
||||
|
||||
try:
|
||||
# Get regions once (they don't change much)
|
||||
regions = get_rw_regions(h_process)
|
||||
total_mb = sum(r[1] for r in regions) / 1024 / 1024
|
||||
print(f"RW regions: {len(regions)} ({total_mb:.0f} MB)", flush=True)
|
||||
|
||||
scan_count = 0
|
||||
while True:
|
||||
scan_count += 1
|
||||
t0 = time.time()
|
||||
aes_key, fmt = quick_scan(h_process, regions, ciphertext)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
if aes_key:
|
||||
print(f"\n{'='*60}", flush=True)
|
||||
print(f"*** FOUND AES key! -> {fmt} ***", flush=True)
|
||||
print(f"AES key: {aes_key}", flush=True)
|
||||
print(f"XOR key: 0x{xor_key:02x}" if xor_key else "XOR key: unknown", flush=True)
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
config['image_aes_key'] = aes_key
|
||||
if xor_key is not None:
|
||||
config['image_xor_key'] = xor_key
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
print(f"Saved to {config_path}", flush=True)
|
||||
|
||||
verify_and_decrypt(attach_dir, aes_key, xor_key)
|
||||
return
|
||||
|
||||
print(f" Scan #{scan_count}: no key found ({elapsed:.1f}s)", end='\r', flush=True)
|
||||
|
||||
# Wait 5 seconds before next scan
|
||||
time.sleep(5)
|
||||
|
||||
# Refresh regions periodically (every 5 scans)
|
||||
if scan_count % 5 == 0:
|
||||
regions = get_rw_regions(h_process)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped by user", flush=True)
|
||||
finally:
|
||||
kernel32.CloseHandle(h_process)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
"""
|
||||
跨平台共享的内存扫描逻辑:HMAC 验证、DB 收集、hex 模式匹配与结果输出。
|
||||
|
||||
Windows / Linux 版分别实现进程发现和内存读取,共用此模块的核心算法。
|
||||
"""
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
|
||||
PAGE_SZ = 4096
|
||||
KEY_SZ = 32
|
||||
SALT_SZ = 16
|
||||
|
||||
|
||||
def verify_enc_key(enc_key, db_page1):
|
||||
"""通过 HMAC-SHA512 校验 page 1 验证 enc_key 是否正确。"""
|
||||
salt = db_page1[:SALT_SZ]
|
||||
mac_salt = bytes(b ^ 0x3A for b in salt)
|
||||
mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ)
|
||||
hmac_data = db_page1[SALT_SZ: PAGE_SZ - 80 + 16]
|
||||
stored_hmac = db_page1[PAGE_SZ - 64: PAGE_SZ]
|
||||
hm = hmac_mod.new(mac_key, hmac_data, hashlib.sha512)
|
||||
hm.update(struct.pack("<I", 1))
|
||||
return hm.digest() == stored_hmac
|
||||
|
||||
|
||||
def collect_db_files(db_dir):
|
||||
"""遍历 db_dir 收集所有 .db 文件及其 salt。
|
||||
|
||||
返回 (db_files, salt_to_dbs):
|
||||
db_files: [(rel_path, abs_path, size, salt_hex, page1_bytes), ...]
|
||||
salt_to_dbs: {salt_hex: [rel_path, ...]}
|
||||
"""
|
||||
db_files = []
|
||||
salt_to_dbs = {}
|
||||
for root, dirs, files in os.walk(db_dir):
|
||||
for name in files:
|
||||
if not name.endswith(".db") or name.endswith("-wal") or name.endswith("-shm"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
size = os.path.getsize(path)
|
||||
if size < PAGE_SZ:
|
||||
continue
|
||||
with open(path, "rb") as f:
|
||||
page1 = f.read(PAGE_SZ)
|
||||
rel = os.path.relpath(path, db_dir)
|
||||
salt = page1[:SALT_SZ].hex()
|
||||
db_files.append((rel, path, size, salt, page1))
|
||||
salt_to_dbs.setdefault(salt, []).append(rel)
|
||||
return db_files, salt_to_dbs
|
||||
|
||||
|
||||
def scan_memory_for_keys(data, hex_re, db_files, salt_to_dbs, key_map,
|
||||
remaining_salts, base_addr, pid, print_fn):
|
||||
"""扫描一段内存数据,匹配 hex 模式并验证密钥。
|
||||
|
||||
返回本次扫描匹配到的 hex 模式数量。
|
||||
"""
|
||||
matches = 0
|
||||
for m in hex_re.finditer(data):
|
||||
hex_str = m.group(1).decode()
|
||||
addr = base_addr + m.start()
|
||||
matches += 1
|
||||
hex_len = len(hex_str)
|
||||
|
||||
if hex_len == 96:
|
||||
enc_key_hex = hex_str[:64]
|
||||
salt_hex = hex_str[64:]
|
||||
if salt_hex in remaining_salts:
|
||||
enc_key = bytes.fromhex(enc_key_hex)
|
||||
for rel, path, sz, s, page1 in db_files:
|
||||
if s == salt_hex and verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = enc_key_hex
|
||||
remaining_salts.discard(salt_hex)
|
||||
dbs = salt_to_dbs[salt_hex]
|
||||
print_fn(f"\n [FOUND] salt={salt_hex}")
|
||||
print_fn(f" enc_key={enc_key_hex}")
|
||||
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
|
||||
print_fn(f" 数据库: {', '.join(dbs)}")
|
||||
break
|
||||
|
||||
elif hex_len == 64:
|
||||
if not remaining_salts:
|
||||
continue
|
||||
enc_key_hex = hex_str
|
||||
enc_key = bytes.fromhex(enc_key_hex)
|
||||
for rel, path, sz, salt_hex_db, page1 in db_files:
|
||||
if salt_hex_db in remaining_salts and verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex_db] = enc_key_hex
|
||||
remaining_salts.discard(salt_hex_db)
|
||||
dbs = salt_to_dbs[salt_hex_db]
|
||||
print_fn(f"\n [FOUND] salt={salt_hex_db}")
|
||||
print_fn(f" enc_key={enc_key_hex}")
|
||||
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
|
||||
print_fn(f" 数据库: {', '.join(dbs)}")
|
||||
break
|
||||
|
||||
elif hex_len > 96 and hex_len % 2 == 0:
|
||||
enc_key_hex = hex_str[:64]
|
||||
salt_hex = hex_str[-32:]
|
||||
if salt_hex in remaining_salts:
|
||||
enc_key = bytes.fromhex(enc_key_hex)
|
||||
for rel, path, sz, s, page1 in db_files:
|
||||
if s == salt_hex and verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = enc_key_hex
|
||||
remaining_salts.discard(salt_hex)
|
||||
dbs = salt_to_dbs[salt_hex]
|
||||
print_fn(f"\n [FOUND] salt={salt_hex} (long hex {hex_len})")
|
||||
print_fn(f" enc_key={enc_key_hex}")
|
||||
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
|
||||
print_fn(f" 数据库: {', '.join(dbs)}")
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def cross_verify_keys(db_files, salt_to_dbs, key_map, print_fn):
|
||||
"""用已找到的 key 交叉验证未匹配的 salt。"""
|
||||
missing_salts = set(salt_to_dbs.keys()) - set(key_map.keys())
|
||||
if not missing_salts or not key_map:
|
||||
return
|
||||
print_fn(f"\n还有 {len(missing_salts)} 个 salt 未匹配,尝试交叉验证...")
|
||||
for salt_hex in list(missing_salts):
|
||||
for rel, path, sz, s, page1 in db_files:
|
||||
if s == salt_hex:
|
||||
for known_salt, known_key_hex in key_map.items():
|
||||
enc_key = bytes.fromhex(known_key_hex)
|
||||
if verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = known_key_hex
|
||||
print_fn(f" [CROSS] salt={salt_hex} 可用 key from salt={known_salt}")
|
||||
missing_salts.discard(salt_hex)
|
||||
break
|
||||
|
||||
|
||||
def save_results(db_files, salt_to_dbs, key_map, db_dir, out_file, print_fn):
|
||||
"""输出扫描结果并保存 JSON。"""
|
||||
print_fn(f"\n{'=' * 60}")
|
||||
print_fn(f"结果: {len(key_map)}/{len(salt_to_dbs)} salts 找到密钥")
|
||||
|
||||
result = {}
|
||||
for rel, path, sz, salt_hex, page1 in db_files:
|
||||
if salt_hex in key_map:
|
||||
result[rel] = {
|
||||
"enc_key": key_map[salt_hex],
|
||||
"salt": salt_hex,
|
||||
"size_mb": round(sz / 1024 / 1024, 1)
|
||||
}
|
||||
print_fn(f" OK: {rel} ({sz / 1024 / 1024:.1f}MB)")
|
||||
else:
|
||||
print_fn(f" MISSING: {rel} (salt={salt_hex})")
|
||||
|
||||
if not result:
|
||||
print_fn(f"\n[!] 未提取到任何密钥,保留已有的 {out_file}(如存在)")
|
||||
raise RuntimeError("未能从任何微信进程中提取到密钥")
|
||||
|
||||
result["_db_dir"] = db_dir
|
||||
with open(out_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||
print_fn(f"\n密钥保存到: {out_file}")
|
||||
|
||||
missing = [rel for rel, path, sz, salt_hex, page1 in db_files if salt_hex not in key_map]
|
||||
if missing:
|
||||
print_fn(f"\n未找到密钥的数据库:")
|
||||
for rel in missing:
|
||||
print_fn(f" {rel}")
|
||||
175
latency_test.py
175
latency_test.py
|
|
@ -1,175 +0,0 @@
|
|||
"""测量消息延迟 - 用mtime检测WAL变化(WAL文件是预分配固定大小的)"""
|
||||
import time, os, sys, io, hashlib, struct, sqlite3, json
|
||||
from datetime import datetime
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
PAGE_SZ = 4096; KEY_SZ = 32; SALT_SZ = 16; RESERVE_SZ = 80
|
||||
SQLITE_HDR = b'SQLite format 3\x00'
|
||||
WAL_HEADER_SZ = 32; WAL_FRAME_HEADER_SZ = 24
|
||||
|
||||
from config import load_config
|
||||
_cfg = load_config()
|
||||
DB_DIR = _cfg["db_dir"]
|
||||
KEYS_FILE = _cfg["keys_file"]
|
||||
DECRYPTED = os.path.join(_cfg["decrypted_dir"], "session", "session.db")
|
||||
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
enc_key = bytes.fromhex(keys["session/session.db"]["enc_key"])
|
||||
|
||||
session_db = os.path.join(DB_DIR, "session", "session.db")
|
||||
wal_path = session_db + "-wal"
|
||||
|
||||
|
||||
def decrypt_page(enc_key, page_data, pgno):
|
||||
iv = page_data[PAGE_SZ - RESERVE_SZ: PAGE_SZ - RESERVE_SZ + 16]
|
||||
if pgno == 1:
|
||||
encrypted = page_data[SALT_SZ: PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ)
|
||||
else:
|
||||
encrypted = page_data[:PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return decrypted + b'\x00' * RESERVE_SZ
|
||||
|
||||
|
||||
def full_decrypt(src, dst):
|
||||
t0 = time.perf_counter()
|
||||
total = os.path.getsize(src) // PAGE_SZ
|
||||
with open(src, 'rb') as fin, open(dst, 'wb') as fout:
|
||||
for pgno in range(1, total + 1):
|
||||
page = fin.read(PAGE_SZ)
|
||||
if len(page) < PAGE_SZ: break
|
||||
fout.write(decrypt_page(enc_key, page, pgno))
|
||||
return total, (time.perf_counter() - t0) * 1000
|
||||
|
||||
|
||||
def decrypt_wal_full(wal_path, dst):
|
||||
"""解密WAL当前有效frame,patch到dst (校验salt跳过旧周期遗留frame)"""
|
||||
t0 = time.perf_counter()
|
||||
wal_sz = os.path.getsize(wal_path)
|
||||
frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ
|
||||
patched = 0
|
||||
|
||||
with open(wal_path, 'rb') as wf, open(dst, 'r+b') as df:
|
||||
wal_hdr = wf.read(WAL_HEADER_SZ)
|
||||
wal_salt1 = struct.unpack('>I', wal_hdr[16:20])[0]
|
||||
wal_salt2 = struct.unpack('>I', wal_hdr[20:24])[0]
|
||||
|
||||
while wf.tell() + frame_size <= wal_sz:
|
||||
fh = wf.read(WAL_FRAME_HEADER_SZ)
|
||||
if len(fh) < WAL_FRAME_HEADER_SZ: break
|
||||
pgno = struct.unpack('>I', fh[0:4])[0]
|
||||
frame_salt1 = struct.unpack('>I', fh[8:12])[0]
|
||||
frame_salt2 = struct.unpack('>I', fh[12:16])[0]
|
||||
ep = wf.read(PAGE_SZ)
|
||||
if len(ep) < PAGE_SZ: break
|
||||
if pgno == 0 or pgno > 1000000: continue
|
||||
if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2: continue
|
||||
dec = decrypt_page(enc_key, ep, pgno)
|
||||
df.seek((pgno - 1) * PAGE_SZ)
|
||||
df.write(dec)
|
||||
patched += 1
|
||||
|
||||
return patched, (time.perf_counter() - t0) * 1000
|
||||
|
||||
|
||||
# 初始化: 全量解密
|
||||
print("初始全量解密...", flush=True)
|
||||
pages, ms = full_decrypt(session_db, DECRYPTED)
|
||||
print(f" DB: {pages}页 {ms:.0f}ms", flush=True)
|
||||
if os.path.exists(wal_path):
|
||||
patched, ms2 = decrypt_wal_full(wal_path, DECRYPTED)
|
||||
print(f" WAL: {patched}页 {ms2:.0f}ms", flush=True)
|
||||
|
||||
# 获取初始状态
|
||||
conn = sqlite3.connect(DECRYPTED)
|
||||
prev_sessions = {}
|
||||
for r in conn.execute("SELECT username, last_timestamp FROM SessionTable WHERE last_timestamp>0"):
|
||||
prev_sessions[r[0]] = r[1]
|
||||
conn.close()
|
||||
|
||||
# 记录初始mtime
|
||||
prev_wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0
|
||||
prev_db_mtime = os.path.getmtime(session_db)
|
||||
wal_sz = os.path.getsize(wal_path) if os.path.exists(wal_path) else 0
|
||||
|
||||
print(f"\nWAL大小: {wal_sz} bytes (固定预分配)", flush=True)
|
||||
print(f"跟踪 {len(prev_sessions)} 个会话", flush=True)
|
||||
print(f"\n等待微信新消息... (60秒超时, 30ms轮询)\n", flush=True)
|
||||
|
||||
start = time.time()
|
||||
|
||||
while time.time() - start < 60:
|
||||
time.sleep(0.03)
|
||||
|
||||
# 用mtime检测变化
|
||||
try:
|
||||
wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0
|
||||
db_mtime = os.path.getmtime(session_db)
|
||||
except:
|
||||
continue
|
||||
|
||||
if wal_mtime == prev_wal_mtime and db_mtime == prev_db_mtime:
|
||||
continue
|
||||
|
||||
t_detect = time.perf_counter()
|
||||
detect_str = datetime.now().strftime('%H:%M:%S.%f')[:-3]
|
||||
|
||||
wal_changed = wal_mtime != prev_wal_mtime
|
||||
db_changed = db_mtime != prev_db_mtime
|
||||
print(f"[{detect_str}] 变化检测: WAL={'变' if wal_changed else '不变'} DB={'变' if db_changed else '不变'}", flush=True)
|
||||
|
||||
# 如果DB变了(checkpoint), 全量重解密
|
||||
if db_changed and not wal_changed:
|
||||
pages, ms = full_decrypt(session_db, DECRYPTED)
|
||||
print(f" 全量解密: {pages}页 {ms:.0f}ms", flush=True)
|
||||
else:
|
||||
# WAL变了, 重新patch所有WAL frame (因为不知道哪些是新的)
|
||||
# 先全量解密DB基础
|
||||
pages, ms = full_decrypt(session_db, DECRYPTED)
|
||||
patched, ms2 = decrypt_wal_full(wal_path, DECRYPTED)
|
||||
print(f" DB {pages}页/{ms:.0f}ms + WAL {patched}页/{ms2:.0f}ms", flush=True)
|
||||
|
||||
t_decrypt = time.perf_counter()
|
||||
|
||||
# 查询变化
|
||||
conn = sqlite3.connect(DECRYPTED)
|
||||
new_msgs = []
|
||||
for r in conn.execute("""
|
||||
SELECT username, last_timestamp, summary, last_sender_display_name
|
||||
FROM SessionTable WHERE last_timestamp > 0
|
||||
"""):
|
||||
uname, ts, summary, sender = r
|
||||
if ts > prev_sessions.get(uname, 0):
|
||||
delay = time.time() - ts
|
||||
new_msgs.append((uname, ts, summary or '', sender or '', delay))
|
||||
prev_sessions[uname] = ts
|
||||
conn.close()
|
||||
|
||||
t_query = time.perf_counter()
|
||||
|
||||
decrypt_ms = (t_decrypt - t_detect) * 1000
|
||||
query_ms = (t_query - t_decrypt) * 1000
|
||||
total_ms = (t_query - t_detect) * 1000
|
||||
|
||||
print(f" 处理总耗时: {total_ms:.1f}ms (解密{decrypt_ms:.1f}ms + 查询{query_ms:.1f}ms)", flush=True)
|
||||
|
||||
for uname, ts, summary, sender, delay in sorted(new_msgs, key=lambda x: x[1]):
|
||||
if ':\n' in summary:
|
||||
summary = summary.split(':\n', 1)[1]
|
||||
msg_time = datetime.fromtimestamp(ts).strftime('%H:%M:%S')
|
||||
print(f" >>> 消息时间={msg_time} | 微信→DB延迟={delay:.1f}s | {sender}: {summary}", flush=True)
|
||||
|
||||
if not new_msgs:
|
||||
print(f" (无新消息变化)", flush=True)
|
||||
|
||||
prev_wal_mtime = wal_mtime
|
||||
prev_db_mtime = db_mtime
|
||||
print(flush=True)
|
||||
|
||||
print("超时退出", flush=True)
|
||||
116
main.py
116
main.py
|
|
@ -1,116 +0,0 @@
|
|||
"""
|
||||
WeChat Decrypt 一键启动
|
||||
|
||||
python main.py # 提取密钥 + 启动 Web UI
|
||||
python main.py decrypt # 提取密钥 + 解密全部数据库
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
from key_utils import strip_key_metadata
|
||||
|
||||
|
||||
def check_wechat_running():
|
||||
"""检查微信是否在运行,返回 True/False"""
|
||||
from find_all_keys import get_pids
|
||||
try:
|
||||
get_pids()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_keys(keys_file, db_dir):
|
||||
"""确保密钥文件存在且匹配当前 db_dir,否则重新提取"""
|
||||
if os.path.exists(keys_file):
|
||||
try:
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
keys = {}
|
||||
# 检查密钥是否匹配当前 db_dir(防止切换账号后误复用旧密钥)
|
||||
saved_dir = keys.pop("_db_dir", None)
|
||||
if saved_dir and os.path.normcase(os.path.normpath(saved_dir)) != os.path.normcase(os.path.normpath(db_dir)):
|
||||
print(f"[!] 密钥文件对应的目录已变更,需要重新提取")
|
||||
print(f" 旧: {saved_dir}")
|
||||
print(f" 新: {db_dir}")
|
||||
keys = {}
|
||||
keys = strip_key_metadata(keys)
|
||||
if keys:
|
||||
print(f"[+] 已有 {len(keys)} 个数据库密钥")
|
||||
return
|
||||
|
||||
print("[*] 密钥文件不存在,正在从微信进程提取...")
|
||||
print()
|
||||
from find_all_keys import main as extract_keys
|
||||
try:
|
||||
extract_keys()
|
||||
except RuntimeError as e:
|
||||
print(f"\n[!] 密钥提取失败: {e}")
|
||||
sys.exit(1)
|
||||
print()
|
||||
|
||||
# 提取后再次检查
|
||||
if not os.path.exists(keys_file):
|
||||
print("[!] 密钥提取失败")
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
keys = {}
|
||||
if not strip_key_metadata(keys):
|
||||
print("[!] 未能提取到任何密钥")
|
||||
print(" 可能原因:选择了错误的微信数据目录,或微信需要重启")
|
||||
print(" 请检查 config.json 中的 db_dir 是否与当前登录的微信账号匹配")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" WeChat Decrypt")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# 1. 加载配置(自动检测 db_dir)
|
||||
from config import load_config
|
||||
cfg = load_config()
|
||||
|
||||
# 2. 检查微信进程
|
||||
if not check_wechat_running():
|
||||
print(f"[!] 未检测到微信进程 ({cfg.get('wechat_process', 'WeChat')})")
|
||||
print(" 请先启动微信并登录,然后重新运行")
|
||||
sys.exit(1)
|
||||
print("[+] 微信进程运行中")
|
||||
|
||||
# 3. 提取密钥
|
||||
ensure_keys(cfg["keys_file"], cfg["db_dir"])
|
||||
|
||||
# 4. 根据子命令执行
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "web"
|
||||
|
||||
if cmd == "decrypt":
|
||||
print("[*] 开始解密全部数据库...")
|
||||
print()
|
||||
from decrypt_db import main as decrypt_all
|
||||
decrypt_all()
|
||||
elif cmd == "web":
|
||||
print("[*] 启动 Web UI...")
|
||||
print()
|
||||
from monitor_web import main as start_web
|
||||
start_web()
|
||||
else:
|
||||
print(f"[!] 未知命令: {cmd}")
|
||||
print()
|
||||
print("用法:")
|
||||
print(" python main.py 启动实时消息监听 (Web UI)")
|
||||
print(" python main.py decrypt 解密全部数据库到 decrypted/")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1738
mcp_server.py
1738
mcp_server.py
File diff suppressed because it is too large
Load Diff
260
monitor.py
260
monitor.py
|
|
@ -1,260 +0,0 @@
|
|||
"""
|
||||
微信实时消息监听器
|
||||
|
||||
原理: 定期解密 session.db (2MB, <1秒), 检测新消息
|
||||
session.db 包含每个聊天的最新消息摘要、发送者、时间戳
|
||||
"""
|
||||
import hashlib, struct, os, sys, json, time, sqlite3, io
|
||||
import hmac as hmac_mod
|
||||
from datetime import datetime
|
||||
from Crypto.Cipher import AES
|
||||
import zstandard as zstd
|
||||
from key_utils import get_key_info, strip_key_metadata
|
||||
|
||||
_zstd_dctx = zstd.ZstdDecompressor()
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
PAGE_SZ = 4096
|
||||
KEY_SZ = 32
|
||||
SALT_SZ = 16
|
||||
IV_SZ = 16
|
||||
HMAC_SZ = 64
|
||||
RESERVE_SZ = 80
|
||||
SQLITE_HDR = b'SQLite format 3\x00'
|
||||
|
||||
from config import load_config
|
||||
_cfg = load_config()
|
||||
DB_DIR = _cfg["db_dir"]
|
||||
KEYS_FILE = _cfg["keys_file"]
|
||||
CONTACT_CACHE = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
|
||||
|
||||
POLL_INTERVAL = 3 # 秒
|
||||
|
||||
|
||||
def derive_mac_key(enc_key, salt):
|
||||
mac_salt = bytes(b ^ 0x3a for b in salt)
|
||||
return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ)
|
||||
|
||||
|
||||
def decrypt_page(enc_key, page_data, pgno):
|
||||
iv = page_data[PAGE_SZ - RESERVE_SZ : PAGE_SZ - RESERVE_SZ + IV_SZ]
|
||||
if pgno == 1:
|
||||
encrypted = page_data[SALT_SZ : PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
page = bytearray(SQLITE_HDR + decrypted + b'\x00' * RESERVE_SZ)
|
||||
return bytes(page)
|
||||
else:
|
||||
encrypted = page_data[:PAGE_SZ - RESERVE_SZ]
|
||||
cipher = AES.new(enc_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return decrypted + b'\x00' * RESERVE_SZ
|
||||
|
||||
|
||||
def decrypt_db_to_memory(db_path, enc_key):
|
||||
"""解密DB到内存中的bytes, 返回可用于sqlite3的数据"""
|
||||
file_size = os.path.getsize(db_path)
|
||||
total_pages = file_size // PAGE_SZ
|
||||
if file_size % PAGE_SZ != 0:
|
||||
total_pages += 1
|
||||
|
||||
chunks = []
|
||||
with open(db_path, 'rb') as fin:
|
||||
for pgno in range(1, total_pages + 1):
|
||||
page = fin.read(PAGE_SZ)
|
||||
if len(page) < PAGE_SZ:
|
||||
if len(page) > 0:
|
||||
page = page + b'\x00' * (PAGE_SZ - len(page))
|
||||
else:
|
||||
break
|
||||
decrypted = decrypt_page(enc_key, page, pgno)
|
||||
chunks.append(decrypted)
|
||||
|
||||
return b''.join(chunks)
|
||||
|
||||
|
||||
def decrypt_db_to_sqlite(db_path, enc_key):
|
||||
"""解密DB并返回sqlite3连接 (内存数据库)"""
|
||||
data = decrypt_db_to_memory(db_path, enc_key)
|
||||
|
||||
# 写临时文件 (sqlite3不支持直接从bytes打开)
|
||||
tmp_path = db_path + ".tmp_monitor"
|
||||
with open(tmp_path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
conn = sqlite3.connect(tmp_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn, tmp_path
|
||||
|
||||
|
||||
def load_contact_names():
|
||||
"""从已解密的contact.db加载联系人昵称映射"""
|
||||
names = {}
|
||||
if not os.path.exists(CONTACT_CACHE):
|
||||
return names
|
||||
try:
|
||||
conn = sqlite3.connect(CONTACT_CACHE)
|
||||
rows = conn.execute(
|
||||
"SELECT username, nick_name, remark FROM contact"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
username, nick, remark = r
|
||||
names[username] = remark if remark else nick if nick else username
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WARN] 加载联系人失败: {e}")
|
||||
return names
|
||||
|
||||
|
||||
def get_session_state(conn):
|
||||
"""获取当前session状态"""
|
||||
state = {}
|
||||
try:
|
||||
rows = conn.execute("""
|
||||
SELECT username, unread_count, summary, last_timestamp,
|
||||
last_msg_type, last_msg_sender, last_sender_display_name
|
||||
FROM SessionTable
|
||||
WHERE last_timestamp > 0
|
||||
""").fetchall()
|
||||
for r in rows:
|
||||
state[r[0]] = {
|
||||
'unread': r[1],
|
||||
'summary': r[2] or '',
|
||||
'timestamp': r[3],
|
||||
'msg_type': r[4],
|
||||
'sender': r[5] or '',
|
||||
'sender_name': r[6] or '',
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 读取session失败: {e}")
|
||||
return state
|
||||
|
||||
|
||||
def format_msg_type(t):
|
||||
types = {
|
||||
1: '文本', 3: '图片', 34: '语音', 42: '名片',
|
||||
43: '视频', 47: '表情', 48: '位置', 49: '链接/文件',
|
||||
50: '语音/视频通话', 10000: '系统消息', 10002: '撤回',
|
||||
}
|
||||
return types.get(t, f'type={t}')
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" 微信实时消息监听器")
|
||||
print("=" * 60)
|
||||
|
||||
# 加载密钥
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
keys = strip_key_metadata(json.load(f))
|
||||
|
||||
session_key_info = get_key_info(keys, os.path.join("session", "session.db"))
|
||||
if not session_key_info:
|
||||
print("[ERROR] 找不到session.db的密钥")
|
||||
sys.exit(1)
|
||||
|
||||
enc_key = bytes.fromhex(session_key_info["enc_key"])
|
||||
session_db = os.path.join(DB_DIR, "session", "session.db")
|
||||
|
||||
# 加载联系人
|
||||
print("加载联系人...")
|
||||
contact_names = load_contact_names()
|
||||
print(f"已加载 {len(contact_names)} 个联系人")
|
||||
|
||||
# 初始状态
|
||||
print("读取初始状态...")
|
||||
conn, tmp_path = decrypt_db_to_sqlite(session_db, enc_key)
|
||||
prev_state = get_session_state(conn)
|
||||
conn.close()
|
||||
os.remove(tmp_path)
|
||||
|
||||
print(f"跟踪 {len(prev_state)} 个会话")
|
||||
print(f"轮询间隔: {POLL_INTERVAL}秒")
|
||||
print(f"\n{'='*60}")
|
||||
print("开始监听... (Ctrl+C 停止)\n")
|
||||
|
||||
poll_count = 0
|
||||
try:
|
||||
while True:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
poll_count += 1
|
||||
|
||||
try:
|
||||
conn, tmp_path = decrypt_db_to_sqlite(session_db, enc_key)
|
||||
curr_state = get_session_state(conn)
|
||||
conn.close()
|
||||
os.remove(tmp_path)
|
||||
except Exception as e:
|
||||
if poll_count % 10 == 0:
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] 读取失败: {e}")
|
||||
continue
|
||||
|
||||
# 比较差异
|
||||
for username, curr in curr_state.items():
|
||||
prev = prev_state.get(username)
|
||||
|
||||
if prev is None:
|
||||
# 新会话
|
||||
display = contact_names.get(username, username)
|
||||
ts = datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S')
|
||||
print(f"[{ts}] 新会话 [{display}]")
|
||||
print(f" {curr['summary']}")
|
||||
print()
|
||||
continue
|
||||
|
||||
# 检查时间戳变化 (有新消息)
|
||||
if curr['timestamp'] > prev['timestamp']:
|
||||
display = contact_names.get(username, username)
|
||||
ts = datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S')
|
||||
msg_type = format_msg_type(curr['msg_type'])
|
||||
sender = curr['sender_name'] or curr['sender'] or ''
|
||||
|
||||
# 群聊显示发送者
|
||||
if '@chatroom' in username and sender:
|
||||
sender_display = contact_names.get(curr['sender'], sender)
|
||||
print(f"[{ts}] [{display}] {sender_display}:")
|
||||
else:
|
||||
print(f"[{ts}] [{display}]")
|
||||
|
||||
# 消息内容
|
||||
summary = curr['summary']
|
||||
if isinstance(summary, bytes):
|
||||
try:
|
||||
summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
summary = '(压缩内容)'
|
||||
if summary:
|
||||
# 群消息格式: "wxid_xxx:\n内容" - 提取内容部分
|
||||
if ':\n' in summary:
|
||||
summary = summary.split(':\n', 1)[1]
|
||||
print(f" [{msg_type}] {summary}")
|
||||
else:
|
||||
print(f" [{msg_type}]")
|
||||
|
||||
# 未读数变化
|
||||
if curr['unread'] > 0:
|
||||
print(f" (未读: {curr['unread']})")
|
||||
print()
|
||||
|
||||
prev_state = curr_state
|
||||
|
||||
# 心跳
|
||||
if poll_count % 20 == 0:
|
||||
now = datetime.now().strftime('%H:%M:%S')
|
||||
print(f"--- {now} 运行中 (第{poll_count}次轮询) ---")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n监听结束, 共 {poll_count} 次轮询")
|
||||
|
||||
# 清理
|
||||
tmp = session_db + ".tmp_monitor"
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
2095
monitor_web.py
2095
monitor_web.py
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue