CodeGym /Corsi /C++ SELF /Bug della memoria manuale — leak / use-after-free / doubl...

Bug della memoria manuale — leak / use-after-free / double-free

C++ SELF
Livello 41, Lezione 4
Disponibile

1. Introduzione

Quando un programmatore vede per la prima volta new e delete, nella sua testa nasce un pensiero pericoloso: «Oh, ora posso creare oggetti quando e dove voglio! Io sono il padrone della memoria!». Questa sensazione passa in fretta quando il programma inizia a crollare non dove c'è l'errore, ma "da qualche parte vicino a std::cout" (classico). La ragione è semplice: la gestione manuale della memoria è un contratto che è molto facile violare. Oggi esamineremo i tre modi più comuni per violare quel contratto.

Per avere un riferimento, fissiamo il "golden invariant" della memoria manuale (ciò che deve essere vero sempre):

Ogni risorsa ottenuta tramite `new` / `new[]` deve essere liberata esattamente una volta
con il corrispondente `delete` / `delete[]` e solo dopo che non viene più usata.

Se l'invariante viene violato, otteniamo uno dei tre protagonisti di questa lezione.

Mappa del territorio: tre errori e come si differenziano

Prima di tuffarci negli esempi, è utile fare per un minuto una "tabella del buon senso" per non confondere i concetti. Questi bug vengono spesso confusi perché l'esito è simile: "tutto si è rotto". Ma si rompono in modi diversi.

Bug Cosa è stato violato Sintomo tipico Perché è insidioso
leak
risorsa non liberata il programma "aumenta" l'uso di memoria, a volte crolla dopo molto tempo può non manifestarsi nei test piccoli
double-free
risorsa liberata due volte terminazione anomala, crash dell'allocator, "heap corruption" l'errore spesso è lontano dal punto del crash
use-after-free
risorsa utilizzata dopo la sua liberazione "dati spazzatura", crash rari, valori strani a volte "funziona", finché la memoria non viene riutilizzata

Andremo in quest'ordine: prima leak (i più "silenziosi"), poi double-free (i più rumorosi), poi use-after-free (i più misteriosi).

2. Leak: la memoria è "persa" perché l'avete "smarrita"

Una perdita di memoria è la situazione in cui avete allocato memoria ma non l'avete liberata. Importante: la memoria non deve necessariamente "perdersi per sempre" dal punto di vista del sistema operativo — quando il processo termina, il SO riprende tutto. Ma finché il programma è in esecuzione, una leak significa aumento del consumo di memoria e, talvolta, degrado delle prestazioni a causa delle allocazioni e della frammentazione.

Il punto più fastidioso è che una leak spesso appare "innocua": il programma stampa i numeri corretti, i test passano, e il problema si manifesta solo in esecuzioni lunghe o sotto carico. Proprio dove meno vi aspettate sorprese.

Il leak più semplice: dimenticare il delete

Iniziamo con un esempio "didattico". È banale, ma è proprio così che la mente del principiante si abitua: alloco — dimentico.

#include <iostream>

int main() {
    int* p = new int{10};
    std::cout << *p << '\n'; // 10

    // delete p; // dimenticato => leak
}

Il problema qui non è che il programma necessariamente crashi. Il problema è che avete violato il contratto: la risorsa vive più a lungo del necessario e non la controllate più.

Leak tramite return anticipato: "sei uscito dalla funzione e hai lasciato la porta aperta"

Questo è più vicino alla vita reale: la logica si dirama e in una delle branch uscite dalla funzione senza liberare la risorsa.

#include <string>

int* parse_positive_or_null(const std::string& s) {
    int* p = new int{0};

    if (s.empty()) {
        return nullptr; // leak: p non è stato liberato
    }

    *p = 42; // supponiamo di aver parsato
    return p;
}

È importante sentire: il problema non è il return in sé. Il problema è che il delete è "sparso" sulle varie branch (e in una di esse manca del tutto). Più branch ci sono, maggiore la probabilità di sbagliare. Ed è una delle ragioni per cui la memoria manuale scala male con la complessità del codice.

Leak per sovrascrittura del puntatore: "hai perso l'ultimo indirizzo"

Errore molto comune: avete allocato memoria, poi avete assegnato al puntatore un nuovo indirizzo — e il primo indirizzo è perso, non c'è più modo di liberarlo.

