← 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/ 하위에 위치하지 않습니다.

🔒

The decode-then-compare ordering at the heart of this bug is one of the oldest anti-patterns in web security — the analysis traces exactly which URLs slipped through and why CSP3's spec deliberately reverses the order.

더 확인하려면 구독해 주세요

🔒

Four reusable audit directions for percent-encoded canonicalisation bypasses across WebKit policy layers, with specific starting points beyond CSP path matching.

더 확인하려면 구독해 주세요