CSP 정책은 원래 conjunctive하게 설계되어 있습니다. 두 개의 policy header를 받은 문서는 두 정책을 모두 만족시켜야 하며, 결과적으로 실효 제약은 항상 더 엄격한 쪽을 따르게 됩니다. blob:, data:, srcdoc, about:blank처럼 네트워크를 거치지 않는 문서는 자체 header가 없기 때문에, WebKit은 creator의 정책을 스냅샷으로 저장한 뒤 이를 합성된 response에 재생하여 새 문서가 마치 실제로 전달받은 것처럼 파싱하도록 합니다. 이 재생 과정에서 정책의 개수가 그대로 유지되어야 하는데, ContentSecurityPolicyResponseHeaders::addPolicyHeadersTo()는 N개의 정책 목록을 header map에 한 번에 하나씩 대입하는 방식으로 작성하고 있었습니다.
관전 포인트: 엄격한 baseline 정책 아래에 더 느슨한 정책을 겹쳐놓은 사이트에서는, 같은 origin의 blob 문서에서 실행되는 script가 느슨한 정책의 통제만 받게 됩니다. 부모 페이지가 차단하는 inline script가 자식 문서에서는 실행되는 것입니다.
Source/WebCore/page/csp/ContentSecurityPolicyResponseHeaders.cpp
LayoutTests/http/tests/security/contentSecurityPolicy/resources/echo-multiple-csp-blob-iframe.py
LayoutTests/http/tests/security/contentSecurityPolicy/resources/create-blob-iframe.js
LayoutTests/imported/w3c/web-platform-tests/trusted-types/inheriting-csp-for-local-schemes-expected.txt
Patch Details
기능적으로 바뀐 부분은 식별자 두 개뿐입니다. addPolicyHeadersTo()는 creator 문서의 policy container에서 캡처된 {policy 문자열, ContentSecurityPolicyHeaderType} 쌍의 vector인 m_headers를 순회하면서, 각 항목을 합성된 ResourceResponse에 기록합니다. switch의 두 분기 모두 ResourceResponse::setHTTPHeaderField()에서 ResourceResponse::addHTTPHeaderField()로 변경되었습니다. Enforce 항목에는 HTTPHeaderName::ContentSecurityPolicy가, Report 항목에는 HTTPHeaderName::ContentSecurityPolicyReportOnly가 사용됩니다. setHTTPHeaderField는 대입 방식이라 해당 header 이름에 이미 있던 값을 덮어씁니다. 반면 addHTTPHeaderField는 기존 값에 쉼표로 이어붙이는 방식으로, 반복된 header는 하나의 쉼표 결합 header와 동등하다는 HTTP 규칙과 일치합니다. CSP parser는 쉼표로 구분된 값을 다시 독립된 정책들로 분리하기 때문에, 이제 이 round trip을 거쳐도 목록이 그대로 보존됩니다.
addPolicyHeadersTo(), m_headers = [P1, P2, P3]
Before (set): After (add):
CSP := P1 CSP := P1
CSP := P2 ← P1 gone CSP := "P1, P2"
CSP := P3 ← P2 gone CSP := "P1, P2, P3"
─────────────────── ───────────────────
child parses {P3} child parses {P1, P2, P3}
나머지 부분은 test scaffolding입니다. echo-multiple-csp-blob-iframe.py는 서로 다른 두 개의 enforced Content-Security-Policy header를 내보내는 CGI로, 순서상 첫 번째는 inline script를 금지하고(script-src 'self') 두 번째는 이를 허용하도록(script-src 'self' 'unsafe-inline') 배치되어 있으며, 둘 다 frame-src blob:을 허용합니다. create-blob-iframe.js는 blob: 문서를 하나 생성하는데, 이 문서의 body에는 실행되면 PASS 문자열을 FAIL로 바꿔 쓰는 inline <script>가 포함되어 있습니다. expected output에는 부모 frame에서의 console 차단 기록이 남아 있는데, 이는 두 정책이 모두 creator에 바인딩된다는 근거이며, 여기에 더해 blob 자식 문서에서는 PASS: Inline script was blocked by CSP가 기록됩니다. trusted-types/inheriting-csp-for-local-schemes에 대한 WPT expectation은 "trusted-types directive should be inherited in local blob frames" 항목이 FAIL에서 PASS로 바뀌는데, 이는 다른 directive를 통해 관찰된 것과 동일한 truncation 문제입니다.
Background
Content Security Policy. CSP는 HTTP header로 전달되는 정책으로, 문서가 로드할 수 있는 리소스와 inline script 또는 eval 실행 여부를 제한합니다. response는 여러 개의 Content-Security-Policy header를 가질 수 있고, 하나의 header 값 자체도 쉼표로 구분된 여러 정책을 담을 수 있습니다. 결과 목록에 있는 모든 정책은 독립적으로 강제됩니다. 즉 로드는 이 정책들을 모두 만족해야 하며, 실효 제약은 이들의 교집합이 됩니다.
Report-only 정책. Content-Security-Policy-Report-Only는 평가되고 보고되지만 절대 차단하지는 않는 정책을 선언합니다. 이는 enforced 정책과 별도로 관리되는데, 그래서 ContentSecurityPolicyHeaderType이 Enforce와 Report를 구분하고, diff의 switch문이 서로 다른 header 이름을 기록하는 두 분기를 갖는 것입니다.
Policy container와 local scheme 상속. blob:, data:, about:blank, srcdoc처럼 자체 network fetch 없이 생성된 문서는 CSP를 포함한 보안 컨텍스트를 자신을 만든 문서로부터 상속받습니다. WebKit은 이를 구현하기 위해 creator의 CSP header를 ContentSecurityPolicyResponseHeaders value object에 스냅샷으로 저장하고, 이를 새 문서를 위해 합성한 ResourceResponse에 재생합니다. 새 문서는 이 response를 실제 response와 똑같이 파싱합니다. 상속받는 문서와 creator는 구조상 항상 same-origin입니다.
ContentSecurityPolicyResponseHeaders. 이 value object는 m_headers(policy 문자열/타입 쌍의 vector)와 원본 HTTP status code를 갖습니다. worker로의 cross-thread 전달을 위한 isolatedCopy()와, response에 재생하기 위한 addPolicyHeadersTo()를 제공합니다.
ResourceResponse header API. setHTTPHeaderField(name, value)는 대입 방식이라 해당 이름의 이전 값을 버립니다. addHTTPHeaderField(name, value)는 쉼표로 이어붙이며 추가합니다. 이 둘이 따로 존재하는 이유는 HTTP header field가 다중 값을 가질 수 있는 것과 그렇지 않은 것이 함께 있기 때문입니다.
Blob URL. URL.createObjectURL(new Blob([html], { type: "text/html" }))는 same-origin blob: URL을 생성합니다. iframe을 이 URL로 이동시키면 creator의 policy container를 상속받는 문서가 만들어집니다. trusted-types와 require-trusted-types-for는 DOM injection sink에 대한 할당을 제약하는 일반적인 CSP directive로, 다른 directive와 마찬가지로 이를 선언한 정책에 종속됩니다.
Analysis
이 문제는 policy engine의 결함이 아니라 직렬화 단계의 결함입니다. ContentSecurityPolicyResponseHeaders는 내부의 모든 단계에서 CSP의 multi-policy semantics를 정확하게 모델링하고 있습니다. m_headers는 vector이고, constructor도 이를 올바르게 구성하며, isolatedCopy()도 이를 보존하고, enforcement 경로도 전체를 순회합니다. cardinality 손실은 정확히 한 지점, 즉 이 목록이 last-write-wins 방식의 setter를 가진 header map으로 평탄화되는 순간에만 발생합니다.
Creator document (2 CSP headers) blob: child document
──────────────────────────────── ────────────────────────
P1: script-src 'self' ┐
P2: script-src 'self' ├─► m_headers = [P1, P2]
'unsafe-inline' ┘ │
│ addPolicyHeadersTo()
enforce(P1 ∧ P2) │ set → CSP = P2 only
inline script BLOCKED ✔ ▼
enforce(P2)
inline script RUNS ✘
왼쪽 열은 creator입니다. 두 정책이 모두 바인딩되고, enforcement가 conjunctive하기 때문에 P1의 script-src 'self'가 P2라면 허용했을 inline script를 거부합니다. expected-output 파일에는 이 거부가 부모 frame의 console 메시지로 기록되어 있습니다. 오른쪽 열은 실제 루프가 만들어낸 결과입니다. 매 iteration마다 이전 값 위에 대입이 이루어졌기 때문에, 합성된 response가 CSP parser에 도달할 즈음에는 값이 하나만 남아 있었습니다. 마지막 Enforce 항목 하나, 그리고 별도로 마지막 Report 항목 하나입니다. 자식 문서는 {P2}, 즉 명백히 더 약한 집합을 파싱하게 되었고, blob 문서 안의 inline <script>가 실행되었습니다.
여기서 방향성에 주목할 필요가 있습니다. 이것이 누가 영향을 받는지를 결정하기 때문입니다. last-value로의 truncation은 균일하게 약화되는 것이 아니라, header 순서에 전적으로 좌우되는 방식으로 약화됩니다. 엄격한 baseline 뒤에 기능별로 더 느슨한 정책을 배치하는 사이트는 blob 문서 안에서 baseline을 잃게 됩니다. 같은 사이트라도 header 순서를 반대로 하면 아무것도 잃지 않습니다. 따라서 정책 집합이 동일한 두 배포가 노출 정도에서는 완전히 달라질 수 있으며, 이것이 이 버그가 오래 살아남은 이유이기도 합니다. 압도적으로 흔한 단일 정책 케이스에서는, 한 번 대입하는 루프와 한 번 append하는 루프가 구분되지 않기 때문입니다.
같은 클래스의 문제를 ingestion 쪽과 비교해볼 필요가 있습니다. constructor는 header 이름별로 httpHeaderField()를 한 번씩 읽고 m_headers 항목을 하나씩 append하는데, 이는 network layer가 WebCore에 전달하기 전에 이미 반복된 header들을 하나의 값으로 쉼표 결합해두었다는 전제에 의존합니다.
// ContentSecurityPolicyResponseHeaders::ContentSecurityPolicyResponseHeaders(const ResourceResponse& response)
String policyValue = response.httpHeaderField(HTTPHeaderName::ContentSecurityPolicy);
if (!policyValue.isEmpty())
m_headers.append({ policyValue, ContentSecurityPolicyHeaderType::Enforce });
Ingestion이 올바르게 동작했던 이유는 이미 상위 단계에서 이루어진 join에 기대고 있었기 때문입니다. 반면 egress는 스스로 그 join을 수행한 적이 없어서 잘못되었습니다. 이 비대칭이 버그의 전부입니다. 코드는 header map의 슬롯을 내보낼 때는 단일 문자열이라고 가정했지만, 실제로 직렬화 대상이 되는 포맷은 그 슬롯을 목록으로 취급합니다.
이번 fix는 상속받는 컨텍스트의 실효 정책이 creator의 정책과 같아야 한다는 invariant를 복원합니다. addHTTPHeaderField()는 각 정책을 누적되는 값에 쉼표로 이어 붙이고, CSP parser가 쉼표로 구분된 header 값을 다시 독립된 정책들로 분리하기 때문에 {P1, P2}가 round trip을 거쳐도 그대로 보존됩니다. 이 fix가 막는 실질적인 노출은 새로운 primitive라기보다는 defense-in-depth의 약화 쪽에 가깝습니다. attacker는 이미 same-origin script foothold나 DOM injection foothold를 확보한 상태에서 blob 문서를 생성할 수 있는 경로가 필요하며, 이를 통해 얻는 이득은 injected script가 origin의 실제 정책 집합보다 상당히 약해진 script-src / frame-src / trusted-types 제약 아래에서 실행된다는 점입니다. memory-safety 요소는 없고, read/write primitive도 없으며, process 경계를 넘는 동작도 없습니다. blob 문서는 구조상 creator와 same-origin이므로, XSS-containment 손실을 넘어서는 확장을 위해서는 별도의 버그가 필요합니다.
conjunctive한 정책 목록이 last-write-wins 방식의 header setter를 거쳐 직렬화되었고, 그 결과 blob 문서는 마지막 CSP 정책만 상속받게 되었습니다. 이것이 사이트를 약화시키는지 여부는 순전히 header 순서에 달려 있었습니다.
Insight
보안 정책이 conjunctive하고 multi-valued한 성격을 갖는다면, 이를 single-valued 표현으로 round trip시키는 모든 지점은 가장 제한적인 구성원을 조용히 잃어버릴 위험을 안고 있습니다. policy engine이 아니라 각 seam에서의 append/assign 선택을 점검해야 합니다.
Audit directions
-
직렬화 루프 안의 replace 방식 setter. invariant는 정책 목록을 직렬화할 때 cardinality가 보존되어야 한다는 것입니다. 따라서 루프 안에서 호출되는 writer는 대입이 아니라 append 방식이어야 합니다. Narrow: WebCore에서
for/range-for 본문 안에 있는setHTTPHeaderField(를 검색하고, 특히 상속받은 보안 상태를ResourceResponse에 재생하는 다른 합성 response builder들을 점검해야 합니다.data:,srcdoc,about:blank문서를 위한 policy-container replay와,ContentSecurityPolicyResponseHeaders의 workerisolatedCopy()경로가 그 대상입니다. Wider: 같은 형태는 목록 타입의 보안 속성이 이름으로 keyed된 map에 저장되는 모든 곳에서 나타날 수 있습니다. sandbox flag 직렬화,Permissions-Policy/Feature-Policy조립,Clear-Site-Data, COEP/COOP reporting endpoint 등이 해당됩니다. 검색 결과에서 눈여겨봐야 할 신호는, 루프 본문의 유일한 문장이 대입 semantics를 가진 keyed container에 값을 쓰는 형태라는 점입니다. Widest: 이는 "multi-valued header가 scalar setter에 의해 붕괴되는" 일반적인 클래스로,set과add를 모두 노출하는 어떤 HTTP 스택에서도 나타날 수 있습니다. Chromium의net::HttpResponseHeaders, Go의http.Header.Setvs.Add, Node의 반복된Set-Cookie에 대한res.setHeader, Rust의HeaderMap::insertvsappend가 그 예입니다. 여기서도 이어지는 신호는, policy 목록이나 credential 목록을 순회하는 루프의 본문이 append 대신 insert/replace 계열 함수를 호출하고 있는지 여부입니다. -
Ingestion 단계에서 나타나는 대칭적 결함. Constructor는
httpHeaderField(HTTPHeaderName::ContentSecurityPolicy)를 단 한 번만 읽어m_headers에 항목 하나만 추가합니다. 이 동작은 네트워크 계층이 반복되는 header를 이미 comma로 join해 두었다는 전제에 의존합니다. WebCore가 CSP를 파싱하는ResourceResponse생성 경로를 모두 점검할 필요가 있습니다. Service worker가 합성한 response,FetchResponse, intercepted response 경로, 그리고 curl/soup/CFNetwork의 platform response adapter가 대상입니다. 각 경로가 중복된 CSP header를 첫 번째나 마지막 값만 취하지 않고 실제로 join하는지 확인해야 합니다. 의심할 만한 지점은 raw header line을 순회하며 set 방식 API를 호출하는 response 생성 경로, 또는 join 단계 없이 header를 단순HashMap<String, String>에 저장하는 platform header adapter입니다. Adapter 코드는 정적으로 검토할 수 있지만, port별 end-to-end 동작을 확인하려면echo-multiple-csp-blob-iframe.py형태의 header 2개짜리 테스트가 필요합니다. -
상속 과정에서 약화되는 security state. 상속받는 context의 유효 정책은 생성자의 정책과 같거나 더 엄격해야 하며, 절대 약해져서는 안 된다는 것이 invariant입니다. 문제는 parent만 검증하는 테스트는 통과하면서도 derived context는 무방비 상태로 남을 수 있다는 점입니다. Narrow: 새로 추가된
blob-url-inherits-multiple-csp-policies.html형태의 테스트를 다른 local scheme으로 확장할 필요가 있습니다.data:,srcdoc,about:blank,javascript:navigation이 대상이며, report-only 정책까지 포함해 non-final policy가 담고 있는 directive가 child에서도 실제로 적용되는지 검증해야 합니다. Wider: 동일한 differential-inheritance 테스트를 policy container의 나머지 요소에도 적용할 필요가 있습니다. sandbox flag, referrer policy, COEP/COOP, opener 관계가 해당되며, 다중 정책 document에서 생성된 Worker와 Worklet도 포함됩니다. 이 경우isolatedCopy()가 한 단계를 더 거치게 되는데, 이 단계에서도 정책 개수와 순서가 그대로 보존되어야 합니다. Widest: 재사용 가능한 원칙은 상속되는 capability 시스템이라면 "origin context가 아니라 derived context를 테스트하라"는 것입니다. 다른 엔진의 iframe sandbox, 컨테이너 이미지의 capability 상속, OS process token 상속 모두 같은 원칙이 적용됩니다. 의심할 만한 지점은 origin에서 한 번 조립된 정책이 origin이 사용하는 경로와는 다른 code path를 통해 child로 복사되는 경우입니다. 이렇게 경로가 갈라지는 지점에서 정책의 개수와 순서가 유실되곤 합니다. -
CSP 처리 곳곳에 남아 있는 순서 의존성. 이번 취약점의 exploitability는 전적으로 어떤 정책이 마지막에 적용되는가에 달려 있었습니다. 따라서
m_headers를 구성하는 경로와 parser의 list 구성 로직을 추적해, 모든 단계에서 enforcement가 실제로 순서에 무관하게 이루어지는지 확인할 필요가 있습니다. 이어서 report-only 정책이 끼어드는 경우도 점검해야 합니다. 두 개의 enforced 정책 사이에 선언된 report-only 정책이 양쪽 어느 쪽의 enforcement에도 영향을 주어서는 안 됩니다. 의심할 만한 지점은 정책 list를 순회하지 않고m_headers.last(),[0],first()처럼 위치 기반으로 접근하는 코드입니다. Conjunctive set에 대한 위치 기반 접근은 거의 항상 truncation bug가 다른 모습으로 나타난 것에 불과합니다.