Bug bounty e vulnerabilità "non risolvibili" nel 2026: come triagiare DoS, brute force e rate limit con un threat model serio
Tre anni fa ho passato due settimane a inseguire un ticket di sicurezza per un cliente del settore e-commerce. Un bug hunter aveva segnalato che il loro endpoint /password/reset permetteva di mandare 200 email di reset password al minuto verso lo stesso indirizzo, e chiedeva una bounty di 1500 euro. Il team di sviluppo aveva implementato un rate limit per IP, il bug hunter aveva girato Tor, gli avevano aggiunto un captcha, lui aveva linkato 2captcha.com con un costo a richiesta di 0.0008 dollari, e a quel punto la conversazione era diventata circolare. Quando sono entrato io, la prima cosa che ho fatto è stata leggere il report originale di Sjoerd Langkemper sulla classe "long password DoS" del 2021, poi ho aperto la mia copia delle release notes Django del 15 settembre 2013 per CVE-2013-1443. E ho capito che il cliente non aveva un problema di rate limit. Aveva un problema di threat model. Non sapeva chi voleva fermare, come, e a che costo. Stava giocando a un videogioco senza condizione di vittoria.
Esistono classi di vulnerabilità che non si "risolvono" mai con un fix puntuale. Ogni mitigazione tecnica genera un nuovo edge case, e ogni edge case un nuovo report di bug bounty. Questa non è incompetenza di chi ha scritto il codice - è la natura intrinseca del problema. Capirla è la differenza fra una pipeline di bug bounty matura e una palude di ticket aperti da anni.
Anatomia di un caso ricorrente: dal CVE Django 2013 al DoS Nextcloud 2022
Il 15 settembre 2013 il team Django pubblica CVE-2013-1443: l'authentication framework di Django (django.contrib.auth) accetta password di lunghezza arbitraria e le passa al password hasher PBKDF2. Una password da 1 MB richiede circa un minuto di CPU per essere hashata. Un attaccante che invia 100 richieste di login parallele con password da 1 MB può saturare i worker dell'application server in pochi secondi. La fix è "imporre un limite di 4096 byte sulla lunghezza della password e fallire l'autenticazione prima di entrare nel hasher". Lezione imparata, problema risolto. Sembra.
Nove anni dopo, gennaio 2022, HackerOne report #840598: un ricercatore segnala a Nextcloud che inviando una password da 1.000.000 di caratteri al modulo di creazione utente, il backend Nextcloud (che usa Argon2id come hasher) consuma CPU e RAM in modo proporzionale alla lunghezza dell'input. Argon2 è memory-hard per design - è esattamente quello che lo rende un buon password hasher contro attacchi offline - ma quando l'input cresce, cresce anche il working set. La fix di Nextcloud è imporre un limite di lunghezza sulla password lato server. Identica struttura della fix Django del 2013. Stessa classe di vulnerabilità. Stesso tipo di codice.
E questo è il punto: la classe non sparisce, ricompare ovunque c'è un endpoint che hasha input controllato dall'utente. Nel 2025 ho visto la stessa identica vulnerabilità su tre codebase diverse di clienti: una era un'app Laravel con bcrypt sul /login, una era un microservizio Symfony con Argon2id sul reset password, una era un'app legacy CodeIgniter con SHA-256 + salt iterato 50.000 volte (che è essenzialmente un PBKDF2 fatto male). In tutti e tre i casi, il bug bounty hunter aveva trovato un endpoint dove il limite di lunghezza era applicato lato client (HTML maxlength="64") e non lato server. La validazione client-side è una decorazione, non una difesa.
Il punto operativo per Laravel/Symfony/PHP è semplice: bcrypt tronca silenziosamente le password oltre i 72 byte (è un limite del Blowfish sottostante), quindi il problema CPU non esiste, ma esiste un problema diverso di sicurezza (utenti pensano di avere password lunghe, in realtà ne hanno una di 72 byte). Argon2id e PBKDF2 invece processano l'input completo, e devono essere protetti con una validazione di lunghezza esplicita, idealmente nel form request:
// app/Http/Requests/RegisterRequest.php
public function rules(): array
{
return [
'email' => ['required', 'email:rfc,dns', 'max:254'],
'password' => [
'required',
'string',
'min:12',
'max:128', // hard cap PRIMA del passaggio al hasher
Password::min(12)->letters()->mixedCase()->numbers()->symbols(),
],
];
}Una variante alternativa, che alcune codebase moderne adottano, è il pre-hash SHA-256 prima di passare la password al password hasher: l'output è sempre 32 byte indipendentemente dalla lunghezza dell'input, neutralizzando il vettore DoS senza imporre un limite "duro" alla password che l'utente vede. Va combinato con il salt corretto per evitare di rendere bcrypt vulnerabile a "password shucking", e va documentato perché chiunque legga il codice in futuro deve capire perché c'è un hash('sha256', $password, true) nel mezzo.
Le tre classi di mitigazioni che restano "non risolvibili"
Il caso del DoS da password lungo è il più chirurgico, ma c'è una famiglia di problemi più ampia che condivide la stessa proprietà: ogni difesa apre un nuovo vettore. Tre esempi che incontro su quasi ogni audit.
Anti-brute force basato su sleep server-side. Il pattern classico: l'utente sbaglia password, il backend dorme 500 ms prima di rispondere. Sembra ragionevole. Su 10 richieste parallele, il pool di worker PHP-FPM passa 5 secondi in sleep cumulativo, e a 100 richieste parallele il pool è completamente bloccato. Hai costruito un selfDoS gentile a chi sa premere F5 con un proxy. L'alternativa "corretta" - introdurre un costo computazionale uniforme sulla risposta - porta a una superficie di rate-limiting più ampia, che è esattamente la prossima classe di problemi.
Rate limiting basato su IP sorgente. Il pattern: dopo N richieste da uno stesso IP entro T secondi, blocco. Funziona contro lo script kiddie con uno script Python single-threaded. Non funziona contro chi ha 50 nodi Tor, una botnet IoT, o un servizio di residential proxies che vende 100.000 IP residenziali a 0.50 dollari al GB di traffico. E quando un bug hunter dimostra che basta cambiare lo header X-Forwarded-For per bypassare il rate limit (succede regolarmente: vedi HackerOne report #723974 su Moneybird), la fix è "leggere l'IP dal RemoteAddr invece che dall'header" - ma questo rompe il deployment dietro un Cloudflare/CDN dove l'IP del client è proprio nell'header. Ogni mossa apre una contro-mossa.
Account lockout dopo N tentativi falliti. Storicamente la difesa "ovvia" contro il brute force di password. In pratica: trasforma istantaneamente un attacco di brute force in un attacco di account lockout-as-denial-of-service. Un attaccante che conosce 10.000 username dei tuoi clienti può bloccare 10.000 account in 30 secondi, riducendo a zero il valore del tuo prodotto per quei clienti finché non chiamano il supporto. È esattamente perché le linee guida NIST SP 800-63B raccomandano di evitare il lockout aggressivo e preferire altri pattern (CAPTCHA dopo soglia, MFA obbligatoria, monitoraggio anomalie).
In tutti e tre i casi non esiste "il fix": esiste la scelta del compromesso meno dannoso per il tuo specifico modello di minaccia. E quel compromesso è una decisione di prodotto, non una decisione di sicurezza pura. I principi di architettura cybersecurity per PMI che ho documentato altrove partono proprio da questa premessa: la difesa in profondità non è uno slogan, è il riconoscimento che ogni livello da solo è bypassabile.
Bug bounty: perché certe segnalazioni vengono chiuse "Informative"
Se hai un programma di bug bounty attivo (anche solo una pagina /security con un'email pubblica), prima o poi riceverai segnalazioni di queste classi. La domanda non è "se", è "quando" e "come triagi". I programmi maturi su HackerOne e Bugcrowd hanno tutti una sezione "Out of Scope" che lista esplicitamente le classi che NON pagano bounty, e quasi sempre include:
- Rate limiting bypass su endpoint pubblici di registrazione, reset password, contatto.
- DoS che richiedono "high request rate" (centinaia di richieste al secondo).
- Email bombing/spam tramite endpoint pubblici di notifica.
- Self-XSS, account enumeration via timing differences <100 ms, missing HTTP security headers su endpoint statici.
Questa lista non è pigrizia organizzativa: è una decisione tecnica che riconosce che la classe è intrinsecamente non risolvibile con un fix puntuale. Pagare bounty per queste segnalazioni significa pagare per ricevere un report che documenta ciò che il programma sa già, senza un'azione correttiva proporzionata. La policy corretta è chiudere come "Informative" (o "Won't Fix" se la piattaforma ha quello stato) e - questo è il punto critico - scrivere una motivazione tecnica nella chiusura, non un'email generica. Il bug hunter che riceve "abbiamo deciso di non considerarlo" è frustrato; quello che riceve "questa classe è intenzionalmente accettata perché [ragionamento tecnico] e mitigata da [contromisure compensative]" capisce e in genere passa al prossimo target.
Per un cliente che non ha mai gestito un programma di bug bounty, il primo passo che faccio è scrivere una scope policy esplicita che definisce in-scope, out-of-scope, severity matrix e processo di triage. La differenza fra avere e non avere quel documento è la differenza fra un canale di sicurezza utile e un buco nero di ticket aperti. La policy deve allinearsi con il piano di hardening Laravel/Symfony NIS2 in 14 giorni, perché molte delle decisioni di scope sono in realtà decisioni di compliance (NIS2 art. 21 sulla gestione delle vulnerabilità).
Il pattern di throttling che funziona davvero in Laravel
Quando applico una mitigazione concreta su un'app Laravel cliente, il pattern che porto in produzione non è il rate limit "una key, una soglia". È un throttling multi-chiave, dove la stessa richiesta consuma slot su due dimensioni diverse: identità (email/username) e provenienza (IP normalizzato). L'idea è banale ma sorprendentemente rara: un attaccante può ruotare gli IP, ma se sta cercando di brute-forzare un account specifico finisce comunque a martellare lo stesso email, e se sta facendo password spraying su tanti account finisce comunque a mostrare la firma "molti tentativi dallo stesso IP". Bloccare entrambe le dimensioni rende il bypass significativamente più costoso.
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('login', function (Request $request) {
$email = (string) $request->input('email');
return [
// Per identità: 5 tentativi al minuto su quella email
Limit::perMinute(5)
->by('login:email:' . sha1(strtolower($email)))
->response(fn () => response()->json(['message' => 'Too many attempts on this account.'], 429)),
// Per provenienza: 30 tentativi al minuto da quell'IP
Limit::perMinute(30)
->by('login:ip:' . $request->ip())
->response(fn () => response()->json(['message' => 'Too many attempts from this network.'], 429)),
];
});
}Tre dettagli operativi che salvano in produzione. Primo, la chiave per email è hashata con sha1(strtolower(...)) - questo evita di mettere indirizzi email in chiaro nei driver Redis/Memcached e normalizza il case (gli attacchi tipici provano sia [email protected] sia [email protected]). Secondo, la risposta 429 è esplicita e diversa per le due dimensioni: questo aiuta in fase di forensics quando devi capire se l'incidente è "un account attaccato da molti" o "un'IP che attacca molti account". Terzo, queste soglie vanno tarate sul tuo traffico reale, non copiate dall'esempio: 5/min per email è ragionevole per un'app SaaS B2B con utenti che usano password manager, ma diventa fastidioso per un'app consumer dove l'utente prova tre password "vere" prima di ricordarsi quella giusta. Misura prima di scegliere.
Nessuna di queste mitigazioni è "il fix" del problema. Sono compensating control che alzano il costo dell'attacco a un livello che, combinato con MFA obbligatoria sui flussi sensibili, rende l'aggressione economicamente non sensata per la maggior parte degli attori. È esattamente questo il framework che applico quando un cliente mi chiama dopo un incident: leggi il piano operativo di incident response Laravel/Symfony in 72 ore per NIS2, perché senza un canale di triage strutturato anche le mitigazioni migliori finiscono in un cassetto.
Come un threat model vero cambia la conversazione
Tornando al cliente e-commerce dell'aneddoto iniziale: dopo due settimane di ticket circolari, ho proposto di fare un threat model strutturato in mezza giornata. Non STRIDE accademico, una versione operativa: chi sono gli attori che potrebbero attaccare l'endpoint /password/reset, cosa cercano di ottenere, quanto vale per loro, e cosa costa a noi non fermarli. Tre attori:
- Bot scanner generici (95% del traffico anomalo). Cercano credenziali deboli su qualsiasi endpoint pubblico. Costo per noi se passano: zero (ogni utente ha password con politica di complessità minima). Mitigazione: nessuna oltre il rate limit blando esistente.
- Concorrenti che vogliono fare scraping del catalogo prodotti. Costo per noi se passano: traffico bandwidth. Mitigazione: rate limit al livello applicativo sui soli endpoint catalogo, non su
/password/reset. - Attaccante mirato che vuole prendere account specifici. Costo per noi se passa: incident di sicurezza, possibile data breach, possibile sanzione GDPR/NIS2. Mitigazione: MFA obbligatoria sopra una certa soglia di valore carrello, monitoring di pattern di login da IP geograficamente improbabili, alert via PagerDuty al security team se vediamo la firma.
A quel punto la conversazione sul rate limit di /password/reset ha smesso di essere una discussione di sicurezza ed è diventata una discussione di costi: "quanto ci costa che un bot mandi 1000 email di reset password al nostro stesso utente?". Risposta: zero, perché Mailgun ci fattura su volume mensile e abbiamo headroom, e l'utente che riceve 1000 email capisce che è uno spam e non clicca. Il bug hunter aveva ragione tecnicamente - il rate limit era bypassabile - ma l'impatto reale era zero. Abbiamo chiuso il report come "Informative", pagato 50 euro di goodwill al ricercatore, scritto la motivazione tecnica nel reply e siamo passati ad altro.
Questa è la differenza fra fare sicurezza guidata dai report e fare sicurezza guidata dal rischio. La prima ti porta a inseguire ogni segnalazione fino a sfiancarti; la seconda ti porta a investire dove c'è impatto reale. Entrambe richiedono un'organizzazione di DevSecOps integrato nel ciclo di sviluppo, perché senza un canale strutturato fra security e prodotto la decisione "accettiamo questo rischio" diventa solo una mail dimenticata. Il framework che applico è lo stesso che ho descritto nel pezzo sulla crisi del sistema CVE e strategie di difesa per le PMI: triage strutturato, motivazione scritta, decisione tracciata, periodo di review.
La sicurezza informatica non è un gioco con uno stato finale "vinto". È una serie di decisioni di rischio che vanno prese, documentate e riviste periodicamente. Le vulnerabilità "non risolvibili" non sono fallimenti tecnici: sono punti dove la natura del problema impone di scegliere quale compromesso accettare. Il valore aggiunto di un consulente di sicurezza serio non è trovare ogni bug - è aiutarti a decidere quali bug meritano un fix, quali una compensating control, e quali una motivazione di chiusura ben scritta. Se la tua azienda sta inseguendo report di bug bounty senza un threat model esplicito, o se vuoi passare da una postura reattiva a una postura strategica sulla sicurezza, scopri il mio approccio professionale alla cybersecurity per PMI - dieci anni di esperienza in offensive security e red teaming mi hanno insegnato a riconoscere quando un fix è davvero un fix e quando è teatro. Se vuoi una valutazione concreta del tuo backlog di security report o del tuo programma di bug bounty, contattami per una consulenza: in due settimane di lavoro ti consegno una scope policy, una severity matrix calibrata sul tuo prodotto, e un piano di triage che il tuo team può applicare senza inseguire fantasmi.