AOS - 19 - CROSS RING DATA MOVE I


Lecture Info

  • Data: [2019-11-06 mer]

  • Sito corso: link

  • Slides: AOS - 4 CROSS RING DATA MOVE I

  • Progresso unità: 1/1

  • Argomenti:

    • User/Kernel Data Movement Problem

    • Fix for Flexible Segmentation

    • Per-Thread Memory Limits*

    • User/Kernel Data Move API

    • Service Redundancy

    • Constrained Supervisor Mode

    • Kernel Masked Segfaults

  • Introduzione: In questa lezione ci poniamo il seguente problema: come possiamo comunicare informazioni tra moduli software che lavorano ad un livello di privilegio differente?

1 User/Kernel Interactions

Abbiamo visto nelle lezioni precedenti che siamo in grado di cambiare il livello di privilegio del flusso di esecuzione passando da lato user a lato kernel. Gli effetti di questo passaggio sono il cambio del registri di segmentazione e il cambio del CPL associato al text segment.

Abbiamo già visto, attraverso l'architettura delle system calls, e le istruzioni syscall~/~int 0x80 come sia possibile utilizzare i general purpose registers della CPU per passare i valori tra lo user space e il kernel space.

L'utilizzo dei soli registri della CPU però non è una soluzione scalabile per passare i dati da user/kernel. Per implementare meccanismi di I/O, ad esempio per le system calls read() write() necessitiamo di un ulteriore meccanismo di passaggio dei dati.


Lo scambio di pointers tra user space e kernel space offre un meccanismo in grado di passare dati da user a kernel in modo più flessibile.

L'utilizzo dei pointers però rompe completamente il sistema di protezione ring-based, in quanto un pointer può essere definito lato user, e il contenuto puntato può essere sovrascritto, o semplicemente letto, lato kernel. Questo implica che non c'è una separazione netta tra i vari livelli, in quanto è l'user che stabilisce dove il kernel va a lavorare. Necessitiamo dunque di meccanismi addizionali per gestire la comunicazione user/kernel senza rompere il ring-model.

Le possibili soluzioni a questa problematica dipendono dai supporti offerti dall'hardware, come il supporto per la segmentazione, ed eventuali altri componenti più recenti.


1.1 Flexible Segmentation

Consideriamo il caso in cui abbiamo una segmentazione flessibile, ovvero una segmentazione in cui:

  1. La base di ogni segmento può essere un qualsiasi indirizzo logico.

  2. La dimensione dei segmenti può essere di taglia arbitraria.

In questo caso ad esempio ricade la protected-mode offerta dall'archtiettura x86 .

Con questo supporto siamo in grado di separare completamente i buffer utilizzati lato users e i buffer utilizzati lato kernel, e quindi siamo sempre in grado di stabilire se una lettura/scrittura fatta in modalità kernel va su zone di memoria allocate allo user space.

Utilizzando questo approccio però dobbiamo stare attenti e non lasciare la gestione della segmentazione completamente al compilatore. In particolare, se assumiamo che i segmenti CS/DS lato user e quelli CS/DS lato kernel iniziano da basi diverse, allora non possiamo semplicemente utilizzare gli offset che ci vengono passati lato user, in quanto altrimenti, girando lato kernel, andremmo ad applicare l'offset al selettore DS kernel, e non al selettore DS user.

L'idea è quindi quella di gestire "a mano" le istruzioni che spostano informazioni dal lato user al lato kernel. In particolare è possibile utilizzare il selettore di segmenti FS mappandolo al selettore DS lato user. Le istruzioni che muovono dati devono quindi utilizzare il displacement FS assieme all'offset passato dall'utente. Queste operazioni vengono anche chiamate segmentation fixup.

Supponiamo quindi che l'utente esegue una read(src, dst, count) . Lato kernel dobbiamo eseguire le seguenti operazioni:

In dettaglio:

  1. Entriamo lato kernel con una trap (int x080 o syscall).

  2. Materializziamo in qualche buffer livello kernel i dati che devono essere passati all'user.

  3. Settiamo la base del segmento FS pari alla base del segmento DS lato user .

  4. Eseguiamo una copia della memoria utilizzando FS come displacement

    mov src, FS: (dst)
    
  5. Ripristiniamo il valore originario del selettore FS.


Le operazioni di semgentation fixup coinvolgono quindi più istruzioni macchina del previsto, ed è per questa ragione che molto spesso le librerie di sistema, come libc , utilizzano dei buffer interni e cercano di chiamare le system call nel modo più morigerato possibile.


1.2 Constrained Segmentation

