← All reports

[1] CSS @function substitution drops the CustomProperty backing its own tokens

HighWebCore StyleUAF

A stylesheet and one getComputedStyle() call read freed token storage.

4b97014

High. 일반 stylesheet와 getComputedStyle() 호출 한 번만으로 도달 가능한 style-resolution 경로에서, 해제된 heap으로부터 token payload를 읽어들입니다. Crash를 넘어서는 확장 여부는, resolver가 token을 다시 capture하기 전에 해제된 string slab을 공격자가 재점유할 수 있는지에 달려 있습니다.

CSS custom-property 값은 style 계산 과정에서 문자열이 아니라 token vector로 저장됩니다. 각 token은 다른 객체가 소유한 string storage에 대한 view만 갖습니다. Style resolution은 중첩된 substitution(var(), attr(), 그리고 CSS Mixins에서 새로 도입된 dashed @function 형태)을 하나의 평탄한 token buffer로 펼친 뒤, 이 buffer를 CSSVariableData::create()에 전달합니다. 이 함수는 token 데이터를 자신의 storage로 복사합니다. 이 구조가 안전하게 동작하려면, buffer에 token이 섞여 들어간 모든 객체가 이 최종 재capture 시점까지 살아있어야 한다는 invariant가 지켜져야 합니다.

관전 포인트: CSS @function을 정의하고 이를 이용해 property 값을 읽어오는 페이지는, 대상 property가 style resolution될 때마다 style resolver가 해제된 token storage를 읽게 만듭니다.

substitute()가 새로운 CSSVariableData를 생성할 때까지 resolve된 CustomPropertym_intermediateCustomProperties에 보관하여 살려둡니다. 이는 attr() tokenizer 출력에 이미 쓰이고 있던 m_intermediateTokenStrings 메커니즘과 동일한 방식입니다.

Source/WebCore/style/StyleSubstitutionResolver.cpp

@@ substituteDashedFunction
if (guard.isCyclicContext())
return false;
 
+ // Tokens reference resolvedResult's string backing; keep it alive until CSSVariableData re-captures.
tokens.appendVector(resolvedResult->tokens());
+ m_intermediateCustomProperties.append(WTF::move(resolvedResult));
return true;
}
 
@@ substitute
auto substitutedTokens = substituteTokenRange(value.m_data->tokenRange(), context);
if (!substitutedTokens) {
m_intermediateTokenStrings.clear();
+ m_intermediateCustomProperties.clear();
return nullptr;
}
 
auto data = CSSVariableData::create(*substitutedTokens, m_isAttrTainted ? IsAttrTainted::Yes : IsAttrTainted::No, context);
m_intermediateTokenStrings.clear();
+ m_intermediateCustomProperties.clear();
return data;

Source/WebCore/style/StyleSubstitutionResolver.h

Vector<String> m_intermediateTokenStrings;
+ Vector<RefPtr<const CustomProperty>> m_intermediateCustomProperties;

LayoutTests/fast/css/variables/dashed-function-result-token-lifetime.html

+@function --f(--a) {
+ result: aaaa var(--a);
+}
+#target { --x: --f(bbbb); }
+var v = getComputedStyle(target).getPropertyValue('--x');

Header에는 기존 vector와 나란히 두 번째 lifetime-anchor vector가 추가되었습니다: Vector<RefPtr<const CustomProperty>> m_intermediateCustomProperties입니다. SubstitutionResolver::substituteDashedFunction에서는, tokens.appendVector(resolvedResult->tokens()) 직후에 지역 변수 RefPtr<const CustomProperty> resolvedResult를 함수 반환 시점에 소멸하도록 두지 않고 이 vector로 move하도록 변경되었습니다. SubstitutionResolver::substitute에서는, 기존에 m_intermediateTokenStrings를 clear하던 두 지점 모두에서 새 vector도 함께 clear됩니다. 하나는 substituteTokenRange가 값을 반환하지 못하는 실패 경로이고, 다른 하나는 CSSVariableData::create(*substitutedTokens, ...)가 반환된 이후의 성공 경로입니다. Regression LayoutTest는 @function --f(--a) { result: aaaa var(--a); }를 정의하고, 대상 요소에 --x: --f(bbbb)를 적용한 뒤 computed value를 다시 읽어들여 이 경로를 강제로 타게 합니다.

