MCP server CSS @counter-style additive security: additive system consent step misrepresentation, cyclic looping, symbolic marker obscuring, and fixed marker overflow attacks
Published 2026-09-25 — SkillAudit Research
The CSS @counter-style at-rule allows authors to define custom list marker systems. The system descriptor selects the algorithm used to convert counter values to marker strings. Three system values — additive, cyclic, and symbolic — have particular implications for MCP server consent UI security:
- additive — assigns numeric values to symbols; a counter value is represented by summing symbols. Used for Roman numerals, Hebrew, etc.
- cyclic — cycles through a symbol list repeatedly regardless of counter value. Step 1 = symbol 1, step 5 = symbol 1 again (for a 4-symbol cycle).
- symbolic — repeats symbols with increasing multiplicity: step 1 = ●, step 2 = ●●, step 3 = ●●●.
For MCP servers that render multi-step consent wizards — "Step 1: Data collection", "Step 2: Third-party sharing", "Step 3: Acceptance" — a custom @counter-style applied to the ordered list can replace meaningful step numbers with misleading, opaque, or cyclic markers that prevent users from understanding which step they are on or how many steps remain.
Critical consent flow attack: A consent wizard with 7 steps numbered "Step 1" through "Step 7" is a material disclosure to the user — they know they have not finished reading. A system: cyclic counter with 3 symbols displays steps 4, 5, 6, 7 as "Step 1", "Step 2", "Step 3" again — the cycle repeats. The user believes they are back at the beginning and has already read everything, when in fact steps 4-7 contain the critical data-sharing and liability clauses.
Attack 1: system:additive with homoglyph symbols — numeric step misrepresentation
The additive system maps counter values to marker strings by summing symbol weights. An adversary defines additive symbols that look like standard Western Arabic numerals but use Unicode homoglyphs or visually similar characters. Step 3 renders as a character that appears to be "3" but is not — it may be a fullwidth digit (U+FF13 3), a mathematical digit, or a Unicode number form.
/* Homoglyph numeric counter via additive system */
@counter-style consent-steps {
system: additive;
additive-symbols:
9 "9", /* U+FF19 FULLWIDTH DIGIT NINE */
8 "8", /* U+FF18 */
7 "7", /* U+FF17 */
6 "6", /* U+FF16 */
5 "5", /* U+FF15 */
4 "4", /* U+FF14 */
3 "3", /* U+FF13 */
2 "2", /* U+FF12 */
1 "1"; /* U+FF11 FULLWIDTH DIGIT ONE */
/* Step 1 renders as: "1." (fullwidth)
To most users, "1" and "1" are visually identical at normal font sizes.
The DOM ::marker pseudo-element textContent = "1."
An audit comparing marker textContent to expected "1." will fail the match
depending on whether it uses === or normalized comparison.
Practical attack: the fallback counter on step 10 (not in additive-symbols
for this 9-symbol set) falls back to the 'fallback' descriptor value.
If fallback is 'disc', step 10 renders as a bullet.
The user sees: 1, 2, 3, 4, 5, 6, 7, 8, 9, •
They may miss that the final consent step shows as a bullet instead of "10". */
suffix: ". ";
fallback: disc;
}
/* Applied to the consent wizard list */
ol.consent-wizard { list-style: consent-steps; }
/* Detection: compare the @counter-style name used on ordered lists;
extract additive-symbols and check for non-ASCII or homoglyph characters */
function detectHomoglyphCounter(styleSheets) {
const findings = [];
for (const sheet of styleSheets) {
for (const rule of sheet.cssRules || []) {
if (rule.type !== CSSRule.COUNTER_STYLE_RULE) continue;
const text = rule.cssText;
if (/system\s*:\s*additive/.test(text)) {
const symbols = text.match(/additive-symbols\s*:[^;]+/);
if (symbols) {
const hasNonAscii = /[^\x00-\x7F]/.test(symbols[0]);
if (hasNonAscii) {
findings.push({ ruleName: rule.name, text: text.slice(0, 200),
note: 'additive @counter-style with non-ASCII symbols — potential homoglyph misrepresentation' });
}
}
}
}
}
return findings;
}
Attack 2: system:cyclic — consent step looping
A cyclic counter cycles through its symbols indefinitely. Step N is represented by symbol at index (N-1) mod (number of symbols). An adversary defines a cyclic counter with 3 symbols for a 7-step consent flow. Steps 1, 2, 3 show as expected. Steps 4, 5, 6 show the cycle symbols again — appearing identical to steps 1, 2, 3. Step 7 shows as step 1 again. The user perceives the wizard as having only 3 steps and believes they have already reviewed the content of "step 1" when they see step 4.
/* Cyclic counter that repeats every 3 steps */
@counter-style misleading-steps {
system: cyclic;
symbols: "1" "2" "3";
/* A 7-step consent flow renders as:
Step 1: "1. Data collection" → user reads
Step 2: "2. Third-party sharing" → user reads
Step 3: "3. Arbitration clause" → user reads
Step 4: "1. Liability limitation" → user thinks "I've seen step 1"
Step 5: "2. Auto-renewal terms" → user thinks "already read"
Step 6: "3. Data retention (5yr)" → user thinks "already read"
Step 7: "1. I accept all terms" → user thinks "already read"
The user accepts at step 7 without reading steps 4-7.
Note: if the host renders "Step X of Y" text from JS, the cyclic counter
affects only the marker. The "X of Y" label from JS shows 4/7, 5/7, etc.
This attack is most effective when no JS-driven step label exists — only
the CSS-rendered ::marker is the step indicator. */
suffix: ". ";
}
/* Subtler variant: use Unicode circled numbers that look like step indicators */
@counter-style circled-steps {
system: cyclic;
symbols: "①" "②" "③";
/* Circled numbers ①②③ are standard consent UI patterns in some locales.
A cyclic counter with these three symbols creates a visually
"correct-looking" consent flow for any number of steps.
The user sees ①②③①②③ and may not notice the repetition. */
suffix: " ";
fallback: decimal;
}
Attack 3: system:symbolic — exponential marker growth obscuring labels
A symbolic counter repeats its symbols with increasing multiplicity: step 1 = ●, step 2 = ●●, step 3 = ●●●. For a large step number (e.g., step 20 in a long consent flow), the marker is ●●●●●●●●●●●●●●●●●●●●. This 20-character marker may overflow the list-item marker box. Depending on list-style-position and the container's overflow setting, the long marker can:
- Overflow into the list item content, overlapping the consent text
- Be clipped by
overflow: hiddenon the list element, along with part of the consent text - Push the consent text far to the right, clipping it beyond the container boundary
/* Symbolic counter with exponential growth */
@counter-style long-markers {
system: symbolic;
symbols: "●";
/* Step 1: "●" (1 char)
Step 5: "●●●●●" (5 chars)
Step 10: 10 bullet chars (~20px at 14px font)
Step 20: 20 bullet chars (~40px — often wider than marker box)
With list-style-position: inside, the markers are inline with content.
A 40px wide marker at a 14px font-size indents the first line 40px.
The consent text on the first line is shifted 40px right.
If the container is 200px wide: remaining content width = 160px.
This is fine for step 20.
For a single-symbol symbolic counter on a list with 100 items,
step 100's marker is 100 symbols ≈ 200px — the full container width.
The consent text on the first line is completely obscured by the marker. */
suffix: " ";
}
/* overflow: hidden on the list clips wide markers AND the start of content */
ol.terms-list {
list-style: long-markers inside;
overflow: hidden;
width: 300px;
/* At step 30: marker = 30 bullet chars ≈ 60px.
Content: 240px remaining.
At step 300: marker = 300 chars ≈ 600px > container width.
The marker itself overflows. With overflow:hidden, the consent text
start is clipped — the first words of each consent item are invisible. */
}
/* Detection: check for symbolic @counter-style with single or few symbols
applied to lists with many items */
function detectSymbolicMarkerGrowth(root, styleSheets) {
const symbolicCounters = new Set();
for (const sheet of styleSheets) {
for (const rule of sheet.cssRules || []) {
if (rule.type !== CSSRule.COUNTER_STYLE_RULE) continue;
if (/system\s*:\s*symbolic/.test(rule.cssText)) {
symbolicCounters.add(rule.name);
}
}
}
const findings = [];
root.querySelectorAll('ol, ul').forEach(list => {
const cs = window.getComputedStyle(list);
const lsType = cs.listStyleType;
if (symbolicCounters.has(lsType)) {
const itemCount = list.querySelectorAll('li').length;
if (itemCount > 10) {
findings.push({ list, listStyleType: lsType, itemCount,
note: `symbolic @counter-style on list with ${itemCount} items — marker width grows linearly; items beyond ~20 may have overflow` });
}
}
});
return findings;
}
Attack 4: fixed @counter-style with wide additive symbols — marker overflow truncation
An additive counter can define symbols that are individually wide — multi-character strings, emoji, or Unicode sequences. Setting additive-symbols: 1 "✓ Step" creates a marker string "✓ Step" for the value 1 — a 6-character wide marker rendered before the consent text. If the marker box is not wide enough, the text is truncated. Combined with the pad descriptor, the attacker can force the marker to a fixed minimum width that is wider than the container allows for consent text.
/* Wide additive symbols with pad */
@counter-style wide-steps {
system: additive;
additive-symbols:
3 "Step Three Complete — ",
2 "Step Two Complete — ",
1 "Step One Complete — ";
pad: 30 " ";
/* Each marker is "Step N Complete — " (18+ characters).
With list-style-position:inside and a 200px wide list,
the 18-char marker takes ~150px, leaving ~50px for consent text.
50px at 14px font = about 4-5 characters per line.
Consent text is broken into 4-char fragments across many lines,
becoming extremely hard to read.
With list-style-position:outside (default):
The marker is in the margin (outside the content box).
A very wide outside marker may overlap the content if the
list has insufficient left-padding to accommodate it.
Default browser UA stylesheets typically give ol 40px left-padding.
A 150px marker overflows the 40px margin into the content box. */
suffix: "";
}
/* Exploit: zero-width space in additive symbols creates invisible markers
while making the marker box non-zero width */
@counter-style invisible-step {
system: additive;
additive-symbols:
1 "\200B"; /* U+200B ZERO WIDTH SPACE — renders as empty, but present */
/* Marker appears empty (zero-width space is invisible).
The list appears to have no markers — no "Step 1.", "Step 2." guidance.
The user sees a plain list of paragraphs without step numbering.
If the consent flow relies on step markers to communicate "you must
read all N steps before accepting", the invisible markers remove
this UI signal entirely. */
}
Summary
| Attack | Mechanism | Severity | Detection method |
|---|---|---|---|
HIGHadditive system with homoglyph digits |
Fullwidth or Unicode homoglyphs replace standard numerals; visually identical to users, fail string equality in audits | Step numbers appear correct visually; programmatic audit misses the substitution; fallback symbol on step 10+ may be bullet | Check additive-symbols for non-ASCII characters; normalize Unicode before comparing to expected step markers |
CRITICALcyclic system — consent step looping |
N-symbol cycle repeats; steps > N appear identical to steps 1–N; user believes they have already read the content | Later consent steps rendered as step 1, 2, 3 again; user accepts without reading data-sharing and liability clauses | Flag system:cyclic on any @counter-style used in consent flows; verify step count matches symbol count |
MEDIUMsymbolic system — exponential marker growth |
Single-symbol symbolic counter generates N-char marker for step N; wide markers overflow into consent text | At high step counts, marker width exceeds container; consent text clipped or shifted off-screen by overflow:hidden | Check symbolic @counter-style on lists with > 10 items; compute expected marker width at max step count |
MEDIUMWide additive symbols or invisible zero-width-space markers |
Multi-character additive symbols consume list item content width; zero-width-space removes step numbering signal | Consent text crammed into narrow residual width; OR step numbers invisible, removing multi-step flow signal | Check additive-symbols string width; flag zero-width-space characters in counter symbols |
See also: CSS @counter-style security overview for the full at-rule attack surface, CSS counter-style speak-as security for accessibility tool misrepresentation, and CSS counter-style pad security for marker width manipulation via the pad descriptor.
SkillAudit parses @counter-style at-rules and checks system, symbols, and additive-symbols descriptors for consent flow manipulation patterns. Start a free scan.