Skip to content

feat(windows): Chrome App-Bound Encryption implementation#573

Merged
moonD4rk merged 4 commits intomoonD4rk:mainfrom
slimwang:feat/windows-abe-chrome-v20
Apr 18, 2026
Merged

feat(windows): Chrome App-Bound Encryption implementation#573
moonD4rk merged 4 commits intomoonD4rk:mainfrom
slimwang:feat/windows-abe-chrome-v20

Conversation

@slimwang
Copy link
Copy Markdown
Contributor

Summary

Implements Chrome 127+ App-Bound Encryption (ABE) support for Windows.
Chromium starting in version 127 no longer stores the Local State
master key as a plain DPAPI blob — it's now an app-bound blob
decryptable only from a legitimate signed chrome.exe / msedge.exe /
brave.exe process via the elevation_service COM RPC
(IElevator::DecryptData).

Design is documented in rfcs/010-chrome-abe-integration.md (merged
to main in a separate docs PR). This PR is the implementation.

  • Native C payload (crypto/windows/abe_native/, ~550 lines) —
    self-authored reflective DLL loader (Bootstrap) plus COM business
    logic (abe_extractor.c) and a vendor CLSID/IID table (com_iid.c).
    Zero third-party C vendored.
  • Go reflective injector (utils/injector/) — spawns the browser
    in CREATE_SUSPENDED, VirtualAllocEx-writes the patched payload,
    CreateRemoteThreads into Bootstrap's raw file offset, reads the
    32-byte master key back via ReadProcessMemory, TerminateProcess.
  • Go ABE retriever (crypto/keyretriever/abe_windows.go) — parses
    Local State, strips the APPB prefix, hands the encrypted blob to
    the payload via the HBD_ABE_ENC_B64 env var, returns the decrypted
    master key. Falls through to DPAPI via errNoABEKey on pre-127
    Chromium builds.
  • CLI --abe-key — operator override for supplying a
    pre-decrypted 32-byte hex master key (forensic / offline-analysis
    scenarios where re-injection isn't desirable).

Design highlights (see RFC-010 for full rationale)

  • Pure Go default build — contributors not touching ABE do NOT
    need zig. Default go build links against a friendly stub.
  • Zero disk footprint at runtime — no temp files, no named pipes.
    Payload bytes live only in .rdata (embedded via //go:embed), a
    []byte in Go memory, and the target process's VirtualAllocEx'd
    region. Master key returns via shared-memory ReadProcessMemory
    from remoteBase + 0x40.
  • Zero new Go dependencies — no additions to go.mod.
  • zig cc as the only new toolchain — cross-compiles to
    x86_64-windows-gnu from any host; brew install zig and done.

Test plan

  • go build ./... on Linux / macOS / Windows (stub path)
  • GOOS=windows go vet ./... passes
  • GOOS=windows go vet -tags abe_embed ./... passes
  • make payload builds 76 KB crypto/abe_extractor_amd64.bin
  • make payload-verify confirms Bootstrap export
  • make build-windows produces ~11 MB Windows exe
  • golangci-lint v2.10 (CI pin): 0 issues across darwin/linux/windows
  • go test ./... passes on darwin (native) and linux (Docker)
  • End-to-end on Windows 10 19044 / Chrome 147:
    • 4 ABE-capable browsers: Chrome (223 cookies, 2 passwords),
      Microsoft Edge (81 cookies), Brave (14 cookies, 1 password),
      CocCoc (31 cookies)
    • 9 non-ABE browsers via DPAPI fallback: Arc, Firefox, Opera, OperaGX,
      Vivaldi, Yandex, 360 Speed X, QQ, Sogou
    • 574 cookies extracted, zero non-printable bytes in values

Browser coverage matrix

Browser CLSID source Tested v20 decrypt
Chrome Stable injector-old migration
Chrome Beta same (dup basename, see RFC §14.1)
Microsoft Edge injector-old migration ✅ (v2→v1 fallback)
Brave injector-old migration
CocCoc TypeLib dump from elevation_service.exe
Avast Secure Browser injector-old migration untested
Opera / Vivaldi / Yandex / Arc / 360 / QQ / Sogou not catalogued DPAPI fallback only

Non-ABE platforms

macOS and Linux are completely unaffected. The CLI --abe-key flag is
accepted on all platforms for UX consistency but is a no-op on
non-Windows (crypto/abe_stub_other.go).

Risk / roll-forward

  • All ABE code is gated behind //go:build windows && abe_embed.
    Users who don't want ABE can omit the tag and get a stub.
  • DPAPI path for pre-Chrome-127 is untouched; this is purely additive.
  • Payload binary is git-ignored; CI / release must run make payload
    before Windows release builds.

Known follow-ups (tracked in RFC-010 §14)

  1. StartupInfo.Cb should be explicitly set in reflective_windows.go
    (technical CreateProcess ABI nit, works on Win10+).
  2. ABE hard-errors currently fall through to DPAPI silently. Ideally
    return a specific error for "ABE injection failed" vs "pre-ABE
    browser".
  3. Chrome Beta deduplication (registry-based channel detection).
  4. v20 domain-binding prefix handling if Chromium reintroduces it.
  5. ARM64 / 386 Windows payload variants.

@codecov-commenter
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.29%. Comparing base (eb58ebb) to head (89c37ab).

Files with missing lines Patch % Lines
crypto/abe_stub_other.go 0.00% 2 Missing ⚠️
browser/chromium/decrypt.go 0.00% 1 Missing ⚠️
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              
Flag Coverage Δ
unittests 72.29% <0.00%> (-0.16%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@moonD4rk moonD4rk changed the title feat(windows): Chrome App-Bound Encryption implementation (RFC-010) feat(windows): Chrome App-Bound Encryption implementation Apr 18, 2026
@moonD4rk moonD4rk requested review from Copilot and moonD4rk and removed request for Copilot April 18, 2026 15:25
@moonD4rk
Copy link
Copy Markdown
Owner

LGTM 👍

@moonD4rk moonD4rk merged commit c3d30b9 into moonD4rk:main Apr 18, 2026
11 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-key CLI 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")
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return nil, fmt.Errorf("abe: empty browser key in storage parameter")
return nil, errNoABEKey

Copilot uses AI. Check for mistakes.
Comment on lines +75 to +80

for _, candidate := range loc.fallbacks {
expanded := os.ExpandEnv(candidate)
if fileExists(expanded) {
return expanded, nil
}
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +93

_ = windows.TerminateProcess(pi.Process, 0)
_, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond))
terminated = true

if status != bootstrapKeyStatusReady {
marker := readMarker(pi.Process, remoteBase)
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
_ = 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 {

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +46
loaderRVA, err := validateAndLocateLoader(payload)
if err != nil {
return nil, err
}

Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +185 to +186
_, _ = windows.WaitForSingleObject(windows.Handle(hThread), uint32(wait/time.Millisecond))
return nil
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
_, _ = 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)
}

Copilot uses AI. Check for mistakes.
moonD4rk added a commit that referenced this pull request Apr 19, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants