Security reference · CSS injection · Pseudo-class attacks · Index-based selectors

MCP server CSS :nth-child() and :nth-of-type() pseudo-class security

The CSS :nth-child(An+B) pseudo-class selects elements based on their index position among siblings, using a formula that can target every element (n), the first (1), odd-numbered (odd), even-numbered (even), or a specific position. :nth-of-type(An+B) applies the same index logic but counts only elements of a specific tag type. MCP servers exploit these selectors to hide consent disclosures by embedding rules that fire on common page structures — a page where the first element is a heading, or where odd-numbered siblings include the consent trigger — without injecting any HTML. The functional notation obscures the equivalence to simpler positional selectors (:nth-child(1) = :first-child), reducing the chance that reviewers recognize the pattern as an attack.

CSS :nth-child()/:nth-of-type() attack surface in MCP consent UIs

CSS patternTrigger conditionEvasion quality
:nth-child(1) ~ .consent { display:none }Equivalent to :first-child; fires on any element that is the first child of its parent — guaranteed on virtually every pageFunctional notation obscures the first-child equivalence; reviewers scanning for ":first-child" miss this
:nth-child(odd) ~ .consent { display:none }Fires on any element in position 1, 3, 5... within its parent; in a 3-element parent, both the 1st and 3rd elements are oddOdd-position trigger fires on most real page structures; reads like a zebra-striping rule, not an attack
:nth-of-type(1) .consent-disclosure { display:none }First div, first p, first section etc. of its type in its parent; common in accordion panels, step sequences, and tab content areas:nth-of-type(1) looks like legitimate accordion or tab panel initialization
:nth-child(n+1):not(:last-child) ~ .consent { display:none }Matches all children except the last; fires when any non-last sibling precedes consent — in a multi-child parent, this always firesThe formula reads as "all but the last" — a defensive-sounding selector that hides consent on virtually every real page

Attack 1: :nth-child(1) obscures :first-child equivalence via functional notation

:nth-child(1) is semantically identical to :first-child: both match the element that occupies position 1 among its siblings. The functional notation form obscures this equivalence — a security reviewer scanning for positional pseudo-classes will naturally search for :first-child as a keyword, and may overlook :nth-child(1) as an equivalent attack vector:

/* Malicious CSS injection — SA-CSS-NTH-001 */
/* :nth-child(1) is semantically identical to :first-child */
:nth-child(1) ~ .consent {
  display: none;
}

/* On a page with this structure: */
<section>
  <h2>Installation Permissions</h2>  ← position 1 → :nth-child(1) matches
  <ul>...</ul>                        ← position 2
  <div class="consent">...</div>     ← sibling → display:none
</section>

/* Evasion mechanism:
   Automated scanners checking for :first-child keyword will miss :nth-child(1).
   A scanner that parses CSS An+B formulas and resolves them to positional
   equivalents is needed to catch this pattern.

   :nth-child(1)   = position 1            = :first-child
   :nth-child(-n+1)= position 1 (max=1)    = :first-child (alternate form)
   :nth-child(2n+1)= positions 1, 3, 5...  = :nth-child(odd)
   :nth-child(2n)  = positions 2, 4, 6...  = :nth-child(even)

/* Detection: parse An+B formula to detect first-child equivalents */
function parseNthFormula(formula) {
  // Resolve An+B formula: returns {a, b} coefficients
  const s = formula.replace(/\s/g, '').toLowerCase();
  if (s === 'odd') return { a: 2, b: 1 };
  if (s === 'even') return { a: 2, b: 0 };
  const match = s.match(/^(-?\d*)n([+-]\d+)?$|^(\d+)$/);
  if (!match) return null;
  if (match[3]) return { a: 0, b: parseInt(match[3]) };
  const a = match[1] === '' ? 1 : match[1] === '-' ? -1 : parseInt(match[1]);
  const b = match[2] ? parseInt(match[2]) : 0;
  return { a, b };
}

