← Chrome — 2026-W03

105200496fd76cef793c4f9da422de0ea9008932  [Cache Encryption] Store derived key in `ChunkedEncryptor`

severity medium class use-after-free CVSS 6.5 confidence 0.6 signal score 8.1 services/network/enterprise AI review CONFIRMED
Primitive: crypto::Aead::Init retains a reference to the passed key bytes; those pointed into EncryptionContext, so if it was destroyed the Aead read freed key memory. Fix copies derived_key_ into ChunkedEncryptor.
Triage note: Genuine dangling-reference UAF of key material, but in the enterprise cache-encryption path (services/network/enterprise) that is not attacker-reachable in default consumer config. Author calls it a 'uaf risk'; watch.

The bug at a glance

This is a latent-UAF hardening fix in the network service (services/network/enterprise), a sandboxed but attacker-adjacent process. crypto::Aead::Init retains a pointer/span into the caller-supplied key buffer rather than copying; ChunkedEncryptor previously let that buffer be owned by the transient EncryptionContext passed by const-ref, so if the context outlived shorter than the encryptor, aead_ would dereference freed key bytes on the next Encrypt/Decrypt. The author states ‘fix uaf risk’ and exploitable=false: no live crash or attacker-reachable lifetime inversion is demonstrated in-tree, and current callers may well keep the context alive long enough. That, plus the enterprise-only cache-encryption surface (feature-gated, not default web content), keeps it below high. It is not low: dereferencing freed key memory is a real memory-safety defect that would produce either a crash or use of attacker-influenced heap contents as an AEAD key, and lifetime coupling this fragile is exactly what refactors silently break later. Medium / 6.5 is the honest read; treat as watch-tier because the security motive (own the key material) is explicit.

The interesting property is API contract mismatch: crypto::Aead::Init is a borrow, not a copy. Any class that Init()s an Aead from a key it does not itself own is a UAF waiting for the owner’s lifetime to shrink. ChunkedEncryptor stored the Aead but borrowed the key from EncryptionContext (a struct passed by const ref into the constructor). The fix flips it to own by copying derived_key_ into a member std::array and Init()ing from that member, so the Aead’s borrow now points at storage with the same lifetime as the Aead. The auditable angle is: grep every crypto::Aead::Init / Encrypt / AEAD wrapper caller and check who owns the key buffer for the Aead’s whole life.

Root cause

ChunkedEncryptor::ChunkedEncryptor(const EncryptionContext& encryption_context) previously initialized aead_(AES_256_GCM_SIV) and then called aead_.Init(encryption_context.derived_key). crypto::Aead::Init records a reference/span to the passed key bytes and does not copy them; the AEAD implementation reads those bytes at Encrypt/Decrypt time, potentially long after the constructor returns. Because encryption_context is a const reference to a caller-owned EncryptionContext, the key bytes live only as long as that context object. If the EncryptionContext was a temporary or was destroyed while the ChunkedEncryptor was retained (the encryptor is a chunked/streaming object, so it is meant to outlive the setup call and process many chunks), aead_ would hold a dangling reference and every subsequent chunk operation would read freed heap memory as the symmetric key. The fix adds a member std::array<uint8_t, kKeySize> derived_key_, copies encryption_context.derived_key into it in the initializer list, and calls aead_.Init(derived_key_). Crucially the member declaration order is changed so derived_key_ and nonce_prefix_ are declared before aead_: members are constructed in declaration order and destroyed in reverse, so derived_key_ is guaranteed to be alive when aead_.Init reads it and to outlive aead_’s destruction. The Aead now borrows from storage it effectively co-owns for its whole life, removing the external lifetime dependency.

Key code

chunked_encryptor.cc/.h — own the key material; declare it before aead_ so Init’s borrow stays valid

// chunked_encryptor.cc
 ChunkedEncryptor::ChunkedEncryptor(const EncryptionContext& encryption_context)
