[3] IOSurface from renderer send right consumed without validation
The privileged process took the sandboxed one's word for a buffer's shape.
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.
Commit message
In
WebPageProxy::takeSnapshot(), validate IOSurface from MachSendRight
WebPageProxy::takeSnapshot()now uses the newIOSurface::createFromUntrustedSendRight(), which has stronger checks of the MachSendRight-provided IOSurface, expecting a valid IOSurface as produced uncompressed and uni-planar fromIOSurface::create().
Source/WebCore/platform/graphics/cocoa/IOSurface.mm
Source/WebKit/UIProcess/WebPageProxy.cpp
Tools/TestWebKitAPI/Tests/WebCore/cocoa/IOSurfaceTests.mm
Patch Details
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.
Background
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.
Analysis
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:
- Declared-format versus declared-stride skew.
bytesPerElementis selected fromIOSurfaceGetPixelFormatitself, sowidth * bytesPerElement <= bytesPerRowis not aimed at odd small-element formats — those hit the switch'sdefault:. What it rejects is a surface declaring an allowlisted format such askCVPixelFormatType_32BGRA(implying 4 bytes per pixel) together with a largewidthbut abytesPerRowsmaller thanwidth * 4. A consumer walkingwidthfour-byte pixels per row would then step past the end of each declared row and, cumulatively, past the allocation. - Row/alloc inconsistency.
bytesPerRow * heightexceedingIOSurfaceGetAllocSize(), or overflowing, would let aheight-row traversal run off the end of the mapping — hence theCheckedSizeproduct compared against the alloc size. - 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
YUV422regression test, assertingnullptr, is the direct witness that such surfaces previously flowed straight through tocreateFromSurface().
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.
Insight
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.
Audit directions
-
A resource handle crossing a privilege boundary that carries its own self-describing geometry, where the receiver derives read extents from the sender's metadata instead of from independently-established bounds. The receiver must re-derive every extent it will traverse and prove it fits inside the mapping, treating the sender's declared dimensions as input rather than as fact. Narrow: grep
Source/WebKit/UIProcessandSource/WebKit/GPUProcessfor remainingIOSurface::createFromSendRight(call sites and forIOSurfaceLookupFromMachPort— each one where the send right originates in a reply or message from a lower-privileged process is a candidate; the match tell is aMachSendRightthat arrived through an IPC message handler or completion handler and reachescreateFromSurface/sinkIntoImage/createNativeImagewith no intervening width/height/bytesPerRow/allocSize check. Wider: the same class covers every other shared-buffer type crossing the same boundary —SharedMemory::Handle,ShareableBitmap::Handle,WebCore::SharedVideoFrame, and CVPixelBuffer-carrying paths — where the receiver reads a declared size or stride from the message and uses it to size a traversal; the tell is a decoded size or stride field flowing into a loop bound or aspanconstruction without a comparison against the actual mapping length. Widest: this is the general "handle plus attacker-declared descriptor" class, holding anywhere a privileged process maps a lower-privileged process's buffer — Chromium'sbase::UnsafeSharedMemoryRegionplus Mojo-transportedgfx::Size, Linux dma-buf import with client-supplied stride/modifier, Vulkan external memory import, Wayland compositors acceptingwl_shm_pooloffsets; if the descriptor and the allocation can disagree, assume they do, and reject on disagreement rather than clamping. -
An absent allowlist of formats/variants that a consumer's memory model actually supports, where every exotic variant silently takes the linear fast path. Any code computing an address as
base + row * stride + column * elementSizemust first prove the buffer is single-plane, uncompressed, and of a format whose elementSize it knows. Narrow: audit the otherIOSurfaceconsumers inSource/WebCore/platform/graphics/cocoa/IOSurface.mmand its callers —createImage,createNativeImage,createPlatformContext,createBitmapPlatformContext, and the locking helpers that build a span fromIOSurfaceGetBaseAddressandIOSurfaceGetAllocSize— and check which can be reached with a surface whoseIOSurfaceGetPixelFormatwas never inspected; the match tell is a bytes-per-pixel constant (4, 8) or aPixelFormatenum value assumed from context rather than derived from the surface's own format tag. Wider: the same shape appears wherever a decoder or importer branches on a format tag and has a permissivedefault:— media pipelineCVPixelBufferRefhandling, WebGPU/GraphicsContextGL texture import, ImageBuffer backend selection; the tell in code-search results is aswitchon a format/fourcc whosedefaultfalls through to the generic path instead of rejecting. Widest: a format tag from an untrusted source selects an interpretation of memory, so the tag must be validated against a closed set before any layout assumption is made — applicable to image and video decoders in any browser, to GPU texture import in Vulkan/Metal wrappers, and to any deserializer keyed on a type byte; an unrecognized format must be a rejection, never a fallback. -
Parallel factory functions where one is safe and one is not, distinguished only by naming discipline, so new call sites can pick the wrong one without any compiler or reviewer signal. The trust level of an input should be carried by the type or by an unavoidable API distinction, not by a convention a caller can forget. Narrow: enumerate every remaining caller of
IOSurface::createFromSendRight(declared alongside the new factory inSource/WebCore/platform/graphics/cocoa/IOSurface.h) and, for each, trace where theMachSendRightcame from; the match tell is any caller in a*Proxy.cppor*MessageReceivercontext, since those are by construction on the receiving end of a less-trusted process. Wider: the same asymmetric-pair shape recurs in WebKit's IPC layer wherever a decode path has both a checked and an unchecked variant — theMESSAGE_CHECKfamily versus raw decoded field use,SharedMemory::mapwith a caller-supplied size versus the handle's own size, and any unchecked span construction in a message-handling path; the tell is two functions with near-identical signatures where only one performs validation. Widest: this is the "trusted and untrusted parsers sharing a namespace" class, present in Chromium'smojotraits versus raw struct access, in Rust codebases exposing bothfrom_sliceandfrom_slice_unchecked, and in any library pairingparsewithparse_trusted; if a safe and an unsafe entry point are equally reachable and equally easy to type, the unsafe one will eventually be chosen — prefer making the unsafe one require an explicit witness type or an audited annotation.