Fondamenta
16 min read

Il kernel: ring, syscall, user/kernel space

Cos'è il kernel, i ring di privilegio, syscall e separazione user/kernel space.

linux
kernel
syscall

Blocco: A — Il kernel e i processi
Prerequisiti: nessuno
Collegato a: A2 (processi), A3 (memoria), B1 (utenti e identità)


Il problema che ha generato tutto

Siamo negli anni '50. I computer esistono, ma funzionano in modo che oggi sembrerebbe assurdo: un programma alla volta. Si caricava un programma su nastro magnetico, il computer lo eseguiva, poi si fermava. Nel frattempo la CPU — la parte più costosa e preziosa della macchina — stava ferma ad aspettare che il nastro si riavvolgesse, o che l'operatore caricasse il prossimo lavoro. Una perdita enorme.

La prima risposta fu il batch processing: si raccoglievano più programmi insieme e si facevano girare in sequenza automatica, senza intervento umano tra uno e l'altro. Meglio, ma la CPU continuava a stare ferma ogni volta che un programma aspettava dati dal disco o da una stampante. L'hardware era fermo, e l'hardware costava quanto un appartamento.

Negli anni '60 arriva l'idea rivoluzionaria: e se facessimo girare più programmi contemporaneamente, passando da uno all'altro rapidamente? Mentre il programma A aspetta che il disco risponda, la CPU esegue il programma B. Mentre B aspetta input dall'utente, torna su A. L'utente ha l'illusione che i suoi programmi girino in parallelo. Questa idea si chiama multiprogramming, poi time-sharing.

Ma qui emerge immediatamente un problema gravissimo.


Il problema della fiducia

Se più programmi girano "contemporaneamente" sulla stessa macchina, cosa impedisce al programma A di leggere la memoria del programma B? Cosa impedisce a un programma di scrivere sull'area di memoria del sistema operativo e mandarla in crash? Cosa impedisce a un programma mal scritto — o deliberatamente malevolo — di prendere il controllo dell'hardware e bloccare tutto?

Negli anni '60 questi non erano problemi teorici. Erano cose che succedevano davvero. Un bug in un programma poteva corrompere la memoria di un altro. Un programma poteva monopolizzare la CPU e non restituirla mai. I sistemi erano fragili e inaffidabili.

I ricercatori del MIT, dei Bell Labs e di altri centri capirono che serviva una soluzione hardware, non solo software. Perché una soluzione puramente software può essere aggirata da un programma furbo. Se scrivi una regola che dice "non puoi leggere la memoria altrui", ma la regola è implementata in codice che gira sullo stesso piano di tutti gli altri programmi, un programma può semplicemente ignorarla o sovrascriverla.

Serviva che la CPU stessa facesse rispettare certi confini. Che fosse il processore, non il software, a dire: "questo programma non può fare questa cosa".


La soluzione hardware: i Ring di privilegio

Intel, negli anni '70 e '80, introdusse nei processori x86 il concetto di livelli di privilegio, chiamati informalmente ring (anelli), perché si immaginano come cerchi concentrici dove quello più interno ha più potere.

        Ring 0 — Kernel
       Ring 1 — (non usato)
      Ring 2 — (non usato)
     Ring 3 — Applicazioni utente

L'idea è semplice ma potente: la CPU tiene traccia in quale ring sta eseguendo il codice corrente. In base al ring, alcune istruzioni sono permesse e altre no.

  • Ring 0 è il livello più privilegiato. Il codice che gira qui può fare assolutamente tutto: accedere a qualsiasi indirizzo di memoria, parlare direttamente con l'hardware, modificare le tabelle che controllano la memoria stessa. È il livello del kernel.

  • Ring 3 è il livello meno privilegiato. Il codice che gira qui — cioè tutti i tuoi programmi normali — non può accedere direttamente all'hardware, non può leggere la memoria di altri processi, non può eseguire certe istruzioni CPU riservate. Se ci prova, la CPU genera un'eccezione e il kernel interviene, tipicamente terminando il programma.

Linux e Windows usano solo Ring 0 e Ring 3. Ring 1 e Ring 2 esistono nell'architettura x86 ma sono rimasti inutilizzati — nessuno ha mai trovato un uso pratico per i livelli intermedi. Oggi Ring 1 viene usato informalmente dagli hypervisor di virtualizzazione in alcune configurazioni, ma è un caso speciale.


