A one-line getter that made Chrome's TextDecoder read freed memory

highCVE-2026-3921browser

On 17 February 2026, we reported a use-after-free in Chrome's TextEncoding component. Blink's [PassAsSpan] fast path captured an unowned base::span<const uint8_t> over a TypedArray or ArrayBuffer backing store, then kept converting later arguments in the same call. That second step mattered because later conversions can run attacker-controlled JavaScript. In TextDecoder.decode(input, options), a getter on options.stream could call ArrayBuffer.transfer(0), detach the original buffer, and invalidate the storage while native code still believed it had a live byte span. The eventual read happened in UTF-8 decode, inside renderer process code that thought it was looking at ordinary input bytes.

This was a read-after-free, not an arbitrary write. That is an important distinction. It still earned S1 / P1, CVE-2026-3921, and an emergency set of backmerges, but it also explains why the later VRP discussion framed it as stale-memory read exposure rather than full memory corruption.

Google fixed it on trunk on 24 February, verified the fix through ClusterFuzz the next day, then backmerged it through release lines including M146 and LTS-138. The Chrome VRP later awarded $2,000 for the report.

The dangerous part of decode(input, options)

TextDecoder.decode looks harmless from JavaScript. It takes bytes as the first argument and a small options dictionary as the second. The binding code, though, does not see "bytes" and "options". It sees "do fast conversion for the first argument" and then "continue WebIDL conversion for the second".

For the first argument, Blink used a [PassAsSpan] path that produced an efficient native view over the supplied byte buffer. No copy. No retained owner. Just pointer plus length. That is exactly why the fast path exists.

The trouble is that the call is not over after the first argument is converted. The second argument is still a user-controlled JavaScript value. If it is a dictionary, member access can invoke getters. If it is a proxy, property access can do arbitrary work. So the binding sequence was effectively:

the call shape that made the bug possible - conceptual
auto bytes = ToSpan(input);        // pointer + length into JS-owned storage
auto stream = options.stream;      // getter or proxy can run attacker JS here
return decoder.Decode(bytes, stream);

That is the whole bug in three lines. The binding captured borrowed storage before the call had crossed its last JavaScript execution boundary.

[PassAsSpan] is fast because it owns nothing

base::span<const uint8_t> is a view, not a container. That is its job. It is a pair of values saying "there are n bytes starting here". Lifetime is somebody else's problem.

In a hot API like TextDecoder, that tradeoff is reasonable. Most calls are ordinary. JavaScript passes a Uint8Array, Blink points at the backing store, and native code decodes immediately. Avoiding a copy on every decode is real performance work, not premature cleverness.

The adversary here is not sloppiness. It is a perfectly normal optimization colliding with a place where JavaScript can still re-enter. Browser bindings are full of those edges: dictionary members, proxies, coercions, callbacks, exception paths, and now buffer-transfer operations that can invalidate storage synchronously.

What made this case unsafe was not the span by itself. It was the span plus later re-entrancy plus a storage invalidation primitive.

ArrayBuffer.transfer() turned re-entrancy into a UAF

The report used ArrayBuffer.prototype.transfer(0) inside the options.stream getter. That call detaches the original buffer and transfers its contents into a new buffer object. From native code's perspective, that means the span it captured from the original object may now point at detached, invalid, or unmapped storage.

There is no thread race here. No timer. No worker. No window where the attacker has to win scheduling. The re-entrancy is synchronous and happens on the same stack frame as the API call.

the shape of the PoC - simplified from the report
<script>
let ab = new ArrayBuffer(0x1000);
let view = new Uint8Array(ab);
view.fill(0x41);

new TextDecoder().decode(view, {
  get stream() {
    ab = ab.transfer(0);  // detach original backing store mid-call
    return false;
  },
});
</script>

The exact attached testcase used in the report was passasspan_textdecoder_uaf.html. The important part was not the HTML wrapping. It was the order: capture a span first, then run a getter that detaches the object the span points into, then fall back into native decode with the stale pointer still in hand.

The crash landed where the bytes were finally consumed

The report reproduced cleanly on an ASan build in headless mode. The steps were boring in the best possible way: start an ASan Chrome build with remote debugging enabled, then ask it to open the local PoC in a new tab.

headless reproduction from the report
/home/ubuntu/chromium-asan/out/linux-release-1579808/chrome \
  --headless=new \
  --no-sandbox \
  --remote-debugging-port=9222 \
  about:blank

curl -X PUT \
  "http://127.0.0.1:9222/json/new?file:///absolute/path/to/passasspan_textdecoder_uaf.html"

The resulting renderer crash was a SIGSEGV, and the top of the stack was exactly where you would expect a stale byte span to become a real bug:

symbolized top frames from the report
blink::TextCodecUtf8::Decode(...)
blink::TextDecoder::Decode(...)
blink::TextDecoder::decode(...)
v8_text_decoder::DecodeOperationCallback(...)

ClusterFuzz picked the testcase up almost immediately, reproduced it on Linux ASan, and recommended High severity. The crash type it saw was UNKNOWN READ, again consistent with the underlying primitive: native code reading through a dead span rather than smashing memory directly.

Why TextDecoder was only the symptom

The bug manifested in TextDecoder, but the real fault line was in Blink bindings. The CL that fixed it was not titled "patch TextDecoder". It was titled:

the fix title says where the real bug lived
[bindings] Retain underlying array buffer for [PassAsSpan] arrays...

That is the right level. If a binding helper can hand out a borrowed span over JavaScript-owned bytes, and there are still later argument conversions that can execute user code, then the helper needs to keep the underlying storage alive for the full duration of conversion and use. Otherwise every [PassAsSpan] API becomes a lifetime puzzle.

The conceptual fix is simple:

what changed conceptually
auto bytes = ToSpan(input);
+auto retained_buffer = RetainUnderlyingArrayBuffer(input);
auto stream = options.stream;
return decoder.Decode(bytes, stream);

The point is not the exact helper name. The point is ownership. Borrowed views are fine. Borrowed views that survive a later JavaScript re-entrancy point without retaining their backing store are not.

The release work mattered almost as much as the patch

The trunk fix landed on 24 February 2026. ClusterFuzz verified it on 25 February. From there the discussion in the bug turned immediately to merge pressure: stable, extended stable, beta, and later ChromeOS LTS. That is exactly how browser security fixes should move. Once a patch exists in public source control, the bug is no longer just a private report. It is an n-day countdown.

Backmerge CLs followed for M146 and then LTS-138. By the time the writeup placeholder first appeared in this repo, the issue had been assigned CVE-2026-3921 and fixed across the relevant release lines. The public breadcrumbs are the main fix, the M146 backmerge, the LTS backmerge, and the ChromeOS advisory.

The long tail on browser fixes is not the first landing. It is the release matrix: stable, extended stable, platform-specific channels, ChromeOS LTS, and everything that inherits the vulnerable binding code. "Fixed on main" is only the start of the work.

The transferable lesson

This is a browser bug, but the pattern is general. Any API boundary that does these three things in order is suspect:

  1. Capture a borrowed native view over user-controlled memory.
  2. Continue argument processing through a path that can re-enter user code.
  3. Use the borrowed view after that re-entrancy point.

In this case the re-entrancy came from a dictionary getter and the invalidation primitive was ArrayBuffer.transfer(). In another code base it might be a proxy trap, a callback, a coercion method, or a destructor on an exception path. The bug class is the same. If you have not reached your last user-code execution point yet, you do not own the lifetimes you think you own.

The useful part of this report is not "TextDecoder happened to crash". It is that one binding optimization made a raw pointer outlive the last safe moment to borrow it, and a one-line getter was enough to prove it.