← All reports

[3] DocumentThreadableLoader dereferences a dead document WeakPtr

MediumWebCore loaderUAF

8384754

Severity는 Medium입니다. 타이밍은 web content가 쥐고 있습니다. Preflight가 필요한 cross-origin fetch를 시작한 뒤 frame을 detach시키면, 완료 콜백이 RELEASE_ASSERT에 걸려 renderer가 종료됩니다. 다만 이 assert 자체가 피해 범위를 제한하는 요인이기도 합니다. Null storage에 대한 pointer arithmetic이 일어나기 전에 abort되므로, attacker 입장에서 memory-safety 관점의 추가 이득은 없습니다.

WebKit에서 비동기 load는 이를 시작한 DOM 객체보다 오래 살아남습니다. fetch()XMLHttpRequest로 시작된 loader는 request가 완료될 때까지 network 및 CORS state machine이 소유하게 되는데, 이 loader는 의도적으로 자신을 시작한 document를 강하게 붙잡지 않습니다. Long-lived loader가 document에 대한 strong reference를 들고 있으면 frame 전체가 leak되기 때문입니다. 대신 loader는 weak reference만 저장하며, document를 건드리기 전에 여전히 존재하는지 확인하도록 되어 있습니다. 즉 teardown 이후 도착하는 모든 콜백이 document의 부재를 안전하게 처리해야 한다는 것이 invariant입니다.

관전 포인트: preflight가 필요한 cross-origin fetch를 시작한 페이지가 이를 발행한 frame을 teardown시키면, preflight 결과가 돌아오는 시점에 WebContent process를 안정적으로 abort시킬 수 있습니다.

This patch adds liveliness checks for dereferencing a WeakPtr in WebCore::DocumentThreadableLoader. Previously the m_document WeakPtr was dereferenced by calling the document() or protectedDocument() member functions.

Since it's possible for the WeakPtr m_document to be null, we should add checks before dereferencing it to avoid hitting a RELEASE_ASSERT in WeakPtr's * operator. To ensure that m_document is kept alive after performing the null check, we convert it to a RefPtr.

Source/WebCore/loader/DocumentThreadableLoader.h

- Document& document() { return *m_document; }
+ Document* document() { return m_document; }

Source/WebCore/loader/DocumentThreadableLoader.cpp