int main() {
    int* p = new int{1};

    p = new int{2}; // leak: l'indirizzo di {1} è perso

    delete p;       // liberiamo solo {2}
    p = nullptr;
}

Succede spesso nel codice che "riusa la variabile" pensando che sia più efficiente. Si risparmiano solo righe di codice, non memoria né nervi.

Leak in un ciclo: "un po' alla volta, ma ogni volta"

Le leak diventano veramente pericolose quando entrano in sezioni ripetute del codice. Allora si ottiene l'effetto "goccia a goccia — e allagamento".

#include <iostream>

int main() {
    for (int i = 0; i < 3; ++i) {
        int* p = new int{i};
        std::cout << *p << ' '; // 0 1 2
        // delete p; // dimenticato => leak ad ogni iterazione
    }
    std::cout << '\n';
}

A tre iterazioni nessuno se ne accorge. A tre milioni se ne accorge anche il vostro portatile, che comincerà a "rumoreggiare" con le ventole e a fingere il decollo.

4. Double-free: risorsa liberata due volte (e questo è già UB)

double-free suona come "ho pulito la stanza due volte, che c'è di male?". Sulla memoria funziona al contrario: il secondo delete prova a liberare una risorsa già liberata. Da lì in poi inizia Undefined Behavior: il programma può crashare immediatamente, oppure più tardi, oppure "solo" corrompere le strutture interne dell'allocator e causare un crash altrove.

La causa più comune di double-free nei principianti è la mancata comprensione della differenza tra "copiare il puntatore" e "copiare l'oggetto". Un puntatore è solo un indirizzo. Se copiate l'indirizzo avete due nomi per la stessa risorsa. Se entrambi i nomi si considerano proprietari, entrambi chiameranno delete.

Due puntatori allo stesso new: "due proprietari di un frigorifero"

int main() {
    int* p = new int{5};
    int* q = p;     // q e p puntano allo stesso oggetto

    delete p;
    // delete q;    // UB: double-free (non eseguito)
    q = nullptr;
}

Idea chiave: copiare T* copia l'indirizzo, non crea un secondo oggetto.

Double-free dovuto a una funzione "utile" che decide di cancellare

A volte il codice si rompe a causa di un contratto di funzione poco chiaro. Supponiamo che passiate un puntatore a una funzione "fai qualcosa" e questa decida che, dato che ha ricevuto il puntatore, sia sua responsabilità cancellarlo. Voi invece pensavate di cancellarlo voi. Risultato: due delete.

#include <iostream>

void print_and_delete(int* p) {
    std::cout << *p << '\n'; // supponiamo di stampare
    delete p;                // e per caso cancelliamo
}

int main() {
    int* p = new int{7};
    print_and_delete(p);

    // delete p;             // UB: double-free (non eseguito)
    p = nullptr;
}

Questo esempio è importante non perché "non si deve fare" (anche se non si deve), ma perché mostra che la proprietà deve essere univoca. Dal tipo int* non è chiaro chi sia il proprietario. Questa è una delle questioni fondamentali delle API "raw".

Double-free per "liberato e non azzerato": secondo delete su un vecchio indirizzo

A volte il secondo delete non avviene perché qualcuno ha copiato il puntatore, ma perché il codice ha "riutilizzato la variabile" e ha dimenticato che lì era già stato fatto il delete.

int main() {
    int* p = new int{1};

    delete p;
    // ... molto codice ...
    // delete p; // UB: secondo delete dello stesso indirizzo (non eseguito)

    p = nullptr;
}

Sì, azzerare con p = nullptr; non risolve l'architettura di proprietà. Ma spesso aiuta a trasformare un "UB silenzioso" in un errore più evidente: se qualcuno poi fa delete su p, allora delete nullptr è sicuro e non rompe nulla.

5. Use-after-free: l'oggetto è già morto, ma voi continuate a parlargli

Use-after-free è quando liberate memoria ma continuate a usare il puntatore che ci punta. Tecnicamente dopo delete il puntatore diventa dangling (penzolante): conserva ancora l'indirizzo, ma a quell'indirizzo non c'è più un oggetto "vivo" con un tempo di vita valido.

