Refactor key extraction: drop V3 support, parallelize V4

Removed all code and logic related to WeChat V3 key extraction, including darwin and windows V3 extractors. Updated extractor selection to return an error for V3. Refactored Windows DLL extractor to run DLL and native (Dart-style) memory scan in parallel for V4, reporting keys as soon as found. Improved image key acquisition flow in the TUI and updated documentation to reflect the new focus on V4, parallel key extraction, and the deprecation of V3 support.
This commit is contained in:
teest114514
2025-12-16 00:15:59 +08:00
parent 91ae4a0886
commit cbf5201216
14 changed files with 631 additions and 897 deletions

View File

@@ -3,7 +3,6 @@ package chatlog
import (
"fmt"
"path/filepath"
"runtime"
"time"
"github.com/rs/zerolog/log"
@@ -225,29 +224,25 @@ func (a *App) inputCapture(event *tcell.EventKey) *tcell.EventKey {
func (a *App) initMenu() {
getDataKey := &menu.Item{
Index: 2,
Name: "获取密钥",
Description: "从进程获取数据密钥 & 图片密钥",
Name: "获取图片密钥",
Description: "扫描内存获取图片密钥(需微信V4)",
Selected: func(i *menu.Item) {
modal := tview.NewModal()
if runtime.GOOS == "darwin" {
modal.SetText("获取密钥中...\n预计需要 20 秒左右的时间,期间微信会卡住,请耐心等待")
} else {
modal.SetText("获取密钥中...")
}
modal.SetText("正在扫描内存获取图片密钥...\n请确保微信已登录并浏览过图片")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
go func() {
err := a.m.GetDataKey()
err := a.m.GetImageKey()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 解密失败
modal.SetText("获取密钥失败: " + err.Error())
modal.SetText("获取图片密钥失败: " + err.Error())
} else {
// 解密成功
modal.SetText("获取密钥成功")
modal.SetText("获取图片密钥成功")
}
// 添加确认按钮

View File

@@ -179,6 +179,18 @@ func (m *Manager) GetDataKey() error {
return nil
}
func (m *Manager) GetImageKey() error {
if m.ctx.Current == nil {
return fmt.Errorf("未选择任何账号")
}
if _, err := m.wechat.GetImageKey(m.ctx.Current); err != nil {
return err
}
m.ctx.Refresh()
m.ctx.UpdateConfig()
return nil
}
func (m *Manager) RestartAndGetDataKey() error {
if m.ctx.Current == nil {
return fmt.Errorf("未选择任何账号")

View File

@@ -67,6 +67,15 @@ func (s *Service) GetDataKey(info *wechat.Account) (string, error) {
return key, nil
}
// GetImageKey extracts the image key from a WeChat process
func (s *Service) GetImageKey(info *wechat.Account) (string, error) {
if info == nil {
return "", fmt.Errorf("no WeChat instance selected")
}
return info.GetImageKey(context.Background())
}
func (s *Service) StartAutoDecrypt() error {
log.Info().Msgf("start auto decrypt, data dir: %s", s.conf.GetDataDir())
dbGroup, err := filemonitor.NewFileGroup("wechat", s.conf.GetDataDir(), `.*\.db$`, []string{"fts"})

View File

@@ -1,264 +0,0 @@
package darwin
import (
"bytes"
"context"
"encoding/hex"
"runtime"
"sync"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/wechat/decrypt"
"github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
"github.com/sjzar/chatlog/internal/wechat/model"
)
const (
MaxWorkersV3 = 8
)
var V3KeyPatterns = []KeyPatternInfo{
{
Pattern: []byte{0x72, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x69, 0x33, 0x32},
Offsets: []int{24},
},
}
type V3Extractor struct {
validator *decrypt.Validator
keyPatterns []KeyPatternInfo
}
func NewV3Extractor() *V3Extractor {
return &V3Extractor{
keyPatterns: V3KeyPatterns,
}
}
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, string, error) {
if proc.Status == model.StatusOffline {
return "", "", errors.ErrWeChatOffline
}
// Check if SIP is disabled, as it's required for memory reading on macOS
if !glance.IsSIPDisabled() {
return "", "", errors.ErrSIPEnabled
}
if e.validator == nil {
return "", "", errors.ErrValidatorNotSet
}
// Create context to control all goroutines
searchCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Create channels for memory data and results
memoryChannel := make(chan []byte, 100)
resultChannel := make(chan string, 1)
// Determine number of worker goroutines
workerCount := runtime.NumCPU()
if workerCount < 2 {
workerCount = 2
}
if workerCount > MaxWorkersV3 {
workerCount = MaxWorkersV3
}
log.Debug().Msgf("Starting %d workers for V3 key search", workerCount)
// Start consumer goroutines
var workerWaitGroup sync.WaitGroup
workerWaitGroup.Add(workerCount)
for index := 0; index < workerCount; index++ {
go func() {
defer workerWaitGroup.Done()
e.worker(searchCtx, memoryChannel, resultChannel)
}()
}
// Start producer goroutine
var producerWaitGroup sync.WaitGroup
producerWaitGroup.Add(1)
go func() {
defer producerWaitGroup.Done()
defer close(memoryChannel) // Close channel when producer is done
err := e.findMemory(searchCtx, uint32(proc.PID), memoryChannel)
if err != nil {
log.Err(err).Msg("Failed to read memory")
}
}()
// Wait for producer and consumers to complete
go func() {
producerWaitGroup.Wait()
workerWaitGroup.Wait()
close(resultChannel)
}()
// Wait for result
select {
case <-ctx.Done():
return "", "", ctx.Err()
case result, ok := <-resultChannel:
if ok && result != "" {
return result, "", nil
}
}
return "", "", errors.ErrNoValidKey
}
// findMemory searches for memory regions using Glance
func (e *V3Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel chan<- []byte) error {
// Initialize a Glance instance to read process memory
g := glance.NewGlance(pid)
// Read memory data
memory, err := g.Read()
if err != nil {
return err
}
totalSize := len(memory)
log.Debug().Msgf("Read memory region, size: %d bytes", totalSize)
// If memory is small enough, process it as a single chunk
if totalSize <= MinChunkSize {
select {
case memoryChannel <- memory:
log.Debug().Msg("Memory sent as a single chunk for analysis")
case <-ctx.Done():
return ctx.Err()
}
return nil
}
chunkCount := MaxWorkers * ChunkMultiplier
// Calculate chunk size based on fixed chunk count
chunkSize := totalSize / chunkCount
if chunkSize < MinChunkSize {
// Reduce number of chunks if each would be too small
chunkCount = totalSize / MinChunkSize
if chunkCount == 0 {
chunkCount = 1
}
chunkSize = totalSize / chunkCount
}
// Process memory in chunks from end to beginning
for i := chunkCount - 1; i >= 0; i-- {
select {
case <-ctx.Done():
return ctx.Err()
default:
// Calculate start and end positions for this chunk
start := i * chunkSize
end := (i + 1) * chunkSize
// Ensure the last chunk includes all remaining memory
if i == chunkCount-1 {
end = totalSize
}
// Add overlap area to catch patterns at chunk boundaries
if i > 0 {
start -= ChunkOverlapBytes
if start < 0 {
start = 0
}
}
chunk := memory[start:end]
log.Debug().
Int("chunk_index", i+1).
Int("total_chunks", chunkCount).
Int("chunk_size", len(chunk)).
Int("start_offset", start).
Int("end_offset", end).
Msg("Processing memory chunk")
select {
case memoryChannel <- chunk:
case <-ctx.Done():
return ctx.Err()
}
}
}
return nil
}
// worker processes memory regions to find V3 version key
func (e *V3Extractor) worker(ctx context.Context, memoryChannel <-chan []byte, resultChannel chan<- string) {
for {
select {
case <-ctx.Done():
return
case memory, ok := <-memoryChannel:
if !ok {
return
}
if key, ok := e.SearchKey(ctx, memory); ok {
select {
case resultChannel <- key:
default:
}
}
}
}
}
func (e *V3Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) {
for _, keyPattern := range e.keyPatterns {
index := len(memory)
for {
select {
case <-ctx.Done():
return "", false
default:
}
// Find pattern from end to beginning
index = bytes.LastIndex(memory[:index], keyPattern.Pattern)
if index == -1 {
break // No more matches found
}
// Try each offset for this pattern
for _, offset := range keyPattern.Offsets {
// Check if we have enough space for the key
keyOffset := index + offset
if keyOffset < 0 || keyOffset+32 > len(memory) {
continue
}
// Extract the key data, which is at the offset position and 32 bytes long
keyData := memory[keyOffset : keyOffset+32]
// Validate key against database header
if e.validator.Validate(keyData) {
log.Debug().
Str("pattern", hex.EncodeToString(keyPattern.Pattern)).
Int("offset", offset).
Str("key", hex.EncodeToString(keyData)).
Msg("Key found")
return hex.EncodeToString(keyData), true
}
}
index -= 1
}
}
return "", false
}
func (e *V3Extractor) SetValidate(validator *decrypt.Validator) {
e.validator = validator
}

View File

@@ -26,14 +26,12 @@ type Extractor interface {
// NewExtractor 创建适合当前平台的密钥提取器
// 对于Windows平台优先使用DLL方式如果DLL存在
func NewExtractor(platform string, version int) (Extractor, error) {
// 暂停对V3版本的支持
if version == 3 {
return nil, fmt.Errorf("当前已暂停对微信V3版本的支持")
}
switch {
case platform == "windows" && version == 3:
// 尝试使用DLL方式
if extractor, err := NewDLLExtractor(platform, version); err == nil {
return extractor, nil
}
// 如果DLL方式失败回退到原来的方式
return windows.NewV3Extractor(), nil
case platform == "windows" && version == 4:
// 尝试使用DLL方式
if extractor, err := NewDLLExtractor(platform, version); err == nil {
@@ -41,8 +39,6 @@ func NewExtractor(platform string, version int) (Extractor, error) {
}
// 如果DLL方式失败回退到原来的方式
return windows.NewV4Extractor(), nil
case platform == "darwin" && version == 3:
return darwin.NewV3Extractor(), nil
case platform == "darwin" && version == 4:
return darwin.NewV4Extractor(), nil
default:

View File

@@ -93,28 +93,123 @@ func (e *DLLExtractor) Extract(ctx context.Context, proc *model.Process) (string
}
e.mu.Lock()
defer e.mu.Unlock()
// 清理之前的初始化(如果存在)
if e.initialized {
e.cleanup()
}
// 初始化DLL
// 注意:初始化必须在锁内完成
if err := e.initialize(proc.PID); err != nil {
e.mu.Unlock()
return "", "", err
}
// 确保无论成功失败都清理
// 注意这里使用defer确保cleanup在函数返回前执行
defer func() {
// DLL相关的清理函数
cleanupDLL := func() {
if e.initialized {
e.cleanup()
}
}
e.mu.Unlock() // 初始化完成后解锁允许并行执行注意pollKeys内部需要处理并发访问或者我们确保它独占DLL资源
// 查看pollKeys实现它只访问e.lastKey和DLL函数且DLLExtractor是单例/单次使用的所以这里解锁只要保证没有其他Extract调用即可。
// 但为了安全我们在pollKeys的goroutine里重新加锁pollKeys是长时间运行的。
// 实际上DLLExtractor的设计似乎假设是单线程使用的。
// 为了安全起见我们在DLL goroutine里持有锁。
// 准备并行执行
var (
finalDataKey string
finalImgKey string
keyMu sync.Mutex // 保护结果更新
wg sync.WaitGroup
)
// 创建可取消的上下文
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// 辅助函数:更新密钥并检查是否完成
updateKeys := func(dk, ik, source string) {
keyMu.Lock()
defer keyMu.Unlock()
updated := false
if dk != "" && finalDataKey == "" {
finalDataKey = dk
updated = true
log.Info().Msgf("通过 %s 获取到数据库密钥", source)
}
if ik != "" && finalImgKey == "" {
finalImgKey = ik
updated = true
log.Info().Msgf("通过 %s 获取到图片密钥", source)
}
// 检查是否所有需要的密钥都已获取
// 对于V4需要DataKey + ImgKey
// 对于V3只需要DataKey
if updated {
if proc.Version == 4 {
if finalDataKey != "" && finalImgKey != "" {
log.Info().Msg("已获取所有所需密钥,提前结束轮询")
cancel()
}
} else {
if finalDataKey != "" {
log.Info().Msg("已获取所有所需密钥,提前结束轮询")
cancel()
}
}
}
}
// 任务 1: DLL 轮询 (运行在Goroutine中)
wg.Add(1)
go func() {
defer wg.Done()
// DLL操作需要持有锁以保护状态
e.mu.Lock()
defer e.mu.Unlock()
defer cleanupDLL() // 确保退出时清理
// 执行轮询,传入回调以便立即报告发现的密钥
dk, ik, _ := e.pollKeys(ctx, proc.Version, func(d, i string) {
updateKeys(d, i, "DLL")
})
// 轮询结束后的最终更新(防止遗漏,虽然回调应该覆盖了大部分情况)
if dk != "" || ik != "" {
updateKeys(dk, ik, "DLL")
}
}()
// 轮询获取密钥
return e.pollKeys(ctx, proc.Version)
// 任务 2: 原生内存扫描 (仅V4版本运行在Goroutine中)
if proc.Version == 4 {
wg.Add(1)
go func() {
defer wg.Done()
log.Info().Msg("并行启动原生内存扫描(Dart模式)...")
v4 := NewV4Extractor()
v4.SetValidate(e.validator)
// 执行扫描
dk, ik, _ := v4.Extract(ctx, proc)
// 更新结果
if dk != "" || ik != "" {
updateKeys(dk, ik, "内存扫描")
}
}()
}
// 等待所有任务完成(或超时/被取消)
wg.Wait()
// 只要获取到了任意密钥,就算成功
var err error
if finalDataKey == "" && finalImgKey == "" {
err = fmt.Errorf("未获取到有效密钥")
}
return finalDataKey, finalImgKey, err
}
// initialize 初始化DLL Hook
@@ -153,7 +248,7 @@ func (e *DLLExtractor) initialize(pid uint32) error {
}
// pollKeys 轮询获取密钥
func (e *DLLExtractor) pollKeys(ctx context.Context, version int) (string, string, error) {
func (e *DLLExtractor) pollKeys(ctx context.Context, version int, onKeyFound func(dataKey, imgKey string)) (string, string, error) {
if !e.initialized {
return "", "", fmt.Errorf("DLL未初始化")
}
@@ -170,7 +265,7 @@ func (e *DLLExtractor) pollKeys(ctx context.Context, version int) (string, strin
for {
select {
case <-ctx.Done():
return "", "", ctx.Err()
return dataKey, imgKey, ctx.Err()
case <-timeout:
// 检查是否获取到了数据密钥
if dataKey != "" {
@@ -239,10 +334,13 @@ func (e *DLLExtractor) pollKeys(ctx context.Context, version int) (string, strin
continue
}
foundNew := false
// 检查是否是数据库密钥
if e.validator != nil && e.validator.Validate(keyBytes) {
if dataKey == "" {
dataKey = key
foundNew = true
msg := "通过DLL找到数据库密钥: " + key
log.Info().Msg(msg)
// 记录到日志文件
@@ -257,6 +355,7 @@ func (e *DLLExtractor) pollKeys(ctx context.Context, version int) (string, strin
// 图片密钥通常是16字节32字符HEX字符串
if len(key) == 64 && dataKey == "" {
dataKey = key
foundNew = true
msg := "通过DLL找到数据库密钥无验证: " + key
log.Info().Msg(msg)
// 记录到日志文件
@@ -266,6 +365,7 @@ func (e *DLLExtractor) pollKeys(ctx context.Context, version int) (string, strin
}
} else if len(key) == 32 && imgKey == "" {
imgKey = key
foundNew = true
msg := "通过DLL找到图片密钥无验证: " + imgKey
log.Info().Msg(msg)
// 记录到日志文件
@@ -280,6 +380,7 @@ func (e *DLLExtractor) pollKeys(ctx context.Context, version int) (string, strin
if e.validator != nil && e.validator.ValidateImgKey(keyBytes) {
if imgKey == "" {
imgKey = key[:32] // 16字节的HEX字符串是32个字符
foundNew = true
msg := "通过DLL找到图片密钥: " + imgKey
log.Info().Msg(msg)
// 记录到日志文件
@@ -290,6 +391,11 @@ func (e *DLLExtractor) pollKeys(ctx context.Context, version int) (string, strin
}
}
// 如果发现了新密钥,立即通过回调报告
if foundNew && onKeyFound != nil {
onKeyFound(dataKey, imgKey)
}
// 如果两个密钥都找到了,返回
if dataKey != "" && imgKey != "" {
return dataKey, imgKey, nil

View File

@@ -1,24 +0,0 @@
package windows
import (
"context"
"github.com/sjzar/chatlog/internal/wechat/decrypt"
)
type V3Extractor struct {
validator *decrypt.Validator
}
func NewV3Extractor() *V3Extractor {
return &V3Extractor{}
}
func (e *V3Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) {
// TODO : Implement the key search logic for V3
return "", false
}
func (e *V3Extractor) SetValidate(validator *decrypt.Validator) {
e.validator = validator
}

View File

@@ -1,13 +0,0 @@
//go:build !windows
package windows
import (
"context"
"github.com/sjzar/chatlog/internal/wechat/model"
)
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, string, error) {
return "", "", nil
}

View File

@@ -1,258 +0,0 @@
package windows
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"fmt"
"runtime"
"sync"
"unsafe"
"github.com/rs/zerolog/log"
"golang.org/x/sys/windows"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/wechat/model"
"github.com/sjzar/chatlog/pkg/util"
)
const (
V3ModuleName = "WeChatWin.dll"
MaxWorkers = 16
)
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, string, error) {
// 即使状态是offline未登录也允许尝试
// 因为用户可能在获取密钥过程中登录微信
if proc.Status == model.StatusOffline {
log.Info().Msg("微信进程存在但未登录,将尝试获取密钥,请登录微信后操作")
// 不返回错误,继续执行
}
// Open WeChat process
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, proc.PID)
if err != nil {
return "", "", errors.OpenProcessFailed(err)
}
defer windows.CloseHandle(handle)
// Check process architecture
is64Bit, err := util.Is64Bit(handle)
if err != nil {
return "", "", err
}
// Create context to control all goroutines
searchCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Create channels for memory data and results
memoryChannel := make(chan []byte, 100)
resultChannel := make(chan string, 1)
// Determine number of worker goroutines
workerCount := runtime.NumCPU()
if workerCount < 2 {
workerCount = 2
}
if workerCount > MaxWorkers {
workerCount = MaxWorkers
}
log.Debug().Msgf("Starting %d workers for V3 key search", workerCount)
// Start consumer goroutines
var workerWaitGroup sync.WaitGroup
workerWaitGroup.Add(workerCount)
for index := 0; index < workerCount; index++ {
go func() {
defer workerWaitGroup.Done()
e.worker(searchCtx, handle, is64Bit, memoryChannel, resultChannel)
}()
}
// Start producer goroutine
var producerWaitGroup sync.WaitGroup
producerWaitGroup.Add(1)
go func() {
defer producerWaitGroup.Done()
defer close(memoryChannel) // Close channel when producer is done
err := e.findMemory(searchCtx, handle, proc.PID, memoryChannel)
if err != nil {
log.Err(err).Msg("Failed to find memory regions")
}
}()
// Wait for producer and consumers to complete
go func() {
producerWaitGroup.Wait()
workerWaitGroup.Wait()
close(resultChannel)
}()
// Wait for result
select {
case <-ctx.Done():
return "", "", ctx.Err()
case result, ok := <-resultChannel:
if ok && result != "" {
return result, "", nil
}
}
return "", "", errors.ErrNoValidKey
}
// findMemoryV3 searches for writable memory regions in WeChatWin.dll for V3 version
func (e *V3Extractor) findMemory(ctx context.Context, handle windows.Handle, pid uint32, memoryChannel chan<- []byte) error {
// Find WeChatWin.dll module
module, isFound := FindModule(pid, V3ModuleName)
if !isFound {
return errors.ErrWeChatDLLNotFound
}
log.Debug().Msg("Found WeChatWin.dll module at base address: 0x" + fmt.Sprintf("%X", module.ModBaseAddr))
// Read writable memory regions
baseAddr := uintptr(module.ModBaseAddr)
endAddr := baseAddr + uintptr(module.ModBaseSize)
currentAddr := baseAddr
for currentAddr < endAddr {
var mbi windows.MemoryBasicInformation
err := windows.VirtualQueryEx(handle, currentAddr, &mbi, unsafe.Sizeof(mbi))
if err != nil {
break
}
// Skip small memory regions
if mbi.RegionSize < 100*1024 {
currentAddr += uintptr(mbi.RegionSize)
continue
}
// Check if memory region is writable
isWritable := (mbi.Protect & (windows.PAGE_READWRITE | windows.PAGE_WRITECOPY | windows.PAGE_EXECUTE_READWRITE | windows.PAGE_EXECUTE_WRITECOPY)) > 0
if isWritable && uint32(mbi.State) == windows.MEM_COMMIT {
// Calculate region size, ensure it doesn't exceed DLL bounds
regionSize := uintptr(mbi.RegionSize)
if currentAddr+regionSize > endAddr {
regionSize = endAddr - currentAddr
}
// Read writable memory region
memory := make([]byte, regionSize)
if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
select {
case memoryChannel <- memory:
log.Debug().Msgf("Memory region: 0x%X - 0x%X, size: %d bytes", currentAddr, currentAddr+regionSize, regionSize)
case <-ctx.Done():
return nil
}
}
}
// Move to next memory region
currentAddr = uintptr(mbi.BaseAddress) + uintptr(mbi.RegionSize)
}
return nil
}
// workerV3 processes memory regions to find V3 version key
func (e *V3Extractor) worker(ctx context.Context, handle windows.Handle, is64Bit bool, memoryChannel <-chan []byte, resultChannel chan<- string) {
// Define search pattern
keyPattern := []byte{0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
ptrSize := 8
littleEndianFunc := binary.LittleEndian.Uint64
// Adjust for 32-bit process
if !is64Bit {
keyPattern = keyPattern[:4]
ptrSize = 4
littleEndianFunc = func(b []byte) uint64 { return uint64(binary.LittleEndian.Uint32(b)) }
}
for {
select {
case <-ctx.Done():
return
case memory, ok := <-memoryChannel:
if !ok {
return
}
index := len(memory)
for {
select {
case <-ctx.Done():
return // Exit if context cancelled
default:
}
// Find pattern from end to beginning
index = bytes.LastIndex(memory[:index], keyPattern)
if index == -1 || index-ptrSize < 0 {
break
}
// Extract and validate pointer value
ptrValue := littleEndianFunc(memory[index-ptrSize : index])
if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
if key := e.validateKey(handle, ptrValue); key != "" {
select {
case resultChannel <- key:
log.Debug().Msg("Valid key found: " + key)
return
default:
}
}
}
index -= 1 // Continue searching from previous position
}
}
}
}
// validateKey validates a single key candidate
func (e *V3Extractor) validateKey(handle windows.Handle, addr uint64) string {
keyData := make([]byte, 0x20) // 32-byte key
if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
return ""
}
// Validate key against database header
if e.validator.Validate(keyData) {
return hex.EncodeToString(keyData)
}
return ""
}
// FindModule searches for a specified module in the process
func FindModule(pid uint32, name string) (module windows.ModuleEntry32, isFound bool) {
// Create module snapshot
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPMODULE|windows.TH32CS_SNAPMODULE32, pid)
if err != nil {
log.Debug().Msgf("Failed to create module snapshot for PID %d: %v", pid, err)
return module, false
}
defer windows.CloseHandle(snapshot)
// Initialize module entry structure
module.Size = uint32(windows.SizeofModuleEntry32)
// Get the first module
if err := windows.Module32First(snapshot, &module); err != nil {
log.Debug().Msgf("Module32First failed for PID %d: %v", pid, err)
return module, false
}
// Iterate through all modules to find WeChatWin.dll
for ; err == nil; err = windows.Module32Next(snapshot, &module) {
if windows.UTF16ToString(module.Module[:]) == name {
return module, true
}
}
return module, false
}

View File

@@ -4,14 +4,18 @@ import (
"context"
"github.com/sjzar/chatlog/internal/wechat/decrypt"
"github.com/sjzar/chatlog/pkg/util"
)
type V4Extractor struct {
validator *decrypt.Validator
logger *util.DLLLogger
}
func NewV4Extractor() *V4Extractor {
return &V4Extractor{}
return &V4Extractor{
logger: util.GetDLLLogger(),
}
}
func (e *V4Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) {

View File

@@ -1,12 +1,12 @@
package windows
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"fmt"
"runtime"
"sync"
"time"
"unsafe"
"github.com/rs/zerolog/log"
@@ -18,6 +18,7 @@ import (
const (
MEM_PRIVATE = 0x20000
MaxWorkers = 8
)
func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, string, error) {
@@ -35,82 +36,107 @@ func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string,
}
defer windows.CloseHandle(handle)
// Create context to control all goroutines
searchCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Create channels for memory data and results
memoryChannel := make(chan []byte, 100)
resultChannel := make(chan [2]string, 1)
// Determine number of worker goroutines
workerCount := runtime.NumCPU()
if workerCount < 2 {
workerCount = 2
}
if workerCount > MaxWorkers {
workerCount = MaxWorkers
}
log.Debug().Msgf("Starting %d workers for V4 key search", workerCount)
// Start consumer goroutines
var workerWaitGroup sync.WaitGroup
workerWaitGroup.Add(workerCount)
for index := 0; index < workerCount; index++ {
go func() {
defer workerWaitGroup.Done()
e.worker(searchCtx, handle, memoryChannel, resultChannel)
}()
}
// Start producer goroutine
var producerWaitGroup sync.WaitGroup
producerWaitGroup.Add(1)
go func() {
defer producerWaitGroup.Done()
defer close(memoryChannel) // Close channel when producer is done
err := e.findMemory(searchCtx, handle, memoryChannel)
if err != nil {
log.Err(err).Msg("Failed to find memory regions")
}
}()
// Wait for producer and consumers to complete
go func() {
producerWaitGroup.Wait()
workerWaitGroup.Wait()
close(resultChannel)
}()
// Wait for result
var finalDataKey, finalImgKey string
// 设置总超时时间60秒
// 这给用户足够的时间去打开图片
timeout := time.After(60 * time.Second)
scanRound := 0
for {
scanRound++
// Create context to control all goroutines for THIS round
scanCtx, cancel := context.WithCancel(ctx)
// 记录提示日志
if scanRound == 1 || scanRound % 5 == 0 {
msg := fmt.Sprintf("正在进行第 %d 轮内存扫描... 请打开任意图片以触发密钥加载", scanRound)
log.Info().Msg(msg)
if e.logger != nil {
e.logger.LogInfo(msg)
}
}
// Create channels for memory data and results
memoryChannel := make(chan []byte, 100)
resultChannel := make(chan [2]string, 1)
// Determine number of worker goroutines
workerCount := runtime.NumCPU()
if workerCount < 2 {
workerCount = 2
}
if workerCount > MaxWorkers {
workerCount = MaxWorkers
}
// Start consumer goroutines
var workerWaitGroup sync.WaitGroup
workerWaitGroup.Add(workerCount)
for index := 0; index < workerCount; index++ {
go func() {
defer workerWaitGroup.Done()
e.worker(scanCtx, handle, memoryChannel, resultChannel)
}()
}
// Start producer goroutine
var producerWaitGroup sync.WaitGroup
producerWaitGroup.Add(1)
go func() {
defer producerWaitGroup.Done()
defer close(memoryChannel) // Close channel when producer is done
err := e.findMemory(scanCtx, handle, memoryChannel)
if err != nil {
log.Err(err).Msg("Failed to find memory regions")
}
}()
// Wait for producer and consumers to complete IN BACKGROUND
// We need this to close resultChannel
go func() {
producerWaitGroup.Wait()
workerWaitGroup.Wait()
close(resultChannel)
}()
// Wait for result of THIS round
var roundImgKey string
var roundDone bool
for !roundDone {
select {
case <-ctx.Done():
cancel()
return "", "", ctx.Err()
case <-timeout:
cancel()
return "", "", fmt.Errorf("获取图片密钥超时(60秒),请确保已打开图片")
case result, ok := <-resultChannel:
if !ok {
// Channel closed, round finished
roundDone = true
break
}
// Found something?
if result[1] != "" {
roundImgKey = result[1]
// Found it!
cancel()
return "", roundImgKey, nil
}
}
}
cancel() // Ensure cleanup of this round
// If we are here, round finished but no key found.
// Wait a bit before next round
select {
case <-ctx.Done():
return "", "", ctx.Err()
case result, ok := <-resultChannel:
if !ok {
// Channel closed, all workers finished, return whatever keys we found
if finalDataKey != "" || finalImgKey != "" {
return finalDataKey, finalImgKey, nil
}
return "", "", errors.ErrNoValidKey
}
// Update our best found keys
if result[0] != "" {
finalDataKey = result[0]
}
if result[1] != "" {
finalImgKey = result[1]
}
// If we have both keys, we can return early
if finalDataKey != "" && finalImgKey != "" {
cancel() // Cancel remaining work
return finalDataKey, finalImgKey, nil
}
case <-timeout:
return "", "", fmt.Errorf("获取图片密钥超时(60秒),请确保已打开图片")
case <-time.After(1 * time.Second):
// Continue to next round
}
}
}
@@ -141,8 +167,10 @@ func (e *V4Extractor) findMemory(ctx context.Context, handle windows.Handle, mem
continue
}
// Check if memory region is readable and private
if memInfo.State == windows.MEM_COMMIT && (memInfo.Protect&windows.PAGE_READWRITE) != 0 && memInfo.Type == MEM_PRIVATE {
// Check if memory region is readable and private (Matching Dart logic)
// Dart: _isReadableProtect check (Not NOACCESS, Not GUARD)
isReadable := (memInfo.Protect&windows.PAGE_NOACCESS) == 0 && (memInfo.Protect&windows.PAGE_GUARD) == 0
if memInfo.State == windows.MEM_COMMIT && isReadable && memInfo.Type == MEM_PRIVATE {
// Calculate region size, ensure it doesn't exceed limit
regionSize := uintptr(memInfo.RegionSize)
if currentAddr+regionSize > maxAddr {
@@ -168,20 +196,22 @@ func (e *V4Extractor) findMemory(ctx context.Context, handle windows.Handle, mem
return nil
}
// workerV4 processes memory regions to find V4 version key
// worker processes memory regions to find V4 version key
func (e *V4Extractor) worker(ctx context.Context, handle windows.Handle, memoryChannel <-chan []byte, resultChannel chan<- [2]string) {
// Define search pattern for V4
keyPattern := []byte{
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x2F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// Data Key scanning logic removed as per requirement.
// Native scanner is now exclusively for Image Key (Dart mode).
// Helper to check if byte is lowercase alphanumeric
isAlphaNumLower := func(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')
}
ptrSize := 8
littleEndianFunc := binary.LittleEndian.Uint64
// Track found keys
var dataKey, imgKey string
keysFound := make(map[uint64]bool) // Track processed addresses to avoid duplicates
var imgKey string // dataKey removed
// Logging flags and counters
validatorWarned := false
candidateCount := 0
for {
select {
@@ -190,73 +220,106 @@ func (e *V4Extractor) worker(ctx context.Context, handle windows.Handle, memoryC
case memory, ok := <-memoryChannel:
if !ok {
// Memory scanning complete, return whatever keys we found
if dataKey != "" || imgKey != "" {
if candidateCount > 0 {
msg := fmt.Sprintf("内存扫描结束,共检查了 %d 个候选图片密钥字符串", candidateCount)
log.Debug().Msg(msg)
if e.logger != nil {
e.logger.LogDebug(msg)
}
}
if imgKey != "" {
select {
case resultChannel <- [2]string{dataKey, imgKey}:
case resultChannel <- [2]string{"", imgKey}:
default:
}
}
return
}
index := len(memory)
for {
select {
case <-ctx.Done():
return // Exit if context cancelled
default:
// Search for Image Key String (Scan for 32-byte alphanumeric string)
// Only if we haven't found ImgKey yet
if imgKey == "" {
if e.validator == nil {
if !validatorWarned {
msg := "验证器未初始化(可能未找到*_t.dat文件),跳过图片密钥扫描"
log.Warn().Msg(msg)
if e.logger != nil {
e.logger.LogWarning(msg)
}
validatorWarned = true
}
continue
}
// Find pattern from end to beginning
index = bytes.LastIndex(memory[:index], keyPattern)
if index == -1 || index-ptrSize < 0 {
break
}
// Extract and validate pointer value
ptrValue := littleEndianFunc(memory[index-ptrSize : index])
if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
// Skip if we've already processed this address
if keysFound[ptrValue] {
index -= 1
// We scan the memory block for sequences of 32 alphanumeric characters
// The logic mimics img-key.dart: check boundaries and content
for i := 0; i <= len(memory)-32; i++ {
// Optimization: Check first byte
if !isAlphaNumLower(memory[i]) {
continue
}
keysFound[ptrValue] = true
// Validate key and determine type
if key, isImgKey := e.validateKey(handle, ptrValue); key != "" {
if isImgKey {
if imgKey == "" {
imgKey = key
log.Debug().Msg("Image key found: " + key)
// Report immediately when found
select {
case resultChannel <- [2]string{dataKey, imgKey}:
case <-ctx.Done():
return
}
}
} else {
if dataKey == "" {
dataKey = key
log.Debug().Msg("Data key found: " + key)
// Report immediately when found
select {
case resultChannel <- [2]string{dataKey, imgKey}:
case <-ctx.Done():
return
}
}
}
// Boundary check: previous byte must NOT be alphanumeric (unless it's start of block)
// Note: strictly speaking we should check across blocks, but here we check within block
if i > 0 && isAlphaNumLower(memory[i-1]) {
continue
}
// If we have both keys, exit worker
if dataKey != "" && imgKey != "" {
log.Debug().Msg("Both keys found, worker exiting")
return
// Check if we have 32 valid chars
isValid := true
for j := 1; j < 32; j++ {
if !isAlphaNumLower(memory[i+j]) {
isValid = false
i += j // Skip forward
break
}
}
if !isValid {
continue
}
// Boundary check: next byte (33rd) must NOT be alphanumeric
if i+32 < len(memory) && isAlphaNumLower(memory[i+32]) {
continue
}
// Found a candidate 32-byte string
candidateCount++
if candidateCount % 5000 == 0 {
msg := fmt.Sprintf("正在扫描图片密钥... 已检查 %d 个候选字符串", candidateCount)
log.Debug().Msg(msg)
if e.logger != nil {
e.logger.LogDebug(msg)
}
}
candidate := memory[i : i+32]
// Validate using existing validator (which now supports the *_t.dat check)
// We pass the full 32 bytes, validator takes first 16
if e.validator.ValidateImgKey(candidate) {
// Found it!
// Return hex encoded first 16 bytes
foundKey := hex.EncodeToString(candidate[:16])
if imgKey == "" {
imgKey = foundKey
msg := fmt.Sprintf("通过字符串扫描找到图片密钥! (在检查了 %d 个候选后) Key: %s", candidateCount, foundKey)
log.Info().Msg(msg)
if e.logger != nil {
e.logger.LogStatus(1, msg)
}
select {
case resultChannel <- [2]string{"", imgKey}:
case <-ctx.Done():
return
}
}
// Skip past this key
i += 32
}
}
index -= 1 // Continue searching from previous position
}
}
}
@@ -264,20 +327,21 @@ func (e *V4Extractor) worker(ctx context.Context, handle windows.Handle, memoryC
// validateKey validates a single key candidate and returns the key and whether it's an image key
func (e *V4Extractor) validateKey(handle windows.Handle, addr uint64) (string, bool) {
if e.validator == nil {
return "", false
}
keyData := make([]byte, 0x20) // 32-byte key
if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
return "", false
}
// First check if it's a valid database key
if e.validator.Validate(keyData) {
return hex.EncodeToString(keyData), false // Data key
}
// Then check if it's a valid image key
// Data Key validation removed.
// Only check if it's a valid image key
if e.validator.ValidateImgKey(keyData) {
return hex.EncodeToString(keyData[:16]), true // Image key
}
return "", false
}
}

View File

@@ -5,11 +5,13 @@ import (
"fmt"
"os"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/wechat/decrypt"
"github.com/sjzar/chatlog/internal/wechat/key"
"github.com/sjzar/chatlog/internal/wechat/key/windows"
"github.com/sjzar/chatlog/internal/wechat/model"
)
@@ -216,13 +218,61 @@ func (a *Account) clearAccountData() {
// GetKey 获取账号的密钥
func (a *Account) GetKey(ctx context.Context) (string, string, error) {
// 如果已经有数据密钥,直接返回(优先使用保存的密钥)
// 对于微信V4图片密钥可能不是必需的所以即使没有图片密钥也返回数据密钥
if a.Key != "" {
log.Info().Msgf("使用保存的数据密钥,账号: %s", a.Name)
return a.Key, a.ImgKey, nil
hasDataKey := a.Key != ""
hasImgKey := a.ImgKey != ""
isV4 := a.Version == 4
// 1. 如果已有Data Key
if hasDataKey {
// 非V4或者V4且有图片Key -> 完美,直接返回
if !isV4 || hasImgKey {
log.Info().Msgf("使用保存的密钥,账号: %s", a.Name)
return a.Key, a.ImgKey, nil
}
// V4且缺图片Key -> 尝试补全
// 此时我们不走标准的Extractor (它会走DLL然后等待30秒),而是直接用原生扫描器
if isV4 && !hasImgKey && a.Platform == "windows" {
log.Info().Msgf("账号 %s 已有数据库密钥,正在尝试使用内存扫描补全图片密钥...", a.Name)
// 刷新状态以获取最新的Process对象
if err := a.RefreshStatus(); err != nil {
log.Warn().Err(err).Msg("刷新进程状态失败,返回现有密钥")
return a.Key, a.ImgKey, nil
}
process, err := GetProcess(a.Name)
if err != nil {
return a.Key, a.ImgKey, nil
}
// 准备验证器
var validator *decrypt.Validator
if process.DataDir != "" {
validator, err = decrypt.NewValidator(process.Platform, process.Version, process.DataDir)
if err != nil {
log.Warn().Err(err).Msg("创建验证器失败")
}
}
// 直接调用原生V4扫描器
v4 := windows.NewV4Extractor()
if validator != nil {
v4.SetValidate(validator)
}
_, imgKey, err := v4.Extract(ctx, process)
if err == nil && imgKey != "" {
a.ImgKey = imgKey
log.Info().Msg("成功补全图片密钥")
} else {
log.Warn().Msg("补全图片密钥失败,仅返回数据库密钥")
}
return a.Key, a.ImgKey, nil
}
}
// 2. 如果没有Data Key -> 走标准流程 (DLL)
// 刷新进程状态
if err := a.RefreshStatus(); err != nil {
return "", "", errors.RefreshProcessStatusFailed(err)
@@ -243,10 +293,28 @@ func (a *Account) GetKey(ctx context.Context) (string, string, error) {
return "", "", err
}
// 如果是V4版本且DataDir为空等待微信登录及数据目录就绪
// 原生扫描器强烈依赖DataDir来查找验证样本
if isV4 && process.DataDir == "" {
log.Info().Msg("检测到V4版本且数据目录未就绪等待微信登录...")
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
if err := a.RefreshStatus(); err == nil {
if p, err := GetProcess(a.Name); err == nil && p.DataDir != "" {
process = p
a.DataDir = p.DataDir
log.Info().Msgf("数据目录已就绪: %s", p.DataDir)
break
}
}
}
}
// 只有在DataDir存在时才创建验证器
// 对于DLL方式微信可能未登录DataDir可能为空或路径不存在
var validator *decrypt.Validator
if process.DataDir != "" {
log.Info().Msgf("准备创建验证器DataDir: %s", process.DataDir)
validator, err = decrypt.NewValidator(process.Platform, process.Version, process.DataDir)
if err != nil {
// 如果创建验证器失败,记录警告但不返回错误
@@ -261,6 +329,8 @@ func (a *Account) GetKey(ctx context.Context) (string, string, error) {
}
// 提取密钥
// 注意如果这是V4且DLL只拿到Data Keydll_extractor内部的fallback机制会被触发
// 自动去跑原生扫描来拿Image Key
dataKey, imgKey, err := extractor.Extract(ctx, process)
if err != nil {
return "", "", err
@@ -277,6 +347,67 @@ func (a *Account) GetKey(ctx context.Context) (string, string, error) {
return dataKey, imgKey, nil
}
// GetImageKey 仅尝试获取图片密钥(使用原生扫描器)
func (a *Account) GetImageKey(ctx context.Context) (string, error) {
// 只有Windows V4支持此功能
if a.Platform != "windows" || a.Version != 4 {
return "", fmt.Errorf("只支持Windows微信V4版本获取图片密钥")
}
// 刷新进程状态
if err := a.RefreshStatus(); err != nil {
return "", errors.RefreshProcessStatusFailed(err)
}
process, err := GetProcess(a.Name)
if err != nil {
return "", err
}
// 等待DataDir就绪
if process.DataDir == "" {
log.Info().Msg("检测到数据目录未就绪,等待微信登录...")
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
if err := a.RefreshStatus(); err == nil {
if p, err := GetProcess(a.Name); err == nil && p.DataDir != "" {
process = p
a.DataDir = p.DataDir
log.Info().Msgf("数据目录已就绪: %s", p.DataDir)
break
}
}
}
}
if process.DataDir == "" {
return "", fmt.Errorf("数据目录未就绪,无法进行图片密钥扫描,请确保微信已登录")
}
// 准备验证器
validator, err := decrypt.NewValidator(process.Platform, process.Version, process.DataDir)
if err != nil {
return "", fmt.Errorf("创建验证器失败(请确保已浏览过图片以生成缓存): %v", err)
}
// 直接调用原生V4扫描器
v4 := windows.NewV4Extractor()
v4.SetValidate(validator)
log.Info().Msg("正在启动内存扫描以获取图片密钥...")
_, imgKey, err := v4.Extract(ctx, process)
if err != nil {
return "", err
}
if imgKey != "" {
a.ImgKey = imgKey
return imgKey, nil
}
return "", fmt.Errorf("未能获取到图片密钥")
}
// DecryptDatabase 解密数据库
func (a *Account) DecryptDatabase(ctx context.Context, dbPath, outputPath string) error {
// 获取密钥