컴포넌트: UIProcess navigation policy
패치 상세
Patch Details
핵심 수정은 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가 새로 강제하는 경계와 일관성을 유지하도록 만드는 데 있습니다.
Background
프로세스 분리. 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(). continueAfterContentPolicy의 PolicyAction::Use 분기에서 이미 사용되고 있던 DocumentLoader predicate로, 적절한 initiation 없이 발생한 data: URL main-frame navigation을 거부하며, cannotShowMIMEType()과 stopLoadingForPolicyChange()와 짝을 이루어 load를 정상적으로 되감습니다.
Analysis
이 취약점은 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 과정은 다음과 같습니다.
- Compromised WebContent가 popup blocker를 통과시키기 위해
NavigationActionData안의 user-gesture token을 위조합니다. data:application/octet-stream;base64,<payload>로 이동하는 popup을 엽니다.- Navigation response policy가 실행되고,
application/octet-stream은 표시할 수 없으므로 delegate는 표준 응답인WKNavigationResponsePolicyDownload를 반환합니다. receivedNavigationResponsePolicyDecision이PolicyAction::Download를 전달받고, 패치 이전에는 이data:URL에 대해 실제DownloadProxy를 생성합니다.- Download 로직이 수정 없이 그대로 실행되고, commit message에 언급된 대로
Content-Dispositionheader가 없는 경우 인라인 payload가~/Downloads/Unknown에 기록됩니다. 클릭도,downloadattribute도, 서버도 개입하지 않습니다.
결과로 얻어지는 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 문제입니다.
Insight
이 패치에서 가장 시사하는 바가 큰 부분은 새로 만들어야 했던 accessor 자체입니다. APINavigation.h 안에는 이제 형태는 같지만 신뢰 속성이 근본적으로 다른 두 개의 predicate가 나란히 놓여 있습니다. isRequestFromClientOrUserInput()과 isFromAPIClientRequest()인데, 이름만으로는 이 차이가 드러나지 않습니다. 이는 상시적인 naming hazard이며, 다음에 무엇을 살펴봐야 할지에 대한 강한 힌트이기도 합니다. 현재 더 넓은 범위의 helper나, WebContent process가 보내는 NavigationActionData에서 전적으로 유도되는 wasUserInitiated()에 의존하는 다른 UI-process security decision이 있다면, compromised-renderer 모델 아래서 동일한 방식으로 안전하지 않을 가능성이 있으며 같은 방식의 점검이 필요합니다.
Audit directions
-
Sandbox가 제공하는 provenance에 근거한 privileged-side gate. Invariant: 신뢰받은 process 안의 capability gate는, 신뢰할 수 없는 process가 값을 선택할 수 있는 flag를 참조해서는 안 됩니다. 좁게 보면 —
Source/WebKit/UIProcess에서Navigation::isRequestFromClientOrUserInput(),Navigation::wasUserInitiated(), 그리고NavigationActionData::userGestureTokenIdentifier를 직접 읽는 호출부를 grep으로 찾아, 각각을 advisory(UI 표시나 heuristic에만 영향)와 load-bearing(capability 부여)으로 분류해야 합니다. 판별 기준은 그 분기가 단순히 표시(presentation)에만 영향을 주는지, 아니면 파일 시스템, 네트워크, 앱 전달과 같은 capability를 실제로 열어주는지 여부입니다. 넓게 보면 — 이 문제군은 IPC 메시지 struct로부터 deserialize된 boolean이나 identifier를 참조하는 모든 UI-process gate를 포괄합니다. Sandbox extension 발급,ShouldOpenExternalURLsPolicy처리, popup 및 autoplay gating, permission-prompt 억제 등이 해당됩니다. 코드에서는if (someStructFromIPC.someBool)이 renderer 스스로는 수행할 수 없는 side effect를 가드하고 있는지를 찾아보아야 합니다. 가장 넓게 보면 — 이는 "attestation의 대상이 되는 당사자가 그 attestation을 스스로 제공하는" 일반적인 문제군이며, privilege-separated 시스템이라면 어디에서든 성립합니다. Chromium에서 Mojo로 전파되는 user-activation 상태, 신뢰받은 receiver가 신뢰하는 AndroidIntentextra, 클라이언트가 설정한is_admin필드를 신뢰하는 RPC 서버 등이 그 예입니다. Invariant는 코드베이스를 넘어서도 동일하게 적용됩니다. Requester가 어떤 field를 직접 설정할 수 있다면, 그 field는 evidence가 아니라 하나의 요청(request)에 불과합니다. -
enum dispatch에서 join point 대신 개별 분기 안에 위치한 validation. 이번 patch가 수정하는
Use와Download분기 사이의disallowDataRequest()비대칭이 정확히 이 패턴에 해당합니다. Narrow —Source/WebCore/loader에서PolicyAction을 기준으로 분기하거나 switch하는 모든 지점을 나열합니다(DocumentLoader::continueAfterContentPolicy,PolicyChecker,FrameLoader). 각 분기별로 적용되는 guard 집합을 diff로 비교해야 합니다. 동일하게 privileged한 결과에 도달하는데도 한쪽 분기에서만 호출되고 다른 분기에는 없는 predicate가 있다면, 그것이 단서입니다. Wider — 동일한 패턴은 CSP, mixed-content, content-blocker, local-scheme 검사가 load path의 일부에만 적용되는 곳이라면 WebKit loader 전반에서 반복됩니다. 호출자가 적은 validation helper를 찾아, 호출하지 않는 나머지 지점들이 왜 예외로 취급되는지 물어보면 이 패턴을 찾아낼 수 있습니다. Widest — security predicate는 결과마다 반복 배치할 것이 아니라, 모든 privileged 결과를 지배하는 가장 좁은 지점 하나에 위치해야 합니다. 이는 check가 dispatcher가 아니라 개별 handler 안에 존재하는 모든 policy engine, router middleware, authorization layer에 동일하게 적용됩니다. 분기별 check는 누군가 모든 분기를 나열하고 커버리지를 증명할 수 있을 때에만 올바르게 동작합니다. -
renderer가 작성한 URL scheme을 privileged 코드가 fetch된 resource처럼 취급하는 패턴. 이번 fix는
request.url().protocolIsData()만 확인합니다. Narrow —receivedNavigationResponsePolicyDecision과 그 주변 download 처리 로직에서, renderer가 생성하고 채워 넣는blob:URL, 그리고about:/webkit-fake-url:계열이 동일한 unshowable-MIME-type 메커니즘을 통해PolicyAction::Download에 도달할 수 있는지 확인해야 합니다. 서버나 사용자가 영향을 줄 수 있는 네트워크 fetch 없이 response byte를 그대로 소비하는 privileged path가 있다면, 그것이 단서입니다. Wider — 이 부류는 content의 출처가 URL scheme이 아니라 response로부터 추론되는 WebKit의 모든 지점에 적용됩니다. 커스텀WKURLSchemeHandlerresponse, service worker가 합성한 response, 캐시된 substitute-data load 등이 해당합니다. body를 누가 작성했는지 먼저 확인하지 않은 채 MIME type이나Content-Disposition을 기준으로 분기하는 privileged consumer를 찾아보아야 합니다. Widest — provenance는 response metadata로부터 재구성할 것이 아니라 byte의 출처에서부터 추적되어야 합니다. 동일한 실패 패턴은 첨부파일에 명시된 type을 그대로 신뢰하는 email client, embedded manifest를 신뢰하는 package manager, client가 선언한 MIME을 신뢰하는 upload pipeline에서도 똑같이 나타납니다. -
embedder delegate의 기본 응답에 의존하는 capability grant. "표시할 수 없는 것은 모두 download한다"는 기본 응답이, 이번 사례에서 renderer가 선택한 MIME type을 filesystem write로 바꿔놓은 원인입니다. Narrow — client policy callback에 따라 동작하는 다른
WebPageProxyentry point들을 검토하고, embedder가 기본 응답을 반환할 때 어떤 privileged operation으로 유도될 수 있는지 확인해야 합니다.Source/WebKit/UIProcess/WebPageProxy.cpp의PolicyDecisioncompletion handler와DownloadProxy생성 지점부터 시작해, delegate가 단지 이의를 제기하지 않았다는 이유만으로 engine이 privileged action을 수행하는 경로가 있는지 표시해야 합니다. Wider — 이 부류는 생략되거나 기본 응답이 permissive하게 동작하는 모든 WebKit delegate/client hook으로 확장됩니다. UI-client의 popup 처리, form-submission listener, authentication-challenge의 기본값 등이 해당합니다.API::*Clientbase class를 따라가며 override되지 않은 구현을 살펴보아야 합니다. Widest — extension point의 fail-open 기본값은 이를 별도로 customize하지 않는 모든 embedder에게 사실상의 security policy가 됩니다. 이는 browser extension API, servlet filter, policy-callback SDK 등에도 동일하게 적용됩니다. hook의 기본 구현은 placeholder가 아니라 하나의 security 결정입니다.