MCP server CSS counter-reset security: wrong initial value, counter-increment freeze, counter-set mid-flow override, and reversed step counter consent flow attacks

Published 2026-07-25 — SkillAudit Research

CSS counters — managed through the counter-reset, counter-increment, and counter-set properties — allow consent frameworks to build structured, numbered multi-step consent flows entirely in CSS. A common pattern is a three-step consent wizard where each panel's step indicator reads "Step 1 of 3", "Step 2 of 3", "Step 3 of 3" using CSS-generated content: content: "Step " counter(consent-step) " of 3". The framework initializes the counter with counter-reset: consent-step 0 on the container, and each panel increments it with counter-increment: consent-step.

MCP servers can corrupt this counter system without touching any consent text, removing any element from the DOM, or changing any visual display property. The consent text remains fully visible; the step numbering is corrupted. This attack works because counter manipulation is unlikely to appear in standard CSS security audits focused on display: none, opacity: 0, or visibility: hidden patterns.

Why corrupt step indicators? Multi-step consent flows serve a critical function in regulatory compliance: they guide users through distinct consent categories in sequence (e.g., "Step 1: Analytics consent", "Step 2: Marketing consent", "Step 3: Third-party data sharing consent"). Corrupted step indicators disrupt the user's ability to track which consent they are currently granting, how many steps remain, and whether all consent categories have been presented. This can result in users granting consent to steps they believe they have already completed, or skipping steps entirely because the numbering suggests the flow is broken.

Attack 1: counter-reset with wrong initial value produces impossible step numbers

The counter-reset property initializes a CSS counter to a specified integer value. The default initial value is 0, meaning the first counter-increment: consent-step brings it to 1. If a MCP server applies counter-reset: consent-step 99 on the consent container or an ancestor element, the counter starts at 99. Each step's counter-increment: consent-step advances it to 100, 101, 102 — producing "Step 100 of 3", "Step 101 of 3", "Step 102 of 3". The numbering is nonsensical but the text is still visible, so the consent element itself is never hidden.

/* Consent framework CSS: */
.consent-flow {
  counter-reset: consent-step 0;  /* initialize at 0 */
}
.consent-step {
  counter-increment: consent-step;
}
.consent-step::before {
  content: "Step " counter(consent-step) " of 3";
  /* Produces: "Step 1 of 3", "Step 2 of 3", "Step 3 of 3" */
}

/* MCP attack — counter-reset with wrong initial value: */
.consent-flow,
[class*="consent"],
#consent-wizard {
  counter-reset: consent-step 99;
  /* Now the counter starts at 99.
     Each step increments: 100, 101, 102.
     Step indicators read: "Step 100 of 3", "Step 101 of 3", "Step 102 of 3"
     Users have no idea which step they are on or how many remain.
     The consent text itself is fully visible — no hiding attack at all. */
}

// Detection: check counter-reset values on consent container ancestors
function detectWrongCounterReset() {
  // Walk all stylesheets and find counter-reset declarations on consent containers
  Array.from(document.styleSheets).forEach(sheet => {
    try {
      Array.from(sheet.cssRules || []).forEach(rule => {
        if (rule instanceof CSSStyleRule) {
          const cr = rule.style.getPropertyValue('counter-reset');
          if (cr && cr.includes('consent')) {
            // Parse the initial value from "consent-step 99" format
            const match = cr.match(/(\S+)\s+(-?\d+)/);
            if (match) {
              const initialValue = parseInt(match[2], 10);
              if (initialValue !== 0 && initialValue !== -1) {
                console.error('SECURITY: suspicious counter-reset initial value for consent counter:', {
                  counter: match[1], initialValue, selector: rule.selectorText
                });
              }
            }
          }
        }
      });
    } catch { /* cross-origin */ }
  });
}

Attack 2: counter-increment:0 freezes consent step progress indicator

The counter-increment property accepts an integer argument specifying the increment amount per element. The default increment is 1. Setting counter-increment: consent-step 0 increments the counter by zero — the counter value never changes regardless of how many consent steps the DOM contains. Every step indicator resolves to the same number: "Step 1 of 3", "Step 1 of 3", "Step 1 of 3". The user cannot determine which consent category is being presented or whether they have advanced through the flow at all.

/* Consent framework CSS (as before): */
.consent-step {
  counter-increment: consent-step;  /* default increment: 1 */
}
.consent-step::before {
  content: "Step " counter(consent-step) " of 3";
}

/* MCP attack — zero-increment freeze: */
.consent-step,
[class*="consent-panel"],
[class*="consent-screen"],
.wizard-step {
  counter-increment: consent-step 0;
  /* Zero increment: counter value never changes.
     All steps read "Step 1 of 3" (or whatever the reset value + 0 = 1).
     Users think they are always on step 1.
     They may skip steps 2 and 3 thinking the flow is stuck/broken,
     or they may accept/decline step 1 repeatedly thinking each click
     advances to the next consent category. */
}

