WeChat 4.0 database decryptor and real-time message monitor

Extract encryption keys from Weixin.exe process memory, decrypt all
SQLCipher 4 databases, and monitor new messages via Web UI with ~100ms latency.
This commit is contained in:
joshua-deng
2026-02-28 12:03:38 +08:00
commit 4c91eb34ef
9 changed files with 1604 additions and 0 deletions

551
monitor_web.py Normal file
View File

@@ -0,0 +1,551 @@
"""
微信实时消息监听器 - Web UI (SSE推送 + mtime检测)
http://localhost:5678
- 30ms轮询WAL/DB文件的mtime变化WAL是预分配固定大小不能用size检测
- 检测到变化后全量解密DB + 全量WAL patch
- SSE 服务器推送
"""
import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue
import hmac as hmac_mod
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from Crypto.Cipher import AES
import urllib.parse
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"]
CONTACT_CACHE = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
DECRYPTED_SESSION = os.path.join(_cfg["decrypted_dir"], "session", "session.db")
POLL_MS = 30 # 高频轮询WAL/DB的mtime30ms一次
PORT = 5678
sse_clients = []
sse_lock = threading.Lock()
messages_log = []
messages_lock = threading.Lock()
MAX_LOG = 500
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(db_path, out_path, enc_key):
"""首次全量解密"""
t0 = time.perf_counter()
file_size = os.path.getsize(db_path)
total_pages = file_size // PAGE_SZ
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
fout.write(decrypt_page(enc_key, page, pgno))
ms = (time.perf_counter() - t0) * 1000
return total_pages, ms
def decrypt_wal_full(wal_path, out_path, enc_key):
"""解密WAL当前有效framepatch到已解密的DB副本
WAL是预分配固定大小(4MB)包含当前有效frame和上一轮遗留的旧frame。
通过WAL header中的salt值区分只有frame header的salt匹配WAL header的才是有效frame。
返回: (patched_pages, elapsed_ms)
"""
t0 = time.perf_counter()
if not os.path.exists(wal_path):
return 0, 0
wal_size = os.path.getsize(wal_path)
if wal_size <= WAL_HEADER_SZ:
return 0, 0
frame_size = WAL_FRAME_HEADER_SZ + PAGE_SZ # 24 + 4096 = 4120
patched = 0
with open(wal_path, 'rb') as wf, open(out_path, 'r+b') as df:
# 读WAL header获取当前salt值
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_size:
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
# 校验: pgno有效 且 salt匹配当前WAL周期
if pgno == 0 or pgno > 1000000:
continue
if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2:
continue # 旧周期遗留的frame跳过
dec = decrypt_page(enc_key, ep, pgno)
df.seek((pgno - 1) * PAGE_SZ)
df.write(dec)
patched += 1
ms = (time.perf_counter() - t0) * 1000
return patched, ms
def load_contact_names():
names = {}
try:
conn = sqlite3.connect(CONTACT_CACHE)
for r in conn.execute("SELECT username, nick_name, remark FROM contact").fetchall():
names[r[0]] = r[2] if r[2] else r[1] if r[1] else r[0]
conn.close()
except:
pass
return names
def format_msg_type(t):
return {
1: '文本', 3: '图片', 34: '语音', 42: '名片',
43: '视频', 47: '表情', 48: '位置', 49: '链接/文件',
50: '通话', 10000: '系统', 10002: '撤回',
}.get(t, f'type={t}')
def msg_type_icon(t):
return {
1: '💬', 3: '🖼️', 34: '🎤', 42: '👤',
43: '🎬', 47: '😀', 48: '📍', 49: '🔗',
50: '📞', 10000: '⚙️', 10002: '↩️',
}.get(t, '📨')
def broadcast_sse(msg_data):
payload = f"data: {json.dumps(msg_data, ensure_ascii=False)}\n\n"
with sse_lock:
dead = []
for q in sse_clients:
try:
q.put_nowait(payload)
except:
dead.append(q)
for q in dead:
sse_clients.remove(q)
# ============ 监听器 ============
class SessionMonitor:
def __init__(self, enc_key, session_db, contact_names):
self.enc_key = enc_key
self.session_db = session_db
self.wal_path = session_db + "-wal"
self.contact_names = contact_names
self.prev_state = {}
self.decrypt_ms = 0
self.patched_pages = 0
def query_state(self):
"""查询已解密副本的session状态"""
conn = sqlite3.connect(f"file:{DECRYPTED_SESSION}?mode=ro", uri=True)
state = {}
for r in 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():
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 '',
}
conn.close()
return state
def do_full_refresh(self):
"""全量解密DB + 全量WAL patch"""
# 先解密主DB
pages, ms = full_decrypt(self.session_db, DECRYPTED_SESSION, self.enc_key)
total_ms = ms
wal_patched = 0
# 再patch所有WAL frames
if os.path.exists(self.wal_path):
wal_patched, ms2 = decrypt_wal_full(self.wal_path, DECRYPTED_SESSION, self.enc_key)
total_ms += ms2
self.decrypt_ms = total_ms
self.patched_pages = pages + wal_patched
return self.patched_pages
def check_updates(self):
global messages_log
try:
t0 = time.perf_counter()
self.do_full_refresh()
t1 = time.perf_counter()
curr_state = self.query_state()
t2 = time.perf_counter()
print(f" [perf] decrypt={self.patched_pages}页/{(t1-t0)*1000:.1f}ms, query={(t2-t1)*1000:.1f}ms", flush=True)
except Exception as e:
print(f" [ERROR] check_updates: {e}", flush=True)
return
# 收集所有新消息,按时间排序后再推送
new_msgs = []
for username, curr in curr_state.items():
prev = self.prev_state.get(username)
if prev and curr['timestamp'] > prev['timestamp']:
display = self.contact_names.get(username, username)
is_group = '@chatroom' in username
sender = ''
if is_group:
sender = self.contact_names.get(curr['sender'], curr['sender_name'] or curr['sender'])
summary = curr['summary']
if summary and ':\n' in summary:
summary = summary.split(':\n', 1)[1]
new_msgs.append({
'time': datetime.fromtimestamp(curr['timestamp']).strftime('%H:%M:%S'),
'timestamp': curr['timestamp'],
'chat': display,
'username': username,
'is_group': is_group,
'sender': sender,
'type': format_msg_type(curr['msg_type']),
'type_icon': msg_type_icon(curr['msg_type']),
'content': summary,
'unread': curr['unread'],
'decrypt_ms': round(self.decrypt_ms, 1),
'pages': self.patched_pages,
})
# 按时间排序
new_msgs.sort(key=lambda m: m['timestamp'])
for msg in new_msgs:
with messages_lock:
messages_log.append(msg)
if len(messages_log) > MAX_LOG:
messages_log = messages_log[-MAX_LOG:]
broadcast_sse(msg)
try:
now = time.time()
msg_age = now - msg['timestamp']
tag = f"{self.patched_pages}pg/{self.decrypt_ms:.0f}ms"
sender = msg['sender']
now_str = datetime.fromtimestamp(now).strftime('%H:%M:%S')
if sender:
print(f"[{msg['time']} 延迟={msg_age:.1f}s] [{msg['chat']}] {sender}: {msg['content']} ({tag})", flush=True)
else:
print(f"[{msg['time']} 延迟={msg_age:.1f}s] [{msg['chat']}] {msg['content']} ({tag})", flush=True)
except Exception:
pass # Windows CMD编码问题不影响SSE推送
self.prev_state = curr_state
def monitor_thread(enc_key, session_db, contact_names):
mon = SessionMonitor(enc_key, session_db, contact_names)
wal_path = mon.wal_path
# 初始全量解密
pages, ms = full_decrypt(session_db, DECRYPTED_SESSION, enc_key)
wal_patched = 0
wal_ms = 0
if os.path.exists(wal_path):
wal_patched, wal_ms = decrypt_wal_full(wal_path, DECRYPTED_SESSION, enc_key)
print(f"[init] DB {pages}页/{ms:.0f}ms + WAL {wal_patched}页/{wal_ms:.0f}ms", flush=True)
else:
print(f"[init] DB {pages}页/{ms:.0f}ms", flush=True)
mon.prev_state = mon.query_state()
print(f"[monitor] 跟踪 {len(mon.prev_state)} 个会话", flush=True)
print(f"[monitor] mtime轮询模式 (每{POLL_MS}ms)", flush=True)
# mtime-based 轮询: WAL是预分配固定大小不能用size检测
poll_interval = POLL_MS / 1000
prev_wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0
prev_db_mtime = os.path.getmtime(session_db)
while True:
time.sleep(poll_interval)
try:
# 用mtime检测WAL和DB变化
try:
wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0
db_mtime = os.path.getmtime(session_db)
except OSError:
continue
if wal_mtime == prev_wal_mtime and db_mtime == prev_db_mtime:
continue # 无变化
t_detect = time.perf_counter()
wal_changed = wal_mtime != prev_wal_mtime
db_changed = db_mtime != prev_db_mtime
mon.check_updates()
t_done = time.perf_counter()
try:
detect_str = datetime.now().strftime('%H:%M:%S.%f')[:-3]
print(f" [{detect_str}] WAL={'' if wal_changed else '-'} DB={'' if db_changed else '-'} 总耗时={(t_done-t_detect)*1000:.1f}ms", flush=True)
except Exception:
pass
prev_wal_mtime = wal_mtime
prev_db_mtime = db_mtime
except Exception as e:
print(f"[poll] 错误: {e}", flush=True)
time.sleep(1)
# ============ Web ============
HTML_PAGE = '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微信消息监听</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0f;color:#e0e0e0;height:100vh;display:flex;flex-direction:column}
.header{background:linear-gradient(135deg,#1a1a2e,#16213e);padding:14px 24px;border-bottom:1px solid rgba(255,255,255,.08);display:flex;align-items:center;gap:12px;flex-shrink:0}
.header h1{font-size:18px;font-weight:600;background:linear-gradient(90deg,#4fc3f7,#81c784);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.status{font-size:12px;padding:4px 10px;border-radius:12px;transition:all .3s}
.status.ok{background:rgba(76,175,80,.15);color:#81c784;border:1px solid rgba(76,175,80,.3)}
.status.ok::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#4caf50;margin-right:6px;animation:pulse 2s infinite}
.status.err{background:rgba(244,67,54,.15);color:#ef9a9a;border:1px solid rgba(244,67,54,.3)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.stats{margin-left:auto;font-size:12px;color:#666;display:flex;gap:16px}
.messages{flex:1;overflow-y:auto;padding:12px}
.msg{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.06);border-radius:10px;padding:10px 14px;margin-bottom:5px;transition:transform .3s ease}
.msg:hover{background:rgba(255,255,255,.05)}
.msg.hl{border-left:3px solid #4fc3f7;background:rgba(79,195,247,.05);animation:slideIn .3s cubic-bezier(.22,1,.36,1)}
@keyframes slideIn{from{opacity:0;transform:translateY(-20px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
.msg-header{display:flex;align-items:center;gap:8px;margin-bottom:3px}
.msg-time{font-size:11px;color:#555;font-family:"SF Mono",Monaco,monospace;min-width:55px}
.msg-chat{font-weight:600;color:#4fc3f7;font-size:13px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.msg-chat.grp{color:#ce93d8}
.msg-sender{font-size:12px;color:#999}
.msg-r{margin-left:auto;display:flex;gap:6px;align-items:center}
.msg-type{font-size:10px;padding:2px 5px;border-radius:3px;background:rgba(255,255,255,.06);color:#777}
.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}
.empty{text-align:center;padding:80px 20px;color:#444}
.empty .icon{font-size:48px;margin-bottom:12px}
::-webkit-scrollbar{width:4px}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:2px}
</style>
</head>
<body>
<div class="header">
<h1>WeChat Monitor</h1>
<div class="status ok" id="st">SSE 实时</div>
<div class="stats"><span id="cnt">0 消息</span><span id="perf"></span></div>
</div>
<div class="messages" id="msgs">
<div class="empty" id="empty"><div class="icon">📡</div><p>等待新消息...</p><p style="margin-top:6px;font-size:11px;color:#333">WAL增量解密 · SSE推送</p></div>
</div>
<script>
let n=0;
const M=document.getElementById('msgs'), S=document.getElementById('st');
const seen = new Set(); // 去重: timestamp+username
let sseReady = false;
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function addMsg(m, animate){
// 去重
const key = m.timestamp + '|' + (m.username||m.chat);
if(seen.has(key)) return;
seen.add(key);
const x=document.getElementById('empty');
if(x) x.remove();
n++;
document.getElementById('cnt').textContent=n+' 消息';
if(m.decrypt_ms!=null) document.getElementById('perf').textContent=m.pages+'页/'+m.decrypt_ms+'ms';
const d=document.createElement('div');
d.className = animate ? 'msg hl' : 'msg';
const sn=m.sender?`<span class="msg-sender">${esc(m.sender)}</span>`:'';
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>`;
M.insertBefore(d, M.firstChild);
if(animate){
setTimeout(()=>d.classList.remove('hl'), 3000);
document.title='('+n+') 微信监听';
}
// 限制最多200条
while(M.children.length>200) M.removeChild(M.lastChild);
}
function connectSSE(){
const es=new EventSource('/stream');
es.onopen=()=>{
S.textContent='SSE 实时';
S.className='status ok';
sseReady=true;
};
es.onmessage=ev=>{
addMsg(JSON.parse(ev.data), true); // 新消息有动画
};
es.onerror=()=>{
S.textContent='重连...';
S.className='status err';
sseReady=false;
es.close();
setTimeout(connectSSE, 2000); // 重连不清页面
};
}
// 启动: 加载历史(无动画) → 连接SSE(有动画)
fetch('/api/history').then(r=>r.json()).then(ms=>{
ms.sort((a,b)=>a.timestamp-b.timestamp);
ms.forEach(m=>addMsg(m, false)); // 历史消息无动画
connectSSE();
});
</script>
</body>
</html>'''
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def handle(self):
try:
super().handle()
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
pass # 浏览器关闭连接,正常
def do_GET(self):
if self.path in ('/', '/index.html'):
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write(HTML_PAGE.encode('utf-8'))
elif self.path == '/api/history':
with messages_lock:
data = sorted(messages_log, key=lambda m: m.get('timestamp', 0))
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
elif self.path == '/stream':
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Cache-Control', 'no-cache')
self.send_header('Connection', 'keep-alive')
self.end_headers()
q = queue.Queue()
with sse_lock:
sse_clients.append(q)
try:
while True:
try:
payload = q.get(timeout=15)
self.wfile.write(payload.encode('utf-8'))
self.wfile.flush()
except queue.Empty:
self.wfile.write(b': hb\n\n')
self.wfile.flush()
except:
pass
finally:
with sse_lock:
if q in sse_clients:
sse_clients.remove(q)
else:
self.send_error(404)
class ThreadedServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
def main():
print("=" * 60, flush=True)
print(" 微信实时监听 (WAL增量 + SSE推送)", flush=True)
print("=" * 60, flush=True)
with open(KEYS_FILE) 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")
print("加载联系人...", flush=True)
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)
t.start()
server = ThreadedServer(('0.0.0.0', PORT), Handler)
print(f"\n=> http://localhost:{PORT}", flush=True)
print("Ctrl+C 停止\n", flush=True)
try:
os.system(f'cmd.exe /c start http://localhost:{PORT}')
except:
pass
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n已停止")
if __name__ == '__main__':
main()