feat: 添加企业微信相关配置和功能支持

This commit is contained in:
xincheng
2026-04-05 02:19:11 +08:00
parent a84698b9fc
commit ebbea8c895
5 changed files with 476 additions and 10 deletions

View File

@@ -2,5 +2,8 @@
"db_dir": "D:\\xwechat_files\\your_wxid\\db_storage",
"keys_file": "all_keys.json",
"decrypted_dir": "decrypted",
"wechat_process": "Weixin.exe"
"wechat_process": "Weixin.exe",
"wxwork_db_dir": "",
"wxwork_keys_file": "wxwork_keys.json",
"wxwork_process": "WXWork.exe"
}

View File

@@ -41,6 +41,9 @@ _DEFAULT = {
"decrypted_dir": "decrypted",
"decoded_image_dir": "decoded_images",
"wechat_process": _DEFAULT_PROCESS,
"wxwork_db_dir": "",
"wxwork_keys_file": "wxwork_keys.json",
"wxwork_process": "WXWork.exe",
}
@@ -213,8 +216,8 @@ def load_config():
# 将相对路径转为绝对路径
base = _app_base_dir()
for key in ("keys_file", "decrypted_dir", "decoded_image_dir"):
if key in cfg and not os.path.isabs(cfg[key]):
for key in ("keys_file", "decrypted_dir", "decoded_image_dir", "wxwork_keys_file"):
if key in cfg and cfg[key] and not os.path.isabs(cfg[key]):
cfg[key] = os.path.join(base, cfg[key])
# 自动推导微信数据根目录db_dir 的上级目录)

View File

@@ -1,5 +1,5 @@
"""导出微信消息记录到 CSV / HTML / JSON
目录结构: export/<display_name>/messages.csv|html|json
目录结构: <wechat_base_dir>/export/<display_name>/messages.csv|html|json
"""
import sqlite3
import glob
@@ -18,9 +18,12 @@ import zstandard as zstd
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
MSG_DB_DIR = r"decrypted\message"
CONTACT_DB_PATH = r"decrypted\contact\contact.db"
OUTPUT_DIR = "export"
from config import load_config
_cfg = load_config()
MSG_DB_DIR = os.path.join(_cfg["decrypted_dir"], "message")
CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "export")
MSG_TYPES = {
1: "文本",

455
find_wxwork_keys.py Normal file
View File

@@ -0,0 +1,455 @@
"""
从企业微信(WXWork)进程内存中提取所有数据库的缓存raw key
企业微信使用 WCDB/SQLCipher 加密,但与个人微信参数不同:
- 个人微信: AES-256-CBC, 32字节密钥, HMAC-SHA512
- 企业微信: AES-128-CBC, 16字节密钥, 每页根据page index重新派生IV/key
密钥在内存中的缓存格式仍为 x'<hex>',但 key 为16字节(32 hex chars)
"""
import ctypes
import ctypes.wintypes as wt
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
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"
SQLITE_HEADER_HEX = b"SQLite format 3\x00".hex()
PAGE_SZ = 4096
SALT_SZ = 16
# 企业微信可能的加密参数组合 (按可能性排序)
# (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):
"""尝试多种参数组合验证密钥,返回 (成功?, 使用的配置描述)"""
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 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
if len(candidates) == 1:
return candidates[0]
if not sys.stdin.isatty():
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):
"""过滤掉未加密的数据库salt 等于 SQLite header 的)"""
filtered_files = [
entry for entry in db_files if entry[3] != SQLITE_HEADER_HEX
]
filtered_salts = {
s: dbs for s, dbs in salt_to_dbs.items() if s != SQLITE_HEADER_HEX
}
removed = len(db_files) - len(filtered_files)
if removed:
print(f"[*] 跳过 {removed} 个未加密数据库")
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 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
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}")
# ── 配置加载 ─────────────────────────────────────────────────────────
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()
# 宽松正则:匹配 32+ hex chars (16字节key起)
hex_re = re.compile(b"x'([0-9a-fA-F]{32,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_wxwork_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_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)

View File

@@ -10,10 +10,12 @@ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import pilk
from config import load_config
DB_PATH = r"decrypted\message\media_0.db"
CONTACT_DB_PATH = r"decrypted\contact\contact.db"
OUTPUT_DIR = "data"
_cfg = load_config()
DB_PATH = os.path.join(_cfg["decrypted_dir"], "message", "media_0.db")
CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
OUTPUT_DIR = os.path.join(_cfg["wechat_base_dir"], "voice")
def silk_to_mp3(voice_data, output_path):
"""将微信 SILK 语音数据转换为 MP3"""