mirror of https://github.com/jackwener/wx-cli.git
feat(attachment): implement V2 image key providers
parent
14fdfde1d3
commit
bf8d0d934a
|
|
@ -0,0 +1,11 @@
|
|||
use anyhow::{bail, Result};
|
||||
|
||||
use super::{ImageKeyMaterial, ImageKeyProvider};
|
||||
|
||||
pub struct LinuxImageKeyProvider;
|
||||
|
||||
impl ImageKeyProvider for LinuxImageKeyProvider {
|
||||
fn get_key(&self, _wxid: &str) -> Result<ImageKeyMaterial> {
|
||||
bail!("Linux V2 图片 key 当前未实现;请先用 legacy/V1 图片或在 README 中标注 unsupported")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,423 @@
|
|||
//! macOS V2 image AES key 提取。
|
||||
//!
|
||||
//! 主路径:从 `~/Library/Containers/com.tencent.xinWeChat/Data/Documents/key_<uin>_*.statistic`
|
||||
//! 文件名拿 uin,然后 `md5(str(uin) + sanitize(wxid)).hex()[:16]` 派生 AES key。
|
||||
//! 主路径:从 `key_<uin>_*.statistic` 文件名拿 uin,然后
|
||||
//! `md5(str(uin) + normalize(wxid)).hex()[:16]` 派生 AES key。
|
||||
//!
|
||||
//! Fallback:枚举 uin 候选 2^24 个(`uint32`,但 wxid 4-byte 前缀只看后 24 bit),
|
||||
//! 通过 `md5(str(uin))[:4] == wxid 后 4 字节` 匹配。
|
||||
//! 上游 `find_image_key_macos.py` 实测 1-2s 完成。
|
||||
//!
|
||||
//! ⚠️ codex 落实现。
|
||||
//! fallback:通过 `md5(str(uin))[:4] == wxid_suffix` + `uin & 0xff == xor_key`
|
||||
//! 把搜索空间压到 2^24,再用 V2 模板反验 AES key。
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
|
||||
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, join_components, normalize_wxid, verify_aes_key, wxid_from_db_dir,
|
||||
ImageKeyMaterial, ImageKeyProvider,
|
||||
};
|
||||
|
||||
pub struct MacosImageKeyProvider {
|
||||
configured_db_dir: Result<PathBuf, String>,
|
||||
cache: Mutex<HashMap<String, ImageKeyMaterial>>,
|
||||
}
|
||||
|
||||
impl MacosImageKeyProvider {
|
||||
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 MacosImageKeyProvider {
|
||||
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());
|
||||
}
|
||||
|
||||
if let Some(found) = find_via_kvcomm(db_dir, &templates)? {
|
||||
return Ok(found);
|
||||
}
|
||||
|
||||
let (wxid_full, wxid_norm, suffix) =
|
||||
extract_wxid_parts(db_dir).context("db_dir 不含可用于 fallback 的 wxid 4 位后缀")?;
|
||||
let (xor_key, _votes, _total) = derive_xor_key_from_v2_dat(attach_dir, 10, 3)?
|
||||
.context("V2 .dat 样本不足,无法投票反推 xor_key")?;
|
||||
|
||||
for wxid in preferred_wxid_candidates(&wxid_full, &wxid_norm) {
|
||||
if let Some(aes_key) = bruteforce_aes_key(xor_key, &suffix, wxid, &templates)? {
|
||||
return Ok(ImageKeyMaterial { aes_key, xor_key });
|
||||
}
|
||||
}
|
||||
|
||||
bail!("macOS V2 图片 key 派生失败")
|
||||
}
|
||||
|
||||
fn find_via_kvcomm(db_dir: &Path, templates: &[[u8; 16]]) -> Result<Option<ImageKeyMaterial>> {
|
||||
let Some(kvcomm_dir) = find_existing_kvcomm_dir(db_dir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let codes = collect_kvcomm_codes(&kvcomm_dir)?;
|
||||
if codes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let wxids = collect_wxid_candidates(db_dir);
|
||||
if wxids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for wxid in wxids {
|
||||
for code in &codes {
|
||||
let candidate = derive_image_key_material(*code, &wxid);
|
||||
if verify_aes_key(&candidate.aes_key, templates) {
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn derive_image_key_material(code: u32, wxid: &str) -> ImageKeyMaterial {
|
||||
let xor_key = (code & 0xFF) as u8;
|
||||
let digest = format!("{:x}", md5::compute(format!("{}{}", code, wxid)));
|
||||
let mut aes_key = [0u8; 16];
|
||||
aes_key.copy_from_slice(&digest.as_bytes()[..16]);
|
||||
ImageKeyMaterial { aes_key, xor_key }
|
||||
}
|
||||
|
||||
fn collect_wxid_candidates(db_dir: &Path) -> Vec<String> {
|
||||
let Some(raw) = wxid_from_db_dir(db_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = vec![raw.clone()];
|
||||
let normalized = normalize_wxid(&raw);
|
||||
if normalized != raw {
|
||||
out.push(normalized);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_kvcomm_dir_candidates(db_dir: &Path) -> Vec<PathBuf> {
|
||||
let parts: Vec<String> = db_dir
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(idx) = parts.iter().position(|part| part == "xwechat_files") {
|
||||
let documents_root = join_components(&parts[..idx]);
|
||||
candidates.push(documents_root.join("app_data/net/kvcomm"));
|
||||
candidates.push(documents_root.join("xwechat/net/kvcomm"));
|
||||
if idx >= 1 {
|
||||
let container_root = join_components(&parts[..idx - 1]);
|
||||
candidates.push(
|
||||
container_root
|
||||
.join("Library/Application Support/com.tencent.xinWeChat/xwechat/net/kvcomm"),
|
||||
);
|
||||
candidates.push(
|
||||
container_root.join("Library/Application Support/com.tencent.xinWeChat/net/kvcomm"),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.push(
|
||||
home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm"),
|
||||
);
|
||||
}
|
||||
|
||||
let mut dedup = Vec::new();
|
||||
for candidate in candidates {
|
||||
if !dedup.contains(&candidate) {
|
||||
dedup.push(candidate);
|
||||
}
|
||||
}
|
||||
dedup
|
||||
}
|
||||
|
||||
fn find_existing_kvcomm_dir(db_dir: &Path) -> Option<PathBuf> {
|
||||
derive_kvcomm_dir_candidates(db_dir)
|
||||
.into_iter()
|
||||
.find(|path| path.is_dir())
|
||||
}
|
||||
|
||||
fn collect_kvcomm_codes(kvcomm_dir: &Path) -> Result<Vec<u32>> {
|
||||
let mut codes = std::collections::BTreeSet::new();
|
||||
for entry in std::fs::read_dir(kvcomm_dir)? {
|
||||
let entry = entry?;
|
||||
let Some(name) = entry.file_name().to_str().map(|value| value.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(rest) = name.strip_prefix("key_") else {
|
||||
continue;
|
||||
};
|
||||
let Some((code, _)) = rest.split_once('_') else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(code) = code.parse::<u32>() {
|
||||
codes.insert(code);
|
||||
}
|
||||
}
|
||||
Ok(codes.into_iter().collect())
|
||||
}
|
||||
|
||||
fn bruteforce_aes_key(
|
||||
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;
|
||||
let chunk = total / workers as u32;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let wxid = Arc::new(wxid.as_bytes().to_vec());
|
||||
let templates = Arc::new(templates.to_vec());
|
||||
|
||||
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 wxid = Arc::clone(&wxid);
|
||||
let templates = Arc::clone(&templates);
|
||||
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);
|
||||
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())
|
||||
}
|
||||
|
||||
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::{derive_key_for_paths, find_existing_kvcomm_dir};
|
||||
use super::collect_wxid_candidates;
|
||||
use crate::attachment::image_key::normalize_wxid;
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn temp_dir(label: &str) -> std::path::PathBuf {
|
||||
let mut dir = std::env::temp_dir();
|
||||
dir.push(format!(
|
||||
"wx-cli-image-key-macos-{}-{:?}",
|
||||
label,
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_v2_template(path: &Path, aes_key: &[u8; 16], xor_key: u8, plaintext: &[u8; 16]) {
|
||||
let cipher = Aes128::new(aes_key.into());
|
||||
let mut block = GenericArray::clone_from_slice(plaintext);
|
||||
cipher.encrypt_block(&mut block);
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&crate::attachment::decoder::V2_MAGIC);
|
||||
data.extend_from_slice(&0u32.to_le_bytes());
|
||||
data.extend_from_slice(&0u32.to_le_bytes());
|
||||
data.push(0);
|
||||
data.extend_from_slice(&block);
|
||||
data.push(0);
|
||||
data.push(0xD9 ^ xor_key);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, data).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_wxid_matches_expected_shapes() {
|
||||
assert_eq!(normalize_wxid("wxid_abc_def"), "wxid_abc");
|
||||
assert_eq!(normalize_wxid("your_wxid_a1b2"), "your_wxid");
|
||||
assert_eq!(normalize_wxid("plain"), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kvcomm_path_detection_works() {
|
||||
let dir = temp_dir("kvcomm");
|
||||
let db_dir = dir.join(
|
||||
"Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage",
|
||||
);
|
||||
let kvcomm = dir.join(
|
||||
"Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm",
|
||||
);
|
||||
fs::create_dir_all(&db_dir).unwrap();
|
||||
fs::create_dir_all(&kvcomm).unwrap();
|
||||
assert_eq!(find_existing_kvcomm_dir(&db_dir), Some(kvcomm));
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_key_via_kvcomm() {
|
||||
let dir = temp_dir("via-kvcomm");
|
||||
let db_dir = dir.join(
|
||||
"Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage",
|
||||
);
|
||||
let attach = dir.join(
|
||||
"Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/msg/attach/chat/2026-05/Img",
|
||||
);
|
||||
let kvcomm = dir.join(
|
||||
"Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm",
|
||||
);
|
||||
fs::create_dir_all(&db_dir).unwrap();
|
||||
fs::create_dir_all(&kvcomm).unwrap();
|
||||
fs::write(kvcomm.join("key_42_x.statistic"), b"").unwrap();
|
||||
|
||||
let digest = format!("{:x}", md5::compute("42your_wxid"));
|
||||
let mut aes_key = [0u8; 16];
|
||||
aes_key.copy_from_slice(&digest.as_bytes()[..16]);
|
||||
write_v2_template(
|
||||
&attach.join("sample_t.dat"),
|
||||
&aes_key,
|
||||
42,
|
||||
b"\xFF\xD8\xFFtemplate-001!",
|
||||
);
|
||||
|
||||
let derived = derive_key_for_paths(&db_dir, db_dir.parent().unwrap().join("msg/attach").as_path())
|
||||
.unwrap();
|
||||
assert_eq!(derived.aes_key, aes_key);
|
||||
assert_eq!(derived.xor_key, 42);
|
||||
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_key_via_bruteforce_fallback() {
|
||||
let dir = temp_dir("via-fallback");
|
||||
let suffix = format!("{:x}", md5::compute("42"))
|
||||
.chars()
|
||||
.take(4)
|
||||
.collect::<String>();
|
||||
let raw_wxid = format!("mywxid_{}", suffix);
|
||||
let db_dir = dir.join(format!(
|
||||
"Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{}/db_storage",
|
||||
raw_wxid
|
||||
));
|
||||
let attach = dir.join(format!(
|
||||
"Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{}/msg/attach/chat/2026-05/Img",
|
||||
raw_wxid
|
||||
));
|
||||
fs::create_dir_all(&db_dir).unwrap();
|
||||
|
||||
let digest = format!("{:x}", md5::compute("42mywxid"));
|
||||
let mut aes_key = [0u8; 16];
|
||||
aes_key.copy_from_slice(&digest.as_bytes()[..16]);
|
||||
for idx in 0..3 {
|
||||
write_v2_template(
|
||||
&attach.join(format!("sample{}_t.dat", idx)),
|
||||
&aes_key,
|
||||
42,
|
||||
b"\xFF\xD8\xFFtemplate-001!",
|
||||
);
|
||||
}
|
||||
|
||||
let derived = derive_key_for_paths(&db_dir, db_dir.parent().unwrap().join("msg/attach").as_path())
|
||||
.unwrap();
|
||||
assert_eq!(derived.aes_key, aes_key);
|
||||
assert_eq!(derived.xor_key, 42);
|
||||
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_raw_and_normalized_wxid() {
|
||||
let dir = temp_dir("wxid");
|
||||
let db_dir = dir.join(
|
||||
"Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage",
|
||||
);
|
||||
fs::create_dir_all(&db_dir).unwrap();
|
||||
let wxids = collect_wxid_candidates(&db_dir);
|
||||
assert_eq!(wxids, vec!["your_wxid_a1b2".to_string(), "your_wxid".to_string()]);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
//! V2 image AES key 提取 — 平台相关。
|
||||
//!
|
||||
//! ⚠️ 此模块由 codex 落地。本文件只放公共 trait + 平台 dispatch 占位。
|
||||
//!
|
||||
//! 路径:
|
||||
//! - macOS:磁盘派生(`key_<uin>_*.statistic` 文件名拿 uin → `md5(str(uin) + wxid)[:16]`)
|
||||
//! + brute-force fallback(`md5(str(uin))[:4] == wxid_suffix` 枚举 2^24)
|
||||
|
|
@ -9,26 +7,336 @@
|
|||
//! 反验(`find_image_key.py` / `find_image_key.c` 已写实)
|
||||
//! - Linux:上游空白;当前不实现,遇到 V2 .dat 返回 unsupported 错误
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
|
||||
use anyhow::Result;
|
||||
use regex::bytes::Regex;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::attachment::decoder::{detect_image_format, V2_MAGIC};
|
||||
|
||||
/// V2 图片真正需要的是两份材料:
|
||||
/// - 16 字节 ASCII AES key
|
||||
/// - XOR key(macOS 上来自 uin & 0xff,不是总能硬编码成 0x88)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ImageKeyMaterial {
|
||||
pub aes_key: [u8; 16],
|
||||
pub xor_key: u8,
|
||||
}
|
||||
|
||||
/// 单个 wxid 的 V2 image key 提取接口。
|
||||
///
|
||||
/// 实现者负责跨调用缓存(一台机器上同一 wxid 的 image key 在微信不重启时是稳定的)。
|
||||
/// 实现者负责跨调用缓存(一台机器上同一 wxid 的 image key 在微信不重启时通常稳定)。
|
||||
pub trait ImageKeyProvider {
|
||||
/// 返回当前 wxid 的 16 字节 AES key。失败要带可执行的诊断(例如「macOS 没找到
|
||||
/// kvcomm cache,请确认微信已登录」/「Windows 进程不在跑」)。
|
||||
fn get_aes_key(&self, wxid: &str) -> Result<[u8; 16]>;
|
||||
fn get_key(&self, wxid: &str) -> Result<ImageKeyMaterial>;
|
||||
|
||||
fn get_aes_key(&self, wxid: &str) -> Result<[u8; 16]> {
|
||||
Ok(self.get_key(wxid)?.aes_key)
|
||||
}
|
||||
|
||||
fn get_xor_key(&self, wxid: &str) -> Result<u8> {
|
||||
Ok(self.get_key(wxid)?.xor_key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台默认实现(codex 后续填)。
|
||||
///
|
||||
/// 调用方目前可以直接传 `None`,让 resolver 在遇到 V2 .dat 时报「image key 未提取」错。
|
||||
/// 平台默认实现。
|
||||
pub fn default_provider() -> Option<Box<dyn ImageKeyProvider + Send + Sync>> {
|
||||
// TODO(codex): 按 cfg(target_os) 返回 macOS / Windows / 不支持
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return Some(Box::new(macos::MacosImageKeyProvider::from_current_config()));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return Some(Box::new(windows::WindowsImageKeyProvider::from_current_config()));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return Some(Box::new(linux::LinuxImageKeyProvider));
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn configured_db_dir_for_wxid(configured_db_dir: &Path, requested_wxid: &str) -> PathBuf {
|
||||
if requested_wxid.trim().is_empty() {
|
||||
return configured_db_dir.to_path_buf();
|
||||
}
|
||||
|
||||
let configured_leaf = wxid_from_db_dir(configured_db_dir);
|
||||
if let Some(leaf) = configured_leaf.as_deref() {
|
||||
if same_wxid(leaf, requested_wxid) {
|
||||
return configured_db_dir.to_path_buf();
|
||||
}
|
||||
}
|
||||
|
||||
xwechat_files_root(configured_db_dir)
|
||||
.map(|root| root.join(requested_wxid).join("db_storage"))
|
||||
.unwrap_or_else(|| configured_db_dir.to_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) fn wxid_from_db_dir(db_dir: &Path) -> Option<String> {
|
||||
let mut components = db_dir
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().into_owned());
|
||||
while let Some(component) = components.next() {
|
||||
if component == "xwechat_files" {
|
||||
return components.next();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn xwechat_files_root(db_dir: &Path) -> Option<PathBuf> {
|
||||
let parts: Vec<_> = db_dir
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let idx = parts.iter().position(|part| part == "xwechat_files")?;
|
||||
Some(join_components(&parts[..=idx]))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_wxid(raw: &str) -> String {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if let Some(stripped) = raw.strip_prefix("wxid_") {
|
||||
let head = stripped.split('_').next().unwrap_or(stripped);
|
||||
return format!("wxid_{}", head);
|
||||
}
|
||||
if let Some((base, suffix)) = raw.rsplit_once('_') {
|
||||
if suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return base.to_string();
|
||||
}
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn same_wxid(a: &str, b: &str) -> bool {
|
||||
a == b || normalize_wxid(a) == normalize_wxid(b)
|
||||
}
|
||||
|
||||
pub(crate) fn join_components(parts: &[String]) -> PathBuf {
|
||||
let mut out = if parts.first().map(|part| part.is_empty()).unwrap_or(false) {
|
||||
PathBuf::from("/")
|
||||
} else {
|
||||
PathBuf::new()
|
||||
};
|
||||
for part in parts {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn attach_root_for_db_dir(db_dir: &Path) -> PathBuf {
|
||||
db_dir
|
||||
.parent()
|
||||
.map(|base| base.join("msg").join("attach"))
|
||||
.unwrap_or_else(|| PathBuf::from("msg/attach"))
|
||||
}
|
||||
|
||||
pub(crate) fn find_v2_template_ciphertexts(
|
||||
attach_dir: &Path,
|
||||
max_templates: usize,
|
||||
max_files: usize,
|
||||
) -> Result<Vec<[u8; 16]>> {
|
||||
if !attach_dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut out = collect_templates_with_suffix(attach_dir, "_t.dat", max_templates, max_files)?;
|
||||
if out.is_empty() {
|
||||
out = collect_templates_with_suffix(attach_dir, ".dat", max_templates, max_files)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn derive_xor_key_from_v2_dat(
|
||||
attach_dir: &Path,
|
||||
sample: usize,
|
||||
min_samples: usize,
|
||||
) -> Result<Option<(u8, usize, usize)>> {
|
||||
if !attach_dir.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut votes = Vec::new();
|
||||
visit_files(attach_dir, &mut |path| -> Result<bool> {
|
||||
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !name.ends_with(".dat") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let meta = fs::metadata(path)?;
|
||||
if meta.len() < 0x20 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let bytes = fs::read(path)?;
|
||||
if bytes.starts_with(&V2_MAGIC) {
|
||||
let last = *bytes.last().unwrap();
|
||||
votes.push(last ^ 0xD9);
|
||||
if votes.len() >= sample {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
})?;
|
||||
|
||||
if votes.len() < min_samples {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut counts = [0usize; 256];
|
||||
for vote in &votes {
|
||||
counts[*vote as usize] += 1;
|
||||
}
|
||||
let (xor_key, top_votes) = counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, count)| *count)
|
||||
.map(|(idx, count)| (idx as u8, *count))
|
||||
.expect("votes 非空");
|
||||
Ok(Some((xor_key, top_votes, votes.len())))
|
||||
}
|
||||
|
||||
pub(crate) fn verify_aes_key(aes_key: &[u8; 16], templates: &[[u8; 16]]) -> bool {
|
||||
!templates.is_empty()
|
||||
&& templates
|
||||
.iter()
|
||||
.all(|template| decrypt_template_block(aes_key, template).is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn ascii_alnum_candidates<'a>(buf: &'a [u8], len: usize) -> Vec<&'a [u8]> {
|
||||
let re = match len {
|
||||
16 => regex16(),
|
||||
32 => regex32(),
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
re.find_iter(buf)
|
||||
.filter_map(|matched| {
|
||||
let start = matched.start();
|
||||
let end = matched.end();
|
||||
let left_ok = start == 0 || !buf[start - 1].is_ascii_alphanumeric();
|
||||
let right_ok = end == buf.len() || !buf[end].is_ascii_alphanumeric();
|
||||
(left_ok && right_ok).then_some(&buf[start..end])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_templates_with_suffix(
|
||||
dir: &Path,
|
||||
suffix: &str,
|
||||
max_templates: usize,
|
||||
max_files: usize,
|
||||
) -> Result<Vec<[u8; 16]>> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut examined = 0usize;
|
||||
visit_files(dir, &mut |path| -> Result<bool> {
|
||||
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !name.ends_with(suffix) {
|
||||
return Ok(false);
|
||||
}
|
||||
examined += 1;
|
||||
let bytes = fs::read(path)?;
|
||||
if bytes.len() >= 0x1F && bytes.starts_with(&V2_MAGIC) {
|
||||
let template: [u8; 16] = bytes[0x0F..0x1F].try_into().unwrap();
|
||||
if seen.insert(template) {
|
||||
out.push(template);
|
||||
if out.len() >= max_templates {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(examined >= max_files && !out.is_empty())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn visit_files<F>(dir: &Path, f: &mut F) -> Result<bool>
|
||||
where
|
||||
F: FnMut(&Path) -> Result<bool>,
|
||||
{
|
||||
let mut entries: Vec<PathBuf> = fs::read_dir(dir)?
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.collect();
|
||||
entries.sort();
|
||||
|
||||
for path in entries {
|
||||
if path.is_dir() {
|
||||
if visit_files(&path, f)? {
|
||||
return Ok(true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if f(&path)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn decrypt_template_block(aes_key: &[u8; 16], ciphertext: &[u8; 16]) -> Option<&'static str> {
|
||||
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, KeyInit};
|
||||
|
||||
let cipher = aes::Aes128::new(aes_key.into());
|
||||
let mut block = GenericArray::clone_from_slice(ciphertext);
|
||||
cipher.decrypt_block(&mut block);
|
||||
let block: [u8; 16] = block.as_slice().try_into().ok()?;
|
||||
let format = detect_image_format(&block);
|
||||
(format != "bin").then_some(format)
|
||||
}
|
||||
|
||||
fn regex16() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"[A-Za-z0-9]{16}").unwrap())
|
||||
}
|
||||
|
||||
fn regex32() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"[A-Za-z0-9]{32}").unwrap())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ascii_alnum_candidates, normalize_wxid, same_wxid};
|
||||
|
||||
#[test]
|
||||
fn regex_candidates_respect_boundaries() {
|
||||
let buf = b"xx 0123456789ABCDef yy";
|
||||
let hits = ascii_alnum_candidates(buf, 16);
|
||||
assert_eq!(hits, vec![&buf[3..19]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_candidates_ignore_embedded_runs() {
|
||||
let buf = b"x0123456789ABCDefz";
|
||||
assert!(ascii_alnum_candidates(buf, 16).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wxid_normalization_matches_expected_forms() {
|
||||
assert_eq!(normalize_wxid("wxid_abc_def"), "wxid_abc");
|
||||
assert_eq!(normalize_wxid("your_wxid_a1b2"), "your_wxid");
|
||||
assert!(same_wxid("your_wxid_a1b2", "your_wxid"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,238 @@
|
|||
//! Windows V2 image AES key 提取。
|
||||
//!
|
||||
//! 扫 `Weixin.exe` 进程内存,匹配模式 `(?<![a-zA-Z0-9])[a-zA-Z0-9]{32}(?![a-zA-Z0-9])`
|
||||
//! 取候选 key,然后用已知 AES ciphertext-block 反验:每个 candidate 用 AES-128-ECB
|
||||
//! 解一段已知 ciphertext,看产物是否落在合理图片 magic 上。
|
||||
//!
|
||||
//! 上游 `find_image_key.py` / `find_image_key.c` 已经把 signature scan + false-positive
|
||||
//! 控制写实,可以直接对照。
|
||||
//!
|
||||
//! ⚠️ codex 落实现。
|
||||
//! 扫 `Weixin.exe` 进程内存,匹配模式 `[A-Za-z0-9]{32}` / `[A-Za-z0-9]{16}`,
|
||||
//! 然后用 V2 模板 AES block 反验,控制 false positive。
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory;
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows::Win32::System::Memory::{
|
||||
VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE,
|
||||
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOCACHE, PAGE_NOACCESS, PAGE_READWRITE,
|
||||
PAGE_WRITECOMBINE, PAGE_WRITECOPY,
|
||||
};
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ};
|
||||
|
||||
use crate::config;
|
||||
|
||||
use super::{
|
||||
ascii_alnum_candidates, attach_root_for_db_dir, configured_db_dir_for_wxid,
|
||||
derive_xor_key_from_v2_dat, find_v2_template_ciphertexts, verify_aes_key, ImageKeyMaterial,
|
||||
ImageKeyProvider,
|
||||
};
|
||||
|
||||
const CHUNK_SIZE: usize = 2 * 1024 * 1024;
|
||||
const MAX_REGION_SIZE: usize = 50 * 1024 * 1024;
|
||||
|
||||
pub struct WindowsImageKeyProvider {
|
||||
configured_db_dir: Result<PathBuf, String>,
|
||||
cache: Mutex<HashMap<String, ImageKeyMaterial>>,
|
||||
}
|
||||
|
||||
impl WindowsImageKeyProvider {
|
||||
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 WindowsImageKeyProvider {
|
||||
fn get_key(&self, wxid: &str) -> Result<ImageKeyMaterial> {
|
||||
let cache_key = wxid.trim().to_string();
|
||||
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(&attach_dir)?;
|
||||
self.cache.lock().unwrap().insert(cache_key, key);
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_key_for_paths(attach_dir: &std::path::Path) -> Result<ImageKeyMaterial> {
|
||||
let templates = find_v2_template_ciphertexts(attach_dir, 3, 64)?;
|
||||
if templates.is_empty() {
|
||||
bail!("在 {} 下找不到 V2 模板文件", attach_dir.display());
|
||||
}
|
||||
let xor_key = derive_xor_key_from_v2_dat(attach_dir, 10, 3)?
|
||||
.map(|(key, _, _)| key)
|
||||
.unwrap_or(0x88);
|
||||
|
||||
let pid = find_wechat_pid().context("找不到 Weixin.exe 进程,请确认微信正在运行")?;
|
||||
let process = unsafe {
|
||||
OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid)
|
||||
.context("OpenProcess 失败,请以管理员权限运行")?
|
||||
};
|
||||
|
||||
let aes_key = scan_memory_for_key(process, &templates);
|
||||
unsafe {
|
||||
let _ = CloseHandle(process);
|
||||
}
|
||||
|
||||
Ok(ImageKeyMaterial {
|
||||
aes_key: aes_key?,
|
||||
xor_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_wechat_pid() -> Option<u32> {
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? };
|
||||
let mut entry = PROCESSENTRY32 {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
unsafe {
|
||||
if Process32First(snapshot, &mut entry).is_err() {
|
||||
let _ = CloseHandle(snapshot);
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
let name =
|
||||
std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8).to_string_lossy();
|
||||
if name.eq_ignore_ascii_case("Weixin.exe") {
|
||||
let pid = entry.th32ProcessID;
|
||||
let _ = CloseHandle(snapshot);
|
||||
return Some(pid);
|
||||
}
|
||||
if Process32Next(snapshot, &mut entry).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = CloseHandle(snapshot);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn scan_memory_for_key(process: HANDLE, templates: &[[u8; 16]]) -> Result<[u8; 16]> {
|
||||
let mut seen = HashSet::<[u8; 16]>::new();
|
||||
let mut address = 0usize;
|
||||
|
||||
loop {
|
||||
let mut mbi = MEMORY_BASIC_INFORMATION::default();
|
||||
let ret = unsafe {
|
||||
VirtualQueryEx(
|
||||
process,
|
||||
Some(address as *const _),
|
||||
&mut mbi,
|
||||
std::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
)
|
||||
};
|
||||
if ret == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let base = mbi.BaseAddress as usize;
|
||||
let size = mbi.RegionSize;
|
||||
if mbi.State == MEM_COMMIT && is_candidate_page(mbi.Protect.0) && size <= MAX_REGION_SIZE {
|
||||
if let Some(aes_key) = scan_region(process, base, size, templates, &mut seen)? {
|
||||
return Ok(aes_key);
|
||||
}
|
||||
}
|
||||
|
||||
address = base.saturating_add(size);
|
||||
if address == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Windows 进程内存里没有找到可验证的 V2 AES key")
|
||||
}
|
||||
|
||||
fn scan_region(
|
||||
process: HANDLE,
|
||||
base: usize,
|
||||
size: usize,
|
||||
templates: &[[u8; 16]],
|
||||
seen: &mut HashSet<[u8; 16]>,
|
||||
) -> Result<Option<[u8; 16]>> {
|
||||
let overlap = 31usize;
|
||||
let mut offset = 0usize;
|
||||
|
||||
while offset < size {
|
||||
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 = 0usize;
|
||||
|
||||
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);
|
||||
if let Some(key) = scan_candidate_buffer(&buf, templates, seen) {
|
||||
return Ok(Some(key));
|
||||
}
|
||||
}
|
||||
|
||||
offset += if chunk_size > overlap {
|
||||
chunk_size - overlap
|
||||
} else {
|
||||
chunk_size
|
||||
};
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn scan_candidate_buffer(
|
||||
buf: &[u8],
|
||||
templates: &[[u8; 16]],
|
||||
seen: &mut HashSet<[u8; 16]>,
|
||||
) -> Option<[u8; 16]> {
|
||||
for candidate in ascii_alnum_candidates(buf, 32) {
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&candidate[..16]);
|
||||
if seen.insert(key) && verify_aes_key(&key, templates) {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
for candidate in ascii_alnum_candidates(buf, 16) {
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(candidate);
|
||||
if seen.insert(key) && verify_aes_key(&key, templates) {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_candidate_page(protect: u32) -> bool {
|
||||
if protect == PAGE_NOACCESS.0 || (protect & PAGE_GUARD.0) != 0 {
|
||||
return false;
|
||||
}
|
||||
let base = protect & !(PAGE_GUARD.0 | PAGE_NOCACHE.0 | PAGE_WRITECOMBINE.0);
|
||||
matches!(
|
||||
base,
|
||||
value if value == PAGE_READWRITE.0
|
||||
|| value == PAGE_WRITECOPY.0
|
||||
|| value == PAGE_EXECUTE_READWRITE.0
|
||||
|| value == PAGE_EXECUTE_WRITECOPY.0
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue