MDL Shield

MoodleBox

tool_moodlebox

Print Report
Plugin Information

tool_moodlebox is a Moodle site-administration tool (admin/tool) that provides a GUI to monitor and manage a MoodleBox — a Moodle server running on a Raspberry Pi. It surfaces hardware/OS telemetry (CPU load, temperature and frequency, SD-card usage, uptime, connected Wi-Fi/DHCP clients, PiJuice battery status, under-voltage warnings) and lets a privileged user set the device date/time, change the Raspberry Pi Unix and database passwords, configure the Wi-Fi access point (SSID, channel, country, password, static IP), resize the SD-card partition, and restart or shut down the device. Because a web request cannot perform privileged OS actions directly, privileged operations are carried out by writing small trigger files into a dedicated moodledata/moodlebox/ subdirectory, which root-owned direvent watchers detect and execute via the bundled bin/ shell and Python scripts.

Version:2026072200
Release:3.3.0
Reviewed for:5.2
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-08-05
17 files·2,015 lines
Grade Justification

The plugin implements an inherently dangerous capability set (root-level password changes, Wi-Fi reconfiguration, clock changes, partition resizing, restart/shutdown of the host) through a privilege-separation / trigger-file architecture, so the content of every trigger file is security-critical. I traced each sink to its root-run handler and found the injection surface carefully and deliberately contained:

  • The date/time value is a core date_time_selector field, whose exportValue() returns an integer Unix timestamp from make_timestamp() (verified in core, where each component is cast to (int)), so no shell metacharacters can reach the root-executed script.
  • The password flow blocks single quotes and slashes in the form, and the changepassword.sh script uses quoted variables (no command substitution on variable contents), escapes for mysql and sed, and reads only the first line.
  • The Wi-Fi flow re-validates every field in changewifisettings.py and calls subprocess.run([...]) with argument lists (no shell), with the SSID hex-encoded before transport.
  • Resize/restart/shutdown write constant file contents.

Access control is correct and verified against core: the dashboard is gated by moodle/site:config (admin_externalpage_setup), the footer buttons by an explicit tool/moodlebox:viewbuttonsinfooter capability (manager archetype), and every state-changing action is a moodleform with automatic sesskey validation. Attacker-influenceable output (DHCP client hostnames) is escaped with s(), and the Privacy API is a correct null_provider.

No command injection, XSS, CSRF, SQL injection, SSRF, or access-control bypass is exploitable by any user, privileged or otherwise. The remaining findings are all low-severity best-practice and code-quality items: a fragile-but-currently-safe interpolation of the date value into a root-executed script, an exec() invoked on every page load for privileged users, some dead code, and transient cleartext secrets written to moodledata. This places the plugin at the top of the scale, held just below the highest tier only by these minor hardening and hygiene items.

AI Summary

Overview

tool_moodlebox is an admin/tool plugin that gives a Moodle administrator a GUI to monitor and manage a MoodleBox — a Moodle server running on a Raspberry Pi. It displays hardware/OS telemetry and lets privileged users set the device clock, change the Raspberry Pi and database passwords, configure the Wi-Fi access point, resize the SD-card partition, and restart/shut down the device.

Architecture and trust model

Because a web request (running as www-data) cannot perform privileged OS actions directly, the plugin uses a privilege-separation / trigger-file pattern: form submissions write small files into moodledata/moodlebox/, and root-owned direvent watchers (configured by the administrator per the README) detect the writes and run the bundled bin/ shell and Python scripts as root. This makes the entire content of every trigger file security-critical.

Data-flow tracing of every privileged sink

  • Date/time (.set-server-datetime, executed as a bash script): the value is a core date_time_selector element, whose exportValue() always returns an integer timestamp from make_timestamp() — verified in core, where each part is cast to (int). No shell metacharacters can reach the script.
  • Password (.newpassword, read by changepassword.sh): the form forbids ' and /; the script uses quoted variables (so $(...)/backticks in the value are inert), escapes for mysql and sed, and consumes only the first line.
  • Wi-Fi (.wifisettings, read by changewifisettings.py): the Python handler re-validates every field and calls subprocess.run([...]) with argument lists (no shell); the SSID is hex-encoded before transport.
  • Resize / restart / shutdown: constant file contents.

