mercoledì 20 aprile 2016

Costruire un sistema di monitoring scalabile

Salve o miei due lettori (suppongo che la lunga pausa abbia fatto calare drasticamente il numero di lettori di questo blog) in questo articolo tratterò per sommi capi di un argomento squisitamente tecnico dimezzando ulteriormente il numero di lettori. Tale argomento sarà la costruzione di un sistema di monitoraggio scalabile.

Per monitoraggio si intende l'attività di raccolta, archiviazione, costruzione di statistiche, visualizzazione (ed eventualmente allerta in caso di anomalie) di uno o più host (computer connessi tramite una rete TCP/IP) eventualmente distribuiti su più siti geografici. In breve: pornografia per ingegneri di reti (network engineers per gli anglofili).

L'aggettivo scalabile presuppone che la soluzione proposta sia in grado di trattare altrettanto bene uno, dieci, cento, mille, diecimila o più host e che quindi si adatti al serverino domestico come alla rete di datacenter di MEGA_CORPORAZIONE_A_CASO.

Si badi bene che le tecniche che andrò a descrivere possono essere utilizzate con gli opportuni aggiustamenti anche in altre situazioni in cui occorre raccogliere ed elaborare delle misure da un numero imprecisato di entità.

Come potete immaginare il problema è estremamente complesso e particolarmente sentito (specialmente all'aumentare del numero di host da monitorare) e quindi vi sono un numero non indifferente di soluzioni sia a sorgente aperto che proprietarie che sviluppate ad-hoc per una determinata infrastruttura/applicazione (a.k.a. LA collezione di script costruita nel corso di anni dal guru di turno che nessun altro sa come far funzionare). Non andrò ad elencarle tutte (del resto non mi è possibile compilare un elenco esaustivo perché non ho modo di venire a conoscenza di TUTTE le soluzioni ad-hoc) ma mi limiterò a dare delle linee guida generali che potrete adoperare per valutare le varie proposte pre-esistenti ed eventualmente decidere se imbarcarvi nell'ennesima reinvenzione del treno merci (un sistema di monitoring è troppo complesso per la metafora della ruota).

Le premesse

Come prima cosa occorre stabilire esattamente cosa si vuole monitorare, quali sono i parametri in gioco e quali statistiche si vogliono raccogliere. Senza queste informazioni il problema è talmente vago e aperto che una soluzione vale l'altra perché qualsiasi prodotto o pila di prodotti sceglierete vi renderà scontenti per un motivo o per l'altro.

L'unica cosa da tenere a mente è che se vogliamo che la soluzione scali e sia affidabile dobbiamo tenere a mente due principii fondamentali:

  1. Distribuzione del carico.
  2. Ridondanza.

In virtù di questi principii eviteremo categoricamente le soluzioni in cui c'è un ente centrale che contatta direttamente le entità da monitorare: è un'infrastruttura che introduce una debolezza fatale perché accentra il carico (violando il primo principio) ed elimina la ridondanza (per definizione un sistema ridondante ha più componenti di quante strettamente necessarie al suo funzionamento ordinario in modo tale da compensare i guasti) creando un single point of failure (leggasi: se va giù lui va giù tutto).

Basandoci sui due principii possiamo tracciare delle linee guida da seguire:

  • La raccolta dei dati sarà decentralizzata e si procederà a pre-processare quanto più possibile sui nodi remoti.
  • Dove possibile si adotteranno tecniche di funnelling (dall'inglese funnel ovvero imbuto) dei dati raccolti.
  • A seconda della volontà di salvare i dati monitorati a medio e lungo termine, delle dimensioni del carico e delle previsioni di crescita del carico stesso sarà necessario scegliere il tipo di database da utilizzare per il salvataggio dei dati (od optare per nessun database se non si desidera salvare i dati).

Raccogliere i dati

Basta con la fuffa generica! Adesso ci andiamo giù pesante con la fuffa (un po' più) specifica!

Abbiamo deciso che i dati saranno raccolti in maniera decentrata da tante piccole entità che chiameremo agenti (ma possiamo benissimo chiamarli in qualsiasi altro modo purché non sia protetto da Copyright). È fondamentale che gli agenti siano piccoli perché non vogliamo che impattino negativamente sui dati che vogliamo raccogliere, tuttavia (grazie anche agli smartphone) al giorno d'oggi è possibile comprare per poche decine di euro al pezzo macchine piccole quanto pacchetto di sigarette con una dotazione di RAM ed un processore più che adeguati per far girare un sistema GNU/Linux da dedicare alla raccolta dati e i processori multicore e multithread ci assicurano che (se non siamo particolarmente idioti nello scrivere i nostri agenti) solo nelle condizioni di carico estreme non ci sarà la possibilità di far girare il nostro agente su un server da monitorare per cui le scelte possibili sono più ampie che in passato (nei limiti del ragionevole).

Un solo appunto: mi dispiace per i fan di JRuby, ma avere una Virtual Machine che esegue codice Ruby ricompilato in Java non è una soluzione leggera: fatevene una ragione.

Detto questo vediamo quali sono le alternative per i nostri agenti.

Shell script

La cara buona vecchia shell che troviamo in (quasi) ogni installazione di GNU/Linux è il primo strumento a cui dovrebbe pensare un sistemista quando si tratta di scrivere un tool che faccia monitoraggio dei log. Aggiungiamo awk e netcat (anche noto come nc) ed avremo un potente strumento nelle nostre mani. Giusto? Purtroppo in realtà non è così semplice...

Partiamo dalla prima pecca della shell: se scrivo uno script bash che usa awk e nc dovrò installare questi tre programmi in ogni macchina da monitorare. Idem dicasi per qualsiasi altra dipendenza di cui posso aver bisogno.

Seconda pecca della shell: avvia un processo per ogni tool che chiama all'infuori dei suoi builtin. Una riga come questa avvia ben tre processi (a cui si aggiunge la shell):

grep espressione $miofile | tail -n 100 | cut -d ',' -f 3

E sebbene sia possibile fare tutto con awk (anche il tail delle ultime 100 righe) il risultato è alquanto ostico per i non iniziati:

awk -F',' '/espressione/{ o[NR % 100] = $3 } \
END{ i=(NR < 100 ?  0 : NR); \
do print o[++i % 100]; while(i % 100 != NR % 100)}' $miofile

So cosa state pensando: le lettere sono quelle del nostro alfabeto ma la lingua è quella di Mordor. Non posso darvi torto.

Questo ci porta alla terza pecca della shell: per quanto sia potente la sintassi e le idiosincrasie di certi tool sono tali per cui sono in pochi a poterci lavorare con profitto senza impazzire.

Python

Python ha conquistato sempre più proseliti nel corso degli ultimi 15 anni per cui deve essere una via percorribile per la scrittura del nostro agente. Ed in effetti Python è una buona proposta sotto molti punti di vista:

  • Ha una sintassi comprensibile (il più delle volte).
  • Possiede una nutrita comunità di utenti che hanno sviluppato codice di ogni genere e per ogni scopo.
  • Batte la shell in quanto a velocità di esecuzione.

Quindi dichiariamo Python vincitore e chiudiamo la questione? Ni.

Il primo problema contro cui ci scontriamo è: "quale versione di Python posso/devo utilizzare?". La risposta a questa domanda non è banale. A tutt'oggi ci sono due versioni del linguaggio attivamente utilizzate: la versione 2 e la versione 3. La prima è la versione storica di Python, presente in numerose distribuzioni ed utilizzata in un ampio raggio di progetti con diverse estensioni per l'interprete di riferimento (CPython), la seconda è la versione attuale del linguaggio e presenta parecchi cambiamenti rispetto alla precedente sia in termini di sorgenti Python che di interfaccia dell'interprete. Per aiutare chi deve migrare i propri sorgenti Python esiste il tool 2to3, disgraziatamente il tool automatico non copre tutte le possibili amenità a cui si può andare incontro. Per chi volesse approfondire l'argomento il mio consiglio è di leggere l'ottimo articolo di Peter A. Donis ed Eric S. Raymond: Practical Python porting for systems programmers.

CPython inoltre non è l'unica implementazione di Python disponibile (sebbene sia la più completa, stabile e maggiormente supportata).

Tcl

Qui tocchiamo un tasto dolente. Io sono un fan del Tcl, ma sono anche dolorosamente cosciente del fatto che il Tcl ha perso la guerra dei linguaggi di programmazione una ventina di anni fa e se n'è reso conto circa una decina di anni fa.

L'interprete Tcl è meno esoso di memoria rispetto a CPython, il linguaggio è molto espressivo (con dei tocchi di LISP qui e là) ed ha un'ottima astrazione per i socket di TCP/IP. Peccato che quelli che lo conoscano si dividano in due categorie:

  • Barbuti sistemisti con qualche anno sulle spalle che lo associano a lentissime e buggate GUI per tool da riga di comando.
  • Fan duri a morire che cercano di infilarlo in ogni progetto a cui hanno accesso.

Se volete imbarcarvi in una battaglia stile Don Chisciotte contro i mulini a vento usate pure il Tcl, sappiate però che sarete gli unici a capirci qualcosa del codice che scriverete (il che potrebbe essere un vantaggio per la conservazione del posto di lavoro).

Perl

Discorso simile al Tcl con in più una certa tendenza del linguaggio a dare eccessiva libertà al programmatore. Il Perl non è stato definito un linguaggio di programmazione "write only" a caso.

Se avete sufficiente disciplina, non vi spaventano le espressioni regolari e vedete un pregio nella facoltà di poter ridefinire TUTTO a runtime allora potreste pensare di scrivere il vostro agente in Perl.

Un punto a vantaggio del Perl è che anche più ubiquo di Python in ambito UNIX data la sua età.

C o C++

Siete tra coloro i quali reputano che i linguaggi di scripting siano solo fumo negli occhi?

Non vi spaventa il dover gestire da soli la memoria? Anzi il guadagno in prestazioni giustifica l'occasionale memory leak o use after free che sarà comunque rilevato da Valgrind e debitamente eliminato.

Volete avvicinarvi il più possibile alla macchina fisica senza essere costretti ad impare l'assembly?

Il C e/o il C++ potrebbero fare per voi! Se non fosse che il trattamento del testo in C e in C++ sia una pratica sadomasochistica ai limiti della tortura...

Detto questo vi sono librerie che semplificano le cose, ma introducono dipendenze e complicano la compilazione dei propri programmi.

In definitiva il consiglio che mi sento di darvi è scegliete il C o il C++ se non ci sono altre alternative o se le alternative sono troppo pesanti o troppo lente per gestire il flusso di dati che devono gestire. Nella mia esperienza personale simili casi sono piuttosto rari.

Java

Devo proprio scrivere perché non dovreste usare Java?

Scala

Non conosco Scala ma credo che lo userei per lo step successivo (funnelling) ma non per la raccolta diretta dei dati.

Erlang, Haskell, Racket/Common LISP/Scheme, OCaml

Vedi Scala.

Go, Rust, D

Questi potrebbero essere delle valide alternative se non fossero ancora dei Work in progress.

Fateci degli esperimenti ma non usateli in produzione senza averli testati estensivamente.

Ruby

No.

Javascript/ECMAscript

No. Davvero, non fatelo.

PHP

VADE RETRO!!!

Altri linguaggi di programmazione

Se non ho già elencato il vostro linguaggio di programmazione preferito significa che ricade in una di queste categorie:

  • La categoria dei linguaggi poco diffusi. Con questi dovete fare uno sforzo attivo per convincere chi prende le decisioni ad utilizzare qualcosa che nessun altro usa. Qui ci metto anche i linguaggi di cui non sono a conoscenza.
  • La categoria dei linguaggi morti o morenti. Chi scrive più in COBOL o in FORTRAN al di fuori di certi ambiti?
  • La categoria dei linguaggi inadatti al compito. Verilog e PL/SQL sono ottimi nel loro dominio di applicazione, perché piegarli ad altro?
  • Brainfuck, LOLCODE, whitespace e simili. Devo aggiungere altro?

Funnelling

Avrei voluto inserire qui un bel grafico, ma le ricerche con Google Immagini mi hanno portato ad un punto morto pieno di ragazzotti intenti a tracannare birra mediante imbuti... Ah la gioventù!

Se siete arrivati fino a qui vuol dire che siete davvero interessati all'argomento, oppure avete saltato buona parte di quanto ho scritto nella speranza che la finissi con le opinioni oppure semplicemente non avete niente di meglio da fare e state ammazzando il tempo.

Ad ogni modo qui le cose si fanno interessanti. Mentre gli agenti devono essere leggeri ed hanno accesso solo ai dati provenienti dall'entità in cui risiedono (limitando la quantità di pre-processamento che possono effettuare sui dati che raccolgono) i nostri imbuti che ricevono e reinviano i dati possono effettuare diverse operazioni sui dati medesimi:

  • Tipizzare i dati se non è già stato fatto in fase di raccolta.
  • Aggregare i dati ricevuti da diversi agenti.
  • Filtrare i dati.

Ed ovviamente possono inviare i dati ad altri imbuti che a loro volta potranno aggregare, filtrare ed inviare dati nell'eterno ciclo del data mining.

Liberi dai vincoli dettati agli agenti i nostri imbuti sono allo stesso tempo il cervello, il sistema circolatorio e i muscoli della nostra infrastruttura di monitoraggio.

Qui è dove si distingue il dilettante (come il sottoscritto) dal professionista.

Qui è dove si trova la frontiera del data mining e dove si sperimentano tecniche di analisi avanzata e diagnosi precoce mediante il machine learning.

Qui purtroppo è anche dove la mia conoscenza ha le lacune più grandi. L'unica dritta che posso darvi è che, come avrete intuito, questa parte è la più importante di tutta l'infrastruttura ed è quindi quella che va progettata al meglio per potersi adattare alle esigenze di chi dovrà poi utilizzare l'infrastruttura stessa.

Visualizzazione, Reportistica e Allarmistica

Vi ricordate quando vi ho detto che nella fase intermedia ho le lacune maggiori? Ecco nemmeno in questo sono molto ferrato.

La visualizzazione dei dati è una scienza ed è tutt'ora oggetto di ricerca in diverse università in giro per il Mondo.

Questo è anche il campo in cui le soluzioni ad-hoc spuntano come funghi dopo una pioggia autunnale proprio in virtù della necessità di adattare il risultato delle analisi alle necessità di comprensione dell'utente finale (che non necessariamente sarà un essere umano).

Nel caso in cui si decida di sperimentare con il machine learning bisogna anche mettere in conto che si dovrà monitorare anche il livello di accuratezza delle analisi e delle previsioni che ci arrivano dall'infrastruttura stessa.

I sistemi di allarmistica poi dovranno essere particolarmente attenti ad evitare falsi negativi (che portano ad ignorare situazioni pericolose) e falsi positivi (che spingono l'utente a spegnere i sistemi stessi a causa dell'eccessivo rumore di fondo).

La reportistica è opzionale nel momento in cui decidiamo che non vogliamo conservare a lungo termine i dati raccolti ed elaborati. Normalmente non è così e quindi entrano in gioco tutta una serie di scelte su come conservare i dati e sulle eventuali post-elaborazioni da effettuarsi prima di archiviare i dati stessi.

Tutto questo ovviamente va poi adattato alle normative vigenti sul trattamento dei dati sensibili nel Paese (o nei Paesi) in cui si va ad operare.

Insomma ce n'è di che divertirsi! Alla prossima!

lunedì 3 agosto 2015

Container e altre amenità

Salve o lettori! Quest'oggi cercherò di illustrare a quanti di voi non lo conoscono il magico ed affascinante mondo dei Containers.

Per chi non sia un esperto di Information Technology e per coloro che lo sono ma che hanno vissuto sotto ad una roccia negli ultimi tre anni: i container sono una soluzione interessante ad un annoso problema, quello della separazione di diverse applicazioni residenti sulla medesima macchina fisica.

Supponiamo voi siate un allegro e simpatico sistemista amato dai colleghi e invidiato dal management... Fatto? Bene, torniamo ora coi piedi per terra e prendiamo un ben più comune sistemista ignorato dai colleghi e conosciuto unicamente come voce passiva di bilancio dal management.

Il vostro compito è quello di rendere efficace ed efficiente la fruizione dei servizi informatici da parte della vostra azienda e/o da parte dei vostri clienti. Vi è stato chiesto di integrare un nuovo servizio composto da un mix di applicazioni interagenti tra di loro e quasi incompatibili con il vostro attuale stack software. Il budget copre le cialde della macchinetta del caffè e il bonus di produttività dell'anno passato vi ha permesso di comprare la suddetta macchinetta per cui non ci sono soldi per comprare nuovo harware.

A questo punto sareste tentati di optare per una macchina virtuale, ma sapete già per esperienza che le macchine virtuali hanno la terribile tendenza ad essere sovrastimate o sottostimate per il compito che devono assolvere ed inoltre si portano dietro inevitabilmente il layer di virtualizzazione (o richiedono kernel speciali per la paravirtualizzazione). Niente di insormontabile: si è lavorato per un decennio con le macchine virtuali e le prestazioni sono prossime a quelle della macchina fisica su cui girano. Però quella fastidiosa vocina dentro la testa vi dice che si può fare di meglio e che si può sfruttare in maniera più oculata il ferro.

La vocina ha ragione, vediamo se possiamo farla star zitta con i containers.

Gabbie chroot

Prima di addentrarci nel magico mondo dei container vi devo parlare di una syscall e delle implicazioni che questa syscall ha avuto. Tanto tempo fa, quando la Disco music era in declino e il New wave era in ascesa Bill Joy (il creatore dell'editor vi) aggiunse al codice della Berkeley Software Distribution la chiamata di sistema chroot per testare il sistema di installazione di BSD in locale senza dover reinstallare da zero una macchina fisica.

Immagino che gli utenti di Arch Linux a questo punto scattino in piedi come delle molle. Acquietatevi e consentitemi di spiegare anche ai comuni mortali cosa fa chroot e perché è importante in fase di installazione del sistema.

La suddetta chiamata di sistema altro non è che una maniera per far sì che un processo ed i suoi figli vedano un'altra directory come se fosse la radice della gerarchia del filesystem (la famosa /). Cosa significa questo? Significa ad esempio che potreste avere un web server che in /var/www abbia i dati da servire, gli script PHP o i CGI e la sua configurazione completa più tutte le librerie di cui ha bisogno e potreste fare in modo che non possa uscire da /var/www facendogli fare una chiamata a chroot in fase di avvio. Le implicazioni sono palesi: il vostro webserver non potrà modificare nessun file all'infuori di quelli presenti in /var/www e nelle relative sottodirectory e, se la sua nuova radice è montata su un filesystem separato, non potrà impedirvi di lavorare riempiendo completamente il disco di files a causa di un bug nello script che consente agli utenti di caricare files via HTTP POST o HTTP PUT.

Questa condizione si chiama gabbia chroot ed è utilizzata ampiamente da molti software: il server OpenSSH la usa in fase di autenticazione, il server di posta Postfix è diviso in diversi programmi che risiedono in gabbie chroot, il webserver thttpd ha un'opzione per avviarlo e farlo subito entrare in una gabbia chroot e l'elenco potrebbe andare avanti.

Per quanto utili le gabbie chroot non sono perfette. Tanto per comiciare tutti i file descriptor aperti prima della chiamata a chroot vengono mantenuti, anche se riguardano file presenti all'esterno. Inoltre è possibile sfuggire alla gabbia facendo una seconda chiamata a chroot e reimpostando la root a quella di sistema. Le gabbie chroot inoltre non possono nulla contro processi che succhiano RAM e/o cicli di CPU a scapito degli altri processi nel sistema. Senza contare che un processo in una gabbia chroot che abbia i privilegi di root può killare un qualsiasi altro processo presente nel sistema. Infine non c'è alcuna maniera per limitare l'accesso alla rete: un processo in una gabbia chroot può tranquillamente aprire quanti socket vuole verso qualsiasi destinazione sia raggiungibile dall'host da qualsiasi indirizzo IP sia disponibile.

Insomma: le gabbie chroot sono un buon punto di partenza, ma occorre pensare a qualcosa di più se vogliamo realmente isolare un'applicazione dal resto del sistema.

FreeBSD e le jails

Le mancanze di chroot erano note da anni quando Poul-Henning Kamp si trovò davanti all'annoso problema che affligge tutti coloro i quali voglio aprire un servizio di hosting di siti web: come faccio a separare il mio (o i miei) siti da quelli dei miei clienti?

La soluzione efficace ma dispendiosa era (è) quella di ospitare il proprio sito su di un server e quelli dei clienti su altri server. Per darvi un'idea temporale stiamo parlando del 2000: un'epoca in cui la virtualizzazione muoveva i suoi primi passi, il processore di punta di intel era il Pentium III e Windows XP era ancora in fase di sviluppo e si chiamava Whistler. Un'epoca oscura per il mondo dell'IT...

A quei tempi l'unica maniera per avere più di un sito ospitato sullo stesso server fisico consisteva nello sfruttare un campo nell'header HTTP reso obbligatorio a partire da HTTP 1.1: il campo "Host:". Tale campo viene compilato dal client ed indica l'host a cui si vuol fare la richiesta, nella precedente versione di HTTP non era obbligatorio perché si assumeva che l'host a cui ci si collegava fosse lo stesso host a cui si voleva richiedere i dati e che un nome a dominio puntasse a macchine fisicamente diverse (o che, se anche si fosse puntato alla stessa macchina fisica con nomi diversi, questa avrebbe fornito sempre gli stessi dati).

L'avvento dei proxy server (utilizzati per ridurre il traffico dati verso i siti più frequentati dai propri utenti e quindi ridurre anche i costi di connessione) ha reso necessario indicare qual è l'host a cui mi voglio rivolgere perché potrebbe non essere quello a cui sono collegato.

Come effetto collaterale il campo "Host"permette ad un server web di decidere che contenuto mostrare in base al valore del campo stesso tramite il meccanismo dei virtual hosts.

Per quanto i virtual host rendano possibile la convivenza di più siti sul medesimo server web e le gabbie chroot possano essere sfruttate per impedire agli utenti di scrivere via FTP nelle directory degli altri (il server FTP al momento dell'autenticazione crea un nuovo processo che immediamente fa una chiamata a chroot nella home directory dell'utente) questo meccanismo non migliora la situazione agli utenti che richiedono modifiche particolari alla configurazione del webserver e che magari entrano in conflitto con altre modifiche volute da altri utenti o dagli amministratori stessi del server.

La situazione descritta poc'anzi non è così campata per aria, basti pensare a versioni mutualmente incompatibili di PHP (ad esempio PHP 4 e PHP 5) richieste contemporaneamente da due utenti differenti. Si può pensare di compilare staticamente due versioni di PHP, metterle in due gabbie chroot diverse, configurare due istanze del server web affinchè si mettano in ascolto su indirizzi IP diversi e pregare che tutto funzioni. Ma questo non impedisce ai processi di vedere tutti gli altri processi in esecuzione (e potenzialmente di fare disastri).

La soluzione proposta da Paul Henning-Kamp è stata quella di estendere le gabbie chroot in modo da rinchiudere i processi non solo dal punto di vista dell'accesso ai file, ma anche dal punto di vista dei processi con cui possono interagire e dal punto di vista dell'interazione con le connessioni di rete. Nascono così le jails.

Una jail è una gabbia chroot i cui processi non vedono altri processi se non quelli lanciati all'interno della jail ed è associata ad un indirizzo IP (per cui può comunicare solo tramite quell'IP e non tramite tutti gli IP disponibili all'host che ospita la jail). Non solo: l'utente root all'interno della jail non ha la facoltà di uscire dalla jail, solo root dall'esterno della jail può entrare e uscire a piacimento.

In pratica si può creare una userland alternativa in una sottodirectory del proprio filesystem e farci girare quello che si vuole sapendo che il resto del sistema sarà opaco ai processi all'interno della jail.

Iterazioni successive hanno raffinato il meccanismo delle jail migliorandone la sicurezza e la capacità di compartimentazione e rendendole una delle feature più interessanti di FreeBSD.

Linux Containers

Facciamo un bel fast forward di otto anni e raggiungiamo il 2008 quando finalmente il kernel Linux ha un'implementazione matura dei cgroups e può replicare le funzionalità delle jails di FreeBSD. Su questa base comincia a svilupparsi LXC: un insieme di tools in userland che rendono semplice la creazione di quegli ambienti chiusi noti come container.

Se avete resistito fin qui potrete facilmente dedurre cosa sia un container: nient'altro che la versione in salsa Linux delle jails. Tramite chroot e cgroups si ottiene il medesimo effetto, in effetti si ha una granularità maggiore nel limitare ciò che un processo all'interno di un cgroup può fare. L'effetto di questa granularità è stato in parte deleterio: per anni gli unici che usavano queste feature erano pochi iniziati che seguivano attentamente gli sviluppi del kernel.

C'è voluto il 2013, una startup chiamata Docker, e una cospicua dose di marketing perché il mondo si rendesse conto che i container su Linux esistono, sono una tecnologia matura e possono semplificare la vita di chi deve incastrare userland mutualmente incompatibili sullo stesso hardware.

Non sono però la panacea a tutti i problemi: tanto per cominciare il kernel è uno per tutti i container, una macchina virtuale può far girare un kernel diverso da quello dell'host. Stesso discorso vale per i moduli del kernel: tutti i container vedranno gli stessi moduli, anche se il caricamento di un modulo dall'interno dei container può essere inibito (e solitamente è inibito di default per questioni di sicurezza).

Il rovescio della medaglia sono le prestazioni: non c'è bisogno di elaborati artifici con driver virtuali passthrough e simili amenità in un container perché si sta già girando direttamente sul ferro e l'accesso ad una periferica dista appena un mknod ed una modifica al cgroup.

In sintesi per concludere: se siete gente che sa quel che sta facendo e vuole un set di tool minimale per sfruttare rapidamente i container ed avere il massimo della personalizzazione il mio consiglio è di saltare il layer di Docker e di provare LXC direttamente. Se siete interessati ad un sistema che vi consenta di condividere efficacemente le istruzioni per la creazione di containers e che fornisca già migliaia di template già pronti al prezzo della creazione di un account gratuito allora andate tranquilli su Docker. Se dovete far girare kernel diversi o interi sistemi operativi diversi allora la scelta delle macchine virtuali è una scelta obbligata.

venerdì 26 giugno 2015

Turing e Template

Salve a tutti o lettori! Il blog languiva (in buona parte grazie a quella cosa chiamata "vita reale" che ha bloccato diversi GNUrants dallo scrivere qualcosa che non fosse relativo al lavoro o all'università).

Invece di lasciarvi in attesa che grossi articoli appaiano ho deciso di scrivere qualche paragrafo sui Template Engine e sulle macchine di Turing di cui ha già parlato il buon Federico.

La domanda che vi pongo è: preso un Template Engine (ad esempio Jinja2 per Python) questo è Turing-completo? Posso cioé scrivere un qualsiasi programma (da Quicksort a DooM) in forma di template?

Potete subito intuire che le conseguenze di una risposta affermativa a questa domanda non siano da poco, sia in termini di flessibilità del Template Engine (posso potenzialmente fare quello che voglio senza uscire dal Template Engine) sia in termini di sicurezza (un attaccante che riuscisse a sfruttare una mancata validazione dell'input che passo al Template Engine potrebbe far fare di tutto e di più al mio server).

Un vecchio detto recita: in teoria non c'è differenza tra la pratica e la teoria, in pratica la differenza c'è eccome!

Questo è uno dei casi in cui si verifica proprio questa situazione: in teoria io posso scrivere DooM come template Jinja2 ma in pratica non posso farlo perché il mio template engine non ha accesso all'hardware della mia macchina per cui il massimo risultato a cui poso aspirare è renderizzare le schermate una ad una sottoforma di file SVG.

Un po' di teoria

Prima di continuare a sproloquiare su macchine di Turing e Template occorre definire quale sia l'insieme minimo di caratteristiche che fanno sì che un linguaggio di programmazione, una definizione formale di regole di riscrittura o un modello computazionale astratto siano Turing-Completi e quindi equivalenti alla Macchina di Turing.

In primo luogo la memoria deve essere presente e deve essere infinita: non devo preoccuparmi di esaurirla.

In secondo luogo devo avere un modo per dare un input arbitrariamente grande (diciamo un infinito numerabile di possibili input) ed ottenere un numero intero che codifichi il mio output.

Devo ovviamente poter scrivere e leggere a piacere nella mia memoria e devo poter decidere cosa scrivere in base a determinate condizioni.

Ora prendiamo un modello di computo più user-friendly della Macchina di Turing e del Lambda Calcolo: la macchina RASP.

Questa macchina è dotata di infiniti registri contenenti ciascuno un numero intero qualsiasi e ha un set di sole quattro istruzioni:

  • INC x: incrementa di un'unità il registro x.
  • DEC x: decrementa di un'unità il registro x.
  • JZ x z: se il contenuto del registro x è pari a zero salta all'istruzione z.
  • HALT: la computazione è terminata.

È stato dimostrato che questo modello è Turing completo (tranquilli, non vi farò subire una dimostrazione formale). In alcune formulazioni DEC è sostituita da ZERO (istruzione che azzera il contenuto di un registro) e HALT è sostituita da GOTO (come condizione di arresto si assume che la macchina si fermi automaticamente dopo aver eseguito l'ultima istruzione).

Ora se il nostro Template Engine contempla quelle quattro istruzioni o degli equivalenti di quelle quattro istruzioni possiamo tranquillamente affermare che sia equivalente alla macchina RASP e, in virtù della proprietà transitiva, sia equivalente alla Macchina di Turing (ammesso ovviamente che sia abbia a disposizione una memoria infinita, ma siamo nel campo della teoria per cui la memoria è infinita per definizione).

Per tutti i calcolatori, Batman!

Forse ci sarete già arrivati, ma un Template Engine che abbia gli IF e permetta di scrivere operazioni aritmetiche di somma e sottrazione può tranquillamente eseguire tre di quelle quattro istruzioni. Se rinunciamo all'istruzione HALT in favore del più amichevole "quando hai finito di processare il template hai finito la computazione" e organizziamo accuratamente i nostri IF avremo tutto quello che ci serve!

Quindi la maggior parte dei Template Engine avanzati SONO Turing-completi, a dispetto del fatto che spesso servono "solo" ad evitare allo sviluppatore di scrivere tonnellate di codice ripetitivo e noioso.

Meditate gente, meditate...

lunedì 16 marzo 2015

PiFS, e non preoccupiamoci più dello spazio di archiviazione!

Eccoci tornati con la nostra consueta rubrica riguardante la simpatia dei programmatori e le loro divertentissime trovate!
Cosa? Non esiste una rubrica del genere sul nostro blog? Beh, sarà proprio il caso di crearne una allora!

Partiamo dal principio, ossia dal nome che apre il titolo: PiFS. “Pi” è il noto Pi greco, quel numerino che ci si trova sempre in mezzo alle scatole e che quindi non dovrebbe aver bisogno di ulteriori presentazioni.
FS sta invece per FileSystem, quella parte del sistema operativo che permette di compiere operazioni di scrittura e lettura su qualunque dispositivo di memorizzazione (un hard disk, una chiavetta usb ecc ecc) in maniera trasparente all'utente. I nostri dati infatti non sono organizzati in cartelle, ma piuttosto scritti a casaccio, e non sempre in maniera contigua (ad esempio quel film da 4Gb che avete illegalmente scaricato sarà scritto “dove c'è spazio”, e se necessario spezzato più volte: difficile infatti trovare tutto quello spazio libero contiguo sul vostro hard disk!). Compito del filesystem quindi è, mentre navighiamo tra le cartelle del nostro pc e apriamo un file, far ruotare il disco di modo che la testina legga esattamente tutte le parti di quel file. Le cartelle sono semplicemente dei file speciali che contengono la lista dei file presenti al loro interno.
E questo condensa il what; l'how  è molto complesso e ve lo lascio cercare su google, se siete interessati.
Trovo piuttosto affascinante che all'utente tutto ciò sia nascosto, non è fantastico?
Ma bando ai sentimentalismi, andiamo avanti!
Cerchiamo di capire perché asserisco che grazie a PiFS potremo scordarci dello spazio di archiviazione. Esiste una congettura, per ora mai provata ma nemmeno smentita, che afferma che Pi sia un numero normale; cosa sia un numero normale, lo lascio alla chiara definizione data da wikipedia:
a number of infinite length is called normal when all possible sequences of digits (of any given length) appear equally often.
Il fatto che sia normale, implica anche che sia una sequenza disgiuntiva, ossia una sequenza infinita di cifre all'interno della quale compare ogni altra possibile finita sequenza. Proviamo a ragionare un po'…se Pi contiene ogni finita sequenza di numeri...allora, scrivendolo ovviamente in binario, esso conterrà anche ogni dato di tutto ciò che è stato, è, e sarà!
Detto in un'altra maniera: tutti i possibili file, da questo che sto scrivendo ora, a quel file che avete cancellato per errore anni fa, alla vostra tesi di laurea salvata prontamente sul vostro PC, e pure quel progetto di software che avete in mente di scrivere, tutto ciò è già presente in Pi!!
E da qui l'idea di PiFS: celebriamo la grandezza di questo numero, prostriamoci d'innanzi alla sua infinita potenza, e creiamo un FS che sfrutti questa sua proprietà!
L'idea dello sviluppatore è stata quindi quella di creare un filesystem che semplicemente cerchi byte per byte di ciascun file (per questioni di performance non cerca la sequenza del file intero, ma lo spezza in “sotto-file” di un byte) dove inizia la sequenza di quel dato byte di quel dato file, e segni quest'indice come metadato sullo spazio di archiviazione (che comunque è necessario).
Beh...che dire? Geniale!!! Abbiamo sconfitto la fame nel mondo il problema dello spazio di archiviazione!

Ma c'è un enorme limite, anzi due: le performance sono scarsissime e soprattutto non ci è dato sapere quanto tempo ci vorrà a cercare la sequenza corrispondente (in scrittura), potrebbero volerci giorni anche sui pc più potenti. Vale lo stesso problema anche in lettura: pur avendo l'indice dal quale la sequenza corrispondente all'inizio dell' i-esimo byte del file, dobbiamo comunque scorrere Pi fino a quell'indice (e quindi il tempo di accesso può essere molto lungo); inoltre, se spezzare i file in sotto-file da 1 byte ci permette in scrittura di essere “più efficienti”, in lettura si è penalizzati dal fatto che si avranno molti indici da cercare in fila per leggere un file interamente.
L'altro problema, e qua arriva la trollata finale, è che i metadati riguardanti gli indici generati da PiFS, hanno in media dimensione maggiore rispetto ai file stessi!
Insomma, non solo le operazioni di lettura e scrittura sarebbero lentissime, in più alla fine sprecheremmo più spazio di quanto se ne utilizzi ora!
Ovviamente lo sviluppatore è consapevole di questa contraddizione, infatti il filesystem è nato come scherzo che, devo essere sincero, gli è proprio ben riuscito! Mi ha strappato più di qualche risata, oltre a lasciarmi a bocca aperta per la genialità ovviamente!
Lascio il link al github del genio: https://github.com/philipl/pifs.

Gloria, gloria al nostro Pi!
Al prossimo numero di questa nostra splendida rubr... no, non sbattetemi fuori! Nooooooooooooo!

lunedì 2 marzo 2015

GHOST: un fantasma nelle glibc

Torniamo a discutere di sicurezza informatica e torniamo a parlare di bug in librerie che non dovrebbero contenerne. Spirito polemico a parte, il 2015 si è aperto in bellezza con la vulnerabilità di sicurezza etichettata CVE-2015-0235 e nota al pubblico come GHOST.

Cos'ha di speciale questa vulnerabilità? In primo luogo riguarda l'equivalente GNU/Linux di uno dei componenti base di ogni sistema *NIX: la libreria standard del C. Per quanto bello ed interessante il pippone sulla storia di UNIX e del linguaggio C è già stato oggetto di numerosi articoli e discussioni: i più pigri possono risparmiarsi una ricerca su Google e leggere l'articolo di Wikipedia su UNIX.

Se non siete stati risucchiati dal gorgo degli articoli correlati e ce l'avete fatta a tornare tra noi ora sapete che, senza la libreria standard del C, scrivere un qualsiasi programma diventa un compito per guru del linguaggio assembly. Per non parlare della possibilità di avere codice (più o meno) portabile...

Faccio anche notare che il kernel Linux ha una sua versione ridotta della libreria standard del C (la klibc) data la necessità di massima indipendenza del kernel da librerie esterne (e anche qui ce ne sarebbe da discutere, ma mi sono imposto di ridurre le divagazioni al minimo).

Appurato che la libreria standard del C è importante per lo userland tanto quanto lo sono il kernel e le syscall che questo espone, vediamo di capire più in dettaglio cosa è andato storto stavolta e quali implicazioni il bug si porta dietro.

Il Diavolo nei dettagli

Se avete cliccato sul link in cima all'articolo sarete arrivati ad un file di testo scritto in informatichese stretto che spiega con squisito dettaglio quali sono le cause e quali gli effetti del bug, oltre a dare un piccolo programma in C che (una volta compilato) servirà a testare i propri computer.

Potrei cercare di tradurre alla meglio l'articolo in questione e dichiarare festa finita, ma chi mi legge abitualmente sa che non è mia consuetudine scrivere simili articoli.

Cominciamo quindi la nostra maratona di brutalità informatica per iniziati! :-D

La colpevole è una funzione interna delle glibc (ovvero una funzione che non è possibile chiamare dall'esterno ma che viene chiamata da altre funzioni delle glibc): __nss_hostname_digits_dots

Leggendo questa funzione, mi è subito sembrato chiaro che chi l'ha scritta non si fidasse del compilatore: invece di creare una struttura con 4 puntatori ed allocare un buffer per contenerne l'input, ha deciso di allocare un unico buffer e di calcolare a priori lo spazio per contenere 2 puntatori e il testo di input, dimenticando per strada un puntatore.

"Ma così si risparmia spazio!". Certo, se devi far girare il tuo codice su un microcontrollore con 16 kilobyte di RAM lo spazio è vitale. Ma qui si parla di PC che hanno memoria a strafottere e risparmiare lo spazio per un puntatore compromettendo la leggibilità del codice è una falsa economia.

Qual è il risultato di quel puntatore dimenticato? Che si può andare a scrivere roba oltre i limiti del buffer (buffer overflow) e ciò è MALE.

Per chi fosse curioso di scoprire quanto può essere dannoso un buffer overflow consiglio caldamente la lettura di Smashing The Stack For Fun and Profit storico articolo di Aleph One pubblicato su Phrack 49 nel lontano 1996. Per chi non avesse tempo/voglia di leggere l'articolo la versione TL:DR si riduce a "un valore sbagliato nel posto giusto e il mio computer è alla mercé di chi ha scritto quel valore in quel posto".

Vista la natura così pericolosa dei buffer overflow gli sviluppatori di compilatori, linguaggi di programmazione e sistemi operativi hanno passato gli ultimi 20 anni a studiare tecniche atte a mitigare le conseguenze degli errori di programmazione. La letteratura in merito è ampia e variegata e le tecniche più quotate al momento in cui scrivo sono tre:

  1. Canarini nello stack e/o bound checks a runtime.
  2. NX bit (reale o emulato).
  3. Address Space Layout Randomization (ASLR).

Spero che mi perdonerete la brevità ma per capire come hanno fatto ad aggirare questi meccanismi occorre prima capire come questi operano.

Canarini e bound checks

I canarini devono il loro nome a quelli utilizzati nelle miniere per rilevare le fughe di gas tossici nelle gallerie: se il canarino sveniva o moriva l'aria era tossica anche per i minatori.

I canarini nello stack sono porzioni di dati randomizzati inserite dopo il buffer. La procedura di chiamata delle funzioni si complica un po' perché occorre verificare il canarino in fase di uscita dalla funzione se il canarino è stato modificato in qualche modo la routine di uscita dalla funzione interrompe l'esecuzione e uccide il programma. Questo comportamento è giustificato dall'idea che è preferibile una caduta di servizio che una compromissione del computer.

Il valore del canarino di solito è inserito in una posizione particolare di memoria circondata da aree di memoria non valida che causa un segmentation fault nel caso in cui un attaccante cerchi di leggere sequenzialmente la memoria per cercare il canarino.

I canarini richiedono un cambiamento del compilatore e spezzano la compatibilità binaria perché modificano l'ABI (Application Binary Interface) dei programmi e delle librerie.

Un altro metodo per aumentare la sicurezza consiste nel bound checking (controllo dei limiti). Quando la dimensione del buffer è nota durante la compilazione (cioè scritta direttamente nel codice o definita da una costante nota al compilatore) il controllo è facile da implementare automaticamente. Quando la dimensione del buffer non può essere nota a priori il compilatore deve aggiungere del codice che tenga traccia delle dimensioni di tutti i buffer allocati dinamicamente, così da consentire il controllo dei limiti.

La suite di compilatori GCC supporta i canarini, ma non li abilita di default. Per abilitarli occorre aggiungere il flag -fstack-protector (o -fstack-protector-all per proteggere tutte le chiamate a funzione).

Per maggiori informazioni potete consultare la pagina di Wikipedia sulla Buffer Overflow Protection.

NX bit

Il bit NX è una caratteristica di alcune architetture (AMD64/x86_64, Alpha, UltraSPARC) che consente di segnare una pagina di memoria come non eseguibile.

Se la CPU prova ad eseguire istruzioni presenti in una pagina marcata dal bit NX un interrupt hardware viene eseguito e il sistema operativo può interrompere il processo in corso o (se il problema avviene in kernel space) lanciare un kernel panic.

A differenza dei canarini, la protezione data dal bit NX non incide sulle performance dei programmi ma richiede, oltre al supporto da parte dell'hardware, che il compilatore indichi nei file di output quali sono le aree di memoria eseguibili e le consolidi in modo da facilitare il lavoro del loader (la porzione del sistema operativo che si occupa di caricare in memoria i programmi). Inoltre il compilatore non deve emettere codice che dipenda da uno stack o da uno heap eseguibile altrimenti quel codice farà scattare la protezione.

Nelle architetture in cui il bit NX non è presente (le più rilevanti delle quali sono x86 e ARM) si possono utilizzare dei meccanismi alternativi come il SEGMEXEC di PaX che sfrutta il registro Code Segment e gli interrupt di page fault per emulare il bit NX al prezzo di dimezzare la memoria allocabile disponibile per i programmi.

Ovviamente questa emulazione richiede una modifica rispetto al kernel standard ("vanilla"). Solo alcune distro hanno deciso di applicare queste patch, a causa delle ripercussioni sul resto del sistema (minori performance e, in alcuni casi, programmi che smettono di funzionare). Il kernel Linux vanilla per x86_64 (e per alcuni processori x86 che lo supportano) sfrutta il bit NX già dalla versione 2.6.8.

Address Space Layout Randomization (ASLR)

Veniamo ora al più complicato (e secondo alcuni più efficace) mezzo di protezione della memoria: la randomizzazione dello schema di disposizione di dati e istruzioni nella memoria.

Per prima cosa mi scuso per l'orribile termine "randomizzazione" ma non ho trovato un equivalente meno brutto ma altrettanto calzante.

Per seconda cosa sappiate che se non avete ancora letto "Smashing The Stack For Fun and Profit" siete delle brutte persone che non capiranno molto di quello che seguirà.

L'ASLR consiste in una serie di accorgimenti atti a rendere la vita difficile a chi cerca di sfruttare i buffer overflow. Ma quali buffer overflow?

La tecnica canonica è lo stack overflow: una scrittura di dati che eccedono in quantità la dimensione di un buffer allocato nello stack e che vanno a sovrascrivere l'indirizzo di ritorno della funzione con un valore arbitrario. Gli effetti variano dal crash dell'applicazione fino all'esecuzione di una shell con i privilegi dell'utente che ha lanciato l'applicazione.

Contro gli stack overflow si può adottare una tecnica che consiste nel creare uno spazio vuoto (gap) tra l'indirizzo di ritorno e i parametri della funzione. Ovviamente se questo spazio vuoto fosse fisso sarebbe facile compensare per cui il gap dev'essere casuale. Uniamo questo trucco ad un canarino piazzato prima dell'indirizzo di ritorno e abbiamo già cominciato a creare una bella gatta da pelare per chi scrive malware.

La randomizzazione però non si ferma qui: c'è l'intera categoria degli heap overflow che, pur essendo più complessi da sfruttare degli stack overflow, rappresentano una bella fetta delle minacce a cui si va incontro.

Come facciamo a proteggerci dagli heap overflow? Randomizzando la memoria ulteriormente. Per capire come dobbiamo prima capire cos'è uno heap overflow e come si può sfruttare.

Heap Overflows

Se avete tempo trovate un'eccellente spiegazione di come sfruttare un overflow dello heap su Hackers Hut e aggiungo anche l'ottimo (seppur datato) articolo di Michel "MaXX" Kaempf su Phrack 57 che spiega in dettaglio il funzionamento dell'allocazione della memoria nelle glibc. La lettura è decisamente impegnativa e richiede alcune conoscenze approfondite del funzionamento del kernel Linux, del linguaggio C e un'infarinatura su come funziona un linker dinamico per eseguibili in formato ELF.

La versione TL:DR è la seguente: se siete sopravvissuti ad un corso di C saprete già che malloc riserva un'area di memoria nello heap mentre free la libera. Quello che fa la versione di malloc implementata nelle glibc è chiedere al kernel di riservare un po' di memoria e, se tutto va bene, usa parte di quella memoria per scrivere la seguente struttura dati:

struct malloc_chunk {

  INTERNAL_SIZE_T      prev_size;  /*Size of previous chunk (if free).*/
  INTERNAL_SIZE_T      size;       /*Size in bytes, including overhead.*/

  struct malloc_chunk* fd;         /*double links -- used only if free.*/
  struct malloc_chunk* bk;

  /*Only used for large blocks: pointer to next larger size.*/
  struct malloc_chunk* fd_nextsize; /*double links -- used only if free.*/
  struct malloc_chunk* bk_nextsize;
};

Sempre se avete completato il corso di C, saprete che quella struct, al netto degli ultimi due campi (che ignoreremo), è una lista concatenata doppia. Per chi non avesse seguito con attenzione: la lista concatenata doppia è una struttura dati dinamica i cui elementi puntano tutti all'elemento precedente e all'elemento successivo nella lista. Quella struttura dati è fondamentale per il funzionamento di free e per il riciclo della memoria allocata da parte di malloc perché consente di sapere dove si trovano e quanto grandi sono le aree già riservate ma non più utilizzate della memoria.

Tutto molto bello, ma che implicazioni ha? Quando si fa una chiamata a free questa funzione prende il puntatore, ricava da esso la posizione della malloc_chunk e la utilizza per consolidare la memoria liberata in un chunk più grande usando il puntatore bk per trovare il chunk precedente e quindi riaggiustando i puntatori fd e bk.

Ma come fa a sapere se il chunk precedente è libero o è in uso? Con un astuto trucchetto che sfrutta l'allineamento dei dati nella memoria. Per ragioni prestazionali è sempre bene che la memoria sia allineata secondo la dimensione della parola utilizzata internamente dall'harware: 32 bit (o 4 byte) per le architetture a 32 bit e 64 bit (8 byte) per quelle a 64 bit. Nulla vieta di allineare la memoria secondo i multipli delle parole, per cui un allineamento a 8 byte va bene sia per x86 (32 bit) che per AMD64 (64 bit).

Siccome il campo size indica la dimensione in bytes se imponiamo l'allineamento a 8 byte i due bit meno significativi saranno sempre pari a 0 (8 si scrive 100 in binario). Quei due bit sono utilizzati come flag per segnalare se il chunk di memoria è stato ottenuto con mmap (e quindi richiede di essere liberato con munmap) e se il precedente chunk è in uso o meno.

Se il precedente chunk non è in uso allora posso utilizzare il campo prev_size per raggiungere la struttura malloc_chunk ed utilizzare i dati ivi contenuti per aggiornare i campi della mia malloc_chunk e consolidare la memoria liberata.

Se il chunk precendente è in uso posso usare il campo size per saltare al campo successivo e quindi al successivo ancora e controllare se il mio vicino è in uso e, se non lo è, consolidare lo spazio libero in avanti.

Confusi? Forse un schema aiuterà la comprensione:

Nel grafico i nodi A B e C rappresentano dei chunk di memoria contigui. Mentre nodi A e C sono liberi il nodo B è un chuck in uso che è stato selezionato per essere liberato con free.

B sa che A non è in uso grazie al bit meno significativo del suo campo size e sa dove si trova grazie al suo campo prev_size. Va a guardare A e scopre la posizione di C grazie al puntatore fd di A che indica la posizione di C.

free a questo punto può consolidare lo spazio libero fondendo insieme i chunk A, B e C in un unico chunk la cui dimensione è pari alla somma delle dimensioni dei tre chunk. Nel fare questo free deve aggiornare il valore del puntatore fd di A facendolo puntare a fd di C oltre ovviamente ad aggiornare il campo size di A.

Tutto molto bello, ma in concreto cosa significa? Significa che, se non vengono fatti controlli adeguati, possiamo utilizzare i campi della malloc_chunk per scrivere valori arbitrari in indirizzi di memoria scelti da noi. Se avete letto "Smashing The Stack For Fun and Profit" (non smetterò mai di ripeterlo per cui leggetelo!) sapete già cosa significa, se non l'avete fatto: "Sciagura a voi!".

Address Space Layout Randomization (secondo giro)

Ovvero: "Come faccio ad evitare che un errore di programmazione possa causare la sistematica compromissione delle macchine che si suppone siano sotto il mio controllo?".

Prima di tutto: se siete arrivati fin qui vi faccio i miei complimenti, condensare 20 anni di ricerca sulla sicurezza informatica in queste poche righe è stato un compito arduo ed il testo che ne risulta è inevitabilmente frammentario e pesante da digerire.

Un ultimo sforzo e poi vi giuro che vi spiegherò come si fa a capire in cosa consiste GHOST.

Se avete seguito lo sproloquio sugli heap overflow (o se l'avete saltato perché sapevate già tutto) ora sapete che un mancato controllo su quanto viene scritto in memoria può causare grossi guai. I gap di dimensione casuale sono poco utili contro gli heap overflow e quindi che fare?

Una delle regole della strategia militare recita: "Se non puoi affrontarli direttamente aggirali!". Invece di cercare di prevenire futilmente una sovrascrittura della struttura malloc_chunk facciamo sì che l'attaccante non possa sapere a priori dove si trovano le porzioni di codice che vuole richiamare spargendo casualmente le parti eseguibili di programmi e librerie nello spazio di memoria associato al processo (pur tenendo vicini i moduli funzionali che le compongono).

Da una trentina d'anni i programmi sono compilati in modo da collegarsi a runtime alle librerie di cui hanno bisogno, tutto quello che dobbiamo fare è rendere il processo di linking non deterministico inserendo le librerie e il codice del programma in locazioni di memoria casuali ad ogni avvio del programma. Non solo! Possiamo addirittura fare in modo che ogni nuovo processo creato abbia una disposizione differente rispetto al processo padre che l'ha generato (per maggiori informazioni leggete la pagina di Wikipedia dedicata alla chiamata di sistema fork).

Così facendo complichiamo parecchio la vita a chi scrive malware: ora devono attivamente andare a cercare dove si trova il codice che intendevano eseguire.

Sfruttare GHOST e exim per bucare una macchina

Finalmente dopo tanto sproloquiare arriviamo al succo del discorso. Come hanno fatto i ricercatori di Qualsys a superare lo stack protector, il bit NX e l'ASLR?

Primo punto: la vulnerabilità si attiva solo in gethostbyname e solo per indirizzi IPv4.

Secondo punto: perché si possa sfruttare questa vulnerabilità occorre che il programma chiami gethostbyname direttamente senza prima chiamare inet_aton. Molti programmi provano prima a vedere se la stringa contiene un indirizzo IP e inet_aton fa questo controllo e ritorna automaticamente un int che contiene il valore dell'indirizzo IP come sarà utilizzato internamente dallo stack TCP/IP. gethostbyname chiama internamente inet_aton per verificare che non si tratti di un nome host e quindi la stringa che passiamo DEVE passare il controllo di inet_aton.

Per passare questo controllo occorre che si verifichino le seguenti condizioni:

  • La stringa deve contenere solamente cifre decimali o punti.
  • La stringa deve cominciare con una cifra.
  • L'ultimo carattere non può essere un punto.

Ovviamente la stringa deve anche essere abbastanza lunga da causare l'overflow del buffer.

Going de[er]per

Punti bonus a chi ha riconosciuto il gioco di parole realizzato con una regex (sì, non ho una vita sociale degna di nota).

Abbiamo i prerequisiti per far scattare la nostra trappola, ma come dobbiamo metterli assieme per avere successo? Analizziamo ulteriormente il codice vulnerabile alla riga 157:

resbuf->h_name = strcpy (hostname, name);

La cara buona vecchia strcpy, anche nota come "fottitene dei limiti e copia tutto quello che puoi"! In questo caso copia in hostname tutto quello che trova tra name e il primo carattere NUL (Valore ASCII: 0) che incontra. E siccome name lo forniamo noi... :-D

Se ancora ricordate quanto scritto all'inizio sapete che lo spazio allocato è sufficiente per 2 puntatori e per la stringa contenuta in name ma il codice di __nss_hostname_digits_dots (funzione di help chiamata da gethostbyname e sede della vulnerabilità) ha bisogno di 3 puntatori e dello spazio per la stringa name. Quindi possiamo sovrascrivere il numero di bit corrispondenti ad un puntatore: 32 se siamo su x86 e 64 se siamo su AMD64.

La domanda successiva è: quanto danno possiamo fare con i bit a nostra disposizione?

Riscrivere la grandezza di un chunk con un valore maggiore e, quando il programma richiederà altra memoria, potremo riscrivere altre parti della memoria che non avremmo dovuto riscrivere.

Exim o non exim?

Arrivati a questo punto dell'articolo vi aspettate una lunga disquisizione tecnica su come si faccia a bypassare le difese. Non ci sarà. Primo perché questo articolo sta già raggiungendo una considerevole lunghezza, secondo perché i dettagli sono spiegati nell'advisory e presto l'exploit sarà aggiunto a Metasploit pronto per l'uso degli script kiddies, terzo perché a questo punto all'autore interessa di più portare il lettore ad alcune conclusioni.

Sopprimete la vostra delusione e cercate di seguirmi per questi ultimi paragrafi.

L'exploit di exim tramite GHOST è particolarmente brutto perché exim stesso adotta due pratiche che si stanno rivelando sempre più pericolose:

  1. exim ha un allocatore interno della memoria. Se questo non vi fa suonare un campanello di allarme posso permettermi di ricordare una certa libreria dedicata all'implementazione delle specifiche SSL e TLS e un suo certo bug.
  2. exim ha la possibilità di lanciare comandi arbitrari tramite una modifica del file di configurazione, file che viene copiato nello heap.

Ovviamente non possiamo dare tutta la colpa agli sviluppatori di exim: quando hanno operato le loro scelte gestire in proprio l'allocazione della memoria era una buona idea per migliorare le prestazioni, mentre la possibilità di configurare il server di posta in modo che possa lanciare comandi arbitrari in base a certe condizioni aumenta di molto la flessibilità del sistema. Inoltre mappare il file di configurazione in memoria consente un accesso più veloce al medesimo: tutte ragioni legittime insomma ma, come già scritto, il Diavolo è nei dettagli.

Spero che, dopo aver letto questo articolo, siate giunti alla mie medesime conclusioni:

  • Programmare in C vuol dire essere coscienti di quello che si fa e curare maniacalmente il proprio lavoro.
  • La memoria che contiene i dati in lettura/scrittura dovrebbe essere marcata come non eseguibile e la memoria eseguibile non dovrebbe essere scrivibile.
  • Si può scrivere codice intelligente (semplice da capire e ben documentato) oppure codice furbo (difficile da capire perché pieno di trucchi per migliorare le prestazioni).

Il problema nelle glibc è stato risolto, ma ci sono voluti anni prima che fosse scoperto e corretto. Non stiamo parlando di un'oscura libreria piena di codice crittografico che richiede un Dottorato in Matematica con specializzazione in Teoria dei Numeri, stiamo parlando di una libreria fondamentale che ha decine di sviluppatori e centinaia di occhi che la scrutano.

Simili problemi non dovrebbero esserci perché minano alla base la fiducia di chi basa sistemi critici su quel codice.

Se è vero che "Dato un numero sufficiente di occhi, tutti i bug vengono a galla («given enough eyeballs, all bugs are shallow»)" (legge di Linus citata da Eric Steven Raymond nel saggio La cattedrale e il bazaar - fonte Wikipedia) è anche vero che le glibc non godono di molti occhi rispetto ad altri progetti (come il kernel Linux).

Purtroppo GNU/Linux non è più un giocattolo per appassionati ed entusiasti ma uno strumento da cui dipendono numerose infrastrutture di rete in tutto il Mondo.

venerdì 9 gennaio 2015

Su srand() e OpenBSD

Salve a tutti i miei quattro lettori. In questo che è il mio primo articolo del 2015 tratterò di una questione apparentemente marginale ma che invece ha numerosi risvolti pratici.

Abbiamo visto in uno degli articoli precedenti ("Sui vizi e le virtù di /dev/random") che un buon generatore di numeri pseudocasuali (PRNG: Pseudo Random Number Generator) sia un componente essenziale per la sicurezza di un sistema informatico (leggetevi l'articolo per maggiori informazioni... Vi prego!) e che /dev/random in generale non è la soluzione ottimale. Ma allora cosa dovremmo usare?

L'esempio classico che viene dato nei libri sul linguaggio C e nei tutorial su internet in merito a generazione di numeri pseudocasuali è il seguente:

#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    /*Seed the RNG*/
    srand(time(NULL));

    puts("'s' followed by 'Enter' to start the RNG. Ctrl+C to exit\n");

    while(fgetc(stdin) != 's')
    {
        usleep(100000); /*Sleep for 0.1 seconds*/
    }

    while(1)
    {
        int r = rand(); /*Get random number*/
        printf("%d\n", r);
        usleep(100000);
    }

    return 0;

}

Questo esempio canonico inizializza il generatore pseudocausale con lo UNIX EPOCH corrente e poi stampa numeri tra 0 e RAND_MAX. Come da manuale insomma.

Dove sta il problema con questo codice?

Se andiamo a leggere il manuale di srand scopriamo che:

The srand() function sets its argument as the seed for a  new
sequence of  pseudo-random  integers  to be returned by rand().
These sequences are repeatable by calling srand() with the same seed
value.

In Italiano per non addetti ai lavori: se io passo a srand lo stesso numero due volte di seguito otterrò la stessa sequenza pseudocasuale di numeri. In altre parole la coppia srand/rand è DETERMINISTICA e gli standard C89 e POSIX impongono che sia così.

Si, Virginia, tutto ciò è Male e i due lettori che hanno letto l'articolo su /dev/random sanno anche perché (vi ho già pregato di leggere quell'articolo, vero?). Per coloro i quali fossero stati disattenti la risposta è insita nelle caratteristiche richieste ad un buon generatore di numeri pseudocasuali:

1. Che non ripeta la sequenza generata troppo presto (idealmente pescando centomila numeri interi a 64 bit al secondo il tempo in cui la sequenza comici a ripetersi dovrebbe eccedere i 5 milioni di anni).

2. Che renda difficile predire il numero successivo basandosi sui numeri già usciti (dove per "difficile" intendiamo: "data una sequenza lunga N indovinare il numero alla posizione N+1 deve richiedere più di 2M - N operazioni dove M è il numero di bit dei numeri interi generati". Idealmente la lunghezza N dovrebbe essere ininfluente e quindi ogni volta occorrerebbe effettuare 2M operazioni).

Ma tutto questo cosa c'entra con srand e rand ? C'entra nel momento in cui qualcuno si mette ad usare codice simile a quello dell'esempio riportato innanzi per compiti che richiederebbero un vero e proprio PRNG. Come è scritto nel manuale dare lo stesso seme a srand produce la medesima sequenza, quindi è facile intuire che, con un po' di prove ed errori è possibile stimare l'ora in cui un servizio è stato avviato e quandi il momento in cui è stata fatta la chiamata a srand (che non è thread-safe e quindi va fatta una volta sola oppure va racchiusa tra due global-lock). A quel punto ricavare la sequenza generata, per quanto difficile, non è impossibile e da lì il passo per fare danni è breve. Se aggiungiamo poi che in certi casi esiste la possibilità di far crshare un servizio e obbligarlo a riavviarsi si intuisce subito che un attaccante può ridurre al minimo l'incertezza sul momento del seed e quindi massimizzare le possibilità di riuscita dell'attacco.

Ok, è una cosa da paranoici, ma è possibile e se è possibile è probabile che qualcuno stia provando a sfruttare questa feature per i suoi loschi fini. In fin dei conti nessuno pensava che OpenSSL potesse divulgare informazioni in giro prima di Heartbleed.

E questo ci porta a vedere cosa hanno deciso di fare quei paranoici di OpenBSD; hanno rotto la compatibilità con POSIX e sostituito le chiamate a rand con chiamate al loro PRNG (una trattazione esaustiva di detto generatore esula dagli scopi di questo articolo, ma invito i più curiosi ad informarsi dando un'occhiata alla presentazione che Theo de Raadt ha presentato allo EuroBSD Con del 2014 ). Per mantenere il vecchio comportamento occorre chiamare esplicitamente la nuova funzione srand_deterministic invece di srand.

L'idea di spezzare la compatibilità è derivata da un'analisi del codice dei programmi di terze parti inclusi nei ports: la maggiorparte di essi usa rand come una genuina sorgente di numeri pseudocasuali (o crede di farlo) e solo pochissimi software assumono il comportamento deterministico di srand e molti solo a scopo di test.

Quindi come misura ulteriore di sicurezza hanno deciso di rompere la compatibilità e di dare un migliore PRNG a quanti utilizzano srand su OpenBSD.

Se volete saperne di più:

E con questo è tutto! Aloha!

venerdì 19 dicembre 2014

L'asteroide che ucciderà questo dinosauro deve ancora arrivare (seconda parte)

L'articolo è diviso in tre parti:
Prima Parte
Terza Parte

La volta scorsa abbiamo visto un po' di automi e le basi delle espressioni regolari (ed abbiamo intuito una relazione tra i due). Questa volta faremo un po' meno teoria e un po' più pratica: vi illustrerò i metodi che adopero per leggere e scrivere le regex.

Leggere le espressioni regolari

Il primo impatto con le regex è (quasi) sempre traumatico, specialmente se nessuno vi ha mai spiegato prima che quelle sono istruzioni per il computer e non usi creativi della punteggiatura ("ma che c[a-z]{0,} !!?").

Anche sapendo che il punto ha un significato, il più un altro e l'asterisco un altro significato ancora è facile confondersi e sbagliare.

Siccome in Rete si possono trovare numerosi esempi di espressioni regolari che eseguono i compiti più disparati molti si limitano a fare copia & incolla senza fermarsi a capire cosa effettivamente facciano quegli scampoli di lettere e caratteri apparentemente causali.

Alla luce di ciò mi pare giusto che, prima di andare avanti, vi spieghi come si leggono le regex. Specialmente perché potreste ritrovarvi a dover modificare un'espressione scritta da qualcun altro molto tempo fa (anche il vostro ego di 8 mesi fa conta come "qualcun altro").

Siccome scriverò parecchie regex e siccome le scriverò in mezzo al testo dell'articolo userò la seguente convenzione: le regex saranno scritte in monospace e racchiuse tra singoli slash (/). Gli slash NON vanno interpretati come parte della regex ma solo come delimitatori. Questa convenzione è adottata sia dal linguaggio AWK che dal perl e da sed (un tool che vedremo nel prossimo articolo).

La prima regola è: comportarsi come la macchina.

Le espressioni regolari si leggono un carattere alla volta da sinistra a destra. Non provate a saltare dei pezzi o a fare assunzioni, perché la macchina NON lo fa.

La seconda regola è: puntare sempre a riconoscere il più possibile.

Facciamo un esempio con questo testo di input:

Vuolsi così colà dove si puote ciò che si vuole e più non dimandare.
L'espressione regolare /co.*/ applicata al testo precedente ritornerà come stringa trovata la seguente porzione:

così colà dove si puote ciò che si vuole e più non dimandare.
Siccome il . significa "un carattere qualsiasi" e la stella (*) significa "zero o più" quell'espressione si legge come "co seguito da zero o più caratteri qualsiasi". Non essendoci un limite superiore il match prende il primo co, quello di così, e copia in uscita tutto quello che trova fino a raggiungere la fine del testo.

Se invece avessimo usato l'espressione /co../? Avremmo avuto qualcosa del tipo "co seguito da un carattere qualsiasi seguito a sua volta da un altro carattere qualsiasi. Il risultato sarebbe stato solamente così.

Il che mi permette di introdurre la terza regola: salvo indicazione contraria fermati al primo risultato corretto che trovi.

Questa regola è importante quando ci si trova davanti ad una espressione regolare che, pur essendo corretta, non riconosce tutto quello che ci interessa riconoscere. La colpa spesso non è della regex, ma delle opzioni che abbiamo passato al programma che la interpreta.

La seconda regola invece spiega perché certe espressioni regolari funzionano su certi input ma riconoscono anche input che non si voleva riconoscere: di solito perché si è stati troppo generosi nell'uso di ., * e +.

Armati di queste nuove conoscenze vediamo di capire cosa riconosce la seguente espressione:

/[0-9]*\.[0-9]+([eE][-+]?[0-9]+)?/

Piuttosto complessa, vero? Ma adesso sappiamo come procedere a leggerla (si spera).

Il primo carattere è una parentesi quadra aperta e quindi quello che segue è la definizione di una classe di caratteri, segue uno 0 che ci dice che la classe contiene quel carattere. Il - ci dice che stiamo componendo un range di caratteri e il 9 è il carattere di chiusura del range. La parentesi quadra chiusa termina la definizione della classe.

Riepilogando: crea una classe composta da tutti i caratteri da 0 a 9. Segue un * che ci dice "zero o più di quello che mi precede", quindi adesso sappiamo che la stringa cercata può cominciare con una o più cifre decimali.

Il carattere successivo è un backslash (\) che ci informa che il prossimo carattere va interpretato diversamente da come lo si interpreta di solito; infatti il punto che segue normalmente vorrebbe dire "un carattere qualsiasi", ma in questo caso significa "un punto". Quindi lo interpretiamo come un punto letterale.

Segue una replica della classe che abbiamo definito all'inizio seguita a sua volta da un +. Questo si legge come "una o più cifre decimali".

La parentesi tonda aperta ci informa che stiamo definendo un gruppo e la parentesi quadra aperta che la segue ci dice che stiamo definendo un'altra classe che contiene una e ed una E. La classe ci dice che il gruppo comincia con una lettera "e" minuscola oppure maiuscola. La classe che la segue ci mostra che il - inserito all'inizio di una classe non viene considerato come un separatore di range ma come un carattere letterale. L'espressione /[-+]/ significa quindi "un meno oppure un più", seguita da un punto di domanda (come in questo caso) diventa: "può esserci un meno oppure un più, ma può anche non esserci nulla".

Segue la classe delle cifre con un più che adesso sappiamo vuol dire "una o più cifre decimali", quindi arriva la parentesi tonda che chiude il gruppo e un punto interrogativo.

Il punto di domanda è un quantificatore che si riferisce al gruppo e quindi il gruppo che abbiamo appena definito può essere presente in toto una volta oppure non esserci affatto.

L'espressione nel suo complesso si legge: "Un punto preceduto eventualmente da alcune cifre decimali e seguito da almeno una cifra decimale e da un gruppo opzionale di caratteri che comincia con una 'e' o una 'E' seguita da un'eventuale indicazione di segno seguita da una o più cifre decimali". In breve si tratta di un qualsiasi numero decimale in virgola mobile positivo con eventuale indicazione dell'esponente.

Scrivere un'espressione regolare

A quanti non hanno ancora desistito e stanno proseguendo la lettura va il mio più sentito ringraziamento. Per premiarvi di tanta dedizione adesso vi confiderò il metodo che adopero per scrivere una regex (nella segreta speranza che vi sarà utile un giorno).

Per rendere le cose più facili (o più difficili) espanderemo l'espressione vista nella sezione precedente, che riporto di seguito per ridurre l'usura delle rotelline dei mouse:

/[0-9]*\.[0-9]+([eE][-+]?[0-9]+)?/

Questa espressione ha due difetti:

  • Non riconosce i numeri decimali negativi nè quelli positivi preceduti da un più (+).
  • Non riconosce i numeri decimali come 1. oppure 47.
Se volessimo riconoscere tutti i numeri decimali in virgola mobile quell'espressione mancherebbe diversi bersagli.

La prima cosa che faremo sarà aggiungere il segno. Sappiamo che il segno può essere un - oppure un + oppure nulla e sappiamo che se è presente si trova al primo posto, prima cioè di qualsiasi altro carattere che compone un numero decimale in virgola mobile.

Dalla nostra analisi dell'espressione abbiamo già trovato l'espressione che riconosce il segno: /[-+]?/. Ci limiteremo ad inserirla nel posto giusto:

/[-+]?[0-9]*\.[0-9]+([eE][-+]?[0-9]+)?/

Risolto il nostro primo problema dobbiamo cercare una maniera per risolvere il secondo problema.

La prima cosa che si deve fare quando si crea una regex è cercare un schema generale che si ripeta. Nel caso dei numeri decimali con il punto alla fine lo schema è proprio il punto alla fine: deve esserci sempre, altrimenti non stiamo leggendo un numero decimale in virgola mobile.

Quindi il punto dev'esserci e deve stare alla fine, ma davanti cosa ci va? Una o più cifre decimali eventualmente precedute dal segno.

Sappiamo che il simbolo + indica che quello che lo precede dev'essere presente "una o più volte" e che il simbolo ? significa che ciò che lo precede dev'essere presente zero oppure una volta ma non di più.

Quindi l'espressione per cercare i numeri decimali in virgola mobile composti da un numero seguito da un punto è la seguente:

/[-+]?[0-9]+\./

Abbiamo usato due classi, due quantificatori e un simbolo letterale per trovare tutti e soli i numeri simili a 47.. L'inizio di questa espressione somiglia moltissimo all'inizio dell'espressione precedente e potremmo essere tentati di sostituire la stella (* che significa "zero o più senza limite superiore") con il più (che significa "uno o più senza limite superiore"). Ricaveremmo la seguente espressione:

/[-+]?[0-9]+\.[0-9]+([eE][-+]?[0-9]+)?/

Facendo così però abbiamo imposto che ci siano delle cifre prima e dopo il punto, restringendo la gamma di numeri che riconosciamo anzichè ampliarla.

Se invece sostituissimo i due + dell'espressione appena trovata con due stelle? Otterremmo questa:

/[-+]?[0-9]*\.[0-9]*([eE][-+]?[0-9]+)?/

E riconosceremmo sia i numeri che hanno delle cifre prima del punto ma niente dopo che quelli che hanno delle cifre dopo il punto. Peccato che la nuova espressione sia fin troppo generosa e riconosca anche le seguenti stringhe come valide:

  • .
  • -.e0
  • .E-0
E noi non le vogliamo perché quelle stringhe non sono numeri validi!

Che si fa? O siamo troppo rigorosi oppure siamo troppo permissivi... Uhm... Non c'era un operatore che ci permetteva di dire "questo oppure quello ma non tutti e due"? Sì, Virginia, c'è e si tratta della barra verticale (|) che i fan di UNIX chiamano "pipe" perché ha un significato ben preciso nella shell UNIX.

Il segno opzionale all'inizio ci va bene e lo lasceremo dov'è, creeremo invece un gruppo che conterrà al suo interno un | per spezzare in due le possibilità di riconoscimento. Nella prima parte metteremo l'espressione per i numeri come 47. e nella seconda metteremo l'espressione originale che abbiamo visto nella sezione precedente.

Quindi abbiamo il segno:

/[-+]?/

Poi apriamo un gruppo e ci mettiamo i numeri con prima le cifre e poi il punto e basta seguiti da un "oppure":

/[-+]?([0-9]+\.|/

E infine rimettiamo l'espressione originaria e chiudiamo il gruppo:

/[-+]?([0-9]+\.|[0-9]*\.[0-9]+([eE][-+]?[0-9]+)?)/

Rileggiamo la nostra nuova espressione: un segno opzionale seguito da una o più cifre seguite da un punto oppure da zero o più cifre seguite da un punto seguito da una o più cifre seguito da un esponente che si compone della lettera e (maiuscola o minuscola) seguita da un segno opzionale seguito da una o più cifre.

Vi lascio come compito per casa l'espansione di quest'ultima espressione regolare affinché riconosca anche i numeri decimali interi: una o più cifre decimali precedute da un simbolo di segno opzionale e nessun punto.

La prossima volta ci occuperemo di alcuni programmi della shell UNIX che fanno largo uso delle regex.

Aloha!

Prima Parte
Terza Parte