Nelle moderne versioni di x86 , ovvero nella modalità long-mode , la soluzione descritta prima non può più essere applicata in quanto tutti i segmenti tranne FS e GS hanno la base forzata all'indirizzo 0x0 . Utilizzando la soluzione vista prima il risultato può essere una scrittura nelle pagine del kernel a seconda del valore di 'y', che viene passato dall'utente.

Le soluzioni offerte in questo caso dunque non possono far affidamento solamente allo stato del processore, ma devono necessariamente utilizzare delle facility software del kernel, il cui compito principale è quello di verificare la leggitimità delle operazioni di scambio dati tra user space e kernel space. Questa verifica deve essere effettuata individualmente per ogni thread e per ogni address space gestito dal kernel.

I singoli thread hanno quindi deve limitazioni sui luoghi in cui i pointers possono puntare per l'eventuale scambio di informazioni user/kernel space.

2 Per-Thread Memory Limits

Per rappresentare gli address space linux utilizza due supporti, che sono:

  1. addr_limit: è un pointer (di tipo unsigned long) che descrive quali sono le zone di memoria specificabili a livello user da quel thread per il movimento dati. Tale pointer viene embeddato all'interno di una struttura con il nome seg . Può essere letto con la API get_fs() e può essere aggiornato ad un valore generico con la kernel API set_fs(x) .

    Se l'area di memoria in cui vado a fare movimento dati supera l'addr_limit, l'operazione non è valida. Questo tipo di controllo è semplice e veloce e tutti i servizi che implementano movimento dati tra user e kernel controllano questo campo.

    // Current the limit is set to 0x00007ffffffff000, which is the lower
    // half of the x86 long mode canonical addressing form.
             
    // Show current addr_limit
    unsigned long limit;
    limit = (unsigned long) get_fs().seg;
    printk("limit is %p\n", limit);
    

    La possibilità di cambiare l'addr_limit tramite la API set_fs() viene fornita perché altrimenti non saremmo in grado di leggere i dati lato kernel nella memoria.

  2. Oltre a capire se i buffer specificati a livello user sono leggittimi, un altro controllo viene eseguito sulla leggimità dell'esistenza dei buffer in cui il lato user ci ha detto di scrivere. Può infatti succedere che ci venga specificato un indirizzo leggitimo e quindi che passa il controllo su addr_limit , ma che non è stato mappato. L'eventuale accesso in lettura/scrittura a tale indirizzo andrebbe a generare un seg_fault . Dato però che lavoriamo in lato kernel, non possiamo generare un seg_fault.

3 User/Kernel Data Move API

A seguire troviamo le API del kernel che implementano tutti i controlli necessari per scambiare informazioni user/kernel:

  • copy_from_user(): Copia n bytes dall'indirizzo user space from all'indirizzo kernel space to .

    unsigned long copy_from_user(void *to, const void *from, unsigned long n);
    
  • copy_to_user(): Copia n bytes dall'indirizzo kernel space from all'indirizzo user space to .

    unsigned long copy_to_user(void *to, const void *from, unsigned long n);
    
  • get_user(): Copia un valore intero dall'indirizzo user space from all'indirizzo kernel space to .

    void get_user(void *to, void *from);
    
  • put_user(): Copia un valore intero dall'indirizzo kernel space from all'indirizzo user space to .

    void put_user(void *from, void *to);
    
  • strncpy_from_user(): Copia una stringa null -terminated di al massimo count bytes dall'indirizzo user space src all'indirizzo kernel space dst

    long strncpy_from_user(char *dst, const char *src, long count);
    
  • access_ok(): Ritorna un valore diverso da \(0\) se lo spazio di memoria user space è valido, e \(0\) altrimenti. Questa funzione viene chiamata dalle altre API per verificare la validità del trasferimento dati.

    int access_ok(int type, unsigned long addr, unsigned long size);
    

Notiamo che queste operazioni di lettura/scrittura possono essere bloccati da altri componenti del kernel. Questo vuol dire che queste operazioni possono "fallire". In caso di fallimento le API ritornano il numero di bytes che devono essere ancora spostati. In ogni caso, questi "fallimenti" sono limitati a regioni già mappate dello spazio di indirizzamento, e dunque non portano alla generazione di segmentation faults.

L'operazione di spostamento potrebbe anche portare il thread a bloccarsi (sleep).


3.1 copy_to_user() timeline

Le moderne versioni di linux utilizzano le facility SMAP e SMEP (leggi avanti) offerte dall'hardware per aumentare il livello di sicurezza del sistema. L'implementazione delle facility copy_to_user() e copy_from_user() necessitano quindi la disabilitazione temporanea di SMAP.

Per effettuare una copy_to_user() vengono effettuate le seguenti operazioni:

  1. Si controlla il per-thread limit tramite la variabile addr_limit per verificare se la zona di memoria con cui dobbiamo interagire ricade nel corretto intervallo.

  2. Si determina la quantità legale di dati da copiare.

  3. Si disabilità SMAP modificando la AC flag nel registro EFLAGS tramite l'istruzione x86 stac .

  4. Si effettua la copia (potrei bloccarmi, ma niente SEGFAULT).

  5. Si abilità SMAP di nuovo, tramite l'istruzione x86 clacl che modifica la AC flag nel registro EFLGS.

4 Service Redundancy

Supponiamo di avere un servizio importante per fare movimento dati, e supponiamo che questo servizio deve poter essere utilizzato sia dal software user sia dal software kernel. Un esempio potrebbe essere la sys_read() , che è la versione kernel della funzione read() e che viene molto spesso dal software livello kernel.

Con l'architettura vista prima, in particolare con il controllo al valore addr_limit , se del codice kernel dovesse chiamare la funzione sys_read() che esegue la copy_from_user() , quest'ultima chiamata fallisce, in quanto qualsiasi buffer del kernel supererà necessariamente il limite addr_limit .

Per poter utilizzare la sys_read() anche all'interno del kernel un primo approccio potrebbe essere quello di modificare il valore di addr_limit per "cammuffare" i buffer del kernel e farli passare come validi buffer users. Questa soluzione però è estremamente inefficiente, in quanto se chiamo la funzione dall'interno del kernel non devo fare nessun controllo sull'eventuale utilizzo di buffers del kernel.

Un altro approccio è quello della "service redundancy", che si basa sul duplicare il servizio read() all'interno del kernel. Abbiamo quindi due funzioni read() , una utilizzata per far interagire user e kernel, sys_read() , e un'altra utilizzata solo per chiamate che partono dal kernel, kernel_read() .

5 Constrained Supervisor Mode

Cosa succede se per via di un bug, o di un effetto speculativo, la funzione memcpy() viene chiamata dal kernel con dei pointer arbitrary? Una situazione del genere potrebbe far si che il kernel scriva su pagine accessibili lato user, ed è quindi importante da risolvere.

Nelle versioni datate dei processori e di linux non potevamo farci nulla al riguardo.

Nelle architetture moderne è stata introdotta una modalità operativa del processore chiamata constrained supervisor mode. Questa modalità permette di imporre dei limiti sul movimento dati anche al ring 0 . In particolare, se la modalità è attiva effettuare delle attività su delle pagine mappate lato user porta alla generazione di una trap.

Questa modalità cambia quindi il modo in cui il firmware gestisce la page table e mi permette di ottenere un maggior livello di sicurezza.

L'attivazione del constraint supervisor mode in x86 viene effettuata tramite due bits del registro CR4 (il \(21\) e \(20\)). In particolare abbiamo due facility, che sono

  • SMAP (Supervisor Mode Access Prevention): Blocks data access to user pages when running at CPL 0. SMAP può essere disabilitata settando il bit AC nel registro di stato EFLAGS.

  • SMEP (Supervisor Mode Execution Prevention): Blocks instruction fetches from user page when running at CPL 0.

6 Kernel Masked Segfaults

Notiamo nella timeline della copy_to_user(), per effettuare il passo 2, ovvero la determinazione della quantità legale di dati da copiare, siamo costretti ad ispezionare la memory map tramite la struttura mm del running thread. Per fare questo necessitiamo dunque di ulteriori istruzioni macchina, e il tutto potrebbe avere un costo lineare (non costante).

Per motivi di performance le versioni moderne del kernel all'interno della funzione access_ok() eseguono solo il check sul valore di addr_limit . Se il check passa, la memoria viene copiata direttamente, senza controllare che le pagine coinvolte siano pagine effettivamente mmappate.

Questo vuol dire che durante l'operazione di copiatura il kernel potrebbe potenzialmente andare in SEGFAULT . Per gestire questa situazione è stato introdotto il masking dei kernel segfaults. Utilizzando questo sistema il page fault handler che gestisce il SEGFAULT ha una tablela in cui sono presenti due sequenze di valori di RIP : Nella prima colonna sono presenti i valori del registro RIP (segfaulting RIP) per eventuali segfaults durante il movimento dati da user/kernel, e nella seconda colonna sono presenti gli alternative RIP che il page fault handler deve seguire per gestire gli eventuali SEGFAULTS.

Il codice di blocco alternativo non fa altro che ritornare il numero di bytes che devono ancora essere scambiati alla routine che ha chiamato la routine di movimento.

7 RUNNING EXAMPLES

nil

7.1 message-exchange-service

Implementa un servizio di logging e retrieval di messaggi all'interno di un buffer del kernel senza bufferizzazione esterna. Necessita la conoscenza dell'indirizzo di memoria della sys_call_table.

