[4] Cookie access validation for startDownload() and convertMainResourceLoadToDownload()
The download path took the renderer's word for which site it was on.
Medium — 두 다운로드 진입점이 renderer가 임의로 지정한 first-party 값을 그대로 받아들였는데, 다른 모든 cookie-sensitive load 경로에서는 이 값을 검증하고 있었습니다. 이 gap에 도달하려면 사전에 renderer가 compromise된 상태여야 하고 그 자체로는 memory-corruption primitive를 제공하지 않지만, 그 compromise를 활성 cookie를 가진 모든 사이트에 대한 인증된 state-changing 요청으로 전환시킬 수 있습니다.
WebKit은 별도의 NetworkProcess에서 network load를 수행하며, cookie jar도 이 프로세스가 소유합니다. Sandbox된 renderer가 요청에 어떤 cookie를 붙일지 직접 지정하지 못하도록 하기 위한 구조입니다. 모든 ResourceRequest는 요청이 속한 최상위 사이트를 나타내는 firstPartyForCookies URL을 담고 있고, cookie 계층은 이 값을 기준으로 SameSite 첨부 여부와 third-party 차단 여부를 결정합니다. 이때 network process는 이 값을 그대로 신뢰하는 대신, 실제로 요청을 보낸 renderer가 해당 도메인의 콘텐츠를 호스팅하고 있는지를 독립적으로 확인하도록 설계되어 있습니다.
관전 포인트: compromise된 renderer는 요청의 first-party를 임의의 victim 사이트로 지정한 채 다운로드를 시작할 수 있고, network process는 그 사이트의 SameSite-restricted cookie를 attacker가 지정한 endpoint에 그대로 붙여 전송합니다. 서버 입장에서는 이것이 사용자가 직접 시작한 first-party 트래픽처럼 보이는 CSRF에 해당합니다.
Commit message는 문제를 명확히 밝히고 있습니다. NetworkConnectionToWebProcess::startDownload()와 convertMainResourceLoadToDownload()는 web process가 보내온 요청의 firstPartyForCookies를 검증하지 않았습니다. 그 결과 web process가 cross-site origin 값을 그대로 넣어 보낼 수 있었고, 이 값에 대응하는 cookie가 포함된 채로 해당 cross-site origin에 요청이 전송될 수 있었습니다. 사용자로부터 시작된 요청이 아님에도 정상적인 요청처럼 취급되는 상황이었습니다. Fix는 web process가 자신이 지정한 origin에 대해 실제로 cookie access 권한을 가지고 있는지 확인하는 메시지 검사를 추가합니다. 이 check는 firstPartyForCookies가 비어있지 않은 경우로 조건부 처리해야 했는데, 무조건 검사할 경우 TEST(_WKDownload, DownloadRequestOriginalURLDirectDownload) 같은 테스트에서 NetworkProcess::allowsFirstPartyForCookies의 ASSERT_NOT_REACHED()에 걸려 crash가 발생했기 때문입니다. PolicyAction::Download 경로는 애초에 web process를 해당 map에 등록하지 않기 때문입니다.
Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp
Patch Details
두 핸들러 모두 WebProcess가 넘긴 ResourceRequest를 DownloadManager로 전달하기 전에 input-validation guard를 갖게 되었습니다. MESSAGE_CHECK(m_networkProcess->allowsFirstPartyForCookies(m_webProcessIdentifier, request.firstPartyForCookies()) == NetworkProcess::AllowCookieAccess::Allow)가 그것이며, 이 매크로는 MESSAGE_CHECK_BASE(assertion, this->connection())를 통해 assertion이 실패하면 해당 IPC connection을 종료시킵니다. 이 guard는 의도적으로 조건부이며, if (!request.firstPartyForCookies().isEmpty())인 경우에만 실행됩니다. convertMainResourceLoadToDownload()에서는 이 check가 기존의 RELEASE_ASSERT(RunLoop::isMain()) 이후, 그리고 DownloadManager::startDownload를 직접 호출하는 !mainResourceLoadIdentifier fallback 이전에 위치합니다. 다른 파일, 테스트, 호출 지점은 수정되지 않았습니다.
자신이 중재해야 할 신뢰되지 않은 컴포넌트로부터 넘겨받은 security-policy 필드를, 다른 모든 진입점에서는 검증하면서 한 진입점에서만 그대로 신뢰하는 privileged component의 패턴.
Background
Process model. WebContent process는 sandbox되어 있고 완전히 신뢰되지 않는 것으로 취급됩니다. renderer에서 발생하는 어떤 버그든 그 프로세스 안에서의 arbitrary code execution으로 이어질 수 있다고 가정합니다. NetworkProcess는 별도의, 더 높은 권한을 가진 프로세스로 cookie jar를 소유하고 실제 network I/O를 수행합니다. 이 경계를 넘는 모든 메시지는 attacker-controlled라는 전제하에 다뤄집니다.
firstPartyForCookies. 모든 WebCore::ResourceRequest에 담기는 URL로, 요청이 속한 최상위 사이트를 나타냅니다. Cookie 계층은 이 값을 기준으로 요청이 first-party인지 third-party인지 판단하며, 이 판단이 SameSite=Lax/Strict cookie 첨부 여부와 third-party-cookie 차단 여부를 결정합니다.
NetworkProcess::allowsFirstPartyForCookies(ProcessIdentifier, URL). 특정 web process가 실제로 특정 first-party 도메인의 콘텐츠를 호스팅하고 있는지 확인하는, network process 측의 authoritative check입니다. NetworkProcess::AllowCookieAccess tri-state를 반환하며 (이 비교에서 수락되는 값은 Allow뿐입니다), 이 판단은 해당 프로세스에서 페이지가 로드될 때마다 채워지는 process → permitted first-party domain map을 기반으로 이루어집니다.
MESSAGE_CHECK. WebKit의 IPC-validation 매크로입니다. 여기서는 MESSAGE_CHECK_BASE(assertion, this->connection())로 확장되며, 실패 시 해당 메시지를 malformed로 간주하고 보낸 프로세스와의 connection을 종료합니다. Renderer가 원래 보낼 수 없는 것을 보냈을 때의 표준적인 대응 방식입니다.
Download entry points. startDownload()는 새로운 요청으로 다운로드를 시작합니다. convertMainResourceLoadToDownload()는 진행 중인 main-resource load를 다운로드로 전환하며, mainResourceLoadIdentifier가 주어지지 않은 경우 마찬가지로 DownloadManager::startDownload()로 넘어갑니다. 두 경로 모두 최종적으로 DownloadManager::startDownload()에 도달하는데, 이 함수는 요청으로부터 NetworkLoadParameters를 구성하고, ephemeral하지 않은 세션에 대해서는 StoredCredentialsPolicy::Use를 설정한 뒤 실제 load를 수행하는 PendingDownload를 생성합니다.
CSRF. Victim의 ambient credential을 실은 요청을 target 사이트로 전송해, 서버가 마치 사용자가 직접 시작한 요청인 것처럼 처리하게 만드는 공격입니다.
Analysis
두 핸들러는 IPC wire에서 받은 ResourceRequest를 cookie-policy 관련 필드에 대한 검증 없이 그대로 DownloadManager::startDownload()에 넘기고 있었습니다.
Before: After:
WebContent WebContent
└─► StartDownload(req) └─► StartDownload(req)
│ firstPartyForCookies = victim │
▼ ▼
NetworkProcess NetworkProcess
└─► DownloadManager::startDownload ├─ firstParty non-empty?
└─► NetworkLoadParameters │ └─► allowsFirstPartyForCookies(pid, fp)
StoredCredentialsPolicy::Use │ └─ !Allow ──► MESSAGE_CHECK: kill connection
└─► victim's cookies sent └─► DownloadManager::startDownload
제공된 DownloadManager::startDownload() 소스를 보면, 요청은 NetworkLoadParameters parameters; parameters.request = request;를 통해 그대로 전달되며, ephemeral하지 않은 세션에서는 credential이 활성화됩니다(parameters.storedCredentialsPolicy = sessionID.isEphemeral() ? StoredCredentialsPolicy::DoNotUse : StoredCredentialsPolicy::Use;). 그 뒤 PendingDownload::create(...)가 network load를 수행합니다. 즉 위조된 first-party 값이 실제 credential이 실린 outbound 요청까지 도달합니다. 이것은 memory-safety 버그가 아니라 로직 오류에 해당합니다. 메모리가 손상되는 것이 아니라, 잘못된 정책이 적용되는 문제입니다.
이 취약점의 전제조건은 WebContent process의 compromise입니다. 정상적인 renderer는 firstPartyForCookies를 자신이 실제로 호스팅 중인 document로부터 채우기 때문에, script만으로는 이 경로에 도달할 수 없습니다. Renderer code execution을 확보한 상태를 가정하면, attacker는 StartDownload 또는 ConvertMainResourceLoadToDownload IPC 메시지를 위조할 수 있습니다. 이 메시지의 ResourceRequest는 victim 사이트의 state-changing endpoint를 URL로 지정하고, firstPartyForCookies는 해당 victim 사이트의 URL로 설정됩니다. Fix 이전에는 이 값을 m_webProcessIdentifier와 대조하는 절차가 전혀 없었기 때문에, 결과로 만들어지는 PendingDownload load는 victim의 SameSite-restricted cookie를 attacker가 지정한 endpoint로 실어 보내게 됩니다. 이 과정을 대상 사이트별로 반복하면, 프로필 내 활성 cookie를 가진 모든 사이트에 대해 광범위한 authenticated-request primitive를 얻을 수 있습니다.
이번 변경에는 의도적으로 남겨둔 gap도 하나 있습니다. Guard는 !request.firstPartyForCookies().isEmpty() 조건에서만 동작하므로, first-party 값이 비어있는 경우에는 여전히 process-to-origin check 없이 DownloadManager::startDownload()에 도달합니다. 즉 "특정 first-party 관계가 전혀 주장되지 않은 상태에서 web process가 다운로드를 시작한 경우"가 여전히 도달 가능한 상태로 남습니다. 여기서 검증 없이 그대로 유지된 전제는, 빈 firstPartyForCookies 값으로는 특정 victim 사이트를 사칭할 수 없고 그 결과 cookie 분류도 최소한 third-party 수준만큼은 제한적일 것이라는 가정입니다. 만약 하위 코드 경로 중 어딘가가 빈 first-party 값을 기본적으로 same-site로 취급하거나, 나중에 request.url()로부터 이 값을 다시 도출하는 방식을 쓴다면, 이 empty-value branch를 통해 CSRF 조건이 다시 재현될 가능성이 있습니다. 이 예외 처리가 존재하는 이유는 PolicyAction::Download 경로가 애초에 allowsFirstPartyForCookies가 참조하는 map에 프로세스를 등록하지 않기 때문이며, 이는 그 자체로 check 지점이 아니라 근본 원인 쪽에서 닫아야 할 별도의 enforcement gap이라 할 수 있습니다.
이 취약점은 WebContent-to-NetworkProcess 신뢰 경계를 약화시켰습니다. WebKit의 security model은 network process가 renderer의 origin 주장을 독립적으로 검증한다는 전제 위에서, cookie 첨부와 SameSite 분류가 sandbox된 쪽에 의해 좌우되지 않도록 설계되어 있습니다. Fix 이전에는 이 두 다운로드 진입점이 그 전제를 위반하고 있었습니다. Renderer code execution을 확보한 attacker는 network process로 하여금 victim 사이트의 cookie가 실린 cross-site credentialed 요청을 발생시키게 만들 수 있었고, victim의 서버는 이를 정상적인 first-party, 사용자 시작 트래픽으로 인식하게 됩니다. 추가적인 sandbox 계층을 우회하지 않고도, renderer compromise를 무관한 사이트에 대한 인증된 state-changing 동작으로 전환할 수 있는 셈입니다.
이 commit에서 흥미로운 지점은 누락된 check 자체가 아니라, 왜 이 check를 단순히 무조건적으로 추가할 수 없었는가에 있습니다. 정공법인 "항상 검증"은 정상적인 다운로드에서 ASSERT_NOT_REACHED()를 유발했고, 저자는 빈 first-party 값을 예외로 두는 방식으로 이를 우회했습니다. 이는 IPC hardening에서 반복적으로 나타나는 패턴입니다. Validator가 참조하는 상태는 한 subsystem(페이지 로드)이 채우는데, 정작 hardening 대상이 되는 진입점은 다른 subsystem(다운로드)에 속해 있고, 둘의 등록 lifecycle이 서로 어긋나 있는 경우입니다. Validator의 ground-truth map이 검증 대상 동작과는 다른 동작의 side effect로 채워지는 곳이라면, false rejection이 발생하거나, 이번 경우처럼 원래 gap의 더 좁은 버전이 예외 처리를 통해 다시 등장할 가능성을 염두에 두어야 합니다. Download/navigation-policy 경로는 좋은 사냥터에 해당합니다. Load가 더 이상 load가 아닌 것으로 취급되기 시작하는 분기점이기 때문에, load 단위 bookkeeping이 누락되기 쉬운 지점입니다.
Audit directions
-
정책 필드가 대부분의 진입점에서는 검증되지만 전부는 아닌 경우. 이 invariant는 attacker-controllable한 정책 필드를 소비하는 모든 IPC 핸들러가 예외 없이 동일한 validator를 적용해야 한다는 것입니다. Narrow:
Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp와NetworkResourceLoader.cpp에서ResourceRequest,NetworkResourceLoadParameters,ClientOrigin,SecurityOriginData파라미터를 받는 핸들러를 검색하고, 이를allowsFirstPartyForCookies호출 지점 목록과 대조합니다. Code-review tell:request.firstPartyForCookies(),topOrigin,clientOrigin을 읽어 그대로 전달하면서 중간에MESSAGE_CHECK가 없는 핸들러입니다. Wider: 같은 클래스의 문제는 privileged process가 sandbox된 process가 선택한 필드로부터 정책을 다시 도출하는 모든 곳에서 나타날 수 있습니다.WebSWServerConnection,WebSharedWorkerServerConnection,NetworkStorageManager,NetworkSocketChannel핸들러의 origin/first-party 파라미터를 점검해야 합니다. 이들은 각기 별도의 message receiver이고 자체적인 validation discipline을 갖기 때문입니다. 검색 결과에서 눈여겨봐야 할 것은 첫 statement가 check가 아닌 message handler입니다. Widest: 신뢰된 쪽은 신뢰되지 않은 쪽이 자신의 identity나 entitlement에 대해 주장하는 내용을 그대로 받아들여서는 안 된다는 원칙이 적용됩니다.RenderFrameHost에 바인딩된 origin 인자를 받는 Chromium의 Mojo receiver, caller가 지정한 tenant/account ID를 받는 server API, 주장된sub를 그대로 받아들이는 token-issuing 서비스 등이 해당합니다. 이 메시지에서 어떤 필드가 principal을 나타내는지, 그리고 receiver가 이를 자신이 통제하는 channel로부터 다시 도출하는지 아니면 메시지로부터 그대로 읽어들이는지를 계속 물어야 합니다. -
검증 대상과는 다른 subsystem이 채우는 ground-truth를 가진 validator. 이 invariant는 validator의 ground-truth registry가 검증 대상 동작에 도달 가능한 모든 경로에서 채워져야 한다는 것입니다. Narrow:
NetworkProcess::allowsFirstPartyForCookies가 참조하는 map을 채우는 등록 쪽 호출자를 추적하고,PolicyAction::Downloaddecision 경로가 유일한 누락 지점인지 확인합니다. 일치하는 tell: 다른 action들이 수행하는 per-load bookkeeping을 수행하지 않고 조기에 반환하는 policy-decision 분기입니다. Wider: 같은 형태는if (fieldIsPresent) validate(field)형태로 작성된 모든 check에서 반복됩니다. WebKit의 NetworkProcess, GPUProcess IPC 핸들러에서 emptiness,std::optionalengagement,isNull()테스트로 guard된MESSAGE_CHECK를 검색하고, 각각에 대해 skip되는 branch가 정말로 무해한지 아니면 단순히 테스트되지 않았을 뿐인지 물어봅니다. Code-review tell: 핸들러의 첫 statement가 아닌MESSAGE_CHECK입니다. Widest: 예외로 처리된 input class가 명백히 무해하다는 것이 증명되지 않는 한, 조건부 validation은 사실상 bypass에 해당한다는 원칙이 적용됩니다. 헤더가 없을 때 check를 건너뛰는 allowlist middleware, 필드가 없으면 optional로 취급하는 schema validator, subject가 null이면 아무 것도 하지 않는 authorization layer 등이 이에 해당합니다.
Operations that change a request's kind mid-flight and escape the checks attached to its original kind. 여기서의 invariant는 state transition이 source state나 destination state 어느 쪽에 적용되던 validation이든 이를 누락시켜서는 안 된다는 것입니다. 좁게 보면, DownloadManager 주변의 다른 변환 형태의 핸들러들, 즉 convertNetworkLoadToDownload, dataTaskBecameDownloadTask, resumeDownload, publishDownloadProgress를 점검해서, web process에서 발생한 파라미터 중 변환 이전 경로에서만 validation을 거친 것이 있는지 살펴볼 필요가 있습니다. 코드 리뷰에서 눈여겨봐야 할 신호는, 이미 validation을 마친 객체를 그대로 이어받는 대신 IPC 입력으로부터 ResourceRequest나 destination path를 새로 재구성하는 핸들러입니다. 넓게 보면, 같은 부류의 문제가 redirect 처리, service-worker fetch interception, blob-URL resolution에도 걸쳐 있습니다. 이들 모두 중간 시점에 request의 실질적인 identity를 바꾸기 때문입니다. 여기서 주목할 형태는 함수 이름에 convert, becameX, resume, takeOver가 포함되어 있고, 인자로 origin, URL, credential policy를 받는 경우입니다. 가장 넓게 보면, 객체의 최초 분류에 결부된 validation은 분류가 바뀔 때마다 다시 적용되어야 한다는 원칙 자체가 핵심입니다. HTTP request가 WebSocket으로 upgrade되는 경우, file handle이 새 mode로 다시 열리는 경우, OAuth token exchange 등이 모두 같은 패턴에 해당합니다.