← All reports

[2] CSP empty object-src source list treated as permissive for URL-less plugins

MediumWebCore Content Security Policy enforcementCrossOrigin

The object-src spelling that looks like deny-all was the permissive one.

c6228ab

Medium으로 평가된 이유는, 스펙상 동일하게 정의된 두 정책이 서로 다른 결과를 냈고, 하필 허용 쪽으로 흐른 정책이 겉보기에는 deny-all처럼 보이는 표기였기 때문입니다. 메모리 primitive는 없습니다. 다만 플러그인 인스턴스화를 막으려고 배포한 CSP가 실제로는 그 역할을 못했다는 점이 문제입니다.

Content Security Policy는 문서가 리소스 타입별로 어떤 출처를 로드할 수 있는지 선언할 수 있게 해주며, object-src directive는 <object><embed> 플러그인 로드를 통제하는 게이트 역할을 합니다. Directive의 우변은 source list로, 'self''none' 같은 토큰, scheme/host 표현식, 혹은 아무것도 없는 형태를 가질 수 있습니다. CSP Level 3는 빈 source list가 어떤 URL과도 매치되지 않는다고 규정합니다. 즉 object-src;는 의미상 object-src 'none'과 동일하며, 둘 다 모든 플러그인 인스턴스화를 금지해야 합니다.

관전 포인트: object-src;로 강화된 페이지에서, HTML injection sink를 가진 공격자가 data/src 없는 <object type="..."><embed type="...">를 주입하면, 여전히 해당 type의 기본 플러그인이 인스턴스화됩니다.

<object><embed> element에 data/src attribute가 없을 때, WebKit은 이전까지 빈 URL을 CSP check에 전달하면서 리터럴 'none' 키워드에 대해서만 차단하는 special-case 로직을 사용했습니다. 빈 source list(object-src;)는 CSP Level 3 §6.7.2.7 기준으로 'none'과 동등함에도 불구하고 잘못 허용되고 있었습니다.

§6.1.9의 special-case 처리를 완전히 제거합니다. 대신 element에 연관된 URL이 없을 때는 문서 자체의 URL을 source list 매칭의 fallback으로 사용합니다. 문서 URL은 빈 source list나 'none'과는 자연스럽게 매치되지 않아 차단되고, 'self'나 wildcard와는 매치되어 허용됩니다.

Source/WebCore/page/csp/ContentSecurityPolicySourceListDirective.cpp

-bool ContentSecurityPolicySourceListDirective::allows(const URL& url, bool didReceiveRedirectResponse, ShouldAllowEmptyURLIfSourceListIsNotNone shouldAllowEmptyURLIfSourceListEmpty)
+bool ContentSecurityPolicySourceListDirective::allows(const URL& url, bool didReceiveRedirectResponse)
{
if (url.isEmpty())
- return shouldAllowEmptyURLIfSourceListEmpty == ShouldAllowEmptyURLIfSourceListIsNotNone::Yes && !m_sourceList.isNone();
+ return false;
return m_sourceList.matches(url, didReceiveRedirectResponse);
}

Source/WebCore/page/csp/ContentSecurityPolicy.cpp

- if (m_policies.isEmpty() || LegacySchemeRegistry::schemeShouldBypassContentSecurityPolicy(url.protocol()))
+ const auto& urlToCheck = url.isEmpty() ? m_protectedURL : url;
+ if (m_policies.isEmpty() || LegacySchemeRegistry::schemeShouldBypassContentSecurityPolicy(urlToCheck.protocol()))
return true;
- // ... 'MUST be blocked if object-src's value is 'none', but will otherwise be allowed' ...
String sourceURL;
- const auto& blockedURL = !preRedirectURL.isNull() ? preRedirectURL : url;
+ const auto& blockedURL = !preRedirectURL.isNull() ? preRedirectURL : urlToCheck;
...
- return allPoliciesAllow(handleViolatedDirective, &ContentSecurityPolicyDirectiveList::violatedDirectiveForObjectSource, url, redirectResponseReceived == RedirectResponseReceived::Yes, ContentSecurityPolicySourceListDirective::ShouldAllowEmptyURLIfSourceListIsNotNone::Yes);
+ return allPoliciesAllow(handleViolatedDirective, &ContentSecurityPolicyDirectiveList::violatedDirectiveForObjectSource, urlToCheck, redirectResponseReceived == RedirectResponseReceived::Yes);

