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 pattern | Trigger condition | Evasion 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 page | Functional 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 odd | Odd-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 fires | The 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
: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.: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.: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.: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
- CSS :first-child and :last-child consent attacks
- CSS :only-child and :only-of-type consent attacks
- CSS :nth-child(of S) selector security (CSS Selectors 4)
- CSS :not() negation pseudo-class consent attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
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.