* 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
21 KiB
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 — cipher versions (v10, v11, v20)
- RFC-006 —
Retriever/ChainRetriever - RFC-009 — other Windows-specific handling
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) |
2. The constraint that shapes the design
elevation_service verifies the caller:
- The calling process's main executable must be a legitimate browser binary (path in
Program Files, signed by the browser vendor). - 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()
→ masterkey.Retrievers{V10: &DPAPIRetriever{}, V20: &ABERetriever{}}
→ 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)
- Read the embedded payload via
//go:embed abe_extractor_amd64.bin(~75 KB). - Prepare a remote Bootstrap parameter block with the scratch pointer and 5 ×
uintptrfunction pointers (see §4.4). - Look up
Bootstrap's raw file offset (not RVA) viadebug/pe.
Stage 3 — Spawn + inject (still our process, target is newly spawned)
CreateProcessW(browser.exe, CREATE_SUSPENDED)
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 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, 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() → scratchBase[0x40..0x5F] (success)
publish_error(code, hr, comErr) (failure)
Stage 5 — Back in our process
WaitForSingleObject(thread, 30s)— covers cold-start ofGoogleChromeElevationService.ReadProcessMemoryfor the 12-byte diagnostic header, then 32-byte key whenstatus == ready.TerminateProcess(browser)— the target was a throwaway from the start.
The returned key flows back up to crypto.DecryptChromiumGCM (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_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 parameter-supplied LoadLibraryA / GetProcAddress |
set_section_protections |
.text → RX, .rdata → R, .data → RW per Characteristics |
invoke_dllmain |
Call mapped DllMain(DLL_PROCESS_ATTACH, scratchBase) |
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
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:
{ "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 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/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.
5. Go integration
5.1 Injector package — utils/injector/
Four files collaborate:
| File | Role |
|---|---|
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). |
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 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_asserts in the same header guarantee compile-time detection of layout drift:
_Static_assert(offsetof(struct BootstrapScratch, marker) == 0x28, "marker offset");
_Static_assert(offsetof(struct BootstrapScratch, hresult) == 0x2C, "hresult 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.
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 wiring & v20 routing
masterkey.DefaultRetrievers() on Windows returns a Retrievers struct with V10 = &DPAPIRetriever{} and V20 = &ABERetriever{}. The two tiers are wired independently — not in a ChainRetriever — because a single Chrome profile upgraded from pre-127 can carry mixed v10+v20 ciphertexts, and both keys must be available for decryptValue to route each ciphertext to its matching tier (see RFC-006 §4.4 and issue #578). ABERetriever.RetrieveKey:
- Reads
Local State→ extractsos_crypt.app_bound_encrypted_key→ stripsAPPBprefix. If the field is missing,ABERetrieverreturns(nil, nil),V20remains empty, and the independently-wiredV10DPAPI tier still runs. - Resolves browser executable via
utils/winutil/browser_path_windows.go(registry App Paths → hardcoded fallback). - Base64-encodes the encrypted blob and passes it as
HBD_ABE_ENC_B64env var. Reflective.Inject(exePath, payload, env)runs the full flow in §3.- 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.DecryptChromiumGCM (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/windows/payload/abe_extractor_amd64.bin) +GOOS=windows go build -tags abe_embed. Theabe_embedtag activates//go:embedon 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-layoutafter any change tobootstrap_layout.h. This also covers theBootstrapParamsfield offsets the Go injector serializes by hand. go.modunchanged — no new dependencies.zigis 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:
- Build artifact on the developer machine (
crypto/windows/payload/abe_extractor_amd64.bin, git-ignored) .rdatasection ofhack-browser-data.exe(//go:embed)- Go
[]bytein our process memory VirtualAllocEx'd RX payload region in the target browser during injection; released onTerminateProcess
- Build artifact on the developer machine (
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
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
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, 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) |
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. 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.
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) |
| 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.
- 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 inHKLM\SOFTWARE\Classes\CLSID. - Mine IIDs from TypeLib — the interface IIDs live in the TypeLib resource of
<InstallDir>\Application\<version>\elevation_service.exe. PowerShell +ITypeLib.GetTypeInfoenumerates them. MapIElevator<Vendor>→ v1 IID,IElevator2<Vendor>→ v2 IID (absent for older vendors). - Determine vtable slot — count
IElevatormethods 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), utils/winutil/browser_meta_windows.go (add a matching winutil.Entry with the right ABEKind and install-path fallbacks; Entry.Key must equal the BrowserConfig.Key of the matching ABE-enabled config), browser/browser_windows.go (set WindowsABE: true on the new BrowserConfig so its Key doubles as the ABE / winutil.Table identifier), then make payload-clean && make build-windows and redeploy.
12. Known issues & future work
Known:
- Non-
com_iid.cbrowsers (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-gnuonly. 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. Left unimplemented pending evidence that current Chrome versions emit this prefix; re-add if encountered.
- Running-browser handling: if the user has the target browser open we spawn a second instance. Some vendors (Opera GX) serialize the elevation service, which could surface conflicts; an opt-in
--kill-runningis future work.
Future (ordered by value):
- Runtime CLSID/IID lookup from
elevation_service_idl.tlb(no rebuild per fork rotation) - More forks via §11 (Opera, Vivaldi, Yandex, Arc)
- x86 payload variant (for legacy 32-bit Chrome installs)
- Optional
--kill-runningflag - EDR-hardened
injector.Strategyvariant (direct syscalls) - Release signing (cosign / SBOM) + reproducible-build CI verification
- ARM64 Windows support
13. Related RFCs
| RFC | Relation |
|---|---|
| RFC-003 Chromium Encryption | v10/v11/v20 cipher format reference; v20 now implemented on Windows per this RFC |
| RFC-006 Key Retrieval | masterkey.Retrievers taxonomy; Windows populates V10 (DPAPI) + V20 (ABE) as independent tier slots |
| RFC-009 Windows Locked Files | Sibling Windows-specific workaround (handle duplication for locked DBs) |