// class member
WeakPtr<Document, WeakPtrImplWithEventTargetData> m_document;
...
void DocumentThreadableLoader::makeCrossOriginAccessRequest(ResourceRequest&& request) {
...
- Ref document = *m_document;
+ RefPtr document = m_document;
+ if (!document)
+ return;
...
void DocumentThreadableLoader::preflightFailure(...) {
- RefPtr frame = m_document->frame();
+ RefPtr document = m_document;
+ if (!document)
+ return;
+ RefPtr frame = document->frame();

Source/WebCore/loader/CrossOriginPreflightChecker.cpp

void CrossOriginPreflightChecker::validatePreflightResponse(...) {
- RefPtr frame = loader.document().frame();
+ RefPtr loaderDocument = loader.document();
+ if (!loaderDocument) { ASSERT_NOT_REACHED(); return; }
+ RefPtr frame = loaderDocument->frame();

이번 변경은 loader의 document accessor를 다시 작성해 null 케이스를 표현 가능하게 만들고, 모든 접근 지점을 null-checked local strong reference로 바꾸었습니다. 또한 preflight checker를 safer-CPP exemption list에서 제외시켰습니다.

DocumentThreadableLoader::document()는 기존에 *m_document를 통해 Document&를 반환하던 방식에서, raw Document*를 반환하는 방식으로 변경되었습니다. DocumentThreadableLoader.cppCrossOriginPreflightChecker.cpp의 모든 호출 지점은 이제 m_document를 local RefPtr로 캡처한 뒤 null 체크를 거치고 나서야 메서드를 호출하도록 수정되었습니다. Loader 쪽에서는 shouldSetHTTPHeadersToKeep, makeCrossOriginAccessRequest, cancel, didReceiveResponse, didFail, preflightFailure, loadRequest, securityOrigin, contentSecurityPolicy, crossOriginEmbedderPolicy, logErrorAndFail이 대상이며, preflight checker 쪽에서는 validatePreflightResponse, notifyFinished, startPreflight, doPreflight가 대상입니다. 마지막으로 loader/CrossOriginPreflightChecker.cppSource/WebCore/SaferCPPExpectations/UncheckedCallArgsCheckerExpectations에서 제거되었는데, 이는 해당 파일이 이제 WebKit의 safer-CPP unchecked-argument 정적 검사를 통과한다는 것을 의미합니다.

Re-entrancy 경계를 넘어 도착하는 web-content 기반 loader 콜백에서 도달 가능했던, RELEASE_ASSERT로 이어지는 stale WeakPtr dereference 패턴.

이 코드가 있는 위치. DocumentThreadableLoaderDocument를 대신해 비동기 및 동기 load를 수행하는 WebCore 클래스입니다. fetch(), XMLHttpRequest, EventSource 등의 API를 뒷받침하며, CrossOriginPreflightChecker를 통해 CORS preflight를 진행합니다.

Loader 수명과 document 수명. Loader는 RefCounted이며 자신을 시작시킨 Document보다 오래 살아남을 수 있습니다. Frame이 detach되거나 document가 교체되면 document는 파괴되지만, network/CORS state machine이 여전히 소유하고 있는 진행 중인 loader는 계속 동작하다가 결국 완료 또는 오류 콜백을 전달합니다. Loader는 document를 WeakPtr<Document, WeakPtrImplWithEventTargetData> m_document로 저장하는데, 이는 document의 수명을 연장시키지 않기 위한 의도적인 설계입니다.

WeakPtrRefPtr. WebKit의 WeakPtr<T>는 참조 대상이 파괴되면 null이 되는 non-owning smart pointer입니다. Null 상태의 WeakPtroperator*operator->를 호출하면 RELEASE_ASSERT가 발동하는데, 이는 release build를 포함해 항상 활성화되어 있으며 process를 abort시킵니다. RefPtr<T>는 reference-counted owning smart pointer로, WeakPtrRefPtr에 대입하면 참조 대상이 아직 살아있는 경우 strong reference를 확보해 해당 scope 동안 수명을 연장시키고, 이미 파괴된 경우에는 null로 평가됩니다.

이 버그의 유형은 null이 된 WeakPtr를 dereference했을 때 강제로 process가 abort되는 형태입니다. 패치 이전에는 DocumentThreadableLoader::document()m_documentoperator*로 dereference해 Document&를 반환했고, 파일 곳곳의 여러 code path가 해당 멤버를 직접 dereference했습니다.

  Web content                 Loader                        Document
  ───────────                 ──────                        ────────
  fetch(cross-origin,
        custom headers)  ──►  preflight in flight
                                                            live
  iframe.remove()  ─────────────────────────────────────►   destroyed
                                                            m_document → null
  (preflight response)  ──►   preflightFailure()
                              m_document->frame()
                                └─ WeakPtr::operator*
                                     RELEASE_ASSERT → abort

DocumentThreadableLoader는 비동기 CORS preflight 진행 중, redirect 콜백, frame detach 이후의 오류 보고, service-worker 경유 경로 등 여러 시나리오에서 자신의 Document보다 정당하게 오래 살아남을 수 있습니다. 이 때문에 위와 같은 unchecked dereference 지점 중 어느 하나라도 도달되면 release-assert가 발동해 WebContent process가 종료될 수 있었습니다. WeakPtr::operator*가 해당 RELEASE_ASSERT를 가지고 있다는 주장은 커밋 메시지 자체에 명시된 수정 근거를 그대로 인용한 것이며, WTF 구현 자체는 제공된 context에 포함되어 있지 않습니다.

Web content에서의 trigger 과정은 비교적 단순합니다. 먼저 공격자가 제어하는 iframe이나 window 안에서, custom header나 non-simple method를 사용해 preflight가 필요한 cross-origin fetch()를 발행합니다. 이후 preflight가 진행 중인 상태에서 iframe을 제거하거나 frame을 navigate시키거나 window를 닫는 방식으로 document를 동기적으로 detach시킵니다. 이 상태에서 preflight 완료, redirect, 실패 콜백이 도착하면, 해당하는 loader 또는 preflight-checker 메서드가 이미 null이 된 m_document를 dereference하게 됩니다. 패치 이전 기준으로 preflightFailure (m_document->frame()), cancel (m_document->identifier()), loadRequest (m_document->frame()), validatePreflightResponse (loader.document().frame()) 모두 이 경로를 통해 도달 가능한 호출 지점이었습니다.

이렇게 얻어지는 primitive는 web content로 안정적으로 유발 가능한 WebContent process crash이며, 그 이상은 아닙니다. WeakPtr::operator* 내부의 RELEASE_ASSERT는 null storage에 대한 pointer arithmetic이 일어나기 전에 의도적으로 abort시키도록 설계되어 있습니다. 따라서 read/write나 type-confusion 같은 primitive로는 이어지지 않으며, renderer crash가 sandbox 경계를 넘어 GPU, Networking, UI process로 확장되지도 않습니다.

발견 경위는 diff 자체에서도 확인할 수 있습니다. loader/CrossOriginPreflightChecker.cppUncheckedCallArgsCheckerExpectations에서 제거되었다는 것은, 이 파일이 WebKit in-tree safer-CPP 정적 검사기 기준으로 known-bad list에 올라 있었다는 의미입니다. WeakPtr 유래 reference를 null 체크 없이 call argument로 전달하는 패턴은 정확히 unchecked-call-args checker가 잡아내는 대상입니다. WeakPtr::operator* 내부 RELEASE_ASSERT abort에서 나온 crash telemetry 역시 동등하게 유력한 trigger 경로로 볼 수 있습니다.

이 vulnerability는 WebContent process의 availability를 약화시킵니다. HTML loading lifecycle은 DocumentThreadableLoader 콜백이 항상 살아있는 Document를 보게 된다는 전제 위에 서 있습니다. 패치 이전에는 이 invariant가 graceful한 null 체크가 아니라 RELEASE_ASSERT로 강제되고 있었기 때문에, request 시작과 콜백 도착 사이에 document가 detach되는 경로라면 어디서든 renderer가 종료될 수 있었습니다.

Insight: WebKit에서 반복적으로 나타나는 패턴입니다. 자신을 소유하는 Document보다 정당하게 오래 살아남는 컴포넌트들은 수명 연장을 피하기 위해 document를 WeakPtr로 캐싱하면서도, RefPtr로 변환해 null 체크하는 대신 operator*/operator->로 곧바로 dereference하는 경우가 많습니다. RELEASE_ASSERT는 이런 잠재적 UAF-shaped 버그를 결정적인 process abort로 바꿔주는데, 이는 memory corruption에 대한 defence-in-depth 관점에서는 이득이지만 그 대가로 web-content에서 도달 가능한 crash surface가 넓게 남게 됩니다. 이 패치에서 더 근본적인 mitigation은 header 수준의 변경입니다. document()Document*를 반환하도록 바꾼 것은 null 케이스를 type system 안으로 끌어들여 모든 caller가 이를 처리하도록 강제합니다.