Apache Struts RCE (CVE-2023–50164)

vsociety
10 min readApr 29, 2024

by@jakaba

Screenshots from the blog posts

PoC video

Summary

A critical security vulnerability, identified as CVE-2023-50164 (CVE: 9.8) was found in Apache Struts, allowing attackers to manipulate file upload parameters that can potentially lead to unauthorized path traversal and remote code execution (RCE).

Description

Introduction

Apache Struts is a popular and powerful framework for developing Java web applications based on the Model-View-Controller (MVC) design pattern, which allows developers to separate the business logic, presentation, and user interaction of their applications. Struts is free and open-source, making it widely accessible and adaptable. However, a serious security vulnerability has been found in some versions of it, which could compromise the integrity and safety of the web applications that use it.

The vulnerability, known as CVE-2023-50164, affects the way Apache Struts handles file upload parameters. These parameters can be tampered with by an attacker to perform unauthorized path traversal, which means accessing and modifying files and directories on the server that are not intended to be exposed. This could result in uploading a malicious file that can execute arbitrary code on the server, a scenario called Remote Code Execution (RCE). This is one of the most dangerous types of cyberattacks, as it can give the attacker full control over the server and its resources.

Apache's security advisory says that

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.

No specific instances of active exploitation have been disclosed yet. However, considering the severity of the vulnerability, prompt action is highly recommended to prevent potential exploitation.

Affected versions

CVE-2023-50164 was discovered and reported by Steven Seeley, a security researcher. The vulnerability impacts a wide range of Apache Struts versions

  • from Struts 2.0.0 to 2.3.37 (included)
  • from Struts 2.5-BETA1 to Struts 2.5.32 (included)
  • from Struts 6.0.0 to Struts 6.3.0.1(included)

These versions are vulnerable to exploitation and need to be updated as soon as possible. The Apache Struts project strongly recommends this upgrade to all developers. It is a simple and easy upgrade that does not require any changes to the existing code. Users are recommended to upgrade to versions Struts 2.5.33 or Struts 6.3.0.2 or greater to fix this issue.

Timeline

Here is the brief timeline of the vulnerability:

  • December 4th (2023): The git commits related to the fix has been released.
  • December 7th: The vulnerability was reported by Lukasz Lenart.
  • December 9th: A security bulletin on Apache's page is released.
  • December 10th: The first analytical articles have been published.
  • December 12th: The first analysis confirmed by the reporter is published.

Static analysis

Path traversal and RCE

Path traversal, also known as directory traversal, is a security vulnerability that occurs when an attacker manipulates a file path to access files or directories outside the intended scope. In the context of file upload functions, path traversal can become a serious threat. If not properly validated, an attacker could exploit this vulnerability to navigate through the file system and potentially upload malicious files to unintended locations. This could lead to the compromise of sensitive data or even the execution of arbitrary code on the server.

Remote Code Execution (RCE) is a severe consequence of unrestricted file uploads. When an attacker can manipulate the file upload process to upload and execute a malicious file, it opens the door to execute arbitrary code on the server. This is a critical security risk, as it grants the attacker unauthorized access and control over the server, allowing them to execute commands and potentially compromise the entire system. Ensuring robust input validation, proper file type verification, and secure storage practices are crucial in preventing path traversal and mitigating the risk of RCE in file upload functionalities.

Struts' ActionSupport

In Struts 2, the ActionSupport class serves as a convenient base class for action classes, providing default implementations for common actions and simplifying development. Actions are defined in configuration files, typically in XML format, where mappings between URLs and action classes are specified. The configuration files, such as struts.xml, defining the flow of the application by associating actions with results, specifying interceptors for pre-processing and post-processing, and configuring various settings. Here is a very basic struts.xml:

<!DOCTYPE struts PUBLIC "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN" "http://struts.apache.org/dtds/struts-2.0.dtd">
<struts>
<package name="default" extends="struts-default">
<action name="hello" class="com.example.HelloAction">
<result name="success">/success.jsp</result>
<result name="error">/error.jsp</result>
</action>
</package>
</struts>

In this example:

  • The <result> element has two options: one for the "success" outcome and another for the "error" outcome.
  • The "success" result points to a JSP page named success.jsp.
  • The "error" result points to a JSP page named error.jsp.

When a user interacts with a web application, the request is processed by the controller (typically the FilterDispatcher), which consults the configuration files to determine the appropriate action. Then, the action, often extending ActionSupport, is executed. See this code to have a basic understanding:

import com.opensymphony.xwork2.ActionSupport;

public class HelloAction extends ActionSupport {

private String message;

public String execute() {
setMessage("Hello, Struts 2!");
// Custom logic
return SUCCESS;
}

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}
}

Here, the HelloAction class extends ActionSupport and implements the execute method that sets a message and returns SUCCESS, indicating a successful execution. The class also has a message property with corresponding getter and setter methods.

Patch diffing

You can check the commit related to the patch here (version 6.3.x) and here (version 2.5.x).

