RCE via Server-Side Template Injection
In this write-up, we’ll see how I identified a remote code execution vulnerability and bypassed the Akamai WAF rule(s). While I was doing a security scan, I noticed an endpoint that incorporates user-controllable data into a string and reflects it back in the response. Noticing the reflection of the text, I tried some XSS payloads but was not able to execute JavaScript successfully as the response Content-Type was application/json. However, when entering a payload such as ${191*7} I was surprised to see that the arithmetic expression had been successfully evaluated within the response as
[SNIP]…getApprovalGroupByContext.contextType: 1337…[/SNIP]
Note: “RCE_” is not a part of the payload, it is only used to look up reflected text.
The requirement of the $ character in the syntax to successfully evaluate the expression usually indicate that some sort of template engine/server-side evaluation is involved when processing the expression.
Template engines are widely used by web applications to present dynamic data via web pages and emails. Unsafely embedding user input in templates enables Server-Side Template Injection.
In this case, the user controls the content of the context_type query parameter. After detecting template injection, the next step was to identify the template engine in use. This step is sometimes as trivial as submitting invalid syntax, as template engines may identify themselves in the resulting error messages. However, this technique fails when error messages are suppressed. After a bit more research on the Internet, I had a few guesses about what template engine this application might have been using.
To assess the impact of the vulnerability I did some more research to find a payload I could use to execute the commands remotely, and I found that the following payload could be used to execute the ls command onto the remote server:
${"".getClass().forName("java.lang.Runtime").getMethods()[6].invoke("".getClass().forName("java.lang.Runtime")).exec("ls")}
After the execution, I didn’t see any command results in the response because it was a blind injection but I did receive a Unix process reference (highlighted in the response body above). It confirmed that the command was executed successfully.
Now it became more interesting as I wanted to upload a reverse shell (a type of shell in which the target machine communicates back to the attacking machine). I executed two more commands to see if the python and wget modules are already installed in order to download and execute the reverse shell script. As a result of execution, I received a process reference indicating that the utilities are pre-installed. It wasn’t surprising as it’s very common to see such utilities bundled with the flavors of Unix.
Therefore, I started an HTTP server on port 80 using python and hosted a reverse shell script as shown below:
First I downloaded the reverse shell onto the vulnerable server (staging environment).
The Unix process reference in the response was enough to indicate that the reverse shell script got uploaded onto the remote machine. The next step was to start a Netcat listener on port 443 (to catch the shell) and execute the script. As you can see in the below screenshot, I was able to get a reverse shell of the staging server and execute commands.
The next task was to bypass Akamai’s WAF to perform remote code execution on production server.
I received the same response from the production server for basic math operations like multiplication and division, which confirmed that the bug exists on the production server too. However, the final payload failed to execute and resulted in a 403 error page displaying an Access Denied message.
I decided to break down the payload into multiple parts to check what’s safe and unsafe as per the WAF rules. This trial and error approach helped me to figure out the following two keywords as an unsafe string for WAF:
- “java.lang.Runtime”
- ().
The payload to be executed was:
${"".getClass().forName("java.lang.Runtime").getMethods()[6].invoke("".getClass().forName("java.lang.Runtime")).exec("wget")}
I started looking into the Java language documentation to see if there is an alternate way to return the “java.lang.Runtime” string. After spending some time I found out multiple ways to do it but only the concat method worked for this case.
“java.lang.Runtime” === “java.lang”.concat(“.Runtime”)
That is how I was able to bypass the first check. Payload so far:
${"".getClass().forName("java.lang".concat("Runtime")).getMethods()[6].invoke("".getClass().forName("java.lang".concat("Runtime"))).exec("wget")}
The next challenge was to bypass (). keyword check. After doing some analysis I noticed that if I put any character between () and . the firewall doesn’t block it but that’s going to break our payload as the method chaining would fail.
Is there a way we can do it without breaking the method of chaining? While I was recalling JavaScript basics, I could think of Self-Invoking Functions. I never programmed in Java so I was unsure if this would even work but I thought of giving it a try.
In the case of JavaScript console.log(“hello”), (console.log(“hello”)), and (console.log)(“hello”) mean the same.
I replaced getClass() with (getClass()) and as there was no check for the ()). keyword, I was able to bypass the second check too.
The final payload was:
${("".getClass()).forName("java.lang".concat("Runtime")).getMethods()[6].invoke(("".getClass()).forName("java.lang".concat("Runtime"))).exec("wget")}
I executed the payload and in the response, I received a process reference number similar to the one I got for the staging environment, it confirmed that I was able to execute commands remotely onto the production server.
For obvious reasons I did not attempt to upload any shells/malicious code onto the production server, however, I verified if it is possible for one to make an outbound request from the production server to Burp collaborator.
I fired the request to see if the production server would do a DNS lookup or not.
After a few seconds, I received a lookup request, revealing the actual IP address of the production (origin) server.
Mitigations:
The best way to prevent server-side template injection is to not allow any users to modify or submit new templates. However, this is sometimes unavoidable due to business requirements.
One of the simplest ways to avoid introducing server-side template injection vulnerabilities is to always use a “logic-less” template engine, such as Mustache, unless absolutely necessary. Separating the logic from the presentation as much as possible can greatly reduce your exposure to the most dangerous template-based attacks.
Another measure is to only execute the user's code in a sandboxed environment where potentially dangerous modules and functions have been removed altogether. Unfortunately, sandboxing untrusted code is inherently difficult and prone to bypasses.
Finally, another complementary approach is to accept that arbitrary code execution is all but inevitable and apply your own sandboxing by deploying your template environment in a locked-down Docker container, for example.
Other Suggestion(s):
- URL whitelisting should be in place to prevent the attackers from downloading malicious code or exfiltrating the data
Key Takeaways:
- No WAF is 100 percent accurate, and no WAF is foolproof
- If you have found a bug, try to fix it at the code level, and do not rely on the WAF completely
- Always try to escalate vulnerabilities to their maximum level of impact
- Knowing back-end and front-end dependencies, frameworks, etc in advance is a plus
References
- https://portswigger.net/web-security/server-side-template-injection
- https://portswigger.net/research/server-side-template-injection
- https://medium.com/server-side-template-injection/server-side-template-injection-faf88d0c7f34
- https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection#expression-language-el---code-execution
For any feedback or a CTF invitation, please reach out to me on Twitter.
Thank you for reading!