← All reports

컴포넌트: UIProcess navigation policy

핵심 수정은 WebPageProxy::receivedNavigationResponsePolicyDecision에 추가된 13줄입니다. 이 guard는 위치 선정이 의도적입니다. hasRunningProcess() bail-out 직후, 그리고 pageLoadState transaction이 시작되기 에 배치되어 있습니다. 곧 버려질 결정 때문에 페이지 로드 상태가 먼저 변경되는 일을 막기 위해서입니다. 들어온 action이 PolicyAction::Download이고, request URL의 scheme이 data:이며, navigation 객체가 아예 없거나 navigation->isFromAPIClientRequest()가 false인 경우, action은 그 자리에서 PolicyAction::Ignore로 재작성되고 WEBPAGEPROXY_RELEASE_LOG(Loading, …)을 통해 거부 사실이 기록됩니다. 특히 !navigation 분기가 눈에 띕니다. navigation 객체가 없는 경우를 통과로 처리하지 않고 오히려 신뢰할 수 없는 상태로 취급합니다. 즉 guard가 fail-closed로 동작합니다.

두 번째 hunk는 첫 번째 hunk를 올바르게 만드는 부분입니다. API::Navigation에 한 줄짜리 inline accessor인 isFromAPIClientRequest()가 추가되었고, 이는 m_requestIsFromClientInput을 그대로 반환합니다. 이 accessor는 겉보기에 비슷한 질문에 답하는 기존 helper인 isRequestFromClientOrUserInput() 바로 옆에 추가되었는데, 후자는 WebContent process가 제공하는 신호까지 함께 섞여 있다는 점에서 다릅니다. 새 accessor는 UI-process entry point들이 markRequestAsFromClientInput()을 통해 설정하는 raw flag만을 의도적으로 노출합니다.

세 번째 hunk는 commit message에서 명시적으로 defense-in-depth로 언급됩니다. DocumentLoader::continueAfterContentPolicy는 이미 PolicyAction::Use 분기에서 disallowDataRequest()를 호출하고 있었습니다. 이번 패치는 동일한 predicate를 PolicyAction::Download 분기에도 추가하여, policyChecker().cannotShowMIMEType(m_response)stopLoadingForPolicyChange()를 통해 load를 되감습니다. 이 리포트에서 compromised 대상은 WebContent process이므로, WebContent 안에 위치한 체크가 여기서 실질적인 방어 수단이 되지는 않습니다. 이 hunk의 가치는 오히려 non-Cocoa 포트와 향후 flow들이 UI process가 새로 강제하는 경계와 일관성을 유지하도록 만드는 데 있습니다.

프로세스 분리. WebKit2는 신뢰할 수 없는 콘텐츠를 파싱하고 렌더링하는 sandboxed WebContent process와, 임베딩 애플리케이션을 호스팅하며 sandbox 바깥으로 나가는 capability — 파일 시스템 쓰기를 포함 — 를 보유한 UI process를 분리합니다. Navigation policy decision은 WebContent에서 시작되어 IPC를 거쳐 UI process에서 최종 확정됩니다. 이 버그의 모든 내용은 결국 이 경계의 어느 쪽이 특정 질문에 답할 권한을 갖는가로 귀결됩니다.

PolicyAction과 두 개의 checkpoint. Policy decision은 하나의 PolicyAction으로 귀결됩니다. 주로 Use(load 계속), Ignore(load 포기), Download(response를 download 로직으로 전달) 세 가지입니다. 이 값은 서로 다른 두 checkpoint에서 산출됩니다. navigation action policy는 request가 발행되기 전에 실행되며, anchor에 download attribute가 있었는지를 포함해 시작 context를 볼 수 있습니다. navigation response policy는 request 이후, MIME type과 header가 확인된 시점에 실행되며, receivedNavigationResponsePolicyDecision이 바로 이 지점에 위치합니다.

표시 가능한 MIME type. WebKit은 response MIME type을 렌더링 가능한지 여부로 분류합니다. 엔진이 response를 표시할 수 없을 때, embedder의 navigation delegate가 흔히 취하는 동작은 download policy를 반환하는 것입니다. Cocoa API 기준으로는 WKNavigationResponsePolicyDownload입니다. 이는 특정 앱의 특이한 동작이 아니라 사실상 모든 WebKit embedder의 기본 동작에 해당합니다.

data: URL. data: URL은 payload를 URL 문자열 안에 인라인으로 담습니다. 예를 들면 data:application/octet-stream;base64,… 형태입니다. 네트워크 fetch가 발생하지 않으므로, URL을 구성한 주체가 선언된 MIME type과 response body의 모든 byte를 결정하게 됩니다.

DownloadProxy. 진행 중인 download를 나타내는 UI-process 객체입니다. 이 객체를 생성하는 시점이 곧 브라우저가 사용자의 download 디렉터리에 파일을 기록하기로 확정하는 시점입니다.