Il kernel: l'arbitro del sistema

Il kernel è il programma che gira in Ring 0. È l'unico software che ha accesso completo a tutto l'hardware. Quando Linux si avvia, il kernel si carica in memoria e non smette mai di girare finché la macchina è accesa. Tutto il resto — bash, Firefox, il tuo server Node.js — gira in Ring 3, sotto la supervisione del kernel.

Pensa al kernel come al direttore di un grande magazzino. I clienti (i programmi) non possono andare nel magazzino a prendere quello che vogliono da soli. Devono chiedere al direttore. Il direttore verifica che abbiano il diritto di avere quella cosa, la va a prendere, e gliela consegna.

Questo ha una conseguenza importante: un crash in kernel space abbatte tutto. Se il direttore sviene, il magazzino si blocca. È il kernel panic di Linux — lo schermo si riempie di testo tecnico e la macchina si ferma. Al contrario, se crasha un programma in user space (Ring 3), il kernel lo termina ordinatamente e tutto il resto continua a funzionare.


Il ponte tra i due mondi: le syscall

Sorge un problema pratico: se i programmi non possono parlare con l'hardware direttamente, come fanno a fare cose utili? Come fa un programma a leggere un file, inviare dati in rete, stampare qualcosa a schermo?

La risposta è il meccanismo delle system call (syscall): un insieme di funzioni messe a disposizione dal kernel che i programmi in Ring 3 possono invocare. È l'unico modo legittimo per passare da Ring 3 a Ring 0.

Su Linux x86-64 ci sono circa 330 syscall. Ognuna ha un nome e un numero identificativo. Alcuni esempi:

NumeroNomeCosa fa
0readLegge dati da un file descriptor
1writeScrive dati su un file descriptor
2openApre un file
56cloneCrea un nuovo processo o thread
59execveEsegue un nuovo programma
60exitTermina il processo corrente
62killInvia un segnale a un processo

Quando scrivi open("file.txt", O_RDONLY) in C, non stai aprendo un file direttamente. Stai chiedendo al kernel di farlo per te.


Come funziona una syscall, passo per passo

Vediamo cosa succede fisicamente quando un programma chiama write() per stampare qualcosa a schermo.

1. Il programma chiama write()

Normalmente in C si scrive:

#include <unistd.h>
write(1, "ciao\n", 5);

Questa non è una funzione magica. È una funzione definita nella libreria C (su Linux si chiama glibc, su macOS è libSystem). Quella funzione non fa niente di speciale da sola: il suo unico scopo è preparare i parametri nel formato che il kernel si aspetta e poi invocare la syscall.

2. La glibc prepara i parametri nei registri della CPU

Le syscall non passano i parametri sullo stack come le normali funzioni C — li mettono direttamente nei registri della CPU, secondo una convenzione precisa:

rax = 1       ← numero della syscall (write = 1 su Linux x86-64)
rdi = 1       ← primo argomento: file descriptor (1 = stdout)
rsi = &buf    ← secondo argomento: puntatore al buffer
rdx = 5       ← terzo argomento: numero di byte da scrivere

3. Viene eseguita l'istruzione syscall

Questa è un'istruzione speciale della CPU. Quando la CPU la incontra, cambia modalità: passa da Ring 3 a Ring 0, salva lo stato del programma (tutti i registri, la posizione nel codice), e salta al gestore delle syscall del kernel.

4. Il kernel esegue sys_write()

Il kernel legge rax per sapere quale syscall è stata richiesta (1 = write), poi verifica che tutto sia lecito:

  • Il file descriptor 1 è valido per questo processo?
  • Il puntatore al buffer punta a memoria che appartiene a questo processo?
  • Il processo ha i permessi per scrivere su questo fd?

Se tutto è in ordine, esegue l'operazione.

5. La CPU torna in Ring 3

Il kernel mette il valore di ritorno in rax (i byte scritti, oppure un numero negativo in caso di errore) e ripristina lo stato del programma. L'esecuzione riprende dalla riga successiva alla chiamata.

