AOS - 12 - KERNEL PROGRAMMING BASICS V


1 Lecture Info

  • Data: [2019-10-18 ven]

  • Sito corso: link

  • Slides: AOS - 2 KERNEL PROGRAMMING BASICS

  • Progresso unità: 5/6

  • Argomenti:

    • int 0x80 performance

    • The fast system call path

    • How to add a new system call

  • Introduzione: Avevamo lasciato la scorsa lezione descrivendo l'allineamento di stack che viene utilizzato per far comunicare il software user-space con il software kernel-space. A seconda della modalità in cui lavoriamo (32 o 64 bit), lo stack allignment è diverso, ma in entrambi i casi lo stack allignment specifica in quale ordine si devono trovare le informazioni passate dall'applicativo user-space al software kernel. Avevamo poi menzionato come, nel passaggio tra user-space e kernel-space, il firmware andava ad aggiungere delle informazoni extra, principalmente riguardanti il ring model.

2 INT 0x80 performance

Il meccanismo dei GATEs per accedere lato kernel ed eseguire una system call necessita molti accessi alla memoria. In particolare troviamo

  • Un accesso alla memoria per accedere alla IDT e prendere le informazioni relative al GATE 0x80.

  • Un accesso alla memoria alla GDT per prendere le informazioni relative al CS segment per il kernel.

  • Un accesso alla memoria alla GDT (segmento TSS) per prendere lo stack pointer del kernel.

Questi tre accessi alla memoria richiedono quindi molti cicli di clock.

Inoltre, in una architettura di tipo NUMA, in cui la memoria non è vista in modo uniforme, questi vari accessi implicano anche un delay non reliable per entrare in modo kernel a seconda se le strutture dati sono vicine o lontane dal processore che sta eseguendo l'accesso kernel-side. Anche con la duplicazione della GDT, altre strutture dati, come la IDT, non sono replicabili, e quindi resta il fatto che l'accesso non ha una latenza reliable per tutti i processori.

Di conseguenza system calls come gettimeofday() sono estremamente svantaggiate dallo standard path attraverso i GATEs, e non permettono la costruzione di un high resolution timer se si utilizza una architettura con memoria asimmetrica e il meccanismo dei GATEs.

Per risolvere questi problemi di performance è stato introdotto la "fast system call path".

3 Fast System Call Path

All'interno dei moderni processori, a partire dal Pentium3 , sono stati introdotti una serie di registri MSRs (Model Specific Registers) al fine di velocizzare l'entrata in modo kernel per l'esecuzione di una system call.

Questi MSRs contengono delle informazioni di controllo e vengono utilizzati dal firmware per eseguire specifiche attività. In particolare tramite gli MSR siamo in grado di memorizzare,

  • CS value for kernel code.

  • Kernel entry point offset (EIP/RIP).

  • Kernel level stack/data base.

Così facendo siamo in grado di ottenere tutte le informazioni di interessa senza accedere alla memoria, ma semplicemente leggendo i vari MSRs. Questo nuovo metodo per accedere in modo kernel è chiamato fast system call path .


3.1 sysenter/syscall

Per accedere a questa modalità veloce esistono due istruzioni, che sono la sysenter , offerta dall'ISA di x86 in modalità 32 bit, e la syscall , offerta dall'ISA di x86 in modalità 64 bit. Entrambe le operazioni fanno essenzialmente le stesse cose, con alcune piccole differenze. In particolare troviamo:

  • CS viene aggiornato in entrambi i casi.

  • EIP viene aggiornato in entrambi i casi.

  • SS viene aggiornato in entrambi i casi.

  • ESP viene modificato solamente dall'istruzione sysenter.


3.2 sysexit/sysret

Per ritornare dal lato kernel al lato user esistono due operazioni, sysexit (32 bit), e sysret (64 bit).

In generale queste operazioni manipolano i registri MSR per settare lo stato di ritorno. Eseguono operazioni simili, ma mentre lavorando a 32 bit il registro EIP viene scritto con il valore di EDX, lavorando in 64 bit il registro RIP viene scirtto con il valore di RCX.

