CVE-2026-64718
Overview
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.
Attack Path
- Create multiple OffscreenCanvas workers The page transfers several canvases to Web Workers via transferControlToOffscreen().
- Concurrently query stroke geometry Each worker loops calling isPointInStroke() (PathCG::strokeContains) while calling setLineDash with changing patterns to force CoreGraphics dash allocation and freeing.
- Race the shared scratch CGContext All workers use and mutate the same process-shared static scratchContext() simultaneously, racing CG-internal allocations.
- Trigger heap corruption / UAF The unsynchronized concurrent access corrupts CoreGraphics state (freed/overwritten dash allocations), crashing the process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
PathCG::strokeContainsSource/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::strokeBoundingRectSource/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.txtLayoutTests/fast/canvas/offscreen-worker-unsafe-isPointInStroke-call.htmlSource/WebCore/platform/graphics/cg/PathCG.cpp
Audit Directions
- Static/shared CG resources on worker pathsAudit 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 assumptionsSearch graphics helpers for singletons that predate OffscreenCanvas and silently assume single-threaded use; each needs a lock or per-thread instance.
Patch
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);