← All reports

Trusted Types 처리 과정에서 발생하는 Document의 Use-after-free를 한국어로 재작성하겠습니다.

Use-after-free of Document in trustedTypeCompliantString

HighWebCore DOM — Trusted Types enforcement pathUAF

CVE: CVE-2026-64787 · Safari 26.6.1 · 2026년 8월 18일 릴리즈 Impact: 악의적으로 조작된 웹 콘텐츠를 처리하는 과정에서 예기치 않은 프로세스 종료가 발생할 수 있습니다 Apple's description: Use-after-free 문제가 메모리 관리 개선을 통해 수정되었습니다. Credit: 杉山 壮太, Shubham Chaskar

cb83583 | Bugzilla 313703

High. Trusted Types는 author가 제어하는 JavaScript callback을 innerHTML 경로 한가운데에 끼워 넣었습니다. 이 경로를 호출하는 코드는 모두 "script가 개입하지 않는다"는 전제로 작성되어 있었는데, document는 그 경로를 가로질러 bare pointer 형태로 전달되고 있었습니다. free 시점은 전적으로 attacker가 결정할 수 있습니다. controlled crash를 넘어서는 확장에는 reclaim이 필요합니다.

Trusted Types spec은 XSS mitigation으로 설계되었습니다. 페이지가 CSP를 통해 opt-in하면, 이후 모든 string-to-HTML sink는 parser에 도달하기 전에 입력값을 policy object에 먼저 넘겨야 합니다. 이 interposition에는 security model이 드러내지 않는 비용이 하나 있습니다. Document가 default라는 이름의 policy를 등록하면, coercion 단계는 순수한 C++ 변환이 아니라 author JavaScript로의 synchronous call이 되어버립니다. 이 호출은 DOM binding과 parser 사이에 자리하게 됩니다. WebCore::trustedTypeCompliantString()이 바로 이 호출이 일어나는 지점이며, 이 함수는 자신이 속한 governing document를 인자로 받아 해당 document의 CSP와 policy factory를 조회합니다. 이번 fix 이전에는 이 인자가 raw Document*였습니다.

관전 포인트: 페이지는 다른 frame의 innerHTML 대입 도중 실행되는 policy callback을 등록할 수 있습니다. 그 callback 안에서 해당 frame의 document를 파괴한 뒤, native 코드는 이미 해제된 메모리를 가리키는 pointer를 그대로 들고 복귀하게 됩니다.

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

이 fix는 기계적으로는 사소하지만, 의미상으로는 완전한 수정입니다. Document.cpp의 세 call site에서 document 인자를 protect()로 감싼 뒤 coercion helper에 전달하도록 변경되었습니다. Document::parseHTMLUnsafecontext.contextDocument()를 protect하고, writeln의 backend이기도 한 Document::writecontextDocument()를 protect합니다. Document::execCommand visitor lambda의 insertHTML 분기 역시 동일하게 처리됩니다. protect()는 raw Document*를 reference-counted holder로 승격시키며, 이 holder의 lifetime은 전체 call expression과 동일합니다. 따라서 trustedTypeCompliantString 진입 전에 refcount가 증가하고, 함수가 반환된 뒤에야 release됩니다.

Commit message에 따르면, 제공된 hunk에는 포함되어 있지 않은 여섯 개의 sink에도 동일한 수정이 적용되었습니다 — Element::setHTMLUnsafe, Element::setOuterHTML, Element::setInnerHTML, Element::insertAdjacentHTML, Range::createContextualFragment, 그리고 ShadowRoot::setHTMLUnsafeShadowRoot::setInnerHTML입니다. 총 아홉 곳의 call site에 같은 수정이 반복 적용된 셈입니다. Review는 Wenson Hsieh와 Chris Dumez가 담당했으며, commit message 자체의 요약은 "smart pointer를 더 배치해서 버그를 고쳤다"입니다.

나머지 hunk는 regression test와 그 expected output입니다. 이 test는 부수적인 요소가 아닙니다. Deterministic하게 재현되는 완전한 PoC이며, 이 diff에서 가장 많은 정보를 담고 있는 부분입니다.

Trusted Types. CSP 기반의 XSS mitigation입니다. Content-Security-Policy: require-trusted-types-for 'script'를 보내는 document는 더 이상 innerHTML, outerHTML, document.write 같은 HTML sink에 일반 string을 대입할 수 없습니다. 이런 sink는 등록된 policy가 생성한 TrustedHTML object를 요구합니다.

Default policy. Policy는 예약어인 default라는 이름으로 등록될 수 있습니다. Trusted Types를 요구하는 document의 sink가 TrustedHTML이 아닌 일반 string을 받으면, engine은 곧바로 거부하는 대신 이 default policy의 createHTML callback을 호출해 문자열을 변환합니다. 이 callback은 author가 작성한 JavaScript입니다.