Access control and output handling

The dashboard is gated by moodle/site:config (verified via admin_externalpage_setupadmin_externalpage::check_access()), the footer buttons by an explicit tool/moodlebox:viewbuttonsinfooter capability (manager archetype), and all state-changing forms are moodleforms with automatic sesskey validation. Output that can carry attacker-influenced data (DHCP client hostnames) is escaped with s(). The Privacy API is implemented as a null_provider, which is appropriate.

Assessment

No injection, XSS, CSRF, SQL, SSRF, or access-control vulnerability was found exploitable by any user. The findings are low-severity best-practice and code-quality items: a fragile (but currently safe) interpolation of the date value into a root-executed script, an exec() call made on every page load for privileged users, some dead code, and transient cleartext secrets in moodledata. The plugin is carefully written and shows deliberate, layered defense against command injection.

Findings

best practiceLow
Date/time value is interpolated into a shell script that runs as root

The date/time feature writes an executable shell script to the .set-server-datetime trigger file in moodledata, which a root-owned direvent watcher then runs with /bin/bash. The script body is built by string-interpolating $data->currentdatetime directly into a date command, and the same block appears in both the admin dashboard and the footer hook.

At present this is not exploitable. The currentdatetime field is a core date_time_selector form element, whose exportValue() always returns an integer Unix timestamp produced by make_timestamp() — which casts every date component to (int). A raw scalar submitted for that field cannot survive the group element's per-subfield processing, so no shell metacharacters can reach the script body.

The concern is defense-in-depth. This is the only trigger-file sink where user-influenced data is written into a file that is executed as code — the other sinks write inert data or constant strings. The safety of a root-level command therefore rests entirely on an implicit type guarantee from an unrelated core class. A future change to the form element, or reuse of this pattern with a differently-typed field, would silently turn it into root remote code execution with no other barrier in place.

Risk Assessment

Low risk (defense-in-depth). Not exploitable in the current code: date_time_selector::exportValue() and make_timestamp() in core guarantee an integer, so the field cannot carry shell metacharacters. Reaching the sink additionally requires moodle/site:config (dashboard) or the tool/moodlebox:viewbuttonsinfooter capability plus an admin-enabled setting (footer), and every submission is sesskey-protected by moodleform. The recommendation is purely hardening: make the integer constraint explicit at the sink and avoid emitting user-derived data into an executable script body, so a root command is never one refactor away from injection.

Context

index.php (the moodle/site:config-gated dashboard) and classes/hook_callbacks.php (the footer, shown to holders of tool/moodlebox:viewbuttonsinfooter, i.e. managers and admins, when the admin enables the setting) both contain the same block. After moodleform::get_data() returns validated data, the code composes date +%s -s @<timestamp> and writes #!/bin/sh\n<command>\nexit 0\n to the trigger file. Per the README, the administrator configures a direvent watcher that runs /bin/bash /var/www/.../.set-server-datetime on change — i.e. as root.

Identified Code
                $datecommand = "date +%s -s @$data->currentdatetime";
                file_put_contents($datetimetriggerfile, "#!/bin/sh\n" . $datecommand . "\nexit 0\n");
Suggested Fix

Cast the value explicitly at the sink so the guarantee is local, and prefer writing inert data over an executable body:

$datecommand = 'date +%s -s @' . (int)$data->currentdatetime;

Better still, write only the raw integer timestamp to the trigger file and let the watcher script build the date command, so the web layer never emits an executable script at all.

Identified Code
                    $datecommand = "date +%s -s @$data->currentdatetime";
                    file_put_contents($datetimetriggerfile, "#!/bin/sh\n" . $datecommand . "\nexit 0\n");
Suggested Fix

Apply the same (int) cast (or raw-timestamp approach) here in the footer callback.