If you want, you can get the whole source code of Struts since it's an open-source project. Use this command to clone a vulnerable version of the codebase:

git clone https://github.com/apache/struts.git --depth 1 -b STRUTS_6_3_0_1

First, we talk about the code changes, and then, we focus on the unit tests that make more obvious what is the patch exactly about.

The patch affects core/src/main/java/org/apache/struts2/dispatcher/HttpParameters.java file only that is a part of the framework's infrastructure for handling HTTP request parameters and plays a crucial role in managing and providing access to the parameters sent in an HTTP request.

First, see that the parameters in the requests are stored in a Map<String, Parameter>.

final private Map<String, Parameter> parameters;

One of the most important changes is in the appendAll() method:

The get(), contains(), and remove() methods are also affected to make to them case insensitive.

The original version has a different behavior:

  1. Case sensitivity: It uses the equals method for key comparison in the contains and remove methods, which means key comparison is case-sensitive. This behavior might be undesirable in certain cases, especially when dealing with data from the client side that doesn't strictly adhere to a defined format.
  2. Direct removal of parameters: The remove method directly removes keys from the parameters map. This can lead to potential data loss or undesired behavior, especially when keys are case-sensitive.
  3. No handling of Null keys: The get method in the original version doesn't handle null keys and simply creates an empty Parameter object for null keys. This can potentially result in undesired behavior when working with applications that use null keys.

In short, the original version of the code operates with case-sensitive comparisons when handling parameter names in the remove, contains, and get methods of the HttpParameters class. In the patched version, the code has been modified to perform case-insensitive comparisons. It is assumed that a parameter overriding can be achieved through changes in the capitalization of the first letter.

Another commit that is mentioned in some places is related to the files uploaded. Check it out here. Since I found it's not important to understand the core of the vulnerability, we don't analyze it.

The unit tests

Consider these two unit tests added to the patch.

public class HttpParametersTest {

@Test
public void shouldGetBeCaseInsensitive() {
// given
HttpParameters params = HttpParameters.create(new HashMap<String, Object>() {{
put("param1", "value1");
}}).build();

// then
assertEquals("value1", params.get("Param1").getValue());
assertEquals("value1", params.get("paraM1").getValue());
assertEquals("value1", params.get("pAraM1").getValue());
}

@Test
public void shouldAppendSameParamsIgnoringCase() {
// given
HttpParameters params = HttpParameters.create(new HashMap<String, Object>() {{
put("param1", "value1");
}}).build();

// when
assertEquals("value1", params.get("param1").getValue());

params = params.appendAll(HttpParameters.create(new HashMap<String, String>() {{
put("Param1", "Value1");
}}).build());

// then
assertTrue(params.contains("param1"));
assertTrue(params.contains("Param1"));

assertEquals("Value1", params.get("param1").getValue());
assertEquals("Value1", params.get("Param1").getValue());
}

}

The unit test class is created for the org.apache.struts2.dispatcher.HttpParameters class and consisting of two methods:

  1. shouldGetBeCaseInsensitive method:
  • The main purpose of this test method is to verify if the parameter retrieval method in the HttpParameters class is case-insensitive.
  • It creates an HttpParameters object containing a key-value pair: "param1" -> "value1".
  • Retrieves parameter values using different case variations of the key, such as "Param1", "paraM1" and "pAraM1".
  • Asserts that the parameter values obtained using the different case variations are all equal to "value1".
  1. shouldAppendSameParamsIgnoringCase method:
  • This test method checks if the parameter appending (append) functionality of the HttpParameters class is case-insensitive.
  • It creates an HttpParameters object with a key-value pair: "param1" -> "value1".
  • Retrieves parameter values using different case variations of the key to validate the initial state.
  • Creates another HttpParameters object with a key-value pair: "Param1" -> "Value1".
  • Appends the parameters of the second object to the first one.
  • Asserts that after the append operation, the original object contains the new parameter keys "Param1" and "param1", and their values are both "Value1".

In summary, the tests aim to ensure that the HttpParameters class is insensitive to the case of parameter keys when retrieving values and that the parameter appending operation correctly handles case variations. These tests are crucial for verifying the robustness and correctness of the HttpParameters class in handling parameters in a case-insensitive manner.

Now we should have an initial insight about what the vulnerability is related to. Now move on to the attack and debugging part to have a much deeper understanding.

Reproducing the vulnerability

Lab Setup

I used Kali Linux but you are free to use any other distribution. Other softwares I used: IntelliJ (Community Edition), Burp Pro, Tomcat, and JDK 17.

Install Java

First, we have to install JDK 17.

sudo apt update
sudo apt install openjdk-17-jdk -y

Or, if you already have it but have multiple versions, it is possible to set the desired one with sudo update-alternatives --config java command.

You can check your Java version with java -version.

Now, let's clone an example project that has file upload functionality.

cd /opt
git clone https://github.com/apache/struts-examples.git --depth 1
cd struts-examples

Open your favorite text editor and set the struts version to a vulnerable one. At the time of writing this post, the patch has already been applied (version 6.3.0.2):

So, we have to downgrade it to let's say 6.3.0.1. Then, run the following command to build the related module (file-upload).

mvn -e clean package -Dmaven.test.skip=true -pl file-upload

The war has built so now we have to copy it to a servlet container like Tomcat, Jetty, GlassFish, etc.

Installing Tomcat

My choice was Tomcat 9 for now so let's install it. (Note that higher Tomcat versions will produce an error due to the Servlet API version conflict between the Struts app and Tomcat.)

# Download and install Apache Tomcat 9

mkdir /opt/tomcat && cd /opt/tomcat
wget wget https://dlcdn.apache.org/tomcat/tomcat-9/v9.0.83/bin/apache-tomcat-9.0.83.tar.gz
tar -xvf apache-tomcat-9*tar.gz -C /opt/tomcat --strip-components=1
rm -f apache-tomcat-9*.tar.gz

# Create a new user and group for Tomcat
sudo groupadd tomcat
sudo useradd -s /bin/false -g tomcat -d /opt/tomcat tomcat

# Configure user permissions
sudo chown -R tomcat: /opt/tomcat
sudo sh -c 'chmod +x /opt/tomcat/bin/*.sh'

Our objective is to configure Apache Tomcat to operate as a systemd service, allowing for convenient starting, stopping, and enabling. As Tomcat doesn't include a systemd unit file by default, we'll proceed to manually create one.

sudo vi /etc/systemd/system/tomcat.service

Insert the provided code block that defines the systemd service file below.

[Unit]
Description=Tomcat webs servlet container
After=network.target
[Service]
Type=forking
User=tomcat
Group=tomcat
RestartSec=10
Restart=always
Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64/"
Environment="JAVA_OPTS=-Djava.awt.headless=true -Djava.security.egd=file:/dev/./urandom"
Environment="CATALINA_BASE=/opt/tomcat"
Environment="CATALINA_HOME=/opt/tomcat"
Environment="CATALINA_PID=/opt/tomcat/temp/tomcat.pid"
Environment="CATALINA_OPTS=-Xms512M -Xmx1024M -server -XX:+UseParallelGC"
ExecStart=/opt/tomcat/bin/startup.sh
ExecStop=/opt/tomcat/bin/shutdown.sh
[Install]
WantedBy=multi-user.target

Now, save the file and exit from the editor.

For security reasons (to prevent unauthorized access to admin controls), access to the Manager is disabled by default. Moreover, there is no predefined username and password. To enable access, you must create a new username/password combination and assign it the manager-gui role. To achieve this, you'll need to edit the $CATALINA_BASE/conf/tomcat-users.xml file with for example a user called "admin" with password "Supers3c3tP4ssw0rd".

Then reload the unit files, enable, and start Tomcat:

sudo systemctl daemon-reload
sudo systemctl start tomcat
sudo systemctl enable tomcat

Then verify that Apache Tomcat is running with systemctl status tomcat.

The output confirms that the Tomcat daemon is operational, indicating that our configuration is correct.

Now everything is ready for accessing Tomcat's web interface. Open your web browser and navigate to http://localhost:8080. You should see the following page there:

The next step is to deploy our war file. Click on "Manager App" and log in with the credentials we have given.

Now we are in the Tomcat Web Application Manager where we can deploy a war file.

Select the war located at /opt/struts-examples/file-upload/target/file-upload-1.1.0.war and click on "Deploy".

You should see a humble "OK" message under our beloved cat, and also an unclickable "Start" label that indicates that the app is running. Click on "/file-upload-1.1.0".

Now we finally have our amazing upload page with the best UI possible:

A demo app

After some painful hours of debugging (in more detail later) and unsuccessful exploitation, I decided to implement a custom upload logic to have more freedom. Additionally, I assumed that directory traversal cannot occur in the Struts framework itself, so it must be in the business code developed, so I created a demo app with a very basic file upload logic.

Find the repo with the code here.

Consider this ActionSupport class handling file uploads:

// Upload.java 

// imports - <REDACTED>

public class Upload extends ActionSupport {
private File upload;
private String uploadFileName;
private String uploadContentType;

// Custom upload logic
public String execute() throws Exception {
if (uploadFileName != null) {
try {
// Specify the directory where files will be uploaded
String uploadDirectory = System.getProperty("user.dir") + "/uploads/";

// Create the destination file
File destFile = new File(uploadDirectory, uploadFileName);

// Copy the uploaded file to the destination
FileUtils.copyFile(upload, destFile);

// Add message to reflect the exact upload path on the frontend
addActionMessage("File uploaded successfully to " + destFile.getAbsolutePath());

return SUCCESS;
} catch (Exception e) {
addActionError(e.getMessage());
e.printStackTrace();
return ERROR;
}
} else {
return INPUT;
}
}

// Getters and setters - <REDACTED>

}

The struts.xml contains the following related parts:

--

--

vsociety

vsociety is a community centered around vulnerability research