The Exploit
An authenticated user with Subscriber-level access or higher can delete QSM quiz results by POSTing a valid nonce and result_id to QSM’s AJAX delete endpoint.
curl -i -X POST "https://example.com/wp-admin/admin-ajax.php?action=qsm_dashboard_delete_result" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Cookie: wordpress_logged_in_example=USER_SESSION_COOKIE" \
--data-urlencode "nonce=VALID_WP_REST_NONCE" \
--data-urlencode "result_id=42"
The server accepts the request and returns the plugin’s delete-result response body, while the target quiz result identified by result_id=42 disappears from the QSM dashboard. No administrator privileges are required for the request to succeed, only a logged-in user session and a valid nonce.
What the Patch Did
Before:
if ( isset( $_POST['nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'wp_rest' ) && $result_id ) {
After:
if ( isset( $_POST['nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'wp_rest' ) && $result_id && current_user_can( 'administrator' ) ) {
The patch added a WordPress capability check: current_user_can( 'administrator' ). The code already validated the nonce with wp_verify_nonce() and sanitized the posted nonce; the missing defence was role-based authorization.
Root Cause
This is CWE-862: Missing Authorization. The delete workflow accepted attacker-controlled POST fields nonce and result_id, validated only the nonce, then proceeded to delete a quiz result. The key trust boundary crossed unchecked was user privilege: the code never asserted that the authenticated user was allowed to perform result deletion. As a result, any logged-in subscriber or higher could invoke qsm_dashboard_delete_result and remove arbitrary quiz results by supplying result_id in the POST body.
Why It Works
The single load-bearing fix is the added current_user_can( 'administrator' ) condition. Without that line, the bug remains exploitable even though the existing nonce check is still present. The other expressions in the if statement (isset, wp_verify_nonce, sanitize_text_field, wp_unslash, $result_id) are input validation and CSRF protection; they do not enforce authorization. The patch’s added capability check is the only security control that stops a subscriber from executing this destructive action.
Hardening Checklist
- Use
current_user_can()before performing destructive actions, and choose a capability appropriate to the operation rather than relying on page context. - Protect AJAX endpoints with
check_ajax_referer()orwp_verify_nonce()and sanitize the nonce withwp_unslash()andsanitize_text_field(). - Register sensitive AJAX handlers only for authenticated users via
add_action('wp_ajax_...'), notwp_ajax_nopriv_.... - Validate numeric IDs with
absint($_POST['result_id'])orintval()before using them in deletion logic. - Return a clear authorization failure (
wp_send_json_error()/wp_die()) when capability checks fail.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-9294