In particolare possiamo decidere dove ritornare lato user caricando il valore di ritorno in un registro. Questo fatto è utile in quanto il passaggio user-kernel ha un solo punto di entrata ma può avere più punti di uscita.


3.3 Model Specific Registers

I model specific registers sono indicati da delle label all'interno del file /usr/src/linux/include/asm/msr.h .

#define MSR_IA32_SYSENTER_CS  0x174 // Line 101
#define MSR_IA32_SYSENTER_ESP 0x175
#define MSR_IA32_SYSENTER_EIP 0x176

Per leggere e scrivere i registri MSR non possiamo utilizzare delle mov ma dobbiamo utilizzare funzioni specifiche, tra cui

  • rdmsr , read model specific register

  • wrmsr , write model specific register


Nel file /usr/src/linux/arch/i386/kernel/sysenter.c è presente la configurazione utilizzata per supportare l'ingresso in modalità kernel tramite l'istruzione sysenter.

wrmsr(MSR_IA32_SYSENTER_CS, __KERNEL_CS, 0); // Line 36
wrmsr(MSR_IA32_SYSENTER_ESP, tss->esp1, 0);
wrmsr(MSR_IA32_SYSENTER_EIP, (unsigned long) sysenter_entry, 0);

Notiamo che stiamo scrivendo nel registro MSR che contiene il valore dello stack pointer da utilizzare lo stack pointer tss->esp1 . Questo vuol dire la zona di stack che stiamo utilizzando appena entriamo è una zona di stack temporanea.

Su x86-64 invece non c'è cambio di stack.


3.4 The syscall() Construct

Al fine di rendere agevole la coesistenza all'interno dei sistemi di questi due metodi diversi per accedere alle system call è stata introdotta la funzione syscall() , implementata con glibc in stdlib.h.

La funzione syscall() permette di generare una generica system call utilizzando del codice ASM ottimizzato per la particolare architettura del sistema. Se la nostra architettura supporta la fast system call path, allora syscall eseguirà quella.

Il primo argomento è il codice numerico della sys call, e i restanti argomenti, se necessario, sono gli argomenti della system call che vogliamo chiamare.

Notiamo come tramite il costrutto syscall() siamo in grado di chiamare una qualsiasi system call registrata nella system call table, anche quelle che non hanno una interfaccia nella libreria di programmazione scelta.


3.5 SYS-CALL/sys-call-function.c

In questo running example vediamo l'utilizzo della funzione syscall per chiamare una system call nel modo più ottimizzato.

#include <stdlib.h>

#define SIZE 4096

char buff[SIZE];

int main (int a, char** b){
  while(1){
    syscall(0,0,buff,SIZE); // read
    syscall(1,1,buff,SIZE); // write
    syscall(1,1,"\n",1);    // write
  }
}

4 The System Call Table

A seconda della versione del kernel, la system call table è memorizzata in file diversi. In particolare,

I file .S sono file contenenti codice ASM. Ogni entry dela tabella mantiene un riferiemnto simbolico al nominativo utilizzato all'interno del kernel per specificare la system call.


4.1 Limitations

La system call table ha un numero massimo di entrate. Inoltre, dato che il kernel posiziona in modo compattato le strutture dati utili durante la fase di startup, non è possibile aumentare la grandezza della system call table a runtime, in quanto c'è la concreta possibilità di sovrascrivere della memoria già utilizzata dal kernel in altri modi.

L'idea per aggiungere nuove system call non è quindi quella di aumentare il size della tabella, ma piuttosto quella di utilizzare vecchie entry della tabella che non venivano utilizzate per andare ad implementare nuovi servizi. Questo è possibile in quanto tipicamente sono presenti delle entrate libere nella sys call table.

Con il kernel 2.4.25 abbiamo che il numero massimo di system call è definito dalla macro _NR_syscalls , che assume il valore di 270 . Abbiamo quindi un totale di 270 system call, anche se quelle che sono effettivamente implementate vanno da 0 a 252. Da questo consegue che esiste un intervallo di codici numerici disponibili da 253 a 269 che possiamo utilizzare per implementare la nostra system call.


