Security Guide

MCP server CSS @counter-style extended security — additive Unicode font timing oracle, speak-as accessibility hijack, out-of-range counter anomaly, fallback chain depth discriminator

The basic @counter-style attacks (counter value extraction via ::marker, symbol list timing, Unicode range support) are documented separately. This guide covers four deeper surfaces: additive symbols spanning mixed Unicode planes that create per-glyph font substitution timing oracles encoding active counter values; speak-as:numeric overriding custom list semantic labels to break screen-reader accessibility; range descriptor extended beyond list bounds triggering expensive recalculation on dynamic lists; and fallback chain depth differences discriminating browser engines without JavaScript.

How @counter-style additive and fallback descriptors work

The system: additive descriptor defines a set of (weight, symbol) pairs. To render counter value N, the browser picks the largest weight ≤ N, emits its symbol, subtracts the weight, and repeats. The speak-as descriptor controls how screen readers announce the ::marker content. The range descriptor limits which counter values the style applies to. The fallback descriptor names another @counter-style to use when the current style cannot represent the counter value.

Attack 1: additive Unicode multi-plane font substitution timing oracle

Each symbol in an additive-symbols list can be any Unicode character. If the symbols are drawn from different Unicode planes — Latin (U+0000–U+007F), CJK Unified Ideographs (U+4E00–U+9FFF), Mathematical Alphanumerics (U+1D400–U+1D7FF), Emoji (U+1F600–U+1F64F) — the browser must consult a different segment of the font fallback chain for each. Characters from planes not covered by the primary font trigger font substitution at render time, and the substitution cost is measurable via requestAnimationFrame timing:

/* MCP server defines additive counter-style with cross-plane symbols */
@counter-style unicode-oracle {
  system: additive;
  additive-symbols:
    8 "𝕬",  /* Mathematical Bold Fraktur — needs specialized math font */
    4 "京",  /* CJK ideograph — needs CJK font substitution */
    2 "🔑",  /* Emoji — needs emoji font */
    1 "A";   /* Basic Latin — primary font, zero substitution cost */
  fallback: decimal;
}

/* Apply to a list whose counter values MCP wants to probe */
ol.target-list { list-style-type: unicode-oracle; }

/* Timing: counter value 7 (4+2+1) uses 3 different font planes:
   render time ≈ CJK_cost + Emoji_cost + Latin_cost
   counter value 8 (8 only) uses Mathematical Fraktur only
   The different timing profiles per-value encode the counter value
   — MCP reads rAF deltas to reconstruct the active list position */

This is a refinement of the basic symbol list length timing oracle: instead of making all symbols the same Unicode block (where length alone encodes position), this version uses cross-plane symbols where the combination of which planes were consulted encodes position — a higher-entropy channel achievable with a shorter symbol list.

Why this matters: Ordered lists used in checkout flows ("Step 1: Shipping", "Step 2: Payment", "Step 3: Confirm") or instructional wizards expose the user's current step via this timing channel. An MCP server injected in the host app can infer which step the user is on — and which form fields are currently visible — without reading DOM text.

Attack 2: speak-as:numeric accessibility hijacking

The speak-as descriptor controls how a custom counter style is announced by screen readers. When set to speak-as: numeric, the screen reader announces the raw integer counter value instead of the custom symbol or label that sighted users see. An MCP server can inject a @counter-style that overrides a host's custom list style with speak-as: numeric, causing visually impaired users to hear numbers instead of meaningful labels:

/* Host app's instructional list uses semantic labels via custom counter: */
@counter-style step-labels {
  system: fixed;
  symbols: "Introduction" "Requirements" "Installation" "Configuration";
  speak-as: words;  /* screen reader says: "Introduction", "Requirements" etc. */
}
ol.tutorial { list-style-type: step-labels; }

/* MCP server redefines the same counter-style name with speak-as:numeric */
@counter-style step-labels {
  system: fixed;
  symbols: "Introduction" "Requirements" "Installation" "Configuration";
  speak-as: numeric;  /* screen reader now says: "1", "2", "3", "4" */
}
/* The SAME visual output — sighted users see "Introduction", "Requirements"
   — but screen reader users hear "1", "2", "3", "4" with no semantic context.
   The degradation is invisible on visual inspection. */

This attack is particularly insidious because it passes visual QA — the page looks correct — but breaks accessibility for screen reader users. It targets the gap between visual testing and accessibility testing, and it requires only a CSS injection, no DOM manipulation. Medical instruction sets, legal procedure lists, and security setup guides are high-impact targets where step-label meaning is safety-critical.

Attack 3: out-of-range counter style recalculation DoS

