The Exploit
An unauthenticated attacker can inject arbitrary JavaScript into the WordPress admin dashboard by crafting a malicious directory entry with an HTML entity–encoded payload that decodes after sanitization.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=name_directory_add_entry&name_directory_name=Test%20User&name_directory_description=<img%20src=x%20onerror=alert('XSS')>
The payload <img src=x> stores in the database and triggers JavaScript execution whenever any user (including WordPress administrators) views the directory listing. The attacker observes no immediate feedback — the AJAX request succeeds with a 200 response — but upon navigating to the directory page, the injected image tag decodes and fires the onerror handler in the admin or frontend context.
To verify, retrieve the stored entry:
GET /wp-admin/admin-ajax.php?action=name_directory_get_entry&id=1 HTTP/1.1
Host: target.local
The response includes the decoded payload ready to execute in the page DOM.
What the Patch Did
Before:
function name_directory_deep_sanitize_public_user_input($input, $allowed_tags = null) {
$raw = trim( wp_unslash( (string)$input ) );
if( ! is_array( $allowed_tags ) ) {
$allowed_tags = array('p' => array(), 'br' => array(), 'strong'=>array(), 'em'=>array());
}
return wp_kses( $raw, $allowed_tags );
}
After:
function name_directory_deep_sanitize_public_user_input($input, $allowed_tags = null) {
$raw = trim( wp_unslash( (string)$input ) );
$decoded = html_entity_decode( $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
if( ! is_array( $allowed_tags ) ) {
$allowed_tags = array('p' => array(), 'br' => array(), 'strong'=>array(), 'em'=>array());
}
return wp_kses( $decoded, $allowed_tags );
}
The patch introduced html_entity_decode() to normalize HTML entities before applying wp_kses() whitelist filtering. The intent was to prevent attackers from bypassing the whitelist by submitting pre-encoded payloads; however, the implementation is backwards. The control added — html_entity_decode() — actually weakens security by converting encoded entities back into raw HTML before the sanitizer runs. A complementary patch to admin.php replaced unescaped output calls (html_entity_decode(stripslashes($name->name))) with esc_html(), shifting the defense to the output layer, but this does not prevent the root storage of malicious content.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
The vulnerability stems from a logic inversion in the sanitization pipeline. When user input arrives in the name_directory_name and name_directory_description POST parameters via AJAX, it flows through name_directory_deep_sanitize_public_user_input(). The original code passed raw input directly to wp_kses(), which strips disallowed tags and attributes. By adding html_entity_decode() before wp_kses(), the patch unintentionally decodes HTML entities that an attacker may have supplied as <img>, converting them back to <img> before the whitelist filter evaluates them. Since img is not in the $allowed_tags array (only p, br, strong, em), wp_kses() removes the decoded tag—but only after the damage of having decoded it. Worse, if an attacker uses a double-encoded payload (&lt;img>), a single decode leaves <img>, which then passes wp_kses() and decodes again at output time when html_entity_decode() is called in admin.php line 930. This crosses the trust boundary of the database: unsanitized content is persisted, and later decoded during output, bypassing the whitelist.
Why It Works
The load-bearing line is html_entity_decode() itself — without it, pre-encoded payloads remain benign strings. If you removed the decode, an attacker's <script> would stay encoded in the database and render harmlessly as literal text in the browser. However, the engineer added html_entity_decode() with good intent: to catch double-encoding bypasses. The problem is order of operations. The correct sequence is sanitize → store → escape at output. The broken sequence is decode → sanitize → store → decode at output, which allows an attacker to craft a payload that survives the first decode-sanitize cycle (via double-encoding or tag alternatives) and then fires after the second decode at display time. The complementary change in admin.php from html_entity_decode() to esc_html() for output only papers over the deeper storage vulnerability — it prevents one specific attack vector but does not restore the integrity of the sanitization function itself.
Hardening Checklist
- Apply
wp_kses_post()orwp_kses()directly to user input at the point of storage, without prior decoding. Letwp_kses()be the canonical sanitizer; never decode before filtering. - Use
sanitize_text_field()for GET/POST parameters that will not be embedded as HTML (e.g.,$_GET['sub']), as the patch correctly added toadmin.phplines 63, 116, 117. - Escape output using context-aware functions (
esc_html()for HTML context,esc_attr()for attribute context,esc_url()for URLs) after retrieval from the database, never before storage. - Audit any call to
html_entity_decode()in input-processing code. If it appears before a sanitization function, reverse the order. If it appears at output time, verify it does not introduce a second decode that converts already-escaped content. - Use a static analysis tool such as phpstan with a security ruleset to flag patterns like
html_entity_decode()followed by incomplete sanitization (e.g.,wp_kses()with a restrictive whitelist that does not include the tags being decoded).
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-3178