[cocoa] _setAllowOnlyPartitionedCookies may not be set on WebSocket requests
CVE: CVE-2026-43708 · Safari 26.5.2 · Released June 29, 2026 Impact: 악성 웹사이트가 cross-origin으로 데이터를 유출할 수 있습니다 Apple's description: 입력값 검증을 개선하여 문제를 해결했습니다. Credit: Behzad Najjarpour Jabbari (@G4ru)
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.example은 b.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
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
Patch Details
실제 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.mm의 TEST(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 판단이 실제로 의미를 갖도록 만드는 역할을 합니다.
Background
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에서는 이 결과와 진짜 부정 응답을 구분할 방법이 전혀 없습니다.
Analysis
이 사례는 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 전체를 그대로 붙이게 됩니다.
전체 인과관계를 정리하면 다음과 같습니다.
siteb.example의 script가new WebSocket('ws://siteA.example/websocket')를 호출합니다.- NetworkProcess가
createWebSocketTask에 진입합니다. 이 configuration 경로에서는 아직ensureMutableRequest()가 한 번도 호출되지 않아 local이 nil인 상태입니다. [nil respondsToSelector:@selector(_setAllowOnlyPartitionedCookies:)]가NO를 반환합니다.- 블록이 건너뛰어지면서 partitioning 판단도, switch 설정도 이루어지지 않습니다.
- CFNetwork가
sitea.example의 first-party cookie를 outbound handshake에 붙입니다. - WebSocket endpoint가
Cookie:헤더를 통해 이를 읽어냅니다.
2번 단계가 가장 미묘한 지점이며, 이 버그의 구조를 눈여겨봐야 하는 이유이기도 합니다. fix 이전 코드가 항상 깨져 있었던 것은 아닙니다. probe 시점에 local이 nil인지 여부는 함수 안의 더 앞선 configuration 단계가 이미 materialization을 강제했는지에 전적으로 달려 있었습니다. _privacyProxyFailClosedForUnreachableNonMainHosts 할당문이 probe 바로 위에 있지만, 이 역시 자체 조건 아래에서만 실행됩니다. 그 branch가 실행되는 configuration에서는 객체가 이미 존재해서 모든 것이 정상 동작했고, 그렇지 않은 configuration에서는 control이 조용히 사라졌습니다. 기존의 WebSocketCookies 테스트가 계속 통과해온 이유도 바로 여기에 있습니다. 이 테스트는 우연히 non-nil local을 만들어내는 materialization 순서를 거쳤을 뿐입니다. 이 버그를 잡아내려면 다른 configuration 형태를 거치는 두 번째 테스트가 필요했고, 그것이 바로 WebSocketCookies2입니다.
_setResourceLoadStatisticsEnabled:YES와 AllExceptPartitioned 비교에서 눈여겨봐야 할 점은, 이 버그가 폐기해버린 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이 조용히 비활성화되었습니다.
Insight
단서는 인접한 두 줄 사이의 비대칭이었습니다. probe 바로 위 statement는 이미 receiver를 ensureMutableRequest()로 적어두었는데, probe 자신은 mutableRequest로 적혀 있었습니다. accessor는 이미 존재했고 scope 안에 있었으며 바로 옆 두 줄 중 한 줄에서 실제로 사용되고 있었습니다. 바로 이런 차이가 code review를 통과해버리는 전형적인 패턴입니다. 두 표기 모두 개별적으로는 문제없어 보이기 때문에, reviewer의 눈은 둘을 같은 것으로 읽어버립니다. 여기서 새겨둘 점은, probe의 부정 응답이 enforcement를 비활성화시키는 구조라면 그 probe는 진짜 부재 이외의 어떤 이유로도 부정 응답을 낼 수 없도록 구조적으로 설계되어야 한다는 것입니다.
Audit directions
-
지연 초기화된 receiver에 대한 capability probe. Invariant는 다음과 같습니다. feature-detection probe는 반드시 guarded operation이 실제로 동작을 수행할, 완전히 materialize된 동일 객체 위에서 평가되어야 합니다. Narrow —
Source/WebKit/NetworkProcess/cocoa와Source/WebKit/Shared/cocoa에서respondsToSelector:를 검색해, receiver가ensure*()accessor 호출이 아니라 맨몸 local 변수인 지점을 찾아야 합니다.NetworkDataTaskCocoa.mm,NetworkSessionCocoa.mm, 그리고 download /WKURLSessionTaskDelegate경로 모두 일반 load와 redirect에서 동일하게 지연 초기화되는mutableRequestidiom을 사용합니다. Wider — 이 클래스는 probe 대상이 default 값의 sentinel일 수 있는 모든 probe-then-act 쌍으로 일반화됩니다.-conformsToProtocol:,-isKindOfClass:,[obj class]dispatch,ptr이 지연 생성되는 C++의if (ptr && ptr->supportsX()), 그리고 framework가 로드되기 전에 평가되는 soft-linked symbol check (getSomeClass() != nil)가 모두 해당됩니다. 두 rung 모두에서 공통된 match tell은 probe receiver와 operation receiver가 인접한 줄에서 서로 다르게 표기된다는 점입니다. Widest — 지연 초기화에 capability detection과 fail-open default가 결합되면, 어떤 언어에서든 security control을 건너뛰는 triad가 만들어집니다. Chromium의base::FeatureList가 feature 초기화 이전에 읽어들이는 check, Java의Optional.map(...).orElse(false)로 authz branch를 gating하는 패턴, 그리고 부분적으로만 구성된 객체에 대해 Python의getattr(obj, 'check', None)을 사용하는 패턴 모두 정확히 동일한 구조를 재현합니다. -
WebSocket 경로의 다른 privacy attribute들. Narrow —
createWebSocketTask가 outgoing request에 설정하는 모든 attribute(_privacyProxyFailClosedForUnreachableNonMainHosts,_setAllowOnlyPartitionedCookies:, 그리고 범위 내의 enhanced-privacy-mode나 tracker-blocking flag 전체)를 나열하고, 각각이 맨몸 local이 아니라ensureMutableRequest()를 통해 적용되는지 확인해야 합니다. Wider — 그 목록을NetworkDataTaskCocoa.mm에서 일반 load에 적용되는 attribute 목록과 비교해야 합니다. Match tell은 HTTP load에는 설정되지만 WebSocket 경로에는 빠져 있는 privacy attribute인데, WebSocket은 동일한 cookie jar를 공유하면서도 자주 누락되는 두 번째 request 유형이기 때문입니다. Ceiling — 이 rung은 WebKit 고유의 영역입니다. Attribute 목록 자체가 CFNetwork의 request SPI surface에 의해 정의되므로, 앞서 다룬 probe pattern을 넘어서는 의미 있는 일반화는 WebKit 바깥에서 찾기 어렵습니다. -
Choke point가 아니라 request 유형별로 적용되는 policy. 던져야 할 질문은 이것입니다. WebKit이 모든 outbound request class에 걸쳐
thirdPartyCookieBlockingDecisionForRequest를 일관되게 참조하는지, 아니면 각 call site마다 재구현하는지입니다. Narrow —WebCore::NetworkStorageSession::thirdPartyCookieBlockingDecisionForRequest와isOptInCookiePartitioningEnabled의 모든 caller를 추적하고, 어떤 request 종류가 커버되는지 확인해야 합니다. WebSocket, WebTransport, EventSource/SSE,keepalive가 설정된fetch, Beacon, prefetch/preconnect, service-worker에서 시작된 fetch, 그리고 download와 redirect의 후속 처리가 대상입니다. Wider — 동일한 형태가 network entry point 집합 전체에 걸쳐 성립해야 하는 모든 policy에서 반복됩니다. CSPconnect-src강제, ITP/tracker 분류, proxy 선택,Sec-Fetch-*metadata 채우기가 그 예입니다. Match tell은 policy helper의 caller 목록이 해당 process가 만들어낼 수 있는 request 종류 목록보다 짧다는 점입니다. Widest — authorization이나 privacy 결정이 transport에 의해 강제되지 않고 caller가 참조하는 형태로 되어 있는 곳이라면 어디든, 감사 방법은 기계적입니다. Request를 발생시킬 수 있는 모든 code path를 나열한 뒤, policy를 참조하는 집합과 diff를 뜨면 됩니다. 동일한 논리가 non-HTTP admin channel에 의해 우회되는 server-side middleware나, 하나의 ORM 경로만 row-level security를 건너뛰는 database layer에도 그대로 적용됩니다. -
어떤 configuration 형태가 local을 nil로 남기는가. Reachability가 이전 단계들이
mutableRequest를 얼마나 materialize했는지에 달려 있었던 만큼, 패치 이전의 버그는 단순한 script-initiated WebSocket보다 더 넓은 범위에서 도달 가능했을 것으로 보입니다. Narrow —createWebSocketTask에서 partitioned-cookie block 이전의 분기들을 살펴봐야 합니다. Advanced-privacy-protections /_privacyProxyFailClosedForUnreachableNonMainHosts경로와 조건부 조기 반환을 포함해서, 어떤 session configuration·proxy 설정·page settings 조합이 probe 시점에 local을 nil로 남기는지 확인해야 합니다. Wider — 일반적인 match tell은if안에 위치한 모든ensure*()call site입니다. 이런 call site는 이후 줄에서 해당 객체의 존재 여부가 runtime configuration에 의존한다는 뜻이며, 그 뒤에 나오는 모든 맨몸 local 사용은 동일한 failure의 후보가 됩니다. Ceiling — 이 부분은 source만으로 검증하기 어렵고, configuration 형태별로 instrumented build나 API test를 하나씩 돌려야 할 가능성이 높습니다. 기존WebSocketCookies를 확장하는 대신WebSocketCookies2를 별도로 추가해야 했던 이유도 바로 여기에 있습니다.