PressPrimer Quiz <= 2.3.0 - Insecure Direct Object Reference to Authenticated (Custom+) Arbitrary Modification via 'quiz_id', 'item_id', and 'rule_id' Parameters
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:NTechnical Details
<=2.3.0What Changed in the Fix
Changes introduced in v2.3.1
Source Code
WordPress.org SVNThis 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 specificquiz_id,item_id, orrule_idbelongs 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
- Registration:
PressPrimer_Quiz_REST_Controller::register_routes()registers the/quizzes/...endpoints withcheck_permissionas thepermission_callback. - Authorization Check:
check_permission(likely usingcurrent_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. - Callback Execution: The callback (e.g.,
update_quiz_ruleordelete_quiz_rule) is invoked. - The Sink: The callback retrieves the rule/item from the database using the user-provided
rule_idoritem_idand performs the update/delete operation without checking if the associatedquiz_idis authored by theget_current_user_id().
4. Nonce Acquisition Strategy
WordPress REST API requires a wp_rest nonce for non-GET requests when using cookie-based authentication.
- Identify Trigger: The PressPrimer Quiz editor loads on the admin pages for Quizzes.
- Access Admin: Authenticate as the attacker and navigate to the Quiz dashboard:
/wp-admin/admin.php?page=ppq-quizzes. - Extraction: The WordPress core or the plugin usually localizes the REST nonce for use in the editor's React/Vue frontend.
- Browser Eval:
Note:// Standard WordPress REST nonce location window.wpApiSettings?.nonce || window.ppq_editor_vars?.nonceppq_editor_varsis an inferred name based on standard plugin naming conventions; the execution agent should checkwindowfor 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/jsonX-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 aGETrequest to a rule owned by the attacker to see the exact structure).
6. Test Data Setup
- Users:
victim_teacher(Role: Editor)attacker_teacher(Role: Editor)
- 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).
- Create a Quiz via WP-CLI or the UI as
- 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.
- Use
7. Expected Results
- Successful Deletion: The REST API returns a
200 OKor204 No Contentresponse. A subsequentGETrequest to that ID returns a404. - Successful Modification: The REST API returns
200 OKwith the modified object data. - Vulnerable State: The operation completes successfully even though
attacker_teacherdoes not own the quiz.
8. Verification Steps
- Database Check:
If deleted, the query returns no results.wp db query "SELECT * FROM wp_ppq_rules WHERE id = [VICTIM_RULE_ID];" - UI Check: Log in as
victim_teacherand 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.
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
@@ -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.