Re-entrancy. Native C++가 JavaScript를 호출하는 지점을 가리키는 일반 용어입니다. 그 지점에서 실행되는 script는 DOM이 허용하는 범위 내에서 무엇이든 할 수 있습니다. Tree를 변형하거나, frame을 detach하거나, reference를 끊는 것도 가능하며, 이 모든 일이 자신을 호출한 C++ frame으로 제어권이 돌아가기 전에 일어날 수 있습니다. 호출 이전에 계산되어 호출 이후에 사용되는 모든 C++ state는 그 script가 무슨 일을 하든 살아남아야 합니다.

trustedTypeCompliantString(). Trusted-Types로 보호되는 각 sink가 거쳐가는 WebCore helper입니다. 해당 operation을 governing하는 document를 인자로 받아 그 document의 CSP와 policy factory를 조회하고, default policy가 등록되어 있으면 그 callback을 호출한 뒤 coercion된 string이나 CSP violation report를 반환합니다.

Document::contextDocument(). 현재 operation을 governing하는 CSP와 policy factory를 가진 Document*를 반환합니다. Ownership이 없는 bare pointer를 반환합니다.

protect(). Raw pointer나 reference를 RefPtr/Ref holder로 감싸는 WebKit의 편의 함수입니다. 해당 temporary가 살아있는 동안 refcount를 증가시킵니다.

Frame document의 lifetime. iframe의 Document는 자신을 소유한 frame과 GC에 도달 가능한 JavaScript wrapper에 의해 살아있게 유지됩니다. iframe을 tree에서 제거하고 남아있는 wrapper reference까지 없애면, 그 document는 collectable 상태가 됩니다. document.adoptNode()는 node를 다른 document로 옮겨 owner를 바꾸는 함수로, document가 마지막 node에서 벗어나게 만드는 방법이 됩니다. GCController.collect()는 synchronous collection을 강제로 실행시키는 test 전용 hook이며, regression test는 이를 이용해 "언젠가 collectable"한 상태를 "지금 당장 free"된 상태로 바꿉니다.

이 버그는 re-entrancy 경계를 넘어 handle이 빌려지는 유형에 해당합니다. Ownership이 없는 pointer가, pointee의 마지막 reference를 끊을 수 있는 user code를 synchronous하게 호출하는 함수에 전달되는 패턴입니다.

  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

화살표를 따라가 보면 흐름이 드러납니다. Native frame은 ctx를 raw Document*로 들고 helper에 진입하는데, 이 시점에 그 document를 살아있게 만드는 것은 그 순간에 우연히 존재하는 DOM 및 frame ownership뿐입니다. Helper는 default policy를 찾아 createHTML로 진입합니다. 이제 제어권은 attacker JavaScript로 넘어가는데, 같은 stack 위에서 native frame의 local들이 아무 방어 없이 그 아래 놓인 채로 실행됩니다. Callback은 이 document를 지탱하던 모든 연결을 끊어냅니다. adoptNode가 마지막으로 남은 node를 빼내고, iframe.remove()가 frame을 detach하며, iframe = null이 JS handle을 없애고, 강제로 실행된 collection이 object를 회수합니다. Callback이 string을 반환하면 helper는 실행을 재개하는데, violation을 보고하고 CSP를 조회하고 결과를 조립하는 이 모든 과정이 이미 해제된 메모리를 가리키는 pointer를 통해 이루어집니다.

Test의 cross-document 구성이야말로 유심히 볼 필요가 있는 부분입니다. 이 구성이 바로 free를 도달 가능하게 만드는 핵심이기 때문입니다. Attacking script는 대상 document 내부에서는 결코 실행되지 않습니다. iframe의 inline script는 자신의 trustedTypes factory를 parent에게 넘겨주고(parent.innerTrustedTypes = trustedTypes), parent는 prototype을 거쳐 그 factory에 policy를 등록합니다.

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

Parent는 또한 자신이 만든 node를 iframe의 body 안에 심어두는데, 그 결과 parent에서 실행되는 targetElement.innerHTML = 'x'iframe의 Trusted Types context에 의해 governing됩니다. 바로 이것이 이 트릭의 전부이며, 이 버그 유형을 대부분의 sink에서 배제시키는 직관을 정면으로 무너뜨립니다. 통상적인 사고 모델은 "내 document는 내 script가 stack 위에 있는 동안 죽을 수 없다"는 것이고, 이는 대체로 맞습니다. Document에서 실행되는 script는 그 document를 살아있게 유지하기 때문입니다. 하지만 여기서 native stack에 올라가 있는 pointer가 가리키는 document는 attacker script가 속하지 않은 다른 frame의 것이고, 그 바깥 script는 얼마든지 그 document를 파괴할 수 있습니다.

