Comment Tracee résout le manque d’informations BTF


  • Français


  • Tracee est un projet d’Aqua Security pour le traçage des processus au moment de l’exécution. En traçant les processus à l’aide de la technologie Linux eBPF (filtre de paquets Berkeley), Tracee peut corréler les informations collectées et identifier les modèles de comportement malveillants.

    eBPF

    BPF est un système d’aide à l’analyse du trafic réseau. Le dernier système eBPF étend le BPF classique pour améliorer la programmabilité du noyau Linux dans différents domaines, tels que le filtrage de réseau, l’accrochage de fonctions, etc. Grâce à sa machine virtuelle basée sur les registres, qui est embarquée dans le noyau, eBPF peut exécuter des programmes écrits avec un langage C restreint sans avoir besoin de recompiler le noyau ou de charger un module. Grâce à eBPF, vous pouvez exécuter votre programme dans le contexte du noyau et accrocher divers événements dans le chemin du noyau. Pour ce faire, eBPF doit avoir une connaissance approfondie des structures de données utilisées par le noyau.

    eBPF CO-RE

    eBPF s’interface avec l’ABI du noyau Linux (interface binaire d’application). L’accès aux structures du noyau à partir de la machine virtuelle eBPF dépend de la version spécifique du noyau Linux.

    eBPF CO-RE (compile une fois, exécuté partout) est la capacité d’écrire un programme eBPF qui compilera avec succès, passera la vérification du noyau et fonctionnera correctement sur différentes versions du noyau sans avoir besoin de le recompiler pour chaque noyau particulier.

    Ingrédients

    CO-RE a besoin d’une synergie précise de ces composants :

    • Informations BTF (format de type BPF) : Permet la capture d’informations cruciales sur les types de programme et le code du noyau et BPF, activant toutes les autres parties du puzzle BPF CO-RE.
    • Compilateur (Clang): Enregistre les informations de réinstallation. Par exemple, si vous deviez accéder au task_struct->pid champ, Clang enregistrerait qu’il s’agissait exactement d’un champ nommé pid de type pid_t résidant dans une structure task_struct. Ce système garantit que même si un noyau cible a un task_struct disposition dans laquelle le pid le champ est déplacé vers un décalage différent dans un task_struct structure, vous pourrez toujours la trouver uniquement par son nom et ses informations de type.
    • Chargeur BPF (libbpf) : Lie les BTF du noyau et les programmes BPF pour ajuster le code BPF compilé à des noyaux spécifiques sur les hôtes cibles.

    Alors, comment ces ingrédients se mélangent-ils pour une recette réussie ?

    Développement/construction

    Pour rendre le code portable, les astuces suivantes entrent en jeu :

    • Assistants/macros CO-RE
    • Cartes définies par BTF
    • #include "vmlinux.h" (le fichier d’en-tête contenant tous les types de noyau)

    Courir

    Le noyau doit être construit avec le CONFIG_DEBUG_INFO_BTF=y option afin de fournir la /sys/kernel/btf/vmlinux interface qui expose les types de noyau au format BTF. Cela permet à libbpf de résoudre et de faire correspondre tous les types et champs et de mettre à jour les décalages nécessaires et d’autres données relocalisables pour s’assurer que le programme eBPF fonctionne correctement pour le noyau spécifique sur l’hôte cible.

    Le problème

    Le problème survient lorsqu’un programme eBPF est écrit pour être portable mais que le noyau cible n’expose pas le /sys/kernel/btf/vmlinux interface. Pour plus d’informations, se référer à cette liste de distributions prenant en charge BTF.

    Pour charger et exécuter un objet eBPF dans différents noyaux, le chargeur libbpf utilise les informations BTF pour calculer les déplacements de décalage de champ. Sans l’interface BTF, le chargeur ne dispose pas des informations nécessaires pour ajuster les types précédemment enregistrés auxquels le programme tente d’accéder après avoir traité l’objet pour le noyau en cours d’exécution.

    Est-il possible d’éviter ce problème ?

    Cas d’utilisation

    Cet article explore Traceeun projet open source d’Aqua Security, qui fournit une solution possible.

    Tracee propose différents modes de fonctionnement pour s’adapter aux conditions de l’environnement. Il prend en charge deux modes d’intégration eBPF :

    • CŒUR: UN mode portatifqui s’exécute de manière transparente sur tous les environnements pris en charge
    • Non CO-RE : UN mode spécifique au noyaunécessitant la création de l’objet eBPF pour l’hôte cible

    Les deux sont implémentés dans le code C eBPF (pkg/ebpf/c/tracee.bpf.c), où la directive conditionnelle de prétraitement a lieu. Cela vous permet de compiler CO-RE le binaire eBPF, en passant le -DCORE argument au moment de la construction avec Clang (regardez le bpf-core Faire cible).

    Dans cet article, nous allons couvrir un cas du mode portable lorsque le binaire eBPF est construit CO-RE, mais que le noyau cible n’a pas été construit avec CONFIG_DEBUG_INFO_BTF=y option.

    Pour mieux comprendre ce scénario, il est utile de comprendre ce qui est possible lorsque le noyau n’expose pas les types au format BTF sur sysfs.

    Pas de support BTF

    Si vous souhaitez exécuter Tracee sur un hôte sans prise en charge de BTF, il existe deux options :

    1. Construire et installer l’objet eBPF pour votre noyau. Cela dépend de Clang et de la disponibilité d’un package d’en-têtes de noyau spécifique à la version du noyau.
    2. Téléchargez les fichiers BTF à partir de BTFHUB pour votre version de noyau et fournissez-la au tracee-ebpfs chargeur à travers le TRACEE_BTF_FILE variables d’environnement.

    La première option n’est pas une solution CO-RE. Il compile le binaire eBPF, y compris une longue liste d’en-têtes du noyau. Cela signifie que vous avez besoin de packages de développement de noyau installés sur le système cible. De plus, cette solution nécessite que Clang soit installé sur votre machine cible. Le compilateur Clang peut être gourmand en ressources, de sorte que la compilation du code eBPF peut utiliser une quantité importante de ressources, affectant potentiellement une charge de travail de production soigneusement équilibrée. Cela dit, c’est une bonne pratique d’éviter la présence d’un compilateur dans votre environnement de production. Cela pourrait conduire les attaquants à créer un exploit et à effectuer une élévation de privilèges.

    La deuxième option est une solution CO-RE. Le problème ici est que vous devez fournir les fichiers BTF dans votre système afin de faire fonctionner Tracee. L’archive entière fait près de 1,3 Go. Bien sûr, vous pouvez fournir le bon fichier BTF pour votre version du noyau, mais cela peut être difficile lorsqu’il s’agit de différentes versions du noyau.

    En fin de compte, ces solutions possibles peuvent également introduire des problèmes, et c’est là que Tracee opère sa magie.

    Une solution portative

    Avec une procédure de construction non triviale, le projet Tracee compile un binaire pour être CO-RE même si l’environnement cible ne fournit pas d’informations BTF. C’est possible avec le embed Go package qui fournit, lors de l’exécution, l’accès aux fichiers intégrés dans le programme. Pendant la construction, le pipeline d’intégration continue (CI) télécharge, extrait, minimise, puis intègre les fichiers BTF avec l’objet eBPF dans le tracee-ebpf binaire résultant.

    Tracee peut extraire le bon fichier BTF et le fournir à libbpf, qui à son tour charge le programme eBPF pour qu’il s’exécute sur différents noyaux. Mais comment Tracee peut-il embarquer tous ces fichiers BTF téléchargés depuis BTFHub sans trop peser au final ?

    Il utilise une fonctionnalité récemment introduite dans bpftool par l’équipe Kinvolk appelée BTFGendisponible à l’aide du bpftool gen min_core_btf sous-commande. Étant donné un programme eBPF, BTFGen génère des fichiers BTF réduits, collectant uniquement ce dont le code eBPF a besoin pour son exécution. Cette réduction permet à Tracee d’embarquer tous ces fichiers désormais plus légers (quelques kilo-octets seulement) et de supporter les noyaux qui n’ont pas la /sys/kernel/btf/vmlinux interface exposée.

    Construction tracée

    Voici le flux d’exécution du build Tracee :

    (Alessio Greggi et Massimiliano Giovagnoli, CC BY-SA 4.0)

    Tout d’abord, vous devez construire le tracee-ebpf binaire, le programme Go qui charge l’objet eBPF. Le Makefile fournit la commande make bpf-core pour construire le tracee.bpf.core.o objet avec des enregistrements BTF.

    Alors STATIC=1 BTFHUB=1 make all construit tracee-ebpfqui a btfhub ciblée comme une dépendance. Cette dernière cible exécute le script 3rdparty/btfhub.shqui est responsable du téléchargement des référentiels BTFHub :

    Une fois téléchargé et placé dans le 3rdparty répertoire, la procédure exécute le script téléchargé 3rdparty/btfhub/tools/btfgen.sh. Ce script génère des fichiers BTF réduits, adaptés à la tracee.bpf.core.o Binaire eBPF.

    Le script recueille *.tar.xz fichiers de 3rdparty/btfhub-archive/ pour les décompresser et enfin les traiter avec bpftool, en utilisant la commande suivante :

    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

    Ce code a été simplifié pour faciliter la compréhension du scénario.

    Maintenant, vous avez tous les ingrédients disponibles pour la recette :

    • tracee.bpf.core.o Objet eBPF
    • Fichiers BTF réduits (pour toutes les versions du noyau)
    • tracee-ebpf Aller au code source

    À ce point, go build est invoqué pour faire son travail. À l’intérieur de embedded-ebpf.go fichier, vous pouvez trouver le code suivant :

    //go:embed "dist/tracee.bpf.core.o"
    //go:embed "dist/btfhub/*"

    Ici, le compilateur Go est chargé d’intégrer l’objet eBPF CO-RE avec tous les fichiers réduits en BTF à l’intérieur de lui-même. Une fois compilés, ces fichiers seront disponibles en utilisant le embed.FS système de fichiers. Pour avoir une idée de la situation actuelle, vous pouvez imaginer le binaire avec un système de fichiers structuré comme ceci :

    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

    Le binaire Go est prêt. A essayer maintenant !

    Course de suivi

    Voici le flux d’exécution de l’exécution Tracee :

    (Alessio Greggi et Massimiliano Giovagnoli, CC BY-SA 4.0)

    Comme l’illustre l’organigramme, l’une des toutes premières phases de tracee-ebpf l’exécution consiste à découvrir l’environnement dans lequel il s’exécute. La première condition est une abstraction de la cmd/tracee-ebpf/initialize/bpfobject.go fichier, en particulier lorsque le BpfObject() fonction a lieu. Le programme effectue quelques vérifications pour comprendre l’environnement et prendre des décisions en fonction de celui-ci :

    1. Fichier BPF donné et BTF (vmlinux ou env) existe : chargez toujours BPF en tant que CO-RE
    2. Fichier BPF donné mais aucun BTF n’existe : c’est un BPF non CO-RE
    3. Aucun fichier BPF fourni et BTF (vmlinux ou env) existe : chargez le BPF intégré en tant que CO-RE
    4. Aucun fichier BPF fourni et aucun BTF disponible : vérifier les fichiers BTF intégrés
    5. Aucun fichier BPF fourni et pas de BTF disponible et pas de BTF embarqué : BPF non CO-RE

    Voici l’extrait de code :

    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
    }

    Cette analyse se concentre sur le quatrième cas, lorsque le programme eBPF et les fichiers BTF ne sont pas fournis à tracee-ebpf. À ce moment, tracee-ebpf essaie de charger le programme eBPF en extrayant tous les fichiers nécessaires de son système de fichiers intégré. tracee-ebpf est capable de fournir les fichiers dont il a besoin pour fonctionner, même dans un environnement hostile. C’est une sorte de mode haute résilience utilisé lorsqu’aucune des conditions n’est remplie.

    Comme tu vois, BpfObject() appelle ces fonctions dans la quatrième branche de cas :

    • unpackBTFHub()
    • unpackCOREBinary()

    Ils extraient respectivement :

    • Le fichier BTF pour le noyau sous-jacent
    • Le binaire BPF CO-RE

    Déballez le BTFHub

    Jetez maintenant un coup d’œil à partir de 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 fonction a une première phase où elle collecte des informations sur le noyau en cours d’exécution (osId, versionId, kernelRelease, etc). Ensuite, il crée le répertoire qui va héberger le fichier BTF (/tmp/tracee par défaut). Il récupère le bon fichier BTF à partir du embed système de fichiers:

    btfFile, err := embed.BPFBundleInjected.Open(btfFilePath)
    

    Enfin, il crée et remplit le fichier.

    Décompressez le binaire CORE

    La unpackCOREBinary() fonction fait une chose similaire:

    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
    }

    Une fois la fonction principale BpfObject()Retour, tracee-ebpf est prêt à charger le binaire eBPF via libbpfgo. Cela se fait dans le initBPF() fonction, à l’intérieur pkg/ebpf/tracee.go. Voici la configuration de l’exécution du programme :

    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
            }
            ...
    }

    Dans ce morceau de code, nous initialisons les arguments eBPF en remplissant la structure libbfgo NewModuleArgs{}. A travers ses BTFObjPath argument, nous sommes en mesure d’indiquer à libbpf d’utiliser le fichier BTF, précédemment extrait par le BpfObject() fonction.

    À ce point, tracee-ebpf est prêt à fonctionner correctement !

    (Alessio Greggi et Massimiliano Giovagnoli, CC BY-SA 4.0)

    Initialisation du module eBPF

    Ensuite, lors de l’exécution du Tracee.Init() fonction, les arguments configurés seront utilisés pour ouvrir le fichier objet eBPF :

    Tracee.bpfModule = libbpfgo.NewModuleFromBufferArgs(newModuleArgs)
    

    Initialisez les sondes :

    t.probes, err = probes.Init(t.bpfModule, netEnabled)
    

    Chargez l’objet eBPF dans le noyau :

    err = t.bpfModule.BPFLoadObject()
    

    Remplir les cartes eBPF avec les données initiales :

    err = t.populateBPFMaps()
    

    Et enfin, attachez les programmes eBPF aux sondes d’événements sélectionnés :

    err = t.attachProbes()
    

    Conclusion

    Tout comme eBPF a simplifié la manière de programmer le noyau, CO-RE s’attaque à un autre obstacle. Mais tirer parti de ces fonctionnalités a certaines exigences. Heureusement, avec Tracee, l’équipe d’Aqua Security a trouvé un moyen de tirer parti de la portabilité au cas où ces exigences ne pourraient pas être satisfaites.

    En même temps, nous sommes sûrs que ce n’est que le début d’un sous-système en constante évolution qui trouvera un support croissant à plusieurs reprises, même dans différents systèmes d’exploitation.

    Source

    Houssen Moshinaly

    Pour contacter personnellement le taulier :

    Laisser un commentaire

    Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

    Copy code