/* Even more disorienting: negative increment — steps count down */
.consent-step {
  counter-increment: consent-step -1;
  /* With counter-reset at 4: steps read 3, 2, 1, 0.
     Consent flow appears to count down toward an unknown goal.
     "Step 3 of 3", "Step 2 of 3", "Step 1 of 3" — flow direction reversed.
     Users who complete "Step 1" don't know if that was the first or last consent. */
}

// Detection: verify that counter-increment values are non-zero for consent counters
function detectZeroCounterIncrement() {
  Array.from(document.styleSheets).forEach(sheet => {
    try {
      Array.from(sheet.cssRules || []).forEach(rule => {
        if (rule instanceof CSSStyleRule) {
          const ci = rule.style.getPropertyValue('counter-increment');
          if (!ci) return;
          // Look for "consent-step 0" or similar zero-increment patterns
          const match = ci.match(/(\S+)\s+(-?\d+)/);
          if (match) {
            const increment = parseInt(match[2], 10);
            if (increment === 0) {
              console.error('SECURITY: counter-increment:0 on consent counter — step indicator frozen:', {
                counter: match[1], selector: rule.selectorText
              });
            }
            if (increment < 0) {
              console.warn('SECURITY: negative counter-increment on consent counter — step flow reversed:', {
                counter: match[1], increment, selector: rule.selectorText
              });
            }
          }
        }
      });
    } catch { /* cross-origin */ }
  });
}

Attack 3: counter-set mid-flow override loops consent steps back to step 1

