Security reference · CSS injection · Counter reset · Step numbering · Consent manipulation

MCP server CSS counter-reset consent security

CSS counter-reset initializes a named counter to a specified integer value. In a multi-step MCP install flow, the current step number displayed to the user — "Step 2 of 4", "Step 3: Review Permissions" — is typically rendered via CSS counters that are initialized with counter-reset and advanced with counter-increment. An attacker who controls the skill's CSS can manipulate these counter values to confuse the user about which step they are viewing: counter-reset: step -1 causes the single consent step to display as "Step 0" or "Step -1" (suggesting an initialization or pre-step that can be skipped); counter-reset: consent-step 99 makes the consent step label read "Step 99 of ?" (implying 98 prior steps the user has implicitly agreed to); a counter-reset on a sibling element mid-sequence causes the consent counter to restart, making it appear as a repeated or supplementary step; and CSS counter-set (2022 spec) allows forcing an arbitrary value directly on the counter without resetting surrounding elements.

CSS counter-reset attack surface overview

Attack variantcounter-reset valueDisplayed step labelUser confusion vector
Negative initial valuecounter-reset: step -1"Step 0" or "Step -1 of 3"Looks like initialization/pre-step — users skip "Step 0" items
High initial valuecounter-reset: consent-step 99"Step 100 of ?" (first increment)Implies many prior agreed steps — user believes they already accepted
Sibling reset mid-sequencecounter-reset: step 0 on non-consent siblingConsent step re-numbers to step 1 after a re-resetConsent appears to be a repeated/supplementary item
counter-set forced valuecounter-set: step 0 on consent element (CSS 2022)Consent step labeled "Step 1" (after increment) regardless of sequenceCan make consent appear anywhere in an arbitrary sequence

counter-reset attacks target user step-awareness, not text legibility: Standard consent audits check whether consent text is visible, legible, and has sufficient size. They do not verify whether the step numbering shown to the user accurately reflects the sequence — whether the user knows they are on the actual consent step versus a configuration or summary step. Counter manipulation exploits the user's learned behavior of "I've done this many times, let me click through to the numbered consent step" by making the consent step appear at an unexpected or implied-skippable number.

Attack 1: counter-reset: step -1 — consent appears as Step 0 (initialization)

The CSS counter specification allows negative initial values in counter-reset. When a step counter is initialized to -1 and the consent step uses counter-increment: step (default increment of +1), the consent step displays as "Step 0". Users who have completed multi-step install flows before have learned that "Step 0" or negative step numbers are initialization steps, configuration preambles, or legal headers that do not require active attention. A consent step labeled "Step 0: Terms" is far more likely to be skimmed than one labeled "Step 2: Review Permissions":

/* Normal install flow — steps 1, 2, 3 */
.mcp-install-steps {
  counter-reset: step 0; /* starts at 0; first increment → Step 1 */
}
.mcp-step::before {
  counter-increment: step;
  content: "Step " counter(step) ": ";
  font-weight: bold;
}
/* Result: Step 1: Download | Step 2: Consent | Step 3: Configure */

/* Malicious override — SA-CSS-CTR-001 */
.mcp-install-steps {
  counter-reset: step -1; /* starts at -1; first increment → Step 0 */
}
/* Result: Step 0: Download | Step 1: Consent | Step 2: Configure

   The consent step is now labeled "Step 1" — appearing as the very first
   numbered step (which users often skim as setup boilerplate).

   OR with a different setup:
   counter-reset: step -2 → consent is "Step -1: Review" (negative!)
   Counter values in CSS can be negative — getComputedStyle does not cap at 0.
*/

/* Even more confusing: counter-reset: step -1 on the CONSENT element itself */
.mcp-consent-step {
  counter-reset: step -1; /* resets the counter for this element */
  /* If the surrounding sequence had been at step 2, the consent element
     resets to -1, increments to 0, and displays as "Step 0: Consent Review"
     — making it appear to be the initialization step at the wrong position. */
}

function detectNegativeCounterReset(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  for (const el of root.querySelectorAll('*')) {
    if (!CONSENT.test((el.textContent || '').substring(0, 300))) continue;
    /* Check the element and its ancestors for counter-reset with negative values */
    let node = el;
    while (node && node !== document.body) {
      const cs = getComputedStyle(node);
      const counterReset = cs.counterReset || '';
      /* Negative counter values appear as e.g. "step -1" in computed style */
      const negMatch = counterReset.match(/(\w+)\s+(-\d+)/);
      if (negMatch) {
        findings.push({ id: 'SA-CSS-CTR-001', severity: 'medium',
          message: `Element in consent ancestor chain has counter-reset: ${negMatch[1]} ${negMatch[2]}. A negative initial counter value causes consent step labels to display as Step 0 or negative step numbers, making the consent step appear as a skippable initialization item.` });
      }
      node = node.parentElement;
    }
  }
  return findings;
}

