💾 Archived View for it.omarpolo.com › articoli › gmid-sandbox.gmi captured on 2023-03-20 at 17:49:02. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2021-11-30)

-=-=-=-=-=-=-

Sulle diverse tecniche di sandboxing

Come funzionalità principale in vista della versione 1.5 di gmid, ho investito del tempo a studiare le principali tecniche di sandboxing che OpenBSD, FreeBSD e Linux (il kernel) mettono a disposizione.

gmid

Con sandboxing intendo le funzionalità offerte da alcuni sistemi operativi per limitare ciò che un programma può fare. Le chiamate a sistema offerte dai vari sistemi operativi si contano sui multipli delle centinaia, eppure la maggioranza dei programmi necessitano solo di un sottoinsieme di queste.

L’argomento di questo articolo è, appunto, il sandboxing di gmid, il mio server gemini. Si tratta di un server per il protocollo Gemini, è in grado di servire file statici ed eseguire script CGI.

Inizialmente, il demone era un singolo processo in ascolto sulla porta 1965 che, all’occorrenza, eseguiva gli script CGI (fork+exec), il tutto gestito da un event-loop basato su poll(2).

Come primo passo, ancora prima di implementare le varie tecniche di sandboxing, ho separato gmid in due processi: il “listener”, che rimane in ascolto sulla porta 1965 ed è il processo che verrà messo nella sandbox, e un “executor”, che si occupa di eseguire gli script CGI. Questa logica separazione dei compiti ha permesso di poter eseguire script CGI senza limitazioni, avendo comunque il processo di rete nella sandbox.

Intendo quindi concentrarmi sulle principali tecniche di sandboxing che sono state usate per limitare il processo “listener” nei vari sistemi operativi.

Capsicum

È probabilmente il più semplice dei tre da capire, ma anche il meno flessibile. Capsicum permette ad un processo di entrare in una sandbox dove solo alcune operazioni sono permesse: ad esempio, dopo cap_enter, open(2) è disattivata, e si possono aprire nuovi file solo usando openat(2). Openat in particolare viene ulteriormente ristretta in modo tale che non sia possibile aprire file al di fuori della cartella passata (i.e. non puoi openat(“..”) per scappare) — un po’ come una chroot(2).

Le chiamate a sistema “disattivate” non terminano il programma se invocate, come succede con pledge o seccomp, ma ritornano con un errore. Questo può essere tanto un vantaggio quanto uno svantaggio, poiché può portare il programma ad eseguire un percorso del codice non completamente testato e, possibilmente, esporre dei bug per questo.

Usare capsicum non e difficile, ma richiede preparazione: l’idea generale da seguire è che devi aprire ogni risorsa che ti potrebbe servire prima di entrare in capsicum.

Applicare capsicum a gmid non ha richiesto praticamente nessun cambiamento al codice: tranne per l’esecuzione degli script CGI, il demone stava già usando openat(2) e accept(2) come unico modo di ottenere nuovi file descriptor, quindi aggiungere il supporto per capsicum è bastato aggiungere una chiamata a cap_enter prima del loop principale. Separare il demone in due processi e stato necessario per poter permettere l’esecuzione degli script CGI, ma si è rivelato utile anche per pledge e seccomp.

Pledge ed unveil

Pledge ed unveil sono due chiamate a sistema fornite dal kernel di OpenBSD per limitare quello che un processo può fare e vedere. Non sono esattamente una tecnica di sandboxing, ma sono talmente simili da essere considerati una.

Con pledge(2), un processo può dire al kernel che da quel momento in poi farà solo un certo tipo di cose. Ad esempio, il programma cat(1) su OpenBSD, prima del main loop, fa una pledge di “stdio rpath” che significa: « da adesso farò solo input/output su file già aperti (“stdio”) e aprirò file in sola lettura (“rpath”).» Se una pledge viene violata, il kernel uccide il programma con un SIGABRT e logga la violazione.

Una delle funzionalità chiave di pledge è la possibilità di rimuovere permessi man mano che si va avanti. Ad esempio, si può partire con una pledge di “A B C” e successivamente farne un’altra di “A C”, rimuovendo effettivamente B. Non e possibile però ottenere nuovi permessi.

Unveil è il complemento naturale di pledge: permette di limitare le porzioni di file system alle quali un processo può accedere.

Un aspetto importante sia di pledge che di unveil è che vengono resettate sull’exec(2): questo è il motivo per il quale non le categorizzo strettamente come metodi di sandboxing. Ad ogni modo, questo aspetto è, secondo me, una grande dimostrazione di pragmatismo, nonché la ragione per la quale pledge e unveil sono così diffuse, anche in software non scritto inizialmente per OpenBSD.

