CVE-2026-43742
Overview
Background
- objectGraphLock()
- The lock serializing mutations of a WebGL context’s object graph against the concurrent GC marker.
- Concurrent GC marking
- JSC marks live objects on a background thread; for WebGL it walks bound objects via addMembersToOpaqueRoots.
- WebGL context loss/restoration
- A context can be lost and later restored, which reinitializes default bound objects via initializeNewContext.
- WTF_REQUIRES_LOCK
- A Clang thread-safety annotation that makes the compiler require a given lock be held when calling a function.
Root Cause Analysis
WebGL rendering-context state that participates in the GC object graph is protected by objectGraphLock(); the concurrent GC marker traverses a context’s bound objects (via addMembersToOpaqueRoots) while holding that lock. During WebGL context restoration, WebGLRenderingContextBase::initializeNewContext re-initializes that state by calling initializeContextState() and initializeDefaultObjects() — which reset and recreate bound default objects — but it did so WITHOUT holding objectGraphLock(). That is a data race: restoration can free and replace objects that the GC marker is concurrently reading, a use-after-free.
The fix wraps both calls in Locker locker { objectGraphLock() }; inside initializeNewContext and annotates the virtual initializeContextState()/initializeDefaultObjects() overrides (WebGLRenderingContext, WebGL2RenderingContext, and the base) with WTF_REQUIRES_LOCK(objectGraphLock()), so the compiler enforces that they only run under the lock.
The restored invariant is that any mutation of the WebGL object graph — including the reinitialization performed on context restore — is serialized against concurrent GC marking by objectGraphLock(). The added layout test loses and restores a WebGL2 context while forcing concurrent GC marking to overlap the restore, which crashed under ASan pre-patch.
Attack Path
- Create a WebGL2 context Allocate a canvas and get a webgl2 context so bound default objects exist in the GC object graph.
- Lose the context Use WEBGL_lose_context.loseContext() to drop the context and schedule a restore.
- Overlap restore with GC marking Trigger GC (e.g. allocate WebAssembly.Memory / heap padding) so its concurrent marking phase runs while restoreContext() reinitializes context state without the lock.
- Use-after-free Restoration frees/recreates objects the GC marker is still reading, corrupting memory in the WebContent process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
WebGLRenderingContextBase::initializeNewContextSource/WebCore/html/canvas/WebGLRenderingContextBase.cpp |
modified | Now takes Locker { objectGraphLock() } around initializeContextState() and initializeDefaultObjects() so restoration is serialized against concurrent GC marking. |
initializeContextState / initializeDefaultObjects (declarations)Source/WebCore/html/canvas/WebGLRenderingContextBase.h |
modified | Marked WTF_REQUIRES_LOCK(objectGraphLock()); mirrored on WebGLRenderingContext.h and WebGL2RenderingContext.h overrides to enforce the lock at compile time. |
Files Changed
LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash-expected.txtLayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash.htmlSource/WebCore/html/canvas/WebGL2RenderingContext.hSource/WebCore/html/canvas/WebGLRenderingContext.hSource/WebCore/html/canvas/WebGLRenderingContextBase.cppSource/WebCore/html/canvas/WebGLRenderingContextBase.h
Audit Directions
- Other unlocked object-graph mutationsgrep WebGLRenderingContext for methods that add/remove/reset bound objects (addContextObject, detachAndRemoveAllObjects, initialize*) called without a Locker{objectGraphLock()}.
- Restore/reset pathsAudit context-loss/restore and reset flows for state teardown/rebuild that must be serialized against GC marking.
- Missing REQUIRES_LOCK annotationsFind object-graph-touching virtuals lacking WTF_REQUIRES_LOCK(objectGraphLock()); the annotation would surface other unlocked callers.
Patch
diff --git a/LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash-expected.txt b/LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash-expected.txt
new file mode 100644
index 000000000000..c2541f4f3dd7
--- /dev/null
+++ b/LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash-expected.txt
@@ -0,0 +1 @@
+PASS if no crash.
diff --git a/LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash.html b/LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash.html
new file mode 100644
index 000000000000..a62c55af8994
--- /dev/null
+++ b/LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html>
+<body>
+<!-- Exercises a race between WebGL2 context restoration (which reinitializes
+ bound-object state via initializeNewContext) and concurrent GC marking (which
+ traverses that state via addMembersToOpaqueRoots). Without objectGraphLock()
+ held during restoration, the restore path can free objects the GC marker is
+ still reading, causing a use-after-free. -->
+<script>
+if (window.testRunner) {
+ testRunner.dumpAsText();
+ testRunner.waitUntilDone();
+}
+if (window.internals)
+ internals.settings.setWebGLErrorsToConsoleEnabled(false);
+
+const tick = () => new Promise(r => setTimeout(r, 0));
+
+// Ensure the GC marker doesn't reach the WebGL wrapper until the concurrent phase.
+const heapPaddingSize = 300000;
+const heapPadding = new Array(heapPaddingSize);
+for (let i = 0; i < heapPaddingSize; i++)
+ heapPadding[i] = { a: i, b: { c: i } };
+
+async function loseAndRestoreContext() {
+ const canvas = document.body.appendChild(document.createElement('canvas'));
+ canvas.width = 1;
+ canvas.height = 1;
+ const gl = canvas.getContext('webgl2');
+ const ext = gl.getExtension('WEBGL_lose_context');
+ for (let i = 0; i < heapPaddingSize; i += 4096)
+ heapPadding[i].g = gl;
+
+ const contextLost = new Promise(r => {
+ canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); r(); });
+ });
+ const contextRestored = new Promise(r => {
+ canvas.addEventListener('webglcontextrestored', () => r());
+ });
+
+ ext.loseContext();
+ await contextLost;
+ await tick();
+
+ // Trigger GC so its concurrent marking phase overlaps the restore timer.
+ new WebAssembly.Memory({ initial: 1024 });
+ new WebAssembly.Memory({ initial: 1024 });
+ ext.restoreContext();
+ await tick();
+ await tick();
+ await contextRestored;
+
+ canvas.remove();
+}
+
+async function runTest() {
+ for (let i = 0; i < 50; i++)
+ await loseAndRestoreContext();
+ document.body.textContent = 'PASS if no crash.';
+ if (window.testRunner)
+ testRunner.notifyDone();
+}
+runTest();
+</script>
+</body>
+</html>
diff --git a/Source/WebCore/html/canvas/WebGL2RenderingContext.h b/Source/WebCore/html/canvas/WebGL2RenderingContext.h
index dcf736d39056..b691cd7a4c8b 100644
--- a/Source/WebCore/html/canvas/WebGL2RenderingContext.h
+++ b/Source/WebCore/html/canvas/WebGL2RenderingContext.h
@@ -261,7 +261,7 @@ class WebGL2RenderingContext final : public WebGLRenderingContextBase {
private:
using WebGLRenderingContextBase::WebGLRenderingContextBase;
- void initializeContextState() final;
+ void initializeContextState() WTF_REQUIRES_LOCK(objectGraphLock()) final;
RefPtr<ArrayBufferView> arrayBufferViewSliceFactory(ASCIILiteral functionName, const ArrayBufferView& data, unsigned startByte, unsigned bytelength);
RefPtr<ArrayBufferView> sliceArrayBufferView(ASCIILiteral functionName, const ArrayBufferView& data, GCGLuint srcOffset, GCGLuint length);
@@ -269,7 +269,7 @@ class WebGL2RenderingContext final : public WebGLRenderingContextBase {
long long getInt64Parameter(GCGLenum) final;
Vector<bool> getIndexedBooleanArrayParameter(GCGLenum pname, GCGLuint index);
- void initializeDefaultObjects() final;
+ void initializeDefaultObjects() WTF_REQUIRES_LOCK(objectGraphLock()) final;
void detachAndRemoveAllObjects() WTF_REQUIRES_LOCK(objectGraphLock()) final;
bool validateBufferTarget(ASCIILiteral functionName, GCGLenum target) final;
bool validateBufferTargetCompatibility(ASCIILiteral, GCGLenum, WebGLBuffer*);
diff --git a/Source/WebCore/html/canvas/WebGLRenderingContext.h b/Source/WebCore/html/canvas/WebGLRenderingContext.h
index 7404a87eb9a4..a9b2f1b3e9de 100644
--- a/Source/WebCore/html/canvas/WebGLRenderingContext.h
+++ b/Source/WebCore/html/canvas/WebGLRenderingContext.h
@@ -60,7 +60,7 @@ class WebGLRenderingContext final : public WebGLRenderingContextBase {
private:
using WebGLRenderingContextBase::WebGLRenderingContextBase;
- void initializeDefaultObjects() final;
+ void initializeDefaultObjects() WTF_REQUIRES_LOCK(objectGraphLock()) final;
void detachAndRemoveAllObjects() WTF_REQUIRES_LOCK(objectGraphLock()) final;
};
diff --git a/Source/WebCore/html/canvas/WebGLRenderingContextBase.cpp b/Source/WebCore/html/canvas/WebGLRenderingContextBase.cpp
index f128a2833781..2216b3ef9011 100644
--- a/Source/WebCore/html/canvas/WebGLRenderingContextBase.cpp
+++ b/Source/WebCore/html/canvas/WebGLRenderingContextBase.cpp
@@ -524,8 +524,11 @@ void WebGLRenderingContextBase::initializeNewContext(Ref<GraphicsContextGL> cont
updateActiveOrdinal();
if (!wasActive)
addActiveContext(*this);
- initializeContextState();
- initializeDefaultObjects();
+ {
+ Locker locker { objectGraphLock() };
+ initializeContextState();
+ initializeDefaultObjects();
+ }
// Next calls will receive the context lost callback.
m_context->setClient(this);
}
diff --git a/Source/WebCore/html/canvas/WebGLRenderingContextBase.h b/Source/WebCore/html/canvas/WebGLRenderingContextBase.h
index 57fc93560676..9313e4ff0bca 100644
--- a/Source/WebCore/html/canvas/WebGLRenderingContextBase.h
+++ b/Source/WebCore/html/canvas/WebGLRenderingContextBase.h
@@ -538,8 +538,8 @@ class WebGLRenderingContextBase : public GraphicsContextGL::Client, public GPUBa
friend class ScopedWebGLRestoreTexture;
void initializeNewContext(Ref<GraphicsContextGL>);
- virtual void initializeContextState();
- virtual void initializeDefaultObjects();
+ virtual void initializeContextState() WTF_REQUIRES_LOCK(objectGraphLock());
+ virtual void initializeDefaultObjects() WTF_REQUIRES_LOCK(objectGraphLock());
virtual void detachAndRemoveAllObjects() WTF_REQUIRES_LOCK(objectGraphLock());
// ActiveDOMObject