CVE-2026-10623

PressPrimer Quiz <= 2.3.0 - Insecure Direct Object Reference to Authenticated (Custom+) Arbitrary Modification via 'quiz_id', 'item_id', and 'rule_id' Parameters

mediumAuthorization Bypass Through User-Controlled Key
4.3
CVSS Score
4.3
CVSS Score
medium
Severity
2.3.1
Patched in
1d
Time to patch

Description

The PressPrimer Quiz – AI Quiz Maker, Exam Builder & LMS Assessment Plugin plugin for WordPress is vulnerable to Insecure Direct Object Reference in all versions up to, and including, 2.3.0 via the 'rule_id' parameter due to missing validation on a user controlled key. This makes it possible for authenticated attackers, with custom-level access and above, to modify or delete quiz rules belonging to other teachers, resulting in unauthorized tampering of another user's quiz structure.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
None
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=2.3.0
PublishedJune 17, 2026
Last updatedJune 18, 2026
Affected pluginpressprimer-quiz

What Changed in the Fix

Changes introduced in v2.3.1

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This exploitation research plan targets **CVE-2026-10623**, an Insecure Direct Object Reference (IDOR) vulnerability in the **PressPrimer Quiz** plugin. This vulnerability allows authenticated users with "custom-level access" (typically Instructor/Teacher roles) to modify or delete quiz rules and it…

Show full research plan

This exploitation research plan targets CVE-2026-10623, an Insecure Direct Object Reference (IDOR) vulnerability in the PressPrimer Quiz plugin. This vulnerability allows authenticated users with "custom-level access" (typically Instructor/Teacher roles) to modify or delete quiz rules and items belonging to other users.

1. Vulnerability Summary

  • Vulnerability: Insecure Direct Object Reference (IDOR)
  • Location: includes/api/class-ppq-rest-controller.php
  • Cause: The REST API endpoints for updating and deleting quiz items and rules validate that the user has the general permission to manage quizzes (check_permission), but they fail to verify if the specific quiz_id, item_id, or rule_id belongs to a quiz owned by the current user.
  • Impact: Unauthorized modification or deletion of quiz structures (questions, logic rules, etc.) across the entire WordPress installation.

2. Attack Vector Analysis

  • Endpoints:
    • PUT /wp-json/ppq/v1/quizzes/(?P<quiz_id>\d+)/items/(?P<item_id>\d+)
    • DELETE /wp-json/ppq/v1/quizzes/(?P<quiz_id>\d+)/items/(?P<item_id>\d+)
    • PUT /wp-json/ppq/v1/quizzes/(?P<quiz_id>\d+)/rules/(?P<rule_id>\d+)
    • DELETE /wp-json/ppq/v1/quizzes/(?P<quiz_id>\d+)/rules/(?P<rule_id>\d+)
  • Vulnerable Parameters: quiz_id, item_id, rule_id.
  • Authentication: Authenticated. The attacker needs a role that satisfies check_permission. In LMS contexts, this is often a custom role like "Teacher" or "Instructor," or the standard "Editor" role.
  • Preconditions: The attacker must know (or iterate through) the numeric IDs of quizzes and rules they do not own.

3. Code Flow

  1. Registration: PressPrimer_Quiz_REST_Controller::register_routes() registers the /quizzes/... endpoints with check_permission as the permission_callback.
  2. Authorization Check: check_permission (likely using current_user_can('edit_posts') or a custom capability) verifies the user's role globally but does not receive the request parameters to check object ownership.
  3. Callback Execution: The callback (e.g., update_quiz_rule or delete_quiz_rule) is invoked.
  4. The Sink: The callback retrieves the rule/item from the database using the user-provided rule_id or item_id and performs the update/delete operation without checking if the associated quiz_id is authored by the get_current_user_id().

4. Nonce Acquisition Strategy

WordPress REST API requires a wp_rest nonce for non-GET requests when using cookie-based authentication.

  1. Identify Trigger: The PressPrimer Quiz editor loads on the admin pages for Quizzes.
  2. Access Admin: Authenticate as the attacker and navigate to the Quiz dashboard: /wp-admin/admin.php?page=ppq-quizzes.
  3. Extraction: The WordPress core or the plugin usually localizes the REST nonce for use in the editor's React/Vue frontend.
  4. Browser Eval:
    // Standard WordPress REST nonce location
    window.wpApiSettings?.nonce || window.ppq_editor_vars?.nonce
    
    Note: ppq_editor_vars is an inferred name based on standard plugin naming conventions; the execution agent should check window for objects containing "ppq".