Su UNIX abbiamo diversi programmi che sono, o si comportano come, shell. Spesso usiamo fork(2) ed exec(2) per eseguire altri programmi che fanno cose che non vogliamo fare. Inoltre, molti programmi seguono, o possono essere semplicemente modificati in modo che lo facciano, una fase di inizializzazione dove hanno bisogno di molti permessi e di accedere a varie parti del file system, e una seguente fase “main-loop” dove necessitano di poche chiamate a sistema. Questo significa che è fondamentalmente impossibile usare capsicum(4) o seccomp(2) in certi programmi, quando è possibile usare pledge(2).

Prendiamo una shell ad esempio: non è possibile usare capsicum(4) in csh. Non si può usare seccomp(2) su bash; però uno far girare ksh sotto pledge:

; grep 'if (pledge' /usr/src/bin/ksh/ -RinH
/usr/src/bin/ksh/main.c:150:            if (pledge("stdio rpath wpath cpath fattr flock getpw proc "
/usr/src/bin/ksh/main.c:156:            if (pledge("stdio rpath wpath cpath fattr flock getpw proc "
/usr/src/bin/ksh/misc.c:303:            if (pledge("stdio rpath wpath cpath fattr flock getpw proc "

OpenBSD è il solo sistema operativo dove tutti e due i processi di gmid, il listener e l’executor, sono in una sandbox. Il listener gira con le pledge “stdio recvfd rpath inet” e può solo vedere le cartelle che serve, mentre l’executor gira con “stdio sendfd proc exec”.

Per concludere, pledge è più che altro una sorta di complemento del compilatore, un controllo a run-time per verificare che il programma fa quello che ha promesso di fare, più che una tecnica di sandboxing.

Seccomp

Seccomp è enorme. È il metodo di sandboxing più flessibile e complesso che conosca. È stato anche il più scomodo da usare, ma mi sono divertito lo stesso.

Seccomp permette di scrivere uno script in un particolare linguaggio (BPF) che viene eseguito nel kernel prima di OGNI chiamata a sistema. Lo script può decidere di permettere o meno certe syscall, di ucciderlo o ritornare un errore: fondamentalmente può controllare il programma. Oh, e vengono anche ereditati dai figli del tuo programma, quindi controlla anche quelli.

I programmi BPF sono progettati per essere “sicuri” da eseguire nel kernel: non sono Turing-completi, perché hanno salti condizionali ma solo in avanti, e hanno una lunghezza massima, quindi si sa per certo che uno script BPF terminerà e che al massimo l’esecuzione prenderà n tempo. I programmi BPF, d’ora in poi chiamati filtri, vengono anche validati per assicurarsi che ogni possibile percorso finisca con un return.

Questi filtri possono acceder al numero della chiamata a sistema ed ai suoi parametri. Una restrizione importante è che non possono deferenziare puntatori: ciò significa che non possono negare una open(2) se il primo argomento è “/tmp”, ma possono permettere ioctl(2) solo sui file descriptor 1, 5 e 27.

Quindi, come si scrivono questi filtri? Beh, spero vi piacciano le macro in C :)

struct sock_filter filter[] = {
        /* load the *current* architecture */
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
            (offsetof(struct seccomp_data, arch))),
        /* ensure it's the same that we've been compiled on */
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K,
            SECCOMP_AUDIT_ARCH, 1, 0),
        /* if not, kill the program */
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),

        /* load the syscall number */
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
            (offsetof(struct seccomp_data, nr))),

        /* allow write */
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

        /* … */
};

struct sock_fprog prog = {
        .len = (unsigned short) (sizeof(filter) / sizeof(filter[0])),
        .filter = filter,
};

e poi lo si carica con prctl

if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) {
        fprintf(stderr, "%s: prctl(PR_SET_NO_NEW_PRIVS): %s\n",
            __func__, strerror(errno));
        exit(1);
}

if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == -1) {
        fprintf(stderr, "%s: prctl(PR_SET_SECCOMP): %s\n",
            __func__, strerror(errno));
        exit(1);
}

Per rendere il tutto più leggibile ho definito una macro SC_ALLOW in questo modo:

/* make the filter more readable */
#define SC_ALLOW(nr)                                            \
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_##nr, 0, 1),   \
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)

che ci permette quindi di scrivere cose come

        /* … */
        SC_ALLOW(accept),
        SC_ALLOW(read),
        SC_ALLOW(openat),
        SC_ALLOW(fstat),
        SC_ALLOW(close),
        SC_ALLOW(lseek),
        SC_ALLOW(brk),
        SC_ALLOW(mmap),
        SC_ALLOW(munmap),
        /* … */