┌─────────────────────────────────────────────┐
│  RING 3 (User space)                        │
│  write(1, "ciao\n", 5)                      │
│  → glibc prepara i registri                 │
│  → istruzione "syscall"           ──────────┼──┐
├─────────────────────────────────────────────┤  │
│  RING 0 (Kernel space)            ◄─────────┼──┘
│  sys_write():                               │
│    verifica fd, buffer, permessi            │
│    scrive nel terminale                     │
│    mette risultato in rax         ──────────┼──┐
├─────────────────────────────────────────────┤  │
│  RING 3 (User space)              ◄─────────┼──┘
│  il programma riceve il risultato           │
└─────────────────────────────────────────────┘

Questo salto Ring 3 → Ring 0 → Ring 3 avviene milioni di volte al secondo su un sistema in uso normale.


La libreria C non è obbligatoria

Questo è un punto che la maggior parte degli sviluppatori non ha mai considerato.

Quando scrivi un programma C e usi write(), stai usando una funzione della libreria C (glibc su Linux). Quella funzione è un intermediario comodo: si occupa di mettere i parametri nei posti giusti e di invocare la syscall. Ma non è obbligatorio passarci attraverso.

Un programma può invocare le syscall direttamente, senza usare la libreria C. Puoi farlo in due modi:

Modo 1: tramite la funzione syscall() di glibc

Glibc stessa offre una funzione generica chiamata syscall() che ti permette di invocare qualsiasi syscall per numero:

#include <sys/syscall.h>
#include <unistd.h>

int main() {
    // SYS_write = 1, fd = 1 (stdout), buf, len
    syscall(SYS_write, 1, "ciao\n", 5);
    return 0;
}

Stai ancora usando glibc, ma stai bypassando il wrapper specifico di write(). Il risultato finale è identico.

Modo 2: assembly inline — senza librerie

Puoi scrivere direttamente le istruzioni assembly dentro il codice C:

int main() {
    // Scriviamo "ciao\n" usando assembly x86-64 puro
    // senza includere nessuna libreria
    asm volatile (
        "mov $1, %%rax\n"   // syscall numero 1 = write
        "mov $1, %%rdi\n"   // fd = 1 (stdout)
        "mov %0, %%rsi\n"   // puntatore al buffer
        "mov $5, %%rdx\n"   // 5 byte da scrivere
        "syscall\n"
        :
        : "r"("ciao\n")
        : "rax", "rdi", "rsi", "rdx"
    );
    asm volatile (
        "mov $60, %%rax\n"  // syscall numero 60 = exit
        "xor %%rdi, %%rdi\n" // codice di uscita = 0
        "syscall\n"
        ::: "rax", "rdi"
    );
}

Questo programma non include nessun header, non linka nessuna libreria. Compila e funziona. Parla direttamente con il kernel tramite le istruzioni della CPU.

Perché questo conta per la sicurezza?

Perché è esattamente così che funziona uno shellcode — il codice che un exploit inietta in un processo vulnerabile. Lo shellcode non può appoggiarsi alle librerie del sistema (non sa dove sono in memoria, e non vuole farsi scoprire dalle policy di sicurezza). Invoca le syscall direttamente in assembly. Capire questo meccanismo ti permette di leggere e comprendere shellcode reali.


Vedere le syscall in azione: strumenti per ogni sistema

Qui è importante essere precisi, perché gli strumenti cambiano in base al sistema operativo.

Su Linux: strace

strace usa una syscall del kernel Linux chiamata ptrace per intercettare e registrare ogni syscall che un processo esegue. È disponibile su qualsiasi distribuzione Linux.

# Osserva tutte le syscall di un comando
strace ls

# Output (semplificato):
execve("/bin/ls", ["ls"], ...)               = 0
openat(AT_FDCWD, ".", O_RDONLY|O_DIRECTORY)  = 3
getdents64(3, /* entries */, 32768)          = 376
write(1, "file1  file2\n", 13)              = 13
exit_group(0)                                = ?

# Riepilogo statistico — quante chiamate di ogni tipo e quanto tempo
strace -c ls

# Filtra per tipo di syscall
strace -e trace=openat,read,write ls

# Attaccarsi a un processo già in esecuzione
sudo strace -p $(pidof nginx)

