Ricerca nel sito web

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 denominato pid di tipo pid_t residente all'interno di una struttura task_struct. Questo sistema garantisce che anche se un kernel di destinazione ha un layout task_struct in cui il campo pid viene spostato su un offset diverso all'interno di una struttura task_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:

  1. 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.
     
  2. Scarica i file BTF da BTFHUB per la versione del tuo kernel e forniscili al caricatore di tracee-ebpf tramite la variabile di ambiente TRACEE_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:

  1. Il file BPF fornito e BTF (vmlinux o env) esiste: carica sempre BPF come CO-RE
  2. File BPF fornito ma non esiste alcun BTF: è un BPF non CO-RE
  3. Nessun file BPF fornito e BTF (vmlinux o env) esiste: carica BPF incorporato come CO-RE
  4. Nessun file BPF fornito e nessun BTF disponibile: controlla i file BTF incorporati
  5. 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.

Articoli correlati: