A one-line getter that made Chrome's TextDecoder read freed memory
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.
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:
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.
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.
<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.
/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:
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:
[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:
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:
- Capture a borrowed native view over user-controlled memory.
- Continue argument processing through a path that can re-enter user code.
- 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.