AOS - 21 - LINUX MODULES II
Lecture Info
Data:
Sito corso: link
Slides: AOS - 5 LINUX MODULES
Progresso unità: 2/2
Argomenti:
Kprobe Mechanism
Kprobe API
Versioning Macros
dmesg
Kernel Panic
Introduzione: Nella lezione precedente avevamo introdotto la tecnologia dei moduli e il sottosistema del kprobing per instrumentare l'ingresso e l'uscita di determinate funzioni del kernel. In questa lezione analizziamo meglio il kprobing per poi terminare la discussione sui moduli.
1 Kernel Probing
Cerchiamo adesso di capire come è possibile introdurre delle funzionalità di probing all'interno di un kernel.
Un primo approccio potrebbe essere quello di cambiare le JMP
instructions ogni volta che vogliamo instrumentare determinate
funzioni. Questo approccio però è chiaramente poco scalabile, in
quanto ci possono essere veramente tante istruzioni nel kernel da
modificare. Necessitiamo quindi di un approccio più sistematico.
1.1 kprobe Data Structure
La struttura dati principale per effettuare il probing è la struct
kprobe
, definita in <linux/kprobes.h>
. A partire dal kernel 3 c'è
stata una modifica alla struttura:
Kernel < 3
struct kprobe { // ... kprobe_opcode_t addr; // Address of probe // ... const char *symbol_name; // probed function name kprobe_pre_handler_t pre_handler; // Addr of pre-handler kprobe_post_handler_t post_handler; // Addr of post-handler // ... };
A partire dal kernel 3 si è poi introdotta la possibilità di aggiungere più kprobes ad una data funzione.
struct kprobe { struct hlist_node hlist; // list of kprobes for multi-handler support struct list_head list; // count number of times this probe was temporarily disarmed unsigned long nmissed; // .... // .... };
1.2 Kprobe Mechanism
1.2.1 Pre-handler
Le funzioni del kernel che possono essere istrumentalizzate tramite il meccanismo di probing hanno una specifica proprietà: la loro prima istruzione è una istruzione di NOP.
Quando montiamo un kprobe su questa istruzione andiamo a modificare la logica del kernel, sostituendo l'istruzione NOP relativa alla funzione con l'istruzione INT 0x03.
NOP -> INT 0x03
In questo modo siamo in grado di passare il controllo ad un particolare interrupt-handler che gestisce il sistema dei kprobes.
1.2.2 Post-handler
Una funzione binaria può ritornare in un qualsiasi punto del binario. Andare a modificare la logica in ogni possibile punto di ritorno quindi non è una soluzione scalabile.
Detto questo, anche se il punto di ritorno può essere arbitrario, il luogo in cui questo punto di ritorno viene memorizzato non è arbitrario. Quando chiamiamo una funzione infatti andiamo anche a generare lo stack frame per quella funzione. In fondo allo stack frame ci sono le informazioni necessarie passare il controllo alla funzione chiamante. Tra queste informazioni troviamo anche il corretto indirizzo di ritorno da inserire nel PC una volta terminata la chiamata.
L'idea è quindi quella di salvare in una memoria temporeanea l'indirizzo di ritorno, cambiare l'indirizzo di ritorno salvato nello stack con l'indirizzo del post-handler, e una volta che il post-handler termina la sua esecuzione utilizziamo il valore salvato precedentemente per tornare nella funzione chiamante iniziale.
Il costo di questo sistema è quindi quello di eseguire una software traps per girare i nostri kprobes.
1.3 Kprobe API
Il sistema di kprobing permette di definire tre tipologie di handler, che sono
kprobe_pre_handler(): La struct pt_regs può anche essere modificata. Possiamo quindi modificare lo stato dei registri prima di eseguire la funzione di interesse.
typedef int (*kprobe_pre_handler t) (struct kprobe*, struct pt_regs*);
kprobe_post_handler(): Per il post-handler i valori salvati in pt_regs rappresentano lo snapshot dei valori nei registri nel momento in cui la funzione di interesse esce.
typedef int (*kprobe_post_handler t) (struct kprobe*, struct pt_regs*, unsigned long flags);
kprobe_fault_handler(): Utilizzato per specificare come gestire eventuali fault all'interno del pre-handler o post-handler. I fault generati all'interno della funzione instrumentalizzata vengono gestiti come prima.
typedef int (*kprobe_fault_handler t) (struct kprobe*, struct pt_regs*, int trapnr);
kretprobe_handler(): Utilizzato nel caso in cui registriamo una kretprobe.
typedef int (*kretprobe_handler t) (struct kretprobe_instance*, struct pt_regs *);
Notiamo che sia al pre-handler che al post-handler il kernel passa
in input alla funzione lo stato dei registri del processore. Per il
pre-handler, il valore dei registri contenuto in pt_regs
rappresenta lo stato dei registri nel momento in cui è stata
chiamata la INT 0x03
.
1.4 Denial of Probing
Non tutte le funzioni del kernel possono essere strumentalizzate attraverso il meccanismo di kprobing. Le funzioni del kernel che sono "blacklisted" sono inserite nel file
/sys/kernel/debug/kprobes/blacklist
Ci sono varie ragioni che possono portare una funzione ad essere "blacklisted", tra cui:
in-line functions;
functions that are indirectly triggered by probe executions, such as the page fault handler.
2 Useful Macros
Il file include/linux/version.h
viene automaticamente incluso
tramite il file include/linux/module.h
e contiene delle macro che
vengono utilizzate per gestire informazioni relative alla kernel
version tra cui troviamo
UTS_RELEASE: si espande come una stringa che definisce la versione del kernel targettata dal modulo.
LINUX_VERSION_CODE: si espande nella rappresentazione binario della versione del kernel, con un byte per ogni numero che specifica la versione.
KERNEL_VERSION(major, minor, release): si espande nella rappresentazione binaria della versione del kernel definita come major, minor e release.
Abbiamo anche delle macro che ci permettono di rinominare le funzioni di start-up e shutdown dei moduli. Se utilizzate, queste macro si devono trovare alla fine del file sorgente del modulo. Possono essere utilizzate per debuggare o per per avere delle start-up specifiche a determinate versioni:
module_init(): genera una startup routine utilizzabile con il simbolo my_init.
module_init(my_init)
module_exit(): genera una shutdown routine utilizzabile con il simbolo my_exit.
module_init(my_exit)
3 dmesg
Notiamo che la funzione sys_write()
non è immediatamente disponibile
e attiva. Dunque non può essere utilizzata per loggare i messaggi
all'interno del kernel durante la fase di boot. Per produrre dei
messaggi già a partire dal boot abbiamo quindi bisogno di un
sottosistema specifico.
I messaggi che vengono prodotti tramite l'utilizzo di printk() vengono salvati in due posti diversi:
Nella console del device, accessibile tramite il comando dmesg. Non garantito.
In un buffer circolare salvato nella memoria del kernel. Garantito.
Questo duplice trattamento dei messaggi deriva dal fatto che scrivere nel buffer circolare è molto più veloce che scrivere nella console. Dunque devo cercare di minimizzare l'accesso in scrittura alla console.
L'accesso ai buffer circolari è basato su sezioni critiche mentre quello alla console è serializzato. Così facendo siamo sicuri di non perdere nessun tipo di informazioni.
Originariamente il buffer circolare era formato da 4096
bytes (una
pagina). Adesso è composto da vari MBs di memoria. Il suo size è
definito dalla macro LOG_BUF_LEN
, definita in in kernel/printk.c
.
I messaggi del buffer possono essere letti tramite il comando shell
dmesg
che rappresenta il Linux Kernel Messaging System.
3.1 printk()
Opera in modo molto simile alla più nota printf()
, ma printk()
non
può ricevere floating point values. La format string che prende in
input può contenere delle macro che specificano la criticità del
messaggio da loggare.
Per garantire una semantica exactly-once la stampa dei messaggi
nella console viene effettuata in modo sincrono. In particolare la
printk()
non ritorna il controllo fino a quando il messaggio è
consegnato al device-driver di interesse.
Una volta eseguita, oltre a loggare il messaggio di interesse, va anche a verificare se il thread che ha chiamato la funzione deve rilasciare la CPU. Questo significa che chiamare la printk() ci potrebbe far perdere il controllo della CPU.
3.2 Message Priority Levels
Le macro per specificare i livelli di priorità sono definiti nel
file include/linux/kernel.h
, e sono
KERN_EMERG: System unstable
#define KERN_EMERG "<0>"
KERN_ALERT: Action must be taken immediately
#define KERN_ALERT "<1>"
KERN_CRIT: Critical conditions
#define KERN_CRIT "<2>"
KERN_ERR: Error conditions
#define KERN_ERR "<3>"
KERN_WARNING: Warning conditions
#define KERN_WARNING "<4>"
KERN_NOTICE: Normal but significant condition
#define KERN_NOTICE "<5>"
KERN_INFO: Informational
#define KERN_INFO "<6>"
KERN_DEBUG: Debug-levle messages
#define KERN_DEBUG "<7>"
Segue un esempio di utilizzo
printk(KERN_WARNING "message to print")
Esistono poi delle funzioni che chiamano printk con uno specifico
livello di priorità come la pr_emerg
o pr_alert
o pr_crit
.
3.3 Message Priority Treatment
Esistono quattro parametri configurabili per il sistema dei messaggi. Questi parametri possono essere trovati e modificato nella cartella
/sys/modules/printk/parameters
e sono:
console_loglevel: Livello che specifica la soglia per andare a loggare i messaggi in console. In particolare tutti i messaggi con livello < di console_loglevel vengono loggati in console oltre ad andare sul buffer circolare.
default_message_loglevel: Livello di priorità associato automaticamente ai messaggi che non esplicitano il loro livello di priorità.
minimum_console_loglevel: livello minimo per accettare i messaggi del log nella console.
default_console_loglevel: livello di default per i messaggi destinati ad andare nella console.
3.4 syslog()
La system call syslog()
è l'unica che ci permette di leggere i
messaggi all'interno del buffer circolare.
int syslog(int type, char *bufp, int len);
A seconda del valore di 'type' possiamo effettuare azioni diverse. Tra le possibili azioni troviamo le seguenti
SYSLOG_ACTION_CLOSE:
SYSLOG_ACTION_OPEN:
SYSLOG_ACTION_READ:
...
SYSLOG_ACTION_SIZE_UNREAD:
SYSLOG_ACTION_SIZE_BUFFER:
Periodicamente esiste un kernel demon, klogd
, che intercetta e
logga i messagi del kernel.
4 Kernel Panic
La funzione panic()
è definita nel file kernel/panic.c
.
Quando viene eseguita stampa un messaggio utilizzando printk()
e
interrompe l'esecuzione del kernel. Viene utilizzata quando il
kernel non può più funzionare correttamente e la macchina deve
essere rebootata.
5 RUNNING EXAMPLES
5.1 RUNNING EXAMPLE #1
In questo esempio andiamo prima a compilare e caricare in memoria
il modulo kprobe-usage-example
cd kprobe-usage-example
make
sudo insmod hook.ko
subito dopo compilamo e carichiamo in memoria il modulo
kernel-function-pointer-exhibition
cd .. cd kernel-function-pointer-exhibition make sudo insmod function_pointer_exposer.ko
Infine, eseguiamo
cat /sys/module/function_pointer_exposer/parameters/the_hook > /sys/module/hook/parameters/hook_func
5.1.1 KPROBE-USAGE-EXAMPLE
Definiamo due parametri
unsigned long hook_func = 0; module_param(hook_func, ulong, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH); unsigned long audit_counter = 0; module_param(audit_counter, ulong, S_IRUSR | S_IRGRP | S_IROTH);
Durante la funzione init il modulo registra kretprobe alla
funzione finish_task_switch()
.
static struct kretprobe krp = { .handler = tail_hook, }; static int __init hook_init(void) { int ret; // register kretprobe to hook function "finish_task_switch()". krp.kp.symbol_name = target_func; ret = register_kretprobe(&krp); if(ret < 0) { pr_info("hook init failed, returned %d\n", ret); return ret; } printk("hook module correctly loaded\n"); return 0; }
La funzione tail_hook()
non fa altro che leggere il valore del
parametro hook-func
, e se tale valore non è nullo, lo esegue. Ogni
volta che viene eseguita poi incrementa il contatore
audit_counter
.
static int tail_hook(struct kretprobe_instance *ri, struct pt_regs *regs) { h_func *hook; atomic_inc((atomic_t)* &audit_counter); if(!hook_func) goto end; hook = (h_func*) hook_func; hook(); return 0; }
La funzione per uscire dal modulo invece non fa altro che togliere il kretprobe
static void __exit hook_exit(void) { unregister_kretprobe(&krp); printk("Hook invoked %lu times\n", audit_counter); printk("hook module unloaded\n"); }
Infine, le macro per registrare le funzioni init ed exit.
module_init(hook_init); module_exit(hook_exit);
5.1.2 KERNEL-FUNCTION-POINTER-EXHIBITION
Questo modulo invece definisce una funzione f()
il cui indirizzo
di memoria viene scritto nel parametro di modulo the_hook
.
#define THRESHOLD 1000 // Define module param unsigned long the_hook; module_param(the_hook,ulong,0770);
La funzione f()
non fa altro che stampare delle informazioni nella
console.
void f(void) { // PID = 0 means that the process is a kernel demon. if(current->pid == 0) { // print informations about the thread. printk("thread id %d found on CPU %d - TCB located at %p\n", current->pid, smp_process_id(), current); } // used to log the fact that this function has been called, even if // it has never been called to switch a kernel demon. atomic_inc((atomic_t)&passages); if(passages > THRESHOLD) { printk("%s: function actually called\n", MODNAME); passages = 0; } }
La funzione di caricamento e scaricamento del modulo si occupa di
lockare in memoria il modulo precedentemente mostrato e trovare
l'indirizzo della funzione f()
al fine di scriverlo nel parametro
del modulo.
int init_module(void) { int ret; struct module *the_module; // Try to lock module "hook" the_module = find_module("hook"); if (the_module == NULL) return -1; if (!try_module_get(the_module)) return -1; passages = 0; // Write address of f in the pseudofile the_hook the_hook = (unsigned long)f; ret = 0; printk("%s: module mounted\n",MODNAME); return ret; }
La funzione di exit invece unlocka il modulo che si era lockato prima.
void cleanup_module(void) { struct module *the_module; the_module = find_module("hook"); if(the_module != NULL) module_put(the_module); printk("%s: module unmounted\n", MODNAME); }
5.2 STDIN-KPROBE-INTERCEPTOR
In questo esempio andiamo a vedere un modulo che intercetta le
chiamate alla sys_read()
.
Definiamo quindi i seguenti parametri
#define target_func "sys_read" // show how manu times the interceptor function has been called unsigned long audit_counter = 0; called module_param(audit_counter, ulong, S_IRUSR | S_IRGRP | S_IROTH); // the target process id - setup it via /sys long target_pid = -1; module_param(target_pid, long, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH);
La funzione che si occupa di intercettare la read invece è la seguente
static int the_hook(struct kprobe *ri, struct pt_regs *regs) { char buffer[128]; int string_terminator; int i; atomic_int((atomic_t)&audit_counter); if(current->pid == (pid_t)target_pid) { printk("process %d read - channel is %d - address is %p - requested num bytes is %d\n", (int)target_pid, regs->di, (void*)regs->si, regs->dx); if(regs->di != 0) return 0; // take the already delivered bytes back from user space. copy_from_user((void*) buffer, (char*)regs->si, regs->dx); // format correct string string_terminator = regs->dx; if(string_terminator >= 128) string_terminator = 128; buffer[string_terminator] = '\0'; // KERN_CONT is used for line continuation printk(KERN_CONT "buffer content is: "); i = 0; while(buffer[i] != '\n' && buffer[i] != '\0') { printk(KERN_CONT "%c", buffer[i++]); } printk("\n"); } return 0; }
La funzione di init, come al solito, si occupa di registrare il kprobe
static struct kprobe kp = { .symbol_name = target_func, .post_handler = (kprobe_post_handler_t)the_hook, }; static int hook_init(void) { int ret; ret = register_kprobe(&kb); if(ret < 0) { // TODO: understand pr_info pr_info("hook init failed, returned %d\n", ret); return ret; } printk("hook module correctly loaded\n"); return 0; }
Mentre quella di exit si occupa di eliminare il kprobe registrato.
static void hook_exit(void) { unregister_kprobe(&kp); printk("Hook invoked %lu times\n", audit_counter); printk("hook module unloaded\n"); }
Infine, le solite macro
module_init(hook_init); module_exit(hook_exit);