← All reports

[4] Srcdoc iframes inherit the top-level site for cookies

MediumWebCore loaderCrossOrigin

A srcdoc frame inherited its origin from one parent and its cookies from another.

c52bbb5

Medium으로 평가됩니다 — 중첩된 attacker frame에게 top-level document의 cookie site를 그대로 넘겨주던 조건 혼동을 이번 diff가 제거했습니다. Memory-safety 요소는 없으며, attacker가 victim 페이지의 subframe을 이미 장악하고 있어야 한다는 전제가 필요합니다. 다만 그 전제가 성립하는 상황에서는, SameSite=Strict가 이를 신뢰하는 endpoint를 더 이상 방어하지 못하게 됩니다.

Cookie에는 SameSite attribute가 있어서, 다른 site에서 만들어진 요청에 얹혀 전송될지 여부를 결정합니다. 브라우저는 이를 document 단위의 "site for cookies" 값과 비교해서 판단하는데, 이 값은 매 요청마다 상속됩니다. 일부 document는 URL에서 파생되는 자체 origin이 없습니다. about:blankabout:srcdoc가 그런 경우로, 이들은 자신을 생성한 element — 즉 바로 위 부모 — 로부터 origin을 물려받습니다. 그래서 브라우저는 이런 document가 어떤 cookie site에 속하는지를 별도로 결정해야 합니다. 기대되는 동작은 단순합니다. 부모로부터 origin을 물려받는 document라면, cookie site 역시 그 부모를 따라가야 합니다. 실제로 그 document를 대표하는 주체가 부모이기 때문입니다.

관전 포인트: victim 페이지의 subframe을 장악한 attacker라면 — 광고 슬롯, embed 위젯, 사용자 제공 frame 등 무엇이든 — srcdoc 자식 frame을 만들어서 top-level origin으로 credential이 포함된 cross-site 요청을 보낼 수 있었습니다. 이때 SameSite=StrictLax cookie까지 함께 실려 나갑니다.

When we set firstPartyForCookies on a subframe, we check if either:

  1. shouldInheritSecurityOriginFromOwner is true for the current document's URL, or
  2. the current document's URL is same-registrable-domain as the top-level document URL

In the case of an iframe with srcdoc, shouldInheritSecurityOriginFromOwner returns true (as documented), and this causes us to set the page's mainFrameURL as the firstPartyForCookies. We need a conditional exception for shouldInheritSecurityOriginFromOwner, but it should take nested iframes into account. This patch adjusts the logic so we inherit the ancestor frame's siteForCookies instead of the page's URL. The same-registrable-domain check remains unchanged.

Source/WebCore/loader/FrameLoader.cpp

RefPtr localFrame = dynamicDowncast<LocalFrame>(*descendantFrame);
if (!localFrame)
continue;
 
- if (SecurityPolicy::shouldInheritSecurityOriginFromOwner(protect(localFrame->document())->url()) || registrableDomain.matches(protect(localFrame->document())->url()))
+ if (SecurityPolicy::shouldInheritSecurityOriginFromOwner(protect(localFrame->document())->url())) {
+ if (RefPtr parent = dynamicDowncast<LocalFrame>(localFrame->tree().parent()))
+ protect(localFrame->document())->setSiteForCookies(parent->document()->siteForCookies());
+ } else if (registrableDomain.matches(protect(localFrame->document())->url()))
protect(localFrame->document())->setSiteForCookies(url);
}
}

LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.html

+const fetchURL = "http://127.0.0.1:8000/cookies/resources/echo-json.py";
+const srcdocContent = `<!DOCTYPE html><body><script>
+fetch(${JSON.stringify(fetchURL)}, {credentials: "include", mode: "cors"})
+ .then((response) => response.json())
+ .then((cookies) => {
+ window.top.postMessage({type: "cookies-from-srcdoc", cookies}, "*");
+ })
+ .catch((error) => {
+ window.top.postMessage({type: "cookies-from-srcdoc", cookies: {error: String(error)}}, "*");
+ });
+<\/script></body>`;
+
+let srcdocIframe = document.createElement("iframe");
+srcdocIframe.style.display = "none";
+srcdocIframe.srcdoc = srcdocContent;
+document.body.appendChild(srcdocIframe);

실제 production 변경은 FrameLoader::setFirstPartyForCookies(const URL& url) 한 곳입니다. 이 함수는 m_frame의 descendant frame들을 순회하면서, LocalFrame 각각에 대해 그 document의 site-for-cookies를 url — frame tree의 새로운 first-party URL — 로 업데이트할지 결정합니다. 패치 이전에는 두 조건이 하나의 branch로 OR 결합되어 있었습니다: if (shouldInheritSecurityOriginFromOwner(document->url()) || registrableDomain.matches(document->url())) document->setSiteForCookies(url);.

