← 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. A style-resolution path reachable from a plain stylesheet plus one getComputedStyle() call reads token payloads out of freed heap. Escalation past the crash depends on winning a reclaim of the freed string slab before the resolver re-captures the tokens.

CSS custom-property values are not stored as strings during style computation — they are stored as token vectors, and each token holds a view into string storage owned by something else. Style resolution expands nested substitutions (var(), attr(), and the new dashed @function form from CSS Mixins) into one flat token buffer, then hands that buffer to CSSVariableData::create(), which copies the token data into its own storage. The invariant that makes this work is that every object whose tokens were spliced into the buffer must stay alive until that final re-capture happens.

The angle: a page that defines a CSS @function and reads back a property using it drives the style resolver into consuming freed token storage on every style resolution of the target.

Keep the resolved CustomProperty alive in m_intermediateCustomProperties until substitute() has constructed the new CSSVariableData, mirroring the existing m_intermediateTokenStrings mechanism used for attr() tokenizer output.

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');

The header gains a second lifetime-anchor vector alongside the existing one: Vector<RefPtr<const CustomProperty>> m_intermediateCustomProperties. In SubstitutionResolver::substituteDashedFunction, immediately after tokens.appendVector(resolvedResult->tokens()), the local RefPtr<const CustomProperty> resolvedResult is moved into that vector instead of being allowed to expire at function return. In SubstitutionResolver::substitute, the new vector is cleared at both exit points that already cleared m_intermediateTokenStrings — the failure path where substituteTokenRange returns nothing, and the success path after CSSVariableData::create(*substitutedTokens, ...) has returned. A regression LayoutTest defines @function --f(--a) { result: aaaa var(--a); }, applies --x: --f(bbbb) to a target, and reads the computed value back to force the path.

Non-owning token views outliving their backing object due to a missing lifetime anchor across substitution stages.

Where this lives. Style::SubstitutionResolver implements arbitrary-substitution-function resolution for CSS values during style computation. It walks a token range from a CSSSubstitutionValue, recursively expands nested var(), attr(), and dashed-function calls, and produces the final CSSVariableData that the style builder consumes.

CSS Mixins and dashed functions. CSS Mixins introduces @function, a CSS-level function definition of the form @function --name(--arg) { result: <tokens>; }. It is invoked as a dashed-function reference inside a custom-property value, and the resolver expands the invocation by evaluating the function body and splicing the resulting tokens into the caller's buffer.

CSSParserToken and its backing storage. CSSParserToken is a value-type token that stores non-owning views into externally owned backing strings — typically StringImpl payloads owned by an upstream CustomProperty or by parser-allocated buffers. Style::CustomProperty is a reference-counted holder of a parsed and tokenized custom-property value; CustomProperty::tokens() returns its tokens, but the string storage those tokens point at belongs to the CustomProperty itself.

CSSVariableData::create as a re-capture boundary. CSSVariableData is the storage object for an unparsed CSS value. Constructing one from a token vector copies the token data into the new object's own storage, which is why it functions as the point past which upstream owners no longer need to be alive.

m_intermediateTokenStrings. The resolver already carried one lifetime-anchor vector: m_intermediateTokenStrings holds transient strings produced during attr() tokenization, keeping that token backing alive until the final CSSVariableData::create re-captures the data. The anchor idiom exists because the resolver's token buffer accumulates views from several independently owned sources across a recursive expansion, and there is no single owner whose scope naturally covers the whole expansion.

The bug is a heap use-after-free of StringImpl-backed token payloads, produced by a missing anchor on one specific expansion path.

  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

In the sequence above, substituteDashedFunction appends tokens whose payloads are views into storage owned by resolvedResult, then returns, at which point the local RefPtr<const CustomProperty> destructs. If it held the last reference, the backing strings are freed while the appended tokens sit in the resolver's growing tokens buffer still pointing at them. Those dangling views are then consumed by CSSVariableData::create(*substitutedTokens, ...) in SubstitutionResolver::substitute, which re-captures token data from storage that no longer exists — the heap-use-after-free that ASAN reported.

The regression test is also the shortest reachability proof. Three steps from web content:

  1. Install a stylesheet defining @function --f(--a) { result: aaaa var(--a); } — a body containing a var() reference, so the function body itself needs resolution and produces a fresh CustomProperty.
  2. On a target element, set a custom property through a dashed-function call: --x: --f(bbbb);.
  3. Request the computed value via getComputedStyle(target).getPropertyValue('--x'), forcing SubstitutionResolver::substitutesubstituteDashedFunction.

The immediate observed effect is a UAF read at the moment CSSVariableData::create re-captures token data — an ASAN abort under instrumentation and a reliable renderer crash otherwise. To weaponize beyond the crash, an attacker would attempt to reclaim the freed string-backing slab in the window between the drop of resolvedResult and the call to CSSVariableData::create, for example by issuing additional CSS string allocations during the same substitution chain (more nested @function or attr() calls) so that the freed StringImpl buffer is reused with attacker-controlled bytes. If reuse succeeds, CSSVariableData::create would read those bytes as if they were the original token payload, giving a controlled-content read of the substituted variable's value and, depending on how downstream parsing of the resulting CSSVariableData interacts with the controlled bytes, extending toward an info-leak or a confused-token parse primitive over the recycled buffer.

The discovery angle points at ASAN-instrumented CSS fuzzing: the bug title names an ASAN crash, the test case is a minimal snippet any grammar-aware CSS fuzzer with @function support would generate, and heap-use-after-free on token backing is exactly what ASAN catches reliably. Variant analysis from the earlier attr() lifetime fix that introduced m_intermediateTokenStrings is equally plausible — once that anchor pattern is known, spotting its absence on the dashed-function path is near-mechanical.

This vulnerability weakens memory safety inside the WebContent renderer process. The lifetime invariant that CSSParserToken payloads remain valid for the duration of substitution was violated whenever a dashed @function produced a result consumed by an outer substitution, so an attacker who can place CSS containing a @function definition and a property using it triggers the freed-string read on every style resolution of the affected target.

Insight: SubstitutionResolver already had a working solution for this exact hazard — m_intermediateTokenStrings, added for the attr() path. When CSS Mixins support landed, the hazard recurred for CustomProperty-owned token backings but the parallel anchor was not added. Whenever a non-owning view type (CSSParserToken, StringView, std::span) is plumbed through a multi-stage resolver, every new code path that contributes views must also extend the lifetime of the underlying owner until the final consumer re-captures.