Attack 2: counter-reset: consent-step 99 — single consent step labeled Step 100

The inverse attack: start the counter at a very high value. When the single consent step applies counter-increment: consent-step, the displayed label becomes "Step 100 of ?" (if the total is unknown) or "Step 100 of 100" (implying 99 prior steps the user has already agreed to). A user who sees "Step 100 of 100" interprets it as the final step of a long process they have been completing — they believe they have already agreed to the preceding 99 steps and this is just the confirmation. The reality is that this is the only step and it is the entire consent disclosure:

/* Malicious CSS — SA-CSS-CTR-002 */
.mcp-consent-wrapper {
  counter-reset: consent-step 99; /* start at 99 — first increment → 100 */
}

.mcp-consent-header::before {
  counter-increment: consent-step;
  content: "Step " counter(consent-step) " of 100: ";
  /* Displays: "Step 100 of 100: Review Permissions"
     The user interprets this as: they've completed 99 prior steps,
     and this is the final confirmation. They believe prior steps
     contained the detailed consent — this is just a summary.
     In reality, there is only one step and this IS the full consent. */
}

/* Variant: counter starts at large value to imply many prior agreements */
.mcp-install-outer {
  counter-reset: agreement-counter 47;
}
.mcp-consent-item::before {
  counter-increment: agreement-counter;
  content: "Agreement #" counter(agreement-counter) ": ";
  /* Displays: "Agreement #48: File System Access"
     User sees agreement number 48 and assumes 47 prior agreements exist. */
}

function detectHighInitialCounter(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  for (const el of root.querySelectorAll('*')) {
    if (!CONSENT.test((el.textContent || '').substring(0, 300))) continue;

    let node = el;
    while (node && node !== document.body) {
      const cs = getComputedStyle(node);
      const counterReset = cs.counterReset || '';
      /* High counter values: match "counterName NNN" where NNN > 10 */
      const highMatch = counterReset.match(/(\w+)\s+(\d+)/);
      if (highMatch && parseInt(highMatch[2]) > 10) {
        findings.push({ id: 'SA-CSS-CTR-002', severity: 'high',
          message: `Element in consent ancestor chain has counter-reset: ${highMatch[1]} ${highMatch[2]}. Starting at ${highMatch[2]}, the first counter-increment produces step label ${parseInt(highMatch[2]) + 1}, implying ${highMatch[2]} prior completed steps and making this consent appear as a late step in a long already-agreed sequence.` });
      }
      node = node.parentElement;
    }
  }
  return findings;
}

Attack 3: counter-reset on sibling — consent step re-numbers mid-sequence

A counter-reset on a non-consent sibling element that appears just before the consent element in the DOM resets the running step counter. The overall install flow shows steps 1, 2, 3 — then the sibling resets the counter to 0, and the consent step displays as "Step 1" again (after increment). This makes the consent appear to be a secondary "Step 1" that repeats earlier configuration steps. Users who already saw and mentally processed a "Step 1" earlier in the flow may treat this second "Step 1" as a repeated/duplicate of something they already reviewed:

/* Malicious CSS — SA-CSS-CTR-003 */

/* Scenario: install flow is steps 1 (config), 2 (terms), 3 (consent) */
.mcp-step-wrapper {
  counter-reset: step 0;
}
.mcp-step::before {
  counter-increment: step;
  content: "Step " counter(step) ": ";
}
/* Without attack: Step 1: Config | Step 2: Terms | Step 3: Consent */

/* The malicious non-consent sibling appears between steps 2 and 3 */
.mcp-decoration-divider {
  /* Looks like a visual divider element — user ignores it */
  height: 1px;
  background: #eee;

  /* The attack: resets the step counter */
  counter-reset: step 0;
  /* After this element, the next step increments from 0 → 1.
     Result: Step 1: Config | Step 2: Terms | [divider resets] | Step 1: Consent
     The consent step is labeled "Step 1" again — user thinks it's a
     duplicate of the config step they already dismissed. */
}

function detectSiblingCounterReset(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  /* Find all consent elements */
  const consentEls = Array.from(root.querySelectorAll('*'))
    .filter(el => CONSENT.test((el.textContent || '').substring(0, 300)));

  for (const consentEl of consentEls) {
    /* Check previous siblings for counter-reset */
    let sibling = consentEl.previousElementSibling;
    let siblingCount = 0;
    while (sibling && siblingCount < 5) {
      const cs = getComputedStyle(sibling);
      const counterReset = cs.counterReset || '';
      if (counterReset && counterReset !== 'none') {
        /* A preceding sibling resets a counter — may affect consent step label */
        if (!CONSENT.test((sibling.textContent || '').substring(0, 100))) {
          findings.push({ id: 'SA-CSS-CTR-003', severity: 'medium',
            message: `A non-consent sibling element immediately before the consent element has counter-reset: ${counterReset}. This resets the step counter mid-sequence, potentially causing the consent step label to display a lower number (e.g., "Step 1" again), making the consent appear to repeat an earlier already-reviewed step.` });
        }
      }
      sibling = sibling.previousElementSibling;
      siblingCount++;
    }
  }
  return findings;
}

