← All reports

[cocoa] _setAllowOnlyPartitionedCookies may not be set on WebSocket requests

MediumWebKit NetworkProcess, Cocoa networking layerCrossOrigin

CVE: CVE-2026-43708 · Safari 26.5.2 · Released June 29, 2026 Impact: 악성 웹사이트가 cross-origin으로 데이터를 유출할 수 있습니다 Apple's description: 입력값 검증을 개선하여 문제를 해결했습니다. Credit: Behzad Najjarpour Jabbari (@G4ru)

971435f | Bugzilla 315306

Medium 등급입니다. 이 diff 어디에도 memory corruption은 없고, privacy control 하나가 열린 상태로 실패했을 뿐입니다. 다만 이 실패는 일반적인 웹 콘텐츠에서 별다른 전제조건 없이 "cross-site WebSocket을 여는" 동작만으로 조용히 발생했으며, 유출된 대상은 피해자의 unpartitioned cookie jar였습니다.

Cookie partitioning은 third-party tracking에 대한 브라우저 차원의 대응책입니다. 어떤 host가 third-party context에서 로드되면 해당 host의 cookie는 top-level site를 기준으로 구분되어 저장됩니다. 그래서 a.example에 삽입된 tracker.exampleb.example에 삽입되었을 때 기록한 cookie jar를 볼 수 없게 됩니다. Cocoa 플랫폼에서 WebKit은 이 기능의 opt-in 형태를, loader가 cookie를 붙이기 전에 outgoing request 객체에 CFNetwork SPI switch를 지정하는 방식으로 구현합니다. WebSocket handshake에서 이 지정이 이루어지는 지점이 바로 NetworkSessionCocoa::createWebSocketTask입니다. 이때 handshake request는 incoming request를 lazily materialize하는 mutable copy로 구성되기 때문에, 모든 설정 단계가 아직 생성되지 않은 local이 아니라 이미 materialize된 객체를 건드려야 한다는 invariant가 성립해야 합니다.

관전 포인트: 페이지가 cross-site WebSocket을 열면, 브라우저가 목적지 host의 unpartitioned first-party cookie를 handshake에 그대로 붙여서 전송할 수 있습니다. 결과적으로 endpoint는 cross-site로 연결 지을 수 있는 identity를 얻게 됩니다.

Source/WebKit/NetworkProcess/cocoa/NetworkSessionCocoa.mm

ensureMutableRequest().get()._privacyProxyFailClosedForUnreachableNonMainHosts = YES;
 
#if ENABLE(OPT_IN_PARTITIONED_COOKIES) && defined(CFN_COOKIE_ACCEPTS_POLICY_PARTITION) && CFN_COOKIE_ACCEPTS_POLICY_PARTITION
 