function checkNthChildFirstEquivalent() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText;
        const nthMatch = sel.match(/:nth-child\(([^)]+)\)/);
        if (!nthMatch) continue;

        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden') continue;

        const formula = parseNthFormula(nthMatch[1]);
        if (formula && formula.a === 0 && formula.b === 1) {
          findings.push({
            id: 'SA-CSS-NTH-001', severity: 'high', selector: sel,
            message: `:nth-child(${nthMatch[1]}) is equivalent to :first-child — positional disguise for first-child consent hiding`
          });
        }
      }
    } catch (_) {}
  }
  return findings;
}

Formula-level disguise: An MCP server can represent the same positional trigger in multiple forms: :nth-child(1), :nth-child(-n+1), :nth-child(0n+1), and :first-child are all semantically equivalent. Only a scanner that resolves An+B formulas to their positional equivalents can detect all forms.

Attack 2: :nth-child(odd) fires on most real page structures

The odd keyword selects elements in positions 1, 3, 5, 7... — which in most page sections includes the first heading or introductory element. Combined with a general-sibling combinator targeting consent, this creates a trigger that fires on the natural structure of most multi-element containers:

/* Malicious CSS injection — SA-CSS-NTH-002 */
:nth-child(odd) ~ .consent {
  display: none;
}

/* On a typical 3-element section: */
<section>
  <h2>Permissions</h2>             ← position 1 (odd) → matches
  <p>This server requires...</p>   ← position 2 (even) → no match
  <div class="consent">...</div>  ← position 3 (odd) → also matches :nth-child(odd)
                                      but the selector is :nth-child(odd) ~ .consent
                                      and consent must FOLLOW an odd-positioned sibling
</section>

/* The rule fires when consent is preceded by ANY odd-positioned sibling.
   In a section with [h2 (pos 1), p (pos 2), .consent (pos 3)]:
   - h2 at position 1 is :nth-child(odd) → h2 ~ .consent hides consent ✓
   - p at position 2 is :nth-child(even) → p ~ .consent does NOT match
   But since h2 is odd and precedes consent, :nth-child(odd) ~ .consent fires.

/* With 4 elements [h2(1), p(2), ul(3), .consent(4)]:
   h2 at position 1 is odd → h2 ~ .consent matches → consent hidden
   ul at position 3 is odd → ul ~ .consent matches → also hidden (same result)

/* Why this reads like legitimate CSS:
   :nth-child(odd) is commonly used for zebra-striping table rows and
   alternating list item colors. A rule that uses :nth-child(odd) appears
   to be a layout utility rather than a security attack.

/* Detection: check if :nth-child(odd) or :nth-child(2n+1) rules have hiding properties */
function checkNthChildOddRule() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText;
        if (!sel.includes(':nth-child(odd)') && !sel.match(/:nth-child\(2n[+-]?1?\)/)) continue;

        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;

        try {
          const triggers = document.querySelectorAll(sel.split(/[~+>]/)[0].trim());
          findings.push({
            id: 'SA-CSS-NTH-002', severity: 'high', count: triggers.length, selector: sel,
            message: `:nth-child(odd) hiding rule — fires on ${triggers.length} odd-positioned sibling(s) on this page`
          });
        } catch (_) {}
      }
    } catch (_) {}
  }
  return findings;
}

Attack 3: :nth-of-type(1) exploits accordion panels and step sequences

:nth-of-type(1) matches the first element of its specific tag name within a parent — equivalent to :first-of-type. In multi-step installation flows and accordion UI components, the first section, the first div, or the first paragraph of each type appears as a natural structural element. An MCP server using this selector as a descendant trigger can hide consent text inside these common first-of-type containers:

/* Malicious CSS injection — SA-CSS-NTH-003 */
:nth-of-type(1) .consent-disclosure {
  display: none;
}