Re-entrancy 경계가 아니라 substitution 단계 사이에서, lifetime anchor가 누락되어 non-owning token view가 자신의 backing object보다 더 오래 살아남는 패턴입니다.

이 코드가 있는 위치. Style::SubstitutionResolver는 style 계산 중 임의의 substitution function을 resolve하는 역할을 담당합니다. CSSSubstitutionValue로부터 token range를 순회하면서 중첩된 var(), attr(), dashed-function 호출을 재귀적으로 펼치고, style builder가 소비할 최종 CSSVariableData를 만들어냅니다.

CSS Mixins와 dashed function. CSS Mixins는 @function --name(--arg) { result: <tokens>; } 형태의 CSS 레벨 함수 정의인 @function을 도입합니다. 이 함수는 custom-property 값 안에서 dashed-function 참조로 호출되며, resolver는 함수 body를 평가한 뒤 그 결과 token을 호출자의 buffer에 섞어 넣는 방식으로 호출을 펼칩니다.

CSSParserToken과 그 backing storage. CSSParserToken은 값 타입의 token으로, 외부에서 소유하는 backing string에 대한 non-owning view를 저장합니다. 이 backing string은 대체로 상위 CustomProperty나 parser가 할당한 buffer가 소유하는 StringImpl payload입니다. Style::CustomProperty는 파싱되고 tokenize된 custom-property 값을 담는 reference-counted holder입니다. CustomProperty::tokens()는 자신의 token들을 반환하지만, 그 token들이 가리키는 string storage의 소유권은 CustomProperty 자신에게 있습니다.

재capture 경계로서의 CSSVariableData::create. CSSVariableData는 unparsed CSS 값을 담는 storage 객체입니다. Token vector로부터 이를 생성하는 과정에서 token 데이터가 새 객체 자신의 storage로 복사되며, 이 때문에 이 지점을 지나면 상위 owner들이 더 이상 살아있을 필요가 없어집니다.

m_intermediateTokenStrings. Resolver는 이미 하나의 lifetime-anchor vector를 보유하고 있었습니다. m_intermediateTokenStringsattr() tokenization 과정에서 생성되는 임시 문자열을 보관하여, 최종 CSSVariableData::create가 데이터를 재capture할 때까지 해당 token backing을 살려둡니다. 이런 anchor idiom이 존재하는 이유는, resolver의 token buffer가 재귀적인 expansion 과정에서 여러 개의 독립적으로 소유된 소스로부터 view를 누적하기 때문입니다. 전체 expansion 범위를 자연스럽게 커버하는 단일 owner가 존재하지 않는 구조입니다.

이 버그는 특정 expansion 경로에서 anchor가 누락되어 발생하는, StringImpl 기반 token payload의 heap use-after-free입니다.

  substituteDashedFunction()            substitute()
  ────────────────────────────          ────────────────────────────
  resolvedResult = resolve(fn)
    └─ CustomProperty (refcount 1)
         └─ StringImpl backing

  tokens.appendVector(
      resolvedResult->tokens())
    └─ views into that backing ──────┐
                                     │
  return  → resolvedResult dtor      │
    └─ refcount 0 → backing freed    │
                                     │
                                     └──► CSSVariableData::create(
                                              *substitutedTokens)
                                              reads freed backing  ← UAF

위 흐름에서 substituteDashedFunctionresolvedResult가 소유한 storage에 대한 view인 token들을 append한 뒤 반환합니다. 이 시점에 지역 변수인 RefPtr<const CustomProperty>가 소멸합니다. 만약 이것이 마지막 reference였다면, resolver의 커져가는 tokens buffer가 여전히 이 backing string들을 가리키고 있는 상태에서 해당 문자열들이 해제됩니다. 이렇게 dangling 상태가 된 view들은 이후 SubstitutionResolver::substitute 안의 CSSVariableData::create(*substitutedTokens, ...)로 전달되어 처리됩니다. 이 함수는 이미 존재하지 않는 storage로부터 token 데이터를 재capture하게 되는데, 바로 이것이 ASAN이 보고한 heap-use-after-free입니다.