4.2 Structure (in i386)

La system call table è definita come segue

        ENTRY(sys_call_table)
        .long   SYMBOL_NAME(sys_ni_syscall)
        .long   SYMBOL_NAME(sys_exit)
        .long   SYMBOL_NAME(sys_read)
        // ...
        .long SYMBOL_NAME(sys_ni_syscall)
        // Utilizzato per aggiungere in modo interatire le entry nulle
        .rept NR_syscalls - (.-sys_call_table)/4
        .long SYMBOL_NAME(sys_ni_syscall)
        .endr

5 Add a New System Call

Andiamo adesso a descrivere i vari passi da eseguire se vogliamo implementare e rendere disponibile user-side una nuova system call.


5.1 User Side

Per aggiungere una nuova system call lato user API dobbiamo includere il file header <unistd.h> , aggiungere dei codici numerici per le nostre system call, e utilizzare la macro che genera lo stub di chiamata per la system call.

Ad esempio se vogliamo aggiungere due system call, per fornire una API lato client dobbiamo inserire il seguente codice

      #include <unistd.h>

      #define _NR_my_first_sys_call 254
      #define _NR_my_second_sys_call 255

      _syscall0(int, my_first_sys_call);
      _syscall1(int, my_second_sys_call, int, arg);

5.2 Kernel Side

Lato kernel invece per aggiungere una nuova system call dobbiamo andare come prima cosa modificare la system call table. A seconda della versione del kernel, la tabella sarà formattata in modo diverso. Possiamo quindi procedere in due modi diversi:

  • Modificare il file che definisce la system call table e ricompilare il kernel, oppure;

  • Modificare in modo dinamico la tabella in memoria.

Oltre a modificare la system call table dobbiamo anche, ovviamente, scrivere il codice effettivo che implementa il nuovo servizio che vogliamo offrire. Per fare questo dobbiamo inserire il codice della funzione all'interno di un modulo da immettere nel kernel.

Al fine di rendere il codice della system call compliant con le regole utilizzate dal dispatcher, dobbiamo utilizzare la keyword asmlinkage . Utilizzando questa keyword il modo in cui la chiamata prende i parametri non è scelto dal compilatore ma viene deciso dal dispatcher. Così facendo in particolare i parametri vengono presi dallo stack, e non utilizzando le solite convenzioni specificate dalla ABI.

Osservazione: Le funzioni che girano a livello kernel possono utilizzare tutta una serie di funzioni e strutture dati offerte dal kernel. Le uniche funzioni e strutture dati che non possono essere utilizzate sono quelle dichiarate esplicitamente con la keyword 'static'.

6 BASELINE-SYS-CALL-TABLE-HACKING/sys_call_table_hacker.c

In questo running example mostriamo come aggiungere una system call table in modo dinamico andando a modificare la system call table direttamente dalla memoria del kernel. Per fare questo necessitiamo di conoscere due cose:

  1. L'indirizzo della sys_call_table ;

  2. L'indirizzo della sys_ni_syscall , che vienne utilizzata per indicare che una riga della sys call table è vuota.


6.1 Utilizzo della System Map

Queste informazioni possono essere trovate nella System.map del sistema, se questa è stata generata durante la compilazione del kernel. Per trovarla possiamo eseguire il seguente comando

sudo find / -type f -name "System.map"

Che nel mio caso da come risutlato

/usr/lib/modules/5.8.12-arch1-1/build/System.map
/usr/lib/modules/5.4.68-1-lts/build/System.map

Dato che la particolare versione del kernel con cui sto girando è la 5-4.68-1-lts , possiamo ottenere gli indirizzi dei vari oggetti di interesse nel seguente modo

