tr0j4n.tech

CVE-2025-9318: Authenticated SQL Injection in Quiz and Survey Master (QSM)

TL;DR: I discovered a time-based blind SQL injection vulnerability in Quiz and Survey Master (QSM) ≤ 10.3.1, one of the most popular quiz plugins for WordPress with over 40,000 active installations. The vulnerability allows any authenticated user-even a low-privileged Subscriber-to extract sensitive data from the database, including admin credentials.


Introduction

This post documents how I discovered CVE-2025-9318, an authenticated (Subscriber+) time-based SQL injection vulnerability caused by unsafe handling of the is_linking query parameter in the QSM REST API.

This is my first assigned CVE and my first public vulnerability write-up. I wanted to share not just the technical details, but also the methodology and thought process behind finding it. My goal is to clearly explain the vulnerable code path, the conditions that made it exploitable, and the security lessons that can be drawn from it-without focusing on weaponized exploit payloads.


Test Environment

Before diving into any security research, I always set up a clean, isolated environment. This ensures my findings are accurate and reproducible, without interference from other plugins or configurations.

My setup:

  • Fresh WordPress installation running in Docker
  • MySQL as the backend database
  • Quiz and Survey Master (QSM) plugin version 10.3.1
  • No additional plugins or themes that could affect behavior

💡 Quick Tip: This is the Docker Compose script I used to set up my environment. It spins up a ready-to-go WordPress instance with MySQL in seconds - perfect for plugin security testing: Wordpress-docker-lab

After installation, I created:

  • A basic quiz to trigger core plugin functionality
  • A Subscriber-level user account (the lowest privileged role in WordPress)

This matters because many plugin vulnerabilities require some level of authentication, and testing with low-privileged accounts helps demonstrate the true severity of the issue.


My Approach: Hunting for Vulnerabilities

Instead of performing a full code audit (which can be time-consuming), I took a more targeted approach: focus on recently added or modified functionality.

Why does this work? New features often:

  • Receive less security testing before release
  • Have edge cases that haven’t been discovered yet
  • Contain assumptions that may not hold up under adversarial input

During my review of QSM’s codebase, I noticed new logic related to quiz linking - a feature that allows quizzes or surveys to be chained together, sharing questions across multiple quizzes.


Understanding the is_linking Parameter

The is_linking parameter is used when you want to “link” a Question Bank question into a quiz (reusing or sharing it) rather than doing a normal import or copy. You can read more about this feature in QSM’s official documentation:

https://quizandsurveymaster.com/docs/creating-quizzes-and-surveys/adding-and-editing-questions/#10_Import_and_Add_Question

Based on this value, QSM determines:

  • Which backend logic paths run - linking logic only executes when is_linking >= 1
  • What data is retrieved - it builds a list of linked question IDs and queries for associated quizzes
  • How relationships are stored - the value is appended to a linked_question relationship list

Here’s the important part: although the UI treats this as a simple boolean flag (0 or 1), the backend accepts it as a raw value that can be an ID or even a list of IDs. This means it’s essentially user-controlled input that flows directly into backend logic.


The Vulnerable Code

Let’s look at the actual vulnerable code in php/rest-api.php. This is the function qsm_rest_get_question() that handles the REST API endpoint:

function qsm_rest_get_question( WP_REST_Request $request ) {
    // Makes sure user is logged in.
    if ( is_user_logged_in() ) {
        global $wpdb;
        $current_user = wp_get_current_user();
        if ( 0 !== $current_user ) {
            $question = QSM_Questions::load_question( $request['id'] );
            // ... category loading code ...
            
            if ( ! empty( $question ) ) {
                $is_linking = $request['is_linking'];  // ⚠️ User input assigned directly
                $comma_separated_ids = '';
                
                if ( 1 <= $is_linking ) {  // Simple numeric comparison
                    if ( isset( $question['linked_question'] ) && '' == $question['linked_question'] ) {
                        $comma_separated_ids = $is_linking;  // ⚠️ User input used directly
                    } else {
                        // ... merging logic ...
                        $comma_separated_ids = implode(',', array_unique($exploded_question_array));
                    }
                }

                $quiz_name_by_question = array();
                if ( ! empty($comma_separated_ids) ) {
                    // ⚠️ VULNERABLE QUERY - User input concatenated directly into SQL
                    $quiz_results = $wpdb->get_results( 
                        "SELECT `quiz_id`, `question_id` FROM `{$wpdb->prefix}mlw_questions` 
                         WHERE `question_id` IN (" . $comma_separated_ids . ")" 
                    );
                    // ... rest of processing ...
                }
            }
            return $question;
        }
    }
    return array(
        'status' => 'error',
        'msg'    => __( 'User not logged in', 'quiz-master-next' ),
    );
}

