lunedì 1 dicembre 2014

Sui vizi e le virtù di /dev/random

Eccomi qui a scrivere un articolo su una delle componenti fondamentali del kernel Linux (e non solo): /dev/random.

Come si può ricavare dal suo path /dev/random è un character device che, una volta letto, emette una sequenza pseudocasuale di byte.

Se volete riempire completamente il vostro terminale di caratteri casuali vi basta invocare il seguente comando:

cat /dev/random

Ovviamente vi consiglio CALDAMENTE di NON FARLO, ma si sa: il Mondo è pieno di masochisti e magari a qualcuno di voi potrebbe piacere!

Tralasciando gli scherzacci per cosa può essere utile un simile device?

  1. Creazione di password pseudocasuali mediante shell-fu:

    dd if=/dev/random bs=1 count=6 2> /dev/null | base64 | \
    sed -r 's/[^A-Z|a-z|0-9]//g'
  2. Simulatore di tiri di dado per Giochi di Ruolo (se siete veri nerd sapete di cosa sto parlando).

  3. Cancellazione sicura di un disco: prima di formattarlo potete sovrascrivere i suoi dati con un l'output di /dev/random per un certo numero di volte.

Queste sono solo alcune delle idee che potrebbero essere implementate grazie a letture da /dev/random, una di queste idee però si scontrerà subito con il principale limite del generatore di numeri pseudocasuali. Sapete per caso dirmi quale?

Chi ha risposto "la terza!" vince un simpatico sguardo condiscendente! Chi invece non sapeva la risposta potrà leggere la spiegazione nel prossimo paragrafo.

La procedura per interrogare /dev/random si compone dei seguenti passi: si apre un file descriptor e lo si associa al device, si richiedono i dati tramite una chiamata read (http://linux.die.net/man/2/read ), si ASPETTA che read popoli il buffer con i dati richiesti, si chiude il file descriptor.

No, Virginia, non ho evidenziato in corsivo la voce del verbo aspettare per puro vezzo personale. /dev/random non è sempre disponibile ad inviare dati pseudocasuali perché ha bisogno che ci sia una sufficiente entropia per generare dei buoni numeri pseudocasuali. La teoria matematica che sta dietro ai generatori di numeri pseudocasuali è piuttosto complicata ed esula da quelli che sono gli scopi di questo articolo, se avete parecchio tempo da spendere e un diploma di scuola media superiore potete cercare "PRNG" (Pseudo Random Number Generator) su Wikipedia (meglio se interrogate la versione inglese) e smarrirvi in un buco nero di congetture, lemmi e teoremi.

Per chi va di fretta la versione TLDR è la seguente: ogni PRNG è un algoritmo che gira su una macchina deterministica (se la macchina è nello stato X e legge N andrà sempre nello stato Y) e quindi ogni PRNG è condannato prima o poi (meglio se molto poi) a replicare la sequenza di numeri che ha generato dall'inizio. Questa caratteristica si chiama periodo del generatore ed è molto importante per distinguere un buon generatore (leggasi: un generatore che un periodo molto lungo) da uno cattivo. Ma non basta! Ogni PRNG è tale per cui da una sequenza sufficientemente lunga si può ricavare quali saranno i prossimi numeri generati prima che essi vengano generati. Un buon PRNG deve fare in modo che la sequenza che permetta una simile previsione sia la più lunga possibile.

Una maniera che hanno i creatori di PRNG per allungare il periodo ed introdurre una maggiore casualità nella sequenza (rendendo più difficile la previsione dei numeri successivi) è effettuare un (a)periodico re-seed (re-inseminazione mi pareva brutto da scrivere... OPS!) dell'algoritmo da altre fonti di numeri casuali. Semplificando molto quello che si fa è far ripartire il generatore da un altro numero rispetto a quello da cui era partito all'inizio, generando quindi una nuova sequenza.

Sì Virginia? Ah ti stai chiedendo cosa c'entri tutto questo con l'aspettare? Tutto dipende da quanto disordine c'è nel tuo sistema nel momento in cui vai ad interrogare /dev/random. Non è chiaro, allora lasciami spiegare un altro po'.

Come scritto poc'anzi ogni PRNG che consenta il re-seed ha bisogno di un numero di partenza: tanto maggiore è la casualità con cui ricava questo numero ad ogni re-seed tanto migliore sarà la sequenza che ne sarà generata. Verrebbe la tentazione di usare un altro PRNG come generatore di semi per il nostro PRNG ma ci ritroveremmo con il solito problema dell'uovo e della gallina. Per mitigare questo di solito quello che si fa è sfruttare una fonte di disordine esterna alla macchina: l'utente.

La pressione dei tasti, il flusso di dati via rete o da/per il disco rigido, i tempi di latenza delle periferiche sono tutte possibili fonti di disordine che, per essere affini con la teoria dell'informazione di Claude Elwood Shannon, chiameremo entropia.

Maggiore è l'entropia del sistema maggiore sarà la casualità con cui viene prodotto il seme che darà l'avvio alla sequenza di numeri. Intuitivamente possiamo supporre che occorra un certo tempo perché all'interno di un sistema si formi una quantità sufficiente di entropia e che una richiesta continua di numeri pseudocasuali causi l'esaurimento dell'entropia con conseguenze catastrofiche sulla lunghezza del periodo.

Per questa ragione /dev/random blocca qualsiasi richiesta finché non ha abbastanza entropia per soddisfarla, bloccando quindi qualsiasi software che fa uso dei suoi servizi.

Come palliativo gli sviluppatori di Linux hanno creato /dev/urandom: una versione non-bloccante di /dev/random che ricicla i numeri generati in caso di esaurimento dell'entropia del sistema. Eliminando così tutti i benefici derivanti dal re-seeding.

Ora starete pensando che un utente accorto può effettivamente fare un buon uso di /dev/random limitando le chiamate e creando un proprio PRNG all'interno dei suoi programmi... Sorvolando sull'intera questione del "perché usare /dev/random allora?" punto il dito su quanto scritto prima: scrivere un buon PRNG richiede numerose conoscenze e capacità e ci si espone molto facilmente ad attacchi se si decide di fare affidamento ad algoritmi men che perfetti.

In sostanza: se non siete dei guru, non fatelo.

C'è un'altra circostanza in cui l'uso di /dev/(u)random è sconsigliato: per ottenere un numero casuale occorre eseguire la procedura sopradescritta che consta di tre fasi. Aprire un file descriptor può non essere possibile (sì, Virginia, un processo può esaurire il numero massimo di file che può aprire in contemporanea), inoltre un'operazione che dovrebbe essere atomica (indivisibile) viene divisa in tre operazioni. Questo è male in tutti quei contesti in cui l'atomicità di un'operazione è critica, come ad esempio nel caso di un'applicazione multi-threaded.

Forse un esempio chiarirà le idee: supponiamo di avere un'applicazione che ha la necessità di autenticare gli utenti e che lo faccia tramite un meccanismo di crittazione basato su chiavi monouso. Ogni volta che un utente si vuole connettere il server crea una stringa di bit casuali che verranno usati come chiave crittografica temporanea per quella sessione. Supponiamo ora che l'applicazione in questione sia multi-threaded e che crei un nuovo thread ad ogni richiesta di connessione. Oltre al problema dell'esaurimento dell'entropia abbiamo ora anche un problema di accesso concorrente a /dev/(u)random in cui due thread potrebbero leggere la medesima porzione dello stream di dati casuali con conseguente calo drastico della sicurezza del sistema.

Il nostro programma di esempio si trova ora incastrato tra l'incudine e il martello: se utilizza dei meccanismi per impedire l'accesso concorrente a /dev/(u)random si troverà ad avere dei thread bloccati in attesa del loro turno, se non li utilizza rischia di avere due (o più connessioni) che condividono la stessa chiave crittografica. Quanto sia alto questo rischio dipende da numerosi fattori tra cui il più importante è il carico a cui è sottoposto il sistema: maggiore sarà il carico maggiore sarà la probabilità di collisioni.

Ed ora il motivo principe per cui usare /dev/(u)random non è una buona idea: pur essendo un file speciale si tratta sempre di un file. Per leggerlo, oltre alla necessità di avere un file descriptor disponibile, dovete anche avere i permessi per leggerlo e dovete essere in grado di trovarlo. Supponiamo che il vostro programma sia eseguito in una gabbia chroot e che quindi non abbia accesso alla directory /dev e quindi al suo contenuto. In questa situazione le chiamate a file esterni alla gabbia falliscono e quindi siete costretti a ricreare i file speciali all'interno della gabbia chroot e ad istruire il kernel a collegare il generatore di entropia a quei file (non è impossibile, ma è alquanto laborioso). Ma c'è un caso peggiore: supponete che il vostro sistema abbia ricevuto la visita di un malintenzionato che abbia eliminato /dev/random e /dev/urandom e li abbia sostituiti con un nuovo device che altro non è che /dev/zero. Inquietante, nevvero?

Certo, in quest'ultimo caso siete già stati compromessi e quindi non c'è molto che possiate fare, ma sappiate che esistono delle alternative e che (grazie alle pressioni fatte dal team che sta portando LibreSSL su Linux) è possibile chiedere direttamente al kernel di riempire un buffer di memoria con dati casuali grazie a getrandom(2) (https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=c6e9d6f38894798696f23c8084ca7edbf16ee895 ).

Con questo vi saluto e vi invito a leggere la presentazione di Theo DeRaadt tenuta all'EuroBSDcon 2014 (nonostante sia scritta in Comic Sans contiene numerosi spunti e informazioni utili):

http://www.openbsd.org/papers/eurobsdcon2014_arc4random/index.html

Nessun commento:

Posta un commento