← WebKit Silent-Fix Report — 2026-W22

597af50a64  Potential use after free of m_stream in ReadableStreamBYOBReader::visitAdditionalChildren()

severity high class Race confidence 0.85 WebCore Streams exploitable-grade
Chris Dumez Tue May 26 22:28:38 2026 -0700 full: 597af50a6484453da5c501a12f8145eb07f3fcf7 bug report ↗ view on GitHub ↗
Primitive: GC-thread visits m_stream racing release in BYOB reader
Triage note: Same lifetime/race pattern: locks all m_stream access so the GC visitor cannot race with release, a genuine memory-safety fix.
Contents

The bug at a glance

ReadableStreamBYOBReader is obtained via stream.getReader({mode:‘byob’}) and, like the default reader, is visited by the concurrent GC thread which dereferenced m_stream while the main thread could null it. Reachable from ordinary script, cross-thread UAF of a live ReadableStream, so high.

Exactly the sibling of the default-reader race: ReadableStreamBYOBReader::visitAdditionalChildrenInGCThread and isReachableFromOpaqueRoots dereference m_stream on the GC thread, while releaseLock/genericRelease/initialize mutate it on the main thread. The fix adds m_streamLock and serializes all m_stream access through it.

Root cause

ReadableStreamBYOBReader holds RefPtr<ReadableStream> m_stream, set in initialize() and cleared in genericRelease() (m_stream = nullptr). The GC marking thread runs visitAdditionalChildrenInGCThread(visitor), which does if (m_stream) m_stream->visitAdditionalChildrenInGCThread(visitor), and isReachableFromOpaqueRoots(), which reads readIntoRequestsSize() && m_stream && m_stream->isReachableFromOpaqueRoots(). Both dereference m_stream with no synchronization.

On the main thread, readForBindings/releaseLock guard on if (!m_stream), read() and genericRelease() take Ref stream = *m_stream, genericCancel/destructor take RefPtr stream = m_stream, and genericRelease finally assigns m_stream = nullptr. When script releases the BYOB reader (or drops it) during a concurrent marking pass, the main-thread clear can free the ReadableStream just as the GC thread dereferences the stale pointer, producing a use-after-free while marking.

The patch adds mutable Lock m_streamLock with m_stream WTF_GUARDED_BY_LOCK(m_streamLock). initialize() now assigns m_stream under the lock; readForBindings/releaseLock/cancel snapshot the presence of a stream under the lock into a local (bool hasStream or RefPtr); read/genericRelease/genericCancel/destructor snapshot m_stream into a local RefPtr under the lock; genericRelease’s final null-out is locked; and both GC-thread functions take the lock before dereferencing m_stream. Note the read() ASSERT(m_stream)/Ref stream = *m_stream is replaced by a locked RefPtr snapshot, removing an unsynchronized deref that could also observe a torn pointer.

Key code

Lock-guarded GC-thread visitor in ReadableStreamBYOBReader.cpp

bool ReadableStreamBYOBReader::isReachableFromOpaqueRoots() const
{
    Locker locker { m_streamLock };
    return readIntoRequestsSize() && m_stream && m_stream->isReachableFromOpaqueRoots();
}

template<typename Visitor>
void ReadableStreamBYOBReader::visitAdditionalChildrenInGCThread(Visitor& visitor)
{
    Locker locker { m_streamLock };
    if (m_stream)
        SUPPRESS_UNCOUNTED_ARG m_stream->visitAdditionalChildrenInGCThread(visitor);
}

Patch walkthrough

  • Source/WebCore/Modules/streams/ReadableStreamBYOBReader.cpp — Wraps all m_stream access in Locker{m_streamLock}: destructor and read()/genericRelease()/genericCancel() snapshot into a local RefPtr under the lock; readForBindings/releaseLock check if (!m_stream) inside a locked scope; cancel() reads a bool hasStream under the lock; initialize() assigns m_stream = stream under the lock; genericRelease() locks its terminal m_stream = nullptr; isReachableFromOpaqueRoots() and visitAdditionalChildrenInGCThread() take the lock before dereferencing m_stream.
  • Source/WebCore/Modules/streams/ReadableStreamBYOBReader.h — Adds <wtf/Lock.h>, adds mutable Lock m_streamLock, and annotates RefPtr<ReadableStream> m_stream WTF_GUARDED_BY_LOCK(m_streamLock).

Background

ReadableStreamBYOBReader — The ‘bring your own buffer’ reader obtained from getReader({mode:‘byob’}), used for byte streams. It reads directly into caller-supplied ArrayBufferViews and, like the default reader, holds m_stream as a GC-visited back-reference.

initialize() — Sets up the reader by assigning m_stream = &stream and calling stream.setByobReader(this). The pre-patch assignment was unsynchronized; the fix performs it under m_streamLock so the GC thread never observes a half-published pointer.

genericRelease() — The generic reader release algorithm; its final step m_stream = nullptr drops the reader’s reference to the stream. This main-thread write, racing the GC-thread read, is the crux of the UAF.

readIntoRequests — Pending BYOB read requests; isReachableFromOpaqueRoots gates stream reachability on their count. Because that predicate dereferences m_stream, it too must be evaluated under the lock.

Vulnerability window

  1. Baseline — m_stream accessed unsynchronized by main thread (initialize/release) and GC thread (visitor/reachability).
  2. Trigger — Script does getReader({mode:‘byob’}), issues reads, then releaseLock()/drops the reader while a concurrent GC marking pass runs.
  3. Race window — GC thread dereferences m_stream as genericRelease sets it to nullptr and drops the last ref.
  4. UAF — Freed ReadableStream is called into by the GC thread’s visitor/reachability check.
  5. Fix — m_streamLock serializes every access including the GC-thread functions.

Triggering

No regression test ships with the patch. Conceptual trigger: obtain a BYOB reader via getReader({mode:‘byob’}), start reads into ArrayBufferViews, then repeatedly releaseLock()/reacquire while allocating heavily so a concurrent GC marking pass dereferences m_stream just as genericRelease nulls it. Probabilistic; best surfaced under TSAN/ASAN.

Exploitation

  1. Reachability — BYOB readers are standard for byte streams (fetch bodies, etc.); the race is reachable from unprivileged script.
  2. Window widening — Interleave release/reacquire with allocation pressure to keep the marker busy; keep pending read-into requests to influence the reachability predicate.
  3. Primitive — GC-thread dereference of a freed ReadableStream, realistically crash-only absent slot grooming and a won race; no weaponization shown.

Detection & hunting

For defenders and SOC / detection engineers:

  • TSAN race on BYOB m_stream
  • ASAN UAF via BYOB visitor
  • Reachability-check crashes

Audit directions

  • Cross-check default reader
  • Byte-stream controllers
  • initialize/setup publication order
  • ASSERT(m_stream)+deref patterns

Before / after

Loading diff…