AOS - 21 - LINUX MODULES II


Lecture Info

  • Data: [2019-11-11 lun]

  • 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

nil

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:

  1. in-line functions;

  2. 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

nil

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);