CVE-2026-9936
Overview
Background
- Mach send right / dead name
- A Mach port is referenced by a per-task name. A send right lets a task message the port; if the backing receive right is destroyed, the send right transitions to a dead name. Names are refcounted integers that the kernel recycles once their user-reference count hits zero.
- `mach_port_mod_refs`
- Syscall that adjusts a right’s user-reference count. For a dead name it fails (
KERN_INVALID_RIGHT/KERN_INVALID_NAME) because there is no live send right to increment. - `base::apple::RetainMachSendRight`
- Existing helper that increments a send right and returns a
ScopedMachSendRightthat is empty (MACH_PORT_NULL) if the increment failed — i.e. it never hands back a name it did not actually acquire a reference to. - `CALayerParams` / `OnDisplayReceivedCALayerParams`
- Browser-process struct (holding a
ScopedRefCountedIOSurfaceMachPort) populated from a GPU-process Mojo message and copy-assigned intoAcceleratedWidgetMac::last_ca_layer_params_(per the advisory; copy-assignment invokesRetain).
Root Cause Analysis
The violated invariant is ownership accounting: a ScopedTypeRef-style traits Retain must return a valid port name only if it successfully took a reference, otherwise the scoper adopts a reference it does not own. The vulnerable IOSurfaceMachPortTraits::Retain did kern_return_t kr = mach_port_mod_refs(mach_task_self(), port, MACH_PORT_RIGHT_SEND, 1); MACH_LOG_IF(ERROR, kr != KERN_SUCCESS, kr) << ...; return port; — it logged the failure but unconditionally returned the port name. When port is a dead name, mach_port_mod_refs fails, no reference is taken, yet the returned name is adopted by the destination ScopedRefCountedIOSurfaceMachPort (e.g. during last_ca_layer_params_ = ca_layer_params;). The source temporary and the destination now each believe they own one reference, but only one (or zero) exists. When each later runs Release → mach_port_deallocate, the name is deallocated more times than it was referenced: a Mach-port over-release.
The patch replaces the body with return base::apple::RetainMachSendRight(port).release();. RetainMachSendRight increments the send right and, on failure, yields an empty ScopedMachSendRight; .release() then returns MACH_PORT_NULL rather than the dead name. The destination scoper therefore adopts MACH_PORT_NULL (a no-op to deallocate) instead of a name it never referenced, eliminating the extra deallocate. The new IOSurface.MachPortRetainDeadName unit test allocates a receive right, inserts a send right, removes the receive right to force a dead name, and asserts Retain(deadname) == MACH_PORT_NULL. Note the advisory/metadata label this CWE-416 “Use after free in GFX”; the diff substantiates a Mach-port send-right over-release leading to port-name reuse / handle confusion — a use-after-free of a kernel handle name rather than a heap object. The realized impact is a cross-process handle-confusion sandbox-escape vector, not a classic heap UAF.
Retain returned the port name even when it failed to acquire a reference, so a scoped owner adopted an unowned (dead-name) reference and later over-released it.Attack Path
- Send a to-be-dead right
A compromised GPU process allocates a Mach receive right, makes a send right, and sends it to the browser in a
CALayerParamsMojo message viaviz::mojom::DisplayClient::OnDisplayReceivedCALayerParams. - Kill the backing right Immediately after queuing the IPC, destroy the receive right in the GPU process so the in-flight send right becomes a dead name by the time the browser processes it. (inferred timing; the diff proves only the dead-name handling defect.)
- Browser deserializes
The browser accepts the dead name (dead names are considered valid handles) into a temporary
CALayerParams. - Copy-assign triggers Retain
AcceleratedWidgetMac::UpdateCALayerTreeexecuteslast_ca_layer_params_ = ca_layer_params;; the memberScopedRefCountedIOSurfaceMachPortcopy-assignment callsRetain(deadname). Pre-patch,mach_port_mod_refsfails but the dead name is returned and adopted. - Over-release
The temporary goes out of scope and
Releasedeallocates the dead name;last_ca_layer_params_still holds the same (now-freed) name and will deallocate it again on its next update/destroy — a second deallocate of a name with no outstanding reference. - Handle confusion → sandbox escape (inferred)
Using Mach name/generation reuse, a different sensitive port (IOKit user client, shared-memory object) is allocated at the recycled name; the stray
mach_port_deallocatethen over-releases that port, giving cross-process handle confusion — a known GPU→browser sandbox-escape primitive (escalation inferred; the diff proves the over-release, not RCE).
Impact Assessment
S:C, scope change). No user privilege is needed beyond GPU-process code execution; the precondition is winning the race to turn an in-flight send right into a dead name before the browser’s copy-assignment. The primitive proven by the diff is a Mach send-right over-release (one extra mach_port_deallocate); escalation to controlled handle confusion and a full sandbox escape is plausible and matches known XNU exploitation patterns but is inferred, not demonstrated by the diff. This is macOS-specific.Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTui/gfx/mac/io_surface_unittest.cc |
modified |
Files Changed
ui/gfx/mac/io_surface.ccui/gfx/mac/io_surface_unittest.cc
Audit Directions
- Other Mach `Retain`/`Traits` implementationsAudit every
ScopedTypeRef/ScopedMachPorttraits and ad-hocmach_port_mod_refs(..., +1)caller that returns a name without checkingkr, especially in GPU↔browser IPC deserialization paths. - Dead-name handling at trust boundariesReview where
PlatformHandle::ReleaseMachSendRightand Mojo deserialization accept dead names into refcounted owners, since dead names are the trigger class. - Copy/move of handle-owning IPC structsCheck structs like
CALayerParamswhose copy/move assignment indirectly callsRetainwhile being populated from untrusted (GPU/renderer) input. - `RetainMachSendRight` adoption everywhereConfirm the preferred helper is used uniformly rather than hand-rolled increment+return patterns.
Patch
From f88c4aae15d560eaf60f64e26b0175ab5b8d5c21 Mon Sep 17 00:00:00 2001 From: Bryan Oltman <[email protected]> Date: Tue, 05 May 2026 12:10:23 -0700 Subject: [PATCH] Fix over-release handling in IOSurfaceMachPortTraits::Retain Retain() now correctly returns MACH_PORT_NULL if mach_port_mod_refs fails, preventing the adoption of unreferenced port names. Fixed: 502104354 Change-Id: If9b731f548e354ca101df659152ac5b6aacf9fd9 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7815394 Commit-Queue: Bryan Oltman <[email protected]> Reviewed-by: Mark Mentovai <[email protected]> Reviewed-by: Avi Drissman <[email protected]> Cr-Commit-Position: refs/heads/main@{#1625633} --- diff --git a/ui/gfx/mac/io_surface.cc b/ui/gfx/mac/io_surface.cc index 9b0a2f4..f138f75 100644 --- a/ui/gfx/mac/io_surface.cc +++ b/ui/gfx/mac/io_surface.cc @@ -10,6 +10,7 @@ #include <stdint.h> #include "base/apple/mach_logging.h" +#include "base/apple/scoped_mach_port.h" #include "base/bits.h" #include "base/command_line.h" #include "base/feature_list.h" @@ -77,11 +78,7 @@ // static mach_port_t IOSurfaceMachPortTraits::Retain(mach_port_t port) { - kern_return_t kr = - mach_port_mod_refs(mach_task_self(), port, MACH_PORT_RIGHT_SEND, 1); - MACH_LOG_IF(ERROR, kr != KERN_SUCCESS, kr) - << "IOSurfaceMachPortTraits::Retain mach_port_mod_refs"; - return port; + return base::apple::RetainMachSendRight(port).release(); } // static diff --git a/ui/gfx/mac/io_surface_unittest.cc b/ui/gfx/mac/io_surface_unittest.cc index 51d4439..8940bb0 100644 --- a/ui/gfx/mac/io_surface_unittest.cc +++ b/ui/gfx/mac/io_surface_unittest.cc @@ -21,6 +21,31 @@ EXPECT_EQ(IOSurfaceGetHeightOfPlane(io_surface.get(), 1), 50u); } +TEST(IOSurface, MachPortRetainDeadName) { + const mach_port_t task = mach_task_self(); + + // Create a port and give it send rights so that it will transition to a dead + // name when the receive right is removed. + mach_port_t port = MACH_PORT_NULL; + ASSERT_EQ(KERN_SUCCESS, + mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &port)); + ASSERT_EQ(KERN_SUCCESS, + mach_port_insert_right(task, port, port, MACH_MSG_TYPE_MAKE_SEND)); + + // Remove the receive right. + ASSERT_EQ(KERN_SUCCESS, + mach_port_mod_refs(task, port, MACH_PORT_RIGHT_RECEIVE, -1)); + mach_port_type_t port_type = MACH_PORT_TYPE_NONE; + ASSERT_EQ(KERN_SUCCESS, mach_port_type(task, port, &port_type)); + ASSERT_TRUE(port_type & MACH_PORT_TYPE_DEAD_NAME) + << "port should have transitioned to a dead name"; + + // Attempting to retain a dead name fails, `Retain(port)` should return NULL. + mach_port_t result = internal::IOSurfaceMachPortTraits::Retain(port); + EXPECT_EQ(result, static_cast<mach_port_t>(MACH_PORT_NULL)) + << "IOSurface should not retain a dead port"; +} + } // namespace } // namespace gfx
Regression Test / PoC
diff --git a/ui/gfx/mac/io_surface_unittest.cc b/ui/gfx/mac/io_surface_unittest.cc
index 51d4439..8940bb0 100644
--- a/ui/gfx/mac/io_surface_unittest.cc
+++ b/ui/gfx/mac/io_surface_unittest.cc
@@ -21,6 +21,31 @@
EXPECT_EQ(IOSurfaceGetHeightOfPlane(io_surface.get(), 1), 50u);
}
+TEST(IOSurface, MachPortRetainDeadName) {
+ const mach_port_t task = mach_task_self();
+
+ // Create a port and give it send rights so that it will transition to a dead
+ // name when the receive right is removed.
+ mach_port_t port = MACH_PORT_NULL;
+ ASSERT_EQ(KERN_SUCCESS,
+ mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &port));
+ ASSERT_EQ(KERN_SUCCESS,
+ mach_port_insert_right(task, port, port, MACH_MSG_TYPE_MAKE_SEND));
+
+ // Remove the receive right.
+ ASSERT_EQ(KERN_SUCCESS,
+ mach_port_mod_refs(task, port, MACH_PORT_RIGHT_RECEIVE, -1));
+ mach_port_type_t port_type = MACH_PORT_TYPE_NONE;
+ ASSERT_EQ(KERN_SUCCESS, mach_port_type(task, port, &port_type));
+ ASSERT_TRUE(port_type & MACH_PORT_TYPE_DEAD_NAME)
+ << "port should have transitioned to a dead name";
+
+ // Attempting to retain a dead name fails, `Retain(port)` should return NULL.
+ mach_port_t result = internal::IOSurfaceMachPortTraits::Retain(port);
+ EXPECT_EQ(result, static_cast<mach_port_t>(MACH_PORT_NULL))
+ << "IOSurface should not retain a dead port";
+}
+
} // namespace
} // namespace gfx
Original Bug Report
Mach port handle confusion in Browser process via CALayerParams copy-assignment
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: A failure to check the return value of mach_port_mod_refs in IOSurfaceMachPortTraits::Retain leads to a Mach port over-release in the browser process. This potential vulnerability can be exploited by a compromised GPU process to achieve a sandbox escape via Mach port handle confusion.
Affected files:
ui/gfx/mac/io_surface.ccui/gfx/ca_layer_params.hui/gfx/mojom/ca_layer_params_mojom_traits.ccui/accelerated_widget_mac/accelerated_widget_mac.mmcomponents/viz/host/host_display_client.cc
Estimated timestamp from git blame: 2016-01-05
Vulnerability Summary
A Mach port over-release vulnerability exists in the browser process due to improper error handling in IOSurfaceMachPortTraits::Retain (ui/gfx/mac/io_surface.cc). The function calls mach_port_mod_refs to increment a send right’s reference count, but returns the port name even if the call fails. This causes base::apple::ScopedTypeRef to adopt a reference it failed to acquire, resulting in an extra mach_port_deallocate. This can lead to handle confusion of Mach port names in the browser process.
Technical Details
In ui/gfx/mac/io_surface.cc, IOSurfaceMachPortTraits::Retain is implemented as follows:
mach_port_t IOSurfaceMachPortTraits::Retain(mach_port_t port) {
kern_return_t kr =
mach_port_mod_refs(mach_task_self(), port, MACH_PORT_RIGHT_SEND, 1);
MACH_LOG_IF(ERROR, kr != KERN_SUCCESS, kr)
<< "IOSurfaceMachPortTraits::Retain mach_port_mod_refs";
return port;
}
If mach_port_mod_refs fails (for instance, if the port name is a dead name right), the function logs an error but unconditionally returns the port name. base::apple::ScopedTypeRef (which is used by ScopedRefCountedIOSurfaceMachPort) stores this return value during operations like copy assignment and assumes the reference count was successfully incremented.
Attack Vector and Reachability
A compromised GPU process can trigger this vulnerability via the viz::mojom::DisplayClient::OnDisplayReceivedCALayerParams interface.
- The attacker allocates a Mach receive right and sends a corresponding send right to the Browser process via a
CALayerParamsMojo message. - Immediately after queuing the IPC, the attacker destroys the receive right in the GPU process. This causes the send right currently in-flight to the Browser process to become a dead name right.
- The Browser process deserializes the Mojo message.
PlatformHandle::ReleaseMachSendRightconsiders dead names valid, so the dead name is safely placed into a temporaryCALayerParamsstruct. - The Browser calls
AcceleratedWidgetMac::UpdateCALayerTree(ui/accelerated_widget_mac/accelerated_widget_mac.mm), which executeslast_ca_layer_params_ = ca_layer_params;. - The copy-assignment of
CALayerParamstriggers the copy-assignment of itsScopedRefCountedIOSurfaceMachPortmember. This invokesIOSurfaceMachPortTraits::Retainon the dead name. mach_port_mod_refsfails because it operates on a dead name right, not a send right. However,Retainunconditionally returns the dead name, whichlast_ca_layer_params_adopts.- The temporary
CALayerParamsstruct goes out of scope. ItsScopedTypeRefcallsTraits::Release, which executesmach_port_deallocateon the dead name. This frees the dead name entirely, returning the Mach port name (an integer) to the kernel’s freelist. last_ca_layer_params_now holds a dangling Mach port name. When it is next updated or destroyed, it will callmach_port_deallocateon this freed name.
Impact
By leveraging the generation count mechanism of XNU Mach ports, an attacker can arrange for a different, sensitive Mach port (e.g., an IOKit user client or shared memory object) to be allocated at the exact same name as the freed dead name. The subsequent over-release targets this unrelated, newly allocated port, resulting in handle confusion. This primitive is a known vector for achieving a full sandbox escape from the GPU process to the unsandboxed browser process.
Recommendation
Modify IOSurfaceMachPortTraits::Retain to return MACH_PORT_NULL when the reference count cannot be incremented. Alternatively, use the existing base::apple::RetainMachSendRight helper, which correctly handles this failure case:
mach_port_t IOSurfaceMachPortTraits::Retain(mach_port_t port) {
return base::apple::RetainMachSendRight(port).release();
}
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.