Injeção em Templates Server-Side (SSTI): Quando os Templates do Django Viram Arma

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 A05:2025 — Injection | Tempo de leitura: ~16 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 }} com curl e 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 que mais provavelmente termina em execução de código: 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, num motor que avalia expressões, 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 (A05:2025) 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) se recusa a avaliar expressões Python — {{ 7*7 }} lança um TemplateSyntaxError em tempo de parse, não 49 —, mas não é um sandbox, e a documentação do Django diz que ela não é segura contra autores de templates não confiáveis. Passe entrada do usuário para Template(user_input) e o usuário vira um deles. 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. Uma melhoria óbvia seria 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. Foi isso que fez a classe parecer real, e não apenas acadêmica.

Este post cobre onde termina a segurança da DTL: uma string de template escrita pelo usuário, lendo tudo o que a view colocou no contexto.


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.

O que o motor consegue alcançar decide o que o atacante leva. O motor do próprio Django, a DTL (a Django Template Language), não avalia expressões Python: {{ 7*7 }} lança TemplateSyntaxError em tempo de parse em vez de imprimir 49, e nomes de atributo que começam com underscore são recusados. O que ela faz é resolver cada nome de um template contra o contexto que a view passou. Então, quando uma view compila entrada do usuário com Template(), o atacante não está executando código; está escrevendo um template que lê o que estiver nesse contexto.

A view de saudação do laboratório é o ataque inteiro. Ela recebe um parâmetro ?tpl=, compila-o com Template(tpl) e o renderiza com um contexto que guarda um nome de exibição e, ao lado dele, um segredo que a saudação nunca mostra. A primeira sonda de costume, {{7*7}} (a da PortSwigger, para encontrar injeção em templates), recebe um TemplateSyntaxError em vez de 49, o que diz que a entrada chegou a um parser da DTL em vez de voltar como texto. ?tpl={{ name }} renderiza world: o template foi avaliado. ?tpl={{ flag }} renderiza o segredo. Nenhum payload mais elaborado que um nome é necessário, e nada na requisição parece um ataque.