순서대로 정리하면 trigger는 다음과 같습니다.

  1. Trusted Types를 요구하는 CSP를 가진 iframe을 로드합니다. 그 내부에서 parent가 만든 div를 body에 adopt하고, 자신의 trustedTypes factory를 parent에 export합니다.
  2. Parent에서 그 export된 factory에 createPolicy를 호출해, createHTML이 attacker JS인 default policy를 등록합니다.
  3. Export된 factory reference를 null로 만들고 collect를 실행해, iframe 자체 외에는 아무것도 그 iframe document를 붙잡고 있지 않도록 합니다.
  4. Parent에서 targetElement.innerHTML = 'x'를 실행합니다. 해당 element는 여전히 iframe의 document에 속해 있으므로, iframe의 default policy가 조회됩니다.
  5. createHTML 내부에서 element를 adoptNode로 다시 빼내고, iframe을 remove한 뒤 null로 만들고 collection을 강제 실행합니다. iframe의 Document가 파괴됩니다.
  6. Callback에서 반환합니다. Helper는 raw Document*를 통해 실행을 이어갑니다.

protect()는 이 invariant를 직접 복원합니다. Callback이 실행되기 전에 refcount를 확보하고 helper가 끝난 뒤에야 release하므로, createHTML 내부의 어떤 script 시퀀스도 native frame이 아직 필요로 하는 동안 document의 refcount를 0으로 만들 수 없습니다. Document는 detach되고 frame은 사라지고 tree는 비어있지만, object 자체는 살아있으며 coercion 경로는 유효한 메모리 위에서 마무리됩니다.

이 fix가 해결하지 않는 부분은 이것이 crash를 넘어 얼마나 확장될 수 있는가입니다. 관찰되는 동작은 free된 Document에 대한 dereference이며, 그 free 시점은 attacker가 작성한 함수 내부에서 일어나는 만큼 attacker가 정밀하게 선택할 수 있습니다. 확장 가능성은 제공된 context에서 확인되지 않는 두 가지 질문에 달려 있습니다. Helper의 나머지 부분이 해제된 Document의 어떤 field를 실제로 건드리는지, 그리고 그 접근이 일어나기 전에 attacker가 원하는 내용으로 해당 allocation을 재사용할 수 있는지입니다. Document 크기대의 object라면, 이는 createHTML과 같은 JS turn 내에서의 heap grooming을 의미합니다. 만약 callback 이후 경로에 virtual call이나 나중에 dereference되는 pointer load가 포함되어 있고, reclaim이 성공한다면, 이는 단순한 crash가 아니라 reclaim-and-confuse primitive로 이어질 가능성이 있습니다. 두 조건이 모두 성립하지 않는다면, 방어 가능한 결론은 attacker가 free 시점을 선택할 수 있는 controlled use-after-free 정도입니다. 이 모든 동작은 WebContent process 내부에 머물러 있으며, renderer를 벗어나려면 별도의 sandbox escape가 여전히 필요합니다.

Trusted Types의 default policy는 attacker JavaScript가 native innerHTML 경로 한가운데에서 실행되도록 허용하며, 그 경로가 bare pointer로 붙잡고 있던 document 자체를 해제시킬 수 있습니다.

Trusted Types는 과거에는 존재하지 않았던 새로운 synchronous JS re-entrancy 지점을 sink에 도입했습니다. element.innerHTML = 'x'는 원래 binding에서 parser까지 곧바로 이어지는 순수 C++ 실행이었습니다. 하지만 default policy가 등록되면 author JS가 그 한가운데에서 실행되고, "여기서부터 parser까지는 script가 개입하지 않는다"는 전제로 작성된 모든 caller는 이 기능이 출시된 순간 조용히 re-entrancy hazard로 바뀌었습니다. 이 hazard는 call site에서는 눈에 보이지 않습니다. 함수 signature는 여전히 순수 C++ helper처럼 읽힙니다. 또한 WebKit이 하지 않은 선택도 눈여겨볼 필요가 있습니다. Coercion helper가 내부적으로 자체 reference를 갖도록 구조를 바꾸는 대신, fix는 각 caller에 protect()를 배치하는 방식을 택했습니다. 그 결과 이 invariant는 아홉 개의 sink 전체에 걸친 call-site 차원의 규율에 의존하게 됩니다. 이런 구조는 열 번째 sink가 추가되는 순간 다시 무너질 수 있는 형태입니다.

Translate the above section to Korean now. Preserve all markdown formatting, HTML comments (), code blocks, and diff blocks exactly as-is. All heading lines (###, ####) must be reproduced verbatim in English at the same position — never drop, translate, or replace a heading. Output translated markdown ONLY: no preamble, no acknowledgement, no "번역하겠습니다"-style opening line, no trailing commentary.

Section 4 [Sink]