← All reports

[1] Nested DrawDisplayList replay falls back to the singleton ControlFactory

HighWebCore display-list replayUAF

The per-thread isolation held at every level except the one that made levels.

fc4afc4

High. A per-context isolation handle that stops propagating at exactly one nesting boundary collapses four GPU-process render threads onto one shared AppKit cell set. Escalation is gated on the attacker already holding code execution in WebContent — from there the singleton fallback is deterministic, only the interleaving is not.

Cross-thread isolation in WebKit's GPU-process rendering pipeline is normally maintained by giving each rendering client its own copy of the platform objects it draws with, so non-thread-safe native widgets are never touched by two threads at once. Each RemoteRenderingBackend — the GPU-side object that owns one client's 2D rendering resources — runs on its own dedicated work-queue thread and replays recorded drawing commands there. Those recorded commands include DrawControlPart items, which render native form controls through a ControlFactory handed in per replay precisely so that the process-wide ControlFactory::singleton() never gets touched from a render thread.

The angle: a compromised renderer can wrap control-drawing commands one level deeper and make four GPU-process threads mutate the same shared AppKit cell objects simultaneously, which the commit message reports as a concurrent use-after-free.

The commit message is direct about both mechanism and reachability:

DisplayList::applyItem() only special-cases DrawControlPart, so a nested DrawDisplayList item falls through to item.apply(context) and calls the single-arg GraphicsContext::drawDisplayList, which substitutes ControlFactory::singleton(). A compromised WCP can wrap DrawControlPart items in a nested DrawDisplayList and replay it on multiple RemoteRenderingBackends, racing the singleton ControlFactoryMac's shared NSCell state across GPU-process work-queue threads.

Source/WebCore/platform/graphics/displaylists/DisplayListItem.cpp

void applyItem(GraphicsContext& context, const AffineTransform& baseTransform, ControlFactory& controlFactory, const Item& item)
{
WTF::switchOn(item,
[&](const DrawControlPart& item) {
item.apply(context, controlFactory);
}, [&](const SetCTM& item) {
item.apply(context, baseTransform);
+ }, [&](const DrawDisplayList& item) {
+ item.apply(context, controlFactory);
}, [&](const auto& item) {
item.apply(context);
}
);
}

Source/WebCore/platform/graphics/displaylists/DisplayListItems.cpp

-void DrawDisplayList::apply(GraphicsContext& context) const
+void DrawDisplayList::apply(GraphicsContext& context, ControlFactory& controlFactory) const
{
- return context.drawDisplayList(m_displayList);
+ return context.drawDisplayList(m_displayList, controlFactory);
}

Source/WebCore/platform/graphics/controls/ControlPart.cpp

+void ControlPart::setOverrideControlFactory(RefPtr<ControlFactory>&& controlFactory)
+{
+ if (m_overrideControlFactory == controlFactory)
+ return;
+ m_overrideControlFactory = WTF::move(controlFactory);
+ m_platformControl = nullptr;
+}

LayoutTests/ipc/nested-display-list-draw-control-part-crash.html

+ const items = [
+ ['WebCore::ButtonPart', 8], // Button -> m_buttonCell
+ ['WebCore::ButtonPart', 9], // DefaultButton -> m_defaultButtonCell
+ ['WebCore::ToggleButtonPart', 4], // Checkbox -> m_checkboxCell
+ ['WebCore::ToggleButtonPart', 5], // Radio -> m_radioCell
+ ...
+ ];
...
+ function setupBackend(sizePx) {
+ const rb = createRemoteRenderingBackend();
+ const inner = createDisplayListRecorder(rb);
+ recordControlParts(inner.remoteGraphicsContext, sizePx, 30);
+ const innerDL = createDisplayListFromRecorder(rb, inner.recorderIdentifier);
+ const outer = createDisplayListRecorder(rb);
+ outer.remoteGraphicsContext.DrawDisplayList({ identifier: innerDL.displayListIdentifier });
+ const outerDL = createDisplayListFromRecorder(rb, outer.recorderIdentifier);
+ const ib = createRemoteImageBuffer(rb);
+ return { rb, ib, outerDL: outerDL.displayListIdentifier };
+ }
+ const A = setupBackend(20); const B = setupBackend(200);
+ const C = setupBackend(40); const D = setupBackend(120);
+ for (let i = 0; i < 0x40; i++) {
+ A.ib.remoteGraphicsContext.DrawDisplayList({ identifier: A.outerDL });
+ B.ib.remoteGraphicsContext.DrawDisplayList({ identifier: B.outerDL });
+ C.ib.remoteGraphicsContext.DrawDisplayList({ identifier: C.outerDL });
+ D.ib.remoteGraphicsContext.DrawDisplayList({ identifier: D.outerDL });
+ }

Three coordinated changes. DisplayList::applyItem() gains a new switchOn arm for DrawDisplayList that forwards the replay-scoped ControlFactory& (item.apply(context, controlFactory)), alongside the pre-existing special cases for DrawControlPart (factory) and SetCTM (base transform); previously DrawDisplayList matched the generic [&](const auto& item) { item.apply(context); } fallback. DrawDisplayList::apply() changes signature from apply(GraphicsContext&) const to apply(GraphicsContext&, ControlFactory&) const, and its body switches from context.drawDisplayList(m_displayList) — the form that carries no replay-scoped factory into the nested list — to context.drawDisplayList(m_displayList, controlFactory).

ControlPart::setOverrideControlFactory() moves from an inline header setter to an out-of-line WEBCORE_EXPORT definition in ControlPart.cpp that early-returns when the factory is unchanged and, when it does change, clears the cached m_platformControl (std::unique_ptr<PlatformControl>) so a PlatformControl manufactured by the previous factory is not reused under the new one. Collateral: a new regression test driving the GPU process directly over the IPC Testing API, plus a glib TestExpectations skip entry.

Incomplete propagation of a per-context isolation handle through a recursive dispatch table, so the nested case silently falls back to a process-wide singleton.

Where this lives. WebCore records drawing operations into a DisplayList — a Vector<Item> where Item is a variant of small command classes (Save, Clip, DrawControlPart, DrawDisplayList, ...). Replay walks the vector and calls DisplayList::applyItem(), which uses WTF::switchOn to dispatch each variant alternative to its apply() method.

Nesting. DrawDisplayList is a display-list item whose payload is another Ref<const DisplayList>, so display lists can nest and replay is recursive.

The control-drawing layer. ControlPart is a platform-independent description of a native form control (button, checkbox, radio, menu list, search field). It obtains a PlatformControl — the platform-backed drawing object — from a ControlFactory via createPlatformControl(), caches it in mutable std::unique_ptr<PlatformControl> m_platformControl, and drives it through updateCellStates() and draw(). ControlPart::controlFactory() resolves to m_overrideControlFactory when one is installed and otherwise to the process-wide ControlFactory::singleton(). On macOS the concrete factory is a ControlFactoryMac backed by AppKit NSCell objects.

GPU-process rendering IPC. WebContent creates a RemoteRenderingBackend per rendering backend over a stream connection. RemoteRenderingBackend's constructor creates its own IPC::StreamConnectionWorkQueue, and its handlers assertIsCurrent(workQueue()), so each backend processes its messages on a dedicated thread. RemoteDisplayListRecorder records incoming RemoteGraphicsContext messages into a DisplayList::RecorderImpl, SinkDisplayListRecorderIntoDisplayList freezes it into a replayable DisplayList, and RemoteImageBuffer's context replays it.

ThreadSafeRefCounted. A WTF base class giving atomic reference counting. It guarantees the refcount is race-free; it does not make the object's own member state thread-safe.

IPC Testing API. A test-only WebKit facility (IPCTestingAPIEnabled=true) letting a layout test send raw, hand-crafted IPC messages to other processes — used to simulate what a compromised WebContent process could send.

The bug is an isolation-context propagation failure in a recursive dispatch table, which surfaces as a data race on non-thread-safe platform state.

  Backend A thread        Backend B thread        shared singleton
  ────────────────        ────────────────        ────────────────
  replay outerDL          replay outerDL
    factory = A_factory     factory = B_factory
    item: DrawDisplayList   item: DrawDisplayList
      └► generic arm          └► generic arm
         apply(context)          apply(context)     ControlFactoryMac
           no factory ─────────────┬───────────────► m_buttonCell
                                   │                 m_checkboxCell
    30x DrawControlPart            │                 m_radioCell
      updateCellStates(20px) ──────┤                      ▲
                        (200px) ───┘   concurrent RMW ─────┘

applyItem() threads a per-replay ControlFactory& precisely so that control-drawing work stays isolated to the calling context's own factory instance. It implements that threading with a WTF::switchOn enumerating only the item types needing extra arguments — DrawControlPart and SetCTM — and routes everything else to the generic item.apply(context) arm. DrawDisplayList is the one recursive item: it re-enters the replay machinery for a whole nested DisplayList. Because it was not enumerated, it landed in the generic arm and called GraphicsContext::drawDisplayList(m_displayList) with no factory argument, so the nested replay proceeded without a replay-scoped factory installed on its parts. The isolation invariant held for depth-0 items and was silently dropped at every nesting boundary.

Downstream, ControlPart::controlFactory() returns m_overrideControlFactory ? *m_overrideControlFactory : ControlFactory::singleton(), so once the override is not installed for a nested item, every DrawControlPart inside the nested list resolves to the singleton. ControlPart::platformControl() then lazily calls createPlatformControl(), asking that factory for a PlatformControl, and ControlPart::draw() calls updateCellStates(borderRect.rect(), style) followed by draw(...) on it. ControlFactory is ThreadSafeRefCounted, which makes its refcount safe but says nothing about the platform state it vends. On macOS the singleton is a ControlFactoryMac whose lazily-created NSCell members and shared control view are mutated by updateCellStates/draw — per the new test's inline comments and the commit message. Concurrent replay on several work-queue threads therefore mutates and reads the same platform objects with no synchronization. (DrawControlPart::apply(GraphicsContext&, ControlFactory&) installing the passed factory on the part via setOverrideControlFactory is a strong inference from ControlPart::controlFactory() plus the second fix hunk rather than a body visible in the supplied context.)

The second half of the fix closes a related staleness hole: ControlPart caches its PlatformControl in mutable std::unique_ptr<PlatformControl> m_platformControl, and the old inline setOverrideControlFactory swapped the factory without invalidating that cache, so a part could keep using a PlatformControl — and the platform state behind it — manufactured by a different factory after the override changed.

The regression test is the trigger recipe, and its structure is worth reading step by step:

  1. CreateRenderingBackend four times, so four RemoteRenderingBackends exist, each with its own IPC::StreamConnectionWorkQueue thread.
  2. Per backend, CreateDisplayListRecorder, then 30 DrawControlPart messages cycling ButtonPart with Button/DefaultButton/PushButton, ToggleButtonPart with Checkbox/Radio, plus MenuListPart and SearchFieldPart — the test's comments state these map onto distinct lazily-created cell members of the macOS factory. SinkDisplayListRecorderIntoDisplayList freezes this as innerDL.
  3. Per backend, a second recorder receives a single DrawDisplayList(innerDL) message and is sunk into outerDL — this is the nesting step.
  4. A CG-backed ImageBuffer is created per backend, and its RemoteGraphicsContext is told DrawDisplayList(outerDL) 0x40 times on each of the four connections.

The outer replay enters applyItem() with the backend's own ControlFactory; the single DrawDisplayList item matches the generic arm, calls context.drawDisplayList(m_displayList) with no factory, and the nested replay of the 30 DrawControlPart items resolves through ControlPart::controlFactory()'s singleton branch. Each backend passes a different control size (20, 200, 40, 120), so ControlPart::draw()'s platformControl->updateCellStates(borderRect.rect(), style) writes conflicting geometry into the same shared cells from four threads at once.

On exploitability: this is reachable only from a compromised WebContent process — the trigger requires emitting RemoteRenderingBackend/RemoteGraphicsContext IPC by hand, and normal WebKit recording code would install the per-context factory correctly. From that position, the nested-replay path deterministically collapses onto ControlFactory::singleton() across independent work-queue threads. The immediate observable effect is unsynchronized concurrent read-modify-write of shared control-drawing state, plus concurrent lazy initialization of the factory's cached members; a torn or double initialization of a lazily created member would drop or double-release the earlier instance. If the raced state includes reference-counted Objective-C objects whose retain/release the concurrent paths interleave, an over-release could free a still-referenced cell or control view, and the next updateCellStates/draw on another thread could then operate on freed memory — the commit message names exactly this outcome ("Concurrent NSCell UAF"). The retain/release paths of the shared control state are not part of the supplied context, so that step is relayed as an attributed projection. Converting it into a controlled primitive would additionally require grooming the GPU-process heap so the freed AppKit allocation is reclaimed with attacker-shaped data before the surviving thread dereferences it; the supplied context contains no evidence about the allocation size class or field layout of those objects.

This vulnerability weakens thread-isolation inside the GPU process, the trust boundary that keeps a compromised WebContent process from corrupting a higher-privileged process with access to IOSurfaces, media, and GPU drivers. The security model assumption at stake is that each RemoteRenderingBackend's replay work-queue thread touches only its own per-context ControlFactory, so non-thread-safe platform control state is never shared across threads; nested DrawDisplayList replay violated that assumption by collapsing all backends onto the singleton. An attacker with code execution in WebContent could use this to induce unsynchronized concurrent mutation of shared platform objects in the GPU process — memory corruption at a privilege level the sandbox is supposed to protect, i.e. a candidate link in a sandbox-escape chain rather than a bug reachable directly from a web page.

The bug lives in the shape of the dispatcher, not in any one item's logic. applyItem() is an exhaustive-looking WTF::switchOn whose default arm makes forgetting a case silent rather than a compile error — and the one item type that recursively re-enters replay is exactly the one that most needs the extra context propagated. Any dispatch table with a catch-all arm plus a recursive alternative has this hazard: the invariant is enforced at every level except the one that creates new levels. A structurally safer form would remove the auto fallback so the compiler forces each new item type to declare which replay context it needs, or make the factory-less GraphicsContext::drawDisplayList form unavailable to replay code so the singleton fallback cannot be reached implicitly. Note also that ControlFactory being ThreadSafeRefCounted is quietly misleading here: it makes the handle safe to share across threads while the platform state behind it is not, which is precisely the confusion a per-context factory is meant to prevent.

The discovery angle reads as targeted manual auditing of the GPU-process display-list IPC surface with a strong variant-analysis component: the glib TestExpectations hunk shows an adjacent pre-existing test, ipc/remotedisplaylistrecorder-drawcontrolpart-slidertrackpart-crash.html, meaning DrawControlPart replay had already been probed. The natural follow-up is to ask which replay paths reach DrawControlPart without the per-context factory. The test's hand-rolled StreamConnection with an extended timeout and its CoreIPC.typeInfo override for WebCore::PlatformColorSpace indicate hands-on IPC harness work rather than an off-the-shelf fuzzer.