← All reports

시니어 보안 리서치용 뉴스레터 섹션을 한국어 리포트 스타일로 재작성하겠습니다.

Initiator-omitted samesite classification can lead to SameSite=Strict cookie cross-site leakage

MediumWebCore loader /CrossOrigin

CVE: CVE-2026-28958 · Safari 26.5 · Released May 13, 2026 Impact: An app may be able to access sensitive user data Apple's description: This issue was addressed with improved data protection. Credit: Cantina

Severity: Medium | Component: WebCore loader / FrameLoader | 093f346 | Bugzilla 311228

Medium입니다. 메모리 손상은 전혀 없습니다. diff는 policy classification 하나를 복원할 뿐이며, 이를 통해 되찾는 capability는 방어 수단이 Strict뿐인 endpoint를 대상으로 한 CSRF입니다. High에 이르지 못하는 이유는, attacker가 인증된 request를 발생시킬 수는 있지만 그 response를 읽어낼 수는 없기 때문입니다.

브라우저가 발생시키는 모든 navigation에는 자신의 출처에 관한 판정이 함께 붙습니다. same-site인지 cross-site인지를 가리는 이 판정은, 누가 load를 요청했는지와 그 load가 어디로 향하는지를 비교해 계산되며, 이후 cookie layer가 그 결과를 그대로 사용합니다. WebKit에서는 이 판정이 FrameLoader를 거치는 동안 ResourceRequest에 stamp되는데, 이 stamp 작업은 한 곳이 아니라 여러 곳에서 이루어집니다. FrameLoader::load에서의 이른 시점 pass와, 이후 updateRequestAndAddExtraFields에서 더 많은 정보를 갖고 이루어지는 pass가 그것입니다. 두 pass를 하나로 묶어주는 invariant는 tri-state 구조입니다. disposition은 unspecified 상태로 시작하며, 실제로 initiator를 알고 있는 단계만이 이를 확정할 자격을 갖습니다.

관전 포인트: attacker page가 top-level navigation을 victim site로 유도할 수 있다면, 그 request는 victim의 SameSite=Strict cookie를 실어 나르게 됩니다. 즉 방어 수단이 Strict뿐인 endpoint를 대상으로, 인증된 cross-site request가 성립합니다.

Source/WebCore/loader/FrameLoader.cpp

void FrameLoader::load(FrameLoadRequest&& request, std::optional<NavigationRequirement> ...)
...
if (auto advancedPrivacyProtections = request.advancedPrivacyProtections())
loader->setOriginatorAdvancedPrivacyProtections(*advancedPrivacyProtections);
 
- addSameSiteInfoToRequestIfNeeded(loader->request());
+ Ref initiator = request.requester();
+ addSameSiteInfoToRequestIfNeeded(loader->request(), SecurityPolicy::shouldInheritSecurityOriginFromOwner(initiator->url()) ? nullptr : initiator.ptr());
applyShouldOpenExternalURLsPolicyToNewDocumentLoader(protect(m_frame), loader, request);

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

