优化图片密钥获取

feat: brute aes key within 2 minutes
This commit is contained in:
xuncha
2026-02-28 13:47:14 +08:00
committed by GitHub
5 changed files with 455 additions and 358 deletions

View File

@@ -3,7 +3,7 @@ import { join, dirname, basename } from 'path'
import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs'
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import crypto from 'crypto'
import { Worker } from 'worker_threads'
import os from 'os'
const execFileAsync = promisify(execFile)
@@ -26,12 +26,9 @@ export class KeyService {
private user32: any = null
private advapi32: any = null
// Kernel32
// Kernel32 (已移除内存扫描相关的 API)
private OpenProcess: any = null
private CloseHandle: any = null
private VirtualQueryEx: any = null
private ReadProcessMemory: any = null
private MEMORY_BASIC_INFORMATION: any = null
private TerminateProcess: any = null
private QueryFullProcessImageNameW: any = null
@@ -62,50 +59,33 @@ export class KeyService {
private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
// 候选路径列表
const candidates: string[] = []
// 1. 显式环境变量 (最高优先级)
if (process.env.WX_KEY_DLL_PATH) {
candidates.push(process.env.WX_KEY_DLL_PATH)
}
if (isPackaged) {
// 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
} else {
// 开发环境
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
}
// 检查并返回第一个存在的路径
for (const path of candidates) {
if (existsSync(path)) {
return path
}
if (existsSync(path)) return path
}
// 如果都没找到,返回最可能的路径以便报错信息有参考
return candidates[0]
}
// 检查路径是否为 UNC 路径或网络路径
private isNetworkPath(path: string): boolean {
// UNC 路径以 \\ 开头
if (path.startsWith('\\\\')) {
return true
}
// 检查是否为网络映射驱动器简化检测A: 表示驱动器)
// 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API
// 但对于大多数 VM 共享场景UNC 路径检测已足够
if (path.startsWith('\\\\')) return true
return false
}
// 将 DLL 复制到本地临时目录
private localizeNetworkDll(originalPath: string): string {
try {
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
@@ -113,20 +93,12 @@ export class KeyService {
mkdirSync(tempDir, { recursive: true })
}
const localPath = join(tempDir, 'wx_key.dll')
if (existsSync(localPath)) return localPath
// 检查是否已经有本地副本,如果有就使用它
if (existsSync(localPath)) {
return localPath
}
copyFileSync(originalPath, localPath)
return localPath
} catch (e) {
console.error('DLL 本地化失败:', e)
// 如果本地化失败,返回原路径
return originalPath
}
}
@@ -144,9 +116,7 @@ export class KeyService {
return false
}
// 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) {
dllPath = this.localizeNetworkDll(dllPath)
}
@@ -161,13 +131,7 @@ export class KeyService {
return true
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e)
const errorStack = e instanceof Error ? e.stack : ''
console.error(`加载 wx_key.dll 失败`)
console.error(` 路径: ${dllPath}`)
console.error(` 错误: ${errorMsg}`)
if (errorStack) {
console.error(` 堆栈: ${errorStack}`)
}
console.error(`加载 wx_key.dll 失败\n 路径: ${dllPath}\n 错误: ${errorMsg}`)
return false
}
}
@@ -182,24 +146,11 @@ export class KeyService {
this.koffi = require('koffi')
this.kernel32 = this.koffi.load('kernel32.dll')
const HANDLE = this.koffi.pointer('HANDLE', this.koffi.opaque())
this.MEMORY_BASIC_INFORMATION = this.koffi.struct('MEMORY_BASIC_INFORMATION', {
BaseAddress: 'uint64',
AllocationBase: 'uint64',
AllocationProtect: 'uint32',
RegionSize: 'uint64',
State: 'uint32',
Protect: 'uint32',
Type: 'uint32'
})
// Use explicit definitions to avoid parser issues
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
// 直接使用原生支持的 'void*' 替换 'HANDLE',绝对不会再报类型错误
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
return true
} catch (e) {
@@ -219,15 +170,12 @@ export class KeyService {
this.koffi = require('koffi')
this.user32 = this.koffi.load('user32.dll')
// Callbacks
// Define the prototype and its pointer type
const WNDENUMPROC = this.koffi.proto('bool __stdcall (void *hWnd, intptr_t lParam)')
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
@@ -247,8 +195,6 @@ export class KeyService {
this.koffi = require('koffi')
this.advapi32 = this.koffi.load('advapi32.dll')
// Types
// Use intptr_t for HKEY to match system architecture (64-bit safe)
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
const HKEY_PTR = this.koffi.pointer(HKEY)
@@ -274,27 +220,19 @@ export class KeyService {
// --- WeChat Process & Path Finding ---
// Helper to read simple registry string
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
if (!this.ensureAdvapi32()) return null
// Convert strings to UTF-16 buffers
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
const phkResult = Buffer.alloc(8)
const phkResult = Buffer.alloc(8) // Pointer size (64-bit safe)
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) {
return null
}
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) return null
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
try {
const lpcbData = Buffer.alloc(4)
lpcbData.writeUInt32LE(0, 0) // First call to get size? No, RegQueryValueExW expects initialized size or null to get size.
// Usually we call it twice or just provide a big buffer.
// Let's call twice.
lpcbData.writeUInt32LE(0, 0)
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
@@ -306,7 +244,6 @@ export class KeyService {
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
// Read UTF-16 string (remove null terminator)
let str = dataBuf.toString('ucs2')
if (str.endsWith('\0')) str = str.slice(0, -1)
return str
@@ -317,7 +254,6 @@ export class KeyService {
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
@@ -341,33 +277,21 @@ export class KeyService {
}
private async findWeChatInstallPath(): Promise<string | null> {
// 0. 优先尝试获取正在运行的微信进程路径
try {
const pid = await this.findWeChatPid()
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) {
return runPath
}
if (runPath && existsSync(runPath)) return runPath
}
} catch (e) {
console.error('尝试获取运行中微信路径失败:', e)
}
// 1. Registry - Uninstall Keys
const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
]
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
// NOTE: Scanning subkeys in registry via Koffi is tedious (RegEnumKeyEx).
// Simplified strategy: Check common known registry keys first, then fallback to common paths.
// wx_key searches *all* subkeys of Uninstall, which is robust but complex to port quickly.
// Let's rely on specific Tencent keys first.
// 2. Tencent specific keys
const tencentKeys = [
'Software\\Tencent\\WeChat',
'Software\\WOW6432Node\\Tencent\\WeChat',
@@ -382,16 +306,13 @@ export class KeyService {
}
}
// 3. Uninstall key exact match (sometimes works)
for (const root of roots) {
for (const parent of uninstallKeys) {
// Try WeChat specific subkey
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
}
}
// 4. Common Paths
const drives = ['C', 'D', 'E', 'F']
const commonPaths = [
'Program Files\\Tencent\\WeChat\\WeChat.exe',
@@ -424,7 +345,6 @@ export class KeyService {
}
return null
} catch (e) {
console.error(`获取进程失败 (${imageName}):`, e)
return null
}
}
@@ -435,7 +355,6 @@ export class KeyService {
const pid = await this.findPidByImageName(name)
if (pid) return pid
}
const fallbackPid = await this.waitForWeChatWindow(5000)
return fallbackPid ?? null
}
@@ -486,14 +405,11 @@ export class KeyService {
try {
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
} catch (e) {
// Ignore if not found
}
} catch (e) { }
return await this.waitForWeChatExit(5000)
}
// --- Window Detection ---
private getWindowTitle(hWnd: any): string {
@@ -574,17 +490,12 @@ export class KeyService {
for (const child of children) {
const normalizedTitle = child.title.replace(/\s+/g, '')
if (normalizedTitle) {
if (readyTexts.some(marker => normalizedTitle.includes(marker))) {
return true
}
if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true
titleMatchCount += 1
}
const className = child.className
if (className) {
if (readyClassMarkers.some(marker => className.includes(marker))) {
return true
}
if (readyClassMarkers.some(marker => className.includes(marker))) return true
if (className.length > 5) {
classMatchCount += 1
hasValidClassName = true
@@ -630,11 +541,11 @@ export class KeyService {
return true
}
// --- Main Methods ---
// --- DB Key Logic (Unchanged core flow) ---
async autoGetDbKey(
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
@@ -642,7 +553,6 @@ export class KeyService {
const logs: string[] = []
// 1. Find Path
onStatus?.('正在定位微信安装路径...', 0)
let wechatPath = await this.findWeChatInstallPath()
if (!wechatPath) {
@@ -651,7 +561,6 @@ export class KeyService {
return { success: false, error: err }
}
// 2. Restart WeChat
onStatus?.('正在关闭微信以进行获取...', 0)
const closed = await this.killWeChatProcesses()
if (!closed) {
@@ -660,7 +569,6 @@ export class KeyService {
return { success: false, error: err }
}
// 3. Launch
onStatus?.('正在启动微信...', 0)
const sub = spawn(wechatPath, {
detached: true,
@@ -669,23 +577,18 @@ export class KeyService {
})
sub.unref()
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
onStatus?.('等待微信界面就绪...', 0)
const pid = await this.waitForWeChatWindow()
if (!pid) {
return { success: false, error: '启动微信失败或等待界面就绪超时' }
}
if (!pid) return { success: false, error: '启动微信失败或等待界面就绪超时' }
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
onStatus?.('正在检测微信界面组件...', 0)
await this.waitForWeChatWindowComponents(pid, 15000)
// 5. Inject
const ok = this.initHook(pid)
if (!ok) {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) {
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件如360、火绒等\n3. 确保微信没有以管理员权限运行'
return { success: false, error: friendlyError }
@@ -695,8 +598,8 @@ export class KeyService {
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
? this.decodeUtf8(statusBuffer)
: ''
? this.decodeUtf8(statusBuffer)
: ''
return { success: false, error: status || '初始化失败' }
}
@@ -716,9 +619,7 @@ export class KeyService {
for (let i = 0; i < 5; i++) {
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) {
break
}
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break
const msg = this.decodeUtf8(statusBuffer)
const level = levelOut[0] ?? 0
if (msg) {
@@ -726,7 +627,6 @@ export class KeyService {
onStatus?.(msg, level)
}
}
await new Promise((resolve) => setTimeout(resolve, 120))
}
} finally {
@@ -738,21 +638,19 @@ export class KeyService {
return { success: false, error: '获取密钥超时', logs }
}
// --- Image Key Stuff (Legacy but kept) ---
// --- Image Key Stuff (Refactored to Multi-core Crypto Brute Force) ---
private isAccountDir(dirPath: string): boolean {
return (
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
)
}
private isPotentialAccountName(name: string): boolean {
const lower = name.toLowerCase()
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) {
return false
}
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) return false
if (lower.startsWith('wxid_')) return true
if (/^\d+$/.test(name) && name.length >= 6) return true
return name.length > 5
@@ -767,19 +665,12 @@ export class KeyService {
const fullPath = join(rootDir, entry)
try {
if (!statSync(fullPath).isDirectory()) continue
} catch {
continue
}
} catch { continue }
if (!this.isPotentialAccountName(entry)) {
continue
}
if (!this.isPotentialAccountName(entry)) continue
if (this.isAccountDir(fullPath)) {
high.push(fullPath)
} else {
low.push(fullPath)
}
if (this.isAccountDir(fullPath)) high.push(fullPath)
else low.push(fullPath)
}
return high.length ? high.sort() : low.sort()
} catch {
@@ -792,9 +683,7 @@ export class KeyService {
if (!existsSync(trimmed)) return null
try {
const stats = statSync(trimmed)
if (stats.isFile()) {
return dirname(trimmed)
}
if (stats.isFile()) return dirname(trimmed)
} catch {
return null
}
@@ -849,17 +738,13 @@ export class KeyService {
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
continue
}
} catch { continue }
for (const entry of entries) {
const fullPath = join(dir, entry)
let stats: any
try {
stats = statSync(fullPath)
} catch {
continue
}
} catch { continue }
if (stats.isDirectory()) {
stack.push(fullPath)
} else if (entry.endsWith('_t.dat')) {
@@ -900,9 +785,7 @@ export class KeyService {
break
}
}
if (valid) {
counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
}
if (valid) counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
}
} catch { }
}
@@ -918,206 +801,205 @@ export class KeyService {
return bestKey
}
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
// 改为返回 Buffer 数组收集最多2个样本用于双重校验
private getCiphertextsFromTemplate(templateFiles: string[]): Buffer[] {
const ciphertexts: Buffer[] = []
for (const file of templateFiles) {
try {
const bytes = readFileSync(file)
if (bytes.length < 0x1f) continue
// 匹配微信 DAT 文件的特定头部特征
if (
bytes[0] === 0x07 &&
bytes[1] === 0x08 &&
bytes[2] === 0x56 &&
bytes[3] === 0x32 &&
bytes[4] === 0x08 &&
bytes[5] === 0x07
bytes[0] === 0x07 && bytes[1] === 0x08 && bytes[2] === 0x56 &&
bytes[3] === 0x32 && bytes[4] === 0x08 && bytes[5] === 0x07
) {
return bytes.subarray(0x0f, 0x1f)
ciphertexts.push(bytes.subarray(0x0f, 0x1f))
// 收集到 2 个样本就足够做双重校验了
if (ciphertexts.length >= 2) break
}
} catch { }
}
return null
return ciphertexts
}
private isAlphaNumLower(byte: number): boolean {
// 只匹配小写字母 a-z 和数字 0-9AES密钥格式
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39)
}
private isUtf16LowerKey(buf: Buffer, start: number): boolean {
if (start + 64 > buf.length) return false
for (let j = 0; j < 32; j++) {
const charByte = buf[start + j * 2]
const nullByte = buf[start + j * 2 + 1]
if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
return false
}
}
return true
}
private verifyKey(ciphertext: Buffer, keyBytes: Buffer): boolean {
try {
const key = keyBytes.subarray(0, 16)
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
const isPng = decrypted.length >= 8 &&
decrypted[0] === 0x89 &&
decrypted[1] === 0x50 &&
decrypted[2] === 0x4e &&
decrypted[3] === 0x47 &&
decrypted[4] === 0x0d &&
decrypted[5] === 0x0a &&
decrypted[6] === 0x1a &&
decrypted[7] === 0x0a
return isJpeg || isPng
} catch {
return false
}
}
private getMemoryRegions(hProcess: any): Array<[number, number]> {
const regions: Array<[number, number]> = []
const MEM_COMMIT = 0x1000
const MEM_PRIVATE = 0x20000
const PAGE_NOACCESS = 0x01
const PAGE_GUARD = 0x100
let address = 0
const maxAddress = 0x7fffffffffff
while (address >= 0 && address < maxAddress) {
const info: any = {}
const result = this.VirtualQueryEx(hProcess, address, info, this.koffi.sizeof(this.MEMORY_BASIC_INFORMATION))
if (!result) break
const state = info.State
const protect = info.Protect
const type = info.Type
const regionSize = Number(info.RegionSize)
// 只收集已提交的私有内存(大幅减少扫描区域)
if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
regions.push([Number(info.BaseAddress), regionSize])
}
const nextAddress = address + regionSize
if (nextAddress <= address) break
address = nextAddress
}
return regions
}
private readProcessMemory(hProcess: any, address: number, size: number): Buffer | null {
const buffer = Buffer.alloc(size)
const bytesRead = [0]
const ok = this.ReadProcessMemory(hProcess, address, buffer, size, bytesRead)
if (!ok || bytesRead[0] === 0) return null
return buffer.subarray(0, bytesRead[0])
}
private async getAesKeyFromMemory(
pid: number,
ciphertext: Buffer,
onProgress?: (current: number, total: number, message: string) => void
private async bruteForceAesKey(
xorKey: number,
wxid: string,
ciphertexts: Buffer[],
onProgress?: (msg: string) => void
): Promise<string | null> {
if (!this.ensureKernel32()) return null
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
if (!hProcess) return null
const numCores = os.cpus().length || 4
const totalCombinations = 1 << 24 // 16,777,216 种可能性
const chunkSize = Math.ceil(totalCombinations / numCores)
try {
const allRegions = this.getMemoryRegions(hProcess)
const totalRegions = allRegions.length
let scannedCount = 0
let skippedCount = 0
onProgress?.(`准备启动 ${numCores} 个线程进行极速爆破...`)
for (const [baseAddress, regionSize] of allRegions) {
// 跳过太大的内存区域(> 100MB
if (regionSize > 100 * 1024 * 1024) {
skippedCount++
continue
}
const workerCode = `
const { parentPort, workerData } = require('worker_threads');
const crypto = require('crypto');
scannedCount++
if (scannedCount % 10 === 0) {
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
await new Promise(resolve => setImmediate(resolve))
}
const { start, end, xorKey, wxid, cipherHexList } = workerData;
const ciphertexts = cipherHexList.map(hex => Buffer.from(hex, 'hex'));
const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
if (!memory) continue
// 直接在原始字节中搜索32字节的小写字母数字序列
for (let i = 0; i < memory.length - 34; i++) {
// 检查前导字符(不是小写字母或数字)
if (this.isAlphaNumLower(memory[i])) continue
// 检查接下来32个字节是否都是小写字母或数字
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this.isAlphaNumLower(memory[i + j])) {
valid = false
break
}
}
if (!valid) continue
// 检查尾部字符(不是小写字母或数字)
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
continue
}
const keyBytes = memory.subarray(i + 1, i + 33)
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
function verifyKey(cipher, keyStr) {
try {
const decipher = crypto.createDecipheriv('aes-128-ecb', keyStr, null);
decipher.setAutoPadding(false);
const decrypted = Buffer.concat([decipher.update(cipher), decipher.final()]);
const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff;
const isPng = decrypted.length >= 8 && decrypted[0] === 0x89 && decrypted[1] === 0x50 && decrypted[2] === 0x4e && decrypted[3] === 0x47;
return isJpeg || isPng;
} catch {
return false;
}
}
return null
} finally {
try {
this.CloseHandle(hProcess)
} catch { }
}
let found = null;
for (let upper = end; upper > start; upper--) {
// 我就写 --
if (upper % 100000 === 0 && upper !== start) {
parentPort.postMessage({ type: 'progress', scanned: 100000 });
}
const number = (upper * 256) + xorKey;
// 1. 无符号整数校验
const strUnsigned = number.toString(10) + wxid;
const md5Unsigned = crypto.createHash('md5').update(strUnsigned).digest('hex').slice(0, 16);
let isValidUnsigned = true;
for (const cipher of ciphertexts) {
if (!verifyKey(cipher, md5Unsigned)) {
isValidUnsigned = false;
break;
}
}
if (isValidUnsigned) {
found = md5Unsigned;
break;
}
// 2. 带符号整数校验 (溢出边界情况)
if (number > 0x7FFFFFFF) {
const strSigned = (number | 0).toString(10) + wxid;
const md5Signed = crypto.createHash('md5').update(strSigned).digest('hex').slice(0, 16);
let isValidSigned = true;
for (const cipher of ciphertexts) {
if (!verifyKey(cipher, md5Signed)) {
isValidSigned = false;
break;
}
}
if (isValidSigned) {
found = md5Signed;
break;
}
}
}
if (found) {
parentPort.postMessage({ type: 'success', key: found });
} else {
parentPort.postMessage({ type: 'done' });
}
`
return new Promise((resolve) => {
let activeWorkers = numCores
let resolved = false
let totalScanned = 0 // 总进度计数器
const workers: Worker[] = []
const cleanup = () => {
for (const w of workers) w.terminate()
}
for (let i = 0; i < numCores; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, totalCombinations)
const worker = new Worker(workerCode, {
eval: true,
workerData: {
start,
end,
xorKey,
wxid,
cipherHexList: ciphertexts.map(c => c.toString('hex')) // 传入数组
}
})
workers.push(worker)
worker.on('message', (msg) => {
if (!msg) return
if (msg.type === 'progress') {
totalScanned += msg.scanned
const percent = ((totalScanned / totalCombinations) * 100).toFixed(1)
// 优化文案,并确保包含 (xx.x%) 供前端解析
onProgress?.(`多核爆破引擎运行中:已扫描 ${(totalScanned / 10000).toFixed(0)} 万个密钥空间 (${percent}%)`)
} else if (msg.type === 'success' && !resolved) {
resolved = true
cleanup()
resolve(msg.key)
} else if (msg.type === 'done') {
// 单个 worker 跑完了没有找到
activeWorkers--
if (activeWorkers === 0 && !resolved) resolve(null)
}
})
worker.on('error', (err) => {
console.error('Worker error:', err)
activeWorkers--
if (activeWorkers === 0 && !resolved) resolve(null)
})
worker.on('exit', () => {
activeWorkers--
if (activeWorkers === 0 && !resolved) resolve(null)
})
}
})
}
async autoGetImageKey(
manualDir?: string,
onProgress?: (message: string) => void
manualDir?: string,
onProgress?: (message: string) => void
): Promise<ImageKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
if (!this.ensureKernel32()) return { success: false, error: '初始化系统 API 失败' }
onProgress?.('正在定位微信账号目录...')
onProgress?.('正在定位微信账号数据目录...')
const accountDir = this.resolveAccountDir(manualDir)
if (!accountDir) return { success: false, error: '未找到微信账号目录' }
onProgress?.('正在收集模板文件...')
let wxid = basename(accountDir)
wxid = wxid.replace(/_[0-9a-fA-F]{4}$/, '')
onProgress?.('正在收集并分析加密模板文件...')
const templateFiles = this.findTemplateDatFiles(accountDir)
if (!templateFiles.length) return { success: false, error: '未找到模板文件' }
onProgress?.('正在计算 XOR 密钥...')
onProgress?.('正在计算特征 XOR 密钥...')
const xorKey = this.getXorKey(templateFiles)
if (xorKey == null) return { success: false, error: '无法计算 XOR 密钥' }
onProgress?.('正在读取加密模板数据...')
const ciphertext = this.getCiphertextFromTemplate(templateFiles)
if (!ciphertext) return { success: false, error: '无法读取加密模板数据' }
onProgress?.('正在读取加密模板区块...')
const ciphertexts = this.getCiphertextsFromTemplate(templateFiles)
if (ciphertexts.length === 0) return { success: false, error: '无法读取加密模板数据' }
const pid = await this.findWeChatPid()
if (!pid) return { success: false, error: '未检测到微信进程' }
onProgress?.(`成功提取 ${ciphertexts.length} 个特征样本,准备交叉校验...`)
onProgress?.(`准备启动 ${os.cpus().length || 4} 线程并发爆破引擎 (基于 wxid: ${wxid})...`)
onProgress?.('正在扫描内存获取 AES 密钥...')
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => {
onProgress?.(`${msg} (${current}/${total})`)
const aesKey = await this.bruteForceAesKey(xorKey, wxid, ciphertexts, (msg) => {
onProgress?.(msg)
})
if (!aesKey) {
return {
success: false,
error: '未能从内存中获取 AES 密钥,请打开朋友圈图片后重试'
error: 'AES 密钥爆破失败,请确认该账号近期是否有接收过图片,或更换账号目录重试'
}
}
return { success: true, xorKey, aesKey: aesKey.slice(0, 16) }
return { success: true, xorKey, aesKey }
}
}
}

View File

@@ -2172,4 +2172,71 @@
width: 100%;
margin-top: 12px;
}
}
.brute-force-progress {
margin-top: 12px;
padding: 14px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
animation: slideUp 0.3s ease;
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.status-text {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
margin: 0;
// 增加文字呼吸灯效果,表明正在运行
animation: pulse 2s ease-in-out infinite;
}
.percent {
font-size: 14px;
color: var(--primary);
font-weight: 700;
font-family: var(--font-mono);
}
}
.progress-bar-container {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
.fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
border-radius: 4px;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
// 流光扫过的高亮特效
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: progress-shimmer 1.5s infinite linear;
}
}
}
}