패치는 이 둘을 분리합니다. shouldInheritSecurityOriginFromOwner 조건이 참인 경우 더 이상 url을 그대로 대입하지 않습니다. 대신 localFrame->tree().parent()로 descendant의 부모를 조회하고, dynamicDowncast<LocalFrame>로 downcast한 뒤, 성공하면 protect(localFrame->document())->setSiteForCookies(parent->document()->siteForCookies())를 대입합니다. registrableDomain.matches(...) branch는 그대로 유지되어 여전히 url을 대입하지만, 이제 else if를 통해서만 도달하므로 origin을 상속하는 document에 대해서는 더 이상 평가되지 않습니다. 새로 추가된 branch는 부모가 LocalFrame이 아닌 경우 — 예를 들어 site isolation 하의 RemoteFrame — no-op으로 동작합니다. 나머지 커밋 내용은 test collateral로, fetch()<img> 변형이 각각 .html-expected.txt로 추가되었고, record-image-cookies.py를 포함한 세 가지 지원 리소스가 함께 추가되어 버그를 유발하는 정확한 frame 중첩 구조를 만들어냅니다.

바로 위 부모로부터 origin을 상속받는 것과 top-level document의 site에 속하는 것을 혼동한 패턴입니다.

Site-for-cookies / first-party-for-cookies.Document에 저장되는 URL로(Document::setSiteForCookies로 설정 가능), 해당 요청이 속한 site를 나타냅니다. 이 값은 나가는 각 resource request에 복사되며, network layer가 cookie의 site와 비교해서 same-site인지 cross-site인지 판단할 때 사용합니다.

SameSite cookie attribute. SameSite=Strict cookie는 site-for-cookies가 cookie의 site와 일치하는 요청에만 전송됩니다. SameSite=Lax는 여기에 더해 top-level navigation에도 허용됩니다. SameSite attribute가 없는 cookie는 WebKit에서 SameSite=None으로 취급되므로(test comment는 CookieCocoa.mmcoreSameSitePolicy()를 인용합니다), cross-site credentialed request에도 함께 실려 나갑니다. 이 때문에 테스트에서는 이 cookie를 positive control로 사용합니다.

Registrable domain. 호스트의 eTLD+1 형태입니다. RegistrableDomain::matches(url)는 주어진 URL이 이 객체가 생성될 때 사용한 domain과 같은 site에 속하는지를 묻습니다.

Origin inheritance. HTML origin 규칙에 따라, 일부 URL은 자체 origin이 없어서 자신을 생성한 element의 origin을 물려받습니다. SecurityPolicy::shouldInheritSecurityOriginFromOwner(url)가 이 predicate를 구현하며, about:blankabout:srcdoc 같은 URL에 대해 true를 반환합니다.

<iframe srcdoc>. document 내용을 attribute 값으로 inline 제공하는 iframe입니다. 결과 document의 URL은 about:srcdoc이고, origin은 이를 embed한 document로부터 상속됩니다.

Frame tree traversal. FrameTree는 frame을 그 부모 및 descendant와 연결합니다. FrameLoader::setFirstPartyForCookies는 frame의 descendant를 순회하며 first-party 상태를 아래로 전파합니다. Site isolation 하에서는 부모가 LocalFrame이 아니라 RemoteFrame일 수 있는데, 코드가 dynamicDowncast<LocalFrame>를 사용하는 이유입니다.

이번 건은 security policy logic error에 해당하며, memory-safety 요소가 없는 same-site cookie scope 혼동 문제입니다. 패치 이전 코드는 "이 document는 owner로부터 security origin을 상속받는다"는 조건을, "이 document는 top-level frame과 same-site다"라는 조건과 동일한 것처럼 취급했습니다. 그리고 이 둘을 같은 대입문으로 처리했습니다.

  Frame tree                          Pre-fix siteForCookies
  ──────────                          ──────────────────────
  top: 127.0.0.1:8000  (victim A)     A
    └─ iframe: localhost:8000  (B)    (unchanged: B's URL is not
        │                              same-registrable-domain as A)
        └─ iframe srcdoc  (about:srcdoc)
             shouldInheritSecurityOriginFromOwner == true
             ──────────────────────────────►  A   ← wrong; origin is B's

  Post-fix:                            = parent's siteForCookies (B's)

