"""Linux 微信进程内存密钥提取器。 参考 wechat-cli-main 实现: - 通过 /proc//maps 枚举微信进程可读内存段 - 通过 /proc//mem 读取内存 - 匹配 x'' 模式(64 位 enc_key、96 位 enc_key+salt、更长 hex) - 用 HMAC-SHA512 校验确认 enc_key - 返回 salt_hex -> enc_key_hex 映射 """ from __future__ import annotations import hashlib import hmac as hmac_mod import logging import os import re import struct _logger = logging.getLogger(__name__) PAGE_SZ = 4096 KEY_SZ = 32 SALT_SZ = 16 IV_SIZE = 16 HMAC_SIZE = 64 RESERVE_SIZE = 80 MAC_SALT_XOR = 0x3A # 单次内存读取上限(避免超大映射导致内存爆炸) _MAX_REGION_SIZE = 500 * 1024 * 1024 _KNOWN_COMMS = {"wechat", "wechatappex", "weixin"} # crashpad / 渲染辅助子进程:内存中不含主进程的 DB 密钥,扫描会浪费时间 _EXCLUDE_COMM_KEYWORDS = ("crashpad", "crashpad_handler", "renderer", "gpu-process") _INTERPRETER_PREFIXES = ("python", "bash", "sh", "zsh", "node", "perl", "ruby") _SKIP_MAPPINGS = {"[vdso]", "[vsyscall]", "[vvar]"} _SKIP_PATH_PREFIXES = ("/usr/lib/", "/lib/", "/usr/share/") def _safe_readlink(path: str) -> str: try: return os.path.realpath(os.readlink(path)) except OSError: return "" def _is_wechat_process(pid: int) -> bool: """检查 pid 是否为微信主进程(排除 crashpad 等辅助子进程)。 排除逻辑: - crashpad_handler / crashpad 子进程:内存中无 DB 密钥,扫描浪费时间 - 渲染/GPU 辅助进程:同上 - 解释器进程(python/bash 等):避免误中 """ if pid == os.getpid(): return False try: with open(f"/proc/{pid}/comm", encoding="utf-8", errors="replace") as f: comm = f.read().strip() comm_lower = comm.lower() # 排除 crashpad 等辅助子进程 if any(kw in comm_lower for kw in _EXCLUDE_COMM_KEYWORDS): return False if comm_lower in _KNOWN_COMMS: return True exe_path = _safe_readlink(f"/proc/{pid}/exe") exe_name = os.path.basename(exe_path) if any(exe_name.lower().startswith(p) for p in _INTERPRETER_PREFIXES): return False return "wechat" in exe_name.lower() or "weixin" in exe_name.lower() except (PermissionError, FileNotFoundError, ProcessLookupError): return False def _get_wechat_pids() -> list[tuple[int, int]]: """返回疑似微信进程的 (pid, rss_kb) 列表,按内存占用降序。""" pids: list[tuple[int, int]] = [] for pid_str in os.listdir("/proc"): if not pid_str.isdigit(): continue pid = int(pid_str) try: if not _is_wechat_process(pid): continue with open(f"/proc/{pid}/statm", encoding="utf-8") as f: rss_pages = int(f.read().split()[1]) rss_kb = rss_pages * 4 pids.append((pid, rss_kb)) except (PermissionError, FileNotFoundError, ProcessLookupError, OSError, ValueError): continue pids.sort(key=lambda x: x[1], reverse=True) return pids def _get_readable_regions(pid: int, full_scan: bool = False) -> list[tuple[int, int, str]]: """解析 /proc//maps,返回可读内存区域 (base, size, name) 列表。 Args: pid: 目标进程 PID full_scan: 为 True 时不跳过系统库路径(更慢但更全);默认按已知路径过滤 """ regions: list[tuple[int, int, str]] = [] maps_path = f"/proc/{pid}/maps" with open(maps_path, "r", encoding="utf-8", errors="replace") as f: for line in f: parts = line.split() if len(parts) < 2: continue perms = parts[1] if "r" not in perms: continue mapping_name = parts[5] if len(parts) >= 6 else "" if mapping_name in _SKIP_MAPPINGS: continue if not full_scan: mapping_lower = mapping_name.lower() if ( any(mapping_name.startswith(p) for p in _SKIP_PATH_PREFIXES) and "wcdb" not in mapping_lower and "wechat" not in mapping_lower and "weixin" not in mapping_lower ): continue start_s, end_s = parts[0].split("-") start = int(start_s, 16) size = int(end_s, 16) - start if 0 < size < _MAX_REGION_SIZE: regions.append((start, size, mapping_name)) return regions def _collect_db_files(db_dir: str) -> tuple[list[tuple[str, str, int, str, bytes]], dict[str, list[str]]]: """遍历 db_dir 收集所有 .db 文件及其 salt。 Returns: db_files: [(rel_path, abs_path, size, salt_hex, page1_bytes), ...] salt_to_dbs: {salt_hex: [rel_path, ...]} """ db_files: list[tuple[str, str, int, str, bytes]] = [] salt_to_dbs: dict[str, list[str]] = {} if not os.path.isdir(db_dir): return db_files, salt_to_dbs for root, _, files in os.walk(db_dir): for name in files: if not name.endswith(".db") or name.endswith("-wal") or name.endswith("-shm"): continue path = os.path.join(root, name) try: size = os.path.getsize(path) except OSError: continue if size < PAGE_SZ: continue try: with open(path, "rb") as f: page1 = f.read(PAGE_SZ) except OSError: continue if page1.startswith(b"SQLite format 3\x00"): continue rel = os.path.relpath(path, db_dir) salt = page1[:SALT_SZ].hex() db_files.append((rel, path, size, salt, page1)) salt_to_dbs.setdefault(salt, []).append(rel) return db_files, salt_to_dbs def _verify_enc_key(enc_key: bytes, page1: bytes) -> bool: """通过 HMAC-SHA512 校验 page 1 验证 enc_key 是否正确。""" salt = page1[:SALT_SZ] mac_salt = bytes(b ^ MAC_SALT_XOR for b in salt) mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ) hmac_data = page1[SALT_SZ: PAGE_SZ - RESERVE_SIZE + IV_SIZE] stored_hmac = page1[PAGE_SZ - HMAC_SIZE: PAGE_SZ] hm = hmac_mod.new(mac_key, hmac_data, hashlib.sha512) hm.update(struct.pack(" str | None: """扫描内存提取与 probe_db_path 匹配的 SQLCipher 密钥。 Returns: 64 位十六进制 enc_key 字符串,未找到返回 None """ db_dir = self.probe_db_path if os.path.isfile(db_dir): db_dir = os.path.dirname(db_dir) key_map = self.extract_all_keys(db_dir) # 优先匹配 probe_db_path 自己的 salt probe_salt = None if os.path.isfile(self.probe_db_path): try: with open(self.probe_db_path, "rb") as f: page1 = f.read(PAGE_SZ) if not page1.startswith(b"SQLite format 3\x00"): probe_salt = page1[:SALT_SZ].hex() except OSError: pass if probe_salt and probe_salt in key_map: return key_map[probe_salt] # 否则返回任意一个找到的 key if key_map: return next(iter(key_map.values())) return None # ------------------------------------------------------------------ # 新接口:返回 salt_hex -> enc_key_hex 映射 # ------------------------------------------------------------------ def extract_all_keys( self, db_dir: str | None = None, full_scan: bool = False, ) -> dict[str, str]: """扫描内存,为 db_dir 下每个加密 DB salt 提取 enc_key。 Args: db_dir: 微信数据库目录。默认为 probe_db_path 所在目录。 full_scan: 是否完整扫描所有可读内存区域(默认优先扫描 [heap],未找全再扫其它)。 Returns: dict: {salt_hex: enc_key_hex} """ if db_dir is None: db_dir = self.probe_db_path if os.path.isfile(db_dir): db_dir = os.path.dirname(db_dir) db_files, salt_to_dbs = _collect_db_files(db_dir) if not db_files: _logger.warning("在 %s 下未找到加密 DB 文件", db_dir) return {} _logger.info( "找到 %d 个加密 DB,%d 个不同 salt", len(db_files), len(salt_to_dbs), ) if self.wechat_pid: pids = [(self.wechat_pid, 0)] else: pids = _get_wechat_pids() if not pids: _logger.warning("未检测到微信进程") return {} _logger.info( "检测到 %d 个微信进程候选: %s", len(pids), [p for p, _ in pids], ) key_map: dict[str, str] = {} remaining = set(salt_to_dbs.keys()) for pid, _rss_kb in pids: if not remaining: break try: regions = _get_readable_regions(pid, full_scan=full_scan) except (PermissionError, FileNotFoundError, ProcessLookupError) as e: _logger.warning("无法读取 /proc/%s/maps: %s", pid, e) continue if not full_scan: # heap 优先,其次是 stack,再按区域大小降序 def _heap_first_key(item: tuple[int, int, str]) -> tuple[int, int, str]: name = item[2].lower() if name == "[heap]": return (0, 0, name) if name == "[stack]": return (1, 0, name) return (2, -item[1], name) regions.sort(key=_heap_first_key) total_size = sum(size for _, size, _ in regions) total_mib = total_size / (1024 * 1024) _logger.info( "PID %s 开始扫描 %d 个区域,合计 %.1f MiB(full_scan=%s)", pid, len(regions), total_mib, full_scan, ) try: mem = open(f"/proc/{pid}/mem", "rb") except (PermissionError, FileNotFoundError, ProcessLookupError) as e: _logger.warning("无法打开 /proc/%s/mem: %s", pid, e) continue try: for idx, (base, size, name) in enumerate(regions): if not remaining: break _logger.info( "区域 %d/%d PID=%s [%-16s] 0x%016x size=%.1fMiB 剩余salt=%d", idx + 1, len(regions), pid, name or "?", base, size / (1024 * 1024), len(remaining), ) try: self._scan_region( mem, base, size, name, db_files, key_map, remaining, pid, ) except (OSError, ValueError) as e: _logger.debug("读取区域失败 PID=%s addr=0x%016x: %s", pid, base, e) continue _logger.info( "完成区域 %d/%d,已找到 %d/%d 个 enc_key", idx + 1, len(regions), len(key_map), len(salt_to_dbs), ) finally: mem.close() # 交叉验证:用已找到的 key 验证未匹配的 salt(多 DB 可能共用 enc_key) self._cross_verify(db_files, key_map, remaining) if key_map: _logger.info( "成功提取 %d/%d 个 salt 的 enc_key", len(key_map), len(salt_to_dbs), ) else: _logger.warning("扫描完成,未找到任何有效 enc_key") return key_map def _scan_buffer( self, data: bytes, db_files: list[tuple[str, str, int, str, bytes]], key_map: dict[str, str], remaining: set[str], base_addr: int, pid: int, ) -> None: """扫描一段内存数据,匹配 x'hex' 模式并验证。 hex 长度分支覆盖: - 64:纯 enc_key,尝试匹配所有剩余 salt - 96:enc_key(64) + salt(32),按指定 salt 验证 - >96 且偶数:取前 64 为 enc_key,末 32 为 salt - 66~94 偶数:无明确 salt,按纯 enc_key 尝试所有 salt(覆盖 64~192 全范围) """ for m in self._hex_re.finditer(data): hex_str = m.group(1).decode() addr = base_addr + m.start() hex_len = len(hex_str) if hex_len == 64: enc_key_hex = hex_str self._try_key_all_salts(enc_key_hex, db_files, key_map, remaining, addr, pid) elif hex_len == 96: enc_key_hex = hex_str[:64] salt_hex = hex_str[64:] self._try_key(enc_key_hex, salt_hex, db_files, key_map, remaining, addr, pid) elif hex_len > 96 and hex_len % 2 == 0: enc_key_hex = hex_str[:64] salt_hex = hex_str[-32:] self._try_key(enc_key_hex, salt_hex, db_files, key_map, remaining, addr, pid) elif hex_len > 64 and hex_len < 96 and hex_len % 2 == 0: # 66~94 长度:无明确 salt 区,按纯 enc_key 尝试所有 salt enc_key_hex = hex_str[:64] self._try_key_all_salts(enc_key_hex, db_files, key_map, remaining, addr, pid) def _scan_region( self, mem, base: int, size: int, name: str, db_files: list[tuple[str, str, int, str, bytes]], key_map: dict[str, str], remaining: set[str], pid: int, ) -> None: """流式读取并扫描一个内存区域,按 MiB 输出进度。 采用 8 MiB 分块 + 256 字节重叠,避免超大区域一次性读入内存,同时保证 x'hex' 模式跨块边界时也能被匹配。 """ chunk_size = 8 * 1024 * 1024 overlap = 256 tail = b"" position = 0 last_log_mib = -1 region_mib = size / (1024 * 1024) while position < size: to_read = min(chunk_size, size - position) try: mem.seek(base + position) chunk = mem.read(to_read) except (OSError, ValueError): break if not chunk: break data = tail + chunk buffer_base = base + position - len(tail) self._scan_buffer(data, db_files, key_map, remaining, buffer_base, pid) position += len(chunk) scanned_mib = position / (1024 * 1024) current_mib = int(scanned_mib) if current_mib > last_log_mib: last_log_mib = current_mib _logger.info( "PID %s [%-16s] 已扫描 %.1f/%.1f MiB,已找到 %d 个 enc_key", pid, name or "?", scanned_mib, region_mib, len(key_map), ) tail = data[-overlap:] if len(data) >= overlap else data def _try_key( self, enc_key_hex: str, salt_hex: str, db_files: list[tuple[str, str, int, str, bytes]], key_map: dict[str, str], remaining: set[str], addr: int, pid: int, ) -> None: """尝试用一个 enc_key 匹配指定 salt。""" if salt_hex not in remaining: return try: enc_key = bytes.fromhex(enc_key_hex) except ValueError: return for _rel, _path, _sz, s, page1 in db_files: if s != salt_hex: continue if _verify_enc_key(enc_key, page1): key_map[salt_hex] = enc_key_hex remaining.discard(salt_hex) _logger.info( "找到 enc_key: salt=%s PID=%s addr=0x%016x", salt_hex, pid, addr, ) break def _try_key_all_salts( self, enc_key_hex: str, db_files: list[tuple[str, str, int, str, bytes]], key_map: dict[str, str], remaining: set[str], addr: int, pid: int, ) -> None: """尝试用一个 64-hex enc_key 匹配所有剩余 salt。""" if not remaining: return try: enc_key = bytes.fromhex(enc_key_hex) except ValueError: return for _rel, _path, _sz, salt_hex, page1 in db_files: if salt_hex not in remaining: continue if _verify_enc_key(enc_key, page1): key_map[salt_hex] = enc_key_hex remaining.discard(salt_hex) _logger.info( "找到 enc_key: salt=%s PID=%s addr=0x%016x", salt_hex, pid, addr, ) def _cross_verify( self, db_files: list[tuple[str, str, int, str, bytes]], key_map: dict[str, str], remaining: set[str], ) -> None: """用已找到的 key 交叉验证剩余 salt。""" if not remaining or not key_map: return for _rel, _path, _sz, salt_hex, page1 in db_files: if salt_hex not in remaining: continue for known_salt, known_key_hex in key_map.items(): enc_key = bytes.fromhex(known_key_hex) if _verify_enc_key(enc_key, page1): key_map[salt_hex] = known_key_hex remaining.discard(salt_hex) _logger.info( "交叉验证成功: salt=%s 复用 salt=%s 的 enc_key", salt_hex, known_salt, ) break