WPGraphQL < 2.11.1 - Unauthenticated SQL Injection
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:NTechnical Details
What Changed in the Fix
Changes introduced in v2.11.1
Source Code
WordPress.org SVN# 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:
/graphqlor/?graphql=1). - Authentication: Unauthenticated (by default).
- GraphQL Field: The
nodesquery or theusersquery'sincludeargument. - Vulnerable Parameter: The
ids(within thenodesquery) orinclude(withinusers(where: ...)). - Payload Transport: The payload is carried inside a Base64-encoded string representing a GraphQL
ID.
3. Code Flow
- Entry Point: An unauthenticated user sends a GraphQL request to the endpoint.
- Parsing: The
WPGraphQL\Routerprocesses the query. If the query isnodes(ids: [...]), it iterates through the provided IDs. - ID Decoding: WPGraphQL IDs are typically Base64 encoded strings in the format
typename:id(e.g.,user:1). - Loader Execution: For IDs with the type
user, theWPGraphQL\Data\Loader\UserLoaderis invoked via theloadKeys()method (line 120 ofsrc/Data/Loader/UserLoader.php). - Vulnerable Sink:
loadKeys()callsget_public_users( array $keys )(line 144). - Query Construction:
$keyscontains 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 )
- Injection: Because
%1$sis used,$wpdb->preparefails to escape$ids.vsprintfthen 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:
- Check if the endpoint responds to a simple query without a nonce.
- If a nonce is required, it is typically exposed via
wp_localize_scriptunder the keywindow.wpgraphql_settings?.nonceor similar. - Creation: Create a page with the
[wpgraphql]shortcode (if available) or navigate to the admin dashboard (if authenticated). - 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:
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
@@ -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.