Quiz Analytics Dashboard (Fictitious)
local_fakequizanalytics
This is a fictitious plugin used to demonstrate what an MDL Shield security review looks like. This plugin does not exist.
The most severe finding is a SQL injection that any enrolled student can trigger from the report page to read other users' quiz attempts, and with more effort any table the database user can see. A second, independent finding lets any logged-in user read any student's quiz summary through the AJAX endpoint. Either one lets a low-privilege user access other users' data without further prerequisites, which sets the grade on its own.
The remaining findings are constrained to teachers or are code quality issues, and do not move the grade further.
Quiz Analytics Dashboard is a mid-sized local plugin (47 files, about 8,400 lines) that adds per-course quiz statistics for students and teachers, with a dashboard, a CSV export and an AJAX endpoint that feeds the charts.
Most of the plugin uses the Moodle APIs correctly: queries go through $DB, pages call require_login(), and the Privacy API is implemented. The problems are concentrated in the newer reporting code.
Two findings change what should happen before this version is deployed. The SQL injection in report.php lets any enrolled student read other users' data, and the AJAX endpoint returns any student's quiz summary to any logged-in user. Both are reachable without elevated roles and both are straightforward to fix.
Findings
report.php reads the quizids parameter as PARAM_RAW and interpolates it into the IN (...) clause of the attempts query. PARAM_RAW performs no cleaning, so whatever the request carries becomes part of the SQL that $DB->get_records_sql() executes. The userid condition is bound correctly, which makes the query look parameterised at a glance, but the quiz list is not.
Critical risk. Any enrolled student can rewrite the query's WHERE clause and read other users' quiz attempts, and with a UNION any table the database user can see, including the user table. Nothing between the request parameter and the query restricts its content.
The report page is linked from the course navigation for every enrolled user and only calls require_login($course) before running the query. The selected quizzes are passed as a comma-separated list built by the page's filter form.
/local/fakequizanalytics/report.php?courseid=12&quizids=0) OR 1=1 OR qa.quiz IN (0
The report then lists every user's quiz attempts on the site instead of the student's own.
report.php:58Source link unavailable — plugin was reviewed from zip without a matching git ref$quizids = optional_param('quizids', '', PARAM_RAW);
$sql = "SELECT qa.id, qa.quiz, qa.sumgrades, qa.timefinish
FROM {quiz_attempts} qa
WHERE qa.userid = :userid
AND qa.quiz IN ($quizids)";
$attempts = $DB->get_records_sql($sql, ['userid' => $USER->id]);
Read the IDs as an integer array and let get_in_or_equal() build the placeholders.
$quizids = optional_param_array('quizids', [], PARAM_INT);
if (empty($quizids)) {
$attempts = [];
} else {
[$insql, $inparams] = $DB->get_in_or_equal($quizids, SQL_PARAMS_NAMED);
$sql = "SELECT qa.id, qa.quiz, qa.sumgrades, qa.timefinish
FROM {quiz_attempts} qa
WHERE qa.userid = :userid
AND qa.quiz $insql";
$attempts = $DB->get_records_sql($sql, ['userid' => $USER->id] + $inparams);
}
ajax/get_student_data.php takes a userid and returns that user's quiz summary after only require_login(). It never checks that the caller is the requested user or holds a capability to view other students, and it does not require enrolment in the course. Any logged-in user can therefore fetch the grades and attempt history of any student by changing the ID.
High risk. The data exposed is other students' grades and quiz history, which is personal data under most institutions' policies. Exploitation needs only a login and a guessable numeric ID.
ajax/get_student_data.php:28Source link unavailable — plugin was reviewed from zip without a matching git refrequire_login();
$userid = required_param('userid', PARAM_INT);
$courseid = required_param('courseid', PARAM_INT);
echo json_encode(local_fakequizanalytics_get_student_summary($userid, $courseid));
$userid = required_param('userid', PARAM_INT);
$courseid = required_param('courseid', PARAM_INT);
require_login($courseid);
if ($userid != $USER->id) {
require_capability('local/fakequizanalytics:viewstudentdata', context_course::instance($courseid));
}
echo json_encode(local_fakequizanalytics_get_student_summary($userid, $courseid));
The dashboard renderer passes $quiz->name and $quiz->intro straight into html_writer calls. html_writer::tag() and html_writer::div() escape attributes but not content, so the stored values reach the page as raw HTML. A teacher can set a quiz name or intro containing script, which then runs for every student and administrator who opens the dashboard.
High risk. Moodle does not trust teachers with script: core cleans their content for exactly this reason. Script running in an administrator's session can act with the administrator's permissions.
classes/output/dashboard_renderer.php:89Source link unavailable — plugin was reviewed from zip without a matching git ref$html .= html_writer::tag('h3', $quiz->name);
$html .= html_writer::div($quiz->intro, 'quiz-intro');
Here $cm and $context are the quiz's course module and module context, which the renderer already loads.
$html .= html_writer::tag('h3', format_string($quiz->name, true, ['context' => $context]));
$html .= html_writer::div(format_module_intro('quiz', $quiz, $cm->id), 'quiz-intro');
manage.php deletes a course's stored analytics snapshots when it receives action=delete, after checking login and capability but not the sesskey. A link or image on any site the teacher visits can trigger the deletion in their session.
Medium risk. The attacker needs a teacher with the manage capability to visit a crafted page while logged in. The loss is limited to historical snapshots, which the plugin can rebuild only from attempts that still exist.
manage.php:41Source link unavailable — plugin was reviewed from zip without a matching git ref$action = optional_param('action', '', PARAM_ALPHA);
if ($action === 'delete') {
local_fakequizanalytics_delete_snapshots($course->id);
redirect($returnurl);
}
$action = optional_param('action', '', PARAM_ALPHA);
if ($action === 'delete') {
require_sesskey();
local_fakequizanalytics_delete_snapshots($course->id);
redirect($returnurl);
}
export.php builds the output path from the filename parameter, cleaned only as PARAM_TEXT, which keeps ../ sequences. A teacher can write the generated CSV to any path the web server can write to.
Medium risk. Only users with the export capability can reach the code, and the file content is the CSV of their own course. On a typical install the code directory is not writable, but files in $CFG->dataroot can be overwritten.
export.php:52Source link unavailable — plugin was reviewed from zip without a matching git ref$filename = required_param('filename', PARAM_TEXT);
$filepath = $CFG->tempdir . '/' . $filename;
file_put_contents($filepath, $csv);
send_file($filepath, $filename);
PARAM_FILE strips path separators, and a request directory is removed automatically when the request ends.
$filename = required_param('filename', PARAM_FILE);
$filepath = make_request_directory() . '/' . $filename;
file_put_contents($filepath, $csv);
send_file($filepath, $filename, 0, 0, false, true);
The 2024020100 upgrade step creates an index with a raw CREATE INDEX statement naming mdl_ tables. The upgrade fails on any site that uses a different table prefix, and the statement bypasses the XMLDB layer that keeps schema changes portable across databases.
db/upgrade.php:34Source link unavailable — plugin was reviewed from zip without a matching git ref$DB->execute("CREATE INDEX mdl_fqa_userid_ix ON mdl_local_fakequizanalytics_cache (userid)");
Add the same index to db/install.xml so new installs get it too.
$table = new xmldb_table('local_fakequizanalytics_cache');
$index = new xmldb_index('userid', XMLDB_INDEX_NOTUNIQUE, ['userid']);
if (!$dbman->index_exists($table, $index)) {
$dbman->add_index($table, $index);
}
print_error() was deprecated in Moodle 4.1 and removed in 4.5. On Moodle 4.5, a request for a quiz that does not exist fails with a developer error instead of the intended message. The call only reports a missing record, which get_record() can do itself.
view.php:22Source link unavailable — plugin was reviewed from zip without a matching git refif (!$quiz = $DB->get_record('quiz', ['id' => $quizid])) {
print_error('invalidquizid', 'quiz');
}
$quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST);
The summary card is assembled from html_writer calls in the renderer. A Mustache template would let themes override the card and keep markup out of PHP.
classes/output/dashboard_renderer.php:45Source link unavailable — plugin was reviewed from zip without a matching git ref$html = html_writer::start_div('fqa-card');
$html .= html_writer::tag('h4', format_string($title));
$html .= html_writer::div(get_string('attempts', 'local_fakequizanalytics') . ': ' . $attempts);
$html .= html_writer::end_div();
return $html;
With a templates/summary_card.mustache that lays out the card:
return $this->render_from_template('local_fakequizanalytics/summary_card', [
'title' => format_string($title),
'attempts' => $attempts,
]);
No automated tests. The plugin ships neither PHPUnit nor Behat tests. The SQL in report.php and the access rules in ajax/get_student_data.php are exactly the kind of code that regression tests protect.
Consider an external function for the chart data. Moving ajax/get_student_data.php to the external services API would give the endpoint parameter validation, context validation and sesskey handling for free.
Ready to secure your plugin?
Get the same thorough analysis for your Moodle plugin.