-    : aead_(crypto::Aead::AES_256_GCM_SIV),
-      nonce_prefix_(encryption_context.nonce_prefix) {
-  aead_.Init(encryption_context.derived_key);
+    : derived_key_(encryption_context.derived_key),
+      nonce_prefix_(encryption_context.nonce_prefix),
+      aead_(crypto::Aead::AES_256_GCM_SIV) {
+  aead_.Init(derived_key_);
 }

// chunked_encryptor.h  (member order guarantees derived_key_ outlives aead_)
 private:
-  crypto::Aead aead_;
+  std::array<uint8_t, kKeySize> derived_key_;
   std::array<uint8_t, kNoncePrefixSize> nonce_prefix_;
+  crypto::Aead aead_;

Patch walkthrough

  • services/network/enterprise/encryption/chunked_encryptor.cc — Constructor now copies encryption_context.derived_key into the owned member derived_key_ in the initializer list, and Init()s the Aead from derived_key_ instead of from the borrowed encryption_context.derived_key. After construction the ChunkedEncryptor no longer depends on the EncryptionContext’s lifetime at all.
  • services/network/enterprise/encryption/chunked_encryptor.h — Adds std::array<uint8_t, kKeySize> derived_key_ and reorders members so derived_key_ (and nonce_prefix_) precede aead_. This ordering is load-bearing: C++ constructs members in declaration order and destroys in reverse, so the key buffer is alive before aead_.Init reads it and is destroyed after aead_, keeping the Aead’s internal borrow valid for the object’s whole life.

Background

crypto::Aead::Init semantics — Chromium’s AEAD wrapper stores a reference/span to the key passed to Init rather than copying it, for efficiency; the caller must keep the key buffer alive for the lifetime of the Aead. This borrow contract is the root of the bug.

AES-256-GCM-SIV — The nonce-misuse-resistant AEAD used here for enterprise cache encryption. Reading freed memory as its key produces garbage keys (crash or silent decryption failure), not a classic control-flow hijack.

ChunkedEncryptor / EncryptionContext — ChunkedEncryptor is a streaming encryptor that processes data in chunks and is meant to be retained across many operations; EncryptionContext is a lightweight struct carrying derived_key + nonce_prefix, passed by const ref. The lifetime mismatch (long-lived encryptor, potentially short-lived context) is the trigger condition.

Enterprise cache encryption — A feature-gated enterprise capability in the network service, not part of default web-content processing, which bounds the reachable population and keeps severity in the medium band.

Vulnerability window

  1. origin — ChunkedEncryptor introduced Init()ing crypto::Aead directly from the borrowed EncryptionContext key, coupling the Aead’s validity to an externally owned buffer.
  2. recognition — crbug.com/449792676: identified as UAF risk — Init retains a ref; a destroyed EncryptionContext leaves a dangling key reference in aead_.
  3. fix — 2026-01-12, Haihan Chen lands 105200496f (m@{#1567861}), copying the key into an owned member and reordering members.
  4. disclosure — Silent, no test added (a pure lifetime/ownership change). Marked isSecurity=true, exploitable=false — classic watch-tier defensive fix.

Proof of concept

No test or exploit ships with this commit; it is a lifetime/ownership refactor with a security motive. No exploit is demonstrated and none is invented. Honest reproduction recipe: construct a ChunkedEncryptor from an EncryptionContext, then destroy or overwrite the EncryptionContext (e.g. pass a temporary, or free the object that owns derived_key), then call the encryptor’s chunk Encrypt path. Under ASAN this should surface a heap-use-after-free read inside crypto::Aead when it dereferences the borrowed key span. Whether this is reachable in production depends entirely on current call sites keeping the context alive; the fix removes the dependency regardless. Because the process is sandboxed and the surface is enterprise/feature-gated, and because AEAD key bytes are read (not written) from freed memory, the realistic impact is a crash or a cryptographic failure rather than a corruption primitive — so no PoC code is warranted.

Exploitation

  1. precondition — An EncryptionContext whose lifetime is shorter than the ChunkedEncryptor built from it (temporary, or destroyed after setup). Requires being on a code path that constructs the encryptor — network service, enterprise cache encryption enabled.
  2. reached primitive — Pre-fix: aead_ dereferences freed heap bytes as the AES-256-GCM-SIV key during Encrypt/Decrypt. This is a read-of-freed-memory (UAF read), yielding a crash or use of stale/attacker-influenced heap contents as key material.
  3. escalation — No write primitive and no demonstrated attacker control over the freed allocation’s contents from the diff. Theoretical ceiling is cryptographic weakening (predictable/attacker-shaped key) or DoS; nothing here evidences memory-write corruption or RCE.

Detection & hunting

For defenders and detection engineers:

  • ASAN heap-use-after-free in crypto::Aead —
  • Unexplained AEAD decryption failures —

Audit directions

  • All crypto::Aead::Init callers —
  • Other borrow-semantics crypto wrappers —
  • EncryptionContext lifetime at call sites —

Before / after

Loading diff…