← All reports

[3] IOSurface from renderer send right consumed without validation

HighWebKit UI process IPC surfaceOOB

The privileged process took the sandboxed one's word for a buffer's shape.

c374428

High — the privileged host process was taking a sandboxed child's word for the geometry of a shared buffer it then walks. The read-only nature of the primitive caps it below a self-contained escape; the escalation past a UI-process crash depends on the resulting image being observable to the attacker.

WebKit splits work across processes: the UI process hosts the embedding application and the WebKit API and is not confined by the renderer's sandbox, while WebContent is the sandboxed child that renders pages. Pixels move between them as an IOSurface — a kernel-backed shared bitmap that carries its own self-describing metadata (width, height, bytesPerRow, pixelFormat, allocSize) — referenced across the boundary by a MachSendRight, a transferable Mach port right the receiver resolves into a live surface. The expectation on the receiving side is that a surface arriving over IPC was produced by WebKit's own IOSurface::create(): uncompressed, single-plane, and with metadata fields consistent with one another.

The angle: an attacker with code execution in the renderer can reply to a snapshot request with a hostile surface and drive the privileged UI process into reading past the mapped allocation — a crash of the whole browser session at minimum, and adjacent-memory disclosure where the resulting image is observable.

In WebPageProxy::takeSnapshot(), validate IOSurface from MachSendRight

WebPageProxy::takeSnapshot() now uses the new IOSurface::createFromUntrustedSendRight(), which has stronger checks of the MachSendRight-provided IOSurface, expecting a valid IOSurface as produced uncompressed and uni-planar from IOSurface::create().

Source/WebCore/platform/graphics/cocoa/IOSurface.mm

+template <unsigned bytesPerElement>
+static std::unique_ptr<IOSurface> validateAndCreateFromUntrustedSurface(IOSurfaceRef surface)
+{
+ static_assert(bytesPerElement > 0);
+ auto width = IOSurfaceGetWidth(surface);
+ auto height = IOSurfaceGetHeight(surface);
+ auto bytesPerRow = IOSurfaceGetBytesPerRow(surface);
+ if (!width || !height || !bytesPerRow)
+ return nullptr;
+ auto maxSize = IOSurface::maximumSize();
+ if (width > size_t(maxSize.width()) || height > size_t(maxSize.height()))
+ return nullptr;
+ auto rowBytes = CheckedSize { width } * bytesPerElement;
+ if (rowBytes.hasOverflowed() || rowBytes.value() > bytesPerRow)
+ return nullptr;
+ auto totalBytes = CheckedSize { bytesPerRow } * height;
+ auto allocSize = IOSurfaceGetAllocSize(surface);
+ if (totalBytes.hasOverflowed() || totalBytes.value() > allocSize)
+ return nullptr;
+
+ return IOSurface::createFromSurface(surface, { });
+}
+
+std::unique_ptr<IOSurface> IOSurface::createFromUntrustedUncompressedWebKitSendRight(const MachSendRight&& sendRight)
+{
+ ASSERT(ProcessCapabilities::canUseAcceleratedBuffers());
+
+ auto surface = adoptCF(IOSurfaceLookupFromMachPort(sendRight.sendRight()));
+ if (!surface)
+ return nullptr;
+
+ unsigned pixelFormat = IOSurfaceGetPixelFormat(surface.get());
+ switch (pixelFormat) {
+ case kCVPixelFormatType_32BGRA:
+ case kCVPixelFormatType_32RGBA:
+#if ENABLE(PIXEL_FORMAT_RGB10)
+ case kCVPixelFormatType_30RGBLEPackedWideGamut:
+#endif
+ return validateAndCreateFromUntrustedSurface<4>(surface.get());
+
+#if ENABLE(PIXEL_FORMAT_RGBA16F)
+ case kCVPixelFormatType_64RGBAHalf:
+ return validateAndCreateFromUntrustedSurface<8>(surface.get());
+#endif
+
+ default:
+ break;
+ }
+
+ return { };
+}