Come si può notare, BPF sembra assembly, ed infatti si parla di BPF bytecode. Questo non è un tutorial per BPF, ma è facile da imparare se si ha visto in passato un po’ di assembly o il bytecode di una qualche macchina virtuale.

Anche il debugging su seccomp è piuttosto complesso. Quando si viola una pledge, il kernel di OpenBSD fa “abortire” il programma (ovvero lo uccide e salva lo stato della RAM su un file detto “coredump”) e salva nei log di sistema qualcosa come:

Jan 22 21:38:38 venera /bsd: foo[43103]: pledge "stdio", syscall 5

quindi sappiamo sia la pledge mancante, “stdio” in questo caso, che la chiamata a sistema che abbiamo provato ad eseguire, 5 in questo caso, la open(2). Abbiamo inoltre un core dump della memoria, quindi possiamo vedere la stacktrace per provare a capire cosa stesse succedendo.

Con BPF, i filtri possono fare tre cose:

quindi per debuggare bisogna implementare la propria strategia da soli. Sto facendo qualcosa di simile a quello che fa OpenSSH: ho uno switch a compile-time per far sì che il filtro mandi un SIGSYS e installo un gestore.

/* uncomment to enable debugging.  ONLY FOR DEVELOPMENT */
/* #define SC_DEBUG */

#ifdef SC_DEBUG
# define SC_FAIL SECCOMP_RET_TRAP
#else
# define SC_FAIL SECCOMP_RET_KILL
#endif

static void
sandbox_seccomp_violation(int signum, siginfo_t *info, void *ctx)
{
        fprintf(stderr, "%s: unexpected system call (arch:0x%x,syscall:%d @ %p)\n",
            __func__, info->si_arch, info->si_syscall, info->si_call_addr);
        _exit(1);
}

static void
sandbox_seccomp_catch_sigsys(void)
{
        struct sigaction act;
        sigset_t mask;

        memset(&act, 0, sizeof(act));
        sigemptyset(&mask);
        sigaddset(&mask, SIGSYS);

        act.sa_sigaction = &sandbox_seccomp_violation;
        act.sa_flags = SA_SIGINFO;
        if (sigaction(SIGSYS, &act, NULL) == -1) {
                fprintf(stderr, "%s: sigaction(SIGSYS): %s\n",
                    __func__, strerror(errno));
                exit(1);
        }
        if (sigprocmask(SIG_UNBLOCK, &mask, NULL) == -1) {
                fprintf(stderr, "%s: sigprocmask(SIGSYS): %s\n",
                    __func__, strerror(errno));
                exit(1);
        }
}

/* … */
#ifdef SC_DEBUG
        sandbox_seccomp_catch_sigsys();
#endif

In questo modo sappiamo almeno quale chiamata a sistema proibita abbiamo provato a chiamare.

Concludendo

Non sono un esperto di sicurezza, quindi sii pure scettico delle mie conclusioni, ma credo che se vogliamo costruire un sistema sicuro, dobbiamo cercare di rendere questi importanti meccanismi di sicurezza il più possibile accessibili, senza però renderli inutili.

Se un meccanismo di sicurezza è semplice da capire, usare e debuggare, possiamo aspettarci che sia impiegato da un largo numero di persone, e tutti ne beneficeremmo. Questo è quello che mi piace del sistema OpenBSD: nel corso degli anni hanno sempre cercato soluzioni più semplici a problemi comuni, quindi oggi abbiamo cose come reallocarray, strlcat & strlcpy, strtonum e via dicendo. Piccole cose che però rendono gli errori più difficili da scrivere.

Si può criticare pledge(2) od unveil(2) ma un aspetto importante — nonché oggettivo — da sottolineare è quanto sia semplice aggiungere il supporto per pledge e unveil in programmi già esistenti. Ci sono window manager, interpreti, server, e vari programmi d’utilità che girano sotto pledge, ma non conosco un singolo window manager che usi seccomp(2).

Parlando in particolare di linux, solo l’implementazione attuale di seccomp su gmid è circa metà delle righe di codice della prima versione del demone.

Proprio come non sia possibile rendere sicuro un sistema sfruttando la sola ignoranza, non è possibile farlo nemmeno con la complessità: in fondo, la differenza tra oscurità/ignoranza e complessità non è molta.

In ogni caso, grazie di essere arrivato fin qua! È stato un viaggio divertente: ho imparato molto e mi sono divertito. Per segnalarmi qualcosa, scrivi una mail a <op at omarpolo dot com>, oppure manda un messaggio a op2 su freenode.

$BlogIt: gmid-sandbox.gmi,v 1.1 2021/10/20 07:41:39 op Exp $