Navigation의 provenance 신호들. WebKit은 "누가 이것을 요청했는가"에 대해 서로 다른 여러 개념을 추적합니다. Navigation::wasUserInitiated()userGestureTokenIdentifier를 확인하는데, 이 값은 NavigationActionData에 실려 전달됩니다. 즉 WebContent process가 보내는 struct입니다. Popup blocking이 이 값을 사용하는 대표적인 소비자입니다. m_requestIsFromClientInput-loadRequest:와 같은 UI-process 주도 load에서 markRequestAsFromClientInput()이 설정합니다. Helper인 isRequestFromClientOrUserInput()은 이 두 개념을 모두 아우르는 더 넓은 predicate입니다.

disallowDataRequest(). continueAfterContentPolicyPolicyAction::Use 분기에서 이미 사용되고 있던 DocumentLoader predicate로, 적절한 initiation 없이 발생한 data: URL main-frame navigation을 거부하며, cannotShowMIMEType()stopLoadingForPolicyChange()와 짝을 이루어 load를 정상적으로 되감습니다.

이 취약점은 renderer/UI-process 신뢰 경계를 넘나드는 confused deputy 문제입니다. Memory-safety 버그가 아니라, 신뢰할 수 없는 쪽이 신뢰받은 쪽을 의심할 수 없는 방식으로 요청했기 때문에 privileged한 쪽이 privileged한 동작을 수행해버린 사례에 해당합니다.

  WebContent (sandboxed)              │  UIProcess (trusted)
  ────────────────────────────────────┼──────────────────────────────────
  window.open("data:application/      │
    octet-stream;base64,<payload>")   │
  forged userGestureTokenIdentifier ──┼─►  popup blocker: allowed
                                      │
  navigation response (MIME =         │    delegate: not showable
    application/octet-stream) ────────┼─►    └─► PolicyAction::Download
                                      │    receivedNavigationResponse-
                                      │      PolicyDecision()
                                      │      ✗ no scheme / provenance check
                                      │      └─► DownloadProxy
  WebFrame::startDownload ◄───────────┼──────┘
    └─► StartDownload ────────────────┼─►  ~/Downloads/Unknown

화살표를 따라가 보면, 이 다이어그램에서 경계를 넘는 모든 값이 renderer가 작성한 값입니다. URL, 그리고 그로부터 결정되는 payload가 그렇습니다. 선언된 MIME type, 그리고 그로부터 결정되는 delegate의 응답도 마찬가지입니다. Gesture token, 그리고 그로부터 결정되는 popup blocker의 응답 역시 같습니다. Commit message에는 renderer가 제공하는 user-gesture token을 위조함으로써 popup blocker가 우회되었다는 점, 그리고 policy decision 이후의 모든 과정 — WebFrame::startDownload부터 NetworkConnectionToWebProcess::StartDownload까지 — 이 표준 동작 그대로라는 점이 명시되어 있습니다. 이 흐름에서 "아니오"라고 답할 권한을 가진 곳은 오직 오른쪽 박스뿐이었는데, 패치 이전에는 DownloadProxy를 생성하기 전에 request의 scheme을 확인하는 절차가 전혀 없었습니다.

빠져 있던 invariant는 명확합니다. UI process는 신뢰할 수 없는 renderer가 전적으로 채운 request의 내용만을 근거로, sandbox 바깥으로 나가는 capability — 파일 시스템에 byte를 기록하는 것 — 를 허가해서는 안 됩니다. 이 흐름에서 실제로 동작했던 guard들은 모두 다른 것을 확인하고 있었습니다. Popup blocker는 user activation을 묻고 있었습니다. Navigation delegate는 renderability를 묻고 있었습니다. 어느 쪽도 "이 byte들을 sandbox 바깥의 누군가가 선택했는가"를 묻지 않았고, 그 이후 어떤 단계도 이를 묻지 않았습니다. startDownload가 실행되는 시점에는 이미 결정이 끝나 있기 때문입니다.

Commit message가 설명하는 구체적인 trigger 과정은 다음과 같습니다.

  1. Compromised WebContent가 popup blocker를 통과시키기 위해 NavigationActionData 안의 user-gesture token을 위조합니다.
  2. data:application/octet-stream;base64,<payload>로 이동하는 popup을 엽니다.
  3. Navigation response policy가 실행되고, application/octet-stream은 표시할 수 없으므로 delegate는 표준 응답인 WKNavigationResponsePolicyDownload를 반환합니다.
  4. receivedNavigationResponsePolicyDecisionPolicyAction::Download를 전달받고, 패치 이전에는 이 data: URL에 대해 실제 DownloadProxy를 생성합니다.
  5. Download 로직이 수정 없이 그대로 실행되고, commit message에 언급된 대로 Content-Disposition header가 없는 경우 인라인 payload가 ~/Downloads/Unknown에 기록됩니다. 클릭도, download attribute도, 서버도 개입하지 않습니다.

결과로 얻어지는 primitive는 제한적이지만 실재합니다. 파일 내용과 MIME type은 완전히 통제 가능하고, 목적지는 브라우저의 download 디렉터리로 제한되며, Content-Disposition이 없는 경우 commit message가 밝힌 파일명이 사용됩니다. Memory corruption도, read primitive도, 직접적인 code execution도 없습니다. Decision point 자체는 UI process에서 실행되지만 이 사실만으로 UI process에서의 execution이 확보되지는 않으므로, 전형적인 sandbox escape가 되려면 별도의 버그가 여전히 필요합니다. 다만 이 취약점이 실제로 만들어내는 것은, 제한된 renderer가 직접 접근할 수 없는 파일 시스템 위치에 도달한다는 결과이고, 이는 Apple의 요약이 언급하는 "sandbox 바깥에서 restricted web content를 처리한다"는 설명과 정확히 일치합니다. 발판 단계로서는 충분히 유의미합니다. 이후 사용자 동작을 노린 payload를 심어두거나, 파일 처리 컴포넌트의 다른 버그를 위한 입력을 준비하거나, 디스크에 흔적을 남기는 용도로 활용될 수 있습니다. Compromised-renderer라는 전제 없이도, 사용자 클릭 하나에 의존하는 변형은 일반적인 web content에서도 도달 가능한 것으로 보입니다.

패치는 신뢰받은 쪽만이 답할 수 있는 질문을 신뢰받은 쪽에게 던지도록 함으로써 이 invariant를 복원합니다. 여기서 핵심은 어떤 predicate를 사용했는가입니다. isRequestFromClientOrUserInput()이 바로 그 자리에 있었고, reviewer라면 반사적으로 손이 갈 만한 helper였습니다. 하지만 이 helper는 renderer가 제공하는 신호를 함께 포함하고 있어서, compromised renderer가 이를 만족시킬 수 있습니다. 이 CVE가 다루는 threat model 안에서는 그래서 안전하지 않습니다. 그래서 패치는 m_requestIsFromClientInput만을 그대로 읽는 isFromAPIClientRequest()를 새로 추가합니다.

bool wasUserInitiated() const { return m_lastNavigationAction && !!m_lastNavigationAction->userGestureTokenIdentifier; }
bool NODELETE isRequestFromClientOrUserInput() const;
bool isFromAPIClientRequest() const { return m_requestIsFromClientInput; }   // UIProcess-set only
void NODELETE markRequestAsFromClientInput();

세 개의 predicate가 서로 다른 세 가지 신뢰 수준을 갖고 하나의 header에 모여 있습니다. 첫 번째는 renderer로부터 유도된 값입니다. 두 번째는 두 신호가 섞인 값입니다. 오직 세 번째만이 -loadRequest:와 같은 UI-process entry point에 의해서만 설정되며, capability를 여기에 걸어도 안전한 것은 이 세 번째뿐입니다.

이 예외 처리는 정당한 사용 사례에 아무런 비용을 지우지 않는데, 이것이 blanket 방식의 거부가 성립할 수 있었던 이유입니다. <a href="data:…" download>navigation action policy를 거쳐 처리되고, -[WKWebView startDownloadUsingRequest:]는 별개의 API 경로입니다. 둘 다 receivedNavigationResponsePolicyDecision에 도달하지 않으므로, 새 guard가 차단할 수 있는 트래픽은 오직 이 리포트가 설명하는 경로로 들어온 트래픽뿐입니다. 한편 WebCore hunk는 두 분기 사이의 비대칭성을 함께 해소합니다. disallowDataRequest()는 동일한 dispatch의 Use 분기에만 적용되어 있었고 Download 분기에는 조용히 빠져 있었습니다. 즉 두 분기로 구성된 하나의 decision에서 한쪽만 scheme 제한을 갖고 있었던 셈입니다.

UI process는 compromised renderer 스스로가 제공한 provenance flag만을 근거로 파일 시스템 쓰기 권한을 허가했습니다. 이는 memory 버그가 아니라 confused deputy 문제입니다.

이 패치에서 가장 시사하는 바가 큰 부분은 새로 만들어야 했던 accessor 자체입니다. APINavigation.h 안에는 이제 형태는 같지만 신뢰 속성이 근본적으로 다른 두 개의 predicate가 나란히 놓여 있습니다. isRequestFromClientOrUserInput()isFromAPIClientRequest()인데, 이름만으로는 이 차이가 드러나지 않습니다. 이는 상시적인 naming hazard이며, 다음에 무엇을 살펴봐야 할지에 대한 강한 힌트이기도 합니다. 현재 더 넓은 범위의 helper나, WebContent process가 보내는 NavigationActionData에서 전적으로 유도되는 wasUserInitiated()에 의존하는 다른 UI-process security decision이 있다면, compromised-renderer 모델 아래서 동일한 방식으로 안전하지 않을 가능성이 있으며 같은 방식의 점검이 필요합니다.