← All reports

Use-after-free of Document in trustedTypeCompliantString

HighWebCore DOM — Trusted Types enforcement pathUAF

CVE: CVE-2026-64787 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected process termination Apple's description: A use-after-free issue was addressed with improved memory management. Credit: 杉山 壮太, Shubham Chaskar

cb83583 | Bugzilla 313703

High. Trusted Types dropped an author-controlled JavaScript callback into the middle of innerHTML — a path every caller had been written assuming was script-free — and the document was passed across it as a bare pointer. The free timing is fully attacker-chosen; escalation past a controlled crash needs the reclaim.

The Trusted Types spec was designed as an XSS mitigation: a page opts in via CSP, and every string-to-HTML sink must then hand its input to a policy object before the parser ever sees it. That interposition has a cost the security model doesn't advertise — when a document registers a policy named default, the coercion step stops being a pure C++ transformation and becomes a synchronous call into author JavaScript, sitting between the DOM binding and the parser. WebCore::trustedTypeCompliantString() is where that call happens, and it takes the governing document as an argument so it can read that document's CSP and policy factory. Before this fix, that argument was a raw Document*.

The angle: A page can register a policy callback that runs during another frame's innerHTML assignment, tear that frame's document down from inside the callback, and return into native code still holding a pointer to freed memory.

Source/WebCore/dom/Document.cpp

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();
 
@@ Document::write @@
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());
@@ Document::execCommand @@
[&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);
},

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

+ 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(); }

The fix is mechanically trivial and semantically total: three call sites in Document.cpp wrap the document argument in protect() before passing it into the coercion helper. Document::parseHTMLUnsafe protects context.contextDocument(); Document::write (which also backs writeln) protects contextDocument(); the insertHTML branch of the Document::execCommand visitor lambda does the same. protect() promotes a raw Document* into a reference-counted holder whose lifetime is that of the full call expression, so the refcount is incremented before trustedTypeCompliantString is entered and released only after it returns.

The commit message extends the identical edit to six more sinks that are not in the supplied hunks — Element::setHTMLUnsafe, Element::setOuterHTML, Element::setInnerHTML, Element::insertAdjacentHTML, Range::createContextualFragment, and both ShadowRoot::setHTMLUnsafe and ShadowRoot::setInnerHTML. Nine call sites, one edit repeated nine times. Reviewed by Wenson Hsieh and Chris Dumez; the commit message's own summary is "Fixed the bug by deploying more smart pointers."

The remaining hunks are the regression test and its expected output. The test is not incidental — it is a complete, deterministic PoC, and the most informative artifact in the diff.

Trusted Types. A CSP-driven XSS mitigation. A document that sends Content-Security-Policy: require-trusted-types-for 'script' can no longer assign a plain string to an HTML sink such as innerHTML, outerHTML, or document.write — those sinks demand a TrustedHTML object produced by a registered policy.

The default policy. A policy may be registered under the reserved name default. When a sink in a Trusted-Types-requiring document receives a plain string rather than a TrustedHTML, the engine calls that default policy's createHTML callback to convert the string, rather than rejecting outright. The callback is author-supplied JavaScript.

Re-entrancy. The general term for a point where native C++ calls into JavaScript. Script that runs there can do anything the DOM permits — mutate trees, detach frames, drop references — before returning control to the C++ frame that invoked it. Any C++ state computed before the call and used after it must survive whatever that script did.

trustedTypeCompliantString(). The WebCore helper each Trusted-Types-guarded sink funnels through. It takes the document whose context governs the operation, reads that document's CSP and policy factory, invokes the default policy callback when one is registered, and returns either the coerced string or a CSP violation report.

Document::contextDocument(). Returns the Document* whose context — CSP, policy factory — governs the current operation. It returns a bare pointer with no ownership.

protect(). A WebKit convenience that wraps a raw pointer or reference in a RefPtr/Ref holder, incrementing the refcount for as long as the temporary lives.

Frame document lifetime. An iframe's Document is kept alive by its owning frame and by GC-reachable JavaScript wrappers. Remove the iframe from the tree and drop the remaining wrapper references, and the document becomes collectable. document.adoptNode() moves a node between documents, changing its owner — a way to relieve a document of its last node. GCController.collect() is a test-only hook that forces a synchronous collection, which is how the regression test converts "eventually collectable" into "freed right now."

