it-swarm.dev

Perché Linux non randomizza l'indirizzo del segmento di codice eseguibile?

Di recente ho scoperto come funziona ASLR (randomizzazione dello spazio degli indirizzi) su Linux. Almeno su Fedora e Red Hat Enterprise Linux, esistono due tipi di programmi eseguibili:

  • I PIE (Position Independent Executables) ricevono una forte randomizzazione dell'indirizzo. Apparentemente, la posizione di tutto è randomizzata, separatamente per ciascun programma. Apparentemente, i demoni rivolti verso la rete dovrebbero essere compilati come PIE (usando il -pie -fpie flag del compilatore), per garantire che ricevano la randomizzazione completa.

  • Altri eseguibili ricevono randomizzazione dell'indirizzo parziale. Il segmento di codice eseguibile non è randomizzato, ma a un indirizzo fisso e prevedibile è lo stesso per tutti i sistemi Linux. Al contrario, le librerie condivise sono randomizzate: sono caricate in una posizione casuale che è la stessa per tutti questi programmi sul sistema.

Penso di capire perché gli eseguibili non PIE abbiano la forma più debole di randomizzazione per le librerie condivise (questo è necessario per prelink, che accelera il collegamento e il caricamento degli eseguibili). Penso anche di capire perché gli eseguibili non PIE non hanno affatto il loro segmento eseguibile randomizzato: sembra che sia perché il programma deve essere compilato come PIE, per poter randomizzare la posizione del segmento di codice eseguibile.

Tuttavia, lasciare la posizione del segmento di codice eseguibile non randomizzato è potenzialmente un rischio per la sicurezza (ad esempio, rende più facili gli attacchi ROP), quindi sarebbe bene capire se è possibile fornire una randomizzazione completa per tutti i file binari.

Quindi, c'è un motivo per non compilare tutto come PIE? C'è un sovraccarico prestazionale alla compilazione come PIE? In tal caso, quanto costa l'overhead delle prestazioni su architetture diverse, in particolare su x86_64, dove la randomizzazione dell'indirizzo è più efficace?


Riferimenti:

30
D.W.

Anche se i dettagli variano notevolmente tra le architetture, ciò che dico qui vale ugualmente bene per x86 a 32 bit, x86 a 64 bit, ma anche ARM e PowerPC: affrontati con gli stessi problemi, su tutta l'architettura i progettisti hanno utilizzato soluzioni simili.


Esistono (approssimativamente parlando) quattro tipi di "accessi", a livello di Assemblea, che sono rilevanti per il sistema "indipendente dalla posizione": ci sono chiamate di funzione (call opcodes) e accessi ai dati ed entrambi possono indirizzare un'entità all'interno dello stesso oggetto (dove un oggetto è un " oggetto condiviso ", ovvero una DLL o il file eseguibile stesso) o all'interno di un altro oggetto. Gli accessi ai dati per le variabili dello stack non sono rilevanti qui; Sto parlando dell'accesso ai dati a variabili globali o dati di costante statica (in particolare il contenuto di ciò che appare, a livello di sorgente, come stringhe di caratteri letterali) . In un contesto C++, i metodi virtuali sono referenziati da ciò che è, internamente, puntatori a funzioni in tabelle speciali (chiamate "vtables"); ai fini di questa risposta, si tratta anche di accessi ai dati , anche se un metodo è il codice.

Il codice operativo call utilizza un indirizzo di destinazione che è relativo : è un offset calcolato tra il puntatore dell'istruzione corrente (tecnicamente, il primo byte dopo l'argomento per call opcode) e l'indirizzo di destinazione della chiamata. Ciò significa che le chiamate di funzione all'interno dello stesso oggetto possono essere completamente risolte al momento del collegamento (statico); non vengono visualizzati nelle tabelle dei simboli dinamici e sono "indipendenti dalla posizione". D'altra parte, le chiamate di funzione ad altri oggetti (chiamate cross-DLL o chiamate dal file eseguibile a una DLL) devono passare attraverso una direzione indiretta gestita dal linker dinamico. Il codice operativo call deve ancora saltare "da qualche parte" e il linker dinamico vuole regolarlo dinamicamente. Il formato cerca di raggiungere due caratteristiche:

  • Lazy linking: l'obiettivo di chiamata viene cercato e risolto solo al primo utilizzo.
  • Pagine condivise: al massimo possibile, le strutture in memoria devono essere mantenute identiche ai byte corrispondenti nei file eseguibili, per promuovere la condivisione tra più invocazioni (se due processi caricano la stessa DLL, il codice dovrebbe essere presente solo una volta nella RAM) e un paging più semplice (quando RAM è stretto, una pagina che è una copia non modificata di una porzione di dati in un file può essere sfrattata dalla RAM fisica, poiché può essere ricaricato a volontà).

Poiché la condivisione è basata su una pagina per pagina, ciò significa che è necessario evitare di modificare in modo dinamico l'argomento call (i pochi byte dopo il codice operativo call). Invece, il codice compilato utilizza un Global Offsets Table (o diversi - semplifico un po 'le cose). Fondamentalmente, call passa a un piccolo pezzo di codice che esegue la chiamata effettiva ed è soggetto a modifiche da parte del linker dinamico. Tutti questi piccoli wrapper, per un dato oggetto, sono memorizzati insieme in pagine che il linker dinamico modificherà; queste pagine hanno un offset fisso rispetto al codice, quindi l'argomento su call viene calcolato al momento del collegamento statico e non deve essere modificato dal file di origine. Quando l'oggetto viene caricato per la prima volta, tutti i wrapper puntano a una funzione di linker dinamico che esegue il collegamento al primo richiamo; tale funzione modifica il wrapper stesso in modo che punti al target risolto, per successive invocazioni. La giocoleria a livello di Assemblea è complessa ma funziona bene.

Accesso ai dati seguono un modello simile, ma non hanno un indirizzo relativo. Cioè, un accesso ai dati utilizzerà un indirizzo assoluto . Tale indirizzo verrà calcolato all'interno di un registro, che verrà quindi utilizzato per l'accesso. La riga x86 della CPU può avere l'indirizzo assoluto direttamente come parte del codice operativo; per le architetture RISC, con codici operativi di dimensioni fisse, l'indirizzo verrà caricato come due o tre istruzioni successive.

In un file eseguibile non PIE, l'indirizzo di destinazione di un elemento dati è noto al linker statico, che può codificarlo direttamente nel codice operativo a cui accede. In un eseguibile PIE o in una DLL, ciò non è possibile poiché l'indirizzo di destinazione non è noto prima dell'esecuzione (dipende da altri oggetti che verranno caricati nella RAM e anche da ASLR). Al contrario, il codice binario deve utilizzare nuovamente GOT. L'indirizzo GOT viene calcolato dinamicamente in un registro di base. Su x86 a 32 bit, il registro di base è convenzionalmente %ebx e il seguente codice è tipico:

    call nextaddress
nextaddress:
    popl %ebx
    addl somefixedvalue, %ebx

