From 4c514fbbf6451d6425e66a18ab97b0c32175bcb2 Mon Sep 17 00:00:00 2001 From: RoseCamel Date: Sun, 14 Jun 2026 20:22:16 +0800 Subject: [PATCH] feat(image_key): implement Linux V2 AES key derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-path strategy, symmetric with macOS implementation: Primary path — process memory extraction: - Auto-detect WeChat PID via /proc//comm - Scan readable memory regions for base64-encoded UIN in 'uin=' URL parameters - Derive AES key: md5(str(uin) + normalize(wxid)).hex()[:16] - Derive XOR key: uin & 0xff - Verify against V2 template ciphertexts from attach directory Fallback — brute-force enumeration: - Extract 4-hex-char wxid suffix from db_dir path - Option 1 (with XOR hint): enumerate 2^24 space using uin & 0xff == xor_key + md5 suffix match, full AES template verify - Option 2 (no XOR hint): enumerate 10000..50_000_000 range, filter by md5 suffix, verify AES key against templates Reuses existing shared infrastructure: - verify_aes_key(), find_v2_template_ciphertexts(), derive_xor_key_from_v2_dat() - normalize_wxid(), wxid_from_db_dir(), same_wxid() 8 tests pass including known-value key derivation verification. Co-Authored-By: Claude --- src/attachment/image_key/linux.rs | 437 +++++++++++++++++++++++++++++- src/attachment/image_key/mod.rs | 2 +- 2 files changed, 433 insertions(+), 6 deletions(-) diff --git a/src/attachment/image_key/linux.rs b/src/attachment/image_key/linux.rs index 4100ab2..134f4ab 100644 --- a/src/attachment/image_key/linux.rs +++ b/src/attachment/image_key/linux.rs @@ -1,11 +1,438 @@ -use anyhow::{bail, Result}; +//! Linux V2 image AES key 提取。 +//! +//! 主路径:从微信进程内存 `/proc//mem` 提取 base64 编码的 UIN, +//! 然后 `md5(str(uin) + normalize(wxid)).hex()[:16]` 派生 AES key。 +//! +//! fallback:若无法读取进程内存(权限不足),通过 `md5(str(uin))[:4] == wxid_suffix` +//! 暴力枚举 UIN(搜索空间最大 ~5×10^7),找到后用 V2 模板反验 AES key。 +//! +//! XOR key 始终为 `uin & 0xff`,与 macOS/Windows 一致。 -use super::{ImageKeyMaterial, ImageKeyProvider}; +use anyhow::{bail, Context, Result}; +use std::collections::HashMap; +use std::fs; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; -pub struct LinuxImageKeyProvider; +use crate::config; + +use super::{ + attach_root_for_db_dir, configured_db_dir_for_wxid, derive_xor_key_from_v2_dat, + find_v2_template_ciphertexts, normalize_wxid, verify_aes_key, wxid_from_db_dir, + ImageKeyMaterial, ImageKeyProvider, +}; + +pub struct LinuxImageKeyProvider { + configured_db_dir: Result, + cache: Mutex>, +} + +impl LinuxImageKeyProvider { + pub fn from_current_config() -> Self { + let configured_db_dir = config::load_config() + .map(|cfg| cfg.db_dir) + .map_err(|err| err.to_string()); + Self { + configured_db_dir, + cache: Mutex::new(HashMap::new()), + } + } +} impl ImageKeyProvider for LinuxImageKeyProvider { - fn get_key(&self, _wxid: &str) -> Result { - bail!("Linux V2 图片 key 当前未实现;请先用 legacy/V1 图片或在 README 中标注 unsupported") + fn get_key(&self, wxid: &str) -> Result { + let cache_key = normalize_wxid(wxid); + if let Some(found) = self.cache.lock().unwrap().get(&cache_key).copied() { + return Ok(found); + } + + let configured_db_dir = self + .configured_db_dir + .as_ref() + .map_err(|err| anyhow::anyhow!("读取 config.db_dir 失败: {}", err))?; + let db_dir = configured_db_dir_for_wxid(configured_db_dir, wxid); + let attach_dir = attach_root_for_db_dir(&db_dir); + let key = derive_key_for_paths(&db_dir, &attach_dir)?; + self.cache.lock().unwrap().insert(cache_key, key); + Ok(key) + } +} + +fn derive_key_for_paths(db_dir: &Path, attach_dir: &Path) -> Result { + let templates = find_v2_template_ciphertexts(attach_dir, 3, 64)?; + if templates.is_empty() { + bail!("在 {} 下找不到 V2 模板文件", attach_dir.display()); + } + + // 1) 尝试从进程内存提取 UIN(主路径) + if let Some(uin) = find_uin_from_process_memory() { + let (wxid_full, wxid_norm, _suffix) = extract_wxid_parts(db_dir) + .context("db_dir 不含可用于密钥派生的 wxid")?; + + for wxid_candidate in preferred_wxid_candidates(&wxid_full, &wxid_norm) { + let candidate = + derive_image_key_material(uin, wxid_candidate); + if verify_aes_key(&candidate.aes_key, &templates) { + return Ok(candidate); + } + } + } + + // 2) Fallback: 暴力枚举 UIN + let (wxid_full, wxid_norm, suffix) = extract_wxid_parts(db_dir) + .context("db_dir 不含可用于 fallback 的 wxid 4 位后缀")?; + + // XOR key: 优先从 .dat 尾部投票推导(可靠),失败时用枚举 + let xor_key_hint = derive_xor_key_from_v2_dat(attach_dir, 10, 3)? + .map(|(key, _votes, _total)| key); + + for wxid_candidate in preferred_wxid_candidates(&wxid_full, &wxid_norm) { + // 若已有 xor_key 提示,枚举时可压到 2^24 + if let Some(xor_key) = xor_key_hint { + if let Some(aes_key) = + bruteforce_aes_key_with_xor(xor_key, &suffix, wxid_candidate, &templates)? + { + return Ok(ImageKeyMaterial { aes_key, xor_key }); + } + } + + // 否则全范围枚举 UIN(最多 5×10^7),利用 AES 模板反验 + if let Some(result) = + bruteforce_aes_key_full(&suffix, wxid_candidate, &templates)? + { + return Ok(result); + } + } + + bail!("Linux V2 图片 key 派生失败:无法从进程内存提取 UIN,且暴力枚举未命中") +} + +fn derive_image_key_material(uin: u32, wxid: &str) -> ImageKeyMaterial { + let xor_key = (uin & 0xFF) as u8; + let digest = format!("{:x}", md5::compute(format!("{}{}", uin, wxid))); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&digest.as_bytes()[..16]); + ImageKeyMaterial { aes_key, xor_key } +} + +// ============ UIN 从进程内存提取 ============ + +/// WeChat Linux 进程名 +const WECHAT_COMM: &str = "wechat"; + +/// 在 `/proc//mem` 中搜索 base64 编码的 UIN。 +/// UIN 出现在 URL 参数 `uin=` 中,base64 值解码后为纯数字。 +fn find_uin_from_process_memory() -> Option { + let pid = find_wechat_pid()?; + + let maps = fs::read_to_string(format!("/proc/{}/maps", pid)).ok()?; + let mem_path = format!("/proc/{}/mem", pid); + + let mut mem = + fs::OpenOptions::new().read(true).open(&mem_path).ok()?; + + for line in maps.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 || !parts[1].contains('r') { + continue; + } + let range: Vec<&str> = parts[0].split('-').collect(); + if range.len() != 2 { + continue; + } + let start = u64::from_str_radix(range[0], 16).ok()?; + let end = u64::from_str_radix(range[1], 16).ok()?; + let size = (end - start) as usize; + if size == 0 || size > 100 * 1024 * 1024 { + continue; + } + + // 只读前 2MB 加速(UIN 通常在数据段而非巨型堆) + let read_size = size.min(2 * 1024 * 1024); + let mut buf = vec![0u8; read_size]; + if mem.seek(SeekFrom::Start(start)).is_err() { + continue; + } + if mem.read_exact(&mut buf).is_err() { + continue; + } + + if let Some(uin) = scan_for_uin(&buf) { + return Some(uin); + } + } + None +} + +fn find_wechat_pid() -> Option { + for entry in fs::read_dir("/proc").ok()? { + let entry = entry.ok()?; + let name = entry.file_name(); + let pid_str = name.to_str()?; + if !pid_str.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let comm_path = entry.path().join("comm"); + if let Ok(comm) = fs::read_to_string(&comm_path) { + if comm.trim().eq_ignore_ascii_case(WECHAT_COMM) { + return pid_str.parse::().ok(); + } + } + } + None +} + +/// 在内存 buffer 中搜索 `uin=` 模式。 +fn scan_for_uin(buf: &[u8]) -> Option { + // base64 字符集: [A-Za-z0-9+/=] + let mut pos = 0; + while pos + 10 < buf.len() { + // 搜索 "uin=" 文本 + let needle_pos = buf[pos..].windows(4).position(|w| w == b"uin=")?; + let abs_pos = pos + needle_pos + 4; + + // 收集 base64 字符(8-16 字节) + let mut b64_end = abs_pos; + while b64_end < buf.len() && b64_end - abs_pos < 16 { + let b = buf[b64_end]; + if b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=' { + b64_end += 1; + } else { + break; + } + } + let b64_len = b64_end - abs_pos; + if b64_len >= 8 && b64_len <= 16 { + let b64_str = std::str::from_utf8(&buf[abs_pos..b64_end]).ok()?; + // 尝试解码 + use base64::Engine as _; + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64_str) { + if let Ok(uin_str) = std::str::from_utf8(&decoded) { + if uin_str.bytes().all(|b| b.is_ascii_digit()) { + if let Ok(uin) = uin_str.parse::() { + if uin > 10000 { + return Some(uin); + } + } + } + } + } + } + pos = abs_pos + 1; + } + None +} + +// ============ wxid 提取 ============ + +fn extract_wxid_parts(db_dir: &Path) -> Option<(String, String, String)> { + let raw = wxid_from_db_dir(db_dir)?; + let idx = raw.rfind('_')?; + let suffix = &raw[idx + 1..]; + if suffix.len() != 4 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + Some(( + raw.clone(), + normalize_wxid(&raw), + suffix.to_ascii_lowercase(), + )) +} + +fn preferred_wxid_candidates<'a>(raw: &'a str, normalized: &'a str) -> Vec<&'a str> { + if raw == normalized { + vec![raw] + } else { + vec![normalized, raw] + } +} + +// ============ UIN 暴力枚举 ============ + +/// 有 XOR key 提示:枚举空间压到 2^24。 +/// `uin & 0xff == xor_key` 且 `md5(str(uin)).hex()[:4] == suffix`。 +fn bruteforce_aes_key_with_xor( + xor_key: u8, + suffix_hex: &str, + wxid: &str, + templates: &[[u8; 16]], +) -> Result> { + let suffix = hex_prefix_to_bytes(suffix_hex)?; + let workers = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1) + .max(1); + let total = 1u32 << 24; // 搜索 2^24 空间 + let chunk = total / workers as u32; + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + + std::thread::scope(|scope| { + for idx in 0..workers { + let start = idx as u32 * chunk; + let end = if idx + 1 == workers { + total + } else { + (idx as u32 + 1) * chunk + }; + let stop = Arc::clone(&stop); + let tx = tx.clone(); + let templates = Arc::new(templates.to_vec()); + let wxid = Arc::new(wxid.to_string()); + + scope.spawn(move || { + for upper in start..end { + if stop.load(Ordering::Relaxed) { + break; + } + let uin = (upper << 8) | xor_key as u32; + let uin_ascii = uin.to_string(); + let digest = md5::compute(uin_ascii.as_bytes()); + if digest.0[0] != suffix[0] || digest.0[1] != suffix[1] { + continue; + } + + let mut input = Vec::with_capacity(uin_ascii.len() + wxid.len()); + input.extend_from_slice(uin_ascii.as_bytes()); + input.extend_from_slice(wxid.as_bytes()); + let aes_hex = format!("{:x}", md5::compute(input)); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&aes_hex.as_bytes()[..16]); + if verify_aes_key(&aes_key, &templates) { + stop.store(true, Ordering::Relaxed); + let _ = tx.send(aes_key); + break; + } + } + }); + } + }); + drop(tx); + Ok(rx.try_iter().next()) +} + +/// 无 XOR key 提示:全范围枚举 UIN (10000 ~ 50_000_000), +/// 同时返回推导出的 ImageKeyMaterial(含 xor_key = uin & 0xff)。 +fn bruteforce_aes_key_full( + suffix_hex: &str, + wxid: &str, + templates: &[[u8; 16]], +) -> Result> { + let suffix = hex_prefix_to_bytes(suffix_hex)?; + let workers = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1) + .max(1); + let min_uin: u32 = 10000; + let max_uin: u32 = 50_000_000; + let total = max_uin - min_uin; + let chunk = total / workers as u32; + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + + std::thread::scope(|scope| { + for idx in 0..workers { + let start = min_uin + idx as u32 * chunk; + let end = if idx + 1 == workers { + max_uin + } else { + min_uin + (idx as u32 + 1) * chunk + }; + let stop = Arc::clone(&stop); + let tx = tx.clone(); + let templates = Arc::new(templates.to_vec()); + let wxid = Arc::new(wxid.to_string()); + + scope.spawn(move || { + for uin in start..end { + if stop.load(Ordering::Relaxed) { + break; + } + let digest = md5::compute(uin.to_string().as_bytes()); + if digest.0[0] != suffix[0] || digest.0[1] != suffix[1] { + continue; + } + + let mut input = + Vec::with_capacity(uin.to_string().len() + wxid.len()); + input.extend_from_slice(uin.to_string().as_bytes()); + input.extend_from_slice(wxid.as_bytes()); + let aes_hex = format!("{:x}", md5::compute(input)); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&aes_hex.as_bytes()[..16]); + if verify_aes_key(&aes_key, &templates) { + stop.store(true, Ordering::Relaxed); + let _ = tx.send(ImageKeyMaterial { + aes_key, + xor_key: (uin & 0xFF) as u8, + }); + break; + } + } + }); + } + }); + drop(tx); + Ok(rx.try_iter().next()) +} + +fn hex_prefix_to_bytes(hex: &str) -> Result<[u8; 2]> { + if hex.len() != 4 { + bail!("wxid suffix 不是 4 位 hex: {}", hex); + } + let hi = u8::from_str_radix(&hex[..2], 16)?; + let lo = u8::from_str_radix(&hex[2..], 16)?; + Ok([hi, lo]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_wxid_removes_suffix() { + assert_eq!(normalize_wxid("demo_user_a1b2"), "demo_user"); + assert_eq!(normalize_wxid("wxid_abc_def"), "wxid_abc"); + assert_eq!(normalize_wxid("plain"), "plain"); + } + + #[test] + fn preferred_candidates_returns_normalized_first() { + let raw = "demo_user_a1b2"; + let norm = "demo_user"; + let cands = preferred_wxid_candidates(raw, norm); + assert_eq!(cands, vec!["demo_user", "demo_user_a1b2"]); + } + + #[test] + fn hex_prefix_conversion() { + assert_eq!(hex_prefix_to_bytes("a1b2").unwrap(), [0xa1, 0xb2]); + assert!(hex_prefix_to_bytes("xyz").is_err()); + } + + #[test] + fn scan_uin_finds_base64_encoded_uin() { + // UIN=12345678 → base64("12345678") = "MTIzNDU2Nzg=" + let uin_str = "12345678"; + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(uin_str); + let pattern = format!("uin={}", b64); + let mut buf = vec![0u8; 64]; + buf[10..10 + pattern.len()].copy_from_slice(pattern.as_bytes()); + let found = scan_for_uin(&buf); + assert_eq!(found, Some(12345678)); + } + + #[test] + fn derive_key_material_matches_known_value() { + // uin=12345678, wxid=demo_user + // md5("12345678demo_user") = 96484838237b075540ab8e287e903c45 + // aes_key = first 16 hex chars = "96484838237b0755" + let material = derive_image_key_material(12_345_678, "demo_user"); + let actual_hex = std::str::from_utf8(&material.aes_key).unwrap(); + assert_eq!(actual_hex, "96484838237b0755"); + assert_eq!(material.xor_key, 0x4e); // 12345678 & 0xff = 0x4e } } diff --git a/src/attachment/image_key/mod.rs b/src/attachment/image_key/mod.rs index 74eee30..c42355d 100644 --- a/src/attachment/image_key/mod.rs +++ b/src/attachment/image_key/mod.rs @@ -59,7 +59,7 @@ pub fn default_provider() -> Option> { } #[cfg(target_os = "linux")] { - return Some(Box::new(linux::LinuxImageKeyProvider)); + return Some(Box::new(linux::LinuxImageKeyProvider::from_current_config())); } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] {