Source/WebKit/UIProcess/WebPageProxy.cpp

, [&image] (MachSendRight& machSendRight) {
- if (auto surface = WebCore::IOSurface::createFromSendRight(WTF::move(machSendRight)))
+ if (auto surface = WebCore::IOSurface::createFromUntrustedUncompressedWebKitSendRight(WTF::move(machSendRight)))
image = WebCore::IOSurface::sinkIntoImage(WTF::move(surface));
}

Tools/TestWebKitAPI/Tests/WebCore/cocoa/IOSurfaceTests.mm

+TEST(IOSurfaceTest, createFromUntrustedUncompressedWebKitSendRightYUV422)
+{
+ auto original = WebCore::IOSurface::create(nullptr, { 5, 5 }, WebCore::DestinationColorSpace::ExtendedRec2020(), WebCore::IOSurface::Name::Default, WebCore::IOSurface::Format::YUV422);
+ ASSERT_NE(original, nullptr);
+
+ auto roundTripped = WebCore::IOSurface::createFromUntrustedUncompressedWebKitSendRight(original->createSendRight());
+ ASSERT_EQ(roundTripped, nullptr);
+}

The change splits into two clusters: a new validating import path inside WebCore's Cocoa IOSurface wrapper, and the single UI-process call site switched over to it, with GTest coverage pinning the new contract.