O mesmo erro vai mais longe em outros motores e em outros lugares. Num motor que avalia expressões — um backend Jinja2 deixado no Environment padrão em vez de um SandboxedEnvironment —, o template injetado é Python: a partir de qualquer string, ''.__class__.__mro__[1].__subclasses__() sobe até object e desce até subprocess.Popen, e o template executa comandos no servidor com a conta do processo web, o que é execução remota de código. Quando a saída renderizada nunca chega ao atacante — um template renderizado num e-mail ou numa tarefa em segundo plano —, a injeção é cega, e a execução é provada out-of-band, por uma requisição que o servidor faz a um host que o atacante controla, ou por um atraso. Uma blocklist de {{ ou __class__ não a impede: o filtro |attr e o {% print %} do Jinja2 alcançam os mesmos objetos sem nenhuma das duas strings. E o template nem precisa chegar na requisição: um corpo guardado no banco e compilado na hora da renderização — os e-mails de notificação editáveis da introdução, construídos do jeito ingênuo — é SSTI armazenado, uma escrita e uma execução a cada envio.


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. A exploração começou em menos de uma semana: a CISA respondeu a intrusões de prováveis atores APT a partir de 12 de abril, em 14 e 15 de abril a Morphisec a viu sendo usada para instalar a ferramenta Core Impact, com táticas que descreveu como comuns a grupos como o Rocket Kitten, ligado ao Irã, e em agosto a Fortinet a via entregando variantes do botnet Mirai, ransomware e criptomineradores. A pontuação CVSS de 9.8 reflete a superfície de ataque pré-autenticação: qualquer instalação afetada e sem correção com a interface web acessível podia ser explorada 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 Context: o dicionário da view mais o que os context processors acrescentam. Filtros e tags como {% if %} trazem sua própria lógica limitada, mas não há parser para expressões Python arbitrárias, não há acesso a built-ins Python, e não há caminho para o grafo de objetos além do que está no contexto.

As quatro consequências que decorrem dessa arquitetura:

  1. {{ 7*7 }} lança TemplateSyntaxError — 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.
  2. {{ user.__class__ }} lança TemplateSyntaxError — a DTL proíbe explicitamente acesso a atributos que começam com underscores ("Variables and attributes may not begin with underscores").
  3. {{ undefined_name }} renderiza como string vazia — um nome de variável sintaticamente válido que não está no contexto resolve para '' (o padrão string_if_invalid). Apenas nomes sintaticamente válidos e indefinidos produzem esse comportamento silencioso de string vazia.
  4. {{ request.META.HTTP_HOST }} renderiza como string vazia com um Context simples, como no lab. Renderize via RequestContext ou render(request, ...) com o context processor request habilitado, o que o startproject faz por padrão, e o request está lá sem que a view jamais o passe.

O {% load %}, mecanismo para adicionar funcionalidades personalizadas a templates DTL, só alcança bibliotecas registradas no motor: os pacotes templatetags/ dos apps instalados e as listadas em OPTIONS['libraries']. Quem escreve o template pode carregar qualquer uma delas ({% load i18n %} funciona a partir de uma string do usuário), mas não {% load subprocess %}: o mecanismo de carregamento resolve bibliotecas registradas, nunca módulos Python arbitrários.

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.


O Padrão Vulnerável e a Correção

A segurança da DTL presume que foi você quem escreveu o template; a documentação do Django diz com todas as letras que o sistema de templates não é seguro contra autores de templates não confiáveis. Passe uma string da entrada do usuário para Template() e essa premissa acaba: 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.

Template(user_input) em uma View

O erro mais comum de SSTI no Django é passar a entrada do usuário direto ao construtor Template(). A view de saudação do laboratório faz isso com um parâmetro ?tpl=, e a correção mantém a mesma entrada como dado:

     tpl = request.GET.get("tpl", "Hello!")
     flag = (Flag.objects.first() or Flag(value="")).value
-    rendered = Template(tpl).render(Context({"name": "world", "flag": flag}))
+    rendered = Template("{{ message }}").render(
+        Context({"message": tpl, "name": "world", "flag": flag})
+    )

Na view vulnerável, ?tpl={{ flag }} volta como o segredo: o usuário escreveu o template, e a DTL resolveu flag contra o contexto. Na view segura, o mesmo payload é o valor de message, substituído num template que o desenvolvedor escreveu e nunca mais interpretado, então volta o texto literal {{ flag }} — com flag ainda no contexto. Numa view real, o template fixo seria um arquivo, render(request, "greeting.html", {"message": tpl}); o laboratório o escreve inline para que as duas views difiram em uma instrução. As duas estão completas no laboratório: views_vulnerable.py e views_secure.py.

Um contexto real raramente tem um único segredo. Passe request.user e {{ user.password }} devolve o hash armazenado (pbkdf2_sha256$...), o que basta para um ataque de força bruta offline.

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. Outro risco, mais grave, é 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, depois de um {% load %} que o usuário também pode escrever — 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 isto 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 e ao que as tags e os filtros instalados fazem: divulgação de dados e invocação de tags neste lab, não um shell, embora uma tag personalizada que execute comandos mude isso.


A Visão do Analista

Para um analista, um achado Template(user_input) numa view DTL é um achado de divulgação cuja gravidade é dada pelo contexto, não pelo motor. Faça a triagem listando o que a view e os context processors colocam ali: request.user traz o hash da senha e toda relação que o ORM alcança a partir do usuário, o context processor request traz a própria requisição, e toda template tag registrada é invocável a partir do template do atacante. Um contexto com só um nome de exibição é um achado baixo; a mesma linha renderizada via RequestContext num projeto com uma tag personalizada que toca o sistema de arquivos não é. E o teto muda com o motor: a CVE da VMware acima, o mesmo erro num motor que avalia expressões, pontua 9.8. Um projeto com backend Jinja2 precisa da mesma revisão em toda chamada que compila uma string pelo from_string() desse motor, onde o achado é execução de código, não divulgação.

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 nível mais forte que existe. Um contexto mínimo é a camada atrás dele: tudo o que um template consegue nomear, um template injetado consegue ler, então uma view que passa uma string em vez de request.user limita o que o próximo erro expõe. A conclusão para a gestão de vulnerabilidades é que uma chamada Template( com um argumento não literal é um ponto de revisão de alto valor: cada ocorrência exige que alguém confirme quem controla a origem (os falsos positivos conhecidos da regra estão listados abaixo), e ela é barata de encontrar com grep e cara de ignorar, que é o perfil que pertence a um gate de CI, não a uma revisão trimestral.


Detectando Automaticamente

Testando Sua Defesa

Um único teste basta para provar a correção: enviar o payload à view segura e conferir que ele volta como texto, e não como o segredo. Este é o que o laboratório usa:

def test_exploit_is_blocked_on_secure_view(self):
    html = self.client.get("/ssti/secure/", {"tpl": "{{ flag }}"}).content.decode()
    self.assertIn("{{ flag }}", html)  # o payload voltou como dado...
    self.assertNotIn(FLAG, html)       # ...e o segredo nunca foi resolvido

O setUp grava o segredo, FLAG, que as duas views mantêm no contexto. Aponte o teste para /ssti/vulnerable/ e ele falha. No seu projeto, envie um payload que nomeie algo que você sabe estar no contexto — {{ user.email }}, por exemplo — por todo campo que possa acabar num template, e verifique que ele volta literalmente. {{7*7}} é o payload errado para isso: a DTL nunca devolve 49, então uma verificação contra 49 só pega o Jinja2; contra a DTL, o bug aparece como um TemplateSyntaxError, e não como um valor que se possa verificar. O tests.py do laboratório cobre as outras duas metades: o exploit funciona na view vulnerável, e a view segura continua renderizando uma saudação comum.

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 rodam (151 Python, 5 multilinguagem), 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.)

Diferente do Post 2, as ferramentas erram de vez, e essa é a outra metade do gatilho para uma regra própria. 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 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

Por ser sintática, ela nunca rastreia de onde veio um argumento não literal. O Semgrep propaga constantes, então um literal movido uma linha acima — t = "{{ v }}"; Template(t) — continua sendo reconhecido e fica em silêncio, um caso ok na fixture semgrep --test da regra. O que ela não consegue liberar é uma origem segura por um motivo de fora do código: um arquivo de template que o seu próprio repositório inclui, lido com Path(...).read_text(), é um falso positivo, documentado na fixture como todook. No lab isso nunca acontece: ambas as views compilam o template inline, onde a regra é exata. Ela também só casa com as formas de chamada que nomeia: Template(t, engine=e), Template(template_string=t) e template.Template(t) passam pelo Template($X). E, num backend Jinja2, ela sinaliza uma chamada num SandboxedEnvironment, como sandbox.from_string(body), tão alto quanto o bug — não enxerga qual ambiente a variável guarda —, além de qualquer método .from_string() sem relação chamado sobre uma variável, então cada chamada com sandbox precisa de um # nosemgrep revisado. 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 eles confirmam um acerto por plugins específicos de cada motor, que fazem o motor avaliar uma sonda como {{7*7}} ou lançar os próprios erros, e a lista de motores do SSTImap, por exemplo, não tem Django. Não os rodei contra este lab; os plugins de SSTI deles não foram feitos para perceber {{ flag }} levando o segredo pela porta da frente. Uma verificação sob medida perceberia, como um template do Nuclei que envia ?tpl={{ flag }} e procura o segredo conhecido, mas isso é o tests.py via HTTP. DAST por sonda de expressão só vira uma captura que vale a pena 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 o que cada uma reportou; o tests.py continua sendo a prova executável do vazamento em si.


O que levo deste post: um template é um arquivo que você controla, não uma string que um usuário te envia — então trate qualquer chamada a Template() ou from_string() com argumento não literal como trataria um cursor.execute(raw_sql): um achado a justificar ou corrigir 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

Próximo na série → Post 4: Injeção de Comandos OS: subprocess, os.system e os Perigos de shell=True

← Voltar para a série