Il rapporto della Casa Bianca del febbraio 2023 incoraggia l’adozione di linguaggi di programmazione sicuri per mitigare le vulnerabilità di memoria, indicando Rust come un’alternativa efficace a C/C++.
Rust offre una gestione sicura della memoria tramite un sistema di proprietà e verifiche a tempo di compilazione, eliminando molti errori comuni. Tuttavia, C++ resta preferito per le sue prestazioni e ottimizzazione delle risorse, nonostante i rischi legati alla sicurezza.
Il report
Un report pubblicato nel Febbraio di quest’anno dalla Casa Bianca, mostra come il governo degli Stati Uniti abbia invitato gli sviluppatori di software ad abbandonare i linguaggi di programmazione che causano buffer overflow e altre vulnerabilità correlate all’uso della memoria. Il documento esplora l’attuale approccio reattivo alla sicurezza informatica e il potenziale onere per gli utenti.
In sostanza, sottolinea la necessità di adottare approcci proattivi alla sicurezza informatica, concentrandosi principalmente sull’eliminazione di intere classi di vulnerabilità piuttosto che semplicemente sulla correzione di quelle note. L’amministrazione ha introdotto l’Executive Order 14028 insieme alla National Cybersecurity Strategy, che propugna il riequilibrio delle responsabilità e la priorità degli investimenti a lungo termine verso la sicurezza informatica, evidenziando l’importanza della cooperazione tra governo, industria e comunità tecnica nel raggiungimento di questi obiettivi.
Il documento sottolinea l’importanza di adottare misure proattive per eliminare interi gruppi di vulnerabilità software esistenti. In particolare, suggerisce che i produttori dovrebbero pubblicare i dati CVE (Common Vulnerability and Exposures) e concentrarsi su CWE (Common Weakness Enumeration) per aiutare a comprendere meglio la prevalenza dei problemi. Il report non si ferma qui, suggerisce anche di utilizzare i cosiddetti linguaggi di programmazione memory-safe per mitigare tali vulnerabilità, identificando questo approccio come il più efficiente, esplorando al contempo approcci complementari, tra cui l’utilizzo di hardware memory-safe e l’implementazione di metodi formali in alcuni casi specifici.
L’indicazione di utilizzare linguaggio di programmazione memory-safe di fatto mette in discussione l’utilizzo di uno dei linguaggi più famosi e più frequentemente impiegati dagli sviluppatori di tutto il mondo il C/C++. Questo linguaggio mette a disposizione la più ampia libertà di gestione della memoria e dei dispositivi hardware con massima efficienza sia in termini di prestazioni che di ottimizzazione dell’utilizzo delle risorse. Per questi motivi C/ C++ rappresenta il “core” della maggior parte delle applicazioni.
Proprio i suoi punti di forza diventano i sui maggiori punti di debolezza quando analizziamo il codice valutandone gli aspetti di vulnerabilità. Il linguaggio C/C++ non si può considerare un linguaggio memory-safe in quanto proprio in nome della libertà di gestione delle risorse ne viene lasciato il pieno controllo allo sviluppatore il quale oltre a risolvere il problema dato, individuazione dell’algoritmo risolutivo, deve anche preoccuparsi di gestire operazioni di “contorno”, per esempio allocare e deallocare la memoria, attività che in altri linguaggi non è necessaria perché gestita dal linguaggio stesso.
Le più comuni vulnerabilità di sicurezza del C/C++
Buffer Overflow
Un buffer overflow si verifica quando un programma scrive più dati in un buffer (una zona di memoria allocata) di quanto sia stato allocato per esso, sovrascrivendo così la memoria adiacente. Questo può portare a:
• Corruzione della memoria: sovrascrittura di dati importanti o indirizzi di ritorno, potenzialmente causando il crash del programma o un comportamento imprevedibile.
• Esecuzione di codice arbitrario: gli attaccanti possono sfruttare il buffer overflow per eseguire codice arbitrario inserito nella memoria durante l’overflow.
Uso di Puntatori Non Validi (Dangling Pointers)
I puntatori non validi sono puntatori che fanno riferimento a una locazione di memoria che è stata deallocata o che non è più valida. L’utilizzo di tali puntatori può portare a:
• Crash del programma: tentare di accedere a memoria deallocata spesso provoca errori di segmentazione.
• Vulnerabilità di sicurezza: un attaccante potrebbe manipolare il programma in modo che punti a una zona di memoria controllata dall’attaccante stesso.
Memory Leak (Perdita di Memoria)
Un memory leak si verifica quando un programma non libera memoria allocata dinamicamente che non è più in uso. I problemi principali includono:
• Esecuzione prolungata: nel tempo, il programma può esaurire la memoria disponibile, portando a un rallentamento delle prestazioni o al crash.
• Problemi di disponibilità: in ambienti di server o applicazioni critiche, i memory leak possono ridurre la stabilità e la disponibilità del sistema.
Use After Free
L’uso dopo la deallocazione (use after free) si verifica quando un programma continua a utilizzare un puntatore dopo che la memoria alla quale puntava è stata liberata. Questo può portare a:
• Comportamento indefinito: accedere a memoria che è stata deallocata può portare a risultati imprevedibili o crash.
• Vulnerabilità di sicurezza: un attaccante potrebbe essere in grado di allocare nuovamente la memoria liberata e manipolare i dati in essa contenuti.
Double Free (Doppia Deallocazione)
Un doppio free si verifica quando un programma tenta di deallocare una zona di memoria che è stata già deallocata. Questo può causare:
• Crash del programma: il gestore della memoria potrebbe rilevare un’inconsistenza e terminare il programma.
• Possibili exploit di sicurezza: in alcuni casi, un attaccante può sfruttare la doppia deallocazione per manipolare la struttura interna del gestore della memoria e ottenere l’esecuzione di codice arbitrario.
Race Condition
Una race condition si verifica quando il comportamento del programma dipende dalla temporizzazione relativa di eventi concorrenti (come thread o processi multipli), e l’esito corretto non è garantito. Questo può portare a:
• Comportamento imprevedibile: variabili condivise potrebbero essere lette o scritte in modi non previsti.
• Vulnerabilità di sicurezza: un attaccante potrebbe sfruttare una race condition per modificare dati critici o ottenere un’escalation di privilegi.
Integer Overflow e Underflow
Gli overflow e underflow degli interi si verificano quando una variabile intera eccede il suo valore massimo rappresentabile o va sotto il suo valore minimo rappresentabile. Questo può causare:
• Comportamento imprevisto: operazioni aritmetiche possono dare risultati errati o comportamenti inaspettati.
• Vulnerabilità di sicurezza: un attaccante potrebbe sfruttare un overflow per bypassare controlli di sicurezza o causare altre vulnerabilità.
Iniezione di Codice
C++ consente l’uso di funzioni di sistema come system(), che possono essere vulnerabili a iniezioni di codice se non utilizzate correttamente. Ad esempio:
• Esecuzione di comandi malevoli: se l’input dell’utente è passato direttamente a una chiamata di sistema senza adeguata sanitizzazione, un attaccante potrebbe eseguire comandi arbitrari.
Formato String Vulnerability
Questa vulnerabilità si verifica quando l’input dell’utente viene utilizzato come stringa di formato in funzioni come printf() senza la dovuta verifca. Un esempio classico:
• Crash del programma o esecuzione di codice arbitrario: un attaccante può utilizzare specificatori di formato per leggere o scrivere in memoria, eseguire codice o causare un crash.
Insufficient Input Validation
L’insufficiente convalida dell’input è un problema generale che può portare a molteplici vulnerabilità, come SQL injection, command injection, o buffer overflow. Assicurarsi sempre che l’input venga validato correttamente per tipo, lunghezza e formato.
Buone pratiche di programmazione
Per mitigare questi problemi di sicurezza, è essenziale adottare buone pratiche di programmazione come:
• Sanitizzazione e convalida dell’input: Verifca rigorosa dell’input dell’utente.
• Uso di funzioni sicure: Funzioni che limitano l’accesso alla memoria e gestiscono in modo sicuro i buffer.
• Utilizzo di strumenti di analisi del codice: strumenti come AddressSanitizer, Valgrind, compilatori con protezione degli stack possono aiutare a rilevare vulnerabilità.
• Gestione corretta della memoria: applicare tecniche come RAII (Resource Acquisition Is Initialization) per gestire automaticamente l’allocazione e la deallocazione delle risorse.
Uscendo un attimo dallo schema si potrebbe valutare come buona pratica di programmazione l’utilizzo del linguaggio più adatto alla risoluzione del problema, che nella nostra fattispecie è la mitigazione dei problemi di sicurezza, dunque un linguaggio memory-safe quale per esempio Rust, Java, Python oppure C#. Tra questi Rust è ad oggi il linguaggio che presenta più similitudini rispetto a C/C++, con in più la prerogativa della gestione sicura della memoria.
Storia del linguaggio Rust
Rust è un linguaggio di programmazione sviluppato da Mozilla Research, con l’obiettivo principale di fornire un’alternativa sicura e performante ai linguaggi tradizionali utilizzati per lo sviluppo a basso livello, come il C e il C++. Il progetto ha avuto inizio nel 2006, quando Graydon Hoare, un dipendente di Mozilla, iniziò a lavorare su un nuovo linguaggio di programmazione nel suo tempo libero, con l’intento di creare un linguaggio che potesse prevenire molti dei problemi di sicurezza e stabilità comuni nei linguaggi di programmazione tradizionali.
Nel 2009, Mozilla decise di sponsorizzare lo sviluppo di Rust, vedendo il potenziale per un linguaggio che potesse migliorare le prestazioni del loro browser Firefox, mantenendo al contempo un alto livello di sicurezza. Il linguaggio è stato progettato per essere “sicuro per default”, ovvero per evitare comportamenti indefiniti e per garantire che molte categorie di bug di programmazione comuni, come le race condition e i puntatori nulli, fossero impossibili da rappresentare nei programmi scritti in Rust.
Nel 2010, il compilatore di Rust, inizialmente scritto in OCaml, fu riscritto in Rust stesso, una pratica comune conosciuta come “bootstrapping”. Questa riscrittura permise di migliorare il compilatore e di ottimizzarlo utilizzando le caratteristiche uniche del
linguaggio. Nel 2015, Rust diventa uffcialmente stabile e pronto per l’uso in produzione. Da allora, Rust è cresciuto rapidamente in popolarità, grazie alla sua enfasi sulla sicurezza della memoria e sulla concorrenza senza lock.
Comparazione tra Rust e C++
Similitudini
Da un punto di vista superfciale, Rust e C++ hanno una sintassi simile. Entrambi i linguaggi utilizzano un insieme affine di parole chiave e strutture di controllo, il che può rendere il passaggio da C++ a Rust agevole per molti sviluppatori. Entrambi i linguaggi offrono anche un controllo granulare sulla gestione della memoria, il che è fondamentale per i tipi di applicazioni a basso livello per cui sono stati progettati.
Entrambi i linguaggi supportano la programmazione orientata agli oggetti, permettendo agli sviluppatori di organizzare il codice in classi e oggetti per una migliore modularità e riutilizzo del codice. Infne, sia Rust che C++ hanno robusti sistemi di tipi che aiutano a prevenire una serie di errori di programmazione.
Differenze
Nonostante queste similitudini, ci sono importanti differenze tra Rust e C++. Forse la più notevole è la sicurezza della memoria. C++ consente agli sviluppatori di gestire direttamente la memoria, libertà che può portare a errori gravi se non viene utilizzata correttamente. Rust, invece, adotta un approccio differente, combinando controllo statico al tempo di compilazione e un sistema di gestione della memoria sicuro che evita la necessità di un garbage collector utilizzando un sistema di ownership (proprietà) con borrowing (prestito) e lifetimes (durate) per garantire che ogni segmento di memoria abbia un proprietario unico, e che i riferimenti alla memoria siano sempre validi.
Allocazione della Memoria: Esempio di Confronto
In questo esempio C++, la memoria per l’array arr è allocata dinamicamente usando new, e deve essere esplicitamente deallocata usando delete[]. Se l’istruzione delete[] arr; fosse omessa o posizionata in modo errato, si verificherebbe un memory leak. Inoltre, un uso improprio di arr dopo la sua deallocazione porterebbe a un comportamento indefinito (use-after-free).
In Rust, l’allocazione dinamica della memoria per arr viene gestita dalla struttura Vec. Quando arr esce dallo scope (ambito), la memoria viene automaticamente deallocata. Questo meccanismo elimina completamente la necessità di gestire manualmente la memoria, prevenendo così molti dei bug di gestione della memoria comuni in C++.
Gestione dei Tipi di Dato: Esempio di Confronto
In questo esempio in C++, utilizziamo la classe std::variant per rappresentare un tipo di dato che può contenere un intero o un double. Se si tenta di accedere a un tipo diverso da quello attualmente contenuto, viene sollevata un’eccezione std::bad_variant_access. Questo tipo di gestione è relativamente sicuro, ma richiede comunque una gestione esplicita delle eccezioni per evitare il crash del programma.
In Rust, il tipo Result è utilizzato per rappresentare un valore che può essere di successo (Ok) o un errore (Err). L’uso di match per gestire i diversi possibili valori garantisce che ogni caso sia gestito esplicitamente, prevenendo errori di runtime e rendendo il codice più robusto e sicuro. Rust non ha eccezioni nel senso tradizionale, ma utilizza invece tipi di risultato come Option e Result per la gestione degli errori, incoraggiando un approccio di programmazione più sicuro e prevedibile.
È questo il momento di abbandonare le applicazioni С/С++?
Dal mio punto di vista la domanda merita una ed una sola risposta, dipende! Di certo avere a disposizione un linguaggio che eviti al programmatore di incappare nei tipici problemi di vulnerabilità che si possono incontrare lavorando in C++ non è cosa da sottovalutare, ma di certo non si può nemmeno sottovalutare il fatto che la base di codice già scritto in C++ è spesso enorme il che richiederebbe una smisurata quantità di tempo per essere convertito con tutti i problemi di possibili errori che una tale conversione comporterebbe.
Solo una attenta valutazione del progetto da realizzare e la conoscenza delle sue basi fondamentali (architettura, dipendenze, funzioni critiche) potrà fornirci la risposta che stiamo cercando. La sicurezza del codice non passa solo per l’utilizzo di un determinato linguaggio, ma si tratta piuttosto di analizzare i dati relativi alle vulnerabilità tipiche del linguaggio in uso e mettere in atto delle best practice di sviluppo sicuro per evitarle.
Un esempio per tutti, oltre il 30% delle vulnerabilità imputabili a errori nel codice C/C++ sono di classe CWE-119 (“buffer errors”) mentre quelle di classe CWE-20 (“input validation”) ammontano al 15%. Evitare i “buffer error” e validare correttamente l’input quando si sviluppa in linguaggio C/C++ significa sistemare buona parte dei rischi di vulnerabilità. Un approccio allo sviluppo sicuro passa sicuramente per attente review e debug del codice, ma occorre ora più che mai giocare di anticipo affrontando i problemi sul nascere.