Files
zWorkFlow/find_wxwork_keys.py
ylytdeng e5e2269947 fix: PR #107 后续清理 (security/正确性/一致性)
针对 4 个 review agent 在 PR #107 (5649 行巨型 PR) 找到的关键问题做最小
侵入修复。已合并代码本身能跑,这次是收紧 security + 消重 + 文档一致性。

## 安全修复

### wxwork_keys.json 落盘权限 (find_wxwork_keys.py)
含明文 16-byte raw key 的 keys 文件,之前 default umask 落盘。改成:
  1. 写 tmp 文件
  2. chmod 0o600 (Unix 严格 owner-only; Windows 上 chmod 控制只读位,
     至少避免世界可读最差情况)
  3. atomic rename
旧产物自然过期,新生成的都受保护。

### SNS XXE 防护 (export_sns.py)
朋友圈 XML 来源是不可信输入(他人发的 content),原 `ET.fromstring()`
完全没过滤,可被恶意 entity expansion / 外部实体引用攻击。加跟
`mcp_server._XML_UNSAFE_RE` 同模式的过滤(拒 `<!DOCTYPE>` / `<!ENTITY>`)
+ 200KB 大小上限。`_parse_timeline_xml` 检查后才进 ET.fromstring。

## 正确性 / 消重

### AES 对齐公式统一 (decode_image.py + decrypt_sns.py + export_sns.py)
原本三处各写一份:
  - decode_image.py:   aes_size -= ~(~aes_size % 16)   ← bitwise trick
  - decrypt_sns.py:    同上
  - export_sns.py:     aes_size + (16 - aes_size%16) if … else aes_size+16
两个公式数学等价(对 0/1/15/16/17/100/1000/12345 全部验证一致),但
bitwise trick 难读且漂移风险高。抽 `aligned_aes_block_size()` 到
decode_image.py 作 canonical 实现, 另两处 import 复用。