+TEST(WKHTTPCookieStore, SameSiteStrictCookieNotSentOnCrossSiteNavigation)
+{
+ if (path.endsWith("/setcookie"_s)) {
+ co_await connection.awaitableSend(
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Length: 4\r\n"
+ "Set-Cookie: id=secret; Path=/; SameSite=Strict\r\n"
+ "\r\n"
+ "Done"_s);
+ } else if (path.endsWith("/cross-site-check"_s)) {
+ crossSiteCheckHasCookie = contains(request.span(), "id=secret"_span);
...
+ [webView loadRequest:... @"http://victim.example:%d/same-site-check" ...];
+ EXPECT_TRUE(sameSiteCheckHasCookie);
+
+ [webView loadRequest:... @"http://attacker.example:%d/attacker" ...];
+ [webView _test_waitForDidFinishNavigation];
+
+ [webView loadRequest:... @"http://victim.example:%d/cross-site-check" ...];
+ Util::run(&receivedCrossSiteCheck);
+ EXPECT_FALSE(crossSiteCheckHasCookie);
+}

프로덕션 코드 변경은 호출 지점 단 하나입니다. WebCore::FrameLoader::load(FrameLoadRequest&&, std::optional<NavigationRequirement>...)는 기존에 addSameSiteInfoToRequestIfNeeded(loader->request())를 호출했습니다. 이는 인자가 하나뿐인 형태로, initiator document를 전혀 전달하지 않습니다. 패치는 FrameLoadRequest에서 requester document를 꺼내(Ref initiator = request.requester()) 두 번째 인자로 전달하도록 변경했습니다. 그 결과 helper는 initiator가 없는 상태의 기본값으로 대체하는 대신, 실제 initiator를 기반으로 disposition을 계산하게 됩니다.

이 인자를 감싼 조건문이 fix의 나머지 절반입니다. SecurityPolicy::shouldInheritSecurityOriginFromOwner(initiator->url())가 true인 경우, 즉 initiator의 URL이 about:blank이거나 비어 있어서 그 origin이 URL 자체에서 파생된 것이 아니라 생성 context로부터 상속된 경우, 패치는 nullptr을 대신 전달합니다. 이는 해당 케이스에서 기존의 permissive한 기본 동작을 의도적으로 유지하기 위함입니다. frame의 초기 empty document가 바로 이런 형태의 URL을 갖습니다. 이런 URL을 target site와 비교하면 principal이 아니라 단순한 문자열에 대한 판정이 나오게 됩니다.

참고로 request.requester()는 pointer가 아니라 Document&를 반환합니다. Ref initiator 바인딩은 조건 없이 이루어지는데, 바로 이 지점 때문에 이 호출 지점에서의 누락이 눈에 띄지 않고 조용히 지나갈 수 있었습니다. helper가 필요로 했던 정보는 이미 전달되던 객체 안에 있었습니다. 그저 호출부가 그것을 가져다 쓰지 않았을 뿐입니다.

diff의 나머지 부분은 테스트 코드입니다. TEST(WKHTTPCookieStore, SameSiteStrictCookieNotSentOnCrossSiteNavigation)은 coroutine 기반 HTTPServer를 proxy 설정 뒤에 세워, victim.exampleattacker.example이라는 서로 다른 두 hostname이 동일한 test server로 resolve되도록 합니다. 이후 WKWebView를 4단계 navigation 시퀀스로 진행시키면서, 매 단계마다 request header에 id=secret이 포함되는지를 검사합니다.

SameSite cookie. SameSiteStrict, Lax, None 세 값을 갖는 cookie attribute로, request를 발생시킨 context와 cookie 자체의 site 사이의 관계에 따라 브라우저가 언제 cookie를 첨부할지를 제한합니다. Lax는 top-level navigation에서도 cookie 첨부를 허용하지만, Strict는 그런 경우조차 첨부를 보류하고 request의 site-for-cookies가 일치할 때만 첨부합니다. 이 추가적인 엄격함이야말로, Strict가 별도의 token 없이도 sensitive endpoint의 독립적인 CSRF 방어 수단으로 배치되는 이유입니다.

Site-for-cookies와 registrable domain. SameSite가 수행하는 비교는 origin 동일성이 아닙니다. initiator의 site와 request URL의 site 사이의 registrable-domain 동일성, 즉 eTLD+1 비교입니다. a.victim.exampleb.victim.example은 동일 site로 취급되지만, attacker.examplevictim.example은 서로 다른 site입니다.

Initiator(requester) document. FrameLoadRequest는 requester Document를 함께 전달합니다. 이는 어떤 script, markup, 혹은 user-gesture context가 해당 load를 발생시켰는지를 나타내는 document입니다. "무엇을 기준으로 same-site인가"라는 질문에 답을 제공하는 입력값이며, 이것이 없으면 registrable-domain 비교의 좌변 자체가 성립하지 않습니다.

ResourceRequest의 tri-state disposition. request의 same-site flag는 boolean이 아닙니다. same-site, cross-site, 그리고 unspecified 세 상태를 가지며, 마지막 상태는 "아직 어떤 단계도 확정하지 않았음"을 의미하고 isSameSiteUnspecified()로 조회됩니다. addSameSiteInfoToRequestIfNeeded(request, initiator)는 이 tri-state에 대한 fill-in-if-unset helper입니다. disposition이 이미 확정된 상태라면 즉시 반환하고, 그렇지 않은 경우에만 값을 계산해 기록합니다. initiator 인자는 optional인데, 일부 load는 실제로 배후에 document가 존재하지 않기 때문입니다.

updateRequestAndAddExtraFields. loader pipeline 하류에는 outgoing request에 request-level 필드를 stamp하는 단계가 있습니다. 여기에는 extra header, policy flag, 그리고 initiator를 반영한 same-site 재계산이 포함됩니다. 이 재계산은 게이트로 조건화되어 있어, disposition이 여전히 unspecified 상태일 때만 실행됩니다.

Inherited origin. SecurityPolicy::shouldInheritSecurityOriginFromOwner(url)은 origin이 URL 자체가 아니라 생성 context로부터 온 URL, 즉 about:blank와 빈 URL에 대해 true를 반환합니다. 이런 document의 URL은 어떤 의미로도 site identifier가 되지 못합니다.

Navigation flow. FrameLoader::load는 request를 감싸는 DocumentLoader를 구성하고, 자신의 policy stamp를 적용한 뒤 policy check를 거쳐 network process로 진행합니다. network process는 request의 site-for-cookies와 same-site disposition을 ground truth로 삼아 cookie store를 조회합니다.

이 버그는 sentinel-consumption 오류에 해당합니다. 정보가 적은 이른 시점의 writer가, 더 많은 정보를 가진 나중 시점의 writer가 확정하기를 기다리던 tri-state를 먼저 확정해버린 셈입니다.

  Before                                After
  ──────────────────────────────        ──────────────────────────────
  FrameLoader::load                     FrameLoader::load
    addSameSiteInfo(req)                  addSameSiteInfo(req, initiator)
      no initiator → true                   eTLD+1 compare → false
      sentinel cleared                      sentinel cleared
  updateRequestAndAddExtraFields        updateRequestAndAddExtraFields
    isSameSiteUnspecified()? no             isSameSiteUnspecified()? no
    recompute SKIPPED                       recompute SKIPPED
  network: Strict cookie SENT           network: Strict cookie WITHHELD

이 diagram의 두 column 모두에서 재계산이 SKIP됩니다. 바로 이 지점이 핵심입니다. fix는 나중 단계가 다시 실행되도록 만드는 방식이 아닙니다. 대신 이른 단계가 내놓는 답 자체를 올바르게 고칩니다. updateRequestAndAddExtraFields가 게이트를 확인하는 시점에는 이미 값이 어느 쪽으로든 확정되어 있기 때문입니다. 바뀐 것은 어떤 값이 확정되느냐입니다. 왼쪽 column에서는 initiator 없는 형태의 addSameSiteInfoToRequestIfNeeded가 무조건 isSameSite = true를 기록하며, 이 기록은 두 가지 피해를 동시에 냅니다. 아무것도 계산하지 않은 상태에서 same-site 판정을 단정해버리는 것이 하나이고, 하류 단계에게 "아직 할 일이 남아 있다"는 유일한 신호였던 unspecified 상태를 지워버리는 것이 다른 하나입니다. 오른쪽 column에서는 같은 시점에 같은 write가 일어나지만, 이번에는 requester document를 기준으로 한 실제 registrable-domain 비교의 결과물입니다.

이 문제가 호출 지점만 봐서는 "무해해 보이는" 이유는 helper의 형태 자체에 있습니다. fill-in-if-unset 함수는 idempotent해 보입니다. 두 번 호출해도 두 번째 호출은 no-op이 되기 때문에, 호출자 입장에서는 이른 시점에 방어적으로 먼저 호출해도 괜찮을 것처럼 느껴집니다. idempotence 자체는 사실이지만, 방향이 반대로 작용합니다. 첫 번째 writer가 승리하는 구조이므로, 나중 단계가 갖고 있는 정보보다 적은 정보를 가진 채 이른 시점에 호출하는 행위는 보수적인 기본 동작이 아니라 선점(preemption)에 해당합니다. no-initiator branch의 permissive한 성격이 이 선점을 security failure로 만듭니다. fallback이 신뢰할 수 없는 답이 아니라 신뢰받는 답 쪽으로 수렴하기 때문입니다. 부재한 principal이 신뢰받는 principal처럼 취급된 셈입니다.

diff 안의 테스트는 이렇게 만들어지는 capability를 구체적으로 보여줍니다. 이 테스트는 coverage보다는 proof-of-concept에 가깝게 읽어볼 필요가 있습니다.

  1. victim.example/setcookie로 navigate합니다. 서버는 Set-Cookie: id=secret; Path=/; SameSite=Strict로 응답합니다.
  2. victim.example/same-site-check로 navigate합니다. 서버는 request에서 id=secret을 확인하며, EXPECT_TRUE가 성립합니다. 진짜 same-site navigation에서는 Strict cookie가 여전히 동작해야 하기 때문입니다.
  3. attacker.example/attacker로 navigate합니다. 이로써 attacker-origin document가 현재 frame의 content가 되고, 이어지는 navigation의 requester 역할을 하게 됩니다.
  4. victim.example/cross-site-check로 navigate합니다. EXPECT_FALSE(crossSiteCheckHasCookie)가 성립해야 합니다. Strict cookie가 함께 실려가면 안 되기 때문입니다.

4단계가 이 vulnerability의 핵심입니다. fix 이전에는 이 시점의 requester가 attacker.example document이고 target은 victim.example이므로, 명백히 cross-site 관계입니다. 그럼에도 request는 FrameLoader::load를 떠날 때 same-site로 stamp된 상태였고, 그 stamp를 ground truth로 삼은 채 network process의 cookie-attachment 판정에 도달했습니다. 2단계는 fix가 왜 단순히 cross-site를 하드코딩하거나 이른 시점의 호출을 제거하는 방식으로 해결될 수 없었는지를 보여줍니다. 같은 code path가 정당한 same-site navigation도 함께 처리하기 때문에, disposition은 어느 한쪽으로 기본값을 정해두는 것이 아니라 실제로 계산되어야 합니다.

이로 인해 얻어지는 capability는 memory-level이 아니라 policy-level입니다. victim site로의 navigation을 유도할 수 있는 attacker page라면, 즉 link나 form target, window.location, 혹은 자신이 소유한 frame을 통제하는 일반적인 page라면, SameSite=Strict만을 CSRF 방어 수단으로 삼는 endpoint를 상대로 인증된 cross-site request primitive를 확보하게 됩니다. attacker는 cross-origin response를 읽어낼 수는 없습니다. same-origin policy는 그쪽 방향에서는 여전히 유효합니다. 실제 정보 유출로 이어지려면 victim endpoint 자체의 동작에서 무언가가 더해져야 합니다. 관측 가능한 지점으로 이어지는 redirect chain이라든지, URL에 반영되는 token, 혹은 attacker가 측정할 수 있는 인증된 request의 side effect 등이 필요합니다. attacker가 안정적으로 얻는 것은 victim의 identity로 이루어지는 state change이며, 이것이 바로 Strict가 방어하고자 했던 threat model 그 자체입니다.

process 관점도 짚어볼 필요가 있습니다. Apple의 advisory 문구("An app may be able to access sensitive user data")는 이 구조를 다소 평탄하게 표현하고 있기 때문입니다. 오분류 자체는 FrameLoader가 request를 구성하는 WebContent process에서 시작됩니다. 반면 그 결과는 disposition을 다시 계산하지 않고 그대로 소비하는 Networking process에서 나타납니다. 어떤 sandbox도 우회되지 않으며, 애초에 그럴 필요도 없습니다. 여기서 넘어서는 경계는 process 경계가 아니라 same-site cookie 경계이기 때문입니다. 바로 이 비대칭성이 흥미로운 지점입니다. 신뢰도가 낮은 process가 stamp한 필드를, 신뢰도가 더 높은 process가 그대로 authoritative하게 받아들이는 구조이다 보니, producing 쪽의 logic error가 memory corruption 없이도 consuming 쪽의 policy bypass로 이어지게 됩니다.

shouldInheritSecurityOriginFromOwner guard는 fix가 새로 열어놓을 뻔한 correctness gap을 막아줍니다. initiator URL을 site identity로 사용하기 시작하면, 고유한 identity를 갖지 않는 URL은 그 자체로 유효하지 않은 입력이 됩니다. frame의 초기 empty document는 about:blank URL을 가지며, 그 origin은 생성 주체로부터 상속된 것입니다. 이 URL의 registrable domain을 target과 비교한다면, 지극히 평범한 신규 navigation조차 cross-site로 오분류될 것입니다. 이런 경우 nullptr을 전달함으로써 해당 load는 기존의 same-site 기본 동작으로 되돌아가게 되며, 이는 이전에 비교할 site 자체가 없던 navigation에 대한 올바른 disposition에 해당합니다.

initiator를 한 번도 확인하지 않은 채 same-site로 분류된 navigation이, cross-site page load에게도 target site의 SameSite=Strict cookie를 그대로 넘겨주는 결과를 낳았습니다.

fill-in-if-unset helper는 이름이 암시하는 것과 달리 idempotent하지 않습니다. pipeline 단계들 사이에서 first-writer-wins 방식으로 벌어지는 경쟁에 가깝고, 대개는 정보를 가장 적게 가진 단계가 가장 먼저 실행됩니다. 따라서 policy field에 명시적인 "unset" 상태가 존재한다면, 모든 write를 authority에 대한 주장으로 간주해야 합니다. 방어적인 목적으로 이른 시점의 호출을 추가하기 전에는, 지금 write하려는 단계가 나중 게이트가 기다리고 있던 입력값을 실제로 갖고 있는지부터 확인할 필요가 있습니다.