CVE-2026-40762

WPGraphQL < 2.11.1 - Unauthenticated SQL Injection

highImproper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
7.5
CVSS Score
7.5
CVSS Score
high
Severity
2.11.1
Patched in
10d
Time to patch

Description

The WPGraphQL plugin for WordPress is vulnerable to SQL Injection in versions up to 2.11.1 due to insufficient escaping on the user supplied parameter and lack of sufficient preparation on the existing SQL query. This makes it possible for unauthenticated attackers to append additional SQL queries into already existing queries that can be used to extract sensitive information from the database.

CVSS Vector Breakdown

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

Technical Details

Affected versions<2.11.1
PublishedApril 21, 2026
Last updatedApril 30, 2026
Affected pluginwp-graphql

What Changed in the Fix

Changes introduced in v2.11.1

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan - CVE-2026-40762 ## 1. Vulnerability Summary The **WPGraphQL** plugin (versions < 2.11.1) contains an unauthenticated SQL injection vulnerability in its `UserLoader` class. The flaw exists because the plugin uses numbered placeholders (e.g., `%1$s`) within a `$wpdb->pre…

Show full research plan

Exploitation Research Plan - CVE-2026-40762

1. Vulnerability Summary

The WPGraphQL plugin (versions < 2.11.1) contains an unauthenticated SQL injection vulnerability in its UserLoader class. The flaw exists because the plugin uses numbered placeholders (e.g., %1$s) within a $wpdb->prepare() call.

WordPress's $wpdb->prepare() implementation specifically looks for literal %s (string), %d (integer), or %f (float) placeholders to perform escaping and quoting. It does not recognize numbered placeholders like %1$s. Consequently, when the query is finally processed by vsprintf, the user-supplied input is interpolated directly into the SQL statement without being escaped or enclosed in quotes.

2. Attack Vector Analysis

  • Endpoint: The standard GraphQL endpoint (default: /graphql or /?graphql=1).
  • Authentication: Unauthenticated (by default).
  • GraphQL Field: The nodes query or the users query's include argument.
  • Vulnerable Parameter: The ids (within the nodes query) or include (within users(where: ...)).
  • Payload Transport: The payload is carried inside a Base64-encoded string representing a GraphQL ID.

3. Code Flow

  1. Entry Point: An unauthenticated user sends a GraphQL request to the endpoint.
  2. Parsing: The WPGraphQL\Router processes the query. If the query is nodes(ids: [...]), it iterates through the provided IDs.
  3. ID Decoding: WPGraphQL IDs are typically Base64 encoded strings in the format typename:id (e.g., user:1).
  4. Loader Execution: For IDs with the type user, the WPGraphQL\Data\Loader\UserLoader is invoked via the loadKeys() method (line 120 of src/Data/Loader/UserLoader.php).
  5. Vulnerable Sink: loadKeys() calls get_public_users( array $keys ) (line 144).
  6. Query Construction:
    • $keys contains the raw IDs extracted from the Base64 input.
    • $ids = implode( ', ', $keys ) (line 90).
    • The query is built using $wpdb->prepare() on line 96:
      $wpdb->prepare(
          "SELECT DISTINCT $wpdb->users.ID FROM $wpdb->posts INNER JOIN $wpdb->users ON post_author = $wpdb->users.ID $where AND post_author IN ( %1\$s ) ORDER BY FIELD( $wpdb->users.ID, %2\$s)",
          $ids,
          $ids
      )
      
  7. Injection: Because %1$s is used, $wpdb->prepare fails to escape $ids. vsprintf then performs raw interpolation.

4. Nonce Acquisition Strategy

By default, the WPGraphQL endpoint does not require a nonce for unauthenticated queries. If the setting "Restrict Endpoint to Authenticated Users" is enabled, the vulnerability is still present but requires a valid user session.

If the environment has been hardened to require a nonce for the GraphQL endpoint (uncommon but possible), it usually relies on the standard WordPress REST API nonce (wp_rest).

Strategy:

  1. Check if the endpoint responds to a simple query without a nonce.
  2. If a nonce is required, it is typically exposed via wp_localize_script under the key window.wpgraphql_settings?.nonce or similar.
  3. Creation: Create a page with the [wpgraphql] shortcode (if available) or navigate to the admin dashboard (if authenticated).
  4. Extraction: browser_eval("window.wpgraphql_settings?.nonce").