L'insidia è che spesso si pensa: "l'indirizzo è lo stesso. Quindi i dati sono lì". Ma il linguaggio C++ non garantisce nulla: dopo delete quel blocco di memoria può essere immediatamente riutilizzato per altri scopi, oppure può rimanere "comunque là" per un po'. Perciò use-after-free talvolta "funziona" (peggio che crashare subito).

Use-after-free diretto: dereferenziazione dopo delete

#include <iostream>

int main() {
    int* p = new int{99};
    delete p;

    // std::cout << *p << '\n'; // UB: use-after-free (non eseguito)
    p = nullptr;
}

Se siete fortunati il programma crasha. Se non lo siete, ottenete un numero qualsiasi e pensate che sia tutto ok. Poi quel "funziona" diventerà un bug in produzione.

Use-after-free tramite "indirizzo salvato": il puntatore è andato a spasso

Versione più realistica: avete salvato l'indirizzo da qualche parte, poi avete liberato e poi usate la "copia salvata".

#include <iostream>

int main() {
    int* p = new int{10};
    int* alias = p;   // seconda "etichetta" per lo stesso indirizzo

    delete p;
    // std::cout << *alias << '\n'; // UB: use-after-free (non eseguito)

    alias = nullptr;
}

Nota psicologica: potete aver azzerato onestamente p = nullptr;, ma alias resterà dangling. Perciò "azzerare il puntatore" non è una difesa universale se l'indirizzo è stato copiato.

Use-after-free che sembra "random": la memoria è stata riutilizzata da un nuovo new

Effetto istruttivo: avete cancellato l'oggetto e poi da qualche parte fate un altro new. L'allocator può restituire lo stesso indirizzo, e allora il vostro vecchio dangling-pointer improvvisamente "punta" a un nuovo oggetto. Leggete attraverso di esso e ottenete i dati di un altro oggetto. È praticamente un caso da romanzo giallo.

#include <iostream>

int main() {
    int* p = new int{1};
    delete p;

    int* q = new int{777};     // a volte può ricevere lo stesso indirizzo di p
    // std::cout << *p << '\n'; // UB: use-after-free (non eseguito)

    delete q;
}

Perché è spaventoso: se il programma crashasse sarebbe facile trovare l'errore. Ma quando "funziona, ma sbagliato", catturate bug fantasma.

Perché questi bug si manifestano in modo strano: modello semplificato dell'allocator

Vorremmo credere che un errore rompa il programma subito e in modo chiaro. Ma la realtà è che il memory manager (allocator) è un sistema complesso che riutilizza blocchi liberati, mantiene liste di zone libere, unisce e divide blocchi. Quindi delete non significa "azzerare la memoria", ma "restituire il blocco alla gestione".

È utile avere in testa uno schema semplice del ciclo di vita:

flowchart LR
    A[new allocato un blocco] --> B[l'oggetto vive]
    B --> C[delete ha terminato il lifetime]
    C --> D[il blocco può essere riutilizzato]
    D --> E[un nuovo new può restituire lo stesso indirizzo]

Da questo schema derivano due effetti sgradevoli. Primo, use-after-free a volte produce dati "plausibili" perché il blocco non è ancora stato riutilizzato. Secondo, double-free può non crashare al secondo delete ma "corrompere l'interno" e far crashare più tardi su un diverso new o delete.

6. Mini-case: TaskBoard e note legacy su char*

Per non studiare gli errori nel vuoto, immaginiamo di avere una semplice applicazione console TaskBoard (nome convenzionale), dove i task vivono in std::vector e i titoli in std::string. Tutto bene... finché qualcuno non decide di aggiungere alla task una "nota in stile C", perché "è più veloce" o "voglio esercitarmi con new[]".

Creiamo una piccola funzione che alloca una C-string per la nota e ritorna char*. Non è una raccomandazione, è un esempio didattico per mostrare i rischi.

Allochiamo memoria per una C-string

#include <cstring>
#include <string>

char* make_note_cstr(const std::string& note) {
    char* p = new char[note.size() + 1];
    std::memcpy(p, note.c_str(), note.size() + 1); // includendo '\0'
    return p; // ATTENZIONE: chi farà delete[]?
}

Ed è qui che inizia il classico problema di design: dal tipo di ritorno char* non si capisce chi è il proprietario e quando liberare. Di conseguenza, tutti e tre i nostri bug diventano scenari "naturali".

Leak: si ottiene la nota e ci si dimentica di liberarla

#include <iostream>
#include <string>

int main() {
    char* note = make_note_cstr("Call mom");

    std::cout << note << '\n'; // Call mom

    // delete[] note; // dimenticato => leak
}

Un delete[] dimenticato in un punto può sembrare piccolo. Ma se le note vengono create frequentemente (per esempio, ad ogni aggiornamento del task), la leak diventa sistemica.

Double-free: due moduli pensano di essere "proprietari della nota"

#include <string>

void store_note_somewhere(char* note) {
    // ... abbiamo salvato ...
    delete[] note; // "pensiamo che sia nostra responsabilità"
}

int main() {
    char* note = make_note_cstr("Buy milk");
    store_note_somewhere(note);

    // delete[] note; // UB: double-free (non eseguito)
}

Questa situazione emerge spesso nello sviluppo di team, quando una persona scrive la funzione, un'altra la chiama, e il contratto di ownership non è documentato (o è documentato in un commento che nessuno legge — realistico).

Use-after-free: la nota è stata cancellata ma il puntatore è ancora nel task

Immaginate che in un task sia memorizzato char* note;, e durante l'aggiornamento della nota fate delete[] note, ma dimenticate di aggiornare il campo o dimenticate che qualcun altro ci fa riferimento.

#include <iostream>

int main() {
    char* note = make_note_cstr("Old note");
    delete[] note;

    // std::cout << note << '\n'; // UB: use-after-free (non eseguito)
    note = nullptr;
}

Qui la morale è la stessa: se gestite la memoria manualmente, dovete assicurarvi manualmente la correttezza dei tempi di vita in tutti i punti in cui l'indirizzo è "uscito".

7. Errori tipici

Errore n.1: "Restituiamo semplicemente un T* dalla funzione, poi si vedrà".
Restituire un puntatore raw a un oggetto dinamico quasi sempre crea una falla nel contratto di ownership. Il codice compila, funziona sui casi semplici, e poi compare improvvisamente una leak (nessuno ha cancellato) o un double-free (hanno cancellato due volte), perché la responsabilità non è assegnata in modo univoco.

Errore n.2: la copia del puntatore è scambiata per copia dell'oggetto.
Questa è forse la causa più frequente di double-free e use-after-free nei principianti. int* q = p; non crea un secondo int, crea solo una seconda etichetta per lo stesso indirizzo. Se non vi preoccupate esplicitamente "ora abbiamo due nomi per la stessa memoria", la mente tende a immaginare una copia dell'oggetto che non esiste.

Errore n.3: il delete è "sparso" tra branch e return anticipati.
Quando in una funzione compaiono diversi return e più branch if, e il delete è "da qualche parte", la probabilità di una leak diventa quasi statistica. Di solito si nota scorrendo la funzione: vi chiedete "qui abbiamo veramente liberato?". Se dovete pensarci — è già pericoloso.

Errore n.4: dopo delete si continua ad accedere al puntatore o lo si passa oltre.
Use-after-free nasce spesso non per malafede, ma per dimenticanza: si è cancellato l'oggetto ma l'indirizzo è rimasto nella variabile, nel campo della struttura non è stato aggiornato, o un "alias temporaneo" è stato salvato altrove. Dopo delete il puntatore va considerato invalido. Azzerare con p = nullptr; aiuta, ma non risolve il problema delle copie dell'indirizzo.

Errore n.5: confusione tra new[] e delete (o viceversa), considerandoli "quasi la stessa cosa".
Anche se l'argomento è già noto, è direttamente collegato ai crash di oggi: la forma sbagliata di deallocazione è anche essa UB e spesso si manifesta come "heap corrotto", quindi sintomi simili a double-free o use-after-free. Se avete allocato un array, liberatelo con delete[], senza compromessi.

1
Compito
C++ SELF, livello 41, lezione 4
Bloccato
Quadrato in memoria
Quadrato in memoria
1
Compito
C++ SELF, livello 41, lezione 4
Bloccato
Bonus solo positivo
Bonus solo positivo
1
Compito
C++ SELF, livello 41, lezione 4
Bloccato
Backup
Backup
1
Compito
C++ SELF, livello 41, lezione 4
Bloccato
Due note del terminale
Due note del terminale
1
Sondaggio/quiz
Nullable‑design, livello 41, lezione 4
Non disponibile
Nullable‑design
Nullable‑design
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION