Introduzione
Poiché il packet filter PF è implementato nel kernel, viene utilizzata uno
pseudo-device, "/dev/pf", per permettere ai processi in zona utente di
controllarlo. Tale controllo è svolto attraverso una interfaccia ioctl(2).
I sorgenti presenti nel documento sono tratti dal kernel della versione
3.4-RELEASE di OpenBSD.
Device in OpenBSD
A livello del kernel ogni device ha un insieme di funzioni che vengono
chiamate quando un programma utente vi accede.
Per permettere l'utilizzo di alcune macro il nome di tali funzioni è
composto dal nome del device seguito da quello della funzione vera e
propria, ad esempio nel caso di pf avremo pfopen, pfclose e così via.
Ovviamente ogni device supporterà solo le funzioni che necessitano al
proprio utilizzo, in ogni caso esiste un set di funzioni che non mancano
quasi mai, ovvero attach, open, close e ioctl.
Di seguito vedremo in dettaglio queste funzioni e vedremo come sono
implementate nel kernel di OpenBSD. Per pf il file che le contiene è
"/sys/net/pf_ioctl.c".
Attach
Questa funzione è chiamata solo quando il kernel è avviato, viene usata
in genere per inizializzare le varie strutture necessarie al device.
La funzione attach per pf è
l'unico parametro passato è un intero che rappresenta il numero di device
che il driver dovrà gestire, nel caso di pf è fissato a 1.
Come detto prima tale funzione si occupa dell'inizializzazione di alcuni
parametri ed in particolare inizializza i timeout, definisce il
comportamento di default del firewall
pf_default_rule.action = PF_PASS
e avvia le varie componenti di pf
pfr_initialize()
pf_osfp_initialize()
pf_normalize_init()
Open e Close
La funzione pfopen è chiamata quando un programma a livello utente esegue
una open(2) sul device.
È definita nel seguente modo:
int pfopen(dev_t dev, int flags, int fmt, struct proc *p)
{
if (minor(dev) >= 1)
return (ENXIO);
return (0);
}
"dev" indica il numero del device, dev_t è definito in /sys/sys/types.h:
"flags" e "fmt" rappresentano le modalità di apertura indicate nella
open(2).
"proc" è un puntatore alla struttura proc del processo che ha chiamato
la open.
La funzione pfclose ovviamente chiude un device aperto precedentemente.
I parametri sono gli stessi della funzione pfopen:
int pfclose(dev_t dev, int flags, int fmt, struct proc *p)
{
if (minor(dev) >= 1)
return (ENXIO);
return (0);
}
La funzione minor() restituisce il device minor number, se tale valore è
maggiore o uguale a 1 viene restituito l'errore ENXIO (No such device or
address).
minor() fa coppia con major() ed è definito in types.h nel seguente modo:
#define major(x) ((int32_t)(((u_int32_t)(x) >> 8) & 0xff))
#define minor(x) ((int32_t)((x) & 0xff) | (((x) & 0xffff0000) >> 8))
Ioctl
Le ioctl su pf sono implementate dalla funzione pfioctl:
int pfioctl(dev_t dev, u_long cmd, caddr_t addr,
int flags, struct proc *p)
Dove "dev", "flags" e "proc" hanno lo stesso significato visto in pfopen.
"cmd" è il comando da eseguire, i comandi sono definiti nel file
pfvar.h, ma su questo ci torneremo dopo.
"addr" è un puntatore ai parametri passati dal programma utente e varia
da comando a comando, ad esempio il comando DIOCBEGINRULES ne fa un
casting alla struttura pfioc_rule
struct pfioc_rule *pr = (struct pfioc_rule *)addr;
il tipo caddr_t è definito in types.h:
A livello utente il prototipo di una generica ioctl(2) è il seguente
int ioctl(int d, unsigned long request, ...)
dove "d" è il descrittore di un file, e "request" è il comando che si
vuole eseguire, è possibile inserire anche un terzo argomento "arg"
che contiene informazioni aggiuntive necessarie all'esecuzione della
richiesta.
Per vedere come funziona l'interfaccia ioctl verso pf partiamo un po'
più da lontano e precisamente da pfctl.
Vediamo cosa accade quando attiviamo il packet filter con il comando:
Viene settato il flag PF_OPT_ENABLE di opts e viene chiamata la funzione
pfctl_enable:
if (opts & PF_OPT_ENABLE)
if (pfctl_enable(dev, opts))
error = 1;
dev è un file descriptor, restituito da una open(2), che fa riferimento a
"/dev/pf" mentre opts contiene le opzioni impostate. Come abbiamo visto
prima chiamando la open(2) su /dev/pf viene scomodata la funzione pfopen.
La funzione pfctl_enable, sempre all'interno del file pfctl.c, è la
seguente:
int
pfctl_enable(int dev, int opts)
{
if (ioctl(dev, DIOCSTART)) {
if (errno == EEXIST)
errx(1, "pf already enabled");
else
err(1, "DIOCSTART");
}
if ((opts & PF_OPT_QUIET) == 0)
fprintf(stderr, "pf enabled\n");
if (altqsupport && ioctl(dev, DIOCSTARTALTQ))
if (errno != EEXIST)
err(1, "DIOCSTARTALTQ");
return (0);
}
La parte importante di questa funzione è la chiamata a ioctl con /dev/pf
come file descriptor e DIOCSTART come comando di controllo (o richiesta).
Per il resto si può notare che se il flag PF_OPT_QUIET è impostato,
eseguendo pfctl con l'opzione -q, non viene stampata la stringa
"pf enabled", in quanto con tale opzione vengono stampati sullo standard
error solo i messaggi d'errore e di warning.
L'ultimo "if" invece riguarda il sistema ALTQ, che non tratteremo in
questo documento.
Andiamo ora a vedere come sono implementate le ioctl per lo pseudo-device
pf. Tutto quello che ci serve si trova nella directory /sys/net dei
sorgenti del kernel.
Vediamo innanzitutto, nel file pfvar.h, come è definito DIOCSTART:
#define DIOCSTART _IO ('D', 1)
_IO(g,n) è una macro definita in "ioccom.h" per facilitare la definizione
dei comandi ioctl, "g" rappresenta il "magic number" del device ed "n" è
il numero ordinale del comando all'interno del gruppo.
Oltre a _IO sono definite anche _IOW, _IOR e _IOWR, ogni macro corrisponde
ad una possibile direzione per il trasferimento dei dati.
In particolare abbiamo che _IO corrisponde alla direzione IOC_VOID che si
usa quando non ci sono dati da trasferire, invece _IOW corrisponde a
IOC_IN da usare quando i dati devono essere scritti sul device, e così
via.
_IOW, _IOR e _IOWR prendono anche un terzo argomento "t" che rappresenta
la struttura dati coinvolta nel comando, in realtà quello che interessa al
kernel è la dimensione di tali dati.
Vediamo ora, nel file pf_ioctl.c, come è implementato il comando
DIOCSTART. Come spesso accade i comandi sono gestiti attraverso uno switch
su "cmd" che è il comando passato a ioctl come secondo parametro.
switch (cmd) {
case DIOCSTART:
if (pf_status.running)
error = EEXIST;
else {
u_int32_t states = pf_status.states;
bzero(&pf_status, sizeof(struct pf_status));
pf_status.running = 1;
pf_status.states = states;
pf_status.since = time.tv_sec;
if (status_ifp != NULL)
strlcpy(pf_status.ifname,
status_ifp->if_xname, IFNAMSIZ);
DPFPRINTF(PF_DEBUG_MISC, ("pf: started\n"));
}
break;
...
}
Viene controllato innanzitutto se PF è già in esecuzione, in caso
affermativo viene restituito a pfctl l'errore EEXIST, e di conseguenza
pfctl stamperà sullo standard error la frase "pf already enabled":
# pfctl -e
pf enabled
# pfctl -e
pfctl: pf already enabled
Vengono poi impostati alcuni campi della struttura pf_status, che come si
deduce dal nome contiene alcune informazioni sullo stato di pf, pf_status
è definita in pfvar.h:
struct pf_status {
u_int64_t counters[PFRES_MAX];
u_int64_t fcounters[FCNT_MAX];
u_int64_t pcounters[2][2][3];
u_int64_t bcounters[2][2];
u_int32_t running;
u_int32_t states;
u_int32_t since;
u_int32_t debug;
char ifname[IFNAMSIZ];
};
In particolare è possibile notare che viene conservato il valore di states
che contiene il numero di entry nella tabella di stato, che è possibile
vedere con il comando:
Viene anche posto a 1 il valore di pf_status.running, che sta a indicare
che pf è in esecuzione. Viene inizializzato il valore di pf_status.since
utilizzato da print_status in pfctl_parser.c per calcolare il tempo di
esecuzione.
DPFPRINTF è definito nel seguente modo
#define DPFPRINTF(n, x) if (pf_status.debug >= (n)) printf x
la stringa x viene stampata se il livello di debug (pf_status.debug) è
maggiore o uguale al valore n, che nel nostro caso vale PF_DEBUG_MISC.
I possibili livelli di debug, in ordine crescente di informazioni fornite,
sono PF_DEBUG_NONE, PF_DEBUG_URGENT, PF_DEBUG_MISC e PF_DEBUG_NOISY.
I livelli di debug sono impostabili attraverso pfctl con l'opzione
-x <level>, dove level può assumere i valori none, urgent, misc e loud.
In realtà non vedremo mai questa stringa perché in questa posizione la
variabile pf_status.debug è stata azzerata dalla funzione bzero(3) poche
righe più sopra.
La variabile debug viene posta a PF_DEBUG_URGENT all'inizio dalla funzione
pfattach, chiamata dal kernel durante il boot.
Poi se non si indica un livello di debug a pfctl in fase di avvio con
l'opzione -x il valore di debug viene posto a zero, se invece si passa un
valore di debug la funzione pfctl_debug, che si occupa di impostare il
livello di debug, viene comunque chiamata dopo pfctl_enable.
Quindi in ogni caso alla riga
DPFPRINTF(PF_DEBUG_MISC, ("pf: started\n"));
il valore di pf_status.debug è zero, di conseguenza niente stampa.
Questo comportamento è stato corretto alcuni mesi dopo l'uscita della
versione 3.4-RELEASE usata nella stesura di questo documento.
Sono state apportate anche numerose modifiche ben più sostanziali, come
ad esempio, rimanendo nell'ambito ioctl, la sostituzione dei comandi
DIOCBEGINRULES, DIOCCOMMITRULES, DIOCBEGINALTQS, DIOCCOMMITALTQS,
DIOCRINABEGIN e DIOCRINADEFINE con DIOCXBEGIN, DIOCXCOMMIT e
DIOCXROLLBACK, è variata anche la struttura pf_status per permettere
la gestione di altre funzioni.
Conclusioni
Per concludere una curiosità. Durante questo breve viaggio nel kernel ho
trovato due bug... vabbè bug è una parola grossa, in realtà si trattava
di "errori di distrazione" della serie "il primo che li legge li trova"!
In ogni modo quello più consistente era già stato corretto, non stavo
lavorando sull'ultima versione del file in questione, l'altro invece è
stato corretto in meno di 20 minuti dalla comunicazione via email a
Daniel Hartmeier!