MoodleBox
tool_moodlebox
- #1System commands executed via exec()/shell_exec() built with string concatenation
- #2Wi-Fi regulatory country accepted as PARAM_RAW without an allow-list check
- #3Dashboard table outputs some hardware/system values without s() escaping
- #4Dead and unreachable code in classes/local/utils.php
- #5Footer hook runs a sudo shell command on every page load for capable users
tool_moodlebox is a Moodle administration tool that provides a dashboard and GUI to monitor and manage a MoodleBox — a Moodle server running on a Raspberry Pi. It lets a site administrator view hardware and network status (CPU load/temperature, SD-card space, DHCP/Wi-Fi clients, PiJuice battery, etc.) and perform device operations: set the system date/time, change the device/database password, configure the Wi-Fi access point (SSID, channel, regulatory country, password, static IP), resize the SD-card partition, and restart/shut down the device. Managers can optionally be granted restart/shutdown and date-time buttons in the footer of every page.
Privileged operations are implemented with a privilege-separation design: the Moodle web process (running as an unprivileged user) does not execute root commands directly. Instead it writes small trigger files into a protected sub-directory of dataroot (moodledata/moodlebox/). A root-level direvent daemon watches those files and runs the bundled shell/Python scripts in bin/ to carry out the action.
This plugin controls physical hardware and, by design, writes files that a root daemon executes — a pattern that normally invites critical findings. On close inspection the implementation is careful and the dangerous-looking surface is well contained.
Access control is strong. The dashboard (index.php), which exposes every management action (password, date/time, Wi-Fi, partition resize, restart/shutdown), is registered as an admin_externalpage with the default moodle/site:config requirement, and admin_externalpage_setup() enforces it via require_login() plus check_access(). The only lower-privilege surface is the footer hook, which is gated by the tool/moodlebox:viewbuttonsinfooter capability (manager archetype) and an admin-enabled setting, and exposes only date/time and restart/shutdown. The capability carries the correct RISK_CONFIG | RISK_DATALOSS bitmask. No unauthenticated, student, or teacher path reaches any sensitive sink — the hook performs no file writes or shell calls for users lacking the capability.
The one obvious root-RCE vector is closed. The .set-server-datetime trigger file is a shell script executed as root, built from $data->currentdatetime. That value comes from a date_time_selector element whose exportValue() runs make_timestamp(), which casts all components to (int) and returns an integer timestamp — no shell metacharacters can reach the script.
Input handling and downstream defence-in-depth are sound. All exec/shell_exec calls use hardcoded commands with no user-controlled input; form submissions are processed through moodleform::get_data() (automatic sesskey/CSRF protection); user-influenced values shown on the dashboard are escaped with s(); and the root bin/ scripts independently re-validate every value (country against the ISO list, channel range, SSID as hex, password by regex, IP as a private IPv4) and invoke system tools via subprocess.run([...]) list form rather than a shell.
The remaining issues are minor hardening and code-quality items: direct shell execution via string concatenation (currently safe but better served by escapeshellarg()), a PARAM_RAW Wi-Fi country field that is not constrained to an allow-list, inconsistent output escaping of non-user-controlled hardware values, some dead/unreachable code, and a per-page sudo shell-out for capable users. None are exploitable in the intended single-device, admin-operated deployment. No critical, high, or medium issues were found; the Privacy API null_provider is appropriate, there is no direct database access, no outbound HTTP, and no bundled third-party libraries.
Overview
tool_moodlebox is a device-management admin tool for a MoodleBox (Moodle on a Raspberry Pi). It surfaces hardware/network telemetry and lets an administrator change the system date/time, device & database password, Wi-Fi access-point configuration, resize the SD-card partition, and reboot/shut down the box. Because Moodle runs unprivileged, the plugin uses a trigger-file privilege-separation model: it writes intent files into moodledata/moodlebox/, and a root direvent daemon executes bundled bin/ scripts to apply the change.
Security posture
The plugin is well engineered for its unusually high-risk purpose:
- Admin-gated management page.
index.phpis anadmin_externalpagerequiringmoodle/site:config; every password/Wi-Fi/partition/restart action lives there. - Narrow, capability-gated footer. The only non-admin surface (date/time and restart/shutdown buttons) requires the
tool/moodlebox:viewbuttonsinfootercapability (manager) and an admin-enabled setting, with a correct risk bitmask. The hook does nothing for users without the capability. - No command injection. The date/time trigger script is built from an integer timestamp guaranteed by
make_timestamp(); all otherexec/shell_execcalls use hardcoded commands. - CSRF/XSS handled. State changes go through
moodleform(sesskey); user-influenced dashboard values ares()-escaped. - Downstream re-validation. The root
bin/scripts re-validate every value and avoid the shell (subprocess.run([...])).
Findings
No critical, high, or medium issues. Five low-severity hardening / code-quality items: shell execution built by string concatenation (use escapeshellarg()), a PARAM_RAW Wi-Fi country field with no allow-list, inconsistent escaping of non-user hardware values on the dashboard, dead/unreachable code in utils.php, and a per-page sudo shell-out for capable users. All are mitigated by the admin-only / single-device deployment model.
The Privacy API null_provider is appropriate, there is no direct DB access, no outbound HTTP, and no bundled third-party libraries.
Findings
The plugin drives the Raspberry Pi hardware by calling the system shell directly — exec() throughout index.php and exec()/shell_exec() in classes/local/utils.php. This is inherent to the plugin's purpose: there is no Moodle API to read a CPU temperature or run nmcli/parted/vcgencmd.
I traced every call site. None interpolates user-supplied input:
- All
nmcli,dpkg-query,iw reg,partedandvcgencmdcommands are fully hardcoded string literals. - The two commands that concatenate a variable use values that are system-derived or hardcoded, not request data:
get_ethernet_addresses()interpolates$ifacefromget_ethernet_interface_name()(a device name scanned from/sys/class/net), andget_connected_ip_adresses()interpolates$interface, whichindex.phppasses as the hardcoded literal'uap0'. - The one path that builds a command from a filesystem path (
python3 .../pijuicestatus.py) wraps the path inescapeshellarg().
Because no attacker-controlled data reaches these commands, there is no command injection today. The finding is a defence-in-depth / coding-standard note: building shell command strings by concatenation is fragile, and a future edit that lets any of these variables carry request data would turn this into OS command execution that runs with the web server's privileges (and, via the trigger files, ultimately root).
Low risk. This is the plugin's core risk surface, but it is not currently exploitable: no request-derived data reaches any command, the commands are hardcoded, and the management page is admin-only on a single-tenant device. The value is purely defensive — consistent use of escapeshellarg() on the few interpolated variables removes the chance that a later change silently introduces OS command injection, which would be severe given that the plugin's trigger files are executed as root.
classes/local/utils.php is the helper layer used by the admin dashboard and the footer hook to gather device information and network state. The shell calls read hardware status and the Wi-Fi/ethernet configuration. The dashboard page that triggers most of these (index.php) is an admin_externalpage requiring moodle/site:config, so only site administrators reach the code, and the inputs are constants defined in the plugin itself.
$command = "ip route show 0.0.0.0/0 dev " . $iface;
if ($ethernetaddresses = exec($command)) {
Wrap interpolated values in escapeshellarg() even when they are currently system-derived, so the call stays safe if the source ever changes:
$command = "ip route show 0.0.0.0/0 dev " . escapeshellarg($iface);
$iwoutput = shell_exec('iw dev ' . $interface . ' station dump') ?: '';
$arpoutput = shell_exec('arp -ani ' . $interface) ?: '';
Escape the interface name:
$iface = escapeshellarg($interface);
$iwoutput = shell_exec('iw dev ' . $iface . ' station dump') ?: '';
$arpoutput = shell_exec('arp -ani ' . $iface) ?: '';
In wifisettings_form.php the wificountry field is a select populated from get_list_of_countries(true), but its type is set to PARAM_RAW and the form's validation() method does not confirm the submitted value is one of the offered country codes. Moodle's form library does not server-side-validate that a submitted select value is among the declared options, so a crafted POST can set wificountry to an arbitrary string, including one containing newline characters (PARAM_RAW strips nothing).
index.php then writes the value verbatim into the .wifisettings trigger file as country=<value>. A newline in the value injects additional key=value lines into that file, which the root changewifisettings.py consumer parses with configparser.
This is primarily an input-hygiene / code-quality issue rather than a live vulnerability, because of two independent mitigations: the form is only reachable from the admin-only dashboard, and every value the Python script reads is re-validated with a safe fallback (the country is checked against /usr/share/zoneinfo/iso3166.tab, the channel is range-checked, the SSID must be hex, the password must match a printable-ASCII regex, and the IP must be a private IPv4). An injected key therefore cannot bypass those checks.
Low risk. Exploitation requires moodle/site:config — a full site administrator, who can already change all of these Wi-Fi parameters legitimately — and the injected data is independently re-validated by the root consumer, so it grants no additional capability and cannot reach a command interpreter (the Python script uses subprocess.run([...]) list form). The correct fix is to type the field as PARAM_ALPHA and reject values outside the offered country list, matching how the other fields are constrained.
The Wi-Fi settings form lives only on the admin-only dashboard (index.php, moodle/site:config). Its output is written to moodledata/moodlebox/.wifisettings, a file inside the protected data directory that is consumed by the root direvent-launched bin/changewifisettings.py. All other free-text fields in the form are already constrained: the SSID is hex-encoded before writing, the password is checked against a printable-ASCII regex, and the static IP is validated by is_private_ipv4_address().
$mform->setType('wificountry', PARAM_RAW);
Constrain the type and reject values outside the offered list in validation():
$mform->setType('wificountry', PARAM_ALPHA);
// ...
public function validation($data, $files) {
$errors = parent::validation($data, $files);
$countries = get_string_manager()->get_list_of_countries(true);
if (!isset($countries[$data['wificountry']])) {
$errors['wificountry'] = get_string('wificountryinvalid', 'tool_moodlebox');
}
// ... existing checks ...
return $errors;
}
"country=" . $data->wificountry . "\n" .
Once wificountry is validated against the country list (see above) it can only be a two-letter code, so no newline can reach the trigger file.
The dashboard builds a flexible_table and prints values directly. flexible_table does not HTML-escape cell content, which is why the author correctly wraps every user-influenceable value in s() (SSID, channel, country, Wi-Fi password, static IP, and DHCP client IP/name/MAC). A number of non-user-controlled values are, however, printed raw:
- PiJuice fields (
battery_status,battery_temp,charge_level,status_error,is_fault) produced bybin/pijuicestatus.pyfrom the PiJuice hardware library. - Hardware identifiers
$hardwaredata['revision']and$hardwaredata['revisioncode']derived from/proc/cpuinfo. $rpiosversion(PRETTY_NAMEfrom/etc/os-release),$kernelversion(php_uname()), and the MoodleBox version/date from/etc/moodlebox-info.
On a correctly provisioned MoodleBox none of these sources is attacker-controllable, and the page is admin-only, so this is not an exploitable XSS. It is flagged as a consistency / defence-in-depth matter: all values written into HTML should pass through s() (or format_string()), so the output stays safe even if one of these local sources is ever tampered with.
Low risk. No realistic exploit exists in the intended deployment: the data is not attacker-controllable and only administrators see the page. The recommendation is purely for output-encoding consistency and robustness against local tampering.
The dashboard is rendered only for site administrators (admin_externalpage with moodle/site:config). The unescaped values originate from local device files and the bundled PiJuice helper script, not from HTTP requests or other users. The author already demonstrates awareness of flexible_table's raw output by escaping the genuinely user-influenced rows.
$table->add_data([get_string('revision', 'tool_moodlebox'), $hardwaredata['revision']], 'subinfo');
$table->add_data([get_string('revisioncode', 'tool_moodlebox'), $hardwaredata['revisioncode']], 'subinfo');
Wrap the values in s() for consistency with the rest of the table:
$table->add_data([get_string('revision', 'tool_moodlebox'), s($hardwaredata['revision'])], 'subinfo');
$table->add_data([get_string('revisioncode', 'tool_moodlebox'), s($hardwaredata['revisioncode'])], 'subinfo');
$table->add_data([get_string('pijuicebatterystatus', 'tool_moodlebox'),
$pijuicestatus['battery_status'], ], 'subinfo');
Apply s() to the PiJuice-derived strings as well, e.g. s($pijuicestatus['battery_status']).
classes/local/utils.php contains code that is never executed:
get_survey_data()is a public method that assembles device/survey information (an MD5 device id from the CPU serial, OS release, kernel, hardware, SD size, timestamp) but is not called anywhere in the plugin. It alsorequiresversion.phpto read$plugin. If telemetry/survey submission was intended it is not wired up; if it is obsolete it should be removed.get_moodlebox_info()has areturn $moodleboxinfo;statement immediately after areturn [...], which is unreachable, and its$fileparameter is overwritten on the first line of the body ($file = '/etc/moodlebox-info';), so the argument is ignored.
These are maintainability defects, not security issues.
Low risk. No functional or security impact — the unreachable return never runs and the unused method is never invoked. Cleanup reduces confusion and the chance that dead paths (such as device-id collection) are accidentally reactivated without a privacy review.
utils.php is the plugin's helper class. get_moodlebox_info() is used by the dashboard to display the MoodleBox image version; the unreachable return and ignored parameter do not affect its observable behaviour. get_survey_data() has no callers in the plugin tree.
public static function get_survey_data() {
require(dirname(dirname(dirname(__FILE__))) . '/version.php');
Remove get_survey_data() if it is unused, or wire it into a documented, privacy-reviewed feature (a scheduled task or explicit admin action) if telemetry is intended. If it is kept, note that any off-device transmission of the device id would require review of the Privacy API null_provider declaration.
$moodleboxinfo = '';
$file = '/etc/moodlebox-info';
Drop the redundant reassignment so the $file parameter is honoured, and remove the unreachable trailing return $moodleboxinfo; after the array return.
The before_footer_html_generation hook fires on every page render. For any user who holds tool/moodlebox:viewbuttonsinfooter (managers and administrators) it unconditionally calls utils::get_throttled_state(), which executes sudo vcgencmd get_throttled via exec() — regardless of whether the footer buttons are enabled. This adds a sudo-backed shell process to the render of every page those users visit, which is a performance and resource cost on a low-powered Raspberry Pi.
The under-voltage warning it powers is a useful health indicator, but it does not need to run on every single request.
Low risk. This is a performance/best-practice concern, not a security issue. The blast radius is limited to managers/administrators, and the work is a single quick vcgencmd call, but on constrained Raspberry Pi hardware repeated sudo invocations on every page are worth avoiding by caching the result.
The hook is registered against core\hook\output\before_footer_html_generation, so it participates in rendering every page. The expensive call is guarded by a capability check, so ordinary users are unaffected (and the hook performs no shell calls or file writes for them), but privileged users trigger it on each navigation.
if ($throttledstate = \tool_moodlebox\local\utils::get_throttled_state()) {
Avoid shelling out on every request — for example cache the throttled state for a short period (e.g. via the Cache API / MUC or a transient), or compute it in a scheduled task and read the cached result in the hook. This keeps the warning while removing the per-page sudo process.
Architecture is the right call for the constraint. Because Moodle runs unprivileged, the plugin cannot (and must not) change passwords, reconfigure Wi-Fi, or reboot the host directly. The trigger-file + root direvent design is a deliberate privilege-separation boundary, and the files are written into moodledata/moodlebox/, which is outside the web root and not web-servable. This is an appropriate use of a plugin-owned dataroot subdirectory rather than the File API.
The date/time trigger is safe specifically because of the integer guarantee. .set-server-datetime is executed as a root bash script, and its only variable is $data->currentdatetime. That value is produced by the core date_time_selector element, whose exportValue() funnels the inputs through make_timestamp(); that function casts every component to (int) and returns DateTime::getTimestamp(). As long as the date/time value keeps coming from this element, no shell metacharacters can enter the script. Any future change that sources this value differently (e.g. a raw parameter) must preserve the integer constraint.
End-to-end safety also depends on operator-supplied configuration that is outside this repository. The README instructs admins to configure /etc/direvent.conf and /etc/sudoers.d/020_www-data-nopasswd. The plugin's side of the contract is sound (integer-constrained date/time, hex-encoded SSID, regex/allow-list re-validation in the bin/ scripts, narrowly scoped sudoers entries for parted ... print free and vcgencmd). The direvent daemon itself and the admin's configuration are not part of the plugin and were not in scope; a mis-scoped sudoers rule or an over-broad direvent watch would shift risk, but that is a deployment concern, not a defect in this code.
Footer capability scope. tool/moodlebox:viewbuttonsinfooter is named for viewing buttons, but when the corresponding admin setting is enabled it also lets the holder press them — i.e. a manager can set the system clock and reboot/shut down the physical device. This is intended and is correctly flagged with RISK_CONFIG | RISK_DATALOSS, so an administrator assigning the capability is warned. Consider making the capability description state explicitly that it permits performing these actions, not only seeing the buttons.
Transient plaintext secrets in dataroot. The .newpassword file briefly holds the new device/DB password in cleartext until changepassword.sh consumes and truncates it (> $PASSWORDFILE), and the current Wi-Fi AP password is shown on the admin dashboard (escaped with s()). Both are inherent to the feature set, confined to the protected data directory / admin-only page, and acceptable given the single-device model.
Shell-quoting nit in the bundled root script. In bin/changepassword.sh, echo $USER:$NEWPASSWORD | chpasswd leaves $NEWPASSWORD unquoted. It is not exploitable (the form forbids / and ', the script escapes for MySQL, and only the first line of the file is used, so a newline merely truncates), but quoting the expansion (printf '%s:%s\n' "$USER" "$NEWPASSWORD" | chpasswd) is good hygiene for a script that runs as root.
Positive observations. State-changing actions all go through moodleform::get_data(), which enforces sesskey (CSRF) automatically; there is no direct database access and no custom tables (so no db/upgrade.php/install.xml are needed); there is no outbound HTTP, so no SSRF/TLS surface; the deprecated footer strings are properly tracked in lang/en/deprecated.txt; and the MOODLE_INTERNAL guards are present exactly where needed (files with file-scope side effects) and absent from the single-class autoloaded files that should not carry them.