shouldInheritSecurityOriginFromOwnerabout:srcdoc이나 about:blank처럼 origin을 소유 element의 document — 즉 바로 위 부모 — 로부터 물려받는 URL에 대해 true를 반환합니다. tree의 최상단이 아닙니다. 여기서 누락되어 있던 invariant는, origin을 상속받는 document의 cookie site 역시 실제로 상속받은 그 origin을 따라가야 한다는 점입니다.

origin A인 페이지가 origin B인 cross-origin iframe을 embed하고, B가 <iframe srcdoc=...>를 만드는 상황을 생각해보면, srcdoc document의 URL은 about:srcdoc이고 security origin은 B로부터 상속됩니다. 따라서 이 document가 A로 보내는 subresource request는 실질적으로 cross-site입니다. 하지만 기존 branch는 inheritance predicate만으로 매칭되어, srcdoc Document의 site-for-cookies에 A의 URL을 그대로 찍어버렸습니다. 이 per-document 값은 document가 보내는 요청의 first-party URL로 그대로 쓰이고 SameSite 판정이 이를 참조하므로(shipped test와 일치하는, WebCore의 표준 동작입니다), srcdoc에서 시작된 요청이 A와 same-site로 분류되었고, A에 스코프된 cookie가 B가 완전히 통제하는 요청에 함께 붙어 나갔습니다.

패치 이후에는 srcdoc document가 부모의 siteForCookies를 그대로 복사합니다. 공격 시나리오에서 부모는 B이고, B의 document는 이 loop에서 A의 URL로 찍히지 않습니다. B의 URL이 A의 registrable domain과 일치하지 않기 때문입니다. 결국 B가 어떤 값을 갖고 있든, srcdoc은 더 이상 A의 site를 획득하지 못합니다. (B의 document가 이미 A의 URL을 갖고 있을 수 있는지는 이전 navigation 상태와 loop가 frame을 방문하는 순서에 달려 있는 runtime 질문으로, 이번 변경이 답을 정해주는 부분은 아닙니다.) 반대로 정상적인 시나리오 — srcdoc이 top-level frame 바로 아래 있는 경우 — 에서는 부모가 곧 top frame이므로, srcdoc은 여전히 top-level site를 받아 same-site cookie가 정상 동작합니다.

이 버그는 일반 웹 콘텐츠에서 그대로 도달 가능합니다. 특별한 API도, 권한 있는 caller도 필요하지 않습니다. Shipped regression test를 따라가 보면 다음과 같습니다.

  1. Victim 페이지가 http://127.0.0.1:8000에 로드되고, 해당 호스트에 대해 strict(SameSite=Strict), lax(SameSite=Lax), implicit-default(SameSite 없음) 세 cookie를 보유합니다.
  2. 이 페이지가 http://localhost:8000/.../srcdoc-creator-inside-cross-origin-iframe.html을 가리키는 iframe을 추가합니다. 다른 host이므로 cross-origin이자 cross-site입니다.
  3. Attacker가 통제하는 이 document가 <iframe srcdoc=...>를 생성합니다. 중첩된 document의 URL은 about:srcdoc이고 origin은 localhost:8000으로부터 상속됩니다.
  4. FrameLoader::setFirstPartyForCookies가 tree를 순회할 때, 패치 이전 predicate가 OR 조건을 단락시켜 srcdoc DocumentsetSiteForCookies(url)을 받게 됩니다. 여기서 url은 top-level인 127.0.0.1:8000의 URL입니다.
  5. srcdoc script가 fetch("http://127.0.0.1:8000/cookies/resources/echo-json.py", {credentials: "include", mode: "cors"})를 실행합니다. 이 요청은 srcdoc document의 first-party URL을 실어 나르므로, network layer가 127.0.0.1과 same-site로 분류하여 strictlax cookie를 함께 붙입니다.

<img> 변형도 동일한 원리로 일반 subresource load에서 같은 문제를 재현하며, 서버 측에서 수신한 Cookie header를 기록합니다. 일반화된 공격 형태를 보면, victim site가 top-level document여야 하고, attacker가 그 안의 어떤 subframe이든 통제해야 하며, attacker의 frame이 srcdoc 자식을 만들어 top-level site로 상태 변경이나 데이터 조회를 위한 credentialed 요청을 보내는 구조입니다. shouldInheritSecurityOriginFromOwnerabout:blank도 포함하므로, cross-origin iframe이 같은 프로세스 내 about:blank 자식을 만들고 document.write로 내용을 채워 넣는 방식도 이론적으로 같은 branch에 도달했을 가능성이 있습니다. 다만 이 변형은 predicate의 문서화된 semantics에서 추론되는 것일 뿐, shipped test로 직접 확인되는 사항은 아닙니다.