Regression test는 동시에 가장 짧은 도달 가능성 증명이기도 합니다. Web content 관점에서 세 단계면 충분합니다.

  1. var() 참조를 포함하는 body를 가진 @function --f(--a) { result: aaaa var(--a); }를 stylesheet에 정의합니다. Body 자체가 resolution을 필요로 하기 때문에, 새로운 CustomProperty가 생성됩니다.
  2. 대상 요소에서 dashed-function 호출을 통해 custom property를 설정합니다: --x: --f(bbbb);.
  3. getComputedStyle(target).getPropertyValue('--x')로 computed value를 요청하여, SubstitutionResolver::substitutesubstituteDashedFunction 경로를 강제로 타게 합니다.

즉시 관찰되는 영향은 CSSVariableData::create가 token 데이터를 재capture하는 순간 발생하는 UAF read입니다. Instrumentation이 켜진 환경에서는 ASAN abort로, 그렇지 않은 환경에서는 항상 동일하게 재현되는 renderer crash로 나타납니다. Crash를 넘어선 weaponization을 시도한다면, 공격자는 resolvedResult가 drop되는 시점과 CSSVariableData::create 호출 사이의 window에서 해제된 string-backing slab을 재점유하려 할 것입니다. 예를 들어 같은 substitution chain 안에서 추가적인 CSS 문자열 할당(더 많은 중첩 @function이나 attr() 호출)을 발생시켜, 해제된 StringImpl buffer가 공격자가 제어하는 바이트로 재사용되도록 유도하는 방식입니다. 이 재사용이 성공한다면 CSSVariableData::create는 그 바이트들을 마치 원래의 token payload인 것처럼 읽어들이게 되고, 이는 substitute된 변수 값에 대한 controlled-content read로 이어집니다. 이후 결과로 만들어진 CSSVariableData에 대한 하위 파싱이 이 controlled 바이트와 어떻게 상호작용하는지에 따라, info-leak이나 재사용된 buffer 위에서의 confused-token parse primitive로까지 확장될 가능성이 있습니다.

발견 경위로는 ASAN-instrumented CSS fuzzing이 유력해 보입니다. Bug title 자체가 ASAN crash를 명시하고 있고, test case는 @function을 지원하는 grammar-aware CSS fuzzer라면 누구나 생성할 법한 최소 스니펫이며, token backing에 대한 heap-use-after-free는 ASAN이 안정적으로 잡아내는 유형의 버그이기 때문입니다. 앞서 m_intermediateTokenStrings를 도입했던 attr() lifetime fix로부터의 variant analysis 역시 마찬가지로 가능성이 있습니다. 일단 이 anchor 패턴이 알려지고 나면, dashed-function 경로에서 이것이 빠져 있음을 발견하는 일은 거의 기계적인 작업에 가깝기 때문입니다.

이 vulnerability는 WebContent renderer process 내부의 memory safety를 약화시킵니다. CSSParserToken payload가 substitution 지속 기간 동안 유효해야 한다는 lifetime invariant는, dashed @function이 만들어낸 결과가 외부 substitution에서 소비될 때마다 깨졌습니다. 따라서 @function 정의와 이를 사용하는 property를 담은 CSS를 배치할 수 있는 공격자라면, 해당 대상이 style resolve될 때마다 해제된 문자열 read를 유발할 수 있습니다.

Insight: SubstitutionResolver는 이와 정확히 동일한 위험에 대한 해법을 이미 갖고 있었습니다. attr() 경로를 위해 추가된 m_intermediateTokenStrings가 그것입니다. CSS Mixins 지원이 도입되면서 CustomProperty 소유의 token backing에 대해서도 같은 위험이 재발했지만, 이에 대응하는 anchor는 함께 추가되지 않았습니다. Non-owning view 타입(CSSParserToken, StringView, std::span)이 multi-stage resolver를 관통하도록 설계되어 있다면, view를 새로 기여하는 모든 code path는 최종 consumer가 재capture할 때까지 하위 owner의 lifetime을 함께 연장해야 합니다.