View File

@@ -82,6 +82,8 @@ function SettingsPage() {
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('')
const [imageKeyProgress, setImageKeyProgress] = useState(0)
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
@@ -222,8 +224,28 @@ function SettingsPage() {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message)
})
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message)
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
let msg = payload.message;
let pct = payload.percent;
// 如果后端没有显式传 percent则用正则从字符串中提取如 "(12.5%)"
if (pct === undefined) {
const match = msg.match(/\(([\d.]+)%\)/);
if (match) {
pct = parseFloat(match[1]);
// 将百分比从文本中剥离,让 UI 更清爽
msg = msg.replace(/\s*\([\d.]+%\)/, '');
}
}
setImageKeyStatus(msg);
if (pct !== undefined) {
setImageKeyPercent(pct);
} else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) {
// 预热阶段
setImageKeyPercent(0);
}
})
return () => {
removeDb?.()
@@ -745,15 +767,18 @@ function SettingsPage() {
}
const handleAutoGetImageKey = async () => {
if (isFetchingImageKey) return
if (isFetchingImageKey) return;
if (!dbPath) {
showMessage('请先选择数据库目录', false)
return
showMessage('请先选择数据库目录', false);
return;
}
setIsFetchingImageKey(true)
setImageKeyStatus('正在准备获取图片密钥...')
setIsFetchingImageKey(true);
setImageKeyPercent(0)
setImageKeyStatus('正在初始化多核爆破引擎...');
setImageKeyProgress(0); // 重置进度
try {
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') {
@@ -1351,8 +1376,21 @@ function SettingsPage() {
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
{isFetchingImageKey && <div className="form-hint status-text">...</div>}
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动多核爆破引擎...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : (
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
)}
</div>
<div className="form-group">

View File

@@ -803,3 +803,79 @@
opacity: 1;
}
}
.brute-force-progress {
margin-top: 16px;
padding: 14px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
animation: slideUp 0.3s ease;
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.status-text {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
margin: 0;
animation: pulse 2s ease-in-out infinite;
}
.percent {
font-size: 14px;
color: var(--primary);
font-weight: 700;
font-family: var(--font-mono);
}
}
.progress-bar-container {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
.fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
border-radius: 4px;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: progress-shimmer 1.5s infinite linear;
}
}
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes progress-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}