5. Exploitation Strategy

The goal is to delete a quiz rule belonging to a different user.

Step 1: Setup Victim Data
As an Admin or Victim user, create a quiz and a rule. Record the rule_id and quiz_id.

Step 2: Attacker Authentication
Authenticate as an "Editor" (Attacker) and obtain the wp_rest nonce.

Step 3: The Attack Request (Deletion)
Send a DELETE request to the victim's rule endpoint.

  • Tool: http_request
  • Method: DELETE
  • URL: http://localhost:8080/wp-json/ppq/v1/quizzes/[VICTIM_QUIZ_ID]/rules/[VICTIM_RULE_ID]
  • Headers:
    • Content-Type: application/json
    • X-WP-Nonce: [EXTRACTED_NONCE]
  • Body: {}

Step 4: The Attack Request (Modification)
Alternatively, modify the rule to change the quiz logic.

  • Method: PUT
  • URL: http://localhost:8080/wp-json/ppq/v1/quizzes/[VICTIM_QUIZ_ID]/rules/[VICTIM_RULE_ID]
  • Body: {"rule_data": {"type": "modified", "logic": "always_fail"}} (Parameter names are inferred from common REST patterns; use the response from a GET request to a rule owned by the attacker to see the exact structure).

6. Test Data Setup

  1. Users:
    • victim_teacher (Role: Editor)
    • attacker_teacher (Role: Editor)
  2. Victim Content:
    • Create a Quiz via WP-CLI or the UI as victim_teacher.
    • Add a "Rule" to that quiz. Rules are likely stored in a custom table (e.g., wp_ppq_rules).
  3. Discovery:
    • Use wp db query "SELECT id, quiz_id FROM wp_ppq_rules LIMIT 1;" to find a valid target ID if not using the UI.

7. Expected Results

  • Successful Deletion: The REST API returns a 200 OK or 204 No Content response. A subsequent GET request to that ID returns a 404.
  • Successful Modification: The REST API returns 200 OK with the modified object data.
  • Vulnerable State: The operation completes successfully even though attacker_teacher does not own the quiz.

8. Verification Steps

  1. Database Check:
    wp db query "SELECT * FROM wp_ppq_rules WHERE id = [VICTIM_RULE_ID];"
    
    If deleted, the query returns no results.
  2. UI Check: Log in as victim_teacher and verify the rule is missing from their quiz editor.

9. Alternative Approaches

  • Item Modification: Target /quizzes/{quiz_id}/items/{item_id} instead. If rules are protected by an extra check, items (individual questions in the quiz) might not be.
  • Quiz Meta Modification: Check if the main quiz endpoint /quizzes/(?P<id>\d+) (EDITABLE) also lacks ownership validation, which would allow changing the title, description, or settings of another user's quiz.
Research Findings
Static analysis — not yet PoC-verified

Summary

The PressPrimer Quiz plugin for WordPress is vulnerable to an Insecure Direct Object Reference (IDOR) via its REST API endpoints for quiz items and rules. Authenticated attackers with basic editing privileges (such as the 'Editor' role or custom 'Instructor' roles) can modify or delete quiz structures belonging to other users by manipulating the 'quiz_id', 'item_id', and 'rule_id' parameters in API requests.

Vulnerable Code

