[3] DocumentThreadableLoader dereferences a dead document WeakPtr
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
WeakPtrinWebCore::DocumentThreadableLoader. Previously them_documentWeakPtrwas dereferenced by calling thedocument()orprotectedDocument()member functions.Since it's possible for the
WeakPtrm_documentto be null, we should add checks before dereferencing it to avoid hitting aRELEASE_ASSERTinWeakPtr's*operator. To ensure thatm_documentis kept alive after performing the null check, we convert it to aRefPtr.
Source/WebCore/loader/DocumentThreadableLoader.h
Source/WebCore/loader/DocumentThreadableLoader.cpp
Source/WebCore/loader/CrossOriginPreflightChecker.cpp
Patch Details
이번 변경은 loader의 document accessor를 다시 작성해 null 케이스를 표현 가능하게 만들고, 모든 접근 지점을 null-checked local strong reference로 바꾸었습니다. 또한 preflight checker를 safer-CPP exemption list에서 제외시켰습니다.
DocumentThreadableLoader::document()는 기존에 *m_document를 통해 Document&를 반환하던 방식에서, raw Document*를 반환하는 방식으로 변경되었습니다. DocumentThreadableLoader.cpp와 CrossOriginPreflightChecker.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.cpp가 Source/WebCore/SaferCPPExpectations/UncheckedCallArgsCheckerExpectations에서 제거되었는데, 이는 해당 파일이 이제 WebKit의 safer-CPP unchecked-argument 정적 검사를 통과한다는 것을 의미합니다.
Re-entrancy 경계를 넘어 도착하는 web-content 기반 loader 콜백에서 도달 가능했던, RELEASE_ASSERT로 이어지는 stale WeakPtr dereference 패턴.
Background
이 코드가 있는 위치.
DocumentThreadableLoader는 Document를 대신해 비동기 및 동기 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의 수명을 연장시키지 않기 위한 의도적인 설계입니다.
WeakPtr와 RefPtr.
WebKit의 WeakPtr<T>는 참조 대상이 파괴되면 null이 되는 non-owning smart pointer입니다. Null 상태의 WeakPtr에 operator*나 operator->를 호출하면 RELEASE_ASSERT가 발동하는데, 이는 release build를 포함해 항상 활성화되어 있으며 process를 abort시킵니다. RefPtr<T>는 reference-counted owning smart pointer로, WeakPtr를 RefPtr에 대입하면 참조 대상이 아직 살아있는 경우 strong reference를 확보해 해당 scope 동안 수명을 연장시키고, 이미 파괴된 경우에는 null로 평가됩니다.
Analysis
이 버그의 유형은 null이 된 WeakPtr를 dereference했을 때 강제로 process가 abort되는 형태입니다. 패치 이전에는 DocumentThreadableLoader::document()가 m_document를 operator*로 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.cpp가 UncheckedCallArgsCheckerExpectations에서 제거되었다는 것은, 이 파일이 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가 이를 처리하도록 강제합니다.
Audit directions
WeakPtr<Document>를 저장하고 비동기 콜백에서 이를 dereference하는 long-lived loader/observer 객체.WeakPtr::operator*내부의RELEASE_ASSERT는 이런 unchecked dereference 하나하나를 web content에서 renderer를 죽일 수 있는 DoS 지점으로 바꿔놓습니다.Source/WebCore전체에서m_document->,m_frame->,m_window->패턴을 검색해, 해당 멤버가WeakPtr인 경우 dereference 이전에RefPtr변환과 null 체크가 선행되는지 확인해야 합니다.WorkerThreadableLoader,PingLoader,BeaconLoader,EventSource같은 유사 loader부터 점검하는 것이 좋습니다.- 내부
WeakPtr를 dereference해 reference를 반환하는 accessor 메서드. Reference 반환 타입은 호출자로부터 null 케이스를 숨기고,RELEASE_ASSERT위험을 모든 호출 지점으로 전파시킵니다.T& foo() { return *m_weakMember; }형태의 선언을 클래스 header에서 찾아,T* foo() { return m_weakMember.get(); }같은 형태로 바꿀 수 있는지 점검해야 합니다.Source/WebCore/loader/와Source/WebCore/page/전반에서DocumentThreadableLoader.h스타일의 accessor부터 확인하는 것이 좋습니다. 리뷰 시*m_someWeakMember를 반환하는 한 줄짜리 accessor가 신호인데, 이 경우 dereference가 모든 호출 지점에서 눈에 보이지 않기 때문입니다. - safer-CPP exemption list를 버그 인벤토리로 활용.
UncheckedCallArgsCheckerExpectations와Source/WebCore/SaferCPPExpectations/의 유사 목록은 WebKit 정적 검사에서 명시적으로 예외 처리된 파일들을 나열하고 있으며, 이 목록에 있는 각 항목은 known-bad 파일에 해당합니다.loader/,dom/,html/하위를 중심으로 남아있는 각 항목을 살펴보고, 이번 커밋과 동일한WeakPtr에서RefPtr로의 변환 처리를 재현할 수 있는지 점검해야 합니다. 상당수가 동일한 유형의 crash 버그를 안고 있을 가능성이 있습니다. - document의 liveness를 전제로 하는 CORS-preflight 및 redirect 콜백 경로. 동기적인 request는 살아있는 document에서 시작되지만, 완료 시점은 임의로 늦게 도착할 수 있습니다.
CrossOriginPreflightChecker,SubresourceLoader,CachedResourceClient의 콜백(notifyFinished,redirectReceived,responseReceived)을 추적해, 각각이 originating context를 건드리기 전에 null 체크를 수행하는지 확인해야 합니다.Source/WebCore/loader/cache/CachedResourceClient.h의 구현체부터 점검하는 것이 좋습니다.