On the WebCore side, a file-local template validateAndCreateFromUntrustedSurface<bytesPerElement>(IOSurfaceRef) reads IOSurfaceGetWidth, IOSurfaceGetHeight and IOSurfaceGetBytesPerRow, rejects zero values, rejects width/height exceeding IOSurface::maximumSize(), then uses CheckedSize (a new #import <wtf/CheckedArithmetic.h>) to require width * bytesPerElement <= bytesPerRow and bytesPerRow * height <= IOSurfaceGetAllocSize(surface), returning nullptr on any overflow or mismatch before delegating to IOSurface::createFromSurface. The public factory IOSurface::createFromUntrustedUncompressedWebKitSendRight(const MachSendRight&&) asserts ProcessCapabilities::canUseAcceleratedBuffers(), performs IOSurfaceLookupFromMachPort, then switches on IOSurfaceGetPixelFormat: kCVPixelFormatType_32BGRA, kCVPixelFormatType_32RGBA and kCVPixelFormatType_30RGBLEPackedWideGamut route to the 4-bytes-per-element validator, kCVPixelFormatType_64RGBAHalf to the 8-bytes-per-element validator, and every other pixel format falls through to return { }.

On the WebKit side, the MachSendRight branch of the takeSnapshot() reply handler switches from IOSurface::createFromSendRight() — a bare IOSurfaceLookupFromMachPort plus createFromSurface with no property checks — to the new validating factory. Three GTest cases round-trip an SRGB BGRA surface and an RGBA16F surface (both must survive) and a YUV422 surface (must be rejected as nullptr).

Trusting self-describing metadata that accompanies a shared-memory handle from a lower-privileged process, without re-deriving the buffer's geometry constraints before consuming it.

WebKit process model. The UI process hosts the embedding application and the WebKit API; WebContent is a sandboxed child process that renders pages. WKWebView-style snapshot APIs are serviced by WebPageProxy::takeSnapshot(), which asks the renderer to draw a region and returns the pixels either as a ShareableBitmap or, on the branch this patch touches, as an IOSurface referenced by a MachSendRight; IOSurface::sinkIntoImage() then converts the surface into a CGImageRef for the caller.

IOSurface. A Cocoa/kernel-backed shared pixel buffer object. It carries self-describing metadata — IOSurfaceGetWidth, IOSurfaceGetHeight, IOSurfaceGetBytesPerRow, IOSurfaceGetPixelFormat, IOSurfaceGetAllocSize — that consumers read to interpret the mapped bytes, and IOSurfaceCreate lets its caller set these properties independently of one another.

Stride and alloc size. bytesPerRow (stride) is the byte distance from the start of one pixel row to the next; for a well-formed surface it is at least width * bytesPerElement and may be larger due to alignment padding. allocSize is the total size of the surface's backing allocation.

MachSendRight. A transferable Mach port right. IOSurfaceLookupFromMachPort() converts one into a live IOSurfaceRef in the receiving process — this is how a surface is shared across a process boundary.

Pixel format. A kCVPixelFormatType_* four-character code describing bytes per pixel and channel layout. Some formats — YUV 4:2:2 and other planar or chroma-subsampled types — are multi-planar, so a single linear base + y * bytesPerRow model does not describe their memory at all.

CheckedSize. WTF's overflow-checked size_t wrapper; hasOverflowed() reports whether an arithmetic step wrapped.

The bug is missing validation of an attacker-supplied resource handle at a process trust boundary, whose downstream consequence is an out-of-bounds read, with a layout/format confusion facet for non-linear pixel formats.

  WebContent (sandboxed)                 UI process (privileged)
  ──────────────────────                 ───────────────────────
  IOSurfaceCreate(                       takeSnapshot() reply handler
    width  = 0x10000,   ──MachSendRight──►  createFromSendRight()
    bytesPerRow = 64,                          └─ IOSurfaceLookupFromMachPort
    allocSize   = small,                       └─ createFromSurface  (no checks)
    format = 32BGRA)                              └─ sinkIntoImage()
                                                     walks width * 4 bytes/row
                                                     for height rows  ──► OOB read
                            ▲
                            └── the boundary the bug crosses

Before the fix, the UI process resolved the incoming send right with IOSurface::createFromSendRight()IOSurfaceLookupFromMachPort() followed by createFromSurface(), with no property of the resolved surface inspected. The missing invariant is that a surface arriving over IPC is not necessarily one that IOSurface::create() produced: the sender is free to call IOSurfaceCreate itself with an arbitrary kIOSurfacePixelFormat, arbitrary kIOSurfaceWidth/kIOSurfaceHeight, and — crucially — a kIOSurfaceBytesPerRow and kIOSurfaceAllocSize chosen independently of those. (That the kernel permits such an inconsistent creation is the premise of the whole fix; neither the diff nor the supplied source establishes it directly, but the shape of the predicates the patch installs is only meaningful if it holds.)

Consumers downstream of createFromSurface() — on this call site, sinkIntoImage()createImage() — treat the surface as a single-plane, uncompressed, N-bytes-per-pixel image and derive their read extent from the surface's own metadata. The fix closes three distinct metadata inconsistencies, and they are disjoint:

  1. Declared-format versus declared-stride skew. bytesPerElement is selected from IOSurfaceGetPixelFormat itself, so width * bytesPerElement <= bytesPerRow is not aimed at odd small-element formats — those hit the switch's default:. What it rejects is a surface declaring an allowlisted format such as kCVPixelFormatType_32BGRA (implying 4 bytes per pixel) together with a large width but a bytesPerRow smaller than width * 4. A consumer walking width four-byte pixels per row would then step past the end of each declared row and, cumulatively, past the allocation.
  2. Row/alloc inconsistency. bytesPerRow * height exceeding IOSurfaceGetAllocSize(), or overflowing, would let a height-row traversal run off the end of the mapping — hence the CheckedSize product compared against the alloc size.
  3. Format/layout confusion. The pixel-format allowlist rejects everything outside the four handled uncompressed single-plane formats, including multi-planar and chroma-subsampled layouts for which the linear model does not describe the backing memory. The YUV422 regression test, asserting nullptr, is the direct witness that such surfaces previously flowed straight through to createFromSurface().

The maximumSize() clamp and zero-dimension rejection cover the degenerate ends of the same metadata space.

The precondition is prior code execution in the process that produces the snapshot reply — this is not reachable from ordinary JavaScript, since only the reply sender chooses which send right to place in the message. (The WebPageProxy.cpp excerpt is truncated well before the takeSnapshot body; that the sender is WebContent follows from the process model and from the new factory's name, which explicitly labels the input untrusted.) Given that precondition, the trigger is: the embedder or WebKit internals initiate a snapshot, reaching WebPageProxy::takeSnapshot(); instead of replying with the send right of a surface built by IOSurface::create(), the compromised sender calls IOSurfaceCreate directly with a hostile property dictionary and replies with that port.

Escalation beyond a crash is conditional and requires all of: the attacker controlling the UI-process VM layout adjacent to the surface mapping well enough that the over-read lands on interesting data — the surface is a separate kernel-backed mapping, so what follows it is a question the supplied context does not settle; the over-read bytes surviving into the produced CGImageRef rather than faulting on an unmapped page; and a path by which the resulting image is observable to the attacker. On that last point, the snapshot image is delivered to the embedding application's completion handler rather than back to web content, so a compromised renderer would need a separate channel to read it; if the attacker is instead a malicious application driving a hostile page, the image would be directly observable and the over-read could yield a UI-process memory disclosure. Absent those conditions, the reliable observed effect is an out-of-bounds read fault that crashes the privileged UI process.

The primitive is an attacker-influenced out-of-bounds read relative to the base of an attacker-sized mapping, with the read extent chosen by the sender. It is read-only — nothing in the validator or the takeSnapshot reply path writes into the surface from the UI-process side. The vulnerable consumer sits on the privileged side of the process split, so successful exploitation would act on the far side of the renderer sandbox rather than requiring a further escape from it; the read-only nature means it functions as one link in an escape chain (info leak, ASLR defeat, DoS against the host process) rather than as a complete escape.

Discovery reads as pattern auditing of the renderer-to-UI-process boundary — systematically enumerating places where the privileged process consumes a resource handle produced by the sandboxed renderer and checking whether the handle's self-declared properties are validated. That the fix ships as a general-purpose createFromUntrustedUncompressedWebKitSendRight helper rather than an inline check at the single call site suggests an internal audit of IOSurface import paths, plausibly variant analysis following earlier hardening of GPU-process IOSurface consumption. The regression tests were written to pin the new contract rather than to reproduce a crash, which argues against fuzzing.

This vulnerability weakens the renderer-to-UI-process trust boundary. The security model assumes the renderer is the untrusted party and that any resource it hands the UI process is treated as attacker-controlled data; before the fix, takeSnapshot() instead assumed the send right named a surface that IOSurface::create() had produced — uncompressed, single-plane, with self-consistent geometry. An attacker who already achieved code execution in the renderer could, by substituting a hostile surface, drive the more privileged UI process into reading beyond the mapped surface allocation: at minimum a remotely-triggerable UI process crash that takes down the whole browser session rather than a single tab, and at best a controlled adjacent-memory read whose bytes land in a rendered image — a privilege-boundary step in a sandbox-escape chain rather than a self-contained escape.

The function name itself encodes the security model: createFromUntrustedUncompressedWebKitSendRight says three things — the input is untrusted, the expected shape is uncompressed, and the expected producer is WebKit's own IOSurface::create(). That is a good pattern to imitate, because it makes the trust level part of the API contract rather than a comment. The structural observation is that createFromSendRight() remains in the tree unchanged and unmarked; every remaining call site of it is now implicitly asserting "my send right came from a trusted producer," and nothing in the API forces that assertion to be justified. Note too what the fix does not attempt: the pixel contents remain writable by the sending process after validation, so this is a metadata-validation fix, not a content-trust fix — the geometry fields are fixed at surface creation and cannot be mutated afterward (standard IOSurface behavior rather than something the supplied context shows), which is why check-then-use is sound here where it would not be for pixel data.