// includes/api/class-ppq-rest-controller.php @ 2.3.0 line 2480
public function update_quiz_item( $request ) {
    $item_id = absint( $request['item_id'] );
    $data    = $request->get_json_params();

    $item = PressPrimer_Quiz_Quiz_Item::get( $item_id );

    if ( ! $item ) {
        return new WP_Error( 'not_found', __( 'Quiz item not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
    }

    $item->update( $data );
    // ...
}

---

// includes/api/class-ppq-rest-controller.php @ 2.3.0 line 2617
public function update_quiz_rule( $request ) {
    $rule_id = absint( $request['rule_id'] );
    $data    = $request->get_json_params();

    $rule = PressPrimer_Quiz_Quiz_Rule::get( $rule_id );

    if ( ! $rule ) {
        return new WP_Error( 'not_found', __( 'Quiz rule not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
    }

    $rule->update( $data );
    // ...
}

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/pressprimer-quiz/2.3.0/includes/api/class-ppq-rest-controller.php	2026-06-03 16:46:06.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/pressprimer-quiz/2.3.1/includes/api/class-ppq-rest-controller.php	2026-06-09 19:34:04.000000000 +0000
@@ -479,6 +479,31 @@
 	}
 
 	/**
+	 * Load a quiz by ID and verify the current user owns it.
+	 *
+	 * Centralizes the ownership pattern so every per-quiz handler shares
+	 * the same check. Admins with pressprimer_quiz_manage_all bypass the
+	 * owner test. Returns 404 when the quiz does not exist and 403 when
+	 * the requester does not own it.
+	 *
+	 * @since 2.3.1
+	 *
+	 * @param int $quiz_id Quiz ID.
+	 * @return PressPrimer_Quiz_Quiz|WP_Error Quiz on success, error otherwise.
+	 */
+	private function get_owned_quiz_or_error( $quiz_id ) {
+		$quiz = PressPrimer_Quiz_Quiz::get( absint( $quiz_id ) );
+		if ( ! $quiz ) {
+			return new WP_Error( 'not_found', __( 'Quiz not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
+		}
+		if ( ! current_user_can( 'pressprimer_quiz_manage_all' )
+			&& absint( $quiz->owner_id ) !== get_current_user_id() ) {
+			return new WP_Error( 'forbidden', __( 'You do not have permission.', 'pressprimer-quiz' ), [ 'status' => 403 ] );
+		}
+		return $quiz;
+	}
+
+	/**
 	 * Check editor permission for block usage
 	 *
 	 * Allows users who can edit posts to see the list of published quizzes
@@ -2396,7 +2421,13 @@
 	 */
 	public function get_quiz_items( $request ) {
 		$quiz_id = absint( $request['quiz_id'] );
-		$items   = PressPrimer_Quiz_Quiz_Item::get_for_quiz( $quiz_id );
+
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
+		$items = PressPrimer_Quiz_Quiz_Item::get_for_quiz( $quiz_id );
 
 		$data = array_map(
 			function ( $item ) {
@@ -2438,6 +2469,11 @@
 		$data         = $request->get_json_params();
 		$question_ids = $data['question_ids'] ?? [];
 
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
 		if ( empty( $question_ids ) ) {
 			return new WP_Error( 'no_questions', __( 'No questions provided.', 'pressprimer-quiz' ), [ 'status' => 400 ] );
 		}
@@ -2478,12 +2514,18 @@
 	 * @return WP_REST_Response|WP_Error Response object or error.
 	 */
 	public function update_quiz_item( $request ) {
+		$quiz_id = absint( $request['quiz_id'] );
 		$item_id = absint( $request['item_id'] );
 		$data    = $request->get_json_params();
 
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
 		$item = PressPrimer_Quiz_Quiz_Item::get( $item_id );
 
-		if ( ! $item ) {
+		if ( ! $item || absint( $item->quiz_id ) !== absint( $quiz->id ) ) {
 			return new WP_Error( 'not_found', __( 'Quiz item not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
 		}
 
@@ -2505,11 +2547,17 @@
 	 * @return WP_REST_Response|WP_Error Response object or error.
 	 */
 	public function delete_quiz_item( $request ) {
+		$quiz_id = absint( $request['quiz_id'] );
 		$item_id = absint( $request['item_id'] );
 
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
 		$item = PressPrimer_Quiz_Quiz_Item::get( $item_id );
 
-		if ( ! $item ) {
+		if ( ! $item || absint( $item->quiz_id ) !== absint( $quiz->id ) ) {
 			return new WP_Error( 'not_found', __( 'Quiz item not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
 		}
 
@@ -2527,13 +2575,28 @@
 	 * @return WP_REST_Response|WP_Error Response object or error.
 	 */
 	public function reorder_quiz_items( $request ) {
+		$quiz_id  = absint( $request['quiz_id'] );
 		$data     = $request->get_json_params();
 		$item_ids = $data['item_ids'] ?? [];
 
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
 		if ( empty( $item_ids ) ) {
 			return new WP_Error( 'no_items', __( 'No items provided.', 'pressprimer-quiz' ), [ 'status' => 400 ] );
 		}
 
+		// Every supplied item must belong to the URL's quiz so the
+		// reorder cannot reach across into another teacher's items.
+		foreach ( $item_ids as $item_id ) {
+			$item = PressPrimer_Quiz_Quiz_Item::get( absint( $item_id ) );
+			if ( ! $item || absint( $item->quiz_id ) !== absint( $quiz->id ) ) {
+				return new WP_Error( 'not_found', __( 'Quiz item not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
+			}
+		}
+
 		$result = PressPrimer_Quiz_Quiz_Item::reorder( $item_ids );
 
 		if ( is_wp_error( $result ) ) {
@@ -2553,7 +2616,13 @@
 	 */
 	public function get_quiz_rules( $request ) {
 		$quiz_id = absint( $request['quiz_id'] );
-		$rules   = PressPrimer_Quiz_Quiz_Rule::get_for_quiz( $quiz_id );
+
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
+		$rules = PressPrimer_Quiz_Quiz_Rule::get_for_quiz( $quiz_id );
 
 		$data = array_map(
 			function ( $rule ) {
@@ -2588,6 +2657,11 @@
 		$quiz_id = absint( $request['quiz_id'] );
 		$data    = $request->get_json_params();
 
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
 		$rule_id = PressPrimer_Quiz_Quiz_Rule::create(
 			[
 				'quiz_id'        => $quiz_id,
@@ -2615,12 +2689,18 @@
 	 * @return WP_REST_Response|WP_Error Response object or error.
 	 */
 	public function update_quiz_rule( $request ) {
+		$quiz_id = absint( $request['quiz_id'] );
 		$rule_id = absint( $request['rule_id'] );
 		$data    = $request->get_json_params();
 
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
 		$rule = PressPrimer_Quiz_Quiz_Rule::get( $rule_id );
 
-		if ( ! $rule ) {
+		if ( ! $rule || absint( $rule->quiz_id ) !== absint( $quiz->id ) ) {
 			return new WP_Error( 'not_found', __( 'Quiz rule not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
 		}
 
@@ -2655,11 +2735,17 @@
 	 * @return WP_REST_Response|WP_Error Response object or error.
 	 */
 	public function delete_quiz_rule( $request ) {
+		$quiz_id = absint( $request['quiz_id'] );
 		$rule_id = absint( $request['rule_id'] );
 
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
 		$rule = PressPrimer_Quiz_Quiz_Rule::get( $rule_id );
 
-		if ( ! $rule ) {
+		if ( ! $rule || absint( $rule->quiz_id ) !== absint( $quiz->id ) ) {
 			return new WP_Error( 'not_found', __( 'Quiz rule not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
 		}
 
@@ -2677,16 +2763,31 @@
 	 * @return WP_REST_Response|WP_Error Response object or error.
 	 */
 	public function reorder_quiz_rules( $request ) {
+		$quiz_id    = absint( $request['quiz_id'] );
 		$data       = $request->get_json_params();
 		$rule_order = isset( $data['rule_order'] ) && is_array( $data['rule_order'] ) ? $data['rule_order'] : [];
 
+		$quiz = $this->get_owned_quiz_or_error( $quiz_id );
+		if ( is_wp_error( $quiz ) ) {
+			return $quiz;
+		}
+
 		if ( empty( $rule_order ) ) {
 			return new WP_Error( 'no_rules', __( 'No rules provided.', 'pressprimer-quiz' ), [ 'status' => 400 ] );
 		}
 
 		$order_map = [];
 		foreach ( $rule_order as $index => $rule_id ) {
-			$order_map[ absint( $rule_id ) ] = (int) $index;
+			$rule_id = absint( $rule_id );
+
+			// Every supplied rule must belong to the URL's quiz so the
+			// reorder cannot reach across into another teacher's rules.
+			$rule = PressPrimer_Quiz_Quiz_Rule::get( $rule_id );
+			if ( ! $rule || absint( $rule->quiz_id ) !== absint( $quiz->id ) ) {
+				return new WP_Error( 'not_found', __( 'Quiz rule not found.', 'pressprimer-quiz' ), [ 'status' => 404 ] );
+			}
+
+			$order_map[ $rule_id ] = (int) $index;
 		}
 
 		$result = PressPrimer_Quiz_Quiz_Rule::reorder( $order_map );

Exploit Outline

The exploit involves an authenticated attacker (with a role capable of editing quizzes, such as an Editor or custom LMS Instructor role) targeting quiz content that they do not own. 1. Authenticate as a user with basic quiz management permissions and obtain a valid WordPress REST API nonce (X-WP-Nonce). 2. Identify the target `quiz_id` and the specific `item_id` (question) or `rule_id` (logic rule) belonging to another user. 3. Send a `DELETE` or `PUT` request to the following REST endpoints: - Items: `/wp-json/ppq/v1/quizzes/{quiz_id}/items/{item_id}` - Rules: `/wp-json/ppq/v1/quizzes/{quiz_id}/rules/{rule_id}` 4. Because the vulnerable code only validates that the user has the general capability to manage quizzes (via the 'permission_callback'), but does not verify ownership of the specific IDs provided, the server will execute the modification or deletion as requested.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.