The range descriptor limits which counter values a custom style applies to. When a counter value falls outside the range, the browser falls back to the fallback style. Setting a very large range on a counter-style applied to a dynamically growing list forces the browser's style engine to evaluate the custom counter representation for every possible value up to the range limit on each style recalculation triggered by DOM mutations:

/* MCP server injects counter-style with pathologically large range
   on a list that has items added dynamically (live feed, chat, etc.) */
@counter-style dos-range {
  system: additive;
  additive-symbols: 1000 "M", 900 "CM", 500 "D", 400 "CD",
                    100 "C", 90 "XC", 50 "L", 40 "XL",
                    10 "X", 9 "IX", 5 "V", 4 "IV", 1 "I";
  range: 1 99999;  /* evaluates all 99,999 potential representations */
  fallback: decimal;
}

ul.live-feed, ol.chat-messages {
  list-style-type: dos-range;
}

/* Each time a new item is appended, the style engine re-evaluates
   the counter-style for all items and all values within range.
   At 99,999 range with complex additive algorithm, this creates
   a CPU spike proportional to (range_max × list_length) per mutation.
   Live feeds with >100 items become slow or unresponsive. */

The attack scales with the rate of DOM mutations. Real-time chat applications, live market feeds, and streaming log viewers that append items rapidly experience continuous CPU pressure that degrades the whole page, not just the list display.

Attack 4: fallback chain depth browser engine discriminator

The fallback descriptor creates a chain: style A falls back to B, B falls back to C, and so on, ultimately resolving to a built-in style. Different browser engines impose different limits on fallback chain resolution depth and behave differently when the chain is circular or exceeds the limit. An MCP server can use this behavioral difference to discriminate between Chrome, Firefox, and Safari without executing any JavaScript:

/* MCP server defines a fallback chain of depth 10 */
@counter-style cs-1 { system: fixed; symbols: "①"; fallback: cs-2; range: 1 1; }
@counter-style cs-2 { system: fixed; symbols: "②"; fallback: cs-3; range: 1 1; }
@counter-style cs-3 { system: fixed; symbols: "③"; fallback: cs-4; range: 1 1; }
/* ... through cs-10 */
@counter-style cs-10 { system: fixed; symbols: "⑩"; fallback: disc; range: 1 1; }

/* The root style: applies to counter value 1,
   all others fall through the chain */
@counter-style chain-probe { system: fixed; symbols: "①"; fallback: cs-2; range: 1 1; }

li::marker { content: counter(list-item, chain-probe); }

/* For counter value 2 (not in range 1 1 of chain-probe),
   Chrome, Firefox, and Safari resolve the fallback chain differently:
   — At depth 5: all three show "⑤"
   — At depth 7: Chrome shows disc (hits engine limit), Firefox shows "⑦"
   — At depth 9: Safari shows disc (different limit)
   The rendered ::marker character read via Range.getClientRects() timing
   or getComputedStyle encodes the browser engine identity. */

The exact depth at which each browser engine terminates the fallback chain and falls through to the system default differs between browser versions, making this a browser version discriminator as well as an engine fingerprint.

AttackPrerequisiteWhat it enablesSeverity
Additive Unicode font substitution timing oracleCSS injection; target list with custom counterActive counter value encoded in per-frame render timingMEDIUM
speak-as:numeric accessibility hijackingCSS injection redefining host @counter-style nameScreen readers announce numbers instead of semantic labels; invisible to visual QAHIGH
Out-of-range recalculation DoSCSS injection on dynamic list elementCPU spike per DOM mutation; live feed / chat degradationHIGH
Fallback chain depth discriminatorCSS injection + ::marker content probeBrowser engine and version fingerprint without JavaScriptMEDIUM

Defences

SkillAudit findings for this attack surface

HIGHspeak-as:numeric accessibility hijack: MCP server @counter-style redefines a host-used counter style name with speak-as: numeric, causing screen readers to announce numeric counters instead of semantic labels
HIGHLarge-range counter recalculation DoS: @counter-style with range exceeding 1,000 applied to a selector matching dynamically-growing list elements (chat, feed, log)
MEDIUMCross-plane additive font timing oracle: additive symbols spanning multiple Unicode planes (Math, CJK, Emoji, Latin) create per-value rendering time profiles encoding active counter value
MEDIUMFallback chain depth discriminator: @counter-style fallback chain of depth ≥6 exploiting engine-specific chain depth limits to fingerprint browser engine and version

Related: CSS @counter-style basic security covers counter value extraction via ::marker, symbol list length timing, Unicode range support fingerprint, and speak-as cycle detection. CSS generated content security documents the broader ::marker and ::before/::after attack surface.

← Blog  |  Security Checklist