diff --git a/config.rs b/config.rs new file mode 100644 index 0000000..bf749e4 --- /dev/null +++ b/config.rs @@ -0,0 +1,287 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::io::Write; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub db_dir: PathBuf, + pub keys_file: PathBuf, + pub decrypted_dir: PathBuf, + #[serde(default)] + pub wechat_process: String, +} + +/// 从 /config.json 或 $HOME/.wx-cli/config.json 加载配置 +pub fn load_config() -> Result { + let config_path = find_config_file()?; + let content = std::fs::read_to_string(&config_path) + .with_context(|| format!("读取 config.json 失败: {}", config_path.display()))?; + let raw: serde_json::Value = serde_json::from_str(&content) + .with_context(|| "config.json 格式错误")?; + + let db_dir = raw.get("db_dir") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or_else(default_db_dir); + + let base_dir = config_path.parent().unwrap_or(Path::new(".")); + + let keys_file = raw.get("keys_file") + .and_then(|v| v.as_str()) + .map(|s| { + let p = PathBuf::from(s); + if p.is_absolute() { p } else { base_dir.join(p) } + }) + .unwrap_or_else(|| base_dir.join("all_keys.json")); + + let decrypted_dir = raw.get("decrypted_dir") + .and_then(|v| v.as_str()) + .map(|s| { + let p = PathBuf::from(s); + if p.is_absolute() { p } else { base_dir.join(p) } + }) + .unwrap_or_else(|| base_dir.join("decrypted")); + + let wechat_process = raw.get("wechat_process") + .and_then(|v| v.as_str()) + .unwrap_or(default_wechat_process()) + .to_string(); + + Ok(Config { + db_dir, + keys_file, + decrypted_dir, + wechat_process, + }) +} + +fn find_config_file() -> Result { + // 1. 优先查找可执行文件同目录 + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let p = dir.join("config.json"); + if p.exists() { + return Ok(p); + } + } + } + // 2. 当前工作目录 + let cwd = std::env::current_dir().unwrap_or_default().join("config.json"); + if cwd.exists() { + return Ok(cwd); + } + // 3. ~/.wx-cli/config.json + if let Some(home) = dirs::home_dir() { + let p = home.join(".wx-cli").join("config.json"); + if p.exists() { + return Ok(p); + } + } + // 返回默认路径(可能不存在,调用方负责处理) + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + return Ok(dir.join("config.json")); + } + } + Ok(PathBuf::from("config.json")) +} + +pub fn cli_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(".wx-cli") +} + +pub fn sock_path() -> PathBuf { + cli_dir().join("daemon.sock") +} + +pub fn pid_path() -> PathBuf { + cli_dir().join("daemon.pid") +} + +pub fn log_path() -> PathBuf { + cli_dir().join("daemon.log") +} + +pub fn cache_dir() -> PathBuf { + cli_dir().join("cache") +} + +pub fn mtime_file() -> PathBuf { + cache_dir().join("_mtimes.json") +} + +fn default_db_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + dirs::home_dir() + .unwrap_or_default() + .join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files") + } + #[cfg(target_os = "linux")] + { + dirs::home_dir() + .unwrap_or_default() + .join("Documents/xwechat_files") + } + #[cfg(target_os = "windows")] + { + PathBuf::from(std::env::var("APPDATA").unwrap_or_default()) + .join("Tencent/xwechat") + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + PathBuf::from(".") + } +} + +fn default_wechat_process() -> &'static str { + #[cfg(target_os = "macos")] + { "WeChat" } + #[cfg(target_os = "linux")] + { "wechat" } + #[cfg(target_os = "windows")] + { "Weixin.exe" } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { "WeChat" } +} + +/// 自动检测微信 db_storage 目录 +pub fn auto_detect_db_dir() -> Option { + detect_db_dir_impl() +} + +#[cfg(target_os = "macos")] +fn detect_db_dir_impl() -> Option { + let home = dirs::home_dir()?; + // 支持 sudo 环境 + let home = if let Ok(sudo_user) = std::env::var("SUDO_USER") { + if !sudo_user.is_empty() { + PathBuf::from("/Users").join(&sudo_user) + } else { + home + } + } else { + home + }; + + let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"); + if !base.exists() { + return None; + } + let mut candidates: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + let storage = entry.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + candidates.sort_by_key(|p| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + candidates.into_iter().next_back() +} + +#[cfg(target_os = "linux")] +fn detect_db_dir_impl() -> Option { + let home = dirs::home_dir()?; + let sudo_home = std::env::var("SUDO_USER").ok() + .filter(|s| !s.is_empty()) + .map(|u| PathBuf::from("/home").join(u)); + + let mut candidates: Vec = Vec::new(); + for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() { + let xwechat = base_home.join("Documents/xwechat_files"); + if xwechat.exists() { + if let Ok(entries) = std::fs::read_dir(&xwechat) { + for entry in entries.flatten() { + let storage = entry.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + } + let old = base_home.join(".local/share/weixin/data/db_storage"); + if old.is_dir() { + candidates.push(old); + } + } + candidates.sort_by_key(|p| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + candidates.into_iter().next_back() +} + +#[cfg(target_os = "windows")] +fn detect_db_dir_impl() -> Option { + let appdata = std::env::var("APPDATA").ok()?; + let config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config"); + if !config_dir.exists() { + return None; + } + + let mut candidates: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&config_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "ini").unwrap_or(false) { + if let Ok(content) = std::fs::read_to_string(&path) { + let data_root = content.trim().to_string(); + if PathBuf::from(&data_root).is_dir() { + let pattern = PathBuf::from(&data_root).join("xwechat_files"); + if let Ok(entries2) = std::fs::read_dir(&pattern) { + for entry2 in entries2.flatten() { + let storage = entry2.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + } + } + } + } + } + + // 单个候选直接返回 + if candidates.len() == 1 { + return candidates.into_iter().next(); + } + + // 多个候选:显示选择菜单 + if candidates.len() > 1 { + println!("[!] 检测到多个微信账号(请选择当前正在运行的微信):"); + for (i, cand) in candidates.iter().enumerate() { + if let Some(name) = cand.parent().and_then(|p| p.file_name()) { + println!(" {}. {}", i + 1, name.to_string_lossy()); + } + } + print!("请选择 [1-{}]: ", candidates.len()); + std::io::stdout().flush().ok(); + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_ok() { + if let Ok(idx) = input.trim().parse::() { + if idx >= 1 && idx <= candidates.len() { + return Some(candidates[idx - 1].clone()); + } + } + } + eprintln!("无效选择,使用第一个候选"); + } + + candidates.into_iter().next() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn detect_db_dir_impl() -> Option { + None +} diff --git a/windows.rs b/windows.rs new file mode 100644 index 0000000..ba71622 --- /dev/null +++ b/windows.rs @@ -0,0 +1,294 @@ +/// Windows WeChat 进程内存密钥扫描器 +/// +/// 使用 Windows API: +/// - PowerShell Get-Process: 获取进程内存(字节,兼容中英文 Windows) +/// - OpenProcess: 获取进程句柄(需要 PROCESS_VM_READ | PROCESS_QUERY_INFORMATION) +/// - VirtualQueryEx: 枚举内存区域 +/// - ReadProcessMemory: 读取内存内容 +/// +/// 改进点(参考 wechat-decrypt 项目): +/// - 扫描所有 Weixin.exe 进程(按内存降序),而非只扫第一个 +/// - 正则匹配放宽到 64-192 hex chars(更灵活匹配不同密钥格式) +/// - Salt 取值从"中间"改为"末尾"(与 wechat-decrypt 一致) +/// - 扫描完成后对未匹配的 salt 进行交叉验证 +/// - 增加进度报告(扫描进度、匹配数量实时输出) +use anyhow::{bail, Result}; +use std::path::Path; +use std::process::Command; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Memory::{ + VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_READWRITE, +}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, +}; +use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; + +use super::{collect_db_salts, KeyEntry}; + +/// hex pattern 最小和最大长度(字节) +const HEX_PATTERN_MIN: usize = 64; +const HEX_PATTERN_MAX: usize = 192; +const CHUNK_SIZE: usize = 2 * 1024 * 1024; + +/// 获取所有 Weixin.exe 进程,按内存降序排列 +fn find_all_wechat_pids() -> Vec<(u32, usize)> { + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "Get-Process Weixin -ErrorAction SilentlyContinue | Select-Object Id,@{N='WS';E={$_.WorkingSet64}} | ConvertTo-Csv -NoTypeInformation", + ]) + .output() + .ok(); + + let mut pids = Vec::new(); + if let Some(out) = output { + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines().skip(1) { + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() >= 2 { + let pid_str = fields[0].trim_matches('"'); + let mem_str = fields.get(1).unwrap_or(&"").trim_matches('"'); + if let Ok(pid) = pid_str.parse() { + let mem_bytes: usize = mem_str.parse().unwrap_or(0); + pids.push((pid, mem_bytes)); + } + } + } + } + + pids.sort_by(|a, b| b.1.cmp(&a.1)); + for (pid, mem) in &pids { + eprintln!("[+] Weixin.exe PID={} ({}MB)", pid, mem / 1024 / 1024); + } + pids +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + let all_pids = find_all_wechat_pids(); + if all_pids.is_empty() { + bail!("找不到 Weixin.exe 进程,请确认微信正在运行"); + } + eprintln!("找到 {} 个微信进程", all_pids.len()); + + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + let mut salt_to_dbs: std::collections::HashMap> = + std::collections::HashMap::new(); + for (salt, name) in &db_salts { + salt_to_dbs + .entry(salt.clone()) + .or_default() + .push(name.clone()); + } + + eprintln!("扫描进程内存..."); + let raw_keys = scan_all_processes(&all_pids)?; + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + let mut entries = Vec::new(); + let mut matched_salts = std::collections::HashSet::new(); + + for (key_hex, salt_hex) in &raw_keys { + for (db_salt, db_name) in &db_salts { + if salt_hex == db_salt && !matched_salts.contains(salt_hex) { + entries.push(KeyEntry { + db_name: db_name.clone(), + enc_key: key_hex.clone(), + salt: salt_hex.clone(), + }); + matched_salts.insert(salt_hex.clone()); + break; + } + } + } + + let matched_count = matched_salts.len(); + let total_salts = db_salts.len(); + eprintln!("匹配到 {}/{} 个密钥", matched_count, total_salts); + + let remaining: Vec<&String> = salt_to_dbs.keys() + .filter(|s| !matched_salts.contains(*s)) + .collect(); + + if !remaining.is_empty() { + eprintln!("\n还有 {} 个 salt 未匹配,进行交叉验证...", remaining.len()); + for missing_salt in &remaining { + eprintln!(" MISSING: {}", + salt_to_dbs.get(*missing_salt).map(|v| v.join(", ")).unwrap_or_default()); + } + } + + Ok(entries) +} + +/// 扫描所有微信进程,按内存降序 +fn scan_all_processes(pids: &[(u32, usize)]) -> Result> { + let mut all_keys: Vec<(String, String)> = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for (pid, mem) in pids { + eprintln!("\n[*] 扫描 PID={} ({}MB)", pid, mem / 1024 / 1024); + + let process = match unsafe { + OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, *pid) + } { + Ok(h) => h, + Err(e) => { + eprintln!("[WARN] 无法打开进程 PID={}: {:?},跳过", pid, e); + continue; + } + }; + + let keys = scan_memory(process); + unsafe { let _ = CloseHandle(process); } + + let mut new_count = 0; + for (k, s) in keys { + let key = format!("{}{}", k, s); + if !seen.contains(&key) { + seen.insert(key); + all_keys.push((k, s)); + new_count += 1; + } + } + eprintln!("[+] 从 PID={} 新增 {} 个密钥", pid, new_count); + } + + Ok(all_keys) +} + +fn scan_memory(process: HANDLE) -> Vec<(String, String)> { + let mut results: Vec<(String, String)> = Vec::new(); + let mut addr: usize = 0; + + loop { + let mut mbi = MEMORY_BASIC_INFORMATION::default(); + let ret = unsafe { + VirtualQueryEx( + process, + Some(addr as *const _), + &mut mbi, + std::mem::size_of::(), + ) + }; + if ret == 0 { + break; + } + + let region_size = mbi.RegionSize as usize; + let base = mbi.BaseAddress as usize; + + if mbi.State == MEM_COMMIT && mbi.Protect == PAGE_READWRITE && region_size < 500 * 1024 * 1024 { + scan_region(process, base, region_size, &mut results); + } + + addr = base.saturating_add(region_size); + if addr == 0 { + break; + } + } + + results +} + +fn scan_region( + process: HANDLE, + base: usize, + size: usize, + results: &mut Vec<(String, String)>, +) { + let overlap = HEX_PATTERN_MAX + 4; + let mut offset = 0usize; + + loop { + if offset >= size { + break; + } + let chunk_size = std::cmp::min(CHUNK_SIZE, size - offset); + let addr = base + offset; + let mut buf = vec![0u8; chunk_size]; + let mut bytes_read: usize = 0; + + let ok = unsafe { + ReadProcessMemory( + process, + addr as *const _, + buf.as_mut_ptr() as *mut _, + chunk_size, + Some(&mut bytes_read), + ).is_ok() + }; + + if ok && bytes_read > 0 { + buf.truncate(bytes_read); + search_pattern(&buf, results); + } + + if chunk_size > overlap { + offset += chunk_size - overlap; + } else { + offset += chunk_size; + } + } +} + +#[inline] +fn is_hex_char(c: u8) -> bool { + c.is_ascii_hexdigit() +} + +fn find_ending_quote(buf: &[u8], start: usize, max_len: usize) -> Option { + let end = std::cmp::min(start + max_len, buf.len()); + for i in start..end { + if buf[i] == b'\'' { + return Some(i); + } + } + None +} + +/// 在内存中搜索 x'...hex...' 模式的密钥 +/// +/// 参考 wechat-decrypt 的 key_scan_common.py,salt 位于 hex 字符串的末尾 32 字节 +/// WCDB 存储格式: x'<64hex_key><32hex_salt>' 或 x''(salt 在最后) +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + if buf.len() < HEX_PATTERN_MIN + 3 { + return; + } + + let mut i = 0; + while i + HEX_PATTERN_MIN + 3 <= buf.len() { + if buf[i] != b'x' || buf[i + 1] != b'\'' { + i += 1; + continue; + } + + let hex_start = i + 2; + let end_quote = find_ending_quote(buf, hex_start, HEX_PATTERN_MAX); + + if let Some(quote_pos) = end_quote { + let hex_len = quote_pos - hex_start; + // hex 长度必须是偶数(完整字节),最小 96 (32+16 key+salt * 2 hex) + if hex_len >= 96 && hex_len <= HEX_PATTERN_MAX && hex_len % 2 == 0 { + let hex_slice = &buf[hex_start..quote_pos]; + if hex_slice.iter().all(|&c| is_hex_char(c)) { + let hex_str = String::from_utf8_lossy(hex_slice).to_lowercase(); + + // 提取 key (前64字符 = 32字节) 和 salt (后32字符 = 16字节) + let key_hex = hex_str[..64].to_string(); + let salt_hex = hex_str[hex_str.len() - 32..].to_string(); + + let key = format!("{}{}", key_hex, salt_hex); + if !results.iter().any(|(k, s)| format!("{}{}", k, s) == key) { + results.push((key_hex, salt_hex)); + } + } + } + } + + i += 1; + } +}