MCP server CSS ::marker security: consent list bullet transparency, false checkmark substitution, oversized marker covering text, and counter corruption attacks

Published 2026-07-25 — SkillAudit Research

The CSS ::marker pseudo-element (fully supported in all modern browsers since 2020) matches the list item marker — the bullet, number, or custom counter-generated string that appears before a <li> element. The ::marker pseudo-element accepts a restricted set of CSS properties: color, content, font-size, font-family, font-style, font-weight, text-decoration, unicode-bidi, white-space, and animation/transition. Of these, color, content, and font-size are the most dangerous for consent manipulation.

Multi-step consent flows are the primary target for ::marker attacks. A typical consent checklist presents items as an ordered or unordered list: "1. We collect your email address", "2. We share data with partners", "3. You can withdraw consent at any time". The ::marker pseudo-element controls the "1.", "2.", "3." prefixes. Corrupting these markers destroys the logical structure of the consent flow without altering the consent text content.

::marker detection gap: Like other pseudo-elements, ::marker styles require getComputedStyle(element, '::marker') to retrieve. Standard consent audits that iterate document.querySelectorAll('li') and check element visibility see the list items as fully visible, with intact text content. The markers — which may be invisible, misleading, or oversized — are not inspected. A content: '✓' rule that changes all step numbers to checkmarks is indistinguishable from a DOM-only audit.

Attack 1: color:transparent hides step numbers from consent disclosure list

An MCP server applies ::marker { color: transparent } to the consent list items. All step numbers or bullets become invisible — the list renders as unnumbered, unmarked paragraphs that are harder to parse as a structured multi-step consent flow. Users lose the ability to count how many consent items there are, which item they are reviewing, and whether they have reached the end of the list. Regulatory requirements for a structured, navigable consent disclosure are undermined without hiding any consent text. The effect is primarily navigational corruption: the user cannot track their position in the consent flow.

/* MCP attack — consent list markers invisible: */
.consent-checklist li::marker,
ol.consent-steps li::marker,
#gdpr-disclosures li::marker {
  color: transparent;
  /* Step numbers "1.", "2.", "3." become transparent.
     List items:
       • [invisible 1.] We collect your email address.
       • [invisible 2.] We share data with advertising partners.
       • [invisible 3.] Data may be transferred to the United States.
     User sees:
       We collect your email address.
       We share data with advertising partners.
       Data may be transferred to the United States.
     (Appears as unnumbered paragraphs — navigational structure destroyed)

     DOM: li.textContent → '1.We collect your email address.' (number is in CSS)
     Wait — actually the step number from CSS counter is NOT in textContent.
     li.textContent → 'We collect your email address.' (marker not in DOM text)
  */
}

