[1] CSS @function substitution drops the CustomProperty backing its own tokens
A stylesheet and one getComputedStyle() call read freed token storage.
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
CustomPropertyalive inm_intermediateCustomPropertiesuntilsubstitute()has constructed the newCSSVariableData, mirroring the existingm_intermediateTokenStringsmechanism used forattr()tokenizer output.
Source/WebCore/style/StyleSubstitutionResolver.cpp
Source/WebCore/style/StyleSubstitutionResolver.h
LayoutTests/fast/css/variables/dashed-function-result-token-lifetime.html
Patch Details
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.
Background
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.
Analysis
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:
- Install a stylesheet defining
@function --f(--a) { result: aaaa var(--a); }— a body containing avar()reference, so the function body itself needs resolution and produces a freshCustomProperty. - On a target element, set a custom property through a dashed-function call:
--x: --f(bbbb);. - Request the computed value via
getComputedStyle(target).getPropertyValue('--x'), forcingSubstitutionResolver::substitute→substituteDashedFunction.
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.
Audit directions
- Non-owning token/view types crossing a multi-stage substitution boundary. Audit all paths in
Style::SubstitutionResolverthat append into the resolver-localtokensbuffer — especiallysubstituteAttrFunction,substituteVariableFunction,substituteInternalAutoBaseFunction, and any future substitution helper — and verify that each owner whose tokens are appended is anchored either inm_intermediateTokenStringsorm_intermediateCustomProperties(or some equivalent) untilCSSVariableData::createre-captures. Start fromSource/WebCore/style/StyleSubstitutionResolver.cpp'ssubstituteTokenRangeand walk every callee that doestokens.appendVector(...). In code review, anappendVectorof another object's tokens with no adjacent anchor append on the following line is the visual tell. - Token splicing from a shorter-lived owner.
CSSParserTokenpayloads are non-owning views, so anywhere tokens from oneCustomProperty/CSSVariableDataare spliced into a buffer that outlives the source object is a candidate UAF. GrepSource/WebCore/cssandSource/WebCore/styleforappendVector(.*->tokens()),tokens().subspan, andCSSParserTokenconstructions that copy from a temporary, and verify each call site keeps the owner alive across subsequent token-consuming operations. CSSVariableData::createas the re-capture boundary. Fixing the lifetime up to that call is the standard idiom. Check every caller ofCSSVariableData::createin the tree — especially in custom-function evaluation,@propertyregistrations, and shorthand expansion — for the same "tokens drawn from a temporary owner that has already gone out of scope" shape. Concrete starting points:resolveAndRegisterDashedFunctionArgumentsin the same file, and any helper that constructsCSSVariableDatafrom aVector<CSSParserToken>returned by an inner resolver call.- Lifetime-anchor parity across feature additions. When a subsystem already uses an explicit lifetime-anchor vector, every subsequently added code path that produces tokens must add a parallel anchor. Audit recent commits to
StyleSubstitutionResolverand the CSS Mixins /@functionimplementation for other paths added sincem_intermediateTokenStringswas introduced, and whether each has a parallel anchor.