Research/moodle-enrol-database-sqli
0-dayNo CVEHighPublic

Moodle, enrol_database concatenates course idnumber

Moodle 5.2.3 enrol_database still builds external-DB SQL with db_addslashes and string concat. auth_db was parameterized in MSA-26-0005. An editing teacher sets course idnumber. Cron interpolates it. Sink is the enrol DB, not core $DB. No CVE yet.

Name
Moodle, enrol_database concatenates course idnumber
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Moodle
Affected
Moodle LMS 5.2.3 tag v5.2.3 (344232c) with enrol_database enabled. Default local map is course idnumber. Gadget lab of db_get_sql / db_addslashes.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, moodle, sqli, enrol-database, cwe-89, authenticated

auth_db got a placeholder. enrol_database got a slash

I am @abraxas_null. The proof of concept is on GitHub: abraxas/moodle-enrol-database-sqli (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, index.php. Authorized lab only. It talks to loopback.

This is Moodle LMS 5.2.3 (344232c15336c71b80f9aca8359ce0e0a9f3d116). No CVE yet. CWE-89. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N). Remote SQLi on the external enrolment database. Plugin must be on. Moodle core $DB is not the sink. Not OS LPE.

MSA-26-0005 / MDL-88138 parameterized auth_db: Execute($sql, [$extusername]). enrol_database still slashes and concatenates. Same class of external DB. Same decade of db_addslashes. One plugin got the patch. The other kept the helper they already distrusted.

Same pin: Blackboard img file read, grades feedback IDOR, hidden grade items.

Why this plugin

Moodle talks to other databases when an institution already has a student-information system. enrol_database is that bridge. Cron reads a remote table and enrols people. The local map defaults to course idnumber, which an editing teacher can set. That is a SQL parameter wearing a course-settings costume.

I only wanted Critical or High. SQLi on an external enrol DB with teacher write is High. FILE / INTO OUTFILE would need that DB role. I did not demonstrate it and I am not inflating it to OS RCE.

Pinning v5.2.3

Same pin as the Blackboard leftover. Tag v5.2.3, commit 344232c, build 20260914. MSA-26-0005 is in this tag. That is the point. The advisory closed auth_db. I opened the other plugin that still had db_addslashes.

The skip list

Do not re-file auth_db. Do not re-file MSA-26-0031 question-bank WS SQLi. Do not re-file MSA-26-0042 profile availability. Those are parameterized or whitelisted on this tag.

The rest of the MSA-26 list from the Blackboard post applies. I am not repeating it as if it were a new hunt.

Surfaces that returned nothing

Core $DB xmldb and moodle_database on 5.2.3 did not give me a teacher SQLi. Report builder audience is is_subclass_of. Question WS is parameterized. I was not looking for "Moodle has SQL." I was looking for the plugin MSA-26-0005 forgot.

enrol_database was that plugin.

The concat

public/enrol/database/lib.php db_get_sql:

PHP
protected function db_get_sql($table, array $conditions, array $fields, $distinct = false, $sort = "") {
    $fields = $fields ? implode(',', $fields) : "*";
    $where = array();
    if ($conditions) {
        foreach ($conditions as $key=>$value) {
            $value = $this->db_encode($this->db_addslashes($value));

            $where[] = "$key = '$value'";
        }
    }

db_addslashes when dbsybasequoting is 0 (the default):

PHP
$text = str_replace("'", "''", $text);

A backslash is not doubled. MySQL sees \' as an escaped quote and the string closes. Cron interpolates the mapped course field:

PHP
$sql = $this->db_get_sql($table, array($coursefield=>$course->mapping), $sqlfields);
if ($rs = $extdb->Execute($sql)) {

sync_user_enrolments does the same with $user->$localuserfield. Student path only if an admin maps localuserfield=email. Default path is teacher idnumber.

auth_db after MDL-88138:

PHP
$sql = "SELECT $select
          FROM {$this->config->table}
         WHERE {$this->config->fielduser} = ?";

if ($rs = $authdb->Execute($sql, [$extusername])) {

That is the fix they already wrote. It is not in this file.

The lab

Faithful gadget of the pin builders. php:8.3-cli serves the copied db_get_sql / db_addslashes / db_encode. mysql:8.0 holds two enrol rows. Loopback :19570. Compose project moodle-enrol-database-sqli. Not a full Moodle PHP app. how=gadget.

Plugin off issued no SQL. Clean idnumber LABCOURSE returned one row (teacher-local). LABCOURSE\' OR 1=1 -- returned two rows, including MOODLE-ENROL-DATABASE-SQLI-WITNESS from another course. The auth_db-style placeholder query returned zero rows and the SQL contained ?.

Plain text
SUCCESS moodle-enrol-database-sqli how=gadget plugin-off=no-query clean-rows=1 inject-rows=2 sql-concat=yes param-rows=0 extra=yes sink=enrol-db MOODLE-ENROL-DATABASE-SQLI-WITNESS
Plain text
python3 moodle-enrol-database-sqli-Abraxas-Labs.py

or cd lab && ./run.sh. Target only http://127.0.0.1:19570. Stack tears down after the run.

Wrong turns, in the order I made them

Treating Moodle core $DB as the sink. The leftover is $extdb->Execute($sql) on the enrol plugin connection. Core xmldb is a different hunt.

Calling this OS LPE because FILE exists as a MySQL privilege. FILE / INTO OUTFILE needs that DB role. Not demonstrated. Not this leftover.

Student email as the default map. Admin has to change localuserfield. Default is course idnumber. Teacher.

Plugin-off as SUCCESS. Plugin-off is the negative. No query. If the gadget still talks to MySQL with the plugin disabled, the lab is wrong.

Stacked queries. This mysqli gadget is single-statement. Extra rows are the oracle. Not a second statement.

A reverse shell. Theatre. The oracle is two rows where one course should have one, plus the witness userid.

What an attacker can do

An editing teacher on a site with enrol_database enabled injects SQL on the external enrolment database by setting the course idnumber. Cron sync_enrolments then runs that SQL.

Class: SQLi. Reach: Remote.

Privilege Method / sink Recon / integrity / availability Lab-proven or inferred
Splice a SQL fragment into the external enrol SELECT Cron sync_enrolments -> db_get_sql -> $extdb->Execute($sql) Integrity of the query string Lab-proven. Concat SQL is WHERE courseid = 'LABCOURSE\'' OR 1=1 -- '
Read other courses' enrol rows on that external DB Same SELECT after the string closes Recon Lab-proven. Clean: 1 row. Inject: 2 rows including MOODLE-ENROL-DATABASE-SQLI-WITNESS
Skip the query when the plugin is off Same gadget, plugin=off n/a Lab-proven closed. executed=false, empty SQL, 0 rows
Hold the same payload as a bound parameter auth_db leftover of MSA-26-0005: Execute($sql, [$value]) n/a Lab-proven closed. style=param returned 0 rows, SQL contains ?
Enrol / unenrol / role-assign Moodle users named in extra rows After cron applies requestedenrols Integrity of enrolments Inferred. Lab stops at extra rows on the enrol DB
FILE / INTO OUTFILE as the enrol-DB user Same sink if that account has FILE Integrity / availability of that DB host Inferred. Not demonstrated. Not OS LPE
Student-controlled path Only if admin maps localuserfield=email n/a Inferred. Default path is teacher idnumber

Closed: Moodle core $DB; plugin disabled; unauthenticated access; OS shell; stacked queries. Default Moodle without enrol_database is not this bug.

The fix

Copy auth_db's parameterized queries (MDL-88138). Pass the mapped course / user value as Execute($sql, [$value]). Do not concatenate db_addslashes into '$value'. Add a test that a quote in course idnumber does not return rows from other courses.

I am not going to print an idnumber you can paste at someone else's course settings. The missing placeholder is the useful part. If you own the site, run the proof of concept against loopback.

References