Add image decryption and inline preview for WeChat V2 format

Support all three .dat encryption formats:
- Old XOR format: single-byte XOR, auto-detect key from magic bytes
- V1 format: AES-ECB with fixed key (md5("0")[:16]) + XOR tail
- V2 format (2025-08+): AES-128-ECB + raw middle + XOR tail

New files:
- decode_image.py: unified image decryption module (XOR/V1/V2)
- find_image_key.py: extract AES key from WeChat process memory
- find_image_key_monitor.py: continuous monitoring version for key capture

monitor_web.py changes:
- Inline image preview in Web UI with async decryption
- MonitorDBCache for mtime-based DB decryption caching
- username-to-DB mapping for image resolution chain
- /img/ endpoint for serving decoded images
- SSE image_update events for real-time preview updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ylytdeng
2026-03-02 00:30:01 +08:00
parent 05b8ba4d45
commit da7525db95
8 changed files with 1729 additions and 25 deletions

View File

@@ -6,13 +6,19 @@ http://localhost:5678
- 检测到变化后全量解密DB + 全量WAL patch
- SSE 服务器推送
"""
import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue
import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback
import hmac as hmac_mod
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from Crypto.Cipher import AES
import urllib.parse
import glob as glob_mod
import zstandard as zstd
from decode_image import extract_md5_from_packed_info, decrypt_dat_file, is_v2_format
_zstd_dctx = zstd.ZstdDecompressor()
PAGE_SZ = 4096
KEY_SZ = 32
@@ -28,6 +34,11 @@ DB_DIR = _cfg["db_dir"]
KEYS_FILE = _cfg["keys_file"]
CONTACT_CACHE = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
DECRYPTED_SESSION = os.path.join(_cfg["decrypted_dir"], "session", "session.db")
DECODED_IMAGE_DIR = _cfg.get("decoded_image_dir", os.path.join(os.path.dirname(os.path.abspath(__file__)), "decoded_images"))
MONITOR_CACHE_DIR = os.path.join(_cfg["decrypted_dir"], "_monitor_cache")
WECHAT_BASE_DIR = _cfg.get("wechat_base_dir", "")
IMAGE_AES_KEY = _cfg.get("image_aes_key") # V2 格式 AES key (从微信内存提取)
IMAGE_XOR_KEY = _cfg.get("image_xor_key", 0x88) # XOR key
POLL_MS = 30 # 高频轮询WAL/DB的mtime30ms一次
PORT = 5678
@@ -37,6 +48,98 @@ sse_lock = threading.Lock()
messages_log = []
messages_lock = threading.Lock()
MAX_LOG = 500
_img_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='img')
class MonitorDBCache:
"""轻量 DB 缓存mtime 检测变化时重新解密"""
def __init__(self, keys, tmp_dir):
self.keys = keys
self.tmp_dir = tmp_dir
os.makedirs(tmp_dir, exist_ok=True)
self._state = {} # rel_key → (db_mtime, wal_mtime)
def get(self, rel_key):
"""返回解密后的临时文件路径mtime 变化时自动重新解密"""
if rel_key not in self.keys:
return None
enc_key = bytes.fromhex(self.keys[rel_key]["enc_key"])
rel_path = rel_key.replace('\\', os.sep)
db_path = os.path.join(DB_DIR, rel_path)
wal_path = db_path + "-wal"
if not os.path.exists(db_path):
return None
try:
db_mtime = os.path.getmtime(db_path)
wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0
except OSError:
return None
out_name = rel_key.replace('\\', '_')
out_path = os.path.join(self.tmp_dir, out_name)
prev = self._state.get(rel_key)
if prev is None or db_mtime != prev[0]:
t0 = time.perf_counter()
full_decrypt(db_path, out_path, enc_key)
if os.path.exists(wal_path):
decrypt_wal_full(wal_path, out_path, enc_key)
ms = (time.perf_counter() - t0) * 1000
print(f" [cache] {rel_key} 全量解密 {ms:.0f}ms", flush=True)
self._state[rel_key] = (db_mtime, wal_mtime)
elif wal_mtime != prev[1]:
t0 = time.perf_counter()
decrypt_wal_full(wal_path, out_path, enc_key)
ms = (time.perf_counter() - t0) * 1000
print(f" [cache] {rel_key} WAL patch {ms:.0f}ms", flush=True)
self._state[rel_key] = (db_mtime, wal_mtime)
return out_path
def build_username_db_map():
"""从已解密的 Name2Id 表构建 username → [db_keys] 映射
同一个 username 可能存在于多个 message_N.db 中,
按 DB 文件修改时间倒序排列(最新的排前面)。
"""
# 先获取每个 DB 的 mtime 用于排序
db_mtimes = {}
for i in range(5):
rel_key = f"message\\message_{i}.db"
db_path = os.path.join(DB_DIR, "message", f"message_{i}.db")
try:
db_mtimes[rel_key] = os.path.getmtime(db_path)
except OSError:
db_mtimes[rel_key] = 0
mapping = {} # username → [db_keys], 最新的在前
decrypted_msg_dir = os.path.join(_cfg["decrypted_dir"], "message")
for i in range(5):
db_path = os.path.join(decrypted_msg_dir, f"message_{i}.db")
if not os.path.exists(db_path):
continue
rel_key = f"message\\message_{i}.db"
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
for row in conn.execute("SELECT user_name FROM Name2Id").fetchall():
if row[0] not in mapping:
mapping[row[0]] = []
mapping[row[0]].append(rel_key)
conn.close()
except Exception as e:
print(f" [WARN] Name2Id message_{i}.db: {e}", flush=True)
# 对每个 username 的 db_keys 按 mtime 倒序(最新的优先)
for username in mapping:
mapping[username].sort(key=lambda k: db_mtimes.get(k, 0), reverse=True)
return mapping
def decrypt_page(enc_key, page_data, pgno):
@@ -156,7 +259,12 @@ def msg_type_icon(t):
def broadcast_sse(msg_data):
payload = f"data: {json.dumps(msg_data, ensure_ascii=False)}\n\n"
event_type = msg_data.get('event', '')
data_line = f"data: {json.dumps(msg_data, ensure_ascii=False)}\n"
if event_type:
payload = f"event: {event_type}\n{data_line}\n"
else:
payload = f"{data_line}\n"
with sse_lock:
dead = []
for q in sse_clients:
@@ -171,15 +279,180 @@ def broadcast_sse(msg_data):
# ============ 监听器 ============
class SessionMonitor:
def __init__(self, enc_key, session_db, contact_names):
def __init__(self, enc_key, session_db, contact_names, db_cache=None, username_db_map=None):
self.enc_key = enc_key
self.session_db = session_db
self.wal_path = session_db + "-wal"
self.contact_names = contact_names
self.db_cache = db_cache
self.username_db_map = username_db_map or {}
self.prev_state = {}
self.decrypt_ms = 0
self.patched_pages = 0
def resolve_image(self, username, timestamp):
"""解密图片: username+timestamp → 解密后的图片文件名,失败返回 None"""
if not self.db_cache or not self.username_db_map:
return None
# 1. 找到 username 对应的所有 message_N.db按 mtime 倒序)
db_keys = self.username_db_map.get(username)
if not db_keys:
return None
# 2. 遍历候选 DB找到包含该 timestamp 消息的那个
table_name = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
local_id = None
for db_key in db_keys:
msg_db_path = self.db_cache.get(db_key)
if not msg_db_path:
continue
try:
conn = sqlite3.connect(f"file:{msg_db_path}?mode=ro", uri=True)
# 精确匹配 timestamp
row = conn.execute(f"""
SELECT local_id FROM [{table_name}]
WHERE local_type = 3 AND create_time = ?
""", (timestamp,)).fetchone()
if not row:
# 模糊匹配±3秒内最近的图片消息
row = conn.execute(f"""
SELECT local_id FROM [{table_name}]
WHERE local_type = 3 AND ABS(create_time - ?) <= 3
ORDER BY ABS(create_time - ?) LIMIT 1
""", (timestamp, timestamp)).fetchone()
conn.close()
if row:
local_id = row[0]
break
except Exception as e:
print(f" [img] 查询 {db_key}/{table_name} 失败: {e}", flush=True)
if not local_id:
print(f" [img] 未找到 local_id: {username} t={timestamp}", flush=True)
return None
# 4. 查 message_resource.db 获取 MD5
# local_id 不全局唯一,需要同时匹配 create_time
res_path = self.db_cache.get("message\\message_resource.db")
if not res_path:
return None
file_md5 = None
try:
conn = sqlite3.connect(f"file:{res_path}?mode=ro", uri=True)
row = conn.execute(
"SELECT packed_info FROM MessageResourceInfo "
"WHERE message_local_id = ? AND message_create_time = ? AND message_local_type = 3",
(local_id, timestamp)
).fetchone()
if not row:
# 降级: 只用 create_time + type
row = conn.execute(
"SELECT packed_info FROM MessageResourceInfo "
"WHERE message_create_time = ? AND message_local_type = 3",
(timestamp,)
).fetchone()
conn.close()
if row and row[0]:
file_md5 = extract_md5_from_packed_info(row[0])
except Exception as e:
print(f" [img] 查询 message_resource 失败: {e}", flush=True)
return None
if not file_md5:
print(f" [img] 未找到 MD5: local_id={local_id} t={timestamp}", flush=True)
return None
# 5. 查找 .dat 文件
attach_dir = os.path.join(WECHAT_BASE_DIR, "msg", "attach")
username_hash = hashlib.md5(username.encode()).hexdigest()
search_base = os.path.join(attach_dir, username_hash)
if not os.path.isdir(search_base):
print(f" [img] attach 目录不存在: {search_base}", flush=True)
return None
pattern = os.path.join(search_base, "*", "Img", f"{file_md5}*.dat")
dat_files = sorted(glob_mod.glob(pattern))
if not dat_files:
print(f" [img] 未找到 .dat: MD5={file_md5}", flush=True)
return None
# 优先原图,然后高清 _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
# 6. 解密图片
os.makedirs(DECODED_IMAGE_DIR, exist_ok=True)
out_base = os.path.join(DECODED_IMAGE_DIR, file_md5)
# 已解密则跳过
for ext in ('jpg', 'png', 'gif', 'webp', 'bmp', 'tif'):
candidate = f"{out_base}.{ext}"
if os.path.exists(candidate):
return os.path.basename(candidate)
# V2 新格式需要 AES key
if is_v2_format(selected) and not IMAGE_AES_KEY:
print(f" [img] V2 格式缺少 AES key: {os.path.basename(selected)}", flush=True)
print(f" [img] 请运行 find_image_key.py 提取密钥", flush=True)
return '__v2_unsupported__'
result_path, fmt = decrypt_dat_file(selected, f"{out_base}.tmp", IMAGE_AES_KEY, IMAGE_XOR_KEY)
if not result_path:
print(f" [img] 解密失败: {selected}", flush=True)
return None
final = f"{out_base}.{fmt}"
if os.path.exists(final):
os.unlink(final)
os.rename(result_path, final)
size_kb = os.path.getsize(final) / 1024
print(f" [img] 解密成功: {os.path.basename(final)} ({size_kb:.0f}KB)", flush=True)
return os.path.basename(final)
def _async_resolve_image(self, username, timestamp, msg_data):
"""后台线程: 解密图片并通过 SSE 推送更新"""
for attempt in range(3):
try:
img_name = self.resolve_image(username, timestamp)
if img_name == '__v2_unsupported__':
# V2 新加密格式,显示占位提示
msg_data['content'] = '[图片 - 新加密格式暂不支持预览]'
broadcast_sse({
'event': 'image_update',
'timestamp': timestamp,
'username': username,
'v2_unsupported': True,
})
return
elif img_name:
image_url = f'/img/{img_name}'
msg_data['image_url'] = image_url
broadcast_sse({
'event': 'image_update',
'timestamp': timestamp,
'username': username,
'image_url': image_url,
})
print(f" [img] 异步解密成功: {img_name}", flush=True)
return
elif attempt < 2:
time.sleep(1.5)
except Exception as e:
print(f" [img] 异步解密失败(attempt={attempt}): {e}", flush=True)
if attempt < 2:
time.sleep(1.5)
def query_state(self):
"""查询已解密副本的session状态"""
conn = sqlite3.connect(f"file:{DECRYPTED_SESSION}?mode=ro", uri=True)
@@ -237,10 +510,15 @@ class SessionMonitor:
sender = self.contact_names.get(curr['sender'], curr['sender_name'] or curr['sender'])
summary = curr['summary']
if isinstance(summary, bytes):
try:
summary = _zstd_dctx.decompress(summary).decode('utf-8', errors='replace')
except Exception:
summary = '(压缩内容)'
if summary and ':\n' in summary:
summary = summary.split(':\n', 1)[1]
new_msgs.append({
msg_data = {
'time': datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S'),
'timestamp': curr['timestamp'],
'chat': display,
@@ -253,7 +531,16 @@ class SessionMonitor:
'unread': curr['unread'],
'decrypt_ms': round(self.decrypt_ms, 1),
'pages': self.patched_pages,
})
}
new_msgs.append(msg_data)
# 图片消息: 后台异步解密(不阻塞轮询)
if curr['msg_type'] == 3:
_img_executor.submit(
self._async_resolve_image,
username, curr['timestamp'], msg_data
)
# 按时间排序
new_msgs.sort(key=lambda m: m['timestamp'])
@@ -281,8 +568,8 @@ class SessionMonitor:
self.prev_state = curr_state
def monitor_thread(enc_key, session_db, contact_names):
mon = SessionMonitor(enc_key, session_db, contact_names)
def monitor_thread(enc_key, session_db, contact_names, db_cache=None, username_db_map=None):
mon = SessionMonitor(enc_key, session_db, contact_names, db_cache, username_db_map)
wal_path = mon.wal_path
# 初始全量解密
@@ -372,6 +659,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
.msg-unread{font-size:10px;padding:1px 6px;border-radius:8px;background:rgba(244,67,54,.2);color:#ef9a9a;font-weight:600}
.msg-perf{font-size:9px;color:#333}
.msg-content{font-size:13px;line-height:1.4;color:#bbb;word-break:break-all;padding-left:63px}
.msg-img{max-width:300px;max-height:200px;border-radius:8px;cursor:pointer;margin-top:4px;transition:transform .2s}
.msg-img:hover{transform:scale(1.02)}
.empty{text-align:center;padding:80px 20px;color:#444}
.empty .icon{font-size:48px;margin-bottom:12px}
::-webkit-scrollbar{width:4px}
@@ -415,7 +704,13 @@ function addMsg(m, animate){
const ur=m.unread>0?`<span class="msg-unread">${m.unread}</span>`:'';
const cc=m.is_group?'msg-chat grp':'msg-chat';
d.innerHTML=`<div class="msg-header"><span class="msg-time">${m.time}</span><span class="${cc}">${esc(m.chat)}</span>${sn}<div class="msg-r"><span class="msg-type">${m.type_icon} ${m.type}</span>${ur}</div></div><div class="msg-content">${esc(m.content||'')}</div>`;
let contentHtml = esc(m.content||'');
if(m.image_url){
contentHtml = `<img class="msg-img" src="${m.image_url}" onclick="window.open('${m.image_url}','_blank')" onerror="this.style.display='none';this.nextElementSibling.style.display='inline'" /><span style="display:none">${esc(m.content||'')}</span>`;
}
const dk=m.timestamp+'|'+(m.username||m.chat);
d.innerHTML=`<div class="msg-header"><span class="msg-time">${m.time}</span><span class="${cc}">${esc(m.chat)}</span>${sn}<div class="msg-r"><span class="msg-type">${m.type_icon} ${m.type}</span>${ur}</div></div><div class="msg-content" data-key="${dk}">${contentHtml}</div>`;
M.insertBefore(d, M.firstChild);
@@ -438,6 +733,22 @@ function connectSSE(){
es.onmessage=ev=>{
addMsg(JSON.parse(ev.data), true); // 新消息有动画
};
es.addEventListener('image_update', ev=>{
const d=JSON.parse(ev.data);
const key=d.timestamp+'|'+(d.username||'');
const msgs=M.querySelectorAll('.msg');
for(const el of msgs){
const ct=el.querySelector('.msg-content');
if(ct && ct.dataset.key===key){
if(d.v2_unsupported){
ct.innerHTML='<span style="color:#999;font-style:italic">[图片 - 新加密格式暂不支持预览]</span>';
} else if(d.image_url){
ct.innerHTML=`<img class="msg-img" src="${d.image_url}" onclick="window.open('${d.image_url}','_blank')" onerror="this.style.display='none'" />`;
}
break;
}
}
});
es.onerror=()=>{
S.textContent='重连...';
S.className='status err';
@@ -481,6 +792,32 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
elif self.path.startswith('/img/'):
filename = urllib.parse.unquote(self.path[5:])
# 安全: 防目录穿越
if '/' in filename or '\\' in filename or '..' in filename:
self.send_error(403)
return
filepath = os.path.join(DECODED_IMAGE_DIR, filename)
if not os.path.isfile(filepath):
self.send_error(404)
return
ext = os.path.splitext(filename)[1].lower()
ct = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif',
'.webp': 'image/webp', '.bmp': 'image/bmp',
'.tif': 'image/tiff',
}.get(ext, 'application/octet-stream')
with open(filepath, 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'public, max-age=86400')
self.end_headers()
self.wfile.write(data)
elif self.path == '/stream':
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
@@ -529,7 +866,20 @@ def main():
contact_names = load_contact_names()
print(f"已加载 {len(contact_names)} 个联系人", flush=True)
t = threading.Thread(target=monitor_thread, args=(enc_key, session_db, contact_names), daemon=True)
print("构建 username→DB 映射...", flush=True)
username_db_map = build_username_db_map()
print(f"已映射 {len(username_db_map)} 个用户名", flush=True)
db_cache = MonitorDBCache(keys, MONITOR_CACHE_DIR)
# 后台预热 message_resource.db图片解密必需
def _warmup():
t0 = time.perf_counter()
db_cache.get("message\\message_resource.db")
print(f"[warmup] message_resource.db 预热完成 {(time.perf_counter()-t0)*1000:.0f}ms", flush=True)
threading.Thread(target=_warmup, daemon=True).start()
t = threading.Thread(target=monitor_thread, args=(enc_key, session_db, contact_names, db_cache, username_db_map), daemon=True)
t.start()
server = ThreadedServer(('0.0.0.0', PORT), Handler)