- if ([mutableRequest respondsToSelector:@selector(_setAllowOnlyPartitionedCookies:)]) {
+ if ([ensureMutableRequest() respondsToSelector:@selector(_setAllowOnlyPartitionedCookies:)]) {
if (CheckedPtr storageSession = networkStorageSession(); storageSession && storageSession->isOptInCookiePartitioningEnabled()) {
bool shouldAllowOnlyPartitioned = storageSession->thirdPartyCookieBlockingDecisionForRequest(request, frameID, pageID, networkProcess().shouldRelaxThirdPartyCookieBlockingForPage(webPageProxyID), isRequestToKnownCrossSiteTracker(request)) == WebCore::ThirdPartyCookieBlockingDecision::AllExceptPartitioned;
[mutableRequest _setAllowOnlyPartitionedCookies:shouldAllowOnlyPartitioned];

Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm

+TEST(WKHTTPCookieStore, WebSocketCookies2)
+{
+ if (path == "http://sitea.example/com"_s) {
+ co_await connection.awaitableSend(
+ "HTTP/1.1 200 OK\r\n"
+ "Set-Cookie: Default=1\r\n"
+ "Set-Cookie: SameSite_None=1; SameSite=None\r\n"
+ "Set-Cookie: SameSite_None_Secure=1; secure; SameSite=None\r\n"
+ "Set-Cookie: SameSite_Lax=1; SameSite=Lax\r\n"
+ "Set-Cookie: SameSite_Strict=1; SameSite=Strict\r\n"
+ ...
+ } else if (path == "ws://sitea.example/websocket"_s) {
+ EXPECT_TRUE(contains(request.span(), "Host: sitea.example"_span));
+ EXPECT_FALSE(contains(request.span(), "Cookie:"_span));
+ receivedThirdRequest = true;
+ } else if (path == "http://siteb.example/ninja"_s) {
+ auto html = @"<script>new WebSocket('ws://siteA.example/websocket')</script>";
+ co_await connection.awaitableSend(HTTPResponse(html).serialize());
+ }
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://siteA.example/com"]]];
+ [webView _test_waitForDidFinishNavigation];
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://siteB.example/ninja"]]];
+ Util::run(&receivedThirdRequest);

실제 production 변경은 한 줄, 토큰 하나 수준입니다. respondsToSelector: probe의 receiver가 맨몸 local인 mutableRequest에서 accessor 호출인 ensureMutableRequest()로 바뀌었을 뿐입니다. 가드로 감싸인 블록 내부는 변경 전후로 byte 단위까지 동일합니다. networkStorageSession() 조회도, isOptInCookiePartitioningEnabled() 체크도, thirdPartyCookieBlockingDecisionForRequest(...) == ThirdPartyCookieBlockingDecision::AllExceptPartitioned 계산도, [mutableRequest _setAllowOnlyPartitionedCookies:shouldAllowOnlyPartitioned] 호출도 그대로입니다. 문 앞의 gate만 교체되었을 뿐, 그 뒤의 방 자체는 손대지 않았습니다.

  Before:                              After:
  ensureMutableRequest()               ensureMutableRequest()
    └─► request materialized             └─► request materialized
  [mutableRequest                      [ensureMutableRequest()
     respondsToSelector:]                  respondsToSelector:]
    └─► receiver may be nil ──► NO       └─► receiver always non-nil
          └─► block SKIPPED                    └─► real SPI answer
                └─► full cookie jar                  └─► partitioning applied

다이어그램에서 볼 수 있듯이, probe 바로 앞 줄은 이미 ensureMutableRequest()를 거치고 있습니다. 바로 이 점 때문에 이번 수정이 구조 변경이 아니라 토큰 하나짜리 fix로 끝날 수 있었습니다. accessor는 이미 scope 안에 있었고 바로 옆 statement에서도 사용되고 있었는데, 유독 probe에서만 쓰이지 않았던 것입니다.

commit의 나머지 부분은 test 관련 변경입니다. WKHTTPCookieStore.mmTEST(WKHTTPCookieStore, WebSocketCookies2)는 coroutine 기반 HTTPServer를 띄워 세 개의 path를 처리합니다. 먼저 web view를 http://siteA.example/com으로 navigate시키면, 서버는 Default, SameSite_None, SameSite_None_Secure, SameSite_Lax, SameSite_Strict까지 모든 SameSite 형태를 아우르는 cookie들을 응답으로 내려보내 sitea.example에 대해 first-party로 설정합니다. 이어서 http://siteB.example/ninja로 navigate하는데, 이 페이지의 body에는 new WebSocket('ws://siteA.example/websocket')라는 script 태그 하나만 들어 있습니다. handshake가 서버에 도착하면, 테스트는 Host: sitea.example 헤더가 포함되어 있는지 확인해서 request가 의도한 cross-site 목적지에 도달했음을 검증합니다. 그리고 결정적으로 EXPECT_FALSE(contains(request.span(), "Cookie:"_span))를 통해 cookie 헤더가 아예 없는지도 확인합니다. data store에 대한 _setResourceLoadStatisticsEnabled:YES 호출은 세션을 classification regime에 진입시켜, partitioning 판단이 실제로 의미를 갖도록 만드는 역할을 합니다.

Cookie는 NetworkProcess가 관리합니다. new WebSocket(url)은 WebContent process에서 실행되지만, WebContent가 직접 cookie jar를 건드리는 일은 없습니다. 이 호출은 NetworkProcess로 전달되며, NetworkProcess가 실제 request 객체를 구성하고 request별 policy 속성을 적용한 뒤 CFNetwork에 넘겨 발행합니다. 아래에서 설명하는 모든 판단은 이 경계의 NetworkProcess 쪽에서 일어납니다.

WebSocket handshake도 결국 HTTP request입니다. 최초 handshake는 Upgrade: websocket 헤더를 포함한 평범한 HTTP GET입니다. 동일한 loader를 거치고 동일한 cookie 부착 로직의 적용을 받으며, 다른 subresource load와 마찬가지로 이를 시작한 페이지의 top-level site를 기준으로 first-party인지 third-party인지가 결정됩니다. siteb.example에서 sitea.example로 여는 WebSocket은 sitea.example에 대한 third-party load에 해당합니다.

Opt-in cookie partitioning. partitioning이 적용되면 host의 cookie는 그것이 설정된 시점의 top-level site를 기준으로 구분되어 저장됩니다. 그래서 같은 host라도 서로 다른 두 top-level site 아래에서 third-party로 로드되면 서로 겹치지 않는 두 개의 jar를 보게 됩니다. WebCore는 request별 판단을 NetworkStorageSession::thirdPartyCookieBlockingDecisionForRequest로 표현하는데, 이 함수는 ThirdPartyCookieBlockingDecision 값을 반환합니다. 이 중 AllExceptPartitioned는 "이 load에 대해서는 partitioned cookie를 제외한 third-party cookie를 모두 차단한다"는 의미입니다. _setAllowOnlyPartitionedCookies:는 이 판단을 loader에 전달하는 CFNetwork request-level switch입니다.

Lazily-materialized mutable request. createWebSocketTask는 들어오는 immutable NSURLRequest를 무조건 NSMutableURLRequest로 복사하는 대신, 실제로 어떤 설정 단계가 mutate를 필요로 할 때까지 복사를 미룹니다. 이 함수 안에는 두 가지 표기가 함께 존재합니다. 하나는 없으면 생성하는 accessor인 ensureMutableRequest()이고, 다른 하나는 그때까지 생성된 것을 그대로 들고 있는 맨몸 local mutableRequest입니다. 이 local은 아무것도 만들어지지 않은 상태일 수도 있습니다.

respondsToSelector: feature detection. WebKit이 호출하는 CFNetwork SPI 중에는 build 대상 OS나 SDK에 따라 존재하지 않을 수 있는 것들이 있어서, 먼저 if ([obj respondsToSelector:@selector(_someSPI:)]) [obj _someSPI:value]; 형태로 probe를 수행합니다. probe가 NO를 반환하면, 아무 것도 하지 않는 것이 원래 의도된 정상적인 fallback입니다.

Objective-C의 nil messaging. Objective-C에서는 nil에게 메시지를 보내는 것이 허용됩니다. 이 경우 method body는 전혀 실행되지 않고 0에 해당하는 값이 반환됩니다. BOOL을 반환하는 method라면 이 0은 NO가 됩니다. 이 과정에서 exception도, warning도 발생하지 않으며, call site에서는 이 결과와 진짜 부정 응답을 구분할 방법이 전혀 없습니다.

이 사례는 enable-gate와 disable-gate가 동일한 값을 만들어내기 때문에 열린 상태로 실패하는 security control입니다.

Background에서 언급한 두 전제조건을 겹쳐보면 버그의 구조가 그대로 드러납니다. respondsToSelector:NO를 반환한다는 것은 "이 OS에는 해당 SPI가 없다"는 뜻이고, 이는 블록을 건너뛰는 것이 올바른 처리인 정상적인 상황입니다. 문제는 nil receiver 역시 NO를 반환한다는 점입니다. 그래서 probe 시점에 mutableRequest가 여전히 nil이면, 가드는 "SPI를 사용할 수 없다"고 판단하고 partitioned-cookie 블록 전체를 건너뛰게 되고, request는 partitioning 지정이 전혀 없는 상태로 CFNetwork에 전달됩니다. thirdPartyCookieBlockingDecisionForRequest 조회도 실행되지 않고, _setAllowOnlyPartitionedCookies: 호출도 일어나지 않습니다. CFNetwork는 이를 막을 별다른 지시를 받지 못한 채, siteb.example이 시작한 handshake에 sitea.example의 unpartitioned cookie jar 전체를 그대로 붙이게 됩니다.

전체 인과관계를 정리하면 다음과 같습니다.

  1. siteb.example의 script가 new WebSocket('ws://siteA.example/websocket')를 호출합니다.
  2. NetworkProcess가 createWebSocketTask에 진입합니다. 이 configuration 경로에서는 아직 ensureMutableRequest()가 한 번도 호출되지 않아 local이 nil인 상태입니다.
  3. [nil respondsToSelector:@selector(_setAllowOnlyPartitionedCookies:)]NO를 반환합니다.
  4. 블록이 건너뛰어지면서 partitioning 판단도, switch 설정도 이루어지지 않습니다.
  5. CFNetwork가 sitea.example의 first-party cookie를 outbound handshake에 붙입니다.
  6. WebSocket endpoint가 Cookie: 헤더를 통해 이를 읽어냅니다.

2번 단계가 가장 미묘한 지점이며, 이 버그의 구조를 눈여겨봐야 하는 이유이기도 합니다. fix 이전 코드가 항상 깨져 있었던 것은 아닙니다. probe 시점에 local이 nil인지 여부는 함수 안의 더 앞선 configuration 단계가 이미 materialization을 강제했는지에 전적으로 달려 있었습니다. _privacyProxyFailClosedForUnreachableNonMainHosts 할당문이 probe 바로 위에 있지만, 이 역시 자체 조건 아래에서만 실행됩니다. 그 branch가 실행되는 configuration에서는 객체가 이미 존재해서 모든 것이 정상 동작했고, 그렇지 않은 configuration에서는 control이 조용히 사라졌습니다. 기존의 WebSocketCookies 테스트가 계속 통과해온 이유도 바로 여기에 있습니다. 이 테스트는 우연히 non-nil local을 만들어내는 materialization 순서를 거쳤을 뿐입니다. 이 버그를 잡아내려면 다른 configuration 형태를 거치는 두 번째 테스트가 필요했고, 그것이 바로 WebSocketCookies2입니다.

_setResourceLoadStatisticsEnabled:YESAllExceptPartitioned 비교에서 눈여겨봐야 할 점은, 이 버그가 폐기해버린 security 판단 자체는 이미 정확하게 계산되어 있었다는 사실입니다. WebKit은 이 load가 third-party이며 partitioned cookie만 실려야 한다는 것을 이미 알고 있었습니다. 다만 그 판단이 CFNetwork까지 전달되지 못했을 뿐인데, 이는 판단을 요청했어야 할 code path가 SPI 가용성과는 무관한 이유로 NO를 반환하는 probe 뒤에 gate되어 있었기 때문입니다. 이번 fix는 probe가 ensureMutableRequest()를 거치도록 바꿔서 receiver가 항상 non-nil임을 보장하고, respondsToSelector:가 원래 측정해야 했던 단 하나의 사실 — CFNetwork가 해당 selector를 구현하는지 여부 — 만을 측정하도록 만듭니다. 이어지는 [mutableRequest _setAllowOnlyPartitionedCookies:] 호출은 이제 실제로 materialize된 동일한 객체를 대상으로 동작합니다.

이 버그가 attacker에게 주는 능력은 제한적이지만 실질적이며, Apple의 advisory가 설명하는 cross-origin exfiltration 그 자체에 해당합니다. 어떤 사이트든 target host로 WebSocket을 여는 script를 삽입할 수 있고, 이때 handshake는 attacker가 제어하거나 관찰하는 endpoint로 해당 host의 unpartitioned first-party cookie를 그대로 실어 나릅니다. 이는 partitioning이 애초에 분리하도록 설계한 사이트 간 경계를 가로질러 사용자 identity를 다시 연결시키고, target host가 그 jar에 담아둔 cookie 기반 상태를 그대로 노출시킵니다. 여기에는 memory-corruption primitive가 없습니다. diff가 바꾼 것은 lifetime도, bound도, type도 아닌 message receiver일 뿐입니다. sandbox escape 역시 없는데, 데이터가 renderer의 address space로 되돌아오는 것이 아니라 network를 통해 바깥으로 나가기 때문입니다. 이번 사례에서 얻어지는 것은 전적으로 privacy와 origin-isolation 영역에 국한됩니다.

nil receiver로 인해 respondsToSelector:가 "object not yet created" 대신 "SPI unavailable"이라고 답하게 되었고, 결과적으로 unmaterialized 경로를 탄 모든 cross-site WebSocket handshake에서 cookie partitioning이 조용히 비활성화되었습니다.

단서는 인접한 두 줄 사이의 비대칭이었습니다. probe 바로 위 statement는 이미 receiver를 ensureMutableRequest()로 적어두었는데, probe 자신은 mutableRequest로 적혀 있었습니다. accessor는 이미 존재했고 scope 안에 있었으며 바로 옆 두 줄 중 한 줄에서 실제로 사용되고 있었습니다. 바로 이런 차이가 code review를 통과해버리는 전형적인 패턴입니다. 두 표기 모두 개별적으로는 문제없어 보이기 때문에, reviewer의 눈은 둘을 같은 것으로 읽어버립니다. 여기서 새겨둘 점은, probe의 부정 응답이 enforcement를 비활성화시키는 구조라면 그 probe는 진짜 부재 이외의 어떤 이유로도 부정 응답을 낼 수 없도록 구조적으로 설계되어야 한다는 것입니다.