Il primo call passa semplicemente al successivo codice operativo (quindi l'indirizzo relativo qui è solo uno zero); dato che si tratta di un call, inserisce l'indirizzo di ritorno (anche quello del popl opcode) nello stack e popl lo estrae. A quel punto, %ebx contiene l'indirizzo di popl, quindi una semplice aggiunta modifica quel valore in modo che punti all'inizio del GOT. Gli accessi ai dati possono quindi essere fatti relativamente a %ebx.


Quindi cosa viene modificato compilando un file eseguibile come PIE? In realtà non molto. Un "eseguibile PIE" significa rendere il file eseguibile principale una DLL e caricarlo e collegarlo come qualsiasi altra DLL. Ciò implica quanto segue:

  • Chiamate di funzione non sono modificate.
  • Accesso ai dati dal codice nell'eseguibile principale, agli elementi di dati che si trovano anche nell'eseguibile principale, comporta un sovraccarico aggiuntivo. Tutti gli altri accessi ai dati sono inalterati.

Il sovraccarico degli accessi ai dati è dovuto all'uso di un registro convenzionale per puntare al GOT: una indiretta aggiuntiva, un registro utilizzato per questa funzionalità (questo ha un impatto sulle architetture affamate di registro come x86 a 32 bit) e un codice aggiuntivo per ricalcolare il puntatore a GOT.

Tuttavia, gli accessi ai dati sono già in qualche modo "lenti", rispetto agli accessi alle variabili locali, quindi il codice compilato memorizza già tali accessi nella cache quando possibile (il valore della variabile viene tenuto in un registro e svuotato solo quando necessario; e anche se svuotato, anche l'indirizzo variabile viene tenuto in un registro). Ciò è reso ancora più vero dal fatto che le variabili globali sono condivise tra i thread, quindi la maggior parte del codice dell'applicazione che utilizza tali dati globali lo utilizza solo in un modo di sola lettura (quando vengono eseguite le scritture, vengono eseguite sotto la protezione di un mutex e afferrare il mutex comporta comunque un costo molto maggiore). La maggior parte del codice ad alta intensità di CPU funzionerà su registri e variabili dello stack e non sarà influenzato rendendo il codice indipendente dalla posizione.

Al massimo, la compilazione del codice come PIE implica un overhead di dimensioni di circa il 2% sul codice tipico, senza alcun impatto misurabile sull'efficienza del codice, quindi non è certo un problema (ho avuto quella cifra discutendo con le persone coinvolte nello sviluppo di OpenBSD; il "+ 2%" è stato un problema per loro nella situazione molto specifica del tentativo di adattare un sistema barebone su un disco floppy di avvio).


Tuttavia, il codice non C/C++ può avere problemi con PIE. Durante la produzione di codice compilato, il compilatore deve "sapere" se è per un DLL o per un eseguibile statico, per includere i blocchi di codice che trovano GOT. Non ci saranno molti pacchetti in un sistema operativo Linux che potrebbe causare problemi, ma Emacs sarebbe un candidato per i problemi, con la sua funzione di scaricamento e ricarica LISP.

Si noti che il codice in Python, Java, C # /. NET, Ruby ... è completamente al di fuori di tutto questo. PIE è per il codice "tradizionale" in C o C++.

26
Thomas Pornin

Uno dei motivi per cui alcune distribuzioni Linux potrebbero essere riluttanti a compilare tutti gli eseguibili come eseguibili indipendenti dalla posizione (PIE), quindi il codice eseguibile è randomizzato, è a causa delle preoccupazioni sulle prestazioni. Il problema delle prestazioni è che a volte le persone si preoccupano delle prestazioni anche quando non è un problema. Quindi, sarebbe bello avere misurazioni dettagliate del costo effettivo.

Fortunatamente, il seguente documento presenta alcune misurazioni del costo di compilazione degli eseguibili come PIE:

Il documento ha analizzato il sovraccarico prestazionale di abilitare PIE su una serie di programmi ad alta intensità di CPU (vale a dire i benchmark SPEC CPU2006). Poiché prevediamo che questa classe di eseguibili mostri le spese generali peggiori a causa della Torta, ciò fornisce una stima prudente, nel caso peggiore, della stima potenziale delle prestazioni.

Riassumendo i principali risultati del documento:

  • Sulle architetture x86 a 32 bit, il sovraccarico delle prestazioni potrebbe essere sostanziale: si tratta di un rallentamento medio di circa il 10%, per i benchmark SPEC CPU2006 (programmi ad alta intensità di CPU) e un rallentamento fino al 25% circa per alcuni dei programmi.

  • Sulle architetture x64 a 64 bit, l'overhead delle prestazioni è molto più piccolo: un rallentamento medio di circa il 3%, sui programmi ad alta intensità di CPU. Probabilmente il sovraccarico prestazionale sarebbe ancora inferiore per molti programmi che le persone usano (poiché molti programmi non richiedono molta CPU).

Ciò suggerisce che abilitare PIE per tutti gli eseguibili su architetture a 64 bit sarebbe un passo ragionevole per la sicurezza e l'impatto sulle prestazioni è molto ridotto. Tuttavia, abilitare PIE per tutti gli eseguibili su architetture a 32 bit sarebbe troppo costoso.

8
D.W.

Abbastanza ovvio perché gli eseguibili dipendenti dalla posizione non sono randomizzati.

"Dipendente dalla posizione" significa semplicemente che almeno alcuni indirizzi sono codificati. In particolare, ciò può applicarsi agli indirizzi delle filiali. Lo spostamento dell'indirizzo di base del segmento eseguibile consente di spostare anche tutte le destinazioni della succursale.

Esistono due alternative per tali indirizzi hardcoded: sostituirli con indirizzi relativi all'IP (in modo che la CPU possa determinare l'indirizzo assoluto in fase di esecuzione) o correggerli al momento del caricamento (quando l'indirizzo base è noto).

Naturalmente hai bisogno di un compilatore in grado di generare tali eseguibili.

2
MSalters