LayoutTests/imported/w3c/web-platform-tests/content-security-policy/object-src/object-src-no-url-empty-source-list-blocked.html

+<meta http-equiv="Content-Security-Policy" content="object-src; script-src 'self' 'unsafe-inline';">
+<object type="text/html"></object>

이번 변경으로 source-list 매칭에서 ShouldAllowEmptyURLIfSourceListIsNotNone special case가 제거되었고, 대신 호출자 쪽에서 URL을 치환하는 방식으로 대체되었습니다. ContentSecurityPolicySourceListDirective::allows()는 이제 URL이 비어 있을 때 !m_sourceList.isNone()을 참조하는 대신 무조건 false를 반환합니다. 이 파라미터는 함수 시그니처, checkSource(), violatedDirectiveForObjectSource(), 관련 allows() 오버로드에서 모두 사라졌고, 두 개의 checkFrameAncestors() 오버로드에서도 제거된 세 번째 인자가 함께 빠졌습니다. ContentSecurityPolicy::allowObjectFromSource()에서는 들어온 url이 비어 있는 경우 — 즉 data/src가 없는 <object>/<embed>의 경우 — 문서 자체의 m_protectedURL로 치환되며, 이 치환된 URL이 scheme bypass check, 보고되는 blocked URL, 그리고 allPoliciesAllow에 전달되는 URL로 일괄 사용됩니다. 새로 추가된 3개의 WPT 테스트는 URL이 없는 <object>/<embed>object-src 'none'object-src; 양쪽 모두에서 차단되는지 검증합니다.

Source-list 매칭의 빈-URL fast path에서 두 가지 CSP 상태('none' 키워드 vs. 빈 source list)를 혼동한 결과, 빈 source list가 deny-all이 아니라 허용 쪽으로 동작하게 된 패턴.

CSP가 통제하는 대상. Content Security Policy는 HTTP 헤더나 <meta http-equiv>로 전달되는 정책으로, 문서가 리소스 타입별로 로드 가능한 출처를 제한할 수 있게 해줍니다. object-src<object>/<embed> 플러그인 로드를 관장하며, allowObjectFromSource()가 WebKit에서 이 로드를 통제하는 게이트입니다. 이 함수는 HTMLPlugInElement, CachedResourceLoader, PolicyChecker에서 호출됩니다.

Source list와 두 가지 "빈" 표기. Source list는 directive의 우변에 해당합니다. 'self', 'none' 같은 토큰이나 scheme/host 표현식을 가질 수도 있고, 비어 있을 수도 있습니다. CSP Level 3는 빈 source list가 어떤 URL과도 매치되지 않아 'none'과 동일하게 동작해야 한다고 규정합니다. WebKit은 source list를 ContentSecurityPolicySourceList로 모델링하며, isNone()은 리터럴 'none' 토큰일 때만 true를 반환하고 빈 list에 대해서는 false를 반환합니다.

연관 URL이 없는 플러그인 element. <object><embed>는 연관된 URL 없이도 플러그인을 인스턴스화할 수 있습니다. datasrc도 없는 경우, type attribute만으로 플러그인이 선택됩니다. 이 케이스 때문에 초기 CSP draft에는 §6.1.9라는 special clause가 있었는데, 'none'이 명시되지 않는 한 URL 없는 이런 플러그인을 허용하는 조항이었습니다. 하지만 더 간결해진 Level 3 모델에서는 이 조항이 더 이상 필요하지 않습니다.

Root cause는 source-list 매칭의 빈-URL fast path에 있는 로직 오류입니다. 패치 이전, ContentSecurityPolicySourceListDirective::allows()는 빈 URL에 대해 shouldAllowEmptyURLIfSourceListEmpty == Yes && !m_sourceList.isNone()라는 predicate로 short-circuit했는데, 이는 지금은 제거된 주석이 언급한 §6.1.9 special case — URL 없는 플러그인은 'none'에 의해서만 차단되고 그 외에는 허용된다 — 를 구현한 것입니다.

이 predicate는 서로 다른 두 CSP 상태를 혼동합니다. m_sourceList.isNone()은 리터럴 'none' 토큰일 때만 true이므로, 빈 source list는 이를 false로 평가하고 predicate는 Yes && !false = true가 됩니다.

  Policy                 isNone()   empty-URL fast path      Spec says
  ────────────────────   ────────   ─────────────────────    ─────────
  object-src 'none';      true      Yes && !true  = false     block  ✓
  object-src;             false     Yes && !false = true      block  ✗ allowed
  object-src 'self';      false     Yes && !false = true      allow  ✓

