feat(windows): Chrome App-Bound Encryption implementation#573
feat(windows): Chrome App-Bound Encryption implementation#573moonD4rk merged 4 commits intomoonD4rk:mainfrom
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #573 +/- ##
==========================================
- Coverage 72.44% 72.29% -0.16%
==========================================
Files 55 56 +1
Lines 2272 2274 +2
==========================================
- Hits 1646 1644 -2
- Misses 472 476 +4
Partials 154 154
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
LGTM 👍 |
There was a problem hiding this comment.
Pull request overview
Implements Windows support for Chrome 127+ App-Bound Encryption (ABE) by adding a reflective-injection based key retrieval path, while preserving DPAPI as a fallback for non-ABE/pre-127 profiles.
Changes:
- Add a Windows reflective injector (Go) plus PE helpers to locate and execute the payload entrypoint.
- Add a native Windows payload (C) that calls the browser’s elevation_service COM interface to decrypt the app-bound master key.
- Wire ABE into the Windows key retrieval chain and add a
--abe-keyCLI override + embedded/stub payload build modes.
Reviewed changes
Copilot reviewed 23 out of 24 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| utils/injector/strategy.go | Defines injector strategy interface used by Windows implementation. |
| utils/injector/reflective_windows.go | Implements suspended-process spawn, remote payload write, remote thread execution, and scratch readback. |
| utils/injector/pe_windows.go | PE export parsing to locate payload entrypoint file offset. |
| utils/injector/arch_windows.go | Detects PE architecture to enforce amd64 payload usage. |
| utils/browserutil/path_windows.go | Adds Windows browser executable path resolution (registry + fallbacks). |
| types/category.go | Repurposes BrowserConfig.Storage on Windows as an ABE browser key trigger. |
| crypto/windows/abe_native/com_iid.h | Declares vendor CLSID/IID table interface for COM calls. |
| crypto/windows/abe_native/com_iid.c | Provides vendor CLSID/IID mappings and vtable slot selection. |
| crypto/windows/abe_native/bootstrap.h | Defines scratch layout/offset contract shared with Go injector. |
| crypto/windows/abe_native/bootstrap.c | Reflective loader that maps/relocs/import-fixes the DLL and calls DllMain. |
| crypto/windows/abe_native/abe_extractor.c | COM logic to decrypt the app-bound key and publish it back to scratch. |
| crypto/windows/abe_native/Makefile.frag | Zig-based build rules for producing the payload binary. |
| crypto/keyretriever/keyretriever_windows.go | Switches Windows default retriever to ABE→DPAPI chain. |
| crypto/keyretriever/abe_windows.go | Implements ABE key retrieval from Local State + reflective injection runner. |
| crypto/abe_windows.go | Adds global --abe-key storage + payload accessor for Windows. |
| crypto/abe_stub_windows.go | Provides a friendly error when payload embedding tag isn’t enabled. |
| crypto/abe_stub_other.go | Makes --abe-key a no-op on non-Windows platforms. |
| crypto/abe_embed_windows.go | Embeds the payload binary behind windows && abe_embed. |
| cmd/hack-browser-data/dump.go | Adds --abe-key flag and wires it into crypto key handling. |
| browser/chromium/decrypt_test.go | Removes the prior “v20 unsupported” placeholder test. |
| browser/chromium/decrypt.go | Routes v20 to the Chromium decrypt path (key retrieval now supplies correct key). |
| browser/browser_windows.go | Populates Storage for ABE-capable Chromium browsers on Windows. |
| Makefile | Adds payload build integration and a build-windows target. |
| .gitignore | Whitelists C/headers/Makefiles while continuing to ignore built payload binaries. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) { | ||
| browserKey := strings.TrimSpace(storage) | ||
| if browserKey == "" { | ||
| return nil, fmt.Errorf("abe: empty browser key in storage parameter") |
There was a problem hiding this comment.
When storage is empty, ABERetriever returns an error ("empty browser key…"). On Windows configs where Storage is intentionally left blank for non-ABE browsers, this needlessly pollutes the chain error context and can obscure the real DPAPI failure. Consider treating empty storage as “not applicable” (e.g., return errNoABEKey or (nil, nil)), so the chain cleanly falls through to DPAPI.
| return nil, fmt.Errorf("abe: empty browser key in storage parameter") | |
| return nil, errNoABEKey |
|
|
||
| for _, candidate := range loc.fallbacks { | ||
| expanded := os.ExpandEnv(candidate) | ||
| if fileExists(expanded) { | ||
| return expanded, nil | ||
| } |
There was a problem hiding this comment.
Fallback paths use %ProgramFiles% / %LocalAppData% syntax, but os.ExpandEnv expands $VAR/${VAR} placeholders (so these likely won't expand and will never match on registry miss). Consider building fallback paths from os.Getenv + filepath.Join, or switch the strings to $ProgramFiles, $LocalAppData, etc., so expansion works on Windows.
|
|
||
| _ = windows.TerminateProcess(pi.Process, 0) | ||
| _, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond)) | ||
| terminated = true | ||
|
|
||
| if status != bootstrapKeyStatusReady { | ||
| marker := readMarker(pi.Process, remoteBase) |
There was a problem hiding this comment.
status/key are read before TerminateProcess, but marker is read after the process is terminated. Since ReadProcessMemory will typically fail on a dead process, marker will usually be 0 and the error loses its diagnostic value. Read the marker before terminating the process (or delay termination until after diagnostic reads).
| _ = windows.TerminateProcess(pi.Process, 0) | |
| _, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond)) | |
| terminated = true | |
| if status != bootstrapKeyStatusReady { | |
| marker := readMarker(pi.Process, remoteBase) | |
| marker := readMarker(pi.Process, remoteBase) | |
| _ = windows.TerminateProcess(pi.Process, 0) | |
| _, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond)) | |
| terminated = true | |
| if status != bootstrapKeyStatusReady { |
| loaderRVA, err := validateAndLocateLoader(payload) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
There was a problem hiding this comment.
validateAndLocateLoader returns a file offset (via FindExportFileOffset), but the variables/params are named loaderRVA/loaderRVA uint32. Since the injection math is remoteBase + fileOffset, consider renaming to loaderOff (and updating helper names) to avoid confusion about RVA vs raw offset.
| _, _ = windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond)) | ||
| return nil |
There was a problem hiding this comment.
runAndWait ignores the result of WaitForSingleObject and always returns nil, even on WAIT_TIMEOUT/WAIT_FAILED. That makes timeouts look like “payload did not publish key” instead of a clear timeout/failure. Consider checking the wait result and returning a distinct error when the thread times out or the wait fails.
| _, _ = windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond)) | |
| return nil | |
| waitResult, waitErr := windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond)) | |
| switch waitResult { | |
| case windows.WAIT_OBJECT_0: | |
| return nil | |
| case windows.WAIT_TIMEOUT: | |
| return fmt.Errorf("injector: WaitForSingleObject timed out after %s", wait) | |
| case windows.WAIT_FAILED: | |
| if waitErr != nil { | |
| return fmt.Errorf("injector: WaitForSingleObject: %w", waitErr) | |
| } | |
| return fmt.Errorf("injector: WaitForSingleObject failed") | |
| default: | |
| return fmt.Errorf("injector: WaitForSingleObject returned unexpected status 0x%x", waitResult) | |
| } |
Post-ABE cleanup addressing four organizational pain points surfaced after the Chrome App-Bound Encryption feature shipped (#573). Layout changes: * utils/winapi/ (NEW) — low-level Windows API layer. Centralizes Kernel32/Ntdll/Crypt32 LazyDLL handles previously declared 3 times across utils/injector, filemanager, and crypto_windows.go (the latter re-created the handles on every DecryptDPAPI call). Typed wrappers for VirtualAllocEx / CreateRemoteThread / QuerySystemHandles / MapFile / ExpandEnvString / DecryptDPAPI; CallBoolErr handles Win32 errno-0 ambiguity. * utils/winutil/ (NEW, replaces utils/browserutil/) — high-level Windows browser utilities. Unifies browser metadata (ExeName, InstallFallbacks, ABEKind enum) in a single Table; adding a Chromium fork now touches two files (winutil.Table + com_iid.c) instead of three. utils/browserutil/ was misleadingly named (Windows-only in practice). * crypto/windows/payload/ (NEW) — ABE payload embed/stub + .bin artifact moved out of crypto/ root, which now contains only pure-Go primitives. * crypto/crypto_windows.go — shrunk from 78 LOC to a thin shim delegating to utils/winapi. Functional changes: * ExecutablePath: add running-process probe as 3rd resolution tier (registry HKLM -> registry HKCU -> running process -> install fallbacks). Picks up portable / non-standard installs not registered in App Paths. * Fix latent bug in InstallFallbacks expansion: os.ExpandEnv recognizes only Unix-style \$VAR / \${VAR} and leaves Windows-style %VAR% untouched, so fallback paths silently never resolved. Switched to winapi.ExpandEnvString (kernel32!ExpandEnvironmentStringsW). Verified on Windows 10 19044 + Go 1.20.14. * Delete utils/injector/strategy.go (YAGNI): 6-line interface, single implementation, zero interface-typed callers. Tests: * utils/injector/pe_windows_test.go — PE parsing unit tests using C:\\Windows\\System32\\kernel32.dll as fixture (DetectPEArch + FindExportFileOffset, including missing / garbage / truncated inputs). * browser/browser_windows_test.go — TestWinUtilTableCoversABEBrowsers cross-checks winutil.Table against platformBrowsers() Storage keys to catch drift when a fork is added to one but not the other. Validation: * go build ./... on linux/darwin/windows + -tags abe_embed * go test ./... passes on host and on Windows 10 sandbox (all _windows_test.go files executed against real Win32 APIs) * go vet + golangci-lint clean for GOOS=linux/darwin/windows * make payload-clean && make build-windows produces 10 MB exe; payload-verify confirms Bootstrap export * make gen-layout-verify: no drift
Summary
Implements Chrome 127+ App-Bound Encryption (ABE) support for Windows.
Chromium starting in version 127 no longer stores the
Local Statemaster key as a plain DPAPI blob — it's now an app-bound blob
decryptable only from a legitimate signed
chrome.exe/msedge.exe/brave.exeprocess via theelevation_serviceCOM RPC(
IElevator::DecryptData).Design is documented in
rfcs/010-chrome-abe-integration.md(mergedto
mainin a separate docs PR). This PR is the implementation.crypto/windows/abe_native/, ~550 lines) —self-authored reflective DLL loader (
Bootstrap) plus COM businesslogic (
abe_extractor.c) and a vendor CLSID/IID table (com_iid.c).Zero third-party C vendored.
utils/injector/) — spawns the browserin
CREATE_SUSPENDED,VirtualAllocEx-writes the patched payload,CreateRemoteThreads intoBootstrap's raw file offset, reads the32-byte master key back via
ReadProcessMemory,TerminateProcess.crypto/keyretriever/abe_windows.go) — parsesLocal State, strips the APPB prefix, hands the encrypted blob tothe payload via the
HBD_ABE_ENC_B64env var, returns the decryptedmaster key. Falls through to DPAPI via
errNoABEKeyon pre-127Chromium builds.
--abe-key— operator override for supplying apre-decrypted 32-byte hex master key (forensic / offline-analysis
scenarios where re-injection isn't desirable).
Design highlights (see RFC-010 for full rationale)
need zig. Default
go buildlinks against a friendly stub.Payload bytes live only in
.rdata(embedded via//go:embed), a[]bytein Go memory, and the target process'sVirtualAllocEx'dregion. Master key returns via shared-memory
ReadProcessMemoryfrom
remoteBase + 0x40.go.mod.zig ccas the only new toolchain — cross-compiles tox86_64-windows-gnufrom any host;brew install zigand done.Test plan
go build ./...on Linux / macOS / Windows (stub path)GOOS=windows go vet ./...passesGOOS=windows go vet -tags abe_embed ./...passesmake payloadbuilds 76 KBcrypto/abe_extractor_amd64.binmake payload-verifyconfirmsBootstrapexportmake build-windowsproduces ~11 MB Windows exegolangci-lint v2.10(CI pin): 0 issues across darwin/linux/windowsgo test ./...passes on darwin (native) and linux (Docker)Microsoft Edge (81 cookies), Brave (14 cookies, 1 password),
CocCoc (31 cookies)
Vivaldi, Yandex, 360 Speed X, QQ, Sogou
Browser coverage matrix
elevation_service.exeNon-ABE platforms
macOS and Linux are completely unaffected. The CLI
--abe-keyflag isaccepted on all platforms for UX consistency but is a no-op on
non-Windows (
crypto/abe_stub_other.go).Risk / roll-forward
//go:build windows && abe_embed.Users who don't want ABE can omit the tag and get a stub.
make payloadbefore Windows release builds.
Known follow-ups (tracked in RFC-010 §14)
StartupInfo.Cbshould be explicitly set inreflective_windows.go(technical CreateProcess ABI nit, works on Win10+).
return a specific error for "ABE injection failed" vs "pre-ABE
browser".