Note: In most default installations, no nonce is required for the payload below.

5. Exploitation Strategy

We will use a time-based blind SQL injection via the nodes query.

Step 1: Baseline Request

Confirm the endpoint is active and identify the Base64 ID format.

{
  "query": "query { users(first: 1) { nodes { id } } }"
}

If the ID returned is dXNlcjox (`user:

Research Findings
Static analysis — not yet PoC-verified

Summary

WPGraphQL is vulnerable to unauthenticated SQL injection in the UserLoader class because it uses numbered placeholders (e.g., %1$s) within $wpdb->prepare(). WordPress's prepare implementation does not recognize these placeholders for escaping, resulting in raw interpolation of user-supplied input into SQL queries via vsprintf.

Vulnerable Code

/* src/Data/Loader/UserLoader.php line 89 */
$where = get_posts_by_author_sql( $post_types, true, $author_id, $public_only );
$ids   = implode( ', ', $keys );

global $wpdb;

$results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
    $wpdb->prepare(
        "SELECT DISTINCT $wpdb->users.ID FROM $wpdb->posts INNER JOIN $wpdb->users ON post_author = $wpdb->users.ID $where AND post_author IN ( %1\$s ) ORDER BY FIELD( $wpdb->users.ID, %2\$s)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder,WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users
        $ids,
        $ids
    )
);

---

/* src/Data/Loader/UserLoader.php line 144 */
public function loadKeys( array $keys ) {
    if ( empty( $keys ) ) {
        return $keys;
    }
    // ... (truncated)
    /**
     * Determine which of the users are public (have published posts).
     */
    $public_users = $this->get_public_users( $keys );

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/wp-graphql/2.11.0/src/Data/Loader/UserLoader.php	2024-08-21 17:58:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-graphql/2.11.1/src/Data/Loader/UserLoader.php	2026-04-10 18:20:34.000000000 +0000
@@ -27,6 +27,33 @@
 	}
 
 	/**
+	 * Normalize a loader key to a WordPress user database ID.
+	 *
+	 * Only non-empty digit-only strings and positive integers are accepted so values
+	 * such as "0) OR …" cannot pass `absint`-based checks elsewhere and reach SQL.
+	 *
+	 * @param mixed $key Loader key (typically an integer or numeric string).
+	 *
+	 * @return int|null Positive user ID, or null if the key is not a valid ID.
+	 */
+	private function parse_user_database_id( $key ): ?int {
+		if ( is_int( $key ) ) {
+			return $key > 0 ? $key : null;
+		}
+
+		if ( is_string( $key ) ) {
+			if ( '' === $key || ! ctype_digit( $key ) ) {
+				return null;
+			}
+			$id = absint( $key );
+
+			return $id > 0 ? $id : null;
+		}
+
+		return null;
+	}
+
+	/**
 	 * The data loader always returns a user object if it exists, but we need to
 	 * separately determine whether the user should be considered private. The
 	 * WordPress frontend does not expose authors without published posts, so our
@@ -46,6 +73,20 @@
 	 * @return array<int,bool> Associative array of author IDs (int) to boolean.
 	 */
 	public function get_public_users( array $keys ) {
+		$sanitized_keys = [];
+		foreach ( $keys as $key ) {
+			$id = $this->parse_user_database_id( $key );
+			if ( null !== $id ) {
+				$sanitized_keys[] = $id;
+			}
+		}
+		$sanitized_keys = array_values( array_unique( $sanitized_keys ) );
+
+		if ( empty( $sanitized_keys ) ) {
+			return [];
+		}
+
+		$keys = $sanitized_keys;

Exploit Outline

The exploit targets the standard GraphQL endpoint (usually /graphql). An unauthenticated attacker sends a 'nodes' or 'users' query containing a malicious Base64-encoded ID. The ID must be prefixed with 'user:' followed by the SQL payload, for example: 'user:1) OR SLEEP(5)--'. When the UserLoader decodes this ID, it extracts the payload and passes it to get_public_users. Inside this method, the payload is imploded into a string and processed by $wpdb->prepare() using a numbered placeholder (%1$s). Because $wpdb->prepare() ignores numbered placeholders, the payload is concatenated directly into the SQL query's WHERE and ORDER BY clauses without escaping, enabling time-based or boolean-based blind SQL injection.

Check if your site is affected.

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