Come Tracee risolve la mancanza di informazioni sui BTF
Tracciando i processi utilizzando la tecnologia Linux eBPF (Berkeley Packet Filter), Tracee può correlare le informazioni raccolte e identificare modelli comportamentali dannosi.
Tracee è un progetto di Aqua Security per il tracciamento dei processi in fase di runtime. Tracciando i processi utilizzando la tecnologia Linux eBPF (Berkeley Packet Filter), Tracee può correlare le informazioni raccolte e identificare modelli comportamentali dannosi.
eBPF
BPF è un sistema che aiuta nell'analisi del traffico di rete. Il successivo sistema eBPF estende il classico BPF per migliorare la programmabilità del kernel Linux in diverse aree, come il filtraggio della rete, l'aggancio delle funzioni e così via. Grazie alla sua macchina virtuale basata su registri, incorporata nel kernel, eBPF può eseguire programmi scritti in un linguaggio C ristretto senza dover ricompilare il kernel o caricare un modulo. Tramite eBPF, puoi eseguire il tuo programma nel contesto del kernel e agganciare vari eventi nel percorso del kernel. Per fare ciò, eBPF deve avere una conoscenza approfondita delle strutture dati utilizzate dal kernel.
eBPF CO-RE
eBPF si interfaccia con il kernel Linux ABI (interfaccia binaria dell'applicazione). L'accesso alle strutture del kernel dalla VM eBPF dipende dalla specifica versione del kernel Linux.
eBPF CO-RE (compilare una volta, eseguire ovunque) è la capacità di scrivere un programma eBPF che verrà compilato con successo, supererà la verifica del kernel e funzionerà correttamente su diverse versioni del kernel senza la necessità di ricompilarlo per ogni particolare kernel.
ingredienti
CO-RE necessita di un preciso sinergismo di queste componenti:
- Informazioni BTF (formato tipo BPF): consente l'acquisizione di informazioni cruciali sui tipi e sul codice del kernel e del programma BPF, abilitando tutte le altre parti del puzzle BPF CO-RE.
- Compilatore (Clang): registra le informazioni sul trasferimento. Ad esempio, se dovessi accedere al campo
task_struct->pid
, Clang registrerebbe che si tratta esattamente di un campo denominatopid
di tipopid_t
residente all'interno di una strutturatask_struct
. Questo sistema garantisce che anche se un kernel di destinazione ha un layouttask_struct
in cui il campopid
viene spostato su un offset diverso all'interno di una strutturatask_struct
, sarai comunque in grado di trovarlo solo tramite il nome e le informazioni sul tipo.
- Caricatore BPF (libbpf): lega insieme i BTF dal kernel e dai programmi BPF per adattare il codice BPF compilato a kernel specifici sugli host di destinazione.
Allora come si mescolano questi ingredienti per una ricetta di successo?
Sviluppo/costruzione
Per rendere il codice portabile entrano in gioco i seguenti trucchi:
- Aiutanti/macro CO-RE
- Mappe definite da BTF
#include "vmlinux.h"
(il file header contenente tutti i tipi di kernel)
Correre
Il kernel deve essere compilato con l'opzione CONFIG_DEBUG_INFO_BTF=y
per fornire l'interfaccia /sys/kernel/btf/vmlinux
che espone tipi di kernel formattati BTF. Ciò consente a libbpf di risolvere e abbinare tutti i tipi e campi e aggiornare gli offset necessari e altri dati rilocabili per assicurarsi che il programma eBPF funzioni correttamente per il kernel specifico sull'host di destinazione.
Il problema
Il problema sorge quando un programma eBPF è scritto per essere portabile ma il kernel di destinazione non espone l'interfaccia /sys/kernel/btf/vmlinux
. Per ulteriori informazioni, fare riferimento a questo elenco di distribuzioni che supportano BTF.
Per caricare ed eseguire un oggetto eBPF in kernel diversi, il caricatore libbpf utilizza le informazioni BTF per calcolare le rilocazioni dell'offset dei campi. Senza l'interfaccia BTF, il caricatore non dispone delle informazioni necessarie per regolare i tipi registrati in precedenza a cui il programma tenta di accedere dopo aver elaborato l'oggetto per il kernel in esecuzione.
È possibile evitare questo problema?
Casi d'uso
Questo articolo esplora Tracee, un progetto open source di Aqua Security, che fornisce una possibile soluzione.
Tracee fornisce diverse modalità di funzionamento per adattarsi alle condizioni ambientali. Supporta due modalità di integrazione eBPF:
- CO-RE: una modalità portatile, che funziona perfettamente su tutti gli ambienti supportati
- Non CO-RE: una modalità specifica del kernel, che richiede la creazione dell'oggetto eBPF per l'host di destinazione
Entrambi sono implementati nel codice C eBPF (pkg/ebpf/c/tracee.bpf.c
), dove ha luogo la direttiva condizionale di pre-elaborazione. Ciò ti consente di compilare CO-RE il binario eBPF, passando l'argomento -DCORE
in fase di compilazione con Clang (dai un'occhiata al target Make bpf-core
).
In questo articolo, tratteremo un caso di modalità portatile quando il binario eBPF viene creato CO-RE, ma il kernel di destinazione non è stato creato con l'opzione CONFIG_DEBUG_INFO_BTF=y
.
Per comprendere meglio questo scenario, è utile capire cosa è possibile quando il kernel non espone tipi formattati BTF su sysfs.
Nessun supporto BTF
Se desideri eseguire Tracee su un host senza supporto BTF, sono disponibili due opzioni:
- Compila e installa l'oggetto eBPF per il tuo kernel. Questo dipende da Clang e dalla disponibilità di un pacchetto kernel-headers specifico per la versione del kernel.
- Scarica i file BTF da BTFHUB per la versione del tuo kernel e forniscili al caricatore di
tracee-ebpf
tramite la variabile di ambienteTRACEE_BTF_FILE
.
La prima opzione non è una soluzione CO-RE. Compila il binario eBPF, incluso un lungo elenco di intestazioni del kernel. Ciò significa che sono necessari pacchetti di sviluppo del kernel installati sul sistema di destinazione. Inoltre, questa soluzione richiede che Clang sia installato sul computer di destinazione. Il compilatore Clang può essere pesante in termini di risorse, quindi la compilazione del codice eBPF può utilizzare una quantità significativa di risorse, influenzando potenzialmente un carico di lavoro di produzione attentamente bilanciato. Detto questo, è buona norma evitare la presenza di un compilatore nel proprio ambiente di produzione. Ciò potrebbe portare gli aggressori a creare con successo un exploit ed eseguire un'escalation dei privilegi.
La seconda opzione è una soluzione CO-RE. Il problema qui è che devi fornire i file BTF nel tuo sistema per far funzionare Tracee. L'intero archivio è di quasi 1,3 GB. Naturalmente puoi fornire il file BTF giusto per la versione del tuo kernel, ma ciò può essere difficile quando si ha a che fare con versioni diverse del kernel.
Alla fine, queste possibili soluzioni possono anche introdurre problemi, ed è qui che Tracee fa la sua magia.
Una soluzione portatile
Con una procedura di costruzione non banale, il progetto Tracee compila un binario che sia CO-RE anche se l'ambiente di destinazione non fornisce informazioni BTF. Ciò è possibile con il pacchetto embed
Go che fornisce, in fase di esecuzione, l'accesso ai file incorporati nel programma. Durante la compilazione, la pipeline di integrazione continua (CI) scarica, estrae, riduce a icona e quindi incorpora i file BTF insieme all'oggetto eBPF all'interno del file binario risultante tracee-ebpf
.
Tracee può estrarre il file BTF corretto e fornirlo a libbpf, che a sua volta carica il programma eBPF per l'esecuzione su kernel diversi. Ma come può Tracee incorporare tutti questi file BTF scaricati da BTFHub senza pesare troppo alla fine?
Utilizza una funzionalità recentemente introdotta in bpftool dal team Kinvolk chiamata BTFGen, disponibile utilizzando il sottocomando bpftool gen min_core_btf
. Dato un programma eBPF, BTFGen genera file BTF ridotti, raccogliendo esattamente ciò di cui il codice eBPF ha bisogno per la sua esecuzione. Questa riduzione consente a Tracee di incorporare tutti questi file che ora sono più leggeri (solo pochi kilobyte) e di supportare i kernel che non hanno l'interfaccia /sys/kernel/btf/vmlinux
esposta.
Compilazione di Tracee
Ecco il flusso di esecuzione della build Tracee:
(Alessio Greggi e Massimiliano Giovagnoli, CC BY-SA 4.0)
Per prima cosa devi creare il binario tracee-ebpf
, il programma Go che carica l'oggetto eBPF. Il Makefile fornisce il comando make bpf-core
per costruire l'oggetto tracee.bpf.core.o
con record BTF.
Quindi STATIC=1 BTFHUB=1 make all
crea tracee-ebpf
, che ha btfhub
individuato come dipendenza. Quest'ultimo target esegue lo script 3rdparty/btfhub.sh
, che è responsabile del download dei repository BTFHub:
btfhub
btfhub-archive
Una volta scaricato e inserito nella directory 3rdparty
, la procedura esegue lo script 3rdparty/btfhub/tools/btfgen.sh
scaricato. Questo script genera file BTF ridotti, adattati per il binario tracee.bpf.core.o
eBPF.
Lo script raccoglie i file *.tar.xz
da 3rdparty/btfhub-archive/
per decomprimerli ed infine elaborarli con bpftool, utilizzando il seguente comando:
for file in $(find ./archive/${dir} -name *.tar.xz); do
dir=$(dirname $file)
base=$(basename $file)
extracted=$(tar xvfJ $dir/$base)
bpftool gen min_core_btf ${extracted} dist/btfhub/${extracted} tracee.bpf.core.o
done
Questo codice è stato semplificato per facilitare la comprensione dello scenario.
Adesso avete tutti gli ingredienti a disposizione per la ricetta:
tracee.bpf.core.o
oggetto eBPF- File ridotti BTF (per tutte le versioni del kernel)
tracee-ebpf
Vai al codice sorgente
A questo punto, go build
viene invocato per svolgere il suo lavoro. All'interno del file embedded-ebpf.go
puoi trovare il seguente codice:
//go:embed "dist/tracee.bpf.core.o"
//go:embed "dist/btfhub/*"
Qui, al compilatore Go viene richiesto di incorporare l'oggetto eBPF CO-RE con tutti i file con riduzione BTF al suo interno. Una volta compilati, questi file saranno disponibili utilizzando il file system embed.FS
. Per avere un'idea della situazione attuale, potete immaginare il binario con un file system strutturato in questo modo:
dist
├── btfhub
│ ├── 4.19.0-17-amd64.btf
│ ├── 4.19.0-17-cloud-amd64.btf
│ ├── 4.19.0-17-rt-amd64.btf
│ ├── 4.19.0-18-amd64.btf
│ ├── 4.19.0-18-cloud-amd64.btf
│ ├── 4.19.0-18-rt-amd64.btf
│ ├── 4.19.0-20-amd64.btf
│ ├── 4.19.0-20-cloud-amd64.btf
│ ├── 4.19.0-20-rt-amd64.btf
│ └── ...
└── tracee.bpf.core.o
Il binario Go è pronto. Ora per provarlo!
Esecuzione traccia
Ecco il flusso di esecuzione dell'esecuzione di Tracee:
(Alessio Greggi e Massimiliano Giovagnoli, CC BY-SA 4.0)
Come illustra il diagramma di flusso, una delle primissime fasi dell'esecuzione di tracee-ebpf
è scoprire l'ambiente in cui è in esecuzione. La prima condizione è un'astrazione del file cmd/tracee-ebpf/initialize/bpfobject.go
, in particolare dove ha luogo la funzione BpfObject()
. Il programma esegue alcuni controlli per comprendere l'ambiente e prendere decisioni in base ad esso:
- Il file BPF fornito e BTF (vmlinux o env) esiste: carica sempre BPF come CO-RE
- File BPF fornito ma non esiste alcun BTF: è un BPF non CO-RE
- Nessun file BPF fornito e BTF (vmlinux o env) esiste: carica BPF incorporato come CO-RE
- Nessun file BPF fornito e nessun BTF disponibile: controlla i file BTF incorporati
- Nessun file BPF fornito e nessun BTF disponibile e nessun BTF incorporato: non CO-RE BPF
Ecco l'estratto del codice:
func BpfObject(config *tracee.Config, kConfig *helpers.KernelConfig, OSInfo *helpers.OSInfo) error {
...
bpfFilePath, err := checkEnvPath("TRACEE_BPF_FILE")
...
btfFilePath, err := checkEnvPath("TRACEE_BTF_FILE")
...
// Decision ordering:
// (1) BPF file given & BTF (vmlinux or env) exists: always load BPF as CO-RE
...
// (2) BPF file given & if no BTF exists: it is a non CO-RE BPF
...
// (3) no BPF file given & BTF (vmlinux or env) exists: load embedded BPF as CO-RE
...
// (4) no BPF file given & no BTF available: check embedded BTF files
unpackBTFFile = filepath.Join(traceeInstallPath, "/tracee.btf")
err = unpackBTFHub(unpackBTFFile, OSInfo)
if err == nil {
if debug {
fmt.Printf("BTF: using BTF file from embedded btfhub: %v\n", unpackBTFFile)
}
config.BTFObjPath = unpackBTFFile
bpfFilePath = "embedded-core"
bpfBytes, err = unpackCOREBinary()
if err != nil {
return fmt.Errorf("could not unpack embedded CO-RE eBPF object: %v", err)
}
goto out
}
// (5) no BPF file given & no BTF available & no embedded BTF: non CO-RE BPF
...
out:
config.KernelConfig = kConfig
config.BPFObjPath = bpfFilePath
config.BPFObjBytes = bpfBytes
return nil
}
Questa analisi si concentra sul quarto caso, quando il programma eBPF e i file BTF non vengono forniti a tracee-ebpf
. A quel punto, tracee-ebpf
tenta di caricare il programma eBPF estraendo tutti i file necessari dal suo file system incorporato. tracee-ebpf
è in grado di fornire i file di cui ha bisogno per funzionare, anche in un ambiente ostile. È una sorta di modalità ad alta resilienza utilizzata quando nessuna delle condizioni è stata soddisfatta.
Come vedi, BpfObject()
chiama queste funzioni nel quarto ramo case:
unpackBTFHub()
unpackCOREBinary()
Si estraggono rispettivamente:
- Il file BTF per il kernel sottostante
- Il binario BPF CO-RE
Disimballare il BTFHub
Ora dai un'occhiata partendo da unpackBTFHub()
:
func unpackBTFHub(outFilePath string, OSInfo *helpers.OSInfo) error {
var btfFilePath string
osId := OSInfo.GetOSReleaseFieldValue(helpers.OS_ID)
versionId := strings.Replace(OSInfo.GetOSReleaseFieldValue(helpers.OS_VERSION_ID), "\"", "", -1)
kernelRelease := OSInfo.GetOSReleaseFieldValue(helpers.OS_KERNEL_RELEASE)
arch := OSInfo.GetOSReleaseFieldValue(helpers.OS_ARCH)
if err := os.MkdirAll(filepath.Dir(outFilePath), 0755); err != nil {
return fmt.Errorf("could not create temp dir: %s", err.Error())
}
btfFilePath = fmt.Sprintf("dist/btfhub/%s/%s/%s/%s.btf", osId, versionId, arch, kernelRelease)
btfFile, err := embed.BPFBundleInjected.Open(btfFilePath)
if err != nil {
return fmt.Errorf("error opening embedded btfhub file: %s", err.Error())
}
defer btfFile.Close()
outFile, err := os.Create(outFilePath)
if err != nil {
return fmt.Errorf("could not create btf file: %s", err.Error())
}
defer outFile.Close()
if _, err := io.Copy(outFile, btfFile); err != nil {
return fmt.Errorf("error copying embedded btfhub file: %s", err.Error())
}
return nil
}
La funzione ha una prima fase in cui raccoglie informazioni sul kernel in esecuzione (osId
, versionId
, kernelRelease
, ecc.). Quindi, crea la directory che ospiterà il file BTF (/tmp/tracee
per impostazione predefinita). Recupera il file BTF corretto dal file system embed
:
btfFile, err := embed.BPFBundleInjected.Open(btfFilePath)
Infine, crea e riempie il file.
Disimballare il file binario CORE
La funzione unpackCOREBinary()
fa una cosa simile:
func unpackCOREBinary() ([]byte, error) {
b, err := embed.BPFBundleInjected.ReadFile("dist/tracee.bpf.core.o")
if err != nil {
return nil, err
}
if debug.Enabled() {
fmt.Println("unpacked CO:RE bpf object file into memory")
}
return b, nil
}
Una volta restituita la funzione principale BpfObject()
, tracee-ebpf
è pronto per caricare il binario eBPF tramite libbpfgo
. Questo viene fatto nella funzione initBPF()
, all'interno di pkg/ebpf/tracee.go
. Ecco la configurazione dell'esecuzione del programma:
func (t *Tracee) initBPF() error {
...
newModuleArgs := bpf.NewModuleArgs{
KConfigFilePath: t.config.KernelConfig.GetKernelConfigFilePath(),
BTFObjPath: t.config.BTFObjPath,
BPFObjBuff: t.config.BPFObjBytes,
BPFObjName: t.config.BPFObjPath,
}
// Open the eBPF object file (create a new module)
t.bpfModule, err = bpf.NewModuleFromBufferArgs(newModuleArgs)
if err != nil {
return err
}
...
}
In questo pezzo di codice stiamo inizializzando gli argomenti eBPF riempiendo la struttura libbfgo NewModuleArgs{}
. Attraverso il suo argomento BTFObjPath
, possiamo istruire libbpf ad utilizzare il file BTF, precedentemente estratto dalla funzione BpfObject()
.
A questo punto, tracee-ebpf
è pronto per funzionare correttamente!
(Alessio Greggi e Massimiliano Giovagnoli, CC BY-SA 4.0)
Inizializzazione del modulo eBPF
Successivamente, durante l'esecuzione della funzione Tracee.Init()
, gli argomenti configurati verranno utilizzati per aprire il file oggetto eBPF:
Tracee.bpfModule = libbpfgo.NewModuleFromBufferArgs(newModuleArgs)
Inizializzare le sonde:
t.probes, err = probes.Init(t.bpfModule, netEnabled)
Carica l'oggetto eBPF nel kernel:
err = t.bpfModule.BPFLoadObject()
Compila le mappe eBPF con i dati iniziali:
err = t.populateBPFMaps()
E infine, allega i programmi eBPF alle sonde degli eventi selezionati:
err = t.attachProbes()
Conclusione
Proprio come eBPF ha semplificato il modo di programmare il kernel, CO-RE sta affrontando un altro ostacolo. Ma sfruttare tali funzionalità richiede alcuni requisiti. Fortunatamente, con Tracee, il team di Aqua Security ha trovato un modo per sfruttare la portabilità nel caso in cui tali requisiti non possano essere soddisfatti.
Allo stesso tempo, siamo sicuri che questo sia solo l'inizio di un sottosistema in continua evoluzione che troverà sempre più supporto, anche in diversi sistemi operativi.