best practiceLow
Footer hook shells out with exec() on every page load for privileged users

The before_footer_html_generation callback is registered for a core hook that fires on every page render of the whole site. For any user holding tool/moodlebox:viewbuttonsinfooter (managers and administrators), the callback unconditionally calls utils::get_throttled_state(), which runs exec('sudo vcgencmd get_throttled | ...'). When the footer-button settings are enabled it also instantiates and processes the date/time and restart/shutdown moodleform objects on every request.

Spawning a sudo subprocess on each page load adds avoidable latency and system load for privileged users. The under-voltage / throttled state changes slowly and is a good candidate for short-lived caching rather than a per-request shell-out.

Risk Assessment

Low risk. This is not a security issue — the capability gate and moodleform sesskey handling are correct, and no untrusted user can trigger the exec. The impact is performance and resource usage: one or more sudo/subprocess spawns per page for privileged users. Caching the hardware state and deferring form construction would remove the overhead.

Context

db/hooks.php registers hook_callbacks::before_footer_html_generation against \core\hook\output\before_footer_html_generation, which core dispatches during footer generation for essentially every page. The capability check correctly ensures unprivileged users trigger no exec and receive empty output, so the cost is confined to managers and admins — but it is incurred on each of their page views.

Identified Code
        if (has_capability('tool/moodlebox:viewbuttonsinfooter', $context)) {
            // Get throttled state and print warning if throttling is active or has occurred.
            if ($throttledstate = \tool_moodlebox\local\utils::get_throttled_state()) {
Suggested Fix

Cache the throttled state in a MUC application cache with a short TTL, and only build the forms when their output is actually about to be rendered (i.e. inside the get_config(...) guards). Non-privileged users are already correctly excluded by the capability check.

Identified Code
        $command = "sudo vcgencmd get_throttled | awk -F'=' '{print $2}'";
        // Get bit pattern from device.
        if ($throttledstate = exec($command)) {
Suggested Fix

Memoise or cache the result of get_throttled_state() so the vcgencmd subprocess is not invoked on every page load.

code qualityLow
Dead and unreachable code in utils.php

classes/local/utils.php contains several methods and statements that are never used or can never execute:

  • get_survey_data(), get_wireless_interface_name() and convert_hex_string() are public static methods that are not called anywhere in the plugin (confirmed by searching the whole plugin tree). get_survey_data() also reads the Raspberry Pi serial number from /proc/cpuinfo and hashes it into a device identifier — code that carries a small privacy footprint yet is never invoked.
  • get_moodlebox_info() contains a return $moodleboxinfo; statement immediately after an earlier return, so it is unreachable; the method also reassigns its $file parameter to the same hard-coded path on the very next line, making the parameter meaningless.

Unused and unreachable code adds maintenance burden, obscures intent, and (in the case of get_survey_data()) leaves latent data-collection logic in the tree.

Risk Assessment

Low risk. Pure code-quality/maintenance concern with no security impact. Worth removing during routine cleanup; get_survey_data() in particular should be removed or clearly justified since it builds a hardware-derived identifier.

Context

These are helper methods on the plugin's utility class. The actively used helpers are get_hardware_model(), parse_config_file(), get_moodlebox_info(), get_connected_ip_adresses(), get_ethernet_addresses(), get_ethernet_interface_name(), unallocated_free_space(), is_private_ipv4_address() and get_throttled_state(); the three listed methods are not referenced by index.php, the hook callback, or the forms.

Identified Code
    public static function get_survey_data() {
        require(dirname(dirname(dirname(__FILE__))) . '/version.php');
Suggested Fix

Remove get_survey_data() if it is genuinely unused, or document where it is consumed (e.g. an external MoodleBox image process) and add a test. If retained, note that it collects a device identifier derived from the hardware serial.

Identified Code
                return $moodleboxinfo;
Suggested Fix

Delete the unreachable return, and drop the redundant $file = '/etc/moodlebox-info'; reassignment (lines 142–143) so the $file parameter is either honoured or removed.

Identified Code
    public static function get_wireless_interface_name() {
Suggested Fix

Remove get_wireless_interface_name() and convert_hex_string() (line 248) if unused, or wire them into the code paths that need them.

best practiceInfo
Secrets are written in cleartext to transient moodledata trigger files

The privilege-separation design necessarily passes secrets from the web layer to the root watchers through files in moodledata/moodlebox/:

  • The new Raspberry Pi Unix account password and database password are written verbatim to .newpassword (changepassword.sh then applies them and blanks the file).
  • The Wi-Fi PSK is written to .wifisettings as password=<plaintext> (changewifisettings.py applies it and blanks the file).

These secrets sit in cleartext on disk for the short window between the web write and the watcher processing the file.

This is inherent to the architecture and well mitigated — it is called out here so the trust model is explicit, not because a specific defect was found.

Risk Assessment

Informational. No practical exposure for a normally configured site: the storage location is not web-accessible, the files are transient and wiped after use, and only an administrator can trigger the writes. Documented so reviewers understand that device secrets briefly transit moodledata by design.

Context

moodledata is outside the web root and not directly served, and both root-side scripts truncate their trigger file after processing (> $PASSWORDFILE in changepassword.sh; open(settings_file, 'w').close() in changewifisettings.py). Only www-data and root can read the directory. Reaching these writes requires moodle/site:config (the Wi-Fi and password forms live only on the admin-only dashboard). There is no Moodle File API suited to this local root-IPC use case, so plain files under the plugin's own dataroot subdirectory are the established approach.

Identified Code
                file_put_contents($passwordtriggerfile, $data->newpassword1);
Suggested Fix

No change is required for correctness. If tighter handling is desired, ensure the trigger files are created with least-privilege permissions (owner www-data, mode 0600) and confirm the root scripts reliably blank them even on early exit (they currently do).

Identified Code
                file_put_contents(
                    $aptriggerfile,
                    "channel=" . $data->wifichannel . "\n" .
                    "country=" . $data->wificountry . "\n" .
                    "password=" . $data->wifipassword . "\n" .
Suggested Fix

Same as above — the pattern is acceptable; restricting file permissions is the only optional hardening.

Additional AI Notes

Deliberate, layered defense against command injection. The plugin is unusually careful for its risk profile: form-level PARAM_* typing and validation() rules are backed by independent re-validation inside the root-side scripts. changewifisettings.py re-checks the country against /usr/share/zoneinfo/iso3166.tab, the channel range, the PSK charset/length, and the IP against Python's ipaddress module, and drives nmcli/iw exclusively through subprocess.run([...]) argument lists (never shell=True). The SSID is hex-encoded before transport and only unhexlified after a hex-format check. changepassword.sh uses quoted shell variables (so command substitution in the value is inert), escapes separately for mysql and sed, and the form additionally forbids ' and /. This is what keeps an intrinsically dangerous feature set safe.

Stale compatibility claim in the README. README.md states the plugin is "compatible with Moodle 3.6 or later", but version.php sets $plugin->requires = 2024042200 (Moodle 4.4) and the CHANGELOG marks releases from 3.1.0 onward as incompatible with Moodle below 4.4. Update the README to avoid misleading administrators of older sites.

Forms pass defaults through global variables. wifisettings_form and datetimeset_form read global $currentssid; (etc.) that are populated in index.php before the form is constructed. Passing these values via the moodleform constructor's $customdata argument would make the forms self-contained and avoid subtle breakage if they are ever instantiated from a different context.

No thirdpartylibs.xml is required. The bundled bin/ shell and Python scripts are first-party (authored by the plugin maintainers under the same GPL header), and the pijuice Python module referenced by pijuicestatus.py is an external runtime dependency that is imported, not bundled. Nothing third-party ships in the tree, and no Moodle-core library is duplicated.

Privacy API is correct. The plugin stores no personal data in Moodle — it writes transient device-config files and reads OS/hardware telemetry — so the null_provider implementation with a privacy:metadata reason string is the appropriate choice.

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