[6] Cross-Process Page Identity Confusion in didPostMessage
The UIProcess looked up the page, then never asked who was asking.
Medium. 이미 code execution을 확보한 renderer가 다른 renderer 소유의 page로 UIProcess 중개 script-message dispatch를 유도할 수 있는데, 이는 site isolation이 막으려는 격리 구조를 정확히 침해하는 지점입니다. Memory-safety primitive를 제공하지는 않고 일반 웹 콘텐츠 관점에서도 별다른 이득이 없기 때문에, severity가 그 이상으로 올라가지는 않습니다.
WebKit은 브라우저를 privileged UIProcess와 다수의 sandboxed WebContent process로 분리하며, UIProcess가 renderer들의 모든 요청에 대한 reference monitor 역할을 담당합니다. 각 WebContent process는 UIProcess 내부에서 WebProcessProxy로 표현됩니다. 이 객체는 해당 process의 IPC connection을 종단하고, 전달되는 메시지를 일반 C++ 메서드 호출 형태로 수신합니다. 한편 WebPageProxy는 하나의 web view에 대응하는 UIProcess측 객체로, wire 상에서는 WebPageProxyIdentifier로 식별됩니다. 이때 per-connection handler가 지켜야 할 불변 조건은 소유권에 관한 것입니다. 즉 WebProcess는 자신이 실제로 호스팅하는 page만 지정할 수 있어야 합니다.
관전 포인트: 이미 공격자에게 장악된 WebProcess가 다른 renderer가 호스팅하는 page를 지정하면, UIProcess가 해당 page의 identity로 script-message dispatch를 전달하게 만들 수 있습니다. 이 경우 async reply로 돌아오는 내용까지 함께 수신할 수 있습니다.
커밋 메시지에서 발췌합니다.
WebProcessProxy::didPostMessage()may look up aWebPageProxybelonging to another web process if given a badWebPageProxyIdentifierfrom a compromised WebProcess.Address the issue by adding a
MESSAGE_CHECKthat checks that the page is associated with the current WebProcess, using the pre-existingWebProcessProxy::isAssociatedWithPage()utility function. Note that I had to tweakisAssociatedWithPage()to also checkm_remotePagesto keep site isolation tests working.
Source/WebKit/UIProcess/WebProcessProxy.cpp
Source/WebKit/UIProcess/RemotePageProxy.cpp
WebContent A (compromised) UIProcess WebContent B ────────────────────────── ───────── ──────────── DidPostMessage(pageID_of_B) ─────► WebProcessProxy(A)::didPostMessage │ ├─► WebPageProxy::fromIdentifier(pageID) │ global registry: ALL pages ◄── hosts B's page │ only check: if (!page) return ▼ WebUserContentControllerProxy::get(id) │ └─► dispatch to embedding app, bound to B's page async reply ──────────► back to A ```
위 다이어그램에서 볼 수 있듯이, didPostMessage()는 connection 단위 handler로서 this는 발신 WebProcess의 proxy를 가리킵니다. 그럼에도 불구하고 이 handler는 attacker가 제공한 identifier를, UIProcess 내 모든 WebPageProxy를 아우르는 프로세스-무관 전역 레지스트리인 WebPageProxy::fromIdentifier()를 통해 조회했습니다. 검증은 조회 결과에 대한 null check가 전부였고, 조회된 page를 메시지가 도착한 connection과 연결 짓는 장치는 존재하지 않았습니다.
WebPageProxyIdentifier 값에는 connection 단위의 namespacing이 전혀 없습니다. attacker가 제어하는 WebProcess는 다른 WebProcess가 호스팅하는 page의 identifier를 담은 DidPostMessage IPC를 보낼 수 있습니다. 이 경우 UIProcess는 그 foreign page를 조회하고, identifier로 WebUserContentControllerProxy를 가져온 뒤, sender가 호스팅하는 어떤 page도 아닌 victim page에 바인딩된 상태로 user-content message dispatch를 계속 진행하게 됩니다.
패치는 isAssociatedWithPage()를 호출하도록 하여 ownership invariant를 복원합니다. 이 함수는 해당 프로세스 자신의 m_pageMap, m_remotePages, m_provisionalPages, m_suspendedPages, m_pagesPendingClose를 모두 열거합니다. m_remotePages와 m_pagesPendingClose가 추가된 이유는, 기존 predicate가 정당한 association을 과소 근사하고 있었기 때문입니다. site isolation 환경에서는 subframe 프로세스가 해당 page에 대해 RemotePageProxy만 갖고 있으며, 비동기 WebPage::Close handshake 도중에는 UIProcess가 이미 자신의 map에서 그 page를 제거한 상태이지만 WebProcess는 여전히 자신의 close 처리 과정 내부에서 정당하게 메시지를 보내고 있는 상태이기 때문입니다.
이 취약점은 web content 단독으로는 도달할 수 없습니다. page script는 WebProcess가 DidPostMessage IPC에 찍는 WebPageProxyIdentifier를 임의로 고를 수 없고, 하나의 WebProcess를 정당하게 공유하는 page들은 모두 그 프로세스의 m_pageMap에 나타나므로 새로운 check도 어차피 통과하게 됩니다. commit message가 명시한 compromised-WebProcess threat model에 부합하는 현실적인 경로는 다음과 같은 2단계 공격입니다.
- 별도의 renderer 버그를 통해 WebContent process에서 code execution을 확보합니다.
- 다른 프로세스에 속한 page의
WebPageProxyIdentifier를 알아내거나 brute-force로 추측합니다. 이 값들은 순차적으로 생성되므로 탐색 공간이 작을 것으로 보이며, 패치 이전에는 잘못 추측하더라도 connection이 종료되는 대신 null check에서 그냥 반환되는 데 그쳤습니다. 다만 identifier 생성 방식이 단조 증가하는ObjectIdentifier::generate()인지, 아니면 randomized identifier인지는 제공된 context로 확인되지 않으며, 이 단계의 실제 비용은 어디까지나 예상되는 방향일 뿐입니다. - 그 foreign identifier와
UserContentControllerIdentifier를 지정한 조작된WebProcessProxy::DidPostMessage를 직접 발신합니다.
여기서 두 가지 결과로 이어질 가능성이 있습니다. 첫째, embedding application의 script-message 처리 로직이 attacker가 호스팅하지 않는 page로부터 온 것으로 attribute된 메시지를 관찰하게 될 수 있습니다. 만약 애플리케이션이 메시지가 도착한 web view나 frame을 기준으로 특권 동작을 승인한다면 — native capability를 gating하는 WKScriptMessageHandler가 흥미로운 대상입니다 — 그 승인 결정이 우회될 가능성이 있습니다. 둘째, didPostMessage의 completion handler는 값을 발신 프로세스로 되돌려주므로, victim page의 handler가 만들어낸 결과가 attacker의 프로세스로 전달될 가능성이 있고, 이는 cross-page information-disclosure channel에 해당할 수 있습니다. 두 결과 모두 handler의 눈에 보이는 형태(fromIdentifier 조회, controller 조회, Expected<..., String> reply)로부터 예상되는 방향이며, 패치된 라인 이후의 didPostMessage 본문은 제공된 source context에 포함되어 있지 않습니다.
취약한 handler 자체는 WebKit 스택에서 가장 권한이 높은 UIProcess에서 실행되지만, 이 패치가 막는 것은 그곳에서의 code execution이 아닙니다. 막는 것은 하나의 WebProcess가 UIProcess가 중개하는 dispatch를 다른 WebProcess의 page 쪽으로 유도하는 행위입니다. 이는 그 자체로 sandbox escape는 아니며, attacker는 여전히 별도의 escape가 필요합니다. chain 안에서의 가치는 수평적입니다. 하나의 sandbox 처리된 renderer에 국한되었던 compromise가 다른 renderer가 소유한 상태에 도달할 수 있게 될 가능성이 있습니다.
이번 변경은 자신이 새로 설치한 check의 attack surface 역시 함께 넓힙니다. m_pagesPendingClose set은 새 validator에 대한 의도적인 완화 장치입니다. isAssociatedWithPage()는 이제, 프로세스가 더 이상 어떤 live page map에도 나타나지 않는 page identifier에 대해서도, sendPageCloseMessage() 호출 시점부터 WebPage::Close async reply가 도착하거나 취소될 때까지 true를 반환합니다. 이 window가 WebProcess 자신의 responsiveness에 의해 bound된다는 전제는 존재하지만 강제되지는 않습니다. WebPage::Close에 아예 응답하지 않는 WebProcess가 있다면, isAssociatedWithPage()에 게이팅된 handler는 UIProcess 관점의 teardown 이후에도 해당 page에 대해 무기한 도달 가능한 상태로 남게 됩니다. 여기에 더해 lifetime 형태의 새로운 결합도 하나 생깁니다. reply lambda는 WeakPtr { *this }를 캡처하고 WebProcessProxy 멤버인 m_pagesPendingClose를 변경하는 반면, pending-reply 큐 자체는 base 클래스인 AuxiliaryProcessProxy에 존재합니다. 이 새로운 state가 끌어들이는 버그 종류는 async-reply 취소 과정에서의 destructor-ordering use-after-free이며, 지금 고치고 있는 authorization 버그와는 별개입니다.
발견 경위는 fuzzing이나 crash report보다는 UIProcess IPC handler에 대한 체계적인 패턴 감사였을 가능성이 높습니다. 이 버그는 crash를 유발하지 않으며, 패치가 사용하는 유틸리티는 WebProcessProxy에 이미 존재했지만 production call site가 없었던 함수였습니다. 감사의 패턴은 "connection 단위 proxy 위의 message handler 중, 전역 레지스트리를 통해 WebPageProxyIdentifier를 조회하면서 발신 프로세스를 기준으로 그 page를 검증하는지"를 하나씩 확인하는 방식이었을 것으로 보입니다. rdar 참조와 두 명의 reviewer sign-off는 compromised-WebProcess threat model에 대한 내부 hardening sweep과 일치합니다. 또한 isAssociatedWithPage()가 m_remotePages까지 확장되어야 site-isolation 테스트를 통과할 수 있었다는 사실은, check가 먼저 작성되고 그로 인한 false-positive가 기존 테스트 스위트에 의해 이후 발견되었음을 시사합니다.
이 취약점은 UIProcess가 중개하는, WebContent process 간의 cross-process isolation 경계를 약화시킵니다. 이 보안 모델은 UIProcess가 reference monitor로 동작한다고 전제합니다. 즉 어떤 프로세스의 connection에서 도착한 identifier는 그 프로세스가 정당하게 호스팅하는 객체만을 가리킬 수 있어야 하며, 그래야 하나의 renderer가 compromise되더라도 그 renderer가 호스팅할 수 있는 site로 피해가 국한됩니다. 패치 이전에는 이 handler에서 이 전제가 강제되지 않았고, 그 결과 한 WebProcess에서 code execution을 확보한 attacker가 무관한 프로세스에 속한 WebPageProxy에 대해 동작을 수행할 수 있었습니다. 즉 attacker가 전혀 통제하지 않는 page의 identity로 embedding application의 script-message 처리에 도달할 수 있었던 셈입니다. 모델 수준에서 보면 그 결과는 process/page 경계를 가로지르는 수평적 privilege escalation이며, 어떤 page 또는 web view에서 온 script message인지를 기준으로 삼는 애플리케이션 수준의 authorization 결정이 우회될 가능성이 있습니다.
Insight
이 commit에서 흥미로운 부분은 한 줄짜리 MESSAGE_CHECK 자체가 아니라, "이 프로세스가 이 page를 소유한다"는 명제를 정의하는 일이 실제로 얼마나 까다로웠는가에 있습니다. 기존의 isAssociatedWithPage()는 NODELETE annotation을 달고 있었고, page가 프로세스와 맺을 수 있는 다섯 가지 관계 중 세 가지만 다루고 있었습니다. 이 함수가 telemetry helper에서 보안 predicate로 승격되는 순간, site isolation(m_remotePages)과 비동기 close handshake(m_pagesPendingClose) 모두 기존 모델이 포착하지 못한 정당한 association 상태였다는 사실이 드러난 셈입니다. 수많은 일시적 상태를 가진 lifecycle을 과소 근사하는 association predicate는, process swap과 back/forward cache, site isolation, 지연된 teardown이 동시에 뒤섞여 돌아가는 UIProcess에서는 구조적인 위험 요인입니다. 너무 좁으면 정상 IPC가 깨지고, 너무 넓으면 check가 더 이상 check로서 기능하지 않기 때문입니다. correctness가 새로운 mutable lifetime-tracking state에 의존하는 보안 hardening check는, 그 대가를 두 번 치르게 됩니다.
Audit directions
IPC handler가 하나의 connection에 묶여 있으면서, 호출자가 넘긴 식별자를 process에 무관한 registry에서 조회하고 결과에 대해 null check만 수행하는 패턴. 여기서 지켜야 할 invariant는 connection C에서 도착한 식별자는 오직 C가 명명할 권한이 있는 객체만 가리킬 수 있다는 것입니다. 그런데 global identifier namespace를 쓰면 이 invariant가 조용히 깨집니다. 조회가 다른 누구 소유의 객체에 대해서도 성공해버리기 때문입니다. Narrow하게 보면, Source/WebKit/UIProcess에서 WebProcessProxy:: 계열 message-handler body 중 WebPageProxy::fromIdentifier(를 호출하는 지점을 grep해서, 조회와 사용 사이에 isAssociatedWithPage() 혹은 그에 준하는 guard가 있는지 확인해야 합니다. 코드에서 찾아야 할 신호는 RefPtr page = ...fromIdentifier(pageID); 다음에 단순히 if (!page) return;만 있고, page를 this process와 비교하는 로직이 전혀 없는 형태입니다. 조금 더 넓게 보면, 동일한 형태가 임의의 static/global accessor가 맨 식별자 하나로 키잉되는 곳 어디서든 나타날 수 있습니다. 실제로 이번에 패치된 handler 바로 다음 줄의 WebUserContentControllerProxy::get(identifier)도 guard가 없는 상태이며, *.messages.in handler에서 도달 가능한 WebFrameProxy / DownloadProxy / registry 스타일의 ::get/::fromIdentifier accessor들도 같은 방식으로 점검할 필요가 있습니다. 코드 검색 결과에서 잡아야 할 신호는, receiver가 connection별 proxy인 메서드 내부에서 static ...::get(SomeIdentifier)를 호출하는 패턴입니다. Widest하게 보면, 이것은 ambient-authority-handle-table 클래스에 해당합니다. Privilege-separated service가 client별 capability table 대신 모든 client에 대해 하나의 flat ID space를 쓰는 곳이라면 어디든 이 문제가 성립합니다. 이 질문은 Chromium의 Mojo interface brokering, Wayland/X11의 client resource ID, 그리고 wire로부터 numeric object ID를 역참조하는 임의의 RPC server로도 확장해서, 해당 ID가 connection별로 namespace되어 있는지 아니면 단순히 server별로만 되어 있는지를 따져봐야 합니다.
Authorization predicate에 grace-window라는 탈출구가 주어져, teardown race 상황에서 fail-closed로 처리되지 않게 되는 패턴. check는 현재의 ownership을 반영해야 한다는 invariant가, check는 어떤 unbounded한 settling window 이내의 ownership을 반영하면 된다는 형태로 약화됩니다. 그리고 이 window의 종료 시점은 흔히 검사 대상이 되는 쪽이 직접 통제하게 됩니다. Narrow하게는 WebProcessProxy::isAssociatedWithPage()와 m_pagesPendingClose의 lifetime을 검토해야 합니다. 이 식별자는 WebProcess가 WebPage::Close에 응답할 때만 제거되므로, 응답을 영영 보내지 않는 process가 있을 경우 어떤 일이 벌어지는지 추적해봐야 합니다. 아울러 WebProcessProxy.cpp와 WebPageProxy.cpp의 다른 message check들도 비슷한 "pending" set을 참조하는 predicate를 쓰고 있는지 확인할 필요가 있습니다. Wider하게 보면, 동일한 형태는 비동기 teardown 도중 객체를 명목상 계속 associated 상태로 유지하는 임의의 state machine에서도 나타납니다. Suspended/provisional/cached page tracking, pending-navigation state, 그리고 validator가 참조하는 *Pending*이라는 이름의 HashCountedSet/HashSet 등이 해당됩니다. 코드 검색 결과에서 잡아야 할 신호는, 권위 있는 map이 이미 false를 반환한 이후에도 "pending"이나 "closing" collection을 확인하는 마지막 절이 predicate 안에 존재하는 형태입니다. Widest하게 보면, 이것은 revocation-lag 클래스에 해당합니다. 진행 중인 작업이 깨지지 않도록 revocation 이후에도 credential을 한동안 인정해주는 임의의 authorization decision(token grace period, TTL을 가진 session invalidation, cached ACL entry 등)에서 성립합니다. 다른 영역으로 옮겨서도 지켜야 할 invariant는, check의 대상이 되는 주체가 grace window의 종료 시점까지 직접 통제한다면 그 window는 구조적으로 unbounded하다는 점입니다.
Async-reply completion handler가 base-class queue에 소유되어 있으면서 derived-class member를 변경하는 패턴으로, destruction 도중의 cancellation이 handler가 다루는 member들이 이미 사라진 뒤에 실행됩니다. 여기서 지켜야 할 invariant는 callback은 자신이 변경하는 state를 소유한 가장 좁은 범위의 객체보다 오래 살아남으면 안 된다는 것입니다. WeakPtr null check는 이를 보장해주지 않습니다. Pointer의 생존 여부와 member의 생존 여부가 base-last destruction 과정에서 서로 어긋나기 때문입니다. Narrow하게는 WebProcessProxy.cpp에서 WeakPtr { *this }를 캡처한 뒤 WebProcessProxy member를 건드리는 sendWithAsyncReply lambda들을 점검해야 합니다. 이번 commit에서 추가된 sendPageCloseMessage의 handler가 m_pagesPendingClose를 변경하는 바로 그 사례이며, AuxiliaryProcessProxy의 pending-message cancellation이 해당 member들이 destroy된 이후에도 실행될 수 있는지를 추적해봐야 합니다. Wider하게 보면, 동일한 클래스는 base나 더 오래 사는 manager가 소유한 container에 담긴 CompletionHandler가 그 body에서 derived나 child의 state를 읽는 모든 경우에 해당됩니다. 다른 AuxiliaryProcessProxy subclass들(NetworkProcessProxy, GPUProcessProxy)에서도 subclass member를 건드리는 reply handler가 있는지, 그리고 destructor에서 drain되는 handler queue가 있는지 확인할 필요가 있습니다. 코드 검색에서 잡아야 할 신호는, WeakPtr { *this }를 캡처한 lambda의 body가 queue를 소유한 클래스보다 더 derived된 클래스에서 선언된 member를 역참조하는 형태입니다. Widest하게 보면, 이것은 callback-outlives-partial-object 클래스이며, staged destruction이나 deferred cancellation을 갖는 임의의 언어에서 나타납니다 — C++의 base-last destruction, self field를 캡처하는 closure가 있는 Rust의 Drop 순서, owner의 field가 clear된 이후 발화하는 JS teardown handler 등입니다. 이식 가능한 감사 질문은, success path가 아니라 cancellation path에서 이 callback이 호출되는 시점에 캡처된 객체의 어느 부분까지 아직 construct된 상태인가입니다.
Note: Commit message에 담긴 compromised-WebProcess threat model에 대한 설명, 그리고 트리에 포함된 close-handshake fail-closed case에 대한 regression test와 m_pagesPendingClose cancellation use-after-free에 대한 regression test는, 기술된 그대로 인용합니다. 해당 test 소스는 제공된 context에 포함되어 있지 않습니다.