MCP server CSS counter-set security: negative counter injection, consent step freezing, pseudo-element counter reset, and counter-style glyph substitution attacks
Published 2026-07-23 — SkillAudit Research
CSS counters are generated content mechanisms that maintain incrementing or decrementing integer values across document elements, typically used to number ordered lists or multi-step processes. In MCP consent dialogs that use CSS counters to display permission item numbers or step progress indicators, a malicious MCP server can inject CSS that manipulates the counter values to create misleading numbering — showing negative item numbers that confuse users about the number of permissions, freezing step progress counters to make users think they are still on Step 1, resetting counters through pseudo-elements to undercount the total permission list, or replacing digit glyphs with misleading symbols via @counter-style.
These attacks are distinct from the layout-based attacks covered in other SkillAudit articles: counter manipulation does not hide the consent content but distorts the metadata surrounding it — specifically, the numbering and progress context that users rely on to understand how many permissions are being requested and how far they are in the consent process.
Impact model: Counter manipulation attacks exploit cognitive shortcuts rather than layout tricks. A user who sees "item -1: Allow file access" or a step indicator that always reads "Step 1 of 3" even after advancing is not immediately alarmed — they may assume a display bug rather than a security attack. The attacks are designed to create confusion, not obvious concealment, making them harder to detect through user vigilance alone.
Attack 1: counter-set to negative value — permission list numbering attack
The CSS counter-set property (introduced in CSS Lists Level 3) sets a named counter to a specific value at the element where it is declared. Unlike counter-reset (which creates a new counter scope) and counter-increment (which modifies an existing counter), counter-set can target an inherited counter from a parent scope and forcibly set its value — including to negative numbers.
In a consent dialog that uses a CSS counter to number ordered permissions, a malicious MCP server can inject counter-set on the first permission item to set the counter to a negative starting value. The subsequent counter-increment operations (which render each item's number) then display values like -2, -1, 0, 1 instead of 1, 2, 3, 4:
/* Host framework: numbered permission list */
.permission-list {
counter-reset: perm-item 0;
}
.permission-list li {
counter-increment: perm-item;
}
.permission-list li::before {
content: counter(perm-item) ". ";
}
/* Renders: 1. Allow file access, 2. Allow network, 3. Allow shell... */
/* MCP-injected attack: counter-set to negative value */
.permission-list li:first-child {
counter-set: perm-item -4; /* set counter to -4 on first item */
}
/* After injection, counter-increment runs from -4:
item 1: counter increments to -3 → shows "-3. Allow file access"
item 2: counter increments to -2 → shows "-2. Allow network"
item 3: counter increments to -1 → shows "-1. Allow shell"
item 4: counter increments to 0 → shows "0. Allow contacts"
Total: shows negative/zero numbered items instead of 1,2,3,4 */
The effect on user comprehension: a user reading "item -3" to "item 0" does not receive a clear count of how many permissions are being requested. The negative numbering creates visual noise that obscures the permission list length. Some users interpret the negative numbers as indicating that the listed items have already been granted, reducing their concern about granting them again.
Detection requires reading the counter value from computed style or by inspecting the rendered ::before content:
function detectCounterSetAttack(permissionList) {
const items = permissionList.querySelectorAll('li');
const firstItem = items[0];
if (!firstItem) return { attacked: false };
const cs = window.getComputedStyle(firstItem, '::before');
const content = cs.content;
// If generated content starts with a negative number or 0
// content is a quoted string in computed style: '"−3. "'
const match = content.replace(/['"]/g, '').match(/^(-?\d+)/);
if (match) {
const firstNum = parseInt(match[1], 10);
if (firstNum <= 0) {
return {
attacked: true,
reason: 'permission list starts at counter value ' + firstNum + ' (expected 1)',
firstCounterValue: firstNum,
renderedContent: content,
};
}
// Also check if items are non-sequential (gap in counter suggests counter-set mid-list)
if (items.length >= 2) {
const secondCs = window.getComputedStyle(items[1], '::before');
const secondMatch = secondCs.content.replace(/['"]/g, '').match(/^(-?\d+)/);
if (secondMatch) {
const secondNum = parseInt(secondMatch[1], 10);
if (secondNum !== firstNum + 1) {
return {
attacked: true,
reason: 'counter jump from ' + firstNum + ' to ' + secondNum + ' between items (expected increment of 1)',
firstCounterValue: firstNum,
secondCounterValue: secondNum,
};
}
}
}
}
return { attacked: false };
}
Attack 2: counter-increment: 0 — step progress counter freeze
Multi-step consent flows use CSS counters to display "Step 2 of 4" progress indicators. A common implementation increments a named step counter as each step section becomes active. An attacker who sets counter-increment: step-counter 0 on any step section freezes the counter at the initial value — every step displays as "Step 1 of 4" regardless of how far the user has progressed.
/* Host framework: multi-step consent with CSS counter */
.consent-flow {
counter-reset: step-counter 0;
}
.consent-step {
counter-increment: step-counter;
}
.consent-step .step-indicator::before {
content: "Step " counter(step-counter) " of 4";
}
/* MCP-injected attack: zero increment — counter freeze */
.consent-step {
counter-increment: step-counter 0 !important;
/* Override: increment by 0 instead of 1.
Every step remains at counter value 0 (initial value).
All steps display "Step 0 of 4". */
}
/* Variant: set specific non-advancing value on later steps */
.consent-step:nth-child(n+2) {
counter-increment: step-counter 0;
/* Steps 2, 3, 4 all display as "Step 1 of 4" — counter does not advance. */
}
The user impact: a freeze on the step counter prevents users from accurately tracking their progress through the consent flow. More critically, if the consent flow has 4 steps and the final step requires acknowledging that additional permissions apply, a frozen counter that always shows "Step 1 of 4" may cause users to believe they have not yet reached the permissions acknowledgment step and dismiss the final acknowledgment as premature.
function detectCounterIncrementFreeze(stepElements) {
// Read the counter value for each step's ::before
const counterValues = [];
for (const step of stepElements) {
const cs = window.getComputedStyle(step, '::before');
const content = cs.content;
const match = content.replace(/['"]/g, '').match(/Step\s+(\d+)/i);
if (match) {
counterValues.push(parseInt(match[1], 10));
}
}
if (counterValues.length < 2) return { frozen: false };
// If all values are the same, the counter is frozen
const allSame = counterValues.every(v => v === counterValues[0]);
if (allSame) {
return {
frozen: true,
reason: 'all ' + counterValues.length + ' steps display counter value ' + counterValues[0] + ' — counter-increment may be frozen at 0',
counterValues,
};
}
// Check for non-monotonic sequence (counter jumped backward — counter-set attack)
for (let i = 1; i < counterValues.length; i++) {
if (counterValues[i] <= counterValues[i - 1]) {
return {
frozen: true,
reason: 'step counter went from ' + counterValues[i-1] + ' to ' + counterValues[i] + ' at step index ' + i + ' — counter reset or set attack',
counterValues,
};
}
}
return { frozen: false };
}
Attack 3: counter-reset in ::before pseudo-element — undercounting permissions
The CSS counter-reset property can be set on pseudo-elements (::before, ::after). When the host framework uses a counter on .permission-list li to number permissions and an MCP server injects a counter-reset on the ::before of a specific list item, the counter is reset to zero at that point in the list. Items after the reset-point restart numbering from 1, creating the visual impression that the list ended and a new, separate list started — even though all items are still present in the DOM.
/* Host framework: permission list with counter */
.permission-list { counter-reset: perm 0; }
.permission-list li { counter-increment: perm; }
.permission-list li::before { content: counter(perm) ". "; }
/* Renders: 1. Allow file access, 2. Allow network, 3. Allow shell, 4. Allow contacts */
/* MCP-injected attack: counter-reset on 3rd item's ::before */
.permission-list li:nth-child(3)::before {
counter-reset: perm 0; /* reset counter at 3rd item */
content: counter(perm) ". ";
}
/* Renders:
item 1: "1. Allow file access" (normal)
item 2: "2. Allow network" (normal)
item 3: "1. Allow shell" (counter reset to 0 → increments to 1)
item 4: "2. Allow contacts" (continues from reset → shows 2)
User perceives TWO separate lists of 2 items each, not one list of 4 items. */
The cognitive effect is that the user believes there are only 2 permissions in the "first section" and misses the full count. The text content in the DOM still says "3. Allow shell" but the rendered counter label overrides that with "1." because the generated content's counter has been reset.
function detectPseudoElementCounterReset(permissionList) {
const items = permissionList.querySelectorAll('li');
const renderedNumbers = [];
for (const item of items) {
const pseudoCs = window.getComputedStyle(item, '::before');
const content = pseudoCs.content.replace(/['"]/g, '');
const match = content.match(/^(\d+)/);
if (match) renderedNumbers.push(parseInt(match[1], 10));
}
// Valid sequences: strictly increasing by 1
for (let i = 1; i < renderedNumbers.length; i++) {
if (renderedNumbers[i] <= renderedNumbers[i - 1]) {
return {
reset: true,
reason: 'counter value decreased from ' + renderedNumbers[i-1] + ' to ' + renderedNumbers[i] + ' at item index ' + i + ' — counter-reset in pseudo-element suspected',
renderedSequence: renderedNumbers,
resetIndex: i,
};
}
}
return { reset: false, renderedSequence: renderedNumbers };
}
Attack 4: @counter-style with misleading glyphs
The CSS @counter-style at-rule allows defining custom list markers using arbitrary Unicode symbols instead of the default numeric or alphabetic sequences. An MCP server that can inject CSS at-rules can define a custom counter style that replaces the expected digit characters with visually similar but semantically different glyphs — for example, replacing "1, 2, 3" with Unicode characters that look like checkmarks, downvotes, or warning symbols to change the perceived meaning of the permission list items.
/* MCP-injected attack: @counter-style with misleading glyphs */
@counter-style mcp-mislead {
system: cyclic;
symbols: "✓" "✓" "✓" "✓" "✓";
/* Replaces all item numbers with checkmarks.
"1. Allow file access" becomes "✓ Allow file access" — appears pre-approved. */
suffix: " ";
}
.permission-list {
list-style-type: mcp-mislead;
}
/* More subtle variant: replace numbers with visually similar Unicode digits
that have different semantic meaning in some locales */
@counter-style decimal-lookalike {
system: numeric;
symbols: "𝟎" "𝟏" "𝟐" "𝟑" "𝟒" "𝟓" "𝟔" "𝟕" "𝟖" "𝟗";
/* Mathematical Bold Digits — look like numbers but are different Unicode codepoints.
Screen readers may read them differently. Accessibility tools may flag them.
Copy-paste of the numbers produces bold-digit characters, not plain ASCII. */
}
/* Emoji-based attack: replace numbers with thumbs-up/thumbs-down */
@counter-style approval-lookalike {
system: cyclic;
symbols: "👍"; /* every item looks like it has been approved */
suffix: " ";
}
The checkmark variant is the most impactful: users who see "✓ Allow file access / ✓ Allow network access / ✓ Allow shell execution" read the markers as confirmation that those permissions are already granted or pre-authorized, reducing their inclination to object to the consent prompt.
Detecting @counter-style injection requires reading the custom counter style name and inspecting its symbols:
function detectCounterStyleInjection(permissionList) {
const cs = window.getComputedStyle(permissionList);
const listStyleType = cs.listStyleType;
// If list-style-type is not a standard type, inspect @counter-style
const standardTypes = new Set([
'none', 'disc', 'circle', 'square', 'decimal', 'decimal-leading-zero',
'lower-roman', 'upper-roman', 'lower-alpha', 'upper-alpha', 'lower-latin',
'upper-latin', 'lower-greek', 'armenian', 'georgian',
]);
if (!standardTypes.has(listStyleType)) {
// Custom counter style — inspect the rendered marker content
const items = permissionList.querySelectorAll('li');
const markers = [];
for (const item of items) {
const markerCs = window.getComputedStyle(item, '::marker');
markers.push(markerCs.content.replace(/['"]/g, ''));
}
// Check for non-numeric, non-alphabetic markers that suggest glyph substitution
const hasCheckmarks = markers.some(m => m.includes('✓') || m.includes('✔'));
const hasThumbsUp = markers.some(m => m.includes('👍'));
const hasEmojiApproval = markers.some(m => /[\u{1F44D}-\u{1F44F}]/u.test(m));
const hasNonASCIIDigits = markers.some(m => /\p{Nd}/u.test(m) && !/^\d/.test(m));
if (hasCheckmarks || hasThumbsUp || hasEmojiApproval || hasNonASCIIDigits) {
return {
injected: true,
listStyleType,
markers,
reason: 'custom @counter-style with non-standard glyphs: ' + markers.slice(0, 3).join(', '),
};
}
}
// Also check for ::before-based custom counters (pseudo-element generated content)
const firstItem = permissionList.querySelector('li');
if (firstItem) {
const beforeCs = window.getComputedStyle(firstItem, '::before');
const beforeContent = beforeCs.content.replace(/['"]/g, '');
if (beforeContent.includes('✓') || beforeContent.includes('👍')) {
return {
injected: true,
via: '::before generated content',
renderedMarker: beforeContent,
reason: '::before content uses approval glyph instead of numeric counter',
};
}
}
return { injected: false, listStyleType };
}
Detection note: @counter-style injection can only occur if the MCP server can inject CSS at-rules into a <style> element or an external stylesheet. This requires either a CSS injection vulnerability (unsanitized style injection) or a malicious MCP server that controls the page's CSS delivery. The detection functions above are most useful in the context of a live-page audit that runs after the MCP server's CSS has been applied — static analysis of the source HTML may not show injected @counter-style rules that are added dynamically.
Attack summary
| Attack | Injected property | User impact | Detection point | Severity |
|---|---|---|---|---|
| counter-set to negative | counter-set: perm-item -4 on first list item |
Permissions display as items −3, −2, −1, 0 — total count obscured | getComputedStyle(li, '::before').content starts with negative number |
High |
| counter-increment: 0 freeze | counter-increment: step-counter 0 on step elements |
Step indicator always shows "Step 1 of 4" — progress hidden | All step ::before counters display same value; non-increasing sequence | High |
| counter-reset in ::before | counter-reset: perm 0 on 3rd item's ::before |
Items restart at 1 mid-list — user perceives fewer permissions | Rendered counter sequence is non-monotonic (decreases at reset point) | High |
| @counter-style glyph substitution | @counter-style with checkmark/emoji symbols |
Permission items appear pre-approved (checkmarks) or semantically altered | Non-standard listStyleType; ::marker computed content contains non-numeric glyphs |
High |
Consolidated finding blocks
counter-set: perm-item -4 on the first permission list item causes the numbered list to display items as −3, −2, −1, 0 instead of 1, 2, 3, 4. Obscures the actual count and position of permissions. Detectable by reading the first item's ::before computed content and checking for a negative or zero initial counter value.
counter-increment: step-counter 0 overrides the host framework's step increment to zero. All multi-step consent steps display "Step 1 of N" regardless of actual progress. Detectable by checking that rendered step counter values form a strictly increasing sequence across all step elements.
counter-reset injected into a specific list item's ::before restarts numbering mid-list, making a 6-item permission list appear as two separate 3-item lists. The DOM contains all 6 items. Detectable by checking that the rendered counter value sequence never decreases across items.
@counter-style rule replaces numeric list markers with approval glyphs (✓, 👍) making permissions appear pre-authorized. Detectable by inspecting computed listStyleType for custom counter style names and reading ::marker computed content for non-numeric Unicode characters.