pull/36/merge
Zack 2026-05-19 14:20:31 +08:00 committed by GitHub
commit ad93529dff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 581 additions and 0 deletions

287
config.rs 100644
View File

@ -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,
}
/// 从 <exe_dir>/config.json 或 $HOME/.wx-cli/config.json 加载配置
pub fn load_config() -> Result<Config> {
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<PathBuf> {
// 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<PathBuf> {
detect_db_dir_impl()
}
#[cfg(target_os = "macos")]
fn detect_db_dir_impl() -> Option<PathBuf> {
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<PathBuf> = 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<PathBuf> {
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<PathBuf> = 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<PathBuf> {
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<PathBuf> = 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::<usize>() {
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<PathBuf> {
None
}

294
windows.rs 100644
View File

@ -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<Vec<KeyEntry>> {
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<String, Vec<String>> =
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<Vec<(String, String)>> {
let mut all_keys: Vec<(String, String)> = Vec::new();
let mut seen: std::collections::HashSet<String> = 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::<MEMORY_BASIC_INFORMATION>(),
)
};
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<usize> {
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.pysalt 位于 hex 字符串的末尾 32 字节
/// WCDB 存储格式: x'<64hex_key><32hex_salt>' 或 x'<longer_hex>'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;
}
}