feat(image_key): implement Linux V2 AES key derivation

Two-path strategy, symmetric with macOS implementation:

Primary path — process memory extraction:
- Auto-detect WeChat PID via /proc/<pid>/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 <noreply@anthropic.com>
pull/110/head
RoseCamel 2026-06-14 20:22:16 +08:00
parent 08af894594
commit 4c514fbbf6
2 changed files with 434 additions and 7 deletions

View File

@ -1,11 +1,438 @@
use anyhow::{bail, Result};
//! Linux V2 image AES key 提取。
//!
//! 主路径:从微信进程内存 `/proc/<pid>/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;
impl ImageKeyProvider for LinuxImageKeyProvider {
fn get_key(&self, _wxid: &str) -> Result<ImageKeyMaterial> {
bail!("Linux V2 图片 key 当前未实现;请先用 legacy/V1 图片或在 README 中标注 unsupported")
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<PathBuf, String>,
cache: Mutex<HashMap<String, ImageKeyMaterial>>,
}
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<ImageKeyMaterial> {
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<ImageKeyMaterial> {
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/<pid>/mem` 中搜索 base64 编码的 UIN。
/// UIN 出现在 URL 参数 `uin=<base64>` 中base64 值解码后为纯数字。
fn find_uin_from_process_memory() -> Option<u32> {
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<u32> {
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::<u32>().ok();
}
}
}
None
}
/// 在内存 buffer 中搜索 `uin=<base64>` 模式。
fn scan_for_uin(buf: &[u8]) -> Option<u32> {
// 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::<u32>() {
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<Option<[u8; 16]>> {
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<Option<ImageKeyMaterial>> {
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
}
}

View File

@ -59,7 +59,7 @@ pub fn default_provider() -> Option<Box<dyn ImageKeyProvider + Send + Sync>> {
}
#[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")))]
{