AOS - 12 - KERNEL PROGRAMMING BASICS V
1 Lecture Info
Data:
Sito corso: link
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 registerwrmsr
, 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:
L'indirizzo della
sys_call_table
;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.