여기서 확보되는 primitive는 attacker가 발생시키는 subresource request에 SameSite=StrictLax cookie가 cross-site로 함께 붙는 것입니다. SameSite를 방어 수단으로 삼는 모든 endpoint에 대한 CSRF primitive이며, target endpoint가 permissive CORS와 Access-Control-Allow-Credentials로 응답하는 경우에는 사용자별 응답 데이터를 노출할 수 있는 authenticated cross-site read로도 이어질 수 있습니다. Cookie 자체가 attacker script에 노출되지는 않습니다. Cookie는 요청에 실려 갈 뿐이고, 응답을 읽으려면 여전히 target의 CORS 협조가 필요합니다. 이 전체 과정은 WebContent process 안에서 일어납니다. 이는 요청이 network process에 도달하기 전 WebCore에서 내려지는 policy 결정이므로, 결과적으로 만들어지는 요청은 형식상 정상이고 network process 입장에서는 site-for-cookies가 잘못되었다는 신호를 독립적으로 얻을 방법이 없습니다. Process boundary는 넘어가지 않습니다.

발견 경위를 보면 origin-inheritance 관련 특수 케이스를 점검하는 pattern audit이거나, 같은 디렉터리에 있던 이전 site-for-cookies 버그에 대한 variant analysis로 읽힙니다. LayoutTests/http/tests/cookies/same-site/ 테스트 스위트는 이미 존재했고, 이번 커밋은 여기에 중첩된 srcdoc 변형만 추가했는데, 이는 기존 test corpus를 대상으로 frame 중첩 형태를 하나씩 나열해본 흔적입니다. 이 버그는 crash 없는 순수 logic issue이므로 fuzzing에서 비롯되었을 가능성은 낮고, 두 테스트 모두에 의도적으로 implicit-default positive control이 들어가 있는 점도 사람이 올바른 cookie 집합이 무엇인지 신중하게 검토했음을 시사합니다.

이 vulnerability는 same-site cookie boundary를 약화시킵니다. 이 boundary는 한 site가 시작한 요청과, 그 안에 embed된 무관한 site가 시작한 요청을 구분하는 경계입니다. 여기서 흔들리는 security model의 전제는, SameSite=Strict/Lax cookie가 target의 site에 속하는 initiator의 요청에만 붙어야 한다는 것입니다. 패치 이전에는, cross-origin 부모로부터 origin을 상속받은 document임에도 top-level document의 cookie site를 그대로 인정받는 상황이 있었습니다. 공격이 성립하려면 victim origin이 top-level document이고 그 아래 attacker가 통제하는 subframe이 있어야 합니다. 이런 frame에서라면 attacker는 top-level origin의 SameSite-restricted cookie를 실은 authenticated cross-site 요청을 보낼 수 있었습니다. SameSite를 방어 수단으로 삼는 endpoint에 대한 전형적인 CSRF이고, target endpoint가 permissive CORS와 credential을 함께 허용하는 경우라면 authenticated 응답에 대한 cross-site read로도 이어집니다. 반대 배치 — attacker가 top frame인 경우 — 는 victim의 cookie를 얻지 못합니다. 이 경우 srcdoc은 attacker 자신의 site로 찍히기 때문입니다.

패치 이전 코드는 서로 다른 질문에 답하는 두 predicate를 disjunction으로 묶어 사용했습니다. registrableDomain.matches(url)은 "이 document가 top-level site의 일부인가?"라는 site membership 질문에 답합니다. shouldInheritSecurityOriginFromOwner(url)은 "이 document의 origin이 다른 곳에서 왔는가?"라는 delegation 질문에 답할 뿐, 어디서 왔는지는 말해주지 않습니다. 이 둘을 OR로 묶으면서 "origin이 위임되었다"는 사실이 암묵적으로 "origin이 root의 것이다"로 격상되어 버렸는데, 이는 중첩 depth가 1일 때만 참인 명제입니다. 그래서 이 버그는 두 번째 수준의 중첩이 존재할 때에만 드러났고, 바로 그 이유로 기존 same-site test corpus가 이를 놓쳤습니다. Origin을 상속하는 URL을 특별 취급하는 코드를 볼 때는 "상속하니까 신뢰할 수 있다"가 아니라 "누구로부터 상속받는가"라는 질문으로 다시 읽어볼 가치가 있습니다.

번역