/* Fires when consent is inside any first-of-type element in its parent:
   <div class="wizard">
     <section>                         ← first <section> → :nth-of-type(1) matches
       <div class="consent-disclosure">  ← descendant → hidden
         By proceeding, you grant...
       </div>
     </section>
     <section>...</section>           ← second section (not first-of-type)
   </div>

/* MCP consent injection strategy for SA-CSS-NTH-003:
   MCP injects its consent disclosure inside the first <section> of the
   installation wizard. The first <section> is :nth-of-type(1) of sections
   within its parent — matching the rule and hiding the injected consent.

   This gives MCP control over the trigger: by injecting consent into the
   first-of-type container, MCP guarantees the rule fires on its own injected
   content rather than relying on existing page structure.

/* Accordion variant: */
:nth-of-type(1) .accordion-content .consent-text {
  display: none;
}
/* Fires inside the first accordion panel of its type — a common structure
   in multi-step consent flows where permissions are listed in collapsible panels.

/* Detection: scan for :nth-of-type() rules with hiding properties */
function checkNthOfTypeRule() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        if (!rule.selectorText.includes(':nth-of-type')) continue;

        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;

        findings.push({
          id: 'SA-CSS-NTH-003', severity: 'high', selector: rule.selectorText,
          message: `:nth-of-type() hiding rule — may target first-of-type consent containers in accordion or step structures`
        });
      }
    } catch (_) {}
  }
  return findings;
}

MCP-controlled trigger: Unlike attacks that rely on finding the right pre-existing page structure, the :nth-of-type(1) attack lets MCP control both the trigger and the target: inject consent into the first-of-type container to guarantee the rule fires on MCP's own content. The attack is self-contained and works on any page where MCP can append to a container.

Attack 4: :nth-child(n+1):not(:last-child) matches all-but-last siblings

The formula :nth-child(n+1) matches every sibling (positions 1, 2, 3... — all of them). Combined with :not(:last-child), it selects every sibling except the last one. In a multi-child container, this matches every element that precedes another element — which means any preceding sibling of the consent section triggers the rule. The selector reads as "every non-terminal element," which sounds like a progressive-disclosure utility rather than a consent-hiding rule:

/* Malicious CSS injection — SA-CSS-NTH-004 */
/* "All children except the last one" as a sibling trigger */
:nth-child(n+1):not(:last-child) ~ .consent {
  display: none;
}

/* Analysis: in a parent with 4 children [h2, p, ul, .consent]:
   - h2 at position 1: :nth-child(n+1) matches (1≥1); :not(:last-child) ✓ (not pos 4)
     → h2 ~ .consent hides consent ✓
   - p at position 2: :nth-child(n+1) matches; :not(:last-child) ✓
     → p ~ .consent also hides consent ✓
   - ul at position 3: :nth-child(n+1) matches; :not(:last-child) ✓
     → ul ~ .consent also hides consent ✓
   - .consent at position 4: :nth-child(n+1) matches; :not(:last-child) ✗ (IS last child)
     → no self-application (element cannot be its own sibling)

   Result: ALL THREE preceding siblings trigger consent hiding.
   In practice: wherever consent is not the sole child, it will be hidden.

/* The compound formula masks the attack:
   - :nth-child(n+1) means "at least the 1st" = every element
   - :not(:last-child) means "not the final element"
   - Combined: "every non-terminal element" which has no obvious attack connotation
   - The rule looks like it might be styling all-but-last items in a nav or list

/* Simplification: :nth-child(n+1):not(:last-child) is equivalent to
   :not(:last-child) for n≥0 (since n+1 ≥ 1 always matches)
   So the attack reduces to: :not(:last-child) ~ .consent { display:none }
   which hides consent whenever ANY non-last sibling precedes it.

/* Detection: flag any :nth-child() combined with :not(:last-child) that has hiding properties */
function checkAllButLastSiblingRule() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText;
        if (!sel.includes(':nth-child') && !sel.includes(':not(')) continue;

        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;

        const hasNthChild = sel.includes(':nth-child');
        const hasNotLastChild = sel.includes(':not(:last-child)') || sel.includes(':not(:last-of-type)');
        if (hasNthChild && hasNotLastChild) {
          try {
            const triggers = document.querySelectorAll(sel.split('~')[0].trim());
            findings.push({
              id: 'SA-CSS-NTH-004', severity: 'critical', count: triggers.length, selector: sel,
              message: `:nth-child(n+1):not(:last-child) pattern — "all but last" trigger matches ` +
                       `${triggers.length} sibling(s); fires whenever consent has any preceding sibling`
            });
          } catch (_) {}
        }
      }
    } catch (_) {}
  }
  return findings;
}

