Files
WeFlow/electron/services/keyServiceMac.ts
hicccc77 f47610b98a fix: 修复 macOS 打包后的图标、spawn ENOENT 和 dylib 路径问题
- 新增 resources/icon.icns(由 public/logo.png 转换)
- package.json: 配置 build.icon 和 build.mac.icon 使用 .icns
- main.ts: BrowserWindow 图标改为平台感知,darwin 用 .icns,其他用 .ico
- keyServiceMac.ts: pgrep/ps/pkill 改用绝对路径,修复打包后 PATH 受限导致的 spawn ENOENT
2026-03-14 18:45:12 +08:00

1032 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app, shell } from 'electron'
import { join, basename, dirname } from 'path'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import crypto from 'crypto'
import { homedir } from 'os'
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
const execFileAsync = promisify(execFile)
export class KeyServiceMac {
private koffi: any = null
private lib: any = null
private initialized = false
private GetDbKey: any = null
private ListWeChatProcesses: any = null
private libSystem: any = null
private machTaskSelf: any = null
private taskForPid: any = null
private machVmRegion: any = null
private machVmReadOverwrite: any = null
private machPortDeallocate: any = null
private _needsElevation = false
private getHelperPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) {
candidates.push(process.env.WX_KEY_HELPER_PATH)
}
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'xkey_helper'))
} else {
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'xkey_helper'))
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
throw new Error('xkey_helper not found')
}
private getImageScanHelperPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
} else {
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
throw new Error('image_scan_helper not found')
}
private getDylibPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (process.env.WX_KEY_DYLIB_PATH) {
candidates.push(process.env.WX_KEY_DYLIB_PATH)
}
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
} else {
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
throw new Error('libwx_key.dylib not found')
}
async initialize(): Promise<void> {
if (this.initialized) return
try {
this.koffi = require('koffi')
const dylibPath = this.getDylibPath()
if (!existsSync(dylibPath)) {
throw new Error('libwx_key.dylib not found: ' + dylibPath)
}
this.lib = this.koffi.load(dylibPath)
this.GetDbKey = this.lib.func('const char* GetDbKey()')
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
this.initialized = true
} catch (e: any) {
throw new Error('Failed to initialize KeyServiceMac: ' + e.message)
}
}
async autoGetDbKey(
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
try {
onStatus?.('正在获取数据库密钥...', 0)
onStatus?.('正在请求管理员授权并执行 helper...', 0)
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
try {
const elevatedResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus)
parsed = this.parseDbKeyResult(elevatedResult)
console.log('[KeyServiceMac] GetDbKey elevated returned:', parsed.raw)
} catch (e: any) {
const msg = `${e?.message || e}`
if (msg.includes('(-128)') || msg.includes('User canceled')) {
return { success: false, error: '已取消管理员授权' }
}
throw e
}
if (!parsed.success) {
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
onStatus?.(errorMsg, 2)
return { success: false, error: errorMsg }
}
onStatus?.('密钥获取成功', 1)
return { success: true, key: parsed.key }
} catch (e: any) {
console.error('[KeyServiceMac] Error:', e)
console.error('[KeyServiceMac] Stack:', e.stack)
onStatus?.('获取失败: ' + e.message, 2)
return { success: false, error: e.message }
}
}
private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } {
const text = typeof raw === 'string' ? raw : ''
if (!text) return { success: false, code: 'UNKNOWN', raw: text }
if (!text.startsWith('ERROR:')) return { success: true, key: text, raw: text }
const parts = text.split(':')
return {
success: false,
code: parts[1] || 'UNKNOWN',
detail: parts.slice(2).join(':') || undefined,
raw: text
}
}
private async getDbKeyParsed(
timeoutMs: number,
onStatus?: (message: string, level: number) => void
): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> {
const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus)
return this.parseDbKeyResult(helperResult)
}
private async getWeChatPid(): Promise<number> {
try {
// 优先使用 pgrep避免 ps 的 comm 列被截断导致识别失败
try {
const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-x', 'WeChat'])
const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0)
if (ids.length > 0) return Math.max(...ids)
} catch {
// ignore and fallback to ps
}
const { stdout } = await execFileAsync('/bin/ps', ['-A', '-o', 'pid,comm,command'])
const lines = stdout.split('\n').slice(1)
const candidates: Array<{ pid: number; command: string }> = []
for (const line of lines) {
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/)
if (!match) continue
const pid = parseInt(match[1], 10)
const command = match[3]
const pathMatch = command.includes('/Applications/WeChat.app/Contents/MacOS/WeChat') ||
command.includes('/Contents/MacOS/WeChat')
if (pathMatch) candidates.push({ pid, command })
}
if (candidates.length === 0) throw new Error('WeChat process not found')
const filtered = candidates.filter(p => {
const cmd = p.command
return !cmd.includes('WeChatAppEx.app/') &&
!cmd.includes('/WeChatAppEx') &&
!cmd.includes(' WeChatAppEx') &&
!cmd.includes('crashpad_handler') &&
!cmd.includes('Helper')
})
if (filtered.length === 0) throw new Error('No valid WeChat main process found')
const preferredMain = filtered.filter(p => p.command.includes('/Contents/MacOS/WeChat'))
const selectedPool = preferredMain.length > 0 ? preferredMain : filtered
const selected = selectedPool.reduce((max, p) => p.pid > max.pid ? p : max)
return selected.pid
} catch (e: any) {
throw new Error('Failed to get WeChat PID: ' + e.message)
}
}
private async getDbKeyByHelper(
timeoutMs: number,
onStatus?: (message: string, level: number) => void
): Promise<string> {
const helperPath = this.getHelperPath()
const waitMs = Math.max(timeoutMs, 30_000)
const pid = await this.getWeChatPid()
onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0)
// 最佳努力清理同路径残留 helper普通权限
try { await execFileAsync('/usr/bin/pkill', ['-f', helperPath], { timeout: 2000 }) } catch { }
return await new Promise<string>((resolve, reject) => {
// xkey_helper 参数协议helper <pid> [timeout_ms]
const child = spawn(helperPath, [String(pid), String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
let stdoutBuf = ''
let stderrBuf = ''
let settled = false
let killTimer: ReturnType<typeof setTimeout> | null = null
let pidNotified = false
let locatedNotified = false
let hookNotified = false
const done = (fn: () => void) => {
if (settled) return
settled = true
if (killTimer) clearTimeout(killTimer)
fn()
}
const processHelperLine = (line: string) => {
if (!line) return
console.log('[KeyServiceMac][helper][stderr]', line)
const pidMatch = line.match(/Selected PID=(\d+)/)
if (pidMatch && !pidNotified) {
pidNotified = true
onStatus?.(`已找到微信进程 PID=${pidMatch[1]},正在定位目标函数...`, 0)
}
if (!locatedNotified && (line.includes('strict hit=') || line.includes('sink matched by strict semantic signature'))) {
locatedNotified = true
onStatus?.('已定位到目标函数,正在安装 Hook...', 0)
}
if (line.includes('hook installed @')) {
hookNotified = true
onStatus?.('Hook 已安装,等待微信触发密钥调用...', 0)
}
if (line.includes('[MASTER] hex64=')) {
onStatus?.('检测到密钥回调,正在回填...', 0)
}
}
child.stdout.on('data', (chunk: Buffer | string) => {
const data = chunk.toString()
stdout += data
stdoutBuf += data
const parts = stdoutBuf.split(/\r?\n/)
stdoutBuf = parts.pop()!
})
child.stderr.on('data', (chunk: Buffer | string) => {
const data = chunk.toString()
stderr += data
stderrBuf += data
const parts = stderrBuf.split(/\r?\n/)
stderrBuf = parts.pop()!
for (const line of parts) processHelperLine(line.trim())
})
child.on('error', (err) => {
done(() => reject(err))
})
child.on('close', () => {
if (stderrBuf.trim()) processHelperLine(stderrBuf.trim())
const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean)
const last = lines[lines.length - 1]
if (!last) {
done(() => reject(new Error(stderr.trim() || 'helper returned empty output')))
return
}
let payload: any
try {
payload = JSON.parse(last)
} catch {
done(() => reject(new Error('helper returned invalid json: ' + last)))
return
}
if (payload?.success === true && typeof payload?.key === 'string') {
if (!hookNotified) {
onStatus?.('Hook 已触发,正在回填密钥...', 0)
}
done(() => resolve(payload.key))
return
}
if (typeof payload?.result === 'string') {
done(() => resolve(payload.result))
return
}
done(() => reject(new Error('helper json missing key/result')))
})
killTimer = setTimeout(() => {
try { child.kill('SIGTERM') } catch { }
done(() => reject(new Error(`helper timeout after ${waitMs}ms`)))
}, waitMs + 10_000)
})
}
private shellSingleQuote(text: string): string {
return `'${String(text).replace(/'/g, `'\\''`)}'`
}
private async getDbKeyByHelperElevated(
timeoutMs: number,
onStatus?: (message: string, level: number) => void
): Promise<string> {
const helperPath = this.getHelperPath()
const waitMs = Math.max(timeoutMs, 30_000)
const pid = await this.getWeChatPid()
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
'do shell script cmd with administrator privileges'
]
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
let stdout = ''
try {
const result = await execFileAsync('osascript', scriptLines.flatMap(line => ['-e', line]), {
timeout: waitMs + 20_000
})
stdout = result.stdout
} catch (e: any) {
const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim()
throw new Error(msg || 'elevated helper execution failed')
}
const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
const last = lines[lines.length - 1]
if (!last) throw new Error('elevated helper returned empty output')
let payload: any
try {
payload = JSON.parse(last)
} catch {
throw new Error('elevated helper returned invalid json: ' + last)
}
if (payload?.success === true && typeof payload?.key === 'string') return payload.key
if (typeof payload?.result === 'string') return payload.result
throw new Error('elevated helper json missing key/result')
}
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
if (code === 'ATTACH_FAILED') {
const isDevElectron = process.execPath.includes('/node_modules/electron/')
if ((detail || '').includes('task_for_pid:5')) {
if (isDevElectron) {
return `无法附加到微信进程task_for_pid 被拒绝)。当前为开发环境 Electron${process.execPath}\n建议使用打包后的 WeFlow.app已携带调试 entitlements再重试。`
}
return '无法附加到微信进程task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。'
}
return `无法附加到进程 (${detail || ''})`
}
if (code === 'FRIDA_FAILED') {
if ((detail || '').includes('FRIDA_TIMEOUT')) {
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
}
return `Frida 语义定位失败 (${detail || ''})`
}
if (code === 'HOOK_FAILED') {
if ((detail || '').includes('HOOK_TIMEOUT')) {
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
}
if ((detail || '').includes('attach_wait_timeout')) {
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
}
return `原生 Hook 失败 (${detail || ''})`
}
if (code === 'HOOK_TARGET_ONLY') {
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
}
if (code === 'SCAN_FAILED') return '内存扫描失败'
return '未知错误'
}
private async enableDebugPermissionWithPrompt(): Promise<boolean> {
const script = [
'do shell script "/usr/sbin/DevToolsSecurity -enable" with administrator privileges'
]
try {
await execFileAsync('osascript', script.flatMap(line => ['-e', line]), {
timeout: 30_000
})
return true
} catch (e: any) {
const msg = `${e?.stderr || ''}\n${e?.message || ''}`
const cancelled = msg.includes('User canceled') || msg.includes('(-128)')
if (!cancelled) {
console.error('[KeyServiceMac] enableDebugPermissionWithPrompt failed:', msg)
}
return false
}
}
private async openDeveloperToolsPrivacySettings(): Promise<void> {
const url = 'x-apple.systempreferences:com.apple.preference.security?Privacy_DevTools'
try {
await shell.openExternal(url)
} catch (e) {
console.error('[KeyServiceMac] Failed to open settings page:', e)
}
}
private async revealCurrentExecutableInFinder(): Promise<void> {
try {
shell.showItemInFolder(process.execPath)
} catch (e) {
console.error('[KeyServiceMac] Failed to reveal executable in Finder:', e)
}
}
async autoGetImageKey(
accountPath?: string,
onStatus?: (message: string) => void,
wxid?: string
): Promise<ImageKeyResult> {
try {
onStatus?.('正在从缓存目录扫描图片密钥...')
const codes = this.collectKvcommCodes(accountPath)
if (codes.length === 0) {
return { success: false, error: '未找到有效的密钥码kvcomm 缓存为空)' }
}
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
if (wxidCandidates.length === 0) {
return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' }
}
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
let verifyCiphertext: Buffer | null = null
if (accountPath && existsSync(accountPath)) {
const template = await this._findTemplateData(accountPath, 32)
verifyCiphertext = template.ciphertext
}
if (verifyCiphertext) {
onStatus?.(`正在校验候选 wxid${wxidCandidates.length} 个)...`)
for (const candidateWxid of wxidCandidates) {
for (const code of codes) {
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
return { success: true, xorKey, aesKey }
}
}
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
}
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code
const fallbackWxid = wxidCandidates[0]
const fallbackCode = codes[0]
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
return { success: true, xorKey, aesKey }
} catch (e: any) {
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
}
}
async autoGetImageKeyByMemoryScan(
userDir: string,
onProgress?: (message: string) => void
): Promise<ImageKeyResult> {
try {
// 1. 查找模板文件获取密文和 XOR 密钥
onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(userDir, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
// 2. 找微信 PID
const pid = await this.findWeChatPid()
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
// 3. 持续轮询内存扫描
const deadline = Date.now() + 60_000
let scanCount = 0
while (Date.now() < deadline) {
scanCount++
onProgress?.(`${scanCount} 次扫描内存,请在微信中打开图片大图...`)
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
if (aesKey) {
onProgress?.('密钥获取成功')
return { success: true, xorKey, aesKey }
}
await new Promise(r => setTimeout(r, 5000))
}
return { success: false, error: '60 秒内未找到 AES 密钥' }
} catch (e: any) {
return { success: false, error: `内存扫描失败: ${e.message}` }
}
}
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
const collect = (dir: string, results: string[], maxFiles: number) => {
if (results.length >= maxFiles) return
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (results.length >= maxFiles) break
const full = join(dir, entry.name)
if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
}
} catch { }
}
const files: string[] = []
collect(userDir, files, limit)
files.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
})
let ciphertext: Buffer | null = null
const tailCounts: Record<string, number> = {}
for (const f of files.slice(0, 32)) {
try {
const data = readFileSync(f)
if (data.length < 8) continue
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
tailCounts[key] = (tailCounts[key] ?? 0) + 1
}
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
ciphertext = data.subarray(0xF, 0x1F)
}
} catch { }
}
let xorKey: number | null = null
let maxCount = 0
for (const [key, count] of Object.entries(tailCounts)) {
if (count > maxCount) {
maxCount = count
const [x, y] = key.split('_').map(Number)
const k = x ^ 0xFF
if (k === (y ^ 0xD9)) xorKey = k
}
}
return { ciphertext, xorKey }
}
private ensureMachApis(): boolean {
if (this.machTaskSelf && this.taskForPid && this.machVmRegion && this.machVmReadOverwrite) return true
try {
if (!this.koffi) this.koffi = require('koffi')
this.libSystem = this.koffi.load('/usr/lib/libSystem.B.dylib')
this.machTaskSelf = this.libSystem.func('mach_task_self', 'uint32', [])
this.taskForPid = this.libSystem.func('task_for_pid', 'int', ['uint32', 'int', this.koffi.out('uint32*')])
this.machVmRegion = this.libSystem.func('mach_vm_region', 'int', [
'uint32',
this.koffi.out('uint64*'),
this.koffi.out('uint64*'),
'int',
'void*',
this.koffi.out('uint32*'),
this.koffi.out('uint32*')
])
this.machVmReadOverwrite = this.libSystem.func('mach_vm_read_overwrite', 'int', [
'uint32',
'uint64',
'uint64',
'void*',
this.koffi.out('uint64*')
])
this.machPortDeallocate = this.libSystem.func('mach_port_deallocate', 'int', ['uint32', 'uint32'])
return true
} catch (e) {
console.error('[KeyServiceMac] 初始化 Mach API 失败:', e)
return false
}
}
private async _scanMemoryForAesKey(
pid: number,
ciphertext: Buffer,
onProgress?: (message: string) => void
): Promise<string | null> {
// 优先通过 image_scan_helper 子进程调用
try {
const helperPath = this.getImageScanHelperPath()
const ciphertextHex = ciphertext.toString('hex')
// 1) 直接运行 helper有正式签名的 debugger entitlement 时可用)
if (!this._needsElevation) {
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
if (direct.key) return direct.key
if (direct.permissionError) {
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
this._needsElevation = true
onProgress?.('需要管理员权限,请在弹出的对话框中输入密码...')
}
}
// 2) 通过 osascript 以管理员权限运行 helperSIP 下 ad-hoc 签名无法获取 task_for_pid
if (this._needsElevation) {
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
if (elevated.key) return elevated.key
}
} catch (e: any) {
console.warn('[KeyServiceMac] image_scan_helper unavailable, fallback to Mach API:', e?.message)
}
// fallback: 直接通过 Mach API 扫描内存Electron 进程可能没有 task_for_pid 权限)
if (!this.ensureMachApis()) return null
const VM_PROT_READ = 0x1
const VM_PROT_WRITE = 0x2
const VM_REGION_BASIC_INFO_64 = 9
const VM_REGION_BASIC_INFO_COUNT_64 = 9
const KERN_SUCCESS = 0
const MAX_REGION_SIZE = 50 * 1024 * 1024
const CHUNK = 4 * 1024 * 1024
const OVERLAP = 65
const selfTask = this.machTaskSelf()
const taskBuf = Buffer.alloc(4)
const attachKr = this.taskForPid(selfTask, pid, taskBuf)
const task = taskBuf.readUInt32LE(0)
if (attachKr !== KERN_SUCCESS || !task) return null
try {
const regions: Array<[number, number]> = []
let address = 0
while (address < 0x7FFFFFFFFFFF) {
const addrBuf = Buffer.alloc(8)
addrBuf.writeBigUInt64LE(BigInt(address), 0)
const sizeBuf = Buffer.alloc(8)
const infoBuf = Buffer.alloc(64)
const countBuf = Buffer.alloc(4)
countBuf.writeUInt32LE(VM_REGION_BASIC_INFO_COUNT_64, 0)
const objectBuf = Buffer.alloc(4)
const kr = this.machVmRegion(task, addrBuf, sizeBuf, VM_REGION_BASIC_INFO_64, infoBuf, countBuf, objectBuf)
if (kr !== KERN_SUCCESS) break
const base = Number(addrBuf.readBigUInt64LE(0))
const size = Number(sizeBuf.readBigUInt64LE(0))
const protection = infoBuf.readInt32LE(0)
const objectName = objectBuf.readUInt32LE(0)
if (objectName) {
try { this.machPortDeallocate(selfTask, objectName) } catch { }
}
if ((protection & VM_PROT_READ) !== 0 &&
(protection & VM_PROT_WRITE) !== 0 &&
size > 0 &&
size <= MAX_REGION_SIZE) {
regions.push([base, size])
}
const next = base + size
if (next <= address) break
address = next
}
const totalMB = regions.reduce((sum, [, size]) => sum + size, 0) / 1024 / 1024
onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`)
for (let ri = 0; ri < regions.length; ri++) {
const [base, size] = regions[ri]
if (ri % 20 === 0) {
onProgress?.(`扫描进度 ${ri}/${regions.length}...`)
await new Promise(r => setTimeout(r, 1))
}
let offset = 0
let trailing: Buffer | null = null
while (offset < size) {
const chunkSize = Math.min(CHUNK, size - offset)
const chunk = Buffer.alloc(chunkSize)
const outSizeBuf = Buffer.alloc(8)
const kr = this.machVmReadOverwrite(task, base + offset, chunkSize, chunk, outSizeBuf)
const bytesRead = Number(outSizeBuf.readBigUInt64LE(0))
offset += chunkSize
if (kr !== KERN_SUCCESS || bytesRead <= 0) {
trailing = null
continue
}
const current = chunk.subarray(0, bytesRead)
const data = trailing ? Buffer.concat([trailing, current]) : current
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
if (key) return key
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
const fallbackKey = this._searchAny16Key(data, ciphertext)
if (fallbackKey) return fallbackKey
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
}
}
return null
} finally {
try { this.machPortDeallocate(selfTask, task) } catch { }
}
}
private _spawnScanHelper(
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
): Promise<{ key: string | null; permissionError: boolean }> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof spawn>
if (elevated) {
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
child = spawn('osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
{ stdio: ['ignore', 'pipe', 'pipe'] })
} else {
child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] })
}
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
let stdout = '', stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
console.log(tag, chunk.toString().trim())
})
child.on('error', reject)
child.on('close', () => {
const permissionError = !elevated && stderr.includes('task_for_pid failed')
try {
const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean)
const last = lines[lines.length - 1]
if (!last) { resolve({ key: null, permissionError }); return }
const payload = JSON.parse(last)
resolve({
key: payload?.success && payload?.aesKey ? payload.aesKey : null,
permissionError
})
} catch {
resolve({ key: null, permissionError })
}
})
setTimeout(() => { try { child.kill('SIGTERM') } catch {} }, elevated ? 60_000 : 30_000)
})
}
private async findWeChatPid(): Promise<number | null> {
const { execSync } = await import('child_process')
try {
const output = execSync('pgrep -x WeChat', { encoding: 'utf8' })
const pid = parseInt(output.trim())
return isNaN(pid) ? null : pid
} catch {
return null
}
}
cleanup(): void {
this.lib = null
this.initialized = false
this.libSystem = null
this.machTaskSelf = null
this.taskForPid = null
this.machVmRegion = null
this.machVmReadOverwrite = null
this.machPortDeallocate = null
}
private cleanWxid(wxid: string): string {
const first = wxid.indexOf('_')
if (first === -1) return wxid
const second = wxid.indexOf('_', first + 1)
if (second === -1) return wxid
return wxid.substring(0, second)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const cleanedWxid = this.cleanWxid(wxid)
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const aesKey = crypto.createHash('md5').update(dataToHash).digest('hex').substring(0, 16)
return { xorKey, aesKey }
}
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
const candidates: string[] = []
const pushUnique = (value: string) => {
const v = String(value || '').trim()
if (!v || candidates.includes(v)) return
candidates.push(v)
}
// 1) 显式传参优先
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
if (accountPath) {
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
const dirName = basename(normalized)
// 2) 当前目录名为 wxid_*
if (dirName.startsWith('wxid_')) pushUnique(dirName)
// 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录
const marker = '/xwechat_files'
const markerIdx = normalized.indexOf(marker)
if (markerIdx >= 0) {
const root = normalized.slice(0, markerIdx + marker.length)
if (existsSync(root)) {
try {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (!entry.name.startsWith('wxid_')) continue
pushUnique(entry.name)
}
} catch {
// ignore
}
}
}
}
pushUnique('unknown')
return candidates
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const keyBytes = Buffer.from(aesKey, 'ascii').subarray(0, 16)
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes, null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private collectKvcommCodes(accountPath?: string): number[] {
const codeSet = new Set<number>()
const pattern = /^key_(\d+)_.+\.statistic$/i
for (const kvcommDir of this.getKvcommCandidates(accountPath)) {
if (!existsSync(kvcommDir)) continue
try {
const files = readdirSync(kvcommDir)
for (const file of files) {
const match = file.match(pattern)
if (!match) continue
const code = Number(match[1])
if (!Number.isFinite(code) || code <= 0 || code > 0xFFFFFFFF) continue
codeSet.add(code)
}
} catch {
// 忽略不可读目录,继续尝试其他候选路径
}
}
return Array.from(codeSet)
}
private getKvcommCandidates(accountPath?: string): string[] {
const home = homedir()
const candidates = new Set<string>([
// 与用户实测路径一致Documents/xwechat_files -> Documents/app_data/net/kvcomm
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'app_data', 'net', 'kvcomm'),
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'xwechat', 'net', 'kvcomm'),
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'net', 'kvcomm'),
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat', 'net', 'kvcomm')
])
if (accountPath) {
// 规则:把路径中的 xwechat_files 替换为 app_data然后拼 net/kvcomm
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
const marker = '/xwechat_files'
const idx = normalized.indexOf(marker)
if (idx >= 0) {
const base = normalized.slice(0, idx)
candidates.add(`${base}/app_data/net/kvcomm`)
}
let cursor = accountPath
for (let i = 0; i < 6; i++) {
candidates.add(join(cursor, 'net', 'kvcomm'))
const next = dirname(cursor)
if (next === cursor) break
cursor = next
}
}
return Array.from(candidates)
}
private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null {
for (let i = 0; i < data.length - 34; i++) {
if (this._isAlphaNum(data[i])) continue
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this._isAlphaNum(data[i + j])) { valid = false; break }
}
if (!valid) continue
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
const keyBytes = data.subarray(i + 1, i + 33)
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
}
return null
}
private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null {
for (let i = 0; i < data.length - 65; i++) {
let valid = true
for (let j = 0; j < 32; j++) {
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break }
}
if (!valid) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
}
return null
}
private _isAlphaNum(b: number): boolean {
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39)
}
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
try {
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
// 兜底策略:遍历任意 16-byte 候选,提升 macOS 内存布局差异下的命中率
private _searchAny16Key(data: Buffer, ciphertext: Buffer): string | null {
for (let i = 0; i + 16 <= data.length; i++) {
const keyBytes = data.subarray(i, i + 16)
if (!this._verifyAesKey16Raw(keyBytes, ciphertext)) continue
if (!this._isMostlyPrintableAscii(keyBytes)) continue
return keyBytes.toString('ascii')
}
return null
}
private _verifyAesKey16Raw(keyBytes16: Buffer, ciphertext: Buffer): boolean {
try {
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes16, null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private _isMostlyPrintableAscii(keyBytes16: Buffer): boolean {
let printable = 0
for (const b of keyBytes16) {
if (b >= 0x20 && b <= 0x7E) printable++
}
return printable >= 14
}
}