/* 
   IMPORTANT: nelle funzioni sys_log_message() e sys_get_message()
   quando entro nelle sezioni critico e chiamo le funzioni
   copy_from_user() e copy_to_user() posso essere bloccato dal page
   fault handler. Dunque, fino a quando non finisco, la risorsa è
   bloccata e nessun altro thread può accedere al buffer. Questa
   versione senza buffering intermedio non è quindi molto performante. 
   
   ,*/ 

// buffer utilizzato per postare i messaggi con il relativo size.
#define MAX_MSG_SIZE 4096
char  kernel_buff[MAX_MSG_SIZE];
size_t valid = 0;

// Dato che le operazioni di modifica del buffer non possono essere
// effettuate in modo atomico, dobbiamo utilizzare un semaforo.
static DEFINE_MUTEX(log_get_mutex);

// sys call utilizzata lato user per postare il messaggio nel buffer.
asmlinkage long sys_log_message(char *mex, size_t size)
{
  int ret;

  if(size >= (MAX_MSG_SIZE - 1)) goto bad_size;

  mutex_lock(&log_get_mutex); // Inizio sezione critica
  
  ret = copy_from_user((char*) kernel_buff, (char*) mex, size);
  kernel_buff[size-ret] = '\0';
  valid = size - ret; 

  mutex_unlock(&log_get_mutex); // Fine sezione critica

  return size - ret;  // ritorna numero bytes validi

 bad_size:
  return -1;
}

asmlinkage long sys_get_message(char *mex, size_t size)
{
  int ret;
  if(size > (MAX_MSG_SIZE)) goto bad_size;

  mutex_lock(&log_get_mutex);

  if (size > valid) size = valid;
  ret = copy_to_user((char*) mex, (char*) kernel_buff, size)
    mutex_unlock(&log_get_mutex);

  return size - ret; // return number of bytes left to read

 bad_size:
  return -1;
}

7.2 message-exchange-service-intermediate-buffering

Simile all'esempio visto prima ma questa volta con un buffering intermedio che mi permette di essere più performante.

#define MAX_MSG_SIZE 4096
char kernel_buff[MAX_MSG_SIZE];
size_t valid = 0;

static DEFINE_MUTEX(log_get_mutex);

// Logs message into kernel buffer with buffering. In particular it
// does the following:
//
// 1) Maps kernel page to use as intermediate buffer;
//
// 2) uses copy_from_user() to copy mex into intermediate buffer;
//
// 3) gets lock on kernel_buff and copies the user message previously
// copied into the intermediate buffer with memcpy().
//
asmlinkage long sys_log_message(char* mex, size_t size)
{
  unsigned long ret;
  void* addr;

  if(size >= (MAX_MSG_SIZE - 1)) goto bad_size;

  // Get a kernel page to use as an intermediate buffer. 
  addr = (void*) get_zeroed_page(GFP_KERNEL);
  if(addr == NULL) return -1;

  // NOTE: now we copy in our intermediate buffer, so that, even if we
  // do get blocked, we are not blocking other threads from accessing
  // the kernel_buff.
  //
  // copy from mex (user_space) to addr (kernel_space).
  ret = copy_from_user((char*) addr, (char*)mex, size);

  mutex_lock(&log_get_mutex); // Inizio sezione critica

  memcpy((char*) kernel_buff, (char*) mex, size);
  kernel_buff[size - ret] = '\0';
  valid = size - ret;
  
  mutex_unlock(&log_get_mutex); // Fine sezione critica

  // Free the kernel page used for buffering.
  free_pages((unsigned long)addr, 0);
  
  return size - ret;

 bad_size:
  return -1;
}

// Gets message from kernel buffer into user buffer with buffering. In
// particular it does the following:
//
// 1) Maps kernel page to use as intermediate buffer;
//
// 2) Takes lock on kernel_buff to copy its content in intermediate
// buffer using memcpy.
//
// 3) uses copy_to_user() to copy intermediate buffer to user buffer.
//
asmlinkage long sys_get_message(char* mex, size_t size)
{
  int ret;
  void* addr;

  if(size > (MAX_MSG_SIZE)) goto bad_size;
  if (size > valid) size = valid;
  
  addr = (void*) get_zeroed_page(GFP_KERNEL);
  
  mutex_lock(&log_get_mutex);
  memcpy((char*) addr, (char*) kernel_buff, size)
    mutex_unlock(&log_get_mutex);

  // Copy from addr (kernel space) to mex (user space). 
  ret = copy_to_user((char*) mex, (char*) addr, size);
  free_pages((unsigned long) addr, 0);

  return size - ret; // return bytes left to read
    
 bad_size:
  return -1;
}