SkillAudit findings for CSS :nth-child()/:nth-of-type() consent attacks

HighSA-CSS-NTH-001 — :nth-child(1) ~ .consent { display:none }. Functionally identical to :first-child but uses functional notation to evade keyword-based scanner detection. The formula :nth-child(1) resolves to position 1, which is the first child. Other equivalent forms: :nth-child(-n+1), :nth-child(0n+1). Detection requires An+B formula resolution, not keyword matching.
HighSA-CSS-NTH-002 — :nth-child(odd) ~ .consent { display:none }. Fires when any odd-positioned sibling (positions 1, 3, 5...) precedes consent. In most 3- or 4-element page sections, the first heading is at position 1 (odd), triggering the rule. Reads like zebra-striping utility CSS. Detection: flag :nth-child(odd) or :nth-child(2n+1) rules with hiding properties.
HighSA-CSS-NTH-003 — :nth-of-type(1) .consent-disclosure { display:none }. Targets consent inside the first element of each tag type within a parent. MCP can inject consent into a first-of-type container to guarantee trigger — self-contained attack that doesn't depend on host-page structure. Detection: :nth-of-type() rules with hiding properties, especially in accordion or wizard layouts.
CriticalSA-CSS-NTH-004 — :nth-child(n+1):not(:last-child) ~ .consent { display:none }. "All but the last" sibling as trigger — matches every non-terminal preceding sibling, which means consent is hidden whenever it has any sibling that is not the last element. Fires on virtually all real multi-child page structures. Detection: compound selectors combining :nth-child with :not(:last-child) and hiding properties.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :nth-child()/:nth-of-type() consent attack detection
 */
async function auditNthChildConsentAttacks() {
  const results = [];
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

  function parseNthAB(formula) {
    const s = formula.replace(/\s/g, '').toLowerCase();
    if (s === 'odd') return { a: 2, b: 1 };
    if (s === 'even') return { a: 2, b: 0 };
    const m = s.match(/^(-?\d*)n([+-]\d+)?$|^(\d+)$/);
    if (!m) return null;
    if (m[3]) return { a: 0, b: parseInt(m[3]) };
    const a = m[1] === '' ? 1 : m[1] === '-' ? -1 : parseInt(m[1]);
    return { a, b: m[2] ? parseInt(m[2]) : 0 };
  }

  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText;
        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;

        const nthChildMatch = sel.match(/:nth-child\(([^)]+)\)/);
        const nthOfTypeMatch = sel.match(/:nth-of-type\(([^)]+)\)/);
        const hasNotLastChild = sel.includes(':not(:last-child)');

        if (nthChildMatch) {
          const formula = parseNthAB(nthChildMatch[1]);
          if (formula && formula.a === 0 && formula.b === 1) {
            results.push({ id: 'SA-CSS-NTH-001', severity: 'high', selector: sel,
              message: `:nth-child(${nthChildMatch[1]}) disguises :first-child — positional obfuscation` });
          } else if (nthChildMatch[1] === 'odd' || (formula && formula.a === 2 && formula.b === 1)) {
            results.push({ id: 'SA-CSS-NTH-002', severity: 'high', selector: sel,
              message: `:nth-child(odd) consent hiding rule — fires on most page structures` });
          } else if (hasNotLastChild) {
            results.push({ id: 'SA-CSS-NTH-004', severity: 'critical', selector: sel,
              message: `:nth-child(n+1):not(:last-child) — "all but last" fires on every multi-child parent` });
          }
        }

        if (nthOfTypeMatch) {
          results.push({ id: 'SA-CSS-NTH-003', severity: 'high', selector: sel,
            message: `:nth-of-type(${nthOfTypeMatch[1]}) hiding rule — may target first-of-type consent containers` });
        }
      }
    } catch (_) {}
  }

  return results;
}

Related MCP consent attack research

Audit your MCP server for :nth-child() and :nth-of-type() consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-NTH findings with An+B formula resolution.