// Detection: check ::marker color on consent lists
function detectMarkerColorAttack() {
  const listItems = document.querySelectorAll(
    '.consent-checklist li, ol[class*="consent"] li, [class*="disclosure"] li'
  );

  listItems.forEach((li, index) => {
    const pseudoStyle = window.getComputedStyle(li, '::marker');
    const markerColor = pseudoStyle.color;

    if (markerColor === 'rgba(0, 0, 0, 0)' || markerColor === 'transparent') {
      console.error('SECURITY: ::marker color is transparent on consent list item ' + (index+1) + ':', {
        li, textContent: li.textContent.substring(0, 80)
      });
    }
  });

  // Scan stylesheets for ::marker color rules
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSStyleRule &&
            rule.selectorText && rule.selectorText.includes('::marker')) {
          const color = rule.style.color;
          if (color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
            console.error('SECURITY: ::marker color:transparent rule in stylesheet:', {
              selector: rule.selectorText
            });
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

Attack 2: content:'✓' substitutes false acceptance checkmarks for all consent step numbers

The ::marker pseudo-element supports the content property. An MCP server sets ::marker { content: '✓ ' } on all consent list items. All step numbers ("1.", "2.", "3.") or bullets ("•") are replaced with green checkmarks. The visual implication is that all consent items have already been accepted or acknowledged — users who see a list with pre-filled checkmarks may interpret this as confirmation that they have already agreed to all items, and proceed without reading. The deceptive UI pattern exploits the familiar UX convention where checkmarks indicate completed or accepted items.

/* MCP attack — all consent steps show acceptance checkmarks: */
.consent-list li::marker {
  content: '✓ ';
  color: #22c55e;  /* green color reinforces "accepted" visual meaning */
  font-weight: bold;
  /* User sees:
     ✓ We collect your email address and browsing history.
     ✓ We share your data with advertising partners in 47 countries.
     ✓ We use your data for AI model training without compensation.
     ✓ Data retention period: indefinite.

     All four checkmarks are identical. The user interprets this as:
     "I have already accepted all four items" — and doesn't read them,
     since they appear to be already-confirmed.

     The actual DOM: 
  • We collect your email address and browsing history.
  • The "✓ " is CSS-generated content, not in the DOM text. */ } /* Variant: use Unicode U+2713 (✓) or U+2714 (✔) in content value: */ .consent-steps li::marker { content: '\2714 '; /* ✔ HEAVY CHECK MARK */ color: #16a34a; } // Detection: check for non-standard content values in ::marker function detectMarkerContentSubstitution() { for (const sheet of document.styleSheets) { try { for (const rule of sheet.cssRules) { if (rule instanceof CSSStyleRule && rule.selectorText && rule.selectorText.includes('::marker')) { const content = rule.style.content; if (content && content !== '' && content !== 'none' && content !== 'normal') { // Custom content in ::marker — check if it looks like an acceptance indicator const DECEPTIVE_PATTERNS = [ '✓', '✔', '✅', '☑', // checkmarks 'check', 'accepted', 'ok', 'yes', '×', '✘' // also X marks (confusing) ]; const contentLower = content.toLowerCase(); const isDeceptive = DECEPTIVE_PATTERNS.some(p => content.includes(p) || contentLower.includes(p) ); if (isDeceptive) { console.error('SECURITY: ::marker content substitution with acceptance indicator:', { selector: rule.selectorText, content }); } else { console.warn('SECURITY: ::marker uses custom content (verify this is intentional):', { selector: rule.selectorText, content }); } } } } } catch (e) { /* cross-origin */ } } // Also check computed ::marker content on consent list items document.querySelectorAll('li').forEach(li => { const pseudoStyle = window.getComputedStyle(li, '::marker'); const content = pseudoStyle.content; if (content && content !== 'none' && content.includes('✓')) { console.error('SECURITY: computed ::marker content contains checkmark:', { li, content, text: li.textContent.substring(0, 60) }); } }); }

    Attack 3: font-size:3em bloats markers to cover adjacent consent list item text

    The ::marker pseudo-element supports font-size. A standard list marker ("•" or "1.") is sized to match the list item's font-size. Setting ::marker { font-size: 3em } makes the marker three times larger than the list item text. The oversized marker glyph — particularly for disc bullets or wide Unicode characters — can physically overlap the beginning of the list item text to its right. The first words of each consent item are covered by the bloated marker glyph, making the critical opening clause of each consent disclosure unreadable. The effect is particularly severe for Arabic, Hebrew, or other RTL consent text where the marker appears on the right side of the item.

    /* MCP attack — consent list markers bloated to cover item text: */
    .consent-disclosure-list li::marker {
      font-size: 3em;
      /* Default: marker and list item text same size (1em each)
         Attack: marker is 3× the list item text size
         A "1." marker at 3em on 14px text = 42px numerals
         The "1." digits physically overlap "We collect..." text
    
         For disc bullets:
         font-size: 3em makes • a ~42px disc that covers the
         first word(s) of the consent item beside it.
    
         For items with ::marker { content: '•'; font-size: 3em }:
         The bullet occupies the left marker box AND overflows
         into the list item content box, covering opening text. */
    }
    
    /* More subtle variant: use a wide Unicode block character: */
    .consent-items li::marker {
      content: '\25A0';  /* ■ BLACK SQUARE — wider than bullet */
      font-size: 2em;
      color: var(--bg);  /* transparent square covers consent text */
      /* The 2em black square matches page background color.
         It acts as an invisible rectangle covering the first
         word of each consent item.
         The marker box extends into the list item content box
         when font-size exceeds the normal marker box allocation. */
    }
    
    // Detection: check ::marker font-size relative to list item font-size
    function detectMarkerFontSizeBloat() {
      const listItems = document.querySelectorAll(
        '.consent-list li, .consent-checklist li, [class*="consent"] li, [class*="disclosure"] li'
      );
    
      listItems.forEach(li => {
        const pseudoStyle = window.getComputedStyle(li, '::marker');
        const liStyle = window.getComputedStyle(li);
    
        const markerFontSize = parseFloat(pseudoStyle.fontSize);
        const liFontSize = parseFloat(liStyle.fontSize);
    
        // Markers larger than 2× the list item font size are suspicious
        if (markerFontSize > liFontSize * 2) {
          console.error('SECURITY: ::marker font-size (' + markerFontSize + 'px) is',
            Math.round(markerFontSize / liFontSize) + '× the list item font-size (' + liFontSize + 'px):', {
            li, text: li.textContent.substring(0, 60)
          });
        }
    
        // Also check for background-colored content
        const markerColor = pseudoStyle.color;
        const pageBg = window.getComputedStyle(document.body).backgroundColor;
        if (markerFontSize > liFontSize * 1.5 && markerColor === pageBg) {
          console.error('SECURITY: ::marker is large AND matches page background (invisible bloat):', {
            li, markerFontSize, markerColor
          });
        }
      });
    }

    Attack 4: counter manipulation via ::marker content attr produces wrong step numbers

    CSS counters work in conjunction with ::marker. In an ordered list, each <li> auto-increments a counter and ::marker renders it. An MCP server can use a separate counter-reset on a parent element combined with a custom ::marker { content: counter(my-counter) '/' counter(total-steps) } to display step progress indicators ("Step 2 of 5"). By injecting counter-reset: total-steps 99 on a non-visible ancestor, the progress shows "Step 2 of 100" instead of "Step 2 of 5" — making the consent flow appear impossibly long and discouraging users from continuing. Alternatively, setting counter-increment: my-counter 0 on all list items freezes the counter, making all items show "Step 1 of 5".

    /* MCP attack — counter corruption makes consent steps appear infinitely long: */
    
    /* Attacker injects this rule (e.g., via a loaded MCP stylesheet): */
    body, html, .consent-container {
      counter-reset: total-steps 99;
      /* The consent framework sets: counter-reset: total-steps 5 (for a 5-step flow).
         MCP overrides this on body/html with higher specificity: total-steps = 99.
         ::marker content: 'Step ' counter(step-num) ' of ' counter(total-steps) '.'
         Now renders: "Step 1 of 100", "Step 2 of 100", ..., "Step 5 of 100"
         Users believe the consent form has 100 steps — abandon the flow. */
    }
    
    /* Variant: freeze all items at step 1: */
    .consent-steps li {
      counter-increment: step-num 0;  /* increment by 0 = no change */
      /* All items: "Step 1 of 5", "Step 1 of 5", "Step 1 of 5", "Step 1 of 5"
         Users cannot determine which consent category they are reviewing. */
    }
    
    /* Variant: reverse count — steps run backwards: */
    .consent-items {
      counter-reset: step-num 6;  /* start at 6 for a 5-step flow */
    }
    .consent-items li {
      counter-increment: step-num -1;  /* decrement: 5, 4, 3, 2, 1 */
      /* "Step 5 of 5", "Step 4 of 5", "Step 3 of 5", "Step 2 of 5", "Step 1 of 5"
         Users start at the "last" step and see it winding down — may think they are
         at the end of a consent flow they haven't actually read. */
    }
    
    // Detection: check for counter manipulation on consent parent elements
    function detectCounterCorruption() {
      // Check for counter-reset on non-list ancestors
      const potentialAttackers = ['body', 'html', '#app', '.wrapper', '#root', '.page'];
      potentialAttackers.forEach(sel => {
        const el = document.querySelector(sel);
        if (el) {
          const style = window.getComputedStyle(el);
          // counterReset not directly readable from getComputedStyle in all browsers
          // Fall back to stylesheet inspection
        }
      });
    
      for (const sheet of document.styleSheets) {
        try {
          for (const rule of sheet.cssRules) {
            if (rule instanceof CSSStyleRule) {
              const counterReset = rule.style.counterReset || rule.style.getPropertyValue('counter-reset');
              const counterIncrement = rule.style.counterIncrement || rule.style.getPropertyValue('counter-increment');
    
              if (counterReset && counterReset !== 'none') {
                // Check if the reset value is suspiciously high
                const matches = counterReset.match(/(\w+)\s+(\d+)/g);
                if (matches) {
                  matches.forEach(m => {
                    const parts = m.split(/\s+/);
                    const value = parseInt(parts[1]);
                    if (value > 20) {
                      console.error('SECURITY: suspiciously high counter-reset value (counter-corruption attack):', {
                        selector: rule.selectorText, counterReset, value
                      });
                    }
                  });
                }
              }
    
              if (counterIncrement && counterIncrement.includes(' 0')) {
                // counter-increment: name 0 freezes counter
                console.warn('SECURITY: counter-increment of 0 detected (step freeze attack):', {
                  selector: rule.selectorText, counterIncrement
                });
              }
              if (counterIncrement && /\s-\d+/.test(counterIncrement)) {
                // Negative counter-increment reverses step count
                console.error('SECURITY: negative counter-increment (reverse step count attack):', {
                  selector: rule.selectorText, counterIncrement
                });
              }
            }
          }
        } catch (e) { /* cross-origin */ }
      }
    }

    ::marker attacks corrupt multi-step consent navigation: Regulated consent flows (GDPR Art. 7, ePrivacy Directive, CCPA) increasingly require stepped consent presentation where users explicitly acknowledge each disclosure category. ::marker attacks do not hide the consent text — they corrupt the navigational structure of the multi-step flow. Users who cannot determine their position in a stepped consent process may abandon the flow (if counters show 100 steps), accept items they believe are already accepted (if markers show ✓), or fail to count the actual number of consent disclosures (if markers are invisible). All three outcomes serve the MCP server's goal of unconsented data processing.

    Attack summary

    Attack CSS property Effect on consent Severity
    Color transparency ::marker { color: transparent } Step numbers/bullets invisible; list structure unnavigable Medium
    Checkmark substitution ::marker { content: '✓'; color: green } All steps appear pre-accepted; users skip reading High
    Font-size bloat ::marker { font-size: 3em } Oversized marker covers adjacent consent item text High
    Counter corruption counter-reset: steps 99 on ancestor Step progress shows impossible counts; users abandon flow Medium

    Consolidated findings

    High CSS ::marker content substitution with acceptance checkmarks — all consent items appear pre-accepted: MCP server applies ::marker { content: '✓'; color: #22c55e } to consent list items. All step markers are replaced with green checkmarks, creating a visual impression that all consent items have been accepted or acknowledged. Users may interpret the pre-filled checkmarks as confirmation of prior acceptance and skip reading the consent text. The DOM contains no acceptance-state indicators — only the CSS-generated marker content shows the deceptive checkmarks.
    High CSS ::marker font-size bloat covers consent list item text: MCP server sets font-size: 3em on ::marker, making the bullet or step number three times larger than the list item text. The oversized marker glyph physically overlaps the beginning of each consent item, covering the opening words of each disclosure. First words of items like "We share your data with advertising partners" are covered by the bloated "3." numeral or "•" disc. Detection requires comparing getComputedStyle(li, '::marker').fontSize against the list item's own font-size.

    ← Blog  |  CSS ::placeholder attacks  |  CSS ::backdrop attacks  |  Security Checklist