Attack 4: counter-set (CSS 2022) — force arbitrary consent step value

The CSS counter-set property (Level 3 Counters spec, Chrome 85+, Firefox 68+) allows setting a counter to an arbitrary value without the "reset propagation" behavior of counter-reset. Unlike counter-reset, which initializes the counter for all descendants, counter-set simply sets the current value for that element. An attacker can use counter-set: step 0 directly on the consent element to force it to display as "Step 1" (after the standard increment) regardless of what the surrounding sequence has been doing. This is more precise and harder to detect than counter-reset because it operates only on the specific element without affecting the broader counter state:

/* Malicious CSS — SA-CSS-CTR-004 (CSS counter-set, Chrome 85+, Firefox 68+) */

/* Scenario: install flow has properly incremented to step 3 for the consent */
/* Normal display would be: "Step 3: Review Permissions" */

/* counter-set forces the value to 0 on the consent element alone */
.mcp-consent-step {
  counter-set: step 0; /* force this element's counter to 0 */
  /* counter-increment: step then fires → renders "Step 1: Review Permissions" */
  /* All other steps in the sequence are unaffected — they were already rendered
     earlier in the flow. Only this element is re-labeled to "Step 1". */
}

/* Even more targeted: set to a value that makes consent look like step 0 */
.mcp-consent-step.aggressive {
  counter-set: step -1; /* counter-set to -1, increment → 0 */
  /* Renders "Step 0: Review Permissions" — initialization step */
}

/* Detection: check counter-set on consent elements and their ancestors */
function detectCounterSet(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  for (const el of root.querySelectorAll('*')) {
    if (!CONSENT.test((el.textContent || '').substring(0, 300))) continue;
    const cs = getComputedStyle(el);
    const counterSetVal = cs.counterSet || '';

    if (counterSetVal && counterSetVal !== 'none') {
      const valMatch = counterSetVal.match(/(\w+)\s+(-?\d+)/);
      if (valMatch) {
        const num = parseInt(valMatch[2]);
        if (num <= 0 || num > 10) {
          findings.push({ id: 'SA-CSS-CTR-004', severity: 'medium',
            message: `Consent element has counter-set: ${counterSetVal} (CSS 2022). Setting the counter to ${num} forces the consent step to display as step ${num + 1}, ${num === 0 || num < 0 ? 'making it appear as an initialization/pre-step' : 'placing it in an implausible position in the sequence'} — independent of the actual surrounding sequence.` });
        }
      }
    }
  }
  return findings;
}

counter-reset attacks manipulate user cognitive shortcuts, not rendering: These attacks do not hide text or change visual properties. They exploit the learned behavior pattern of users who have completed many install flows: "I'll pay attention on the numbered consent step." By manipulating which number appears on the consent step, an attacker makes the real consent appear at a "skippable" position (Step 0, Step -1) or at a position that implies prior agreement (Step 99, Step 100). Standard consent audits that verify text presence, legibility, and contrast miss this class of attack entirely.

SkillAudit findings for CSS counter-reset consent attacks

MediumSA-CSS-CTR-001 — An element in the consent ancestor chain has counter-reset with a negative initial value. The consent step label displays as "Step 0" or a negative step number, making the consent appear to be a skippable initialization or pre-step rather than a required consent disclosure.
HighSA-CSS-CTR-002 — An element in the consent ancestor chain has counter-reset with an initial value greater than 10. The consent step label displays as a high number (e.g., "Step 100 of 100"), implying the user has already agreed to many prior steps. In reality, this may be the only consent step in the entire flow.
MediumSA-CSS-CTR-003 — A non-consent sibling element immediately preceding the consent element in DOM order has a counter-reset declaration. This mid-sequence reset may cause the consent step counter to restart, displaying a repeated step number (e.g., "Step 1" appearing again after earlier steps 1 and 2) that suggests the consent is a duplicate of an already-reviewed item.
MediumSA-CSS-CTR-004 — The consent element has a counter-set declaration (CSS 2022, Chrome 85+, Firefox 68+) setting the counter to a value that places the consent step at position 0 or lower, or above position 10. counter-set forces the step label independent of the surrounding sequence — the consent step can appear at any arbitrary position.

Related MCP consent attack research

SkillAudit's consent audit inspects counter-reset and counter-set values on consent elements and their ancestor chains, flags negative initial values (SA-CSS-CTR-001), high initial values above 10 (SA-CSS-CTR-002), non-consent sibling resets before the consent element (SA-CSS-CTR-003), and counter-set forcing of implausible step positions (SA-CSS-CTR-004). Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-CTR findings.