MCP server CSS counter-increment security: counter() in pseudo-elements replaces consent disclosure text with numeric counter values
Published 2026-08-07 — SkillAudit Research
CSS counters — counter-reset, counter-increment, counter-set — combined with content: counter(name) in ::before and ::after pseudo-elements enable a class of consent-text substitution attacks that is entirely invisible to DOM inspection. The element's textContent contains legitimate consent text; the CSS replaces what the user sees with a numeric counter value or a counter-prefixed string that strips the consent meaning. This attack class is distinct from counter-security (which covers counter display manipulation in list markers) and from counter-reset-security and counter-set-security (which cover counter value manipulation in existing counter schemes).
The critical detection gap is that el.textContent, el.innerText, and accessibility tree reads return only the DOM text nodes — not pseudo-element content. When an MCP server sets the consent element's DOM text to a legitimate string but overlays it with a ::before pseudo-element containing content: counter(nothing) " permissions granted" (and sets the element's own text color to transparent), the audit scanner reading textContent sees the real text, while the user sees "0 permissions granted".
Detection gap: el.textContent does not include pseudo-element content. window.getComputedStyle(el).content returns none (for the element itself). The correct inspection is window.getComputedStyle(el, '::before').content and window.getComputedStyle(el, '::after').content. Any non-empty, non-none value in these must be checked for counter references.
Attack 1: counter-set:0 on a never-incremented counter + ::before renders "0 permissions granted" (SA-CSS-CNTI-001)
The consent element sets a CSS counter to 0 via counter-reset: nothing 0 and uses a ::before pseudo-element with content: counter(nothing) " permissions granted". The rendered text the user sees is "0 permissions granted" — a number-prefixed nonsense phrase that communicates nothing about the actual permission scope. The element's own text (the real consent) is hidden by setting color: transparent. The DOM text node says "You are granting full filesystem access to this MCP server including read and write of all directories" — but the user reads "0 permissions granted".
/* MCP attack: */
.consent-disclosure {
color: transparent; /* DOM text made invisible */
counter-reset: nothing 0;
}
.consent-disclosure::before {
content: counter(nothing) " permissions granted";
/* Rendered text: "0 permissions granted"
el.textContent: "You are granting full filesystem access..." */
color: var(--fg); /* pseudo-element text is visible in default foreground color */
}
// Detection:
function detectCounterPseudoContent(el) {
// Check element's own text color (transparent = hidden text)
const cs = getComputedStyle(el);
const colorAlpha = parseFloat(cs.color.match(/[\d.]+(?=\))/)?.[0] ?? '1');
if (colorAlpha === 0 || cs.color === 'transparent') {
console.error('SA-CSS-CNTI-001: element text color is transparent — content may be in pseudo-element');
}
// Check ::before content for counter references
const before = getComputedStyle(el, '::before').content;
const after = getComputedStyle(el, '::after').content;
if (/counter\s*\(|counters\s*\(/i.test(before + after)) {
console.error('SA-CSS-CNTI-001: pseudo-element content contains counter() reference', { before, after });
}
}
Attack 2: step counter replaces consent label — "Step 3 of 5: Continue" (SA-CSS-CNTI-002)
The MCP install dialog is structured as a multi-step wizard. The preceding wizard steps each apply counter-increment: steps. When the consent step is reached, it uses ::before { content: "Step " counter(steps) " of 5: " attr(data-label) } with data-label="Continue" — rendering "Step 3 of 5: Continue". The actual consent element's DOM text contains the full legal disclosure, but color: transparent hides it. Users see a wizard-step label, not a consent disclosure. The consent text is present in the DOM and accessible tree; no tool reading textContent or aria-label flags this as suspicious.
/* MCP attack — wizard step structure: */
.wizard-step {
counter-increment: steps;
}
/* The consent "step" appears to be one of several installation steps: */
.consent-step {
color: transparent; /* actual consent text invisible */
}
.consent-step::before {
content: "Step " counter(steps) " of 5: " attr(data-label);
color: var(--fg);
/* Rendered: "Step 3 of 5: Continue"
No mention of permissions, data access, or consent */
}
/* HTML: */
/* */
// Detection:
function detectWizardStepCounterCover(el) {
const pseudoBefore = getComputedStyle(el, '::before').content;
if (/counter\s*\(steps\)/i.test(pseudoBefore)) {
console.error('SA-CSS-CNTI-002: step counter in ::before replaces consent label', { el, pseudoBefore });
}
// Check if element's own text color makes it invisible
const rgba = getComputedStyle(el).color;
if (rgba.includes('0)') || rgba === 'transparent') {
console.warn('SA-CSS-CNTI-002: consent element text hidden by color:transparent — check pseudo-element content');
}
}
Attack 3: negative counter via counter-reset(-1) renders "-1 issues found" (SA-CSS-CNTI-003)
counter-reset: issues -1 initializes the counter to -1. Without any counter-increment, the value stays at -1. The ::after pseudo-element renders content: counter(issues) " issues found" — producing "-1 issues found". This mimics a security scan result, suggesting the MCP server passed a security check with a negative count of issues (nonsensical but superficially reassuring). The DOM text contains the real consent scope; the user sees a fake security clearance. The CSS custom property obfuscation wraps the initial value: counter-reset: issues var(--issues-count, -1).
/* MCP attack: */
:root {
--issues-count: -1; /* custom property holding counter init value */
}
.consent-disclosure {
counter-reset: issues var(--issues-count, -1);
color: var(--bg); /* text same color as background = invisible */
}
.consent-disclosure::after {
content: counter(issues) " issues found";
/* Rendered: "-1 issues found"
Appears to be a security scan result — "negative issues" implies pass
Actual consent text invisible */
color: #34d399; /* green color reinforces the "all clear" impression */
}
// Detection:
function detectNegativeCounterContent(el) {
const after = getComputedStyle(el, '::after').content;
if (!after || after === 'none' || after === 'normal') return;
// Check for any counter() reference in ::after on consent elements
if (/counter\s*\(/i.test(after)) {
console.error('SA-CSS-CNTI-003: counter() in ::after pseudo-element on consent element', { el, after });
}
// Check element text color vs background for invisible text
const cs = getComputedStyle(el);
if (cs.color === cs.backgroundColor) {
console.error('SA-CSS-CNTI-003: element text color matches background — text hidden', { el });
}
}
Attack 4: JS dynamic counter value — counter changes from 0 to 3 at mousedown (SA-CSS-CNTI-004)
The consent element starts with counter-reset: perms 0 so its ::before content renders "0 permissions". At mousedown on the install button, JS executes consentEl.style.setProperty('counter-reset', 'perms 3') — changing the rendered text to "3 permissions" for 200ms during the install click, then resetting to 0. The change is imperceptible to the user (too brief to read) but causes the rendered consent label to appear to say something different at the exact moment the user commits. The load-time audit sees "0 permissions" throughout static inspection; only MutationObserver on the style attribute with counter-reset parsing catches the dynamic change.
/* MCP attack: */
.consent-disclosure {
counter-reset: perms 0;
color: transparent;
}
.consent-disclosure::before {
content: counter(perms) " permissions will be granted";
color: var(--fg);
}
// MCP JS — changes counter at mousedown:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
const consent = document.querySelector('.consent-disclosure');
consent.style.counterReset = 'perms 3'; // "3 permissions" during click
setTimeout(() => {
consent.style.counterReset = 'perms 0'; // revert after 200ms
}, 200);
}, { capture: true });
// Detection:
function detectDynamicCounterChange() {
document.querySelectorAll('.consent-disclosure, [data-consent], #consent-panel').forEach(el => {
const observer = new MutationObserver(() => {
const before = getComputedStyle(el, '::before').content;
const after = getComputedStyle(el, '::after').content;
if (/counter\s*\(/.test(before + after)) {
console.error('SA-CSS-CNTI-004: counter() pseudo-element content detected', { el, before, after });
}
// Also check for counter-reset in inline style
if (el.style.counterReset) {
console.error('SA-CSS-CNTI-004: dynamic counter-reset change detected via style attribute', {
el, counterReset: el.style.counterReset
});
}
});
observer.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
});
}
Root cause across all four attacks: CSS pseudo-element content is not exposed by el.textContent, el.innerText, or accessibility APIs in their standard form. Detection requires getComputedStyle(el, '::before').content and getComputedStyle(el, '::after').content — any value containing counter( or counters( on a consent element is a finding. Additionally check element color against backgroundColor and for transparent to detect the companion text-hiding technique. SkillAudit's scanner checks both pseudo-element content and element text visibility on every identified consent disclosure element.
Attack summary
| ID | Technique | Visible text | DOM text | Severity |
|---|---|---|---|---|
| SA-CSS-CNTI-001 | counter-reset:0 + ::before counter() + color:transparent | "0 permissions granted" | Full consent disclosure | High |
| SA-CSS-CNTI-002 | Wizard step counter in ::before covers consent label | "Step 3 of 5: Continue" | Full consent disclosure | High |
| SA-CSS-CNTI-003 | counter-reset:-1 + ::after counter() = fake security scan | "-1 issues found" | Full consent disclosure | High |
| SA-CSS-CNTI-004 | JS counterReset style change at mousedown | "0 permissions" → "3 permissions" | Full consent disclosure | High |
Consolidated finding blocks
color:transparent to the consent element's own text and renders a ::before pseudo-element with content: counter(nothing) " permissions granted". The counter is never incremented, so it shows 0. DOM inspection, accessibility audits, and textContent checks all return the real consent disclosure. Only getComputedStyle(el, '::before').content reveals the substitution.
steps counter. The consent element uses a ::before pseudo-element with content: "Step " counter(steps) " of 5: " attr(data-label) and data-label="Continue". The consent element's own text is hidden via color:transparent. The user sees a wizard progress label; the consent disclosure is invisible.
::after pseudo-element as "-1 issues found" — visually resembling a security scan pass result. The consent element's own text is hidden. Users interpret the negative-issues count as a passed security check rather than a garbled consent disclosure.
el.style.counterReset = 'perms 3', briefly changing the pseudo-element content from "0 permissions" to "3 permissions" during the install interaction. Load-time audits see counter value 0. The dynamic change is imperceptible to the user but causes the rendered consent to differ from what was audited. MutationObserver on the style attribute detects the counterReset change.
CSS counter display attacks | CSS counter-reset security | Security Checklist