← WebKit Silent-Fix Report — 2026-W21

7a310eed9f  http/tests/webrtc/filtering-ice-candidate-same-origin-frame.html is flaky on DEBUG bots

severity medium class Race confidence 0.62 WebKit NetworkProcess WebRTC exploitable-grade
Youenn Fablet Mon May 18 09:32:33 2026 -0700 full: 7a310eed9feb8c8efb3b04eebe567132a3c91ac9 bug report ↗ view on GitHub ↗
Primitive: data race on WebRTC TCP socket state via concurrent dispatch queue
Triage note: A concurrent dispatch queue allowed unsynchronized parallel access to socket state; serializing removes the race (flaky-on-debug is the tell).
Contents

The bug at a glance

Switching the WebRTC TCP socket dispatch queue from concurrent to serial removes a data race on socket callback state in the NetworkProcess, whose most concrete symptom is resolving the same native promise twice. A double-resolve of an RTCPeerConnection-adjacent promise is undefined behavior that in the best case is a benign flaky test and in the worst case a use-after-free or state-machine corruption on freed promise/socket state. It is bounded because the affected code is per-connection socket setup rather than attacker-controlled data parsing, and the commit is framed as a flakiness fix. Rated by the monitor as a data-race/double-resolve, which is a genuine concurrency-safety defect in a network-facing process even if weaponization is uncertain.

A one-token change — DISPATCH_QUEUE_CONCURRENT to DISPATCH_QUEUE_SERIAL — is the entire fix, and it encodes a subtle invariant: the getInterfaceName completion handler for a TCP socket must never run on two threads at once, because each concurrent invocation races to resolve the same native promise exactly once. The interesting part is that the code was implicitly relying on serialization that a concurrent GCD queue does not provide.

Root cause

NetworkRTCTCPSocketCocoa is the NetworkProcess-side implementation of WebRTC TCP sockets. tcpSocketQueueSingleton() vends the dispatch_queue_t on which the socket’s network callbacks — including the getInterfaceName completion handler — are delivered. The queue is a process-wide singleton held in a NeverDestroyed<OSObjectPtr<dispatch_queue_t>>, so every TCP socket in the process shares it.

Before the patch the queue was created with the concurrent attribute: dispatch_queue_create("WebRTC TCP socket queue", OSObjectPtr { DISPATCH_QUEUE_CONCURRENT }.get()). A concurrent GCD queue may execute multiple submitted blocks simultaneously on different worker threads. The socket completion path assumed at-most-once, mutually-exclusive execution: it reads and mutates socket state and resolves a native promise that must be resolved exactly once. With concurrency, two callbacks (or two deliveries of getInterfaceName) could run in parallel, both observe the promise as unresolved, and both attempt to resolve it — a classic check-then-act race over shared socket state.

The commit message states the invariant directly: ‘We switch from a concurrent to a serial queue so that we are sure that the getInterfaceName callback is not called in parallel. This ensures that we only resolve the native promise once.’ The fix creates the queue as dispatch_queue_create("WebRTC TCP socket queue", DISPATCH_QUEUE_SERIAL). A serial queue runs its blocks one at a time in FIFO order, so the callback can no longer overlap itself; the read-modify-resolve sequence becomes effectively atomic with respect to other blocks on the same queue, and the promise is resolved once.

Double-resolving a promise or double-mutating socket teardown state is the corruption vector: promise resolution typically consumes/moves out a stored completion object and may free associated buffers or unref the socket, so a second concurrent resolve operates on state the first has already torn down. Serialization eliminates the window entirely rather than papering over it with a lock, which is why the diff is a single attribute change.

Key code

NetworkRTCTCPSocketCocoa.mm — serial socket queue

static dispatch_queue_t tcpSocketQueueSingleton()
{
    static NeverDestroyed<OSObjectPtr<dispatch_queue_t>> queue = adoptOSObject(dispatch_queue_create("WebRTC TCP socket queue", DISPATCH_QUEUE_SERIAL));
    return queue.get().get();
}

Patch walkthrough

  • Source/WebKit/NetworkProcess/webrtc/NetworkRTCTCPSocketCocoa.mm — tcpSocketQueueSingleton() is changed to create its shared dispatch_queue_t with DISPATCH_QUEUE_SERIAL instead of the concurrent attribute. This forces all socket callbacks — notably the getInterfaceName completion handler — to run one at a time, removing the parallel-execution race that allowed the native promise to be resolved more than once and socket state to be mutated concurrently.

Background

GCD concurrent vs serial queues — A dispatch_queue_t created with DISPATCH_QUEUE_SERIAL executes submitted blocks one at a time in FIFO order, giving a natural mutual-exclusion and ordering guarantee for anything touched only from that queue. A queue created with DISPATCH_QUEUE_CONCURRENT may run many blocks simultaneously across the global thread pool. Code that reads-then-writes shared state, or that must perform an action exactly once, is only safe on a concurrent queue if it adds its own synchronization; otherwise it races.

