← All issues

[5] CSP path matching bypassed via percent-encoded slashes

Severity: Medium | Component: WebCore CSP path matching | 9a19d07

diff가 수정하는 내용은 허용된 origin에서 별도의 URL injection primitive와 결합 시 매번 재현 가능한 CSP 경로 제한 우회입니다. 영향 범위는 policy bypass에 국한되며, memory corruption은 아닙니다. 이 때문에 Medium으로 평가합니다.

WebKit의 pathMatches()는 CSP3 명세와 달리 동작했습니다. URL path 전체를 단일 flat string으로 percent-decode한 뒤 prefix/equality 비교를 수행하는 방식이었으며, 이 구조는 %2F..%2F를 이용한 path traversal 우회에 취약했습니다. 수정된 구현은 명세의 알고리즘(§6.7.2.12)을 채택합니다. 두 경로를 모두 literal /로 분리하고, 각 segment를 개별적으로 percent-decode한 뒤 대응하는 쌍을 비교합니다.

Source/WebCore/page/csp/ContentSecurityPolicySource.cpp

bool ContentSecurityPolicySource::pathMatches(const URL& url) const
{
+ // https://www.w3.org/TR/CSP3/#match-paths
if (m_path.isEmpty())
return true;
 
- auto path = PAL::decodeURLEscapeSequences(url.path());
+ auto urlPath = url.path();
+
+ if (m_path == "/"_s && urlPath.isEmpty())
+ return true;
+
+ bool exactMatch = !m_path.endsWith('/');
+ auto pathListA = m_path.splitAllowingEmptyEntries('/');
+ auto pathListB = urlPath.toString().splitAllowingEmptyEntries('/');
+
+ if (pathListA.size() > pathListB.size())
+ return false;
+ if (exactMatch && pathListA.size() != pathListB.size())
+ return false;
+ if (!exactMatch) {
+ ASSERT(pathListA.last().isEmpty());
+ pathListA.removeLast();
+ }
 
- if (m_path.endsWith('/'))
- return path.startsWith(m_path);
+ for (unsigned i = 0; i < pathListA.size(); ++i) {
+ if (PAL::decodeURLEscapeSequences(pathListA[i]) != PAL::decodeURLEscapeSequences(pathListB[i]))
+ return false;
+ }
- return path == m_path;
+ return true;
}

Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp

- return PAL::decodeURLEscapeSequences(begin.first(buffer.position() - begin.data()));
+ return String(begin.first(buffer.position() - begin.data()));

기존 구현은 URL path 전체를 하나의 flat string으로 percent-decode한 뒤 m_path에 대해 startsWith/equality 비교를 수행했습니다. 새 구현은 m_pathurl.path() 모두를 splitAllowingEmptyEntries('/')를 통해 literal / 기준으로 분리합니다. 이후 명세의 1~7단계를 적용하는데, 빈 path fast path, / vs empty 처리, directory/exact match 판별, segment 개수 비교, 후행 빈 segment 제거가 이에 해당합니다. 비교 직전에만 각 segment를 개별적으로 percent-decode합니다. 보완적으로, parsePath()는 더 이상 parse 시점에 directive의 path를 percent-decode하지 않습니다. m_path는 이제 raw %2F 시퀀스를 그대로 유지하므로, segment 분리가 의미 있게 동작합니다.

Path matcher에서의 decode-before-tokenise 순서 문제: URL path 전체를 '/'로 분리하기 전에 percent-decode하면, 인코딩된 슬래시(%2F)가 access-control policy가 정의하는 구조적 segment 경계를 넘어설 수 있습니다.

CSP directive는 source expression 목록을 포함하며, 각 expression은 scheme, host, port, 그리고 선택적 path 구성요소를 갖습니다. URL fetching 과정에서는 ContentSecurityPolicySource::matches()가 호출되어 scheme, host, port, path를 순서대로 검사합니다. CSP3 §6.7.2.12는 path matching을 segment 단위 연산으로 정의합니다. 두 경로를 모두 literal /로 분리하고 각 segment 쌍을 percent-decode한 뒤 비교하는 방식입니다. /로 끝나는 directive는 directory match, 그렇지 않으면 exact match로 구분합니다.

%2F/의 percent-encoding입니다. URL 표준은 path 구성요소에서 %2F를 구조적 구분자로 해석하지 않고 그대로 유지합니다. 따라서 /a%2Fb/c는 세 개가 아닌 두 개의 segment(a%2Fbc)를 갖습니다. 반면 PAL::decodeURLEscapeSequences는 segment 경계를 인식하지 못하고 %2F를 그대로 literal /로 변환합니다.

pathMatches()는 URL path를 전체적으로 percent-decode한 상태에서 비교를 수행했습니다. PAL::decodeURLEscapeSequences(url.path())%2F를 literal /로 변환합니다. 예를 들어 공격자가 http://host/security/contentSecurityPolicy%2F..%2F..%2Fresources/script.js와 같은 URL을 사용하면, 이는 /security/contentSecurityPolicy/../../resources/script.js로 decode됩니다. decode된 문자열은 /security/contentSecurityPolicy/로 시작하므로 startsWith(m_path)를 통과합니다. 그러나 CSP3의 segment 단위 정의에 따르면, URL의 실제 path segment는 단일 literal blob인 contentSecurityPolicy%2F..%2F..%2Fresources이며, 이는 /security/contentSecurityPolicy/resources/ 하위에 위치하지 않습니다.

페이지가 fetch하는 URL 문자열을 공격자가 제어할 수 있는 상황을 가정합니다. 예를 들어 <script src=...> 속성을 작성할 수 있는 HTML injection primitive, 허용된 origin의 open redirect, server-side reflection sink 등이 이에 해당합니다. 이런 조건에서 공격자는 경로를 교묘하게 조작할 수 있습니다. 첫 번째 un-encoded segment를 policy의 허용 prefix와 일치시키면서, 이후 encoded-slash 시퀀스로 실제로는 다른 유효 경로를 삽입하는 방식입니다.

7개의 테스트 케이스가 구체적인 재현 방법을 제시합니다. script-src host/security/contentSecurityPolicy/resources/ directive가 적용된 상태에서, host/security/contentSecurityPolicy/resources%2F..%2F..%2Fresources/script.js URL은 패치 이전에 허용되었습니다. decode된 문자열이 startsWith를 통과했기 때문입니다. 실제 두 번째 segment는 literal resources%2F..%2F..%2Fresources이며, 이는 resources와 동일하지 않습니다.

이 vulnerability는 CSP 경로 제한의 신뢰 경계를 약화시켰습니다. CSP3는 script-src https://example.com/safe/와 같은 directive가 /safe/ 하위 경로의 script 로드만을 허용한다고 가정합니다. 패치 이전에는 open redirect, reflected src injection, 기타 XSS sink를 통해 URL에 영향을 미칠 수 있는 공격자가 이 취약점을 활용할 수 있었습니다. %2F..%2F를 이용하면 directory 제한을 벗어나 같은 origin의 임의 위치에서 script를 로드하는 것이 가능했습니다. CSP path matching은 첫 번째 방어선이 아니라 두 번째 방어선입니다. 공격자는 여전히 directive를 통한 origin 도달 가능성과, URL 문자열을 삽입할 수 있는 primitive가 필요합니다. decode-then-compare anti-pattern은 URL parser, 웹 서버, proxy에서 20년에 걸쳐 우회 사례를 반복적으로 낳아 왔습니다. CSP3 명세가 segment-then-decode 순서를 유독 명시적으로 규정하는 이유는, 반대 순서가 대부분의 개발자가 직관적으로 선택하는 구현 방식이기 때문입니다.