The CSS counter-set property (CSS Lists Level 3) forcibly sets a counter to a specific value, overriding any accumulated increments. Unlike counter-reset (which only initializes on an ancestor) and counter-increment (which changes the rate), counter-set acts as an absolute assignment. An MCP server targets the second and third consent step elements with counter-set: consent-step 0, which resets the counter to 0 on those elements, causing the subsequent counter-increment (still active from the framework's stylesheet) to advance to 1 again. Steps 2 and 3 both display as "Step 1 of 3".

/* Consent framework CSS: */
.consent-flow { counter-reset: consent-step 0; }
.consent-step { counter-increment: consent-step; }
.consent-step::before { content: "Step " counter(consent-step) " of 3"; }

/* MCP attack — counter-set mid-flow loop: */
.consent-step:nth-child(n+2) {
  counter-set: consent-step 0;
  /* counter-set assigns counter-step = 0 on steps 2, 3, 4, ...
     Each is then incremented by consent-step's counter-increment.
     Step 1: counter reaches 1  → "Step 1 of 3"  (normal)
     Step 2: counter-set to 0, then incremented to 1 → "Step 1 of 3"  (looped!)
     Step 3: counter-set to 0, then incremented to 1 → "Step 1 of 3"  (looped!)
     All steps show "Step 1 of 3".
     Users believe they are perpetually on the first consent step. */
}

/* Variant: counter-set to step count + 1 makes steps appear "past the end" */
.consent-step:nth-child(2) {
  counter-set: consent-step 3;
  /* Step 2 → counter-set to 3 → incremented to 4 → "Step 4 of 3"
     Users see the second consent step labelled as "Step 4 of 3" — past the end.
     They may believe they have already completed all 3 consent steps
     and the fourth is an unexpected surprise, causing them to dismiss it. */
}

// Detection: look for counter-set on consent step elements
function detectCounterSetOverride() {
  Array.from(document.styleSheets).forEach(sheet => {
    try {
      Array.from(sheet.cssRules || []).forEach(rule => {
        if (rule instanceof CSSStyleRule) {
          const cs = rule.style.getPropertyValue('counter-set');
          if (cs && (rule.selectorText.includes('consent') || rule.selectorText.includes('step') || rule.selectorText.includes('wizard'))) {
            console.warn('SECURITY: counter-set detected on consent-related selector:', {
              selector: rule.selectorText, counterSet: cs
            });
          }
        }
      });
    } catch { /* cross-origin */ }
  });
}

Attack 4: negative counter-increment reverses consent step flow direction

A less obvious variant of the counter manipulation attack uses a negative counter-increment value combined with a high counter-reset initial value to create a countdown effect that reverses the apparent flow direction of the consent wizard. If the framework uses 3-step consent and the MCP sets counter-reset: consent-step 4 and counter-increment: consent-step -1, the steps read "Step 3 of 3", "Step 2 of 3", "Step 1 of 3". The user begins at "Step 3" — the last step — and appears to progress backward. After the last panel appears as "Step 1 of 3", the user may think there are more steps ahead (since they started at 3), failing to realize they have already seen all consent categories.

/* MCP attack — reversed counter producing backward consent flow: */
.consent-flow {
  counter-reset: consent-step 4;
  /* Counter starts at 4 (one above the total of 3 steps) */
}
.consent-step {
  counter-increment: consent-step -1;
  /* Each step decrements counter by 1: 4→3, 3→2, 2→1 */
}
.consent-step::before {
  content: "Step " counter(consent-step) " of 3";
  /* Produces: "Step 3 of 3", "Step 2 of 3", "Step 1 of 3" */
}

/* The flow appears to run backwards:
   - User starts on "Step 3 of 3" (the analytics consent panel)
   - Clicks "Next" → arrives at "Step 2 of 3" (the marketing consent panel)
   - Clicks "Next" → arrives at "Step 1 of 3" (the third-party sharing consent panel)
   - After "Step 1 of 3", the user may expect "Step 0 of 3" or assume the flow is done
   - The framework considers the flow complete after all 3 steps regardless of order
   - But the user's mental model of "I'm still at step 3 / step 2" causes confusion */

/* Even more subtle: combined with a total-steps counter that's also manipulated */
.consent-flow::after {
  counter-reset: total-steps 99;  /* total shown as 99 even though only 3 exist */
}
.consent-step::before {
  content: "Step " counter(consent-step) " of " counter(total-steps);
  /* "Step 3 of 99", "Step 2 of 99", "Step 1 of 99"
     User believes there are 99 consent steps remaining — overwhelms and discourages
     careful reading of each individual consent item. */
}

// Audit consent counters for any non-standard initial values and increments
function auditConsentCounters(containerEl) {
  const cs = window.getComputedStyle(containerEl);
  console.log('Counter audit for', containerEl.id || containerEl.className, {
    counterReset: cs.counterReset,
    counterIncrement: cs.counterIncrement,
    counterSet: cs.counterSet
  });
  // Collect all step elements and read their generated content
  containerEl.querySelectorAll('[class*="step"], [class*="panel"], .consent-item').forEach((step, i) => {
    const before = window.getComputedStyle(step, '::before');
    console.log(`  Step ${i + 1} generated content:`, before.content);
  });
}

Detection difficulty: CSS counter corruption attacks are particularly hard to detect via automated security scanning because (1) no element is hidden — all consent text remains visible and findable in the DOM; (2) the attack operates entirely through CSS generated content, not through HTML text changes; (3) counter-reset, counter-increment, and counter-set declarations are not in the typical suspicious-CSS checklist (which focuses on display, visibility, opacity, z-index, and transform: translateY attacks). Runtime detection requires actually computing the generated content value of ::before and ::after pseudo-elements and comparing it against expected step sequences.

Attack summary

Attack Property Effect on consent flow Severity
Wrong initial value counter-reset: consent-step 99 "Step 100 of 3", "Step 101 of 3" — impossible numbers Medium
Zero-increment freeze counter-increment: consent-step 0 All steps show "Step 1 of 3" — flow appears stuck Medium
counter-set mid-flow loop counter-set: consent-step 0 on steps 2+ Steps 2 and 3 reset to "Step 1 of 3" — loop Medium
Reversed counter countdown counter-reset: 4 + counter-increment: -1 "Step 3", "Step 2", "Step 1" — flow runs backward Medium

Consolidated finding blocks

Medium CSS counter-reset wrong initial value — impossible consent step numbers: MCP server applies counter-reset: consent-step 99 on the consent flow container or an ancestor. The CSS-generated step indicator reads "Step 100 of 3", "Step 101 of 3" for subsequent steps. Users cannot determine which consent category they are currently reviewing. No element is hidden; the attack operates entirely through CSS counter initialization without any display manipulation.
Medium CSS counter-increment:0 — frozen consent step indicator: MCP server overrides counter-increment with a zero value for consent step elements. Every step in the multi-step consent flow displays the same step number ("Step 1 of 3"). Users cannot tell whether they have advanced to a new consent category or whether the flow is broken. The attack may cause users to click through all steps believing they are re-reviewing the same consent without reading subsequent consent categories.
Medium CSS counter-set mid-flow loop — consent steps loop to Step 1: MCP server applies counter-set: consent-step 0 on consent step elements starting from the second step. counter-set forcibly assigns the counter regardless of accumulated increments, then the framework's own counter-increment advances from 0 to 1 again. Steps 2 and 3 display as "Step 1 of 3". Users in a 3-step consent flow see only "Step 1" repeatedly and may not understand that they are viewing distinct consent categories.
Medium CSS reversed counter — consent flow counts backward: MCP server combines a high counter-reset initial value with a negative counter-increment to produce a countdown flow: "Step 3 of 3", "Step 2 of 3", "Step 1 of 3". Users begin at the apparent last step and work backward to step 1, disrupting their mental model of the consent flow. After seeing "Step 1 of 3", users may expect more steps ahead rather than recognizing that all consent categories have been presented.

← Blog  |  CSS custom identifier attacks  |  Security Checklist