# Segui i processi figli (fork)
strace -f bash -c "ls && echo ciao"

Anche un comando banale come ls fa decine di syscall: apre le librerie condivise, legge le variabili d'ambiente, apre la directory, legge le voci, scrive l'output. strace le mostra tutte.

Su macOS: dtruss e dtrace

macOS non ha strace. Il kernel di macOS (XNU) è diverso da Linux e usa un meccanismo diverso. Lo strumento equivalente è dtruss, che si basa su DTrace — un framework di tracing potentissimo sviluppato da Sun Microsystems per Solaris e poi adottato da macOS e BSD.

# dtruss richiede di disabilitare SIP (System Integrity Protection) oppure...
# usare sudo
sudo dtruss ls

# Oppure usare dtrace direttamente (più flessibile)
sudo dtrace -n 'syscall:::entry /pid == $target/ { printf("%s\n", probefunc); }' -p $(pgrep -n bash)

Nota importante: su macOS moderno (Apple Silicon, macOS Ventura+) ci sono restrizioni aggiuntive legate a SIP che rendono il tracing più complicato. Per fare sperimentazione seria sulle syscall è molto più pratico usare una VM Linux — con VirtualBox, VMware, o anche UTM su Mac ARM. Kali Linux in VM è esattamente l'ambiente giusto per questo tipo di lavoro.

Su Windows: Process Monitor

Windows non ha syscall nel senso Linux del termine — ha le Windows API che internamente chiamano le NT Native API (NtCreateFile, NtReadFile, ecc.). Lo strumento equivalente è Process Monitor di Sysinternals (Microsoft), che mostra ogni operazione su file, registro, rete e processo in tempo reale.

Riassunto strumenti

SistemaStrumentoNote
LinuxstraceStandard, disponibile ovunque
LinuxltraceCome strace ma per le chiamate alle librerie
macOSdtrussBasato su DTrace, richiede sudo
macOSdtracePiù potente e flessibile
WindowsProcess MonitorGUI, da Sysinternals
QualsiasiVM Linux + straceIl modo più pratico per imparare

Seccomp: costruire una prigione per un processo

Seccomp (Secure Computing) nasce nel 2005, introdotto da Andrea Arcangeli nel kernel Linux 2.6.12. L'idea originale era semplice fino all'estremo: permettere a un processo di dichiarare "d'ora in poi accetto solo queste 4 syscall — read, write, exit, sigreturn — e nient'altro".

Era pensato per ambienti dove si eseguiva codice non fidato, come i grid computing. Il processo si caricava il codice da eseguire, poi si metteva in modalità "strict seccomp" e da quel momento non poteva più fare nulla di pericoloso — solo leggere input, scrivere output, e uscire.

Oggi seccomp si usa in una forma molto più sofisticata: la filter mode (introdotta nel 2012 con il kernel 3.5), che permette di definire regole granulari usando un mini-linguaggio chiamato BPF (Berkeley Packet Filter — lo stesso usato per filtrare pacchetti di rete, riutilizzato in modo creativo).

Chi usa seccomp oggi:

  • Docker: ogni container gira con un profilo seccomp predefinito che blocca circa 44 syscall considerate pericolose (tra cui reboot, kexec, ptrace, mount).
  • Chrome e Firefox: i renderer — la parte che esegue il codice JavaScript delle pagine web — girano con seccomp attivo. Anche se un attaccante sfrutta un bug nel renderer, non può fare molte cose perché le syscall pericolose sono bloccate.
  • systemd: può applicare profili seccomp ai servizi di sistema.
  • OpenSSH: alcune versioni lo usano per limitare le syscall disponibili al processo di autenticazione.

Verificare seccomp su un processo

# Leggi lo stato seccomp di un processo qualsiasi
cat /proc/self/status | grep Seccomp
# Seccomp: 0   ← nessun filtro attivo (la shell normale)

cat /proc/$(pidof firefox)/status | grep Seccomp
# Seccomp: 2   ← filter mode attivo (Firefox protegge i suoi renderer)

# I valori possibili:
# 0 = nessun filtro
# 1 = strict mode (solo read, write, exit, sigreturn)
# 2 = filter mode (regole personalizzate via BPF)

Scrivere un filtro seccomp in C