cat /usr/lib/modules/5.4.68-1-lts/build/System.map | grep 'sys_call_table'
# ffffffff81e00240 D sys_call_table
# ffffffff81e01200 D ia32_sys_call_table
cat /usr/lib/modules/5.4.68-1-lts/build/System.map | grep 'sys_ni_syscall'
# ffffffff810037e0 T __ia32_sys_ni_syscall
# ffffffff810037e0 T __x64_sys_ni_syscall
# ffffffff810b3120 T sys_ni_syscall
# ffffffff82877060 d _eil_addr___x64_sys_ni_syscall

Abbiamo quindi che la sys_call_table si trova all'indirizzo ffffffff81e00240 mentre la sys_ni_syscall si trova all'indirizzo ffffffff810b3120 .



6.2 Codice kernel

Il codice effettivo che modifica la sys_call_table è il seguente, dove la funzione init_module viene eseguite ogni volta che montiamo il modulo.

#define MODNAME "SYS-CALL TABLE BASIC HACKER"
#define HACKED_ENTRIES 2

int restore[HACKED_ENTRIES] = {[0 ... (HACKED_ENTRIES-1)] -1};

unsigned long sys_call_table = 0xffffffff81e00240;
unsigned long sys_ni_syscall = 0xffffffff810b3120;

int init_module(void) {

  unsigned long *p = (unsigned long *) _sys_call_table;
  int i,j;
  int ret;
  unsigned long cr0;

  printk("%s: initializing\n", MODNAME);

  // read sys call table to find empty places
  j = -1;
  for (i = 0; i < 256; i++){
    if (p[i] == sys_ni_syscall){
      printk("%s: table entry %d keeps address %p\n", MODNAME, i, (void*)p[i]);
      j++;
      restore[j] = i;
      if (j == (HACKED_ENTRIES-1)) break;
    }
  }

  if(j != (HACKED_ENTRIES-1)){
    // no room found in the syst-call table for the new system calls
    return -1;
  }

  // modify sys call table by setting X86_CR0_WP bit in CR0
  cr0 = read_cr0();
  write_cr0(cr0 & ~X86_CR0_WP); 
  for(i = 0; i < HACKED_ENTRIES; i++){
    p[restore[i]] = (unsigned long)new_sys_call_array[i];
  }
  write_cr0(cr0);

  printk("%s: all new system-calls correctly installed on sys-call table \n",
         MODNAME);

  ret = 0;

  return ret;
}

Mentre il codice per le system call vere e proprie è il seguente

asmlinkage int sys_my_first_sys_call(void){
  printk("%s: zero-params sys-call has been called\n",MODNAME);
  return 0;
}

asmlinkage int sys_my_second_sys_call(int a){
  printk("%s: 1-param sys-call has been called (with param %d)\n",MODNAME,a);
  return 0;
}

Infine, quando scegliamo di rimuovere il modulo, andiamo ad eseguire la funzione cleanup_module() , che non fa altro che rimuovere le sys call aggiunte.

void cleanup_module(void) {

  unsigned long * p = (unsigned long*) sys_call_table;
  unsigned long cr0;
  int i;
                
  printk("%s: shutting down\n",MODNAME);

  // restore sys call to its original status
  cr0 = read_cr0();
  write_cr0(cr0 & ~X86_CR0_WP);
  for(i = 0; i < HACKED_ENTRIES; i++){
    if (restore[i] != -1){
      p[restore[i]] = sys_ni_syscall;
    }
  }
  write_cr0(cr0);
  
  printk("%s: sys-call table restored to its original content\n",
         MODNAME);
}


6.3 Compilazione

Per compilare il file possiamo utilizzare il comando make , che utilizza il seguente Makefile ai fini di compilazione

obj-m += sys_call_table_hacker.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules 

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Infine, per inserire e rimuovere il modulo possiamo utilizzare i seguenti comandi

insmod sys-call-table-hacker # inserisci modulo kernel
rmmod sys-call-table-hacker  # rimuovi modulo kernel

In generale una volta fatto partire il codice dovremmo vedere i seguenti messaggi tramite il comando dmesg

