mirror of https://github.com/jackwener/wx-cli.git
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
parent
08af894594
commit
4c514fbbf6
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")))]
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue