CVE-2026-0561

Shield Security <= 21.0.8 - Unauthenticated Reflected Cross-Site Scripting via 'message' Parameter

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
6.1
CVSS Score
6.1
CVSS Score
medium
Severity
21.0.10
Patched in
1d
Time to patch

Description

The Shield Security plugin for WordPress is vulnerable to Reflected Cross-Site Scripting via the 'message' parameter in all versions up to, and including, 21.0.8 due to insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that execute if they can successfully trick a user into performing an action such as clicking on a link.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
Required
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=21.0.8
PublishedFebruary 18, 2026
Last updatedFebruary 19, 2026
Affected pluginwp-simple-firewall

What Changed in the Fix

Changes introduced in v21.0.10

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This research plan targets a reflected Cross-Site Scripting (XSS) vulnerability in the Shield Security plugin. The vulnerability arises from the unescaped reflection of the `message` query parameter on pages rendered via the plugin's `ActionRouter` system. ### 1. Vulnerability Summary - **Vulnerabi…

Show full research plan

This research plan targets a reflected Cross-Site Scripting (XSS) vulnerability in the Shield Security plugin. The vulnerability arises from the unescaped reflection of the message query parameter on pages rendered via the plugin's ActionRouter system.

1. Vulnerability Summary

  • Vulnerability: Unauthenticated Reflected Cross-Site Scripting (XSS).
  • Component: ActionRouter system.
  • Variable: message parameter.
  • Cause: The plugin's action processing logic accepts a message parameter from the request (GET or POST) and echoes it back in the generated HTML response (such as a block page, login page, or error notice) without sufficient sanitization or output escaping.
  • Affected Versions: <= 21.0.8.

2. Attack Vector Analysis

  • Endpoint: Any URL where the Shield ActionRouter is invoked. This is typically the main site index with a shield_action parameter or wp-login.php.
  • Parameter: message (GET parameter).
  • Authentication: None (Unauthenticated).
  • Bypass: Shield's BaseAction.php contains a logic flaw where nonces are only verified for AJAX requests. A standard browser GET request bypasses nonce verification.
  • Payload: A standard <script> or event handler payload, e.g., <script>alert(document.domain)</script>.

3. Code Flow

  1. Entry Point: A user navigates to /?shield_action=[ACTION_SLUG]&message=[PAYLOAD].
  2. Action Initialization: BaseAction::__construct() merges request data into $this->action_data. The message parameter from the URL is now in $this->action_data['message'].
  3. Access Check: BaseAction::process() calls checkAccess().
  4. Nonce Bypass: In BaseAction::isNonceVerifyRequired(), the code checks self::con()->this_req->wp_is_ajax. For a direct browser GET request, this is false, and isNonceVerifyRequired() returns false, bypassing ActionNonce::VerifyFromRequest().
  5. Action Execution: If the action (e.g., mfa_passkey_auth_start) uses the AuthNotRequired trait, it proceeds without a logged-in session.
  6. Sink: The action completes or throws an exception. The ActionRouter then renders a response page. The template logic (not provided, but inferred) retrieves the message from the request or the action_data and echoes it directly into the HTML to display a notice to the user.

4. Nonce Acquisition Strategy

According to src/lib/src/ActionRouter/Actions/BaseAction.php, nonces are not required for non-AJAX requests:

protected function isNonceVerifyRequired() :bool {
    return (bool)( $this->getActionOverrides()[ Constants::ACTION_OVERRIDE_IS_NONCE_VERIFY_REQUIRED ] ?? self::con()->this_req->wp_is_ajax );
}

If wp_is_ajax is false, the check is skipped. This allows unauthenticated exploitation via a direct link.

If a specific action does override this and require a nonce, it can be found in the browser context:

  • Action Slug: mfa_passkey_auth_start (confirmed in source).
  • JS Variable: window.shield_vars_login_2fa or window.shield_vars_main.
  • Extraction: browser_eval("window.shield_vars_main.ajax.shield_action.nonce") (inferred path).

5. Exploitation Strategy