### 32-bit pointer 假设明确化 (find_wxwork_keys.py)
reviewer 担心 `_read_u32` 在 64-bit 进程上错位,实测 WXWork.exe 5.0.x
是 **32-bit 进程** (`Program Files (x86)\WXWork\` + PE Machine = x86),
所以 4 字节读指针是对的。加注释明确这个假设,腾讯如果升级到 64-bit
要重做整套逆向, 当前实测全部 17 db 解密通过印证。

## 一致性

### main.py show_status() 走 _config_file_path() (main.py)
原硬编码 `config_file = "config.json"` 绕开 PR #107 新引入的
`_config_file_path()`,打包成 exe 后 cwd 不一定是 exe 目录,会读到错
位置。改成 `from config import _config_file_path`。

### EXE_USAGE.md 输出目录写错 (EXE_USAGE.md)
EXE_USAGE 说导出到 `export/`,代码实际 `output_base_dir = wechat_files/
<wxid>/`,联系人下还是 `messages.csv/html/json` 而不是
`message_0.db.csv`。修正成真实结构。

## 文档

README 加两段:
  - 安全提示: keys 文件 chmod 0600 + 不要 commit 到 git
  - 朋友圈 XML XXE 防护说明

## 测试

185/185 通过 (含已有 wxsqlite3 roundtrip + image v2 + msg types filter
+ pagination hint + chat export helpers 等)。
aligned_aes_block_size 单独验证跟旧公式等价(0/1/15/16/17/100/1000/12345)。

## 未跟进 (后续 follow-up issue)

- 3 处 V1/V2/XOR 解密代码完全重复(decode_image / decrypt_sns /
  export_messages 各自实现)——抽出来工作量大,本次先抽 helper 不动
  完整解密路径,后续单独 PR
- export_messages HTML base64 内联图片可能爆几 GB,应改成可选 flag
- SNS / wxwork export / batch_decrypt_images / voice_to_mp3 测试缺位
  (0 个 test)
2026-05-17 17:00:20 +08:00

669 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
从企业微信(WXWork)进程内存中提取所有数据库的缓存raw key
企业微信的本地数据库与个人微信不同。实测 Windows 版使用 wxSQLite3
AES-128-CBC 页面加密16 字节 raw key每页按 page index 派生 AES key
和 IV页面没有 SQLCipher HMAC/reserve 区。
"""
import ctypes
import ctypes.wintypes as wt
import bisect
import functools
import hashlib
import hmac as hmac_mod
import json
import os
import re
import struct
import subprocess
import sys
import time
from key_scan_common import collect_db_files
from wxwork_crypto import (
is_plain_sqlite_page,
is_wxsqlite3_aes128_page1,
verify_wxsqlite3_aes128_key,
)
print = functools.partial(print, flush=True)
# ── Windows 内存读取原语 ──────────────────────────────────────────────
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 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
# ── 常量 ─────────────────────────────────────────────────────────────
WXWORK_PROCESS = "WXWork.exe"
PAGE_SZ = 4096
SALT_SZ = 16
# 旧版本/其他平台可能回落到 SQLCipher 参数,保留作兼容验证。
# (key_sz, hmac_hash_name, hmac_sz, pbkdf2_iter, reserve_sz)
VERIFY_CONFIGS = [
# WCDB optimized cipher with AES-128, HMAC-SHA512 (最可能)
(16, "sha512", 64, 2, 80),
# WCDB with AES-128, HMAC-SHA256
(16, "sha256", 32, 2, 48),
# SQLCipher 3 defaults with AES-128
(16, "sha512", 64, 4000, 80),
(16, "sha256", 32, 4000, 48),
# AES-256 回退 (与个人微信相同参数)
(32, "sha512", 64, 2, 80),
]
def verify_enc_key_wxwork(enc_key, db_page1):
"""尝试多种参数组合验证密钥,返回 (成功?, 使用的配置描述)"""
if len(enc_key) == 16 and verify_wxsqlite3_aes128_key(enc_key, db_page1):
return True, "wxSQLite3 AES-128-CBC, per-page MD5 key/IV, no HMAC"
key_sz = len(enc_key)
for cfg_key_sz, hmac_hash, hmac_sz, iterations, reserve_sz in VERIFY_CONFIGS:
if key_sz != cfg_key_sz:
continue
salt = db_page1[:SALT_SZ]
mac_salt = bytes(b ^ 0x3A for b in salt)
mac_key = hashlib.pbkdf2_hmac(hmac_hash, enc_key, mac_salt, iterations, dklen=cfg_key_sz)
hmac_data = db_page1[SALT_SZ: PAGE_SZ - reserve_sz + 16]
stored_hmac = db_page1[PAGE_SZ - hmac_sz: PAGE_SZ]
hash_fn = getattr(hashlib, hmac_hash)
hm = hmac_mod.new(mac_key, hmac_data, hash_fn)
hm.update(struct.pack("<I", 1))
if hm.digest() == stored_hmac:
desc = f"AES-{cfg_key_sz * 8}, HMAC-{hmac_hash.upper()}, iter={iterations}"
return True, desc
return False, ""
# ── WXWork 进程发现 ──────────────────────────────────────────────────
def get_wxwork_pids():
"""返回所有 WXWork.exe 进程的 (pid, mem_kb) 列表,按内存降序"""
r = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {WXWORK_PROCESS}", "/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(f"{WXWORK_PROCESS} 未运行")
pids.sort(key=lambda x: x[1], reverse=True)
for pid, mem in pids:
print(f"[+] {WXWORK_PROCESS} PID={pid} ({mem // 1024}MB)")
return pids
# ── WXWork 数据目录自动检测 ──────────────────────────────────────────
def _wxwork_data_dir_mtime(data_dir):
"""返回企业微信 Data 目录最近活跃时间,用于多账号自动选择。"""
latest = 0
for root, dirs, files in os.walk(data_dir):
dirs[:] = [d for d in dirs if d not in ("-journal",)]
for name in files:
if not name.endswith((".db", ".db-wal", ".db-shm")):
continue
path = os.path.join(root, name)
try:
latest = max(latest, os.path.getmtime(path))
except OSError:
pass
try:
latest = max(latest, os.path.getmtime(data_dir))
except OSError:
pass
return latest
def _is_noninteractive_mode():
return (
os.environ.get("WECHAT_DECRYPT_NONINTERACTIVE") == "1"
or os.environ.get("WXWORK_AUTO_SELECT_DB") == "1"
or os.environ.get("WECHAT_DECRYPT_GUI") == "1"
or not sys.stdin.isatty()
)
def auto_detect_wxwork_db_dir():
"""扫描 %USERPROFILE%\\Documents\\WXWork\\*\\Data 寻找包含加密DB的目录"""
docs = os.path.join(os.environ.get("USERPROFILE", ""), "Documents", "WXWork")
if not os.path.isdir(docs):
return None
candidates = []
for name in os.listdir(docs):
data_dir = os.path.join(docs, name, "Data")
if not os.path.isdir(data_dir):
continue
has_encrypted = False
for fname in os.listdir(data_dir):
if not fname.endswith(".db"):
continue
fpath = os.path.join(data_dir, fname)
if os.path.getsize(fpath) < PAGE_SZ:
continue
with open(fpath, "rb") as f:
header = f.read(16)
if header != b"SQLite format 3\x00":
has_encrypted = True
break
if has_encrypted:
candidates.append(data_dir)
if not candidates:
return None
candidates.sort(key=_wxwork_data_dir_mtime, reverse=True)
if len(candidates) == 1:
return candidates[0]
if _is_noninteractive_mode():
selected = candidates[0]
print("[!] 检测到多个企业微信数据目录,非交互模式下自动选择最近活跃目录:")
for i, c in enumerate(candidates, 1):
marker = " *" if c == selected else " "
print(f" {marker} {i}. {c}")
return candidates[0]
print("[!] 检测到多个企业微信数据目录:")
for i, c in enumerate(candidates, 1):
print(f" {i}. {c}")
print(" 0. 跳过,稍后手动配置")
try:
while True:
choice = input(f"请选择 [0-{len(candidates)}]: ").strip()
if choice == "0":
return None
if choice.isdigit() and 1 <= int(choice) <= len(candidates):
return candidates[int(choice) - 1]
print(" 无效输入,请重新选择")
except (EOFError, KeyboardInterrupt):
print()
return None
def filter_encrypted_dbs(db_files, salt_to_dbs):
"""过滤掉未加密的数据库。"""
filtered_files = [
entry for entry in db_files if not is_plain_sqlite_page(entry[4])
]
filtered_salts = {
s: dbs for s, dbs in salt_to_dbs.items()
if any(entry[3] == s and not is_plain_sqlite_page(entry[4]) for entry in db_files)
}
removed = len(db_files) - len(filtered_files)
if removed:
print(f"[*] 跳过 {removed} 个未加密数据库")
wxsqlite3_count = sum(1 for entry in filtered_files if is_wxsqlite3_aes128_page1(entry[4]))
if wxsqlite3_count:
print(f"[*] 检测到 {wxsqlite3_count} 个 wxSQLite3 AES-128 格式数据库")
return filtered_files, filtered_salts
# ── 企业微信内存扫描 ─────────────────────────────────────────────────
def scan_memory_for_wxwork_keys(data, hex_re, db_files, salt_to_dbs, key_map,
remaining_salts, base_addr, pid, print_fn):
"""扫描内存,匹配 hex 模式并用企业微信参数验证密钥。
企业微信 key=16字节(32 hex), salt=16字节(32 hex)
可能的缓存格式:
- x'<32hex_key><32hex_salt>' = 64 hex total
- x'<32hex_key>' = 32 hex (key only)
- x'<64hex_key><32hex_salt>' = 96 hex (AES-256 回退)
"""
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)
# 尝试不同的解释方式
candidates = []
if hex_len == 32:
# 纯 16字节 key
candidates.append((hex_str, None))
elif hex_len == 64:
# 优先: 32hex key + 32hex salt (WeCom AES-128)
candidates.append((hex_str[:32], hex_str[32:]))
# 回退: 64hex = 32字节 key (personal WeChat AES-256)
candidates.append((hex_str, None))
elif hex_len == 96:
# 优先: 64hex key + 32hex salt (personal WeChat AES-256)
candidates.append((hex_str[:64], hex_str[64:]))
# 也尝试: 32hex key + ... + 32hex salt
candidates.append((hex_str[:32], hex_str[-32:]))
elif hex_len > 96 and hex_len % 2 == 0:
candidates.append((hex_str[:64], hex_str[-32:]))
candidates.append((hex_str[:32], hex_str[-32:]))
for enc_key_hex, salt_hex in candidates:
if len(enc_key_hex) not in (32, 64):
continue
enc_key = bytes.fromhex(enc_key_hex)
if salt_hex and salt_hex in remaining_salts:
# salt 匹配已知数据库
for rel, path, sz, s, page1 in db_files:
if s == salt_hex:
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
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" params: {desc}")
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
print_fn(f" 数据库: {', '.join(dbs)}")
break
elif not salt_hex and remaining_salts:
# 没有 salt暴力尝试所有未匹配的数据库
for rel, path, sz, salt_hex_db, page1 in db_files:
if salt_hex_db in remaining_salts:
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
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" params: {desc}")
print_fn(f" PID={pid} 地址: 0x{addr:016X}")
print_fn(f" 数据库: {', '.join(dbs)}")
break
if not remaining_salts:
break
return matches
def _find_region(memory_regions, starts, addr, length=4):
idx = bisect.bisect_right(starts, addr) - 1
if idx < 0:
return None
base, end, data = memory_regions[idx]
if base <= addr and addr + length <= end:
return base, end, data
return None
def _read_u32(memory_regions, starts, addr):
region = _find_region(memory_regions, starts, addr, 4)
if not region:
return None
base, _end, data = region
return struct.unpack_from("<I", data, addr - base)[0]
def _valid_ptr(memory_regions, starts, addr, length=4):
return _find_region(memory_regions, starts, addr, length) is not None
def _wxwork_page_size_chain(memory_regions, starts, cipher_addr):
"""Validate the AES cipher object by following the page-size pointer chain.
In WXWork 5.x's inlined wxSQLite3 AES-128 code, the decrypt path uses:
raw_key = cipher + 0x08
aes_ctx = *(cipher + 0x2c)
page_size = *(*(*(cipher + 0x30) + 0x04) + 0x24)
NOTE: WXWork.exe (5.0.x) 是 **32-bit** 进程,所有指针 = 4 字节 (`_read_u32`)。
腾讯如果升级到 64-bit (`Program Files\WXWork\`),结构偏移和指针大小都要重做
逆向,这套代码会直接失效——届时需要扫描入口加 IsWow64Process 检测并给出
友好报错。当前实测 5.0.8.6009 全部 17 个 db 解密成功,该 32-bit 假设有效。
"""
page_size_holder = _read_u32(memory_regions, starts, cipher_addr + 0x30)
if page_size_holder is None or not _valid_ptr(memory_regions, starts, page_size_holder, 8):
return None
page_size_obj = _read_u32(memory_regions, starts, page_size_holder + 4)
if page_size_obj is None or not _valid_ptr(memory_regions, starts, page_size_obj + 0x24, 4):
return None
return _read_u32(memory_regions, starts, page_size_obj + 0x24)
def _record_candidate_key(enc_key, db_files, salt_to_dbs, key_map,
remaining_salts, pid, addr, desc, print_fn):
matched = []
params_desc = desc
for rel, path, sz, salt_hex, page1 in db_files:
if salt_hex not in remaining_salts:
continue
ok, verified_desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
key_map[salt_hex] = enc_key.hex()
remaining_salts.discard(salt_hex)
params_desc = verified_desc or params_desc
matched.extend(salt_to_dbs[salt_hex])
if matched:
print_fn(f"\n [FOUND-STRUCT] enc_key={enc_key.hex()}")
print_fn(f" params: {params_desc}")
print_fn(f" PID={pid} cipher对象地址: 0x{addr:08X}")
print_fn(f" 数据库: {', '.join(sorted(set(matched)))}")
return bool(matched)
def scan_memory_for_wxwork_cipher_structs(h, regions, db_files, salt_to_dbs,
key_map, remaining_salts, pid,
print_fn, max_seconds=120):
"""Scan WXWork heap objects for the in-memory wxSQLite3 AES-128 cipher.
This is intentionally targeted: instead of brute-forcing every 16-byte
window as a key, it looks for the cipher object layout used by WXWork 5.x.
"""
t0 = time.time()
memory_regions = []
total_bytes = 0
for base, size in regions:
data = read_mem(h, base, size)
if data:
memory_regions.append((int(base), int(base) + len(data), data))
total_bytes += len(data)
memory_regions.sort(key=lambda item: item[0])
starts = [item[0] for item in memory_regions]
print_fn(f"[*] 结构体扫描内存: {total_bytes / 1024 / 1024:.0f}MB, {len(memory_regions)} 区域")
checked = 0
ptr_hits = 0
chain_hits = 0
key_tests = 0
page_sizes = {512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}
for base, end, data in memory_regions:
max_off = len(data) - 0x40
off = 0
while off >= 0 and off < max_off:
if time.time() - t0 > max_seconds:
print_fn(
f"[WARN] 结构体扫描超时: checked={checked}, "
f"ptr_hits={ptr_hits}, chain_hits={chain_hits}, key_tests={key_tests}"
)
return key_tests
# The AES-128 decrypt branch checks two non-zero flags at +0 and +4.
flag0, flag4 = struct.unpack_from("<II", data, off)
if flag0 in (1, 2) and flag4 in (1, 2, 4096, 8192, 16384):
cipher_addr = base + off
aes_ctx = struct.unpack_from("<I", data, off + 0x2C)[0]
if _valid_ptr(memory_regions, starts, aes_ctx, 0x40):
ptr_hits += 1
page_size = _wxwork_page_size_chain(memory_regions, starts, cipher_addr)
if page_size in page_sizes:
chain_hits += 1
enc_key = data[off + 8 : off + 24]
if enc_key != b"\x00" * 16 and len(set(enc_key)) >= 6:
key_tests += 1
if _record_candidate_key(
enc_key, db_files, salt_to_dbs, key_map,
remaining_salts, pid, cipher_addr,
f"wxSQLite3 AES-128-CBC, page_size={page_size}",
print_fn,
):
if not remaining_salts:
return key_tests
checked += 1
off += 4
print_fn(
f"[*] 结构体扫描完成: checked={checked}, ptr_hits={ptr_hits}, "
f"chain_hits={chain_hits}, key_tests={key_tests}"
)
return key_tests
def cross_verify_wxwork_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)
ok, desc = verify_enc_key_wxwork(enc_key, page1)
if ok:
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_wxwork_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
# 写文件含明文 raw key,先 atomic write 到 tmp 再 rename,中途 chmod 0600
# 限本用户读写。Windows 上 os.chmod 主要控制只读位,严格 ACL 需 win32security
# 这里至少避免世界可读的最差情况。
tmp_file = out_file + ".tmp"
with open(tmp_file, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, ensure_ascii=False)
try:
os.chmod(tmp_file, 0o600)
except OSError:
pass # Windows 上某些场景 chmod 可能失败,不阻塞主流程
os.replace(tmp_file, out_file)
print_fn(f"\n密钥保存到: {out_file} (权限已收紧为 0600)")
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}")
# ── 配置加载 ─────────────────────────────────────────────────────────
def _load_wxwork_config():
"""从 config.json 加载企业微信配置,必要时自动检测"""
from config import _config_file_path, _app_base_dir
config_file = _config_file_path()
cfg = {}
if os.path.exists(config_file):
try:
with open(config_file, encoding="utf-8") as f:
cfg = json.load(f)
except json.JSONDecodeError:
cfg = {}
db_dir = cfg.get("wxwork_db_dir", "")
if not db_dir or not os.path.isdir(db_dir):
detected = auto_detect_wxwork_db_dir()
if detected:
print(f"[+] 自动检测到企业微信数据目录: {detected}")
cfg["wxwork_db_dir"] = detected
with open(config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"[+] 已保存到: {config_file}")
db_dir = detected
else:
print("[!] 未能自动检测企业微信数据目录")
print(f" 请在 {config_file} 中设置 wxwork_db_dir 字段")
print(" 路径格式: C:\\Users\\<用户>\\Documents\\WXWork\\<account_id>\\Data")
sys.exit(1)
keys_file = cfg.get("wxwork_keys_file", "wxwork_keys.json")
base = _app_base_dir()
if not os.path.isabs(keys_file):
keys_file = os.path.join(base, keys_file)
return {"wxwork_db_dir": db_dir, "wxwork_keys_file": keys_file}
# ── 主流程 ───────────────────────────────────────────────────────────
def main():
cfg = _load_wxwork_config()
db_dir = cfg["wxwork_db_dir"]
out_file = cfg["wxwork_keys_file"]
print("=" * 60)
print(" 提取所有企业微信数据库密钥")
print("=" * 60)
# 1. 收集所有DB文件及其salt
db_files, salt_to_dbs = collect_db_files(db_dir)
db_files, salt_to_dbs = filter_encrypted_dbs(db_files, salt_to_dbs)
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_wxwork_pids()
# Some versions do not keep the key as SQL literal x'...'. Bare ASCII
# hex scanning is much slower, so keep it behind an explicit switch.
hex_re = re.compile(b"x'([0-9a-fA-F]{32,192})'")
scan_bare_hex = "--scan-bare-hex" in sys.argv
bare_hex_re = re.compile(b"(?<![0-9a-fA-F])([0-9a-fA-F]{32})(?![0-9a-fA-F])")
if scan_bare_hex:
print("[*] 已启用裸 32-hex key 扫描,速度会明显变慢")
key_map = {}
remaining_salts = set(salt_to_dbs.keys())
all_hex_matches = 0
all_bare_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_wxwork_keys(
data, hex_re, db_files, salt_to_dbs,
key_map, remaining_salts, base, pid, print,
)
if scan_bare_hex and remaining_salts:
all_bare_hex_matches += scan_memory_for_wxwork_keys(
data, bare_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} x'...' patterns, "
f"{all_bare_hex_matches} bare hex patterns, {elapsed:.1f}s"
)
if remaining_salts:
print("\n[*] 未找到 x'...' 形式 key尝试 WXWork 5.x cipher 结构体扫描...")
scan_memory_for_wxwork_cipher_structs(
h, regions, db_files, salt_to_dbs,
key_map, remaining_salts, pid, print,
)
finally:
kernel32.CloseHandle(h)
if not remaining_salts:
print(f"\n[+] 所有密钥已找到,跳过剩余进程")
break
elapsed = time.time() - t0
print(
f"\n扫描完成: {elapsed:.1f}s, {len(pids)} 个进程, "
f"{all_hex_matches} x'...' 模式, {all_bare_hex_matches} bare hex 模式"
)
cross_verify_wxwork_keys(db_files, salt_to_dbs, key_map, print)
save_wxwork_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)