Use-after-free of Document in trustedTypeCompliantString
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
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
LayoutTests/fast/dom/trusted-types-iframe-removal-crash.html
Patch Details
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.
Background
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."
Analysis
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:
- Load an iframe whose CSP requires Trusted Types; from inside it, adopt a parent-created
divinto its body and export itstrustedTypesfactory to the parent. - From the parent, call
createPolicyon that exported factory to register adefaultpolicy whosecreateHTMLis attacker JS. - Null the exported factory reference and collect, so nothing but the frame itself is holding the iframe document.
- Assign
targetElement.innerHTML = 'x'from the parent — the element still lives in the iframe's document, so the iframe's default policy is consulted. - Inside
createHTML,adoptNodethe element back out, remove the iframe, null it, and force a collection. The iframe'sDocumentis destroyed. - 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.
Insight
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.
Audit directions
-
The retrofitted callback. Narrow: enumerate every remaining caller of
trustedTypeCompliantStringinSource/WebCore— the commit message coversDocument.cpp,Element.cpp,Range.cpp,ShadowRoot.cpp— and check not only that the document/element/range arguments areprotect()-ed but whether the caller uses any other pre-call state after the helper returns: aLocalFrame*, aContentSecurityPolicy*, a parser or fragment pointer. Match tell: a bareFoo*/Foo&computed before the call and dereferenced after it. Wider: the same class appears wherever a policy, hook, or observer layer was retrofitted into an existing native path — sanitizer API entry points, custom element reaction callbacks driven from parser code,beforeunload/beforematchstyle hooks, CSP report generation that formats strings via script-visible objects. Match tell: a function body containing both a JS-callable invocation (*Callback*,invoke*,handleEvent) and a later use of a parameter that predates it. Widest: the invariant is "a borrowed handle must not span a synchronous call into untrusted code," and it holds well outside WebKit — Blink (raw_ptrandDocument*across V8 callbacks), Gecko (raw pointers across script runners), and any embedding API that passes a host object by borrow into a callback (N-API handles, Python C-API borrowed references). Carry the question: does the callee own a reference, or borrow one the callback can revoke? -
Sibling state, not just the protected argument. Pattern: partial smart-pointer deployment — one argument protected while other pointers derived from the same doomed object stay raw. Narrow: read
Source/WebCore/dom/TrustedType.cppand check whethertrustedTypeCompliantStringitself caches aContentSecurityPolicy*,TrustedTypePolicyFactory*, orScriptExecutionContext*derived from the document before invoking the default policy callback and uses it afterwards — protecting the caller's argument does nothing for a raw pointer the callee already extracted. Match tell: a localauto* x = document->something()above the callback invocation with a use below it. Wider: apply the same reading to any WebCore function that takes a protected object then works through unprotected accessors of it (->frame(),->view(),->page(),->cachedResourceLoader()) across a re-entrant call. Widest: protecting the root of an object graph does not protect the nodes below it — in any refcounted or GC'd system, ask which specific object the reference pins, and whether the values actually dereferenced after the callback hang off that object or off a sibling. -
Cross-document reachability, independent of Trusted Types. Pattern: document A's script obtaining a live object owned by document B and driving B's C++ code while B is torn down. Narrow: grep the WebIDL bindings for interfaces exposed on
windowthat hold a back-pointer to their owning document and remain usable after the owner frame is removed —TrustedTypePolicyFactory,Range,Selection,DOMImplementation,CustomElementRegistryare the natural starting set; for each, ask what happens when a method is invoked after the owner's frame is detached and collected. Match tell: an implementation whose method body opens by dereferencing a stored document or frame pointer with no null or liveness check. Wider: the same shape appears in any object retained across a detach boundary — a retainedPerformance,IDBFactory, or observer captured from a soon-to-be-removed iframe. Widest: the reusable invariant is "an object handed across an isolation boundary outlives the container that gave it meaning"; in any host, ask whether the object re-validates its container on each entry or trusts a pointer captured at construction. -
Turn call-site discipline into structure. Pattern: an invariant maintained by remembering to write
protect()at N call sites degrades as N grows. Narrow: check whethertrustedTypeCompliantString's signature can be tightened to takeRef<Document>/RefPtr<Document>outright, so a future sink cannot regress silently — if the raw-pointer overload still exists inSource/WebCore/dom/TrustedType.h, that is the regression vector. Match tell: an overload set where both the owning and non-owning parameter forms compile. Wider: this applies to every WebCore helper that re-enters script — prefer signatures that force ownership over conventions that request it, and check whether WebKit's clang static-analyzer checkers flag this argument shape at all. Ceiling: this direction is WebKit-specific — it is about the shape of one helper's API surface and the project's own checker coverage, not a portable invariant.