We will use the `mfa_passkey_auth_start

Research Findings
Static analysis — not yet PoC-verified

Summary

The Shield Security plugin for WordPress is vulnerable to unauthenticated reflected Cross-Site Scripting (XSS) via the 'message' parameter. This occurs because the plugin's ActionRouter system echoes the 'message' query parameter directly into the HTML response of various actions without sufficient sanitization or output escaping.

Vulnerable Code

// src/lib/src/ActionRouter/Actions/BaseAction.php

public function __construct( array $data = [], ?ActionResponse $response = null ) {
    $this->action_data = $data; // Request data (GET/POST) is merged directly into action_data
    $this->response = $response instanceof ActionResponse ? $response : new ActionResponse();
}

// ...

protected function isNonceVerifyRequired() :bool {
    // Nonce verification is skipped for non-AJAX (direct GET) requests by default
    return (bool)( $this->getActionOverrides()[ Constants::ACTION_OVERRIDE_IS_NONCE_VERIFY_REQUIRED ] ?? self::con()->this_req->wp_is_ajax );
}

---

// src/lib/src/ActionRouter/Actions/MfaPasskeyAuthenticationStart.php

class MfaPasskeyAuthenticationStart extends MfaUserConfigBase {

	use AuthNotRequired; // Allows unauthenticated users to trigger this action

	public const SLUG = 'mfa_passkey_auth_start';

	protected function exec() {
        // ... reflection of messages in response data often happens via the 'message' key

Security Fix

--- /src/lib/src/ActionRouter/Actions/BaseAction.php
+++ /src/lib/src/ActionRouter/Actions/BaseAction.php
@@ -154,14 +154,10 @@
 	 * @return self For method chaining
 	 */
 	public function setActionOverride( string $overrideKey, $value ) :self {
-		// Initialize action_overrides array if it doesn't exist
-		if ( !isset( $this->action_data[ 'action_overrides' ] ) ) {
-			$this->action_data[ 'action_overrides' ] = [];
-		}
-
-		// Set the override value
-		$this->action_data[ 'action_overrides' ][ $overrideKey ] = $value;
-
+		$this->action_data[ 'action_overrides' ] = \array_merge(
+			\is_array( $this->action_data[ 'action_overrides' ] ?? null ) ? $this->action_data[ 'action_overrides' ] : [],
+			[ $overrideKey => $value ]
+		);
 		return $this;
 	}
 
--- /src/lib/src/ActionRouter/Actions/MfaPasskeyAuthenticationStart.php
+++ /src/lib/src/ActionRouter/Actions/MfaPasskeyAuthenticationStart.php
@@ -2,27 +2,22 @@
 
 namespace FernleafSystems\Wordpress\Plugin\Shield\ActionRouter\Actions;
 
-use FernleafSystems\Wordpress\Plugin\Shield\ActionRouter\Actions\Traits\AuthNotRequired;
+use FernleafSystems\Wordpress\Plugin\Shield\ActionRouter\Exceptions\ActionException;
 use FernleafSystems\Wordpress\Plugin\Shield\Modules\LoginGuard\Lib\TwoFactor\Provider\Passkey;
 
-class MfaPasskeyAuthenticationStart extends MfaUserConfigBase {
-
-	use AuthNotRequired;
+class MfaPasskeyAuthenticationStart extends MfaLoginFlowBase {
 
 	public const SLUG = 'mfa_passkey_auth_start';
 
 	protected function exec() {
-
 		$response = [
 			'success'     => false,
 			'page_reload' => false
 		];
 
-		$user = $this->getActiveWPUser();
-		if ( empty( $user ) ) {
-			$response[ 'message' ] = __( 'User must be logged-in.', 'wp-simple-firewall' );
-		}
-		else {
+		try {
+			$user = $this->getLoginWPUser();
+
 			$available = self::con()->comps->mfa->getProvidersAvailableToUser( $user );
 			/** @var Passkey $provider */
 			$provider = $available[ Passkey::ProviderSlug() ] ?? null;
@@ -31,19 +26,27 @@
 				$response[ 'message' ] = __( "Passkeys aren't available for this user.", 'wp-simple-firewall' );
 			}
 			else {
-				try {
-					$response = [
-						'success'     => true,
-						'challenge'   => $provider->startNewAuth(),
-						'page_reload' => false
-					];
-				}
-				catch ( \Exception $e ) {
-					$response[ 'message' ] = __( "There was a problem preparing the Passkey Auth Challenge.", 'wp-simple-firewall' );
-				}
+				$response = [
+					'success'     => true,
+					'challenge'   => $provider->startNewAuth(),
+					'page_reload' => false
+				];
 			}
 		}
+		catch ( ActionException $e ) {
+			$response[ 'message' ] = $e->getMessage();
+		}
+		catch ( \Exception $e ) {
+			$response[ 'message' ] = __( 'There was a problem preparing the Passkey Auth Challenge.', 'wp-simple-firewall' );
+		}
 
 		$this->response()->action_response_data = $response;
 	}
+
+	protected function getRequiredDataKeys() :array {
+		return [
+			'login_wp_user',
+			'login_nonce',
+		];
+	}
 }

Exploit Outline

The exploit targets the Shield ActionRouter system. An attacker crafts a GET request to the site's index or login page using the `shield_action` parameter to trigger a specific action that does not require authentication or nonce verification (e.g., `mfa_passkey_auth_start`). By including a malicious payload in the `message` parameter (e.g., `/?shield_action=mfa_passkey_auth_start&message=<script>alert(document.domain)</script>`), the attacker triggers the ActionRouter logic which reflects the `message` input directly into the rendered HTML response page. Since the plugin fails to sanitize or escape this value before outputting it, the script executes in the victim's browser context. Authentication is not required if a publicly accessible action is used.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.