chore: 删除旧项目文件(MCP、Web UI、monitor 等)

feat/daemon-cli
jackwener 2026-04-16 14:05:23 +08:00
parent 0d0ae22a98
commit 0d5ac82349
16 changed files with 0 additions and 8131 deletions

234
USAGE.md
View File

@ -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)。

View File

@ -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) NoneV2 格式文件返回 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)

View File

@ -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()

View File

@ -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;
}

View File

@ -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)

View File

@ -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 名称wechatWeChatAppEx
再用 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)

View File

@ -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)

View File

@ -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;
}

View File

@ -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()

View File

@ -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()

View File

@ -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}")

View File

@ -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当前有效framepatch到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
View File

@ -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()

File diff suppressed because it is too large Load Diff

View File

@ -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()

File diff suppressed because it is too large Load Diff