View File

@@ -48,6 +48,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const [dbKeyStatus, setDbKeyStatus] = useState('')
const [imageKeyStatus, setImageKeyStatus] = useState('')
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
// 安全相关 state
const [enableAuth, setEnableAuth] = useState(false)
@@ -111,8 +112,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message)
})
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message)
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
let msg = payload.message;
let pct = payload.percent;
// 解析文本中的百分比
if (pct === undefined) {
const match = msg.match(/\(([\d.]+)%\)/);
if (match) {
pct = parseFloat(match[1]);
msg = msg.replace(/\s*\([\d.]+%\)/, '');
}
}
setImageKeyStatus(msg);
if (pct !== undefined) {
setImageKeyPercent(pct);
} else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) {
setImageKeyPercent(0);
}
})
return () => {
removeDb?.()
@@ -297,6 +315,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
setIsFetchingImageKey(true)
setError('')
setImageKeyPercent(0)
setImageKeyStatus('正在准备获取图片密钥...')
try {
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
@@ -727,37 +746,52 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
)}
{currentStep.id === 'image' && (
<div className="form-group">
<div className="grid-2">
<div>
<label className="field-label"> XOR </label>
<input
type="text"
className="field-input"
placeholder="0x..."
value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)}
/>
</div>
<div>
<label className="field-label"> AES </label>
<input
type="text"
className="field-input"
placeholder="16位密钥"
value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)}
/>
<div className="form-group">
<div className="grid-2">
<div>
<label className="field-label"> XOR </label>
<input
type="text"
className="field-input"
placeholder="0x..."
value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)}
/>
</div>
<div>
<label className="field-label"> AES </label>
<input
type="text"
className="field-input"
placeholder="16位密钥"
value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)}
/>
</div>
</div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动多核爆破引擎...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : (
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
)}
<div className="field-hint"></div>
</div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
</button>
{imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
<div className="field-hint"></div>
</div>
)}
</div>