[46.808716] SYS-CALL TABLE BASIC HACKER: initializing                                                 
[46.808718] SYS-CALL TABLE BASIC HACKER: table entry 134 keeps address 00000000cecf9efa               
[46.808719] SYS-CALL TABLE BASIC HACKER: table entry 174 keeps address 00000000cecf9efa               
[46.808719] SYS-CALL TABLE BASIC HACKER: all new system-calls correctly installed on sys-call table 


6.4 Nota su kASLR

Nelle moderne versioni del kernel, in particolare a partire dal kernel 4.8 in poi, è stato introdotto il meccanismo di protezione kSALR, che abbiamo già introdotto nella lezione 03 quando abbiamo discusso dell'attacco meltdown e dei possibili modi per risolvere tale vulnerabilità. Con questo meccanismo di difesa gli indirizzi che troviamo nella System map non riflettono più la memoria runtime del kernel.

Questo significa che il codice appena mostrato non funzionerà. Per risolvere tale problematica è possibile disabilitare il kSALR andando ad inserire l'opzione nokalsr durante l'avvio del kernel, dal menu accessibile tramite grup con il tasto 'e'.



6.5 Nota su write_cr0()

Per poter sovrascrivere la zona di memoria in cui è contenuta la system call table abbiamo utilizzato il seguente codice

cr0 = read_cr0();
write_cr0(cr0 & ~X86_CR0_WP);
// modify here to system call table
write_cr0(cr0);

Tale codice però non funziona nelle versioni moderne del kernel, in quanto se lo eseguiamo otteniamo solo un permission error. Per poter comunque cambiare il codice ho trovato il seguente articolo Medium - Change value of WP bit in cr0 when cr0 is panned, scritto da Hadfi Abdel Moumene, che definisce la seguente funzione per forzare la scrittura del registro CR0 senza incorrere in errori di autorizzazione

extern unsigned long __force_order;

static inline  void write_forced_cr0(unsigned long val) {
  asm volatile("mov %0,%%cr0":"+r" (val),"+m"(__force_order));
}

Tale funzione utilizza il fatto che il codice che eseguiamo quando carichiamo un modulo del kernel in memoria gira a livello ring 0 , e dunque possiamo direttamente scrivere in assembly il valore del registro che vogliamo, senza passare per la funzione write_cr0() . Utilizzando questa funzione possiamo quindi cambiare il valore del bit X86_CR0_WP nel seguente modo

cr0 = read_cr0();
write_forced_cr0(cr0 & ~X86_CR0_WP);
// modify here system call table
write_forced_cr0(cr0);


6.6 Codice user

Una volta che abbiamo installato le system calls possiamo utilizzare tramite il seguente codice, che verrà eseguite a livello user

#include <stdlib.h>
#include <stdio.h>

int main(int argc, char** argv){
        
  int sys_call_num, arg;
        
  if(argc < 2){
    printf("usage: prog syscall-num [syscall-param]\n");
    return;
  }
        
  sys_call_num = strtol(argv[1], NULL, 10);
  if (argv[2]){
    arg = strtol(argv[2], NULL, 10);
    syscall(sys_call_num,arg);
    return 0;
  }
        
  syscall(sys_call_num);
        
  return 0;
}       

Come possiamo vedere la funzione main prende in input il numero della sys call da chiamare con il costrutto syscall() e un eventuale argumento. La funzione strtol() viene utilizzata per convertire una stringa in un intero in base 10.


Compilando questo file possiamo quindi eseguire

./a.out 127    # calls first sys call with 0 args
./a.out 174 20 # calls second sys call with 1 args

Utilizzando la console dmesg dobbiamo quindi vedere il seguente risultato

[116.892962] SYS-CALL TABLE BASIC HACKER: zero-params sys-call has been called
[127.708774] SYS-CALL TABLE BASIC HACKER: 1-param sys-call has been called (with param 64946008)

NOTA BENE: Osserviamo che il parametro non viene passato correttamente. Questo molto probabilmente ha a che fare con delle misure di sicurezza che sono state introdotte a partire dal kernel 4.17.