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 A02:2025 — Security Misconfiguration (CWE-611 / CWE-776) | Tempo de leitura: ~15 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 declaração de entidade 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 tem uma verificação PDF/A que lê o bloco de metadados XMP de um certificado — que é XML — através do pikepdf, e o pikepdf o analisa 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 de um PDF a um parser XML. O pikepdf, no fim, blinda esse parser por conta própria (resolve_entities=False e no_network=True, duas das configurações da correção do Padrão 2 abaixo), mas essa foi uma decisão de outra pessoa, numa cadeia que não escrevi.
Este post cobre como uma entidade XML transforma um parser num leitor de arquivos, quais parsers do Python ainda permitem isso e como fechar a porta com o defusedxml ou com um parser lxml blindado explicitamente.
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ó. - 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.
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. Num servidor Django, os arquivos que valem a leitura ficam ao lado do código: o settings.py, um .env, qualquer coisa que guarde a SECRET_KEY.
Numa aplicação Django, o ponto de entrada realista é qualquer endpoint ou tarefa que aceite XML controlado pelo atacante, e o XML nunca foi realmente embora: integrações SOAP com gateways de pagamento e parceiros, leitores de feeds RSS e Atom, webhooks de parceiros e os formatos que os usuários enviam todos os dias — um .docx, .xlsx ou .pptx é um arquivo ZIP cheio de XML, e um .svg é XML. Um importador de feed de parceiro que entrega request.FILES['feed'].read() ao etree.fromstring() é típico. Se essa linha lê /etc/passwd depende do lxml instalado: antes do lxml 5.0 (dezembro de 2023), o parser padrão resolvia entidades externas, e o iterparse() continuou fazendo isso até o lxml 6.1.0 (abril de 2026). Uma segurança que depende da versão instalada não é uma decisão que alguém tomou.
O que torna o XXE perigoso é 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.
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. O OWASP Top 10:2025, no entanto, classifica o XXE sob A02 Security Misconfiguration: a correção é uma decisão de configuração de parser, e não sanitização de entrada. Este post usa tanto CWE-611 (Improper Restriction of XML External Entity Reference) quanto CWE-776 (Improper Restriction of Recursive Entity References in DTDs) como seus identificadores canônicos de fraqueza.
As mesmas entidades causam outros estragos. O identificador SYSTEM também fala http://, então um parser que busca URLs vira um proxy para serviços internos e para um endpoint de metadados da nuvem, o que é server-side request forgery. Quando nada é ecoado de volta, entidades de parâmetro e uma DTD hospedada pelo atacante levam um arquivo para fora dentro de uma URL (XXE cego). E a bomba XML não precisa de nenhum acesso externo: no Billion Laughs, dez níveis de entidades, cada uma referenciando dez vezes a de baixo, expandem um documento de menos de um kilobyte em um bilhão de cópias de lol, cerca de 3 GB, até o worker esgotar a memória.
Incidentes Reais
Facebook XXE — Reginaldo Silva (2014)
Em novembro 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 fazerem requisições de saída de dentro da rede do Facebook. Ele havia encontrado a mesma falha de XXE em OpenID em 2012, e o Google lhe pagara US$ 500 por ela no App Engine e no Blogger; depois que o Facebook corrigiu a leitura de arquivos, ele mostrou à equipe de segurança como o bug escalava para execução remota de código, e a equipe confirmou. O Facebook lhe concedeu US$ 33.500 — o maior pagamento individual que seu programa de bug bounty havia feito até então, elevado quando o Facebook classificou o bug como execução remota de código — 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 ler os arquivos do próprio servidor através do parser, para T1005 (Data from Local System). Uma troca de uma linha para um parser que recusa DTDs e entidades externas teria fechado toda a cadeia na origem.
Fontes: Reginaldo Silva — Remote Code Execution via XXE in Facebook; Threatpost — Facebook Pays $33,500 Bounty for Major Code Execution Flaw
Proteções Padrão do Django
O título é 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. O Django padrão analisa corpos form-encoded e multipart em request.POST e deixa qualquer outro corpo como bytes brutos em request.body, e os parsers padrão do Django REST Framework acrescentam JSON — nenhum dos dois põe um 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 é 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 recusar DTDs, declarações de entidades e referências externas, 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 no libxml2, que a maioria dos projetos Django instala para trabalho real com XML. Desde o lxml 5.0 (dezembro de 2023), o parser padrão não resolve mais entidades externas (resolve_entities='internal'). Os padrões seguros demoraram a chegar a todos os pontos de entrada: oiterparse()e oETCompatXMLParserainda resolviam entidades externas até o lxml 6.1.0 (abril de 2026, CVE-2026-41066), e o'internal'ainda permitia entidades de parâmetro externas até o 6.1.3 (setembro de 2026). E um código que passaresolve_entities=True— a view vulnerável do laboratório — recupera a leitura de arquivo completa em qualquer versão. Blinde o parser explicitamente ou use odefusedxml, em vez de deixar a versão instalada decidir.xml.etree.ElementTree,xml.dom.minidom,xml.sax— a biblioteca padrão — se apoiam no Expat, que, nas palavras da documentação de segurança XML do Python, "does not access local files or create network connections" (não acessa arquivos locais nem cria conexões de rede) por padrão.
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.
O Padrão Vulnerável e a Correção
Padrão 1 — lxml com a Resolução de Entidades Ligada
O webhook do laboratório monta o parser com resolve_entities=True, o footgun explícito, e a correção passa o mesmo corpo pelo defusedxml.ElementTree (importado como defused_etree):
-_UNSAFE_PARSER = etree.XMLParser(resolve_entities=True)
-
def webhook(request):
- root = etree.fromstring(request.body, parser=_UNSAFE_PARSER)
+ try:
+ root = defused_etree.fromstring(request.body)
+ except (EntitiesForbidden, DTDForbidden, ExternalReferenceForbidden,
+ defused_etree.ParseError):
+ return HttpResponse("Rejected: not an acceptable XML document", status=400)
order_id = root.findtext(".//orderId") or ""
return JsonResponse({"received": order_id})
Na view vulnerável, um corpo que declara <!ENTITY xxe SYSTEM "file:///tmp/xxe/flag.txt"> com &xxe; dentro de <orderId> faz o lxml inserir a flag em order_id. O defusedxml espelha os módulos XML do Python um a um e recusa uma declaração perigosa assim que o parser a encontra: por padrão ele proíbe declarações de entidades e referências externas (forbid_entities=True, forbid_external=True) e permite um DOCTYPE simples que declara apenas estrutura de elementos, então o payload lança EntitiesForbidden antes que qualquer arquivo seja lido. A mesma recusa barra uma bomba XML, cujas entidades aninhadas também são declarações. forbid_dtd=True rejeita qualquer DOCTYPE, com DTDForbidden. As três exceções compartilham a classe base DefusedXmlException. Um corpo truncado ou malformado lança ParseError, que não é uma delas, então a correção também a captura. Use o defusedxml.ElementTree, e não o shim de lxml do pacote: importar defusedxml.lxml avisa que ele "is no longer supported and will be removed" (não é mais suportado e será removido). As duas views estão no laboratório: views_vulnerable.py e views_secure.py.
Padrão 2 — Uma Resposta SOAP Confiável por Causa de Quem a Enviou
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. Quando uma biblioteca precisa de uma árvore lxml real, monte o parser você mesmo:
+_SAFE_PARSER = etree.XMLParser(
+ resolve_entities=False, # keep &entity; references unexpanded
+ no_network=True, # no http(s) fetches for SYSTEM identifiers
+ load_dtd=False, # do not load an external DTD
+)
+
def fetch_partner_status(order_ref):
resp = requests.post("https://partner.example/soap", data=build_envelope(order_ref))
- doc = etree.fromstring(resp.content)
+ doc = etree.fromstring(resp.content, parser=_SAFE_PARSER)
+ if doc.getroottree().docinfo.doctype:
+ raise ValueError("DTD not permitted")
return doc.findtext('.//status')
No lxml 6.1.3 ou posterior, a linha antiga não lê arquivos. Do 5.0 ao 6.1.2, ela ainda carrega entidades de parâmetro externas, então um payload consegue ler um arquivo local e vazá-lo pelo log de erros do parser. Em qualquer caso, ela faz o que a versão instalada fizer. O parser explícito põe a decisão no seu código: resolve_entities=False deixa as referências de entidade sem expandir, no_network=True mantém o parser fora da rede, e rejeitar qualquer DOCTYPE recusa a DTD da qual um payload XXE depende. Confiança no remetente não é confiança nos bytes.
Um controle cobre os dois padrões: quando você controla as duas pontas do contrato, prefira JSON, que não tem entidades, nem DTD, nem recurso de referência externa a abusar. Reserve o XML para os casos que você não pode evitar — SOAP de entrada, feeds de parceiros em formato fixo, uploads de escritório e SVG — e passe cada um deles por uma das correções acima.
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–B319 do Bandit sinalizam xml.etree, sax, expatbuilder, minidom e pulldom 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 faz a leitura do arquivo no laboratório é o lxml com resolve_entities=True, e as verificações de lxml do Bandit (B320 para as chamadas, B410 para o import) foram removidas — então um codebase cuja superfície de XXE passa pelo lxml faz o scan 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 resposta. Um achado de XXE tem formato de resposta a incidente: assuma que a leitura teve sucesso e rotacione o que os arquivos alcançáveis expuseram, da SECRET_KEY às credenciais do banco e às chaves de API de um .env. Esse é um modelo de ameaças que um scanner não te entrega.
Detectando Automaticamente
Testando Sua Defesa
Um único teste basta para provar a correção: enviar o payload de leitura de arquivo à view segura e conferir que ele é recusado e que a flag nunca volta. Este é o que o laboratório usa:
XXE_FILE_READ = (
'<?xml version="1.0"?>\n'
f'<!DOCTYPE order [ <!ENTITY xxe SYSTEM "{FLAG_PATH.as_uri()}"> ]>\n'
"<order><orderId>&xxe;</orderId></order>"
).encode()
def test_secure_view_rejects_external_entity(self):
resp = self.client.post(
"/xxe/secure/", data=XXE_FILE_READ, content_type="application/xml"
)
self.assertEqual(resp.status_code, 400)
self.assertNotContains(resp, "FLAG{", status_code=400)
O setUp grava o arquivo da flag fora de alcance para o qual a entidade aponta. Aponte o teste para /xxe/vulnerable/ e ele falha: a view responde 200 com a flag em orderId. Um teste que só analisasse um documento benigno passaria nas duas views. O tests.py do laboratório acrescenta um pedido legítimo que ainda precisa ser analisado.
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 e B313/B314 em xml.etree, B315–B319 e B406–B409 em sax, expatbuilder, minidom e 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 as verificações de lxml do Bandit — B320 e B410 — foram removidas: peça a B410 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 executa o XXE sempre que resolve_entities=True está ligado — é 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 que depende dos 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 finding
semgrep scan --config rules/xxe.yaml labs/post_05_xxe/views_secure.py # 0 findings
Os limites dela são reais. Ela é baseada em nome: casa quatro chamadas num receptor etree, então não pega etree.iterparse(...) nem etree.ETCompatXMLParser(), os dois pontos de entrada que resolviam entidades externas por padrão até o lxml 6.1.0, nem lxml.etree.fromstring(...) escrito por extenso, nem um fromstring ou XMLParser importado direto de lxml.etree — os cinco ficam em silêncio no Semgrep 1.170.0. Ela confia que qualquer objeto parser= está blindado. E sinaliza um etree.fromstring(x) nu, que o lxml 6.1.3 e posteriores analisam sem carregar nenhuma entidade externa, como frágil, de propósito. Ela também é agnóstica à origem: não consegue provar que os bytes são não confiáveis, e não usa o modo de taint que o motor de código aberto do Semgrep oferece. As execuções capturadas e a reprodução estão no diretório scans/ do laboratório.
O XXE é uma classe de runtime, então prove a correção dinamicamente também: o passo a passo com curl do README do laboratório envia o mesmo payload de leitura de arquivo às duas views, e só a vulnerável devolve a flag.
O hábito que este post me deixou: tratar um etree.fromstring nu 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.
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ê é 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 security
- 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 A02:2025 — Security Misconfiguration (includes XXE)
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald, No Starch Press) — Capítulo 15: XML Attacks
Next in this series → Post 6: Broken Access Control e IDOR: Quando Estar Logado Não É o Mesmo que Ter Permissão