CVE-2026-8438: All-In-One Security Unauthenticated Stored XSS (CVSS 7.2)
Table of Contents
CVE-2026-8438 is a CVSS 7.2 (High) Unauthenticated Stored Cross-Site Scripting vulnerability in the All-In-One Security (AIOS) – Security and Firewall WordPress plugin. All versions up to and including 5.4.7 are affected. An unauthenticated attacker can inject arbitrary JavaScript into the plugin’s debug log by sending a single crafted HTTP request to the REST API. The payload is stored in the WordPress database. It fires automatically the next time any administrator opens the AIOS Debug Logs page — no extra interaction is needed. A successful attack can lead to full site compromise.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | All-In-One Security (AIOS) – Security and Firewall |
| Plugin Slug | all-in-one-wp-security-and-firewall |
| CVE ID | CVE-2026-8438 |
| CVSS Score | 7.2 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N |
| Vulnerability Type | Unauthenticated Stored Cross-Site Scripting (XSS) |
| Affected Versions | <= 5.4.7 |
| Patched Version | 5.4.8 |
| Published | June 5, 2026 |
| Researcher | Dmitrii Ignatyev - CleanTalk Inc |
| Wordfence Advisory | Link |
Description
The All-In-One Security (AIOS) – Security and Firewall plugin for WordPress is vulnerable to Stored Cross-Site Scripting in versions up to and including 5.4.7. The flaw comes from two missing safeguards. First, the get_rest_route() function reads the raw request path and never sanitizes it. Second, the debug log list table prints that stored value without escaping it.
The attack needs two AIOS features turned on at the same time: “Disallow unauthorized REST API requests” (aiowps_disallow_unauthorized_rest_requests) and debug logging (aiowps_enable_debug). When both are active, the plugin logs every blocked REST request. An unauthenticated attacker can place HTML or JavaScript inside the REST request path. That payload is stored in the debug log table and runs later in an administrator’s browser.
Technical Analysis
Vulnerable Code Path
The vulnerability follows a store-then-display pattern across three files.
Step 1 — The hook that logs blocked REST requests (classes/wp-security-general-init-tasks.php, line 106):
if ($aio_wp_security->configs->get_value('aiowps_disallow_unauthorized_rest_requests') == 1) {
add_action('rest_api_init', array($this, 'check_rest_api_requests'), 10, 1);
}
When the “Disallow unauthorized REST API requests” feature is enabled, AIOS runs check_rest_api_requests() on every REST API call.
Step 2 — Reading the attacker-controlled path (classes/wp-security-utility.php, lines 1609–1627):
public static function get_rest_route() {
$rest_route = !empty($_GET['rest_route']) ? sanitize_text_field(stripslashes($_GET['rest_route'])) : '';
// If route is not found in query parameter, extract from REQUEST_URI
if (empty($rest_route)) {
$request_uri = !empty($_SERVER['REQUEST_URI']) ? urldecode($_SERVER['REQUEST_URI']) : '';
$parsed_url = parse_url(trim($request_uri, '/'));
$path = isset($parsed_url['path']) ? $parsed_url['path'] : '';
if (false !== strpos($path, rest_get_url_prefix())) {
$path = preg_replace('/index\.php\//', '', $path);
$rest_route = preg_replace('/(.*)\/?'.rest_get_url_prefix().'\/?/', '', $path);
$rest_route = trim($rest_route, '/');
if (empty($rest_route)) $rest_route = '/';
} else {
$rest_route = '';
}
}
return $rest_route;
}
This is the root cause. When the request has no rest_route query parameter, the function falls back to urldecode($_SERVER['REQUEST_URI']). The urldecode() call turns URL-encoded characters such as %3C and %3E into literal < and >. The decoded value is never passed through sanitize_text_field() or any escaping function. The $_GET['rest_route'] branch is sanitized, but the REQUEST_URI branch is not — and that is the branch an attacker uses.
Step 3 — Storing the path in the debug log (classes/wp-security-general-init-tasks.php, lines 877–896):
public function check_rest_api_requests() {
global $aio_wp_security;
$is_whitelisted_route = false;
$rest_route = AIOWPSecurity_Utility::get_rest_route();
if (empty($rest_route)) return;
// ... whitelist and role checks ...
if (!$is_whitelisted_route && (empty($rest_user->ID) || $is_disallowed_role)) {
$error_message = apply_filters('aiowps_rest_api_error_message', __('You are not authorized to perform this action.', 'all-in-one-wp-security-and-firewall'));
$aio_wp_security->debug_logger->log_debug('REST API request '.$rest_route.' was blocked, If this was unintentional whitelist "' . explode('/', $rest_route)[0] . '" the REST route.', 4);
wp_die($error_message, '', 403);
}
}
For an unauthenticated request, $rest_user->ID is empty, so the request is blocked. Before blocking, the plugin builds a debug message and concatenates the raw $rest_route directly into it. The attacker payload is now part of the log message.
Step 4 — Writing the payload to the database (classes/wp-security-debug-logger.php, lines 63–85):
public function log_debug($message, $level_code = 0, $type = 'debug') {
if (!$this->debug_enabled) {
return;
}
global $wpdb;
$debug_tbl_name = AIOWPSEC_TBL_DEBUG_LOG;
$data = array(
'created' => current_time('mysql'),
'level' => $this->get_readable_level_from_code($level_code),
'network_id' => get_current_network_id(),
'site_id' => get_current_blog_id(),
'message' => $message,
'type' => $type,
);
$ret = $wpdb->query($wpdb->prepare("INSERT INTO ".$debug_tbl_name." (created, logtime, level, network_id, site_id, message, type) VALUES (%s, UNIX_TIMESTAMP(), %s, %d, %d, %s, %s)", $data['created'], $data['level'], $data['network_id'], $data['site_id'], $data['message'], $data['type']));
// ...
}
The message is stored only when $this->debug_enabled is true, which maps to the aiowps_enable_debug setting. The $wpdb->prepare() call protects against SQL injection, but it stores the raw HTML payload safely in the message column. The XSS payload is now persistent.
Step 5 — Printing the payload without escaping (admin/wp-security-list-debug.php, lines 41–43):
public function column_default($item, $column_name) {
return $item[$column_name];
}
This is the second half of the bug. The debug log table extends a list table base class. For the message column, the base class calls column_default() and echoes whatever it returns. Here column_default() returns the raw database value with no escaping.
Step 6 — The base class echoes it directly (admin/general/wp-security-list-table.php, lines 1392–1394):
} else {
echo "<td $attributes>";
echo $this->column_default($item, $column_name);
echo $this->handle_row_actions($item, $column_name, $primary);
echo '</td>';
}
The base class echoes the return value of column_default() with no esc_html(). When an administrator opens the AIOS Dashboard Debug Logs page (rendered by render_debug_logs() in admin/wp-security-dashboard-menu.php), the stored <script> tag is written into the page and the browser runs it.
Why This Is Unauthenticated
The whole chain starts from a REST API request that does not need a login. The check_rest_api_requests() handler runs on rest_api_init for every visitor. An unauthenticated request is exactly what triggers the “blocked” branch and writes to the log. The attacker never needs an account, a nonce, or any cookie. The only authenticated action is the admin opening the Debug Logs page later, which is a normal day-to-day task.
Proof of Concept
Disclaimer: This proof of concept is for educational and defensive purposes only. Test it only on systems you own or have explicit written permission to assess. Never use it against systems you do not control.
Prerequisites:
- All-In-One Security (AIOS) version 5.4.7 or earlier is installed and activated.
- The “Disallow unauthorized REST API requests” setting is enabled (
aiowps_disallow_unauthorized_rest_requests). - Debug logging is enabled (
aiowps_enable_debug).
Step 1 — Send the payload as an unauthenticated user.
Send one GET request to the REST API. Put the XSS payload in the URL path, URL-encoded so the server stores the encoded form. The plugin will decode it with urldecode().
curl -s "https://victim.example/wp-json/%3Cscript%3Ealert(document.domain)%3C%2Fscript%3E" \
-H "User-Agent: poc-cve-2026-8438"
The server receives REQUEST_URI = /wp-json/%3Cscript%3Ealert(document.domain)%3C%2Fscript%3E. After urldecode(), the path becomes /wp-json/<script>alert(document.domain)</script>. The plugin extracts the rest route <script>alert(document.domain)</script>, blocks the request with a 403, and writes the payload into the debug log.
Step 2 — Confirm the log entry was stored.
The debug log now contains a message like:
REST API request <script>alert(document.domain)</script> was blocked, If this was unintentional whitelist "<script>alert(document.domain)" the REST route.
Step 3 — Trigger execution.
Log in as an administrator and open WP Security → Dashboard → Debug Logs. The browser parses the stored <script> tag and runs it. The alert(document.domain) box confirms code execution in the admin session.
Real-world impact: A practical attacker would not use alert(). They would load remote JavaScript that reads the admin nonce, creates a new administrator account, or installs a malicious plugin. Because the script runs with the administrator’s session, it can do anything the administrator can do.
Patch Analysis
Version 5.4.8 fixes the issue with defense in depth. It sanitizes the input and escapes the output, so either layer alone would stop the attack.
Fix 1 — Sanitize the request path (classes/wp-security-utility.php):
if (empty($rest_route)) {
- $request_uri = !empty($_SERVER['REQUEST_URI']) ? urldecode($_SERVER['REQUEST_URI']) : '';
+ $request_uri = !empty($_SERVER['REQUEST_URI']) ? sanitize_text_field(urldecode($_SERVER['REQUEST_URI'])) : '';
$parsed_url = parse_url(trim($request_uri, '/'));
The decoded REQUEST_URI now passes through sanitize_text_field(), which strips HTML tags. The payload can no longer reach the database as live markup.
Fix 2 — Escape the debug log output (admin/wp-security-list-debug.php):
- public function column_logtime($item) {
- return AIOWPSecurity_Utility::convert_timestamp($item['logtime']);
- }
-
- public function column_default($item, $column_name) {
- return $item[$column_name];
- }
+ public function column_logtime($item) {
+ echo esc_html(AIOWPSecurity_Utility::convert_timestamp($item['logtime']));
+ }
The plugin removes the unsafe column_default() override from the debug log table. The message column now falls back to the base class, which was updated to escape its output:
protected function column_default($item, $column_name) {
- }
+ echo esc_html($item[$column_name]);
+ }
Now every column value runs through esc_html() before it reaches the page. Stored HTML is shown as plain text instead of being parsed by the browser.
The official fix landed in changeset 3558989.
Timeline
| Date | Event |
|---|---|
| June 2, 2026 | Version 5.4.8 released with the fix |
| June 5, 2026 | Wordfence published the advisory |
| June 6, 2026 | Advisory last updated |
Remediation
Update now. Install All-In-One Security (AIOS) version 5.4.8 or later. Update from the WordPress admin Plugins screen or with WP-CLI:
wp plugin update all-in-one-wp-security-and-firewall
If you cannot update right away:
- Turn off debug logging in AIOS. Without
aiowps_enable_debug, the payload is never stored. - Clear any existing debug log entries that may already contain a payload before you next open the Debug Logs page.
- Review your site for unexpected administrator accounts or plugin changes.
After updating, confirm the plugin version shows 5.4.8 or higher on the Plugins screen.
References
- Wordfence Advisory — CVE-2026-8438
- CVE-2026-8438 — CVE.org Record
- Patch changeset 3558989 — plugins.trac.wordpress.org
- admin/wp-security-list-debug.php (5.4.6) — plugins.trac.wordpress.org
- classes/wp-security-utility.php (5.4.6) — plugins.trac.wordpress.org
- classes/wp-security-general-init-tasks.php (5.4.6) — plugins.trac.wordpress.org
- classes/wp-security-debug-logger.php (5.4.6) — plugins.trac.wordpress.org
- All-In-One Security (AIOS) on WordPress.org