What’s Wrong Here?

The vulnerability exists because:

  1. $is_linking = $request['is_linking']; - User input is assigned directly to a variable without any validation or sanitization.

  2. The only “check” is a numeric comparison (1 <= $is_linking), which passes for any string starting with a number (due to PHP’s type juggling).

  3. The user-controlled value is directly concatenated into a SQL query string:

    "... WHERE question_id IN (" . $comma_separated_ids . ")"
    

The Problem: The $is_linking value from the HTTP request is used directly in a SQL IN() clause without any sanitization, validation, or use of prepared statements.


How the Injection Works

When a legitimate request is made, the query might look like:

SELECT quiz_id, question_id FROM wp_mlw_questions WHERE question_id IN (1)

But because is_linking is not sanitized, an attacker can inject SQL syntax. A payload like:

1) OR SLEEP(5)-- 

Transforms the query into:

SELECT quiz_id, question_id FROM wp_mlw_questions WHERE question_id IN (1) OR SLEEP(5)-- )

Breaking it down:

  • 1) - closes the original IN() clause
  • OR SLEEP(5) - introduces a 5-second delay
  • -- - comments out the remaining parenthesis (note: the space after -- is required in MySQL)

If the HTTP response takes ~5 seconds longer than a normal request, we’ve confirmed SQL injection exists.

Why Time-Based Blind?

While testing this functionality, I noticed that the request never returned any visible database output in the response, no matter what value was passed. There was no direct way to see query results reflected back to the user.

Since the input was fully controllable but no output was reflected, I suspected that if SQL injection was possible, it would be a blind one. Time-based payloads were the logical next step to confirm the vulnerability without needing visible output.


Exploitation Scenario

With time-based blind SQL injection confirmed, an attacker could:

  1. Enumerate Database Structure - Determine table names, column names, and database schema
  2. Extract Sensitive Data - Character by character, extract usernames, email addresses, and password hashes
  3. Privilege Escalation - With admin password hashes, attempt offline cracking or pass-the-hash attacks
  4. Data Manipulation - Depending on database permissions, potentially modify or delete data

Example: Extracting the Admin Username

Using conditional time delays, an attacker can extract data character by character:

1) OR IF(SUBSTRING((SELECT user_login FROM wp_users LIMIT 1),1,1)='a',SLEEP(3),0)-- 

This query sleeps for 3 seconds if the first character of the admin username is ‘a’. By iterating through characters and positions, the entire username (and other data) can be extracted.


The Fix

The patch addresses the vulnerability by properly sanitizing the is_linking parameter. The correct approach involves:

  1. Type casting the input to ensure it’s an integer: $is_linking = intval($request['is_linking']);
  2. Using prepared statements with $wpdb->prepare() for all database queries
  3. Validating that the input matches expected values before use

Secure Code Pattern

// Safe approach using prepared statements
$is_linking = intval($request['is_linking']);

if ($is_linking >= 1) {
    $quiz_results = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT quiz_id, question_id FROM {$wpdb->prefix}mlw_questions 
             WHERE question_id = %d",
            $is_linking
        )
    );
}

If you’re a developer, the key takeaway is: never trust user input, even parameters that seem like they should be simple numeric IDs.


Conclusion

Finding CVE-2025-9318 was a rewarding experience that reinforced some fundamental security principles. What made this vulnerability interesting wasn’t its complexity-it was actually quite straightforward SQL injection-but rather the combination of factors that made it exploitable: a new feature with insufficient input validation, accessible via REST API, requiring only minimal authentication.

The vulnerability demonstrates why defense-in-depth matters: even authenticated endpoints need proper input validation and parameterized queries. “The user is logged in” is not a security control for SQL injection.

I hope this write-up helps other security researchers understand the methodology behind finding vulnerabilities, and helps developers understand why input validation matters at every layer of an application.

If you have questions or want to connect, feel free to reach out on LinkedIn. I’m always happy to discuss security research and share knowledge with the community.


⚠️ Disclaimer: This research was conducted in an isolated test environment. Always ensure you have proper authorization before testing for vulnerabilities. The information shared here is for educational purposes and to help improve software security.

Happy hunting! 😄

← all research