가운데 행이 버그입니다. object-src; 정책이 있어도, data/src가 없는 <object>/<embed>가 기본 플러그인을 로드할 수 있었습니다. 작성자가 모든 object source를 금지하려고 정책을 작성했음에도 그랬습니다. isNone()이 리터럴 토큰일 때만 true라는 사실은 이 버그의 핵심 메커니즘이며, 제거된 predicate 자체의 semantics에서 도출된 것입니다. ContentSecurityPolicySourceList의 구현 자체는 제공된 context에는 포함되어 있지 않습니다.

패치는 이 special case를 매처에서 완전히 걷어내고, 처리를 한 단계 위로 옮깁니다. allows()가 빈 URL에 대해 항상 false를 반환하게 되면서, allowObjectFromSource()는 문서 자체의 protected URL을 대신 사용합니다. 이렇게 치환된 URL은 이후 일반적인 매처를 그대로 통과합니다. 'none'이나 빈 list와는 매치되지 않아 차단되고(둘 다 아무것도 매치하지 않으므로), 'self'나 wildcard와는 매치되어 허용됩니다. 결과적으로 별도의 전용 branch 없이도 §6.1.9가 의도했던 동작이 permissive 정책에 대해 재현됩니다.

Exploitation은 메모리 안전성 문제가 아니라 정책 우회에 해당합니다. Content-Security-Policy: object-src;로 보호된 페이지에서, HTML injection sink를 가진 공격자가 data/src attribute 없는 <object type="..."></object><embed type="...">를 주입합니다. 패치 이전에는 빈-URL 경로가 true를 반환했고 allowObjectFromSource()가 인스턴스화를 허용했으므로, deny-all 정책에도 불구하고 type attribute가 선택한 플러그인이 실행될 수 있었습니다. 그 결과 WebContent process 내에서 후속 exploit이 이용할 수 있는 플러그인 attack surface가 넓어집니다. Sandbox escape는 아닙니다. CSP로 보호된 페이지에 기존 injection vector가 존재해야 하고, 그 페이지 정책 하에서 어떤 플러그인이 인스턴스화될 수 있는지만 완화되기 때문입니다.

발견 경위는 WPT conformance 작업에 가까워 보입니다. 새 테스트들은 imported/w3c/web-platform-tests/content-security-policy/object-src/ 아래에 추가되었고, CSP Level 3 §6.7.2.7이 명시적으로 요구하는 동작을 검증합니다. 이는 upstream WPT를 import하다가 WebKit의 실패를 발견했거나, 리뷰어가 §6.1.9 주석이 이미 대체된 editor's draft를 참조하고 있다는 점을 알아챘거나 둘 중 하나로 볼 수 있습니다. Level 3 §6.7.2.7 인용 자체는 commit message에서 그대로 가져온 내용입니다.

이 취약점은 CSP object-src defense-in-depth 경계를 약화시킵니다. 스펙은 object-src;object-src 'none'이 상호 교환 가능하다고 전제합니다. 둘 다 모든 플러그인 인스턴스화를 금지해야 하는데, 패치 이전에는 URL 없는 플러그인 경로에서 이 불변식이 깨져 있었습니다. 이는 object/embed 기반 exploitation을 무력화하기 위해 종종 배포되는 mitigation을 무너뜨리는 결과로 이어집니다.

Insight: 이 버그는 일종의 화석입니다. CSP Level 1/2에는 URL 없는 플러그인 element를 위한 리터럴 §6.1.9 special case가 있었는데, 이는 빈 source list를 균일하게 deny-all로 처리하는 더 간결한 Level 3 모델이 등장하기 이전의 것입니다. 이 legacy case를 매처 안으로 그대로 끌고 들어온 결과, 의미상 동등한 두 정책이 서로 다른 결정을 내리는 상태가 만들어졌습니다. 패치가 택한 전략 — 리소스 URL이 비어 있을 때 문서 자체의 URL로 치환하는 방식 — 은 참고할 가치가 있습니다. 별도의 전용 로직 없이도 동일한 매처가 URL 없는 케이스를 처리할 수 있게 되는데, 문서 URL이 'none'이나 빈 list와는 자연스럽게 매치되지 않으면서도 'self'나 wildcard와는 매치되기 때문입니다.