Questo esempio mostra come un processo può limitare se stesso — è utile capirlo perché è esattamente quello che fanno Docker e i browser:

#include <stdio.h>
#include <unistd.h>
#include <seccomp.h>    // installa con: apt install libseccomp-dev

int main() {
    // Crea un contesto seccomp con azione default: blocca tutto (SCMP_ACT_KILL)
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);

    // Permetti esplicitamente solo queste syscall
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);

    // Attiva il filtro — da questo momento in poi è irreversibile
    seccomp_load(ctx);
    seccomp_release(ctx);

    // Questo funziona: write è nella whitelist
    write(1, "write permessa\n", 15);

    // Questo ammazzerebbe il processo: open non è nella whitelist
    // open("/etc/passwd", 0);  // ← il kernel ucciderebbe il processo qui

    return 0;
}
# Compila e prova
gcc seccomp_demo.c -o seccomp_demo -lseccomp
./seccomp_demo
# Output: "write permessa"

# Strace mostra che dopo seccomp_load, le syscall non permesse
# causano SIGKILL immediato
strace ./seccomp_demo

Perché seccomp è importante per la sicurezza offensiva?

Perché quando analizzi un processo target per trovare vulnerabilità, devi sapere se usa seccomp. Se lo usa, anche dopo aver trovato un bug e averlo sfruttato, le syscall che puoi chiamare sono limitate. Non puoi aprire file arbitrari, non puoi creare connessioni di rete, non puoi eseguire altri programmi. Il tuo exploit funziona ma non riesci a fare nulla di utile con la shell ottenuta. Esistono tecniche specifiche per aggirare seccomp (trovare syscall permesse che permettono comunque azioni pericolose, o sfruttare il fatto che i filtri possono essere ereditati da processi figlio) — ma prima devi sapere che esiste.


Esperimento pratico completo

Questo esperimento funziona su qualsiasi sistema Linux (inclusa una VM Kali):

# 1. Crea la directory di lavoro
mkdir ~/lab_syscall && cd ~/lab_syscall

# 2. Scrivi un programma che usa write() normalmente
cat << 'EOF' > normale.c
#include <unistd.h>
int main() {
    write(1, "Ciao dalla libreria C\n", 22);
    return 0;
}
EOF

# 3. Scrivi lo stesso programma senza usare la libreria C
cat << 'EOF' > diretto.c
int main() {
    // Nessun #include — assembly diretto
    asm volatile (
        "mov $1, %%rax\n"
        "mov $1, %%rdi\n"
        "lea msg(%%rip), %%rsi\n"
        "mov $22, %%rdx\n"
        "syscall\n"
        "mov $60, %%rax\n"
        "xor %%rdi, %%rdi\n"
        "syscall\n"
        ::: "rax", "rdi", "rsi", "rdx"
    );
    asm volatile (".section .rodata\nmsg: .ascii \"Ciao senza librerie\\n\"\n");
}
EOF

# 4. Compila entrambi
gcc normale.c -o normale
gcc diretto.c -o diretto

# 5. Esegui e confronta con strace
echo "=== Normale ==="
strace -c ./normale

echo "=== Diretto ==="
strace -c ./diretto

# "normale" farà molte più syscall: deve caricare glibc,
# leggere variabili d'ambiente, ecc.
# "diretto" farà esattamente 2 syscall: write + exit

Domande di verifica

  1. Perché la soluzione al problema della protezione tra programmi doveva essere implementata in hardware e non solo in software?
  2. Cosa succederebbe a tutto il sistema se crashasse un programma che gira in Ring 0? E uno in Ring 3?
  3. Perché strace non funziona su macOS? Qual è lo strumento equivalente e su cosa si basa?
  4. Cosa significa che uno shellcode "invoca le syscall direttamente"? Perché non usa la libreria C?
  5. Se un processo è protetto da seccomp con whitelist ristretta, cosa cambia per un attaccante che riesce comunque a sfruttare una vulnerabilità nel processo?

Prossimo: A2 — I processi: struttura, stati, /proc

Continua a leggere

I processi: struttura, stati, /proc

Come è fatto un processo Linux, i suoi stati, e cosa puoi imparare leggendo /proc.

Vai al capitolo