This is a borrowed-handle-across-re-entrancy bug: a non-owning pointer passed into a function that synchronously calls user code capable of dropping the pointee's last reference.

  Attacker frame (parent)          Trusted Types path (native)      Victim document (iframe)
  ─────────────────────            ───────────────────────────      ────────────────────────
  targetElement.innerHTML='x'  ──► trustedTypeCompliantString(
                                     Document* ctx ) ─────────────► alive, refcount held by frame
                                     │
                                     ├─ read ctx->CSP, factory
                                     └─ call default createHTML ──┐
  adoptNode(targetElement)       ◄───────────────────────────────┘  (script runs here)
  iframe.remove(); iframe=null
  GCController.collect()       ─────────────────────────────────►  FREED
                                     │
                                     ▼
                                   resume: ctx-> ...            ──► use-after-free

Follow the arrows. The native frame enters the helper holding ctx as a raw Document* — the only thing keeping that document alive is whatever DOM and frame ownership happens to exist at the moment of the call. The helper finds a default policy and dispatches into createHTML. Control is now in attacker JavaScript, on the same stack, with the native frame's locals sitting untouched below it. The callback severs every edge that was holding the document up: adoptNode moves the last interesting node out, iframe.remove() detaches the frame, iframe = null drops the JS handle, and the forced collection reclaims the object. The callback returns a string, and the helper resumes — reporting a violation, consulting the CSP, assembling the result — through a pointer into freed memory.

The cross-document arrangement in the test is the part worth studying, because it is what makes the free reachable at all. The attacking script never runs inside the doomed document. The iframe's inline script hands its trustedTypes factory out to the parent (parent.innerTrustedTypes = trustedTypes), and the parent then reaches back through the prototype to register a policy on it:

TrustedTypePolicyFactory.prototype.createPolicy.call(
    window.innerTrustedTypes, 'default', { createHTML: function () { ... } });
window.innerTrustedTypes = null;

The parent also parks a node it created inside the iframe's body, so that targetElement.innerHTML = 'x' — evaluated in the parent — is governed by the iframe's Trusted Types context. That is the whole trick, and it defeats the intuition that keeps this class of bug out of most sinks. The natural mental model is "my own document cannot die while my script is on the stack," and it is usually correct — script running in a document holds that document up. Here, the document whose pointer is on the native stack belongs to a frame the attacker's script is outside of, and outside script is free to demolish it.

Ordered, the trigger is:

  1. Load an iframe whose CSP requires Trusted Types; from inside it, adopt a parent-created div into its body and export its trustedTypes factory to the parent.
  2. From the parent, call createPolicy on that exported factory to register a default policy whose createHTML is attacker JS.
  3. Null the exported factory reference and collect, so nothing but the frame itself is holding the iframe document.
  4. Assign targetElement.innerHTML = 'x' from the parent — the element still lives in the iframe's document, so the iframe's default policy is consulted.
  5. Inside createHTML, adoptNode the element back out, remove the iframe, null it, and force a collection. The iframe's Document is destroyed.
  6. Return from the callback. The helper continues through its raw Document*.

protect() restores the invariant directly: the refcount is taken before the callback can run and released only after the helper is done, so no sequence of script inside createHTML can bring the document's refcount to zero while the native frame still needs it. The document is detached, its frame is gone, its tree is empty — but the object is alive, and the coercion path finishes on valid memory.

What the fix does not settle is how far this goes past a crash. The observable effect is a freed-Document dereference with free timing chosen precisely by the attacker, since the free happens inside a function they wrote. Escalation turns on two questions the supplied context leaves open: which fields of the freed Document the remainder of the helper actually touches, and whether the allocation can be reclaimed with attacker-controlled contents before that touch — for an object of Document's size class, that means heap grooming from the same JS turn as the callback. If the post-callback path includes a virtual call or a pointer load that is later dereferenced, and the reclaim lands, this becomes a reclaim-and-confuse primitive rather than a crash. Absent both, the defensible claim is a controlled use-after-free with attacker-chosen free timing. Everything here stays inside the WebContent process; leaving the renderer would still require a separate sandbox escape.

A Trusted Types default policy lets attacker JavaScript run in the middle of a native innerHTML path and free the very document that path is holding by bare pointer.

Trusted Types inserted a new synchronous JS re-entrancy point into sinks that historically had none. element.innerHTML = 'x' used to be a straight C++ run from binding to parser; with a default policy registered, author JS now executes in the middle of it, and every caller written under the assumption "no script runs between here and the parser" silently became a re-entrancy hazard the day the feature shipped. The hazard is invisible at the call site — the signature still reads as a pure C++ helper. Note also what WebKit chose not to do: rather than restructure the coercion helper to take its own reference internally, the fix deploys protect() at each caller, which leaves the invariant resting on call-site discipline across nine sinks. That shape regresses when a tenth sink is added.