[6] LoadImageForDecoding MESSAGE_CHECK tripped on legitimate embedder calls
Low — 실패 양상은 policy bypass가 아니라 프로세스가 불필요하게 종료되는 것에 그칩니다. Cookie validator는 도달하는 모든 값에 대해 무조건적으로 동작하고 있었습니다. 다만 일반적인 embedder API 사용만으로도 이 문제가 유발되었다는 점, 그리고 이번 수정이 cookie 권한을 도출하는 위치 자체를 옮겼다는 점에서 "버그가 아니다"라고 보기는 어렵습니다.
WebKit의 MESSAGE_CHECK 계열 매크로는 특정한 전제를 담고 있습니다. 검사 대상 필드는 이를 보낸 프로세스가 책임지고 유지해야 하는 invariant이며, 그 값이 깨졌다면 sender가 compromise된 것으로 간주해 종료해야 한다는 전제입니다. 이 전제는 sender가 실제로 그 값을 직접 생성한 경우에만 성립합니다. _loadAndDecodeImage: SPI는 UI process에서 앱이 전달한 NSURLRequest를 WebContent process를 거쳐 Networking process까지 그대로 전달하는데, 이때 애플리케이션이 직접 설정한 mainDocumentURL도 함께 딸려 옵니다. 결국 그 필드는 누가 설정했는지 전혀 알 수 없는 프로세스에서 fail-stop validator를 그대로 통과하게 됩니다.
관전 포인트: 방어적 가치 측면에서 보면, navigation이 일어나기 전에 _loadAndDecodeImage:를 호출하거나 자체 mainDocumentURL을 설정해 호출한 앱은 WebContent process가 종료되는 결과를 겪었습니다. 이번 감사에서 얻을 교훈은 어떤 필드를 애초에 MESSAGE_CHECK 뒤에 둘 것인가 하는 문제입니다.
커밋 메시지는 이전의 불완전한 수정 이후에도 남아 있던 두 가지 실패 양상을 다음과 같이 정리합니다.
There are two cases where the
MESSAGE_CHECKinNetworkConnectionToWebProcess::loadImageForDecodingcan still fail after the fix in rdar://176310702: a client set an incorrect or emptymainDocumentURL; a client called_loadAndDecodeImage:on aWKWebViewthat has not loaded any content. SetfirstPartyForCookiesin the web process frompage->mainFrameURL(), which is the authoritative value, and drop theWKWebView-side fallback added in the previous fix. WhenmainFrameURLis empty (no navigation has happened yet, so there is no cookie context), disable cookies for the request instead of failing theMESSAGE_CHECK.
Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp
Source/WebKit/WebProcess/WebPage/WebPage.cpp
Source/WebKit/UIProcess/API/Cocoa/WKWebView.mm
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/LoadAndDecodeImage.mm
Patch Details
이번 변경은 세 개 계층에 걸쳐 있습니다. Cookie context를 도출하는 위치를 web process 쪽으로 옮기고, network process의 validator를 unset 케이스에 한해 완화했으며, 더 이상 필요 없어진 UI-process fallback을 제거했습니다.
WebPage.cpp에서는 WebPage::loadAndDecodeImage가 먼저 corePage()를 확인합니다. Page가 없으면 decodeError(url)을 통해 즉시 빠져나갑니다. 그리고 IPC를 전달하기 전에 request.setFirstPartyForCookies(page->mainFrameURL())을 호출해, 들어온 request의 cookie context를 무조건 덮어쓰도록 변경되었습니다. 그 결과 network process에 도달하는 값은 API client가 붙인 mainDocumentURL이 아니라, page 자신의 main-frame URL로부터 in-process에서 도출된 값이 됩니다.
NetworkConnectionToWebProcess.cpp에서는 기존에 무조건 실행되던 allowsFirstPartyForCookies MESSAGE_CHECK_COMPLETION이 이제 request.firstPartyForCookies().isValid() 조건 뒤로 옮겨졌습니다. First-party URL이 유효하지 않은 경우에는 validator를 건너뛰고, 대신 request.setAllowCookies(false)를 적용합니다. Sender를 종료시키는 대신 이렇게 처리하도록 바뀐 것입니다. URL의 유효성 및 HTTP 계열 여부를 확인하는 check는 그대로 유지됩니다.
WKWebView.mm에서는 -_loadAndDecodeImage:...에서 이전 fix가 추가했던 UI-process fallback이 제거되었습니다. mainDocumentURL을 self.URL로 설정해두던 NSMutableURLRequest 복사 로직이 그 대상입니다. 새로 추가된 두 테스트는 모두 이미지가 정상적으로 decode되는지 확인하며, 특히 EXPECT_FALSE(processTerminated)를 검증합니다.
검증된 신뢰 경계 바깥에서 유입되며 정당한 unset 상태를 가질 수 있는 필드에, fail-stop 방식의 compromise-detection validator를 적용하고 있던 패턴.
Background
Process split. WKWebKit은 embedding app과 UI를 하나의 process에서 실행하고, page content는 sandbox화된 WebContent process에서, 그리고 모든 네트워킹은 별도의 Networking process에서 처리합니다. Networking process는 WebContent를 신뢰하지 않는 대상으로 취급하며, 그로부터 오는 메시지를 검증합니다.
firstPartyForCookies.
WebCore::ResourceRequest의 필드로, subresource load가 속한 최상위 document의 URL을 담습니다. Network stack은 이 값을 request의 cookie/partitioning context로 사용합니다. Cocoa 환경에서는 NSURLRequest.mainDocumentURL에 대응됩니다.
MESSAGE_CHECK_COMPLETION.
WebKit의 IPC validation macro입니다. 검증 조건이 거짓이면 지정된 completion handler를 실행한 뒤 수신 메시지를 invalid로 처리하며, 그 결과 sender process와의 연결이 종료됩니다. NetworkConnectionToWebProcess.cpp에서는 this->connection()에 바인딩되어 있습니다. 이 macro의 설계 의도는 input sanitation이 아니라 compromise detection이며, 바로 이 점 때문에 무엇을 이 macro 뒤에 둘지가 단순한 형식이 아니라 하나의 설계 결정이 됩니다.
NetworkProcess::allowsFirstPartyForCookies(processIdentifier, url).
Network process가 유지하는 registry check로, "이 web process가 해당 first-party cookie context로 로드해도 좋다고 통보받았는가?"에 답합니다. NetworkProcess::AllowCookieAccess 값을 반환하며, Allow가 통과 조건입니다. UI process는 navigation이 일어날 때마다 web process별로 허용된 first party를 등록합니다.
Page::mainFrameURL()과 URL::isValid().
mainFrameURL()은 WebCore 쪽에서 본 page main frame의 URL로, 해당 WebPage에 현재 로드되어 있는 최상위 document를 가리킵니다. Navigation이 한 번도 일어나지 않은 상태라면 비어 있습니다. URL::isValid()는 문자열이 well-formed URL로 파싱된 경우 참이 되며, 빈 문자열이나 파싱 불가능한 문자열은 invalid URL을 만듭니다.
ResourceRequest::setAllowCookies(false).
플랫폼 network layer가 해당 request에 대해 cookie를 첨부하거나 저장하지 않도록 표시합니다. Cocoa에서는 HTTPShouldHandleCookies를 끄는 것에 해당합니다.
-[WKWebView _loadAndDecodeImage:...].
Embedding application을 위한 private WKWebView API로, web page의 network context를 통해 이미지 URL을 fetch하고 decode된 platform image를 반환합니다. 호출 흐름은 UI process → WebPage::loadAndDecodeImage (WebContent) → NetworkConnectionToWebProcess::loadImageForDecoding (Networking) 순으로 이어지며, 그 과정에서 request 객체는 app이 설정한 필드를 그대로 지닌 채 두 개의 process 경계를 넘습니다.
Analysis
이번 사안은 IPC validator를 잘못 분류한 데서 비롯된 문제입니다. Memory corruption이 아니라, spurious process termination을 초래하는 policy 오류에 해당합니다.
Pre-fix Post-fix
App: NSURLRequest.mainDocumentURL App: mainDocumentURL (ignored)
│ (may be nil / unrelated origin) │
▼ UI process ▼ UI process
WKWebView: backfill from self.URL WKWebView: pass through
│ (nil if never navigated) │
▼ WebContent ▼ WebContent
forward request verbatim setFirstPartyForCookies(mainFrameURL)
│ │
▼ Networking ▼ Networking
MESSAGE_CHECK(allowsFirstParty…) isValid() ? MESSAGE_CHECK(…)
└─► FAIL ► kill WebContent : setAllowCookies(false)
WebPage::loadAndDecodeImage는 ResourceRequest를 사실상 그대로 전달했습니다. 그 결과 firstPartyForCookies는 embedding application이 NSURLRequest.mainDocumentURL에 넣어둔 값 그대로였고(앞선 fix에서는 UI process 쪽에서 self.URL로 backfill하는 로직도 있었습니다), 수신 측에서는 이를 attested된 값으로 취급해 validator를 통과시켰습니다. 이 validator의 실패 semantics는 "sender가 악의적이므로 종료시킨다"는 것입니다.
여기서 깨진 invariant는 모든 MESSAGE_CHECK가 암묵적으로 전제하는 바로 그것입니다. 검증 대상 필드는 sending process가 책임지고 유지해야 하는 값이어야 하며, 그 값의 위반은 곧 compromise의 증거로 간주됩니다. 그런데 이 필드는 그런 의미에서 sender-controlled가 아니었습니다. WebContent의 신뢰 경계 바깥에서 유입되는 값이었고, "아직 값이 없다"는 정당한 상태도 가질 수 있었습니다. Commit message는 살아남은 두 가지 실패 모드를 정확히 짚고 있으며, 이 둘은 각각 테스트로 이어졌습니다. 첫 번째 케이스에서는 http://unrelated-domain.example/이 해당 web process identifier에 대해 한 번도 등록된 적이 없기 때문에 allowsFirstPartyForCookies가 Allow가 아닌 값을 반환합니다. 두 번째 케이스에서는 애초에 확인할 유효한 URL 자체가 없습니다. LoadAndDecodeImageBeforeAnyNavigation 테스트의 EXPECT_NULL([webView URL])이 바로 이 상태를 고정한 것으로, UI-process fallback인 self.URL이 nil이고 그 결과 firstPartyForCookies가 비게 되는 상황입니다. (Bug title에 등장하는 crash-report signature와 rdar://176310702 출처 표기는 commit message에서 가져온 것이며, 이전 commit이나 crash record 자체는 제공된 context에 포함되어 있지 않습니다.)
Fix는 두 관심사를 분리합니다. 권위 있는 cookie context는 이제 그 권위가 실제로 존재하는 위치, 즉 web process 내부의 page->mainFrameURL()에서 계산됩니다. 그 결과 network-process validator는 다시 한번 sender가 실제로 책임지는 값을 검증하게 됩니다. "아직 cookie context가 존재하지 않는" 케이스는 fail-stop validator가 아니라 fail-closed degradation으로 처리되는데, 이는 compromise signal이 아니라 정당한 상태에 맞는 올바른 처리 방식입니다.
이번 변경은 IPC validator를 완화하는 성격이므로, 표면적으로 제기될 수 있는 질문을 짚어볼 필요가 있습니다. NetworkConnectionToWebProcess::loadImageForDecoding은 이제 allowsFirstPartyForCookies를 전혀 거치지 않고도 networkSession()에 도달해 실제 network load를 수행하는 경로를 갖게 되었습니다. 이전에는 이 지점에 도달하는 모든 request가 해당 check를 통과한 상태였습니다. 손상된 WebContent process를 포함해 어떤 WebContent process든, URL::isValid()를 통과하지 못하는 firstPartyForCookies를 보내기만 하면 이 경로를 선택할 수 있습니다. 여기서 검증되지 않은 채 가정되고 있는 부분은 두 가지입니다. 하나는 이 load에 대해 setAllowCookies(false)가 end-to-end로 실제 준수되는지이고, 다른 하나는 firstPartyForCookies를 하위에서 소비하는 다른 모든 지점이 이 필드가 비어 있을 때도 안전하게 동작하는지입니다. 이 필드는 WebKit 안에서 cookie 첨부보다 훨씬 많은 역할을 담당합니다. Cache partitioning, same-site 판단, tracking-prevention 결정에도 관여하는데, 새로 추가된 branch는 그중 cookie 차원만 무력화할 뿐입니다. 만약 이 가정들이 깨진다면, 그 결과로 가능해지는 버그 유형은 memory corruption이 아니라 partition confusion에 가깝습니다. 즉 빈 first-party key로 귀속된 load가 무관한 first party와 cache 또는 storage partition을 공유하게 될 가능성이 있습니다. 다만 image-decode 경로에서 setAllowCookies(false)가 하위에서 어떻게 처리되는지는 commit에 드러나 있지 않으므로, 이는 확인된 결함이라기보다 점검이 필요한 지점으로 보아야 합니다.
Exploitability 측면에서는 attacker가 얻을 수 있는 이득이 없습니다. 우선 web content로부터의 reachability 자체가 없습니다. WebPage::loadAndDecodeImage는 _loadAndDecodeImage: SPI를 통해서만 호출되며, JavaScript나 HTML, CSS로 도달 가능한 어떤 binding도 거치지 않습니다. 따라서 page script는 지원되는 경로로는 LoadImageForDecoding 메시지를 발생시킬 수 없습니다. Layout test에서 이 경로에 도달하는 유일한 방법은 IPC_TESTING_API harness를 거치는 것이며, 이는 shipping build에는 포함되지 않습니다. Fix 이전 기준으로 현실적인 trigger 시나리오는, embedder가 navigation이 일어나기 전에 SPI를 호출하거나, UI process가 등록한 적 없는 host를 mainDocumentURL에 설정한 채 호출하는 경우입니다. 두 경우 모두 validator를 걸려 WebContent process가 종료됩니다. 이 취약한 branch를 의도적으로 재현할 수 있는 유일한 주체는 이미 손상된 WebContent process 자신이며, 그 경우에도 얻을 수 있는 결과는 자기 자신의 종료뿐입니다. 이는 attacker에게 유리한 전환이라 볼 수 없습니다.
여기서 관여하는 신뢰 경계는 WebContent → Networking IPC이며, security model의 전제는 MESSAGE_CHECK 실패가 sender가 책임져야 할 invariant를 위반했다는 뜻이라는 것입니다. Fix 이전에는 이 메시지의 firstPartyForCookies에 대해 그 전제가 성립하지 않았기 때문에, 정상적인 application 동작이 compromise로 오인되어 WebContent process가 종료되는 결과로 이어졌습니다. Fix 이전의 구체적인 결과는 정상적인 embedder API 호출로 인한 availability loss이지, cross-origin data access는 아닙니다. Cookie-isolation policy 자체가 우회된 적은 없는데, 이는 validator가 그 값에 도달하는 모든 경우에 대해 무조건 적용되고 있었기 때문입니다. 계속 지켜볼 필요가 있는 잔존 변화는, invalid first-party 값에 대한 cookie 안전성이 이제는 검증되는 방식이 아니라 flag를 통해 강제되는 방식으로 바뀌었다는 점입니다. 이는 이전 validator가 제공하던 것과는 다르고 더 약한 형태의 보장입니다.
Insight
여기서 얻을 수 있는 근본적인 교훈은 MESSAGE_CHECK 뒤에 무엇을 두어야 하는가에 관한 것입니다. 이 macro들은 "여기서 위반이 발생하면 peer가 compromise되었다는 뜻이다"라는 의미를 담고 있으므로, 검증 대상 값은 peer가 올바르게 생성할 책임을 지는 값이어야 합니다. 이번 메시지는 두 가지 지점에서 동시에 이 조건을 만족시키지 못했습니다. 값의 출처가 embedding application이었다는 점에서 애초에 WebContent 신뢰 경계 바깥에서 왔고, 정당한 unset 상태도 가질 수 있었습니다. 이번 fix는 교과서적인 수정 방식을 따릅니다. 권위를 실제로 존재하는 위치로 옮기고, 손상된 sender가 자신이 소유한 값에 대해 거짓말을 하는 경우를 위해 fail-stop validator는 그대로 유지하며, 정당하게 unset인 경우에는 fail-stop이 아니라 fail-closed 데이터 경로를 제공합니다. 한 가지 더 주목할 점은 이번이 두 번째 시도라는 것입니다. 앞선 fix는 UI-process 계층에서 mainDocumentURL을 self.URL로 backfill함으로써 증상만 처리했는데, 이는 한 번도 navigation한 적 없는 WKWebView의 경우를 커버할 수 없는 방식이었습니다. 잘못된 process 경계에 default 값을 끼워 넣는 방식은 validator의 false-positive 발생 범위를 좁힐 뿐, 완전히 없애지는 못합니다. 이번 commit은 그 fallback을 아예 제거하고 값의 도출 위치를 재배치했습니다.
Audit directions
- Sending process 바깥에서 유입된 필드에 fail-stop IPC validator를 적용하는 패턴. 여기서 성립해야 할 invariant는
MESSAGE_CHECK는 오직 sender 자신이 생성할 책임을 지는 속성만 검증해야 하며, embedder나 다른 process로부터 그대로 전달된 데이터는 compromise의 증거가 아니라 sanitize해야 할 입력이라는 것입니다. 좁게 보면:Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp와*MessageReceiver.cpp파일들에서, 검증 조건이ResourceRequest필드(firstPartyForCookies,httpReferrer,httpOrigin,isSameSite,siteForCookies)를 읽는MESSAGE_CHECK*를 검색해보아야 합니다. 판별 기준은, 해당 필드를 출처까지 역추적했을 때 WebCore 내부에서 계산된 값이 아니라 app SPI를 통해 전달된NSURLRequest/API::URLRequest에서 설정된 값이라면 후보로 볼 수 있다는 점입니다. 넓게 보면: 같은 유형의 문제는 validator가 중간 process를 거쳐 전달된 값에 놓여 있는 모든 곳에서 나타날 수 있습니다.WebPageProxy를 통해 도착해WebPage가 재전송하는 UI-process-supplied policy 필드, injected-bundle이 변형한 request, loader로 재진입하는WKURLSchemeHandlerresponse 등이 해당합니다. Code-search 결과에서의 판별 기준은, 검증 대상 struct 필드의 setter가 내부 navigation 코드에서만이 아니라 public/private API에서도 도달 가능한지 여부입니다. 가장 넓게 보면, 이 원리는 "값의 인증은 그 값이 실제로 작성되는 경계에서 이루어져야 하며, 단순히 값을 relay한 하위 경계에서 이루어져서는 안 된다"는 것입니다. 이는 proxy가 client header에서 복사한 JWT claim을 검증하는 상황이나, Chromium Mojo의ReportBadMessage가 renderer가 extension으로부터 전달받아 넘긴 데이터에 대해 호출되는 상황과 동일한 구조입니다. 어디에서든 적용할 수 있는 점검 질문은 "이 check가 실패했을 때, 내가 지금 종료시키려는 process가 실제로 그 값을 작성한 당사자인가?"입니다.
"아직 초기화되지 않음"을 정당하게 표현할 방법이 없는 validator. 정상적인 미초기화 상태와 공격 상황이 구분되지 않게 됩니다. 여기서 지켜야 할 invariant는 lifecycle을 가진 값에 대한 모든 check가 초기화 이전 구간에 대한 동작을 명시적으로 정의해야 하며, 그 동작은 fail-stop 방식의 종료가 아니라 fail-closed 방식의 데이터 처리여야 한다는 것입니다.
Narrow: 첫 navigation이 완료되기 전에도 도달 가능한 다른 NetworkConnectionToWebProcess, NetworkResourceLoader entry point들을 점검할 필요가 있습니다. 각 entry point가 빈 firstPartyForCookies/mainFrameURL을 허용하는지, 아니면 해당 구간에서 애초에 도달 불가능한지 확인해야 합니다. 이때 매칭 대상이 되는 패턴은, 갓 생성된 WebPage가 아직 가질 수 없는 URL, origin, identifier에 대해 걸려 있는 MESSAGE_CHECK입니다.
Wider: 같은 형태는 lifecycle event로 확립되는 모든 상태에서 반복됩니다. Provisional load가 커밋되었다고 가정하는 WebPageProxy handler, didCommitLoadForFrame 이전에 조회되는 WebProcessProxy registration table, page 생성과 경쟁하는 service-worker/shared-worker connection 설정 등이 해당됩니다. 매칭 대상은 load-commit callback에서 채워지는 per-process 또는 per-page registry에 대한 조회입니다.
Widest: 이는 "empty state와 invalid state를 혼동하는" 일반적인 class에 해당하며, 비동기적으로 채워지는 registry를 기준으로 검증하는 모든 시스템에 적용됩니다. Consent 완료 이전에 확인되는 OAuth scope table, 아직 동기화되지 않은 role cache를 대상으로 한 RBAC check, microkernel의 capability table 등이 예입니다. 여기서 유지해야 할 invariant는 "absent는 forbidden과 같지 않으며, 둘 중 하나만이 caller를 종료시킬 근거가 된다"는 것입니다.
Validator를 완화하고 단일 무효화 flag로 보완하는 패턴. 이 flag가 check가 보호하던 downstream consumer 전부를 커버하지 못할 가능성이 있습니다. 여기서 지켜야 할 invariant는 check를 완화하고 mitigation flag로 보완할 때는, 그 flag가 기존 check가 보호하던 모든 consumer를 빠짐없이 지배해야 한다는 것입니다.
Narrow: loadImageForDecoding의 새로운 else branch에서 시작되는 ResourceRequest::setAllowCookies(false)를 NetworkSessionCocoa::loadImageForDecoding까지 추적해서, 이 경로 상에서 firstPartyForCookies를 읽는 모든 downstream — cookie attachment, cache-partition key 도출, same-site 계산, tracking-prevention 분류 — 이 빈 first party를 만나도 올바르게 동작하는지 확인할 필요가 있습니다. 매칭 대상은 allowCookies()를 먼저 확인하지 않고 firstPartyForCookies를 읽는 downstream call site입니다.
Wider: WebKit의 loading 코드 전반에서 "check를 건너뛰고 대신 안전 flag를 세팅하는" 다른 완화 패턴들에도 같은 점검을 적용할 수 있습니다. setAllowCookies, credential-suppression flag, sandbox-flag 기반 early-out 등이 해당됩니다. 코드 검색 결과에서 매칭 대상은, mitigation과 check가 명목상 서로 다른 리소스를 보호하는 조건부 validator(if (x.isValid()) MESSAGE_CHECK(...) 또는 if (!foo) { /* mitigate */ })입니다.
Widest: hard reject를 degraded-mode allow로 완화하는 모든 코드베이스에 재사용 가능한 질문은 "reject가 원래 보호하던 모든 consumer를 나열한 뒤, degraded mode가 변경을 촉발한 그 하나뿐 아니라 전부를 무효화하는지 증명하라"는 것입니다. 이는 feature-flag 기반 auth bypass, HTTP downgrade-to-anonymous fallback, "credential을 보내지 않는다"는 flag와 짝지어진 TLS verification 완화에도 동일하게 적용됩니다.