MDL Shield
Sample Report — This is a fictitious plugin created to demonstrate the format and detail of an MDL Shield security review. The plugin, findings, and code samples shown here are entirely fabricated for illustrative purposes.

Quiz Analytics Dashboard (Fictitious)

local_fakequizanalytics

Plugin Information

This is a fictitious plugin used to demonstrate what an MDL Shield security review looks like. This plugin does not exist.

Privacy API
Unit Tests
Behat Tests
Reviewed:2024-03-15
47 files·8,420 lines
Grade Justification

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.

AI Summary

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

securityCritical
SQL injection through the quizids parameter in report.php
Exploitable by:
student

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.

Risk Assessment

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.

Context

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.

Proof of Concept
/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
Vulnerable Code
$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]);
Suggested Fix

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);
}
securityHigh
AJAX endpoint returns any student's quiz summary to any user
Exploitable by:
student

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.

Risk Assessment

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 ref
Vulnerable Code
require_login();
$userid = required_param('userid', PARAM_INT);
$courseid = required_param('courseid', PARAM_INT);
echo json_encode(local_fakequizanalytics_get_student_summary($userid, $courseid));
Suggested Fix
$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));
securityHigh
Stored XSS through quiz names and intros on the dashboard
Exploitable by:
editingteacher

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.

Risk Assessment

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
Vulnerable Code
$html .= html_writer::tag('h3', $quiz->name);
$html .= html_writer::div($quiz->intro, 'quiz-intro');
Suggested Fix

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');
securityMedium
Snapshot deletion action does not check the sesskey

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.

Risk Assessment

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
Affected Code
$action = optional_param('action', '', PARAM_ALPHA);
if ($action === 'delete') {
    local_fakequizanalytics_delete_snapshots($course->id);
    redirect($returnurl);
}
Suggested Fix
$action = optional_param('action', '', PARAM_ALPHA);
if ($action === 'delete') {
    require_sesskey();
    local_fakequizanalytics_delete_snapshots($course->id);
    redirect($returnurl);
}
securityMedium
Export filename allows writing outside the temp directory
Exploitable by:
editingteacher

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.

Risk Assessment

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
Affected Code
$filename = required_param('filename', PARAM_TEXT);
$filepath = $CFG->tempdir . '/' . $filename;
file_put_contents($filepath, $csv);
send_file($filepath, $filename);
Suggested Fix

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);
code qualityLow
Upgrade step uses raw DDL with a hardcoded table prefix

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
Identified Code
$DB->execute("CREATE INDEX mdl_fqa_userid_ix ON mdl_local_fakequizanalytics_cache (userid)");
Suggested Fix

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);
}
code qualityLow
Removed print_error() breaks the missing-quiz path

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 ref
Identified Code
if (!$quiz = $DB->get_record('quiz', ['id' => $quizid])) {
    print_error('invalidquizid', 'quiz');
}
Suggested Fix
$quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST);
best practiceInfo
Summary card HTML could move to a Mustache template

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
Identified Code
$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;
Suggested Fix

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,
]);
Additional AI Notes

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.

This review was generated by an AI system and may contain inaccuracies. Findings should be verified by a human reviewer before acting on them.

Ready to secure your plugin?

Get the same thorough analysis for your Moodle plugin.