Pergaminho — Writeup completo
webXXEMédio
Sobre o desafio
Plataforma: Infinity CTF 2026 (Harpia Security + SENAC)
Categoria: Web · Dificuldade: Medium · Pontos: 308
Vulnerabilidades: XXE (XML External Entity) — leitura arbitrária de arquivo
Flag: flag{infinity_ctf_2026_pergaminho_77e9f0fa1b}
Este writeup parte do zero: se você nunca ouviu falar de XXE, a ideia é que ao final você entenda por que um formato de arquivo tão comum quanto XML pode virar uma porta pra ler arquivos do servidor — e por que a técnica clássica de bypass às vezes esbarra num obstáculo bem mais chato do que a própria vulnerabilidade.
1. Contexto
“Pergaminho” era um app de importação de contratos: você mandava um XML descrevendo um contrato (remetente, destinatário, valor, observações) e o servidor processava esse documento. Formulários assim — que aceitam XML direto do usuário — são um dos alvos clássicos de uma classe de vulnerabilidade chamada XXE (XML External Entity).
1.1. O que é XML e por que ele tem “entidades”
XML é um formato de texto estruturado em tags, parecido com HTML:
<contrato>
<remetente>Empresa A</remetente>
<valor>1000</valor>
</contrato>
O padrão XML permite declarar um cabeçalho especial chamado DOCTYPE, que pode definir entidades — atalhos que o parser substitui pelo valor real antes de processar o documento. É parecido com um “find and replace” automático. A sintaxe:
<!DOCTYPE contrato [
<!ENTITY nome "Empresa A">
]>
<contrato>
<remetente>&nome;</remetente>
</contrato>
Aqui &nome; vira Empresa A na hora de processar. Até aqui, inofensivo — é só uma forma de reutilizar texto dentro do próprio documento.
1.2. Onde mora o perigo: entidades externas
O problema é que o padrão XML também permite que uma entidade aponte pra um recurso externo, incluindo um arquivo no disco do servidor:
<!DOCTYPE contrato [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<contrato>
<observacoes>&xxe;</observacoes>
</contrato>
Se o parser XML do servidor não estiver configurado pra bloquear entidades externas (comportamento padrão em muitas bibliotecas, inclusive antigas), ele vai literalmente ler o arquivo do disco e colocar o conteúdo no lugar de &xxe; antes de processar o documento — e se a aplicação depois exibe ou ecoa esse campo de volta pra você, você acabou de ler um arquivo arbitrário do servidor só enviando um XML malicioso. Isso é XXE.
2. Reconhecimento
O endpoint de importação era POST /importar, recebendo o XML como um campo de formulário chamado xml (não como corpo bruto da requisição — detalhe que importa: mandar o XML como application/xml puro no corpo não funcionava, tinha que ir dentro de um campo de form multipart/form-data ou x-www-form-urlencoded).
Testando a técnica básica de XXE com um arquivo clássico de prova de conceito:
<!DOCTYPE contrato [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<contrato>
<observacoes>&xxe;</observacoes>
</contrato>
O servidor devolveu o conteúdo de /etc/passwd na resposta. XXE confirmado. /etc/hostname e /etc/os-release também leram limpo — a vulnerabilidade funcionava de verdade, não era um falso positivo.
3. O primeiro beco sem saída: arquivos com conteúdo XML inválido
Com leitura arbitrária confirmada, o próximo passo óbvio era ler o código-fonte da aplicação (/app/app.py) pra entender exatamente o que estava rodando e onde a flag poderia estar.
Só que essa tentativa deu erro: “XML inválido”.
O arquivo lido vira conteúdo XML — e precisa ser XML válido
Isso é um detalhe sutil que confunde muita gente começando com XXE: a entidade externa funciona (o arquivo é lido de verdade), mas o conteúdo lido entra no lugar de &xxe; como se fosse texto dentro de um elemento XML. Se esse conteúdo tiver um caractere < ou & cru — coisa comum em código-fonte (if a < b, comparações, strings com &) — o documento XML inteiro vira malformado depois da substituição, e o parser rejeita tudo antes mesmo de processar. A leitura “funcionou” no sentido técnico, mas você nunca vê o resultado.
Isso explicava por que /app/app.py dava erro mesmo com a técnica correta: o código Python quase certamente tinha um < ou & em algum lugar (comparações, imports, o que for).
A tentativa seguinte foi uma técnica clássica de bypass pra esse exato problema: usar uma entidade de parâmetro (%) combinada com CDATA e exfiltração out-of-band (OOB) via uma URI data: — a ideia geral é empacotar o conteúdo problemático dentro de um bloco CDATA (que o parser XML trata como texto literal, sem interpretar </&) antes de reinjetar no documento. Só que essa técnica não funcionou — nem para /app/app.py, nem para um arquivo seguro de controle como /etc/hostname (que deu “inválido” mesmo sendo um arquivo totalmente limpo, sem </&). Isso indicava que o parser (provavelmente expat via xml.sax, com external_pes desligado) só resolvia entidades gerais externas, não entidades de parâmetro — e sem um servidor próprio pra hospedar um DTD (DTD = Document Type Definition, um documento que pode declarar entidades “por fora”, hospedado em outro lugar), não havia como fazer exfiltração out-of-band (enviar o conteúdo lido embutido numa requisição pra um servidor que você controla, em vez de recebê-lo direto na resposta).
Outra tentativa: /proc/self/environ (variáveis de ambiente do processo, um alvo clássico quando se procura uma flag guardada como variável de ambiente). Essa também falhou sempre — arquivos em /proc costumam ter bytes NUL no conteúdo, que também quebram um documento XML, pelo mesmo motivo: conteúdo que não é válido dentro de um elemento XML.
Ou seja: duas pistas óbvias (código-fonte e env vars) esbarraram no mesmo tipo de obstáculo — não porque a vulnerabilidade não funcionasse, mas porque o conteúdo desses arquivos não é compatível com o formato que está transportando o resultado de volta.
4. O segundo obstáculo: o template só aceita campos reconhecidos
Enquanto isso, havia um segundo problema, independente do primeiro: colocar a entidade em qualquer lugar do XML não bastava. Se o documento não tivesse os campos que o template de contrato esperava (remetente, destinatário, valor), a aplicação respondia “Nenhum campo reconhecido” — mesmo com um XML tecnicamente válido e a entidade corretamente processada. Isso parecia, à primeira vista, uma falha na técnica, mas na verdade era só a aplicação recusando processar um documento fora do formato esperado.
A solução: colocar a entidade dentro de um campo que o template de fato reconhece (<observacoes>), junto dos outros campos obrigatórios:
<!DOCTYPE contrato [
<!ENTITY xxe SYSTEM "file:///caminho/do/arquivo">
]>
<contrato>
<remetente>Empresa A</remetente>
<destinatario>Empresa B</destinatario>
<valor>1000</valor>
<observacoes>&xxe;</observacoes>
</contrato>
Com essa estrutura completa, o app processava o documento normalmente e devolvia o conteúdo lido dentro do campo observacoes da resposta.
5. Achando o arquivo certo
Com os dois obstáculos entendidos — (1) o conteúdo do arquivo precisa ser compatível com XML, e (2) a entidade precisa estar num campo reconhecido pelo template — faltava só achar qual arquivo guardava a flag. /app/app.py e /proc/self/environ estavam descartados pelo motivo (1).
A flag estava, de forma direta, em /tmp/flag.txt — um caminho que ainda não tinha sido testado nas tentativas anteriores (o foco tinha ido primeiro para o código-fonte e variáveis de ambiente, os alvos “óbvios”).
<!DOCTYPE contrato [
<!ENTITY xxe SYSTEM "file:///tmp/flag.txt">
]>
<contrato>
<remetente>Empresa A</remetente>
<destinatario>Empresa B</destinatario>
<valor>1000</valor>
<observacoes>&xxe;</observacoes>
</contrato>
A resposta trouxe a flag dentro do campo observacoes.
🚩 Flag
flag{infinity_ctf_2026_pergaminho_77e9f0fa1b}
6. Recapitulando a cadeia
1. Recon
└─ POST /importar (campo de form "xml", nao body raw) processa XML enviado pelo usuario
2. XXE confirmado
└─ <!ENTITY xxe SYSTEM "file:///etc/passwd"> + &xxe; dentro de <observacoes>
→ servidor le o arquivo e devolve o conteudo
3. Beco sem saida #1: conteudo vira XML invalido
└─ /app/app.py e /proc/self/environ falham ("XML invalido")
→ codigo-fonte tem "<"/"&" cru; /proc tem bytes NUL
→ tecnica de bypass (parameter entity + OOB via data:) tambem falha:
parser so resolve entidades GERAIS externas, nao PARAMETRO
4. Beco sem saida #2: template rejeita estrutura desconhecida
└─ entidade fora dos campos esperados (remetente/destinatario/valor) -> "Nenhum campo reconhecido"
→ resolvido colocando &xxe; dentro de <observacoes>, junto dos campos exigidos
5. Achando o arquivo certo
└─ /tmp/flag.txt (nao testado antes, fora do foco inicial em codigo-fonte/env vars)
→ flag devolvida dentro do campo observacoes da resposta
7. Por que a aplicação era vulnerável (e como corrigir)
- A causa raiz não é “esquecer de validar o XML” — é o parser aceitar, por padrão, resolver
entidades externas. A correção certa é desabilitar isso na configuração do parser (em Python,
por exemplo, usar a biblioteca
defusedxmlno lugar doxml.sax/xml.etreepadrão, ou configurar explicitamenteresolve_entities=Falsee desabilitarDOCTYPEpor completo) — não existe forma segura de “escapar melhor” o XML de entrada, o processamento de entidade externa precisa estar desligado. - Nunca aceitar
DOCTYPE/DTD em XML vindo de um usuário não confiável. A imensa maioria das aplicações não tem nenhum uso legítimo para entidades customizadas em XML enviado por formulário — rejeitar qualquer documento que contenha<!DOCTYPEjá elimina a classe inteira de ataque, sem nem precisar mexer na lógica de resolução de entidades. - Princípio do menor privilégio no processo que roda o parser. Mesmo com o parser corrigido,
vale rodar o processo de importação com o mínimo de acesso a arquivo possível — se ele não
precisa ler
/tmp,/procou o próprio código-fonte da aplicação, um sandbox/contêiner separado reduz o estrago de qualquer XXE que passe despercebido no futuro. - Nunca ecoar de volta o conteúdo bruto de um campo processado a partir de entrada do usuário, mesmo que pareça inofensivo (como um campo de “observações”). Foi justamente esse eco que permitiu a exfiltração in-band (sem precisar de servidor próprio pra OOB) — sem ele, o ataque ainda existiria, mas seria bem mais difícil de confirmar e explorar.
8. Lições
- XXE não é só “conseguir ler o arquivo” — é conseguir ler o arquivo e o resultado sobreviver como XML válido e a aplicação aceitar o documento no formato que ela espera. As três condições são independentes, e cada uma pode te fazer achar que a técnica “não funcionou” quando na verdade só falta ajustar uma delas.
- Conteúdo com
<,&cru ou bytes NUL quebra a substituição da entidade. Isso descarta alvos “óbvios” como código-fonte cheio de comparações/imports, ou arquivos virtuais de/proc— não porque a leitura falhe, mas porque o resultado não é um XML válido depois de colado no lugar da entidade. - Quando os alvos óbvios falharem, teste um arquivo de controle simples primeiro (tipo
/etc/hostname) pra confirmar se o problema é a técnica ou é especificamente o conteúdo daquele arquivo. - Formulários com template esperam campos específicos. Um XML tecnicamente correto ainda pode ser rejeitado se não tiver a “forma” que a aplicação espera — vale sempre colocar o payload dentro da estrutura normal de uso, não isolado.
- Sem servidor próprio pra hospedar um DTD externo, exfiltração fora-de-banda (OOB) com entidades de parâmetro não é garantida — depende de como o parser está configurado. Vale testar, mas não assumir que vai funcionar.
Writeup do desafio Pergaminho (Infinity CTF 2026 · Web · Medium).