Su Linux, avviare un file binario con un semplice comando come ./app dà l’impressione di un’operazione banale, quasi istantanea. In realtà, in quei pochi millisecondi si dipana una sequenza precisa di operazioni che coinvolge kernel, memoria e librerie condivise. Il formato ELF (Executable and Linkable Format) è lo standard de facto per eseguibili, librerie condivise e file oggetto: è supportato nativamente dal kernel Linux fin dalle prime versioni degli anni ’90, quando ha progressivamente sostituito formati più vecchi. Oggi ELF è il punto di contatto tra compilatore, sistema operativo e hardware.
Il punto è che gran parte degli sviluppatori utilizza questo meccanismo senza mai osservarlo davvero: ci siamo imbattuti in un approfondimento tecnico di Facu de la Cruz, ricercatore e programmatore con esperienze 25ennale nello sviluppo di soluzioni Linux operative a basso livello.
Cos’è e come funzionano i file ELF su Linux
Un file ELF non è semplicemente codice compilato: è una struttura dati complessa che descrive come quel codice deve essere caricato in memoria. I primi byte del file, identificati dal cosiddetto magic number 0x7f 45 4c 46, permettono al kernel di riconoscere immediatamente il formato.
La cosa importante da capire è questa: un file ELF non contiene solo istruzioni, ma anche metadati che il kernel legge per sapere come comportarsi. Il binario dice al sistema: “queste parti vanno in memoria, queste sono eseguibili, queste sono dati“.
ELF prevede un header che identifica il file e ne descrive le caratteristiche; segmenti che indicano cosa caricare in memoria; informazioni per il linking, cioè per collegare il programma ad altre librerie.
Un aspetto che spesso sorprende è che la maggior parte dei programmi Linux non è autosufficiente. Sono binari dinamicamente linkati: usano codice che non è dentro il file, ma in librerie esterne, come libc. Un file ELF è, in un certo senso, incompleto perché contiene riferimenti a funzioni che saranno risolte solo al momento dell’esecuzione. Il lavoro di collegamento è quindi rimandato alla fase di runtime.
Il vantaggio consiste in una riduzione del codice duplicato e nella possibilità di effettuare aggiornamenti più semplici. C’è anche un rovescio della medaglia, di cui parliamo al paragrafo successivo.
Il ruolo del dynamic linker e la gestione degli indirizzi
Non appena si richiede l’esecuzione di un programma, il kernel Linux spesso non esegue direttamente il codice ma provvede prima a caricare un componente chiamato dynamic linker (ld-linux).
Tale componente ha il compito di caricare le librerie necessarie, trovare le funzioni mancanti e sistemare gli indirizzi in memoria completando la “conoscenza” di tutte quelle informazioni che in precedenza non erano disponibili.
Quando il compilatore incontra una chiamata a una funzione esterna, ad esempio printf, non può sapere dove si troverà quella funzione in memoria. L’indirizzo reale dipende da quando e dove sarà caricata la libreria libc, e questo cambia a ogni esecuzione per via di meccanismi come ASLR.
In pratica, il binario nasce con dei “vuoti”. Sa che deve chiamare ad esempio printf, ma non sa ancora dove saltare. Non può quindi avvalersi di un indirizzo diretto: usa invece un “posto fisso” in memoria dove quell’indirizzo sarà messo al momento giusto. Il punto importante da ricordare è uno solo: il programma non sa subito dove si trovano le funzioni esterne, ma lo scopre mentre gira.
Perché è importante cambiare il punto di vista su Linux
Leggendo lo splendido approfondimento di de la Cruz ci è venuto in mente come in generale si tenda a pensare a un file binario come qualcosa di finito: in realtà un file ELF è più vicino a una “bozza” che a un prodotto completo. Contiene istruzioni, certo, ma anche parti lasciate intenzionalmente incomplete. Quelle parti sono riempite solo nel momento in cui il programma parte davvero.
L’esecuzione non è solo “avvio del codice”: è anche completamento del codice. Il sistema prende quel file, lo collega alle librerie, sistema gli indirizzi, costruisce lo spazio di memoria: solo alla fine del processo il programma diventa eseguibile.
Quando si cambia il punto di vista, tante cose che prima sembravano scollegate diventano improvvisamente chiare.
Ad esempio, usando ldd per verificare le dipendenze delle librerie condivise, si sta dando proprio uno sguardo ai “pezzi mancanti” via via aggiunti a runtime.
Ancora, consideriamo la variabile d’ambiente LD_PRELOAD: permette di caricare in anticipo specifiche librerie dinamiche (cioè file contenenti funzioni condivise) prima di quelle standard, così da poter modificare o intercettare il comportamento dei programmi in esecuzione. Se il programma viene completato al momento dell’avvio, allora diventa possibile influenzare quel processo. Si può inserire codice prima di altro codice, cambiare le funzioni, alterare il comportamento senza toccare il binario originale!
Lo stesso vale per molti crash sperimentati in fase di avvio. Quando un programma non parte per un errore di linking, non è il codice ad avere problemi: è il processo di completamento che fallisce: una libreria manca, un simbolo non si risolve, un indirizzo non viene sistemato.
E poi c’è l’aspetto sicurezza. Se il runtime è così dinamico, allora è anche un punto di attacco: chi controlla la fase di caricamento, in parte controlla l’esecuzione. Per questo motivo esistono tecniche come la già citata ASLR (Address Space Layout Randomization, che randomizza la disposizione della memoria) e PIE (Position Independent Executable, che permette al programma di essere caricato in posizioni diverse): il loro scopo è proprio quello di rendere meno prevedibile la routine di caricamento, fornendo una linea di difesa contro eventuali attacchi.
Uno studio sul formato ELF che non ha paragoni
Il lavoro di de la Cruz colpisce nel segno. È un approfondimento come pochi ve ne sono “in giro”: non si limita a spiegare i concetti ma li mostra nella pratica.
Il valore sta proprio in questo approccio. Non semplifica nascondendo i dettagli, ma li rende finalmente leggibili. Accompagna il lettore dall’alto livello fino al codice macchina e collega ogni tessera del puzzle a qualcosa di concreto: una syscall, una lettura da file, una mappatura in memoria.
Da parte nostra abbiamo voluto limitarci a descrivere l’idea generale: una volta compresa, nell’approfondimento di de la Cruz si trova tutto il resto: strutture ELF, disassembly, relocations, comportamento reale del dynamic linker.
Segnaliamo anche l’esperimento di una sviluppatrice indipendente che ha realizzato un file binario poliglotta, compatibile con Linux, Windows e browser Web.