CVE-2026-64787
Overview
Background
- Trusted Types
- A CSP-driven mechanism (require-trusted-types-for ‘script’) that forces HTML/script sinks to go through a policy, invoking author JavaScript (createHTML) at the sink.
- Default policy
- A Trusted Types policy named ‘default’ whose createHTML callback runs synchronously whenever a guarded sink receives a plain string, giving attacker JS a foothold inside native code.
- Re-entrancy
- When native C++ calls back into JavaScript mid-operation; here the sink calls the default policy callback while still holding a bare Document pointer.
- trustedTypeCompliantString
- The WebCore helper that validates a string against Trusted Types; it may invoke the default policy (JS) and takes the context Document as a raw pointer.
- contextDocument()
- Returns the Document associated with the current context; a bare pointer whose lifetime is not guaranteed across a callback.
- protect()
- Wraps an object in a Ref/RefPtr so it is kept alive (ref-counted) across a scope — the fix wraps contextDocument() so it survives the Trusted Types callback.
Root Cause Analysis
This fixes a use-after-free of the context Document across a Trusted Types policy callback. Document::parseHTMLUnsafe(), Document::write()/writeln(), and Document::execCommand() call trustedTypeCompliantString(…, contextDocument(), …), which can invoke a web-author-provided Trusted Types default policy (createHTML) — arbitrary JavaScript. That callback can destroy the context document (the regression test removes the source iframe, adoptNode()s a node, nulls references, and forces GC inside createHTML).
Before the fix these call sites passed the raw contextDocument() pointer, so if the callback freed that document the subsequent use dangled, causing the unexpected process termination. The invariant is that a document passed into a routine that can run script must be kept alive for the duration of the call.
The fix wraps the argument in protect(contextDocument()) (a Ref/RefPtr protector) in all three call sites, keeping the context document alive across the Trusted Types callback. Fully established by the diff (the protect() helper’s definition is not shown but its protective intent is clear from usage).
Attack Path
- Set up a Trusted Types default policy The page (via an iframe with require-trusted-types-for ‘script’) installs a default policy whose createHTML callback runs attacker JavaScript.
- Free the context document inside the callback The createHTML callback removes the iframe, adopts nodes across documents, and forces GC so the context document is collected.
- Invoke a Trusted-Types-guarded API The page calls Document.write / execCommand(‘insertHTML’) / parseHTMLUnsafe, which passes the (now-freed) raw contextDocument() into trustedTypeCompliantString.
- Use-after-free After the callback frees the document, the caller dereferences the dangling contextDocument pointer, terminating the process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
Document::parseHTMLUnsafeSource/WebCore/dom/Document.cpp |
modified | Wraps context.contextDocument() in protect(...) when calling trustedTypeCompliantString so the document survives the Trusted Types callback. |
Document::writeSource/WebCore/dom/Document.cpp |
modified | Passes protect(contextDocument()) to trustedTypeCompliantString for write/writeln. |
Document::execCommandSource/WebCore/dom/Document.cpp |
modified | Passes protect(contextDocument()) to trustedTypeCompliantString on the insertHTML path. |
Files Changed
LayoutTests/fast/dom/trusted-types-iframe-removal-crash-expected.txtLayoutTests/fast/dom/trusted-types-iframe-removal-crash.htmlSource/WebCore/dom/Document.cpp
Audit Directions
- Other trustedTypeCompliantString callersGrep every call to trustedTypeCompliantString(…) in WebCore and confirm each passes a protected document/argument. The fix touched nine sinks; any caller passing contextDocument()/context.contextDocument() or a bare Document* without protect(…) is a candidate variant.
- Bare Document across any script-invoking callbackLook for native code that dereferences contextDocument()/document() after calling into anything that can run author JS (Trusted Types policies, custom elements reactions, MutationObserver, event dispatch, promise/microtask drains). The tell: a raw Document*/RefPtr captured before the callback and used after it without a Ref/Protector.
- Objects reachable across detached-frame teardownAudit sinks reachable from a srcdoc/iframe whose document can be adopted away or GC’d inside the callback (as in the PoC: adoptNode + iframe.remove() + GCController.collect()). Any sibling state derived from the document (frame, loader, script controller) may also dangle.
- Make the discipline structuralPrefer converting call-site protect() discipline into API signatures that take Ref<Document>/protected references, so the compiler enforces liveness rather than relying on every caller remembering to protect().
Patch
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
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();