feat: 添加企业微信相关配置和功能支持
This commit is contained in:
455
find_wxwork_keys.py
Normal file
455
find_wxwork_keys.py
Normal 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)
|
||||
Reference in New Issue
Block a user