Blog ·
Una scansione che chiudeva il suo turno
Ogni scansione pianificata riuscita segnava il proprio turno come scaduto, per una parola presa dall’enumerazione sbagliata. Due difetti visti solo misurando.
Di Provibr
La nostra scansione di esposizione ha un pianificatore. Rivendica un turno, invia un comando di scansione all’agent che raggiunge la macchina e attende due cose: la misura stessa e la conclusione del comando che l’ha portata. Quando un turno finisce senza risposta viene segnato come scaduto, e la macchina compare come «nessuna misura recente».
Ogni scansione riuscita segnava il proprio turno come scaduto. Quattro su quattro, misurate contro un agent vero, mentre le quattro misure stesse venivano lette e conservate senza problemi. Il pannello annunciava che quattro macchine non erano state scansionate di recente, pochi minuti dopo averle scansionate.
Una parola, presa da un’altra tabella
Il gestore che chiude un turno rimasto senza risposta cominciava con un’uscita anticipata: se il comando è riuscito non c’è nulla da chiudere, quindi si esce.
La parola con cui si confrontava era succeeded. Quella parola non esiste nell’enumerazione degli stati di comando, che contiene pending, sent, completed, failed, timeout e cancelled. È la parola di tutt’altra tabella, quella che segue le installazioni degli agent, ed è arrivata qui perché qualcuno ha scritto il gestore guardando gli stati di quell’altra tabella e perché il parametro era tipizzato come testo semplice.
Il testo semplice è tutta la storia. Il confronto si compila contro qualunque testo, quindi nulla obietta, e il guasto non è un’eccezione ma un ramo mai preso. Il gestore ha percorso la sua via «non è arrivata risposta» per ogni scansione riuscita e ha diligentemente segnato ogni turno come scaduto.
Nessun test unitario avrebbe potuto trovarlo. In un’imitazione non esiste un ciclo di vita del comando: l’imitazione è ciò che porge lo stato, quindi lo stato è quello che ha scritto l’autore della prova, e quell’autore ha scritto la stessa parola sbagliata. Solo un agent vero, che invia conclusioni vere attraverso il distributore vero, produce lo stato che lo rivela. Il parametro ora è tipizzato sui tre stati che possono davvero raggiungere questa funzione, come il gestore equivalente del monitoraggio era sempre stato. Due funzioni, la stessa domanda, e una sola scritta con cura.
E i due messaggi arrivano in disordine
Riparare il confronto non bastava, e la ragione è una seconda cosa che solo un agent vero mostra.
L’agent invia due messaggi distinti: il risultato della scansione e la conclusione del comando. Viaggiano in modo indipendente. Misurato, il risultato è arrivato per primo, quattordici millisecondi prima della conclusione. Quell’intervallo è abbastanza piccolo perché nessuno ci pensi e abbastanza grande da verificarsi sempre.
Quando il gestore di conclusione parte, il turno è dunque di solito già chiuso dal risultato. Un aggiornamento senza condizione riapre allora un turno concluso e lo segna di nuovo scaduto, che è il difetto originale con un altro cappello. L’aggiornamento è perciò vincolato ai due stati in cui un turno attende ancora risposta. Se è già deciso, il gestore non cambia nulla.
È una forma generale: quando un fatto può arrivare da due direzioni, lo scrittore che non è il principale deve dichiarare da quali stati gli è consentito uscire.
Un reperto chiuso che ha fermato l’intera flotta
Il secondo difetto è più silenzioso e ha fatto più danni.
Quando una fonte di vulnerabilità si aggiorna, i reperti aperti vengono rivalutati sui nuovi dati. Un reperto la cui corrispondenza è sparita viene chiuso, e chiuso come non verificato e non come risolto, perché non è stato misurato nulla: a cambiare è la nostra deduzione, non la macchina. Quella distinzione è la ragione per cui quello stato esiste.
La rivalutazione recuperava i reperti derivati esistenti per distinguere «aggiorna questo» da «creane uno nuovo». Recuperava solo quelli aperti. È un filtro dall’aria ragionevole ed è sbagliato, perché il vincolo di unicità sottostante non sa nulla degli stati: sta sul server e su un’impronta del reperto, punto.
La sequenza va così: una base dati nazionale di vulnerabilità corregge un intervallo di versioni, la nostra corrispondenza sparisce, il reperto si chiude. Settimane dopo la stessa base rivede l’intervallo all’indietro, cosa che accade davvero, e la corrispondenza torna. La query non vede la riga chiusa. Il codice imbocca il ramo «questo non esiste ancora». L’inserimento colpisce l’indice unico e solleva una violazione di vincolo.
E allora quella violazione sfugge. Il chiamante la raccoglie come «la tornata degli avvisi è fallita», e quella tornata non rivaluta più assolutamente nulla, per ogni cliente della piattaforma. La condizione che l’ha causata non sparisce da sé, quindi ogni tornata successiva è fallita allo stesso modo. Un reperto chiuso e poi riaperto ha congelato la rivalutazione di un’intera flotta, in modo permanente, con una riga di registro come unico sintomo.
La correzione è piccola: includere le righe chiuse e, quando un reperto chiuso torna a corrispondere, riaprirlo con la sua cronologia intatta anziché iniziarne uno nuovo. Un reperto riaperto è un fatto diverso da uno nuovo, e chi lo esamina merita di vedere che è tornato e non che è arrivato.
Un ciclo di fonti, misurato tre volte
Il processo delle fonti era stato scritto prima che esistesse un modo di eseguirlo senza toccare una base dati vera, ed è la trappola in cui è caduta la tornata di sicurezza precedente: importare il pacchetto di base dati legge l’ambiente e si collega, così una prova unitaria con una funzione di scaricamento iniettata ha scritto in silenzio quattro avvisi in una base di sviluppo mentre annunciava verde.
Il lato archiviazione ora è una cucitura, e il ciclo si può mettere alla prova contro una contabilità in memoria mentre il codice di produzione usa quella vera, invariata. Poi è girato tre volte contro le fonti pubbliche reali.
| Tornata | Richieste | Che cosa è successo | Tempo |
|---|---|---|---|
| A freddo | 4 | 7.066 avvisi conservati, 31,4 MB dopo la decompressione | 18,7 s |
| Invariata | 1 | 304 Not Modified, validatori conservati, zero scritture | 0,3 s |
| Cursore due giorni indietro | 3 | 355 avvisi aggiornati, nessuno nuovo | secondi |
Il 304 è tutto il senso dell’esercizio. Un catalogo di avvisi servito in modo statico risponde a una richiesta condizionale con «non è cambiato nulla», e il comportamento corretto è conservare i validatori memorizzati, non scrivere nulla e contare la tornata come riuscita.
Alcune dimensioni meritano di essere note, perché è proprio lì che si tira a indovinare: il catalogo delle vulnerabilità attivamente sfruttate pesa circa 188 kB sul filo perché è compresso, e una pagina di duemila voci della fonte più grande sta intorno ai 18,7 MB. Entrambi vengono letti dietro un tetto rigido di byte e non con l’aiuto JSON ordinario, che non ha alcun limite; sono le due risposte più grandi che la piattaforma assorbe e vengono da macchine che non gestiamo.
Una finestra che si dimezza invece di saltare
La fonte più grande si sfoglia a pagine, dentro una finestra temporale mobile. Due cose non andavano in quel ciclo, ed entrambe perdevano dati senza mai segnalare nulla.
Se una finestra conteneva più voci di quante il tetto di pagine consentisse, il ciclo si fermava e spostava comunque il cursore alla fine della finestra. Tutto ciò che stava oltre il tetto veniva saltato in silenzio, e sarebbe stato ripreso solo se un altro cambiamento avesse toccato di nuovo quelle stesse voci.
E una pagina vuota in mezzo a una finestra, mentre altre voci risultavano ancora esistenti, avanzava il cursore di una pagina intera. Duemila voci perse.
Entrambe ora vengono trattate con lo stesso principio: se la risposta non entra, porre una domanda più piccola, e se la risposta è impossibile, non spostare nulla.
Una finestra troppo grande viene dimezzata anziché troncata, al massimo sei volte, fino a un fondo di un’ora. Sei dimezzamenti riportano una settimana a poco più di due ore, il che basta per qualunque arretrato realistico. Sotto l’ora ci si ferma: una finestra di minuti che rende ancora ventimila voci non è un problema di finestra ma un guasto, e non scrivere lasciando il cursore dov’è si dà il caso sia l’unica risposta onesta disponibile. Una pagina vuota a metà finestra viene trattata allo stesso modo, come un errore che lascia in pace il cursore, così che la tornata successiva riprovi anziché passare oltre un buco.
Il dimezzamento avviene dopo la scrittura della pagina, non prima. Ciò che abbiamo già letto appartiene alla finestra originale ed è valido; buttarlo scambierebbe una forma di perdita di dati con un’altra.
Una piccola cosa dello stesso angolo, degna di essere ripetuta perché sotto pressione riesce male con facilità: la chiave di accesso va in un’intestazione e mai nella stringa di ricerca. Gli URL finiscono nei registri, nelle cache intermedie e nelle segnalazioni di errore. Le intestazioni no.
Che cosa annoteremmo
- Tipizzare il parametro, tanto più se è uno stato. Un confronto di testo con una parola dell’enumerazione di un’altra tabella si compila, gira e imbocca per sempre il ramo sbagliato.
- Due messaggi su uno stesso evento arrivano in disordine. Vincolare lo scrittore secondario agli stati da cui può uscire.
- Un vincolo di unicità che ignora gli stati incontrerà codice che non lo fa. Recuperare anche le righe chiuse, altrimenti la prima riapertura diventa una violazione.
- Chiedersi che cosa avvelena un singolo guasto. Un’eccezione che sfugge da un ciclo per elemento trasforma una riga cattiva in un arresto di tutta la piattaforma, e la condizione non si risolve da sé.
- Una richiesta condizionale a cui si risponde 304 è un successo. Conservare i validatori, non scrivere nulla e non contarla come fallimento.
- Se una finestra è troppo grande, dimezzarla; se una pagina è impossibile, fermarsi. Spingere un cursore oltre dati non letti è l’unico esito senza ritorno.
- Un processo che non gira senza base dati verrà provato contro la vostra base dati. Una cucitura di archiviazione costa meno della pulizia.
Per chiarezza: la scansione di esposizione è costruita e sta dietro un interruttore di distribuzione, quindi nessun cliente ce l’ha davanti. La rivalutazione, le fonti e la distribuzione su più indirizzi sono esercitate; restano non dimostrati il restringimento di finestra contro una fonte viva anziché iniettata, il comportamento dell’indice su un catalogo davvero pieno, e una scansione lanciata da una macchina diversa da quella che l’ha chiesta.
Dal lavoro di piattaforma su Provibr. Perché il confrontatore rifiuta di essere sicuro di una versione si trova in una corrispondenza che rifiuta di essere sicura, e perché la sonda dietro non invia quasi nulla in perché uno scanner di vulnerabilità deve inviare zero byte.