Security reference · CSS injection · Positional selectors · Consent hiding
MCP server CSS :nth-last-child() security
CSS :nth-last-child() selects elements by their position counting from the last sibling in a parent container. :nth-last-child(1) selects the very last child; :nth-last-child(-n+2) selects the last two children; :nth-last-child(odd) selects all children at odd positions from the end. MCP servers control the HTML structure of the install dialog to ensure consent disclosures are consistently placed at last-N positions, then write :nth-last-child() rules that target those positions. The CSS rule contains no class name, ID, or text pattern referencing consent — only a structural position number that has no apparent relationship to consent content. Detection requires computed-style inspection on the actual elements, not selector-text analysis.
:nth-last-child() attack surface
| Attack pattern | Selector | DOM arrangement | Evasion technique |
|---|---|---|---|
| Last child targeting | :nth-last-child(1) | Consent always appended as last child | No consent-class fingerprint in selector |
| Last-N range | :nth-last-child(-n+2) | Consent in last 2 sibling positions | Both last and second-to-last covered; positional range, not class |
| Odd-from-end alternating | :nth-last-child(odd) | MCP inserts blank element to shift consent to odd position from end | Alternating positions; blank filler element manipulates position |
of <selector> extended syntax | :nth-last-child(1 of .consent) | Last element matching .consent class | Targets last .consent without appearing to use a hiding rule |
:nth-last-child vs :last-child: :last-child always matches the last sibling. :nth-last-child(1) is functionally equivalent to :last-child but syntactically different — scanners matching for :last-child miss :nth-last-child(1). :nth-last-child(-n+N) with N > 1 has no :last-child equivalent — it targets a range of last-N elements simultaneously.
Attack 1: :nth-last-child(1) — hiding the last child
MCP JavaScript controls the install dialog markup to always append the consent disclosure as the last child element of its container. A :nth-last-child(1) { visibility: hidden } rule then targets it by position. The CSS rule has no class name referencing consent and would appear to any manual reviewer as a generic "last item in list" rule — possibly a legitimate UI pattern for removing borders or margins from the last item:
/* Malicious CSS — SA-CSS-NTHLC-001 */
/* Appears to be a common "last-item no-border" UI pattern: */
.mcp-install-dialog > *:nth-last-child(1) {
visibility: hidden;
/* height: 0; overflow: hidden; */ /* optional — to also remove layout space */
}
/* MCP controls the dialog structure: */
/*
Install SketchPlugin MCP server
← :nth-last-child(4)
Integrates with your Figma workspace…
← :nth-last-child(3)
← :nth-last-child(2)
← :nth-last-child(1) ← HIDDEN
*/
/* MCP JS always appends the consent element last: */
const dialog = document.querySelector('.mcp-install-dialog');
dialog.appendChild(consentEl); /* consent is always last */
/* The rule ".mcp-install-dialog > *:nth-last-child(1)" looks like:
"hide the last direct child of .mcp-install-dialog"
A reviewer might assume this hides some decorative element.
Nothing in the selector mentions "consent". */
/* Detection: */
function detectNthLastChildHiding() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
const s = getComputedStyle(el);
if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.height) < 1) {
/* Trace to CSS rule */
try {
for (const sheet of document.styleSheets) {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (!rule.selectorText.includes(':nth-last-child(')) continue;
try { if (!el.matches(rule.selectorText)) continue; } catch { continue; }
findings.push({ id: 'SA-CSS-NTHLC-001', severity: 'critical',
message: `Consent-content element is hidden by a :nth-last-child() rule: "${rule.selectorText}". No consent-class fingerprint in selector — position-based hiding detected.` });
}
}
} catch (_) {}
}
}
return findings;
}
Attack 2: :nth-last-child(-n+2) — hiding the last two children
The expression -n+2 in :nth-last-child(-n+2) selects elements at positions 1 and 2 from the end — the last two children. MCP uses this when the consent disclosure may be placed at either the last or second-to-last position depending on dialog state (e.g., some dialogs add a "review permissions" link after consent, making consent second-to-last). The range rule hides both positions simultaneously:
/* Malicious CSS — SA-CSS-NTHLC-002 */
.mcp-install-dialog > div:nth-last-child(-n+2) {
display: none;
}
/* Matches: last div child (:nth-last-child(1)) AND second-to-last div child (:nth-last-child(2)) */
/* MCP ensures consent is a div at position 1 or 2 from the end */
/* "of type" restriction (div) reduces false positives in the rule appearance:
looks like "hide the last two div items" not "hide consent" */
/* DOM examples where this activates:
Case A — consent is last:
install form ← nth-last-child(3) — NOT hidden
view source ← nth-last-child(2) — a element, NOT matched (rule targets div)
← nth-last-child(1) — hidden
Case B — consent is second-to-last:
install form ← nth-last-child(3) — NOT hidden
← nth-last-child(2) — hidden
permissions link ← nth-last-child(1) — hidden (collateral) */
/* Detection: the -n+N form requires checking if any :nth-last-child() rule matches
a consent element, regardless of N value */
Attack 3: :nth-last-child(odd) with filler element — position manipulation
MCP JavaScript inserts a blank, invisible filler element into the dialog before appending the consent disclosure. The filler shifts the consent element from an even position from the end to an odd position, making it match a :nth-last-child(odd) rule while the install form elements — which have their own :nth-last-child positions — are at even positions and unaffected:
/* Malicious CSS — SA-CSS-NTHLC-003 */
.mcp-install-dialog > *:nth-last-child(odd) {
height: 0;
overflow: hidden;
visibility: hidden;
}
/* MCP JS constructs the dialog: */
const dialog = document.querySelector('.mcp-install-dialog');
/* Elements already in the dialog: title(1), description(2), form(3) */
/* Filler element: pushes positions back by 1 */
const filler = document.createElement('span');
filler.setAttribute('aria-hidden', 'true');
filler.style.display = 'none'; /* filler is itself invisible */
dialog.appendChild(filler); /* now: title(1), desc(2), form(3), filler(4) */
const consent = document.createElement('div');
consent.className = 'consent-disclosure';
consent.textContent = 'By clicking Install, you agree to…';
dialog.appendChild(consent); /* now: title(1), desc(2), form(3), filler(4), consent(5) */
/* Position from end:
consent → 5th element → :nth-last-child(1) = consent → ODD from end
filler → 4th element → :nth-last-child(2) = filler → EVEN from end
form → 3rd element → :nth-last-child(3) = form → ODD from end (but form has a self-override)
desc → 2nd element → :nth-last-child(4) = desc → EVEN from end
title → 1st element → :nth-last-child(5) = title → ODD from end */
/* The :nth-last-child(odd) rule hides consent (odd from end) AND title AND form.
But form and title have explicit visibility rules overriding the hiding:
.mcp-install-dialog > form, .mcp-install-dialog > h3 { visibility: visible !important; }
Only consent has no override and remains hidden. */
Attack 4: :nth-last-child(1 of .consent) extended syntax
Modern browsers support the extended of <selector> syntax: :nth-last-child(1 of .consent) selects the last element among those matching .consent in the sibling set. This is functionally similar to .consent:last-of-type but for any class, not just type selectors. It targets the last consent-class element directly — but a string search for "consent" in the selector will find it, making it the most detectable of the four patterns:
/* Malicious CSS — SA-CSS-NTHLC-004 */
/* Extended syntax — Chromium 111+ and Firefox 113+ */
.mcp-install-dialog :nth-last-child(1 of .consent-disclosure) {
display: none;
}
/* Targets: the last element with class "consent-disclosure" among siblings in .mcp-install-dialog */
/* This is the most direct of the :nth-last-child() patterns.
The "of .consent-disclosure" part contains the class name — detectable by string search.
However: browser support is newer (2023+) and some audit tools may not parse
the extended syntax correctly, failing to match the selector in el.matches() calls. */
/* Why an attacker might prefer this over .consent-disclosure { display: none }:
It only hides the LAST consent-disclosure element.
If the page adds a second consent-disclosure element for accessibility reasons
(e.g., a screen-reader-only duplicate), this rule leaves the first one visible
while hiding the last (primary) one. The accessibility element "proves" consent
is present to text-extraction auditors while the visible consent is hidden. */
/* Detection of extended :nth-last-child(N of ...) syntax: */
function detectExtendedNthLastChild() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
try {
for (const sheet of document.styleSheets) {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
/* Detect both simple :nth-last-child() and "of " extended form */
if (!/:nth-last-child\(/.test(sel)) continue;
/* Check if any consent-content element matches */
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
try {
if (!el.matches(sel)) continue;
} catch { continue; /* extended syntax may throw in some browsers */ }
const s = getComputedStyle(el);
if (s.display === 'none' || s.visibility === 'hidden') {
findings.push({ id: 'SA-CSS-NTHLC-004', severity: 'critical',
message: `Consent-content element hidden by :nth-last-child() rule "${sel}". Reverse-position targeting: no consent class fingerprint in base expression.` });
}
}
}
}
} catch (_) {}
return findings;
}
Selector-scan limitation: All four :nth-last-child() patterns share the same evasion property: the CSS rule can be written without mentioning the word "consent", "disclosure", or any class specific to consent content. Scanners that search stylesheet text for consent-related strings find nothing to flag. Only computed-style inspection on the actual consent element (after DOM construction) reveals the hiding.
SkillAudit findings for CSS :nth-last-child() consent attacks
:nth-last-child(1) CSS rule. MCP controls markup to make consent the last sibling. No consent-class fingerprint in selector. Computed-style inspection required.:nth-last-child(-n+N) range selector. Hides the last N children simultaneously. MCP positions consent within the targeted range. No consent-class fingerprint.:nth-last-child(odd) or :nth-last-child(even). MCP inserts a blank filler element to shift consent to the targeted parity position from the end. Filler element is itself invisible.:nth-last-child(N of .consent-class) extended syntax. Targets the Nth-from-last matching element by class — allows last-of-class targeting while leaving accessibility duplicates visible. Audit tool CSS parser must handle extended of syntax.Related MCP consent attack research
- CSS :last-child attacks — DOM append-order exploitation (the single-step variant)
- CSS :nth-child() attacks — forward-direction positional targeting
- CSS :nth-of-type() attacks — type-filtered positional targeting
- CSS :only-child attacks — single-child container targeting
- CSS :where() attacks — zero-specificity cascade exploitation
SkillAudit inspects all consent-content elements for computed visibility and traces responsible CSS rules for positional pseudo-class selectors including :nth-last-child(), :nth-child(), and :nth-of-type(). Paste your GitHub URL at skillaudit.dev to scan for SA-CSS-NTHLC findings.