NetworkRTCTCPSocketCocoa — This is the WebKit NetworkProcess Cocoa implementation of TCP sockets used by the WebRTC stack (ICE/TURN over TCP). It bridges nw_connection-style networking to libwebrtc’s socket abstraction and delivers events — connect, data, and metadata like getInterfaceName — on a dedicated dispatch queue. Because it runs in the NetworkProcess, it sits on a privileged side of the sandbox relative to the renderer that requested the peer connection.

getInterfaceName and native promises — Resolving the local interface name for a socket is asynchronous and its result is delivered to a completion handler that resolves a native (C++/libwebrtc) promise. A promise abstraction enforces single resolution; resolving twice is contract violation. If the resolve moves out or frees the stored continuation and any captured buffers, a second resolve dereferences or double-frees that state.

Check-then-act double-resolve race — When two threads both test ‘has the promise been resolved?’ and then both resolve it, the guard is defeated because the test and the act are not atomic. On a serial queue the two callbacks cannot interleave, so the first fully completes (including flipping the resolved state) before the second runs, and the second sees the updated state. This is why serializing the queue is a complete fix rather than a mitigation.

Process-wide singleton queue — The queue is a NeverDestroyed singleton shared by every TCP socket in the NetworkProcess. Making it serial imposes global ordering across all such sockets, which is acceptable for this low-volume control path and is the simplest way to guarantee no callback for any socket overlaps another — trading a small amount of parallelism for correctness.

Vulnerability window

  1. Original implementation — The WebRTC TCP socket queue is created concurrent, implicitly assuming callbacks serialize themselves.
  2. Latent race — Under load or timing on DEBUG builds, getInterfaceName completion runs in parallel, and the native promise can be resolved more than once while socket state is mutated concurrently.
  3. Symptom surfaces — http/tests/webrtc/filtering-ice-candidate-same-origin-frame.html is flaky on DEBUG bots (bug 315010 / rdar://177334197), the visible tip of the underlying data race.
  4. Diagnosis — Youenn Fablet identifies the concurrent queue as permitting parallel callback execution and thus double-resolve.
  5. Fix — The queue attribute is switched to DISPATCH_QUEUE_SERIAL, guaranteeing single, ordered callback execution and exactly-once promise resolution.

Triggering

No PoC is added; the referenced repro is the pre-existing flaky WPT http/tests/webrtc/filtering-ice-candidate-same-origin-frame.html which only fails intermittently on DEBUG bots. Trigger: establish an RTCPeerConnection that gathers ICE candidates over TCP so NetworkRTCTCPSocketCocoa’s getInterfaceName path fires repeatedly under timing pressure; the concurrent queue then occasionally delivers overlapping callbacks that resolve the native promise twice. It is inherently racy and not deterministically reproducible from script.

Exploitation

  1. Reach — A renderer creates an RTCPeerConnection with TCP ICE/TURN candidates, driving repeated socket-setup callbacks in the NetworkProcess via NetworkRTCTCPSocketCocoa.
  2. Race window — On the concurrent queue two getInterfaceName completions overlap; both pass the resolved-state check and resolve the same native promise, double-mutating/freeing the stored continuation and socket state.
  3. Feasibility — Weaponization is uncertain: the race is timing-dependent, not attacker-timed, and lives in per-connection control flow rather than in attacker-controlled payload parsing. Realistic classification is a concurrency-safety/double-free-risk fix; observed impact is flakiness/crash rather than a demonstrated exploit primitive.

Detection & hunting

For defenders and SOC / detection engineers:

  • NetworkProcess crashes on the WebRTC TCP socket queue — Correlate NetworkProcess crash reports whose faulting thread is named ‘WebRTC TCP socket queue’ or is in NetworkRTCTCPSocketCocoa callback/promise-resolution frames; double-resolve/double-free signatures there indicate the pre-patch race.
  • Anomalous TCP ICE churn — Flag renderers that create peer connections forcing large numbers of TCP candidate socket setups (relay-only TCP configurations) in rapid succession, which maximizes the race window on unpatched builds.

Audit directions

  • Other DISPATCH_QUEUE_CONCURRENT users in NetworkProcess — Grep WebKit for dispatch_queue_create(…, …CONCURRENT) and DISPATCH_QUEUE_CONCURRENT where the submitted blocks resolve promises, complete handlers exactly once, or read-modify-write shared object state without an explicit lock.
  • libwebrtc socket callback contracts — Audit the WebKit-side WebRTC socket adapters (UDP, TCP, TURN) for exactly-once completion assumptions and confirm each callback runs on a serial queue or under a mutex.
  • Native promise resolution paths — Review NativePromise/DeferredPromise resolution in networking callbacks for idempotency; ensure a second resolution is either impossible by construction (serial queue) or a hard, non-freeing no-op.
  • Singleton queue ordering assumptions — Verify no other code path submitted work to the shared TCP socket queue expecting concurrency for throughput, which the serialization change would now bottleneck.

Before / after

Loading diff…