Medium CVSS 5.5 webkit Race 🔧 Commit mapped

Overview

Medium
Severity
5.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected Safari crash
ComponentWebCore Platform/Graphics
Bug ClassRace
Tracker313935
Fix commit95f9f59bb141 (WebKit/WebKit) +58/-0
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedOGINOME Tomohito, an anonymous researcher
Disclosed2026-07-27

Background

PathCG scratch context
PathCG stroke queries (strokeContains/strokeBoundingRect) borrow a single process-shared static CGContext and mutate its graphics state to compute stroke geometry.
OffscreenCanvas workers
OffscreenCanvas lets canvas 2D APIs run on multiple worker threads simultaneously — so isPointInStroke()/stroke-bounds can execute concurrently.
setLineDash
Sets the dash pattern; internally CoreGraphics allocates/frees dash state on the context, which is exactly what races when the context is shared.

Root Cause Analysis

This fixes a data race / use-after-free on a shared CoreGraphics scratch context used by path stroke queries. PathCG::strokeContains() and PathCG::strokeBoundingRect() obtain a process-shared scratchContext() (a single static CGContext) and mutate it (CGContextSaveGState, apply the stroke style, etc.). With OffscreenCanvas, isPointInStroke()/stroke-bounds can run concurrently on multiple worker threads, so several threads used and mutated the same scratch CGContext at once — including allocating/freeing CoreGraphics dash state via setLineDash — with no synchronization. The violated invariant is exclusive access to the shared scratch context for the duration of each stroke operation; concurrent use races CoreGraphics-internal allocations (e.g. the dash array), producing heap corruption / use-after-free and the observed crash.

The fix adds a function-local static Lock scratchContextLock and takes a Locker in both strokeContains() and strokeBoundingRect(), serializing all access to the shared context. The regression test spins up four OffscreenCanvas workers that loop calling isPointInStroke() while changing setLineDash to force CG dash alloc/free. This is fully established by the diff.

Key insight
A single static, mutable CGContext was written on the assumption of single-threaded access, but OffscreenCanvas made stroke queries concurrent. The fix serializes access with a function-local static Lock — the shared resource needed synchronization the moment worker threads could reach it.

Attack Path

  1. Create multiple OffscreenCanvas workers The page transfers several canvases to Web Workers via transferControlToOffscreen().
  2. Concurrently query stroke geometry Each worker loops calling isPointInStroke() (PathCG::strokeContains) while calling setLineDash with changing patterns to force CoreGraphics dash allocation and freeing.
  3. Race the shared scratch CGContext All workers use and mutate the same process-shared static scratchContext() simultaneously, racing CG-internal allocations.
  4. Trigger heap corruption / UAF The unsynchronized concurrent access corrupts CoreGraphics state (freed/overwritten dash allocations), crashing the process.

Impact Assessment

A data race and use-after-free on a shared CoreGraphics context, reachable from unprivileged script via OffscreenCanvas workers. Multiple threads mutating one CGContext corrupts CoreGraphics-internal allocations (the dash array), yielding heap corruption in the WebContent process — a controllable primitive since the attacker drives both thread count and allocation churn.

Changed Functions

FunctionChangeNotes
PathCG::strokeContains
Source/WebCore/platform/graphics/cg/PathCG.cpp
modified Adds a static Lock and takes a Locker before using the shared scratchContext(), serializing concurrent (OffscreenCanvas worker) access.
PathCG::strokeBoundingRect
Source/WebCore/platform/graphics/cg/PathCG.cpp
modified Same lock added around its use of the shared scratch CGContext to prevent the cross-thread data race.

Files Changed

  • LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call-expected.txt
  • LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call.html
  • Source/WebCore/platform/graphics/cg/PathCG.cpp

Audit Directions

  • Static/shared CG resources on worker paths
    Audit other static or process-shared CoreGraphics/platform objects touched by canvas 2D code that OffscreenCanvas can now run off the main thread.
  • Main-thread-affinity assumptions
    Search graphics helpers for singletons that predate OffscreenCanvas and silently assume single-threaded use; each needs a lock or per-thread instance.
diff --git a/LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call-expected.txt b/LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call-expected.txt
new file mode 100644
index 000000000000..4fd0bd3beec1
--- /dev/null
+++ b/LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call-expected.txt
@@ -0,0 +1 @@
+PASS if this test does not crash.
diff --git a/LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call.html b/LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call.html
new file mode 100644
index 000000000000..86fa74bd492a
--- /dev/null
+++ b/LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call.html
@@ -0,0 +1,51 @@
+<body>
+    <p>PASS if this test does not crash.</p>
+    <script>
+        window.testRunner?.waitUntilDone();
+        window.testRunner?.dumpAsText();
+
+        const tester = `self.onmessage = event => {
+            const ctx = event.data.getContext("2d");
+            for (let i = 0; i < 500; i++) {
+                ctx.setLineDash([5 + i % 20, 3 + i % 12]); // forces CG dash alloc/free
+                ctx.beginPath();
+                for (let j = 0; j < 25; j++) {
+                    let a = j / 25 * Math.PI * 2 + i * .1;
+                    let r = 30 + 20 * Math.sin(j * .7 + i * .3);
+                    let x = 64 + r * Math.cos(a);
+                    let y = 64 + r * Math.sin(a);
+                    if (!j)
+                        ctx.moveTo(x, y);
+                    else
+                        ctx.bezierCurveTo(
+                            x + 12 * Math.sin(i + j), y + 12 * Math.cos(i + j),
+                            x -  8 * Math.cos(i * j), y -  8 * Math.sin(i * j),
+                            x, y
+                        );
+                }
+                ctx.closePath();
+                ctx.isPointInStroke(64, 64);
+            }
+            self.postMessage({ type: 'done' }); // Send finished signal
+        };`;
+
+        const totalWorkers = 4;
+        let doneWorkers = 0;
+
+        for (let i = 0; i < totalWorkers; i++) {
+            const canvas = document.createElement("canvas");
+            canvas.width = 128;
+            canvas.height = 128;
+            const offscreen = canvas.transferControlToOffscreen();
+            const worker = new Worker(URL.createObjectURL(new Blob([tester])));
+            worker.postMessage(offscreen, [offscreen]);
+
+            worker.onmessage = event => {
+                if (event.data.type === 'done') {
+                    if (++doneWorkers == totalWorkers)
+                        window.testRunner?.notifyDone();
+                }
+            };
+        }
+    </script>
+</body>
diff --git a/Source/WebCore/platform/graphics/cg/PathCG.cpp b/Source/WebCore/platform/graphics/cg/PathCG.cpp
index 1a72e5045c38..32ae939bd590 100644
--- a/Source/WebCore/platform/graphics/cg/PathCG.cpp
+++ b/Source/WebCore/platform/graphics/cg/PathCG.cpp
@@ -606,6 +606,9 @@ bool PathCG::strokeContains(const FloatPoint& point, NOESCAPE const Function<voi
 {
     ASSERT(strokeStyleApplier);
 
+    static Lock scratchContextLock;
+    Locker locker { scratchContextLock };
+
     CGContextRef context = scratchContext();
 
     CGContextSaveGState(context);
@@ -641,6 +644,9 @@ 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);
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.