La memoria di un processo: stack, heap, segmenti
Stack, heap, BSS, data, text: la mappa di memoria di un processo e perché conta per la sicurezza.
Blocco: A — Il kernel e i processi
Prerequisiti: A1 (kernel, ring, syscall), A2 (processi, /proc)
Collegato a: A4 (segnali), Modulo 3 (buffer overflow, exploitation)
Il problema che ha generato tutto
Negli anni '60 e '70, quando i computer iniziarono a far girare più programmi contemporaneamente, emerse un problema concreto: dove metti in memoria ogni programma? Se il programma A è caricato agli indirizzi 1000-2000 e il programma B agli indirizzi 2000-3000, cosa succede se A ha bisogno di più memoria e vuole espandersi? E cosa impedisce a B di leggere o scrivere accidentalmente — o deliberatamente — nella zona di A?
La prima risposta fu la protezione base della memoria: il kernel teneva traccia di quale zona di RAM apparteneva a quale programma, e la CPU verificava che ogni accesso fosse lecito. Funzionava, ma aveva un limite enorme: la memoria fisica disponibile era quella. Se avevi 64KB di RAM e il programma ne chiedeva 128KB, non c'era soluzione.
La risposta definitiva arrivò negli anni '70-'80 con la memoria virtuale — una delle idee più eleganti dell'informatica moderna.
Memoria virtuale: l'illusione del proprio spazio
L'idea di base è questa: ogni processo crede di avere per sé un enorme spazio di indirizzi continuo, tutto suo. In realtà quegli indirizzi non corrispondono direttamente alla RAM fisica — sono indirizzi virtuali, e il kernel (con l'aiuto dell'hardware) li traduce in indirizzi fisici reali nel momento in cui vengono usati.
Su un sistema a 64-bit, ogni processo ha teoricamente uno spazio di indirizzi virtuali di 2^64 byte — molto più della RAM fisica che esiste. Ovviamente non tutta quella memoria è effettivamente usata o allocata. La maggior parte è vuota.
Spazio di indirizzi virtuali di un processo (64-bit, semplificato):
0xFFFFFFFFFFFFFFFF ┐
│ Kernel space (inaccessibile dal processo)
0xFFFF800000000000 ┘
← gap non mappato →
0x00007FFFFFFFFFFF ┐ Stack (cresce verso il basso ↓)
│ ...
│ Librerie condivise (libc, ecc.)
│ ...
│ Heap (cresce verso l'alto ↑)
│ BSS (variabili globali non inizializzate)
│ Data (variabili globali inizializzate)
0x0000000000400000 │ Text (codice eseguibile)
0x0000000000000000 ┘ (non mappato — accesso causa segfault)
Questa struttura non è casuale. Ogni zona ha uno scopo preciso, e capire perché è dove è aiuta a capire come si sfrutta.
I segmenti: le zone della memoria
Text — il codice
Il segmento text contiene le istruzioni del programma — il codice macchina compilato. È caricato dal file eseguibile al momento del lancio del programma.
Ha due caratteristiche fondamentali:
- È read-only: il programma non può modificare il proprio codice mentre è in esecuzione. Se ci prova, riceve un segmentation fault. Questo è intenzionale — impedisce a un bug o a un attaccante di sovrascrivere il codice del programma con codice malevolo.
- È eseguibile: la CPU può eseguire istruzioni da questa zona.
# Vedi i permessi del segmento text in /proc
cat /proc/self/maps | grep "r-xp"
# 55a3b4200000-55a3b4220000 r-xp 00000000 08:01 123 /bin/bash
# ^^^
# r = leggibile
# - = non scrivibile
# x = eseguibile
# p = private (copy-on-write)
Data e BSS — le variabili globali
Il segmento data contiene le variabili globali e statiche che il programma ha inizializzato esplicitamente:
int contatore = 42; // va in .data
char nome[] = "ciao"; // va in .data
Il segmento BSS (Block Started by Symbol — nome storico, senza particolare significato intuitivo) contiene le variabili globali e statiche non inizializzate:
int risultato; // va in .bss
static char buffer[1024]; // va in .bss
La distinzione esiste per efficienza: le variabili non inizializzate non hanno bisogno di essere salvate nell'eseguibile — il kernel sa solo che deve allocare quel tanto di memoria e azzerarla. L'eseguibile su disco contiene i dati di .data ma per .bss conserva solo la dimensione.
Heap — la memoria dinamica
L'heap è la zona di memoria allocata dinamicamente durante l'esecuzione — quella che usi quando chiami malloc() in C, new in C++, o quando crei un oggetto in Python o JavaScript.
L'heap cresce verso l'alto: ogni nuova allocazione viene aggiunta sopra la precedente. Il kernel gestisce la dimensione dell'heap tramite una syscall chiamata brk() (o mmap() per allocazioni grandi) — il programma chiede al kernel di espandere o contrarre il confine superiore dell'heap.
#include <stdlib.h>
#include <string.h>
int main() {
// malloc chiede al kernel memoria sull'heap
char *buf = malloc(100); // alloca 100 byte
strcpy(buf, "ciao"); // scrive sull'heap
free(buf); // restituisce la memoria
// Attenzione: dopo free(), buf punta a memoria non più valida
// Usarla è un "use after free" — vettore di exploit
return 0;
}
# Vedi l'heap di un processo
cat /proc/self/maps | grep heap
# 55a3b5200000-55a3b5400000 rw-p 00000000 00:00 0 [heap]
# ^^^
# rw = leggibile e scrivibile
# - = non eseguibile (protezione importante)
Il fatto che l'heap non sia eseguibile è una protezione fondamentale — si chiama NX bit (No-Execute) o DEP (Data Execution Prevention). Impedisce a un attaccante di iniettare shellcode nell'heap e poi eseguirlo. Vedremo come si aggira (con tecniche come ROP — Return Oriented Programming) nel modulo sull'exploitation.
Stack — le chiamate di funzione
Lo stack è la zona più interessante dal punto di vista della sicurezza. È qui che vivono le variabili locali delle funzioni, i parametri passati alle funzioni, e — dettaglio cruciale — gli indirizzi di ritorno.
Lo stack cresce verso il basso: ogni volta che viene chiamata una funzione, il suo "frame" viene aggiunto in cima allo stack (che è in basso in termini di indirizzo di memoria). Quando la funzione termina, il suo frame viene rimosso.
void funzione_b(int x) {
int locale_b = x * 2; // variabile locale di b
// locale_b vive nello stack frame di funzione_b
}
void funzione_a() {
int locale_a = 10; // variabile locale di a
funzione_b(locale_a); // chiama b
// Dopo che b torna, locale_a è ancora qui
}
int main() {
funzione_a();
return 0;
}
Quando main chiama funzione_a, lo stack cresce:
Indirizzo alto
┌──────────────────────┐
│ frame di main │
│ (indirizzo ritorno,│
│ variabili locali) │
├──────────────────────┤ ← stack pointer (SP) prima della chiamata
│ frame di a │
│ indirizzo ritorno │ ← dove tornare quando a finisce (dentro main)
│ locale_a = 10 │
├──────────────────────┤ ← SP dopo la chiamata di a
│ frame di b │
│ indirizzo ritorno │ ← dove tornare quando b finisce (dentro a)
│ x = 10 │
│ locale_b = 20 │
└──────────────────────┘ ← SP attuale (punta alla cima dello stack)
Indirizzo basso (lo stack cresce verso il basso)
L'indirizzo di ritorno è la chiave di tutto il buffer overflow.
Ogni frame sullo stack contiene l'indirizzo a cui la CPU deve saltare quando la funzione corrente finisce — cioè dove continuare l'esecuzione nel chiamante. Se un attaccante riesce a sovrascrivere quell'indirizzo (ad esempio tramite un buffer overflow — scrivere oltre i limiti di un array locale), può far saltare la CPU ovunque voglia. Questo è il meccanismo fondamentale degli exploit classici.
Il buffer overflow: il concetto
Vale la pena introdurre il concetto qui, anche se lo approfondiremo nel modulo sull'exploitation, perché è la conseguenza diretta di come è organizzata la memoria.
Considera questo codice C:
#include <string.h>
#include <stdio.h>
void funzione_vulnerabile(char *input) {
char buffer[64]; // alloca 64 byte sullo stack
strcpy(buffer, input); // copia input nel buffer SENZA verificare la lunghezza
printf("Hai inserito: %s\n", buffer);
}
int main() {
char input[200];
gets(input); // legge input dall'utente (anche questo è pericoloso)
funzione_vulnerabile(input);
return 0;
}
Il buffer ha 64 byte. Ma strcpy non controlla la lunghezza — copia finché non trova un carattere null. Se l'utente inserisce 100 caratteri, strcpy scrive 100 byte a partire dall'inizio del buffer, andando oltre i 64 byte allocati e sovrascrivendo quello che viene dopo nello stack frame — incluso l'indirizzo di ritorno.
Prima del buffer overflow:
┌────────────────────┐
│ buffer[64] │ ← i 64 byte del buffer
│ saved rbp │ ← base pointer salvato
│ return address │ ← indirizzo dove tornare (es. 0x401234)
└────────────────────┘
Dopo il buffer overflow con 80 byte di 'A':
┌────────────────────┐
│ AAAAAAAAAAAAAAAA.. │ ← i primi 64 byte
│ AAAAAAAA │ ← 8 byte sovrascrivono il saved rbp
│ AAAAAAAA │ ← 8 byte sovrascrivono il return address!
└────────────────────┘
→ quando la funzione torna, salta a 0x4141414141414141
→ crash (o, se l'attaccante controlla l'input, esecuzione arbitraria)
Questo è il motivo per cui in C non si usa strcpy o gets ma strncpy e fgets che richiedono di specificare la dimensione massima. E questo è il motivo per cui linguaggi come Python, Java, Go e Rust non hanno questo problema — gestiscono la memoria con controlli automatici dei limiti.
Le librerie condivise
Tra lo stack e l'heap, nella mappa di memoria di un processo, trovi le librerie condivise — file .so (shared object) su Linux, .dll su Windows.
Invece di includere il codice di printf direttamente in ogni eseguibile, Linux carica la libreria C (libc.so) una volta in memoria e la mappa nello spazio di indirizzi virtuale di ogni processo che ne ha bisogno. Ogni processo vede la libreria come parte del proprio spazio, ma fisicamente in RAM c'è una sola copia condivisa tra tutti.
# Vedere le librerie caricate da un processo
cat /proc/self/maps | grep "\.so"
# 7f8a3c000000-7f8a3c200000 r-xp 00000000 08:01 654321 /lib/x86_64/libc.so.6
# 7f8a3e000000-7f8a3e010000 r-xp 00000000 08:01 654322 /lib/x86_64/libpthread.so.0
# Oppure con ldd (list dynamic dependencies)
ldd /bin/bash
# linux-vdso.so.1
# libtinfo.so.6 => /lib/x86_64-linux-gnu/libtinfo.so.6
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
Le librerie condivise sono rilevanti per la sicurezza perché le loro funzioni (come system(), execve(), printf()) sono spesso i bersagli degli exploit avanzati. Tecniche come ret2libc sfruttano il fatto che la libreria C è sempre caricata in memoria e contiene funzioni potenti come system("/bin/sh") — l'attaccante non inietta codice, fa semplicemente saltare l'esecuzione a una funzione già esistente in memoria.
ASLR: rendere la mappa imprevedibile
Una contromisura fondamentale contro gli exploit è ASLR (Address Space Layout Randomization), introdotta nel kernel Linux nel 2005.
Senza ASLR, ogni volta che avvii un programma, stack, heap e librerie sono sempre agli stessi indirizzi. Un attaccante che conosce il binario sa esattamente dove si trova system() in memoria, e può costruire un exploit che funziona in modo riproducibile.
Con ASLR, gli indirizzi di stack, heap e librerie vengono randomizzati ad ogni avvio. L'attaccante non sa dove si trovano le cose in memoria.
# Verificare che ASLR sia attivo
cat /proc/sys/kernel/randomize_va_space
# 0 = disabilitato
# 1 = stack e librerie randomizzati
# 2 = stack, librerie, heap randomizzati (consigliato)
# Dimostrare ASLR in azione
# Esegui più volte: l'indirizzo dello stack cambia ogni volta
for i in {1..4}; do
cat /proc/self/maps | grep stack
done
# Disabilitare temporaneamente ASLR (per test/debug — mai in produzione)
sudo sysctl -w kernel.randomize_va_space=0
ASLR non è infallibile — esistono tecniche per aggirarlo (information leak, brute force su sistemi a 32-bit, heap spraying) — ma aumenta significativamente la difficoltà degli exploit.
Leggere la memory map di un processo
Torniamo a /proc, che abbiamo visto nel capitolo A2. Il file /proc/[pid]/maps è la fotografia completa della memoria virtuale di un processo:
cat /proc/self/maps
# Esempio di output annotato:
# INIZIO-FINE PERM OFFSET DEV INODE NOME
# 55a3b4200000-55a3b4220000 r-xp 00000000 08:01 123 /bin/bash ← text (codice)
# 55a3b4420000-55a3b4430000 r--p 00020000 08:01 123 /bin/bash ← data read-only
# 55a3b4430000-55a3b4440000 rw-p 00030000 08:01 123 /bin/bash ← data scrivibile
# 55a3b5200000-55a3b5400000 rw-p 00000000 00:00 0 [heap] ← heap
# 7f8a3c000000-7f8a3c200000 r-xp 00000000 08:01 654 /lib/libc ← libc text
# 7f8a3e200000-7f8a3e210000 rw-p 00000000 00:00 0 ← stack di thread
# 7ffe12345000-7ffe12367000 rw-p 00000000 00:00 0 [stack] ← stack principale
# 7ffe12390000-7ffe12394000 r--p 00000000 00:00 0 [vvar] ← variabili kernel
# 7ffe12394000-7ffe12396000 r-xp 00000000 00:00 0 [vdso] ← syscall veloci
# I permessi:
# r = leggibile w = scrivibile x = eseguibile p = private (COW)
Ogni riga è una regione di memoria virtuale. Le cose da notare:
- Il codice (
r-xp) è leggibile ed eseguibile ma non scrivibile - Lo stack e l'heap (
rw-p) sono leggibili e scrivibili ma non eseguibili (NX bit) - Le zone senza nome sono tipicamente memoria anonima allocata con
mmap()
Il vdso (virtual dynamic shared object) è interessante: è una piccola zona di codice che il kernel mappa in ogni processo. Contiene versioni ottimizzate di alcune syscall molto frequenti (come gettimeofday) che possono essere eseguite senza il salto Ring 3 → Ring 0, risparmiando tempo.
Fermati un momento
Finora abbiamo parlato di stack, heap, text, BSS, librerie. Può sembrare un elenco di compartimenti tecnici. Prendiamoci un momento per capire il quadro.
Immagina un cantiere edile. Il processo è il cantiere intero.
Il text segment è il progetto architettonico — i blueprint. Non si toccano mentre si costruisce: sono in sola lettura. Se qualcuno modifica i blueprint durante i lavori, è un casino.
L'heap è il magazzino dei materiali. I muratori (il programma) possono andarci a prendere mattoni (allocare memoria) e riportarli quando non servono più (free). È un posto grande e disorganizzato — se un muratore prende materiale e non lo riporta mai, il magazzino si riempie (memory leak). Se riporta materiale che poi un altro muratore usa ancora (use after free), succedono guai.
Lo stack è il banco di lavoro del singolo operaio. Ogni operaio (funzione) ha il suo spazio temporaneo dove mette gli attrezzi mentre lavora (variabili locali). Quando finisce e se ne va, il suo spazio viene liberato per il prossimo. Il problema è che sul banco c'è anche un post-it che dice "quando hai finito, torna al piano 3, stanza 4" (l'indirizzo di ritorno). Se qualcuno riesce a cambiare quel post-it, l'operaio torna nel posto sbagliato — magari in un posto controllato dall'attaccante.
La cosa più importante da portarsi via: la memoria di un processo è organizzata in zone con permessi diversi. Codice eseguibile ma non scrivibile. Dati scrivibili ma non eseguibili. Questa separazione è una difesa — e capire come aggirarla è il cuore dell'exploitation moderna.
Esperimento pratico
# 1. Crea un programma C che mostra gli indirizzi delle sue zone di memoria
cat << 'EOF' > mappa_memoria.c
#include <stdio.h>
#include <stdlib.h>
int globale_inizializzata = 42; // .data
int globale_non_inizializzata; // .bss
int main() {
int locale = 10; // stack
char *dinamica = malloc(100); // heap
printf("Codice (text): %p\n", (void*)main);
printf("Globale (data): %p\n", (void*)&globale_inizializzata);
printf("Globale (bss): %p\n", (void*)&globale_non_inizializzata);
printf("Heap: %p\n", (void*)dinamica);
printf("Stack: %p\n", (void*)&locale);
free(dinamica);
return 0;
}
EOF
gcc mappa_memoria.c -o mappa_memoria
./mappa_memoria
# 2. Esegui più volte e osserva: con ASLR attivo,
# heap e stack cambiano ad ogni esecuzione.
# Il codice (text) può essere fisso o variare a seconda della configurazione.
for i in {1..3}; do ./mappa_memoria; echo "---"; done
# 3. Confronta con /proc/maps mentre il processo gira
./mappa_memoria &
sleep 0.1
cat /proc/$!/maps
wait
Domande di verifica
- Perché il segmento text è read-only? Cosa succederebbe se un processo potesse scrivere nel proprio codice?
- Qual è la differenza concettuale tra heap e stack? Quando conviene usare l'uno o l'altro?
- Cos'è un buffer overflow? Perché sovrascrivere l'indirizzo di ritorno sullo stack è così pericoloso?
- Cos'è ASLR e contro quale tipo di attacco difende? Quali sono i suoi limiti?
- Perché il NX bit (heap e stack non eseguibili) non è sufficiente da solo a bloccare tutti gli exploit?
Precedente: A2 — I processi
Prossimo: A4 — I segnali
Continua a leggere
Kali Linux: setup, configurazione e tool fondamentali
Configurare Kali come piattaforma professionale: nmap, Metasploit, hashcat, Wireshark e metodologia.
Vai al capitolo