Framework de Crochets dans le Noyau Linux - khook

Introduction

Cet article présente un projet GitHub nommé khook, un framework permettant d'ajouter des fonctions de crochet dans le noyau Linux, compatible avec l'architecture x86. L'adresse du projet est disponible ici : https://github.com/milabs/khook

Nous commencerons par une brève explication des fonctions de crochet, puis analyserons l'utilisation de cet outil, avant d'examiner le code pour comprendre son fonctionnement sous-jacent.

Les Crochets

Imaginons que nous souhaitons intercepter le flux d'exécution d'une fonction spécifique dans le noyau, par exemple lors d'une opération de lecture sur un fichier. Cela nous permettrait de surveiller ces opérations de lecture. C'est précisément le principe des crochets. En insérant une fonction de crochet, nous pouvons interrompre le flux d'exécution normal d'un programme pour effectuer nos propres opérations. Nous pouvons nous contenter de surveiller l'opération, ou bien l'interrompre complètement.

Utilisation de khook

Inclusion du fichier d'en-tête

#include "khook/engine.c"

Ajoutez la ligne suivante dans votre kbuild/Makefile. Il s'agit d'un script de contrôle de liaison dont le contenu sera expliqué plus tard :

ldflags-y += -T$(src)/khook/engine.lds

Utilisez khook_init() et khook_cleanup() pour initialiser et nettoyer le moteur de crochet.

Dans le noyau, les fonctions peuvent être de deux types :

  • Celles dont le prototype est déjà inclus dans un fichier d'en-tête, c'est-à-dire que le noyau a déjà déclaré la fonction. Il suffit d'inclure le fichier d'en-tête correspondant pour utiliser cette fonction.
  • Celles qui n'ont pas de déclaration explicite et sont uniquement utilisées dans des fichiers .c.

Pour les fonctions dont le prototype est connu, après avoir inclus le fichier d'en-tête, vous pouvez définir une fonction de crochet avec le code suivant :

#include <linux/fs.h> // contient le prototype de inode_permission()
KHOOK(inode_permission);
static int khook_inode_permission(struct inode *inode, int mask)
{
        int ret = 0;
        ret = KHOOK_ORIGIN(inode_permission, inode, mask);
        printk("%s(%p, %08x) = %d\n", __func__, inode, mask, ret);
        return ret;
}

Pour les fonctions dont le prototype est inconnu, utilisez plutôt l'approche suivante (le fichier d'en-tête inclus ici n'est pas celui contenant le prototype de la fonction, mais plutôt celui définissant les structures utilisées comme paramètres) :

#include <linux/binfmts.h> // ne contient pas le prototype de load_elf_binary()
KHOOK_EXT(int, load_elf_binary, struct linux_binprm *);
static int khook_load_elf_binary(struct linux_binprm *bprm)
{
        int ret = 0;
        ret = KHOOK_ORIGIN(load_elf_binary, bprm);
        printk("%s(%p) = %d\n", __func__, bprm, ret);
        return ret;
}

Convention de nommage : si la fonction d'origine s'appelle fun, alors la fonction de crochet personnalisée doit obligatoirement s'appeler khook_fun. Le choix entre KHOOK et KHOOK_EXT dépend du type de fonction.

Analyse du Principe

Examinons d'abord les deux diagrammes fournis par l'auteur sur GitHub.

Flux d'exécution normal avant l'ajout d'un crochet :

CALLER
| ...
| CALL X -(1)---> X
| ...  <----.     | ...
` RET       |     ` RET -.
            `--------(2)-'

Flux d'exécution après l'ajout d'un crochet :

CALLER
| ...
| CALL X -(1)---> X
| ...  <----.     | JUMP -(2)----> STUB.hook
` RET       |     | ???            | INCR use_count
            |     | ...  <----.    | CALL handler -(3)------> HOOK.fn
            |     | ...       |    | DECR use_count <----.    | ...
            |     ` RET -.    |    ` RET -.              |    | CALL origin -(4)------> STUB.orig
            |            |    |           |              |    | ...  <----.             | N bytes of X
            |            |    |           |              |    ` RET -.    |             ` JMP X + N -.
            `------------|----|-------(8)-'              '-------(7)-'    |                          |
                         |    `-------------------------------------------|----------------------(5)-'
                         `-(6)--------------------------------------------'

Analysons le deuxième diagramme. La première instruction de X est remplacée par une instruction de saut (JUMP). De plus, trois nouveaux composants apparaissent : STUB.hook, HOOK.fn et STUB.orig, dont les significations sont les suivantes :

STUB.hook : Modèle de fonction de crochet défini par le framework. Il se compose de quatre parties : la gestion du compteur de références, trois instructions de saut (vers le handler) et une instruction de retour.

HOOK.fn : Fonction de crochet personnalisée définie par l'utilisateur. Dans l'exemple précédent, il s'agit des fonctions khook_inode_permission et khook_load_elf_binary. L'étape 4 correspond à KHOOK_ORIGIN, qui pointe vers l'adresse de la fonction d'origine. En général, la fonction de crochet personnalisée appelle également la fonction d'origine à la fin pour garantir que le flux d'exécution normal ne soit pas perturbé.

STUB.orig : Autre modèle de fonction de crochet défini par le framework. Comme la première instruction de X est remplacée par un saut, pour exécuter X normalement, il faut d'abord exécuter les instructions remplacées, puis revenir à X, comme indiqué à l'étape 5 du diagramme.

En résumé, l'approche générale consiste à remplacer les premières instructions de la fonction cible par une instruction de saut, forçant la fonction à sauter vers le code STUB défini par le framwork. Ce STUB appelle ensuite la fonction de crochet personnalisée de l'utilisateur. Après exécution du crochet, le code STUB exécute les instructions d'origine qui ont été remplacées, puis retourne au flux d'exécution normal de la fonction crochetée.

Analyse du Code Source

Structure khook

Examinons d'abord une structure qui représente un crochet :

/*
Représente un crochet dans le noyau
fn : adresse de la fonction de traitement
name : nom du symbole cible
addr : adresse du symbole cible
addr_map : adresse virtuelle où le symbole cible est mappé
orig : fonction d'origine
*/
typedef struct {
    void            *fn;        // adresse de la fonction de traitement
    struct {
        const char    *name;        // nom du symbole cible
        char        *addr;        // adresse du symbole cible (voir khook_lookup_name)
        char        *addr_map;    // mappage accessible en écriture du symbole cible
    } target;
    void            *orig;        // wrapper pour l'appel à la fonction d'origine
} khook_t;

Commençons l'analyse par le point d'entrée des fonctions de crochet définies par l'utilisateur, à savoir KHOOK et KHOOK_EXT :

/*
Convention de formatage
Si la fonction d'origine s'appelle fun
alors la fonction de crochet personnalisée doit s'appeler khook_fun
*/
#define KHOOK_(t)                            \
    static inline typeof(t) khook_##t; /* déclaration forward */        \
    khook_t                                \
    __attribute__((unused))                        \
    __attribute__((aligned(1)))                    \
    __attribute__((section(".data.khook")))                \
    KHOOK_##t = {                            \
        .fn = khook_##t,                    \
        .target.name = #t,                    \
    }
/*
Il existe deux types de fonctions
1. Celles dont le prototype est inclus dans un fichier d'en-tête
2. Celles définies dans un fichier .c mais sans déclaration dans .h
*/
#define KHOOK(t)                            \
    KHOOK_(t)
#define KHOOK_EXT(r, t, ...)                        \
    extern r t(__VA_ARGS__);                    \
    KHOOK_(t)

__attribute__((unused)) indique que l'élément pourrait ne pas être utilisé.

__attribute__((aligned(1))) spécifie un alignement d'un octet.

__attribute__((section(".data.khook"))) garantit que cette structure est placée dans la section .data.khook.

On comprend que KHOOK établit simplement une convention de formatage et s'assure que la structure est placée dans la section .data.khook.

KHOOK_EXT ajoute une déclaration de fonction, permettant ainsi d'utiliser des fonctions non déclarées.

Dans la fonction de crochet mentionnée précédemment, nous utilisons également une macro dont le sens devient clair à partir de khook :

/*
Transmet le nom de la fonction d'origine et ses paramètres
KHOOK_ORIGIN peut être utilisé comme un appel à la fonction d'origine
*/
#define KHOOK_ORIGIN(t, ...)                        \
    ((typeof(t) *)KHOOK_##t.orig)(__VA_ARGS__)

Script de Liaison

Un point important mentionné dans les instructions est l'utilisation d'un script de liaison :

ldflags-y += -T$(src)/khook/engine.lds

Examinons ce script :

SECTIONS
{
    .data : {
        KHOOK_tbl = . ;
        *(.data.khook)
        KHOOK_tbl_end = . ;
    }
}

Dans engine.c, nous voyons que tous les crochets sont placés dans la section .data.khook. Ce script signifie que tout le contenu de .data.khook est placé dans la section .data. Le caractère . représente le symbole de positionnement actuel. Ainsi, KHOOK_tbl pointe au début de .data.khook, et KHOOK_tbl_end pointe à la fin de KHOOK_tbl_end.

Considérons un exemple de script qui place la section text du fichier de sortie à 0x10000 et la section data à 0x8000000:

SECTIONS
{
. = 0x10000;
.text : { *(.text) }
. = 0x8000000;
.data : { *(.data) }
.bss : { *(.bss) }
}

Explication de l'exemple précédent : . = 0x10000 : Positionne le symbole de positionnement à 0x10000 (si non spécifié, sa valeur initiale est 0). .text : { *(.text) } : Fusionne toutes les sections .text des fichiers d'entrée en une seule section .text, dont l'adresse est spécifiée par la valeur du symbole de positionnement, soit 0x10000. . = 0x8000000 : Positionne le symbole de positionnement à 0x8000000 .data : { *(.data) } : Fusionne toutes les sections .data des fichiers d'entrée en une seule section .data, placée à l'adresse 0x8000000. .bss : { *(.bss) } : Fusionne toutes les sections .bss des fichiers d'entrée en une seule section .bss, placée à l'adresse 0x8000000 + taille de la section .data. Après avoir lu chaque description de section, le linker augmente la valeur du symbole de positionnement de la taille de cette section. Note : les contraintes d'alignement ne sont pas prises en compte ici.

En résumé, ce script de liaison définit deux variables représentant les adresses de début et de fin de la table de crochets : KHOOK_tbl et KHOOK_tbl_end.

STUB

Examinons une autre structure, le STUB :

typedef struct {
#pragma pack(push, 1)
    union {
        unsigned char _0x00_[ 0x10 ];
        atomic_t use_count;
    };
    union {
        unsigned char _0x10_[ 0x20 ];
        unsigned char orig[0];
    };
    union {
        unsigned char _0x30_[ 0x40 ];
        unsigned char hook[0];
    };
#pragma pack(pop)
    unsigned nbytes;
} __attribute__((aligned(32))) khook_stub_t;

D'après le principe décrit précédemment, chaque fonction de crochet possède un STUB.

Ce STUB est initialisé avec stub.inc ou stub32.inc, c'est-à-dire un modèle de stub.

Fonctions de Manipulation d'Instructions dans le Noyau

Le projet utilise deux fonctions du noyau pour manipuler les instructions. Leur rôle est d'obtenir l'instruction à une adresse donnée (représentée par une structure struct insn) et d'obtenir la longueur de cette instruction :

/**
 Voici la documentation du noyau pour ces deux fonctions
 insn_init() - initialise la structure struct insn
 @insn:    &struct insn à initialiser
 @kaddr:    adresse (en mémoire noyau) de l'instruction (ou copie)
 @x86_64:    !0 pour un noyau 64-bit ou une application 64-bit

insn_get_length() - Obtient la longueur de l'instruction
@insn:    &struct insn contenant l'instruction

Si nécessaire, collecte d'abord l'instruction jusqu'aux octets immédiats.
*/
static struct {
    typeof(insn_init) *init;
    typeof(insn_get_length) *get_length;
} khook_arch_lde;

// Recherche les adresses de ces deux fonctions
static inline int khook_arch_lde_init(void) {
    khook_arch_lde.init = khook_lookup_name("insn_init");
    if (!khook_arch_lde.init) return -EINVAL;
    khook_arch_lde.get_length = khook_lookup_name("insn_get_length");
    if (!khook_arch_lde.get_length) return -EINVAL;
    return 0;
}

// Obtient la longueur de l'instruction à l'adresse p
static inline int khook_arch_lde_get_length(const void *p) {
    struct insn insn;
    int x86_64 = 0;
#ifdef CONFIG_X86_64
    x86_64 = 1;
#endif
#if defined MAX_INSN_SIZE && (MAX_INSN_SIZE == 15) /* 3.19.7+ */
    khook_arch_lde.init(&insn, p, MAX_INSN_SIZE, x86_64);
#else
    khook_arch_lde.init(&insn, p, x86_64);
#endif
    khook_arch_lde.get_length(&insn);
    return insn.length;
}

Recherche dans la Table des Symboles

Le noyau possède une table des symboles globaux kallsyms, accessible via /proc/kallsyms ou le fichier statique system.map généré lors de la compilation du noyau.

Dans le noyau, on peut également utiliser la fonction kallsyms_on_each_symbol pour interroger la table des symboles. Cette fonction est encapsulée dans les deux parties suivantes :

// Fonction de callback pour la recherche de symbole
static int khook_lookup_cb(long data[], const char *name, void *module, long addr)
{
    int i = 0; while (!module && (((const char *)data[0]))[i] == name[i]) {
        if (!name[i++]) return !!(data[1] = addr);
    } return 0;
}
/*
Utilise kallsyms_on_each_symbol pour interroger la table des symboles
data[0] représente le nom à rechercher
data[1] stockera le résultat
*/
static void *khook_lookup_name(const char *name)
{
    long data[2] = { (long)name, 0 };
    kallsyms_on_each_symbol((void *)khook_lookup_cb, data);
    return (void *)data[1];
}

Comme mentionné précédemment, nous devons allouer une adresse virtuelle pour la mémoire physique où se trouve le symbole, car nous devons y écrire. Cette opération est encapsulée dans la fonction suivante :

// Crée un mappage d'adresse virtuelle pour la mémoire physique d'un symbole
static void *khook_map_writable(void *addr, size_t len)
{
    struct page *pages[2] = { 0 }; // len << PAGE_SIZE
    long page_offset = offset_in_page(addr);
    int i, nb_pages = DIV_ROUND_UP(page_offset + len, PAGE_SIZE);

    addr = (void *)((long)addr & PAGE_MASK);
    for (i = 0; i < nb_pages; i++, addr += PAGE_SIZE) {
        if ((pages[i] = is_vmalloc_addr(addr) ?
             vmalloc_to_page(addr) : virt_to_page(addr)) == NULL)
            return NULL;
    }

    addr = vmap(pages, nb_pages, VM_MAP, PAGE_KERNEL);
    return addr ? addr + page_offset : NULL;
}

Processus d'Initialisation

Pour utiliser le framework, nous devons d'abord appeler la fonction khook_init, définie dans engine.c :

int khook_init(void)
{
    void *(*malloc)(long size) = NULL;

    // Alloue la mémoire nécessaire pour tous les stubs
    malloc = khook_lookup_name("module_alloc");
    if (!malloc || KHOOK_ARCH_INIT()) return -EINVAL;

    khook_stub_tbl = malloc(KHOOK_STUB_TBL_SIZE);
    if (!khook_stub_tbl) return -ENOMEM;
    memset(khook_stub_tbl, 0, KHOOK_STUB_TBL_SIZE);

    // Recherche les adresses de toutes les fonctions à crocheter
    khook_resolve();

    // Établit les mappages
    khook_map();
    // Arrête toutes les machines et exécute khook_sm_init_hooks
    stop_machine(khook_sm_init_hooks, NULL, NULL);
    khook_unmap(0);

    return 0;
}

Cette fonction effectue les opérations suivantes :

  1. Alloue la mémoire nécesaire pour tous les STUBs.
  2. Recherche dans la table des symboles les adresses de toutes les fonctions à crocheter, puis établit les mappages d'adresses virtuelles.
  3. Exécute khook_sm_init_hook pour établir les liens entre les STUBs et les crochets, garantissant leur logique de saut appropriée.

La fonction de résolution des adresses des symboles est simple, comme le montre le code suivant :

// Obtient l'adresse dans le noyau pour chaque crochet dans KHOOK_tbl
static void khook_resolve(void)
{
    khook_t *p;
    KHOOK_FOREACH_HOOK(p) {
        p->target.addr = khook_lookup_name(p->target.name);
    }
}

De même, la fonction d'établissement des mappages :

// Établit les mappages d'adresses virtuelles pour chaque crochet
static void khook_map(void)
{
    khook_t *p;
    KHOOK_FOREACH_HOOK(p) {
        if (!p->target.addr) continue;
        p->target.addr_map = khook_map_writable(p->target.addr, 32);
        khook_debug("target %s@%p -> %p\n", p->target.name, p->target.addr, p->target.addr_map);
    }
}

La partie la plus importante est l'étape 3 :

static int khook_sm_init_hooks(void *arg)
{
    khook_t *p;
    KHOOK_FOREACH_HOOK(p) {
        if (!p->target.addr_map) continue;
        khook_arch_sm_init_one(p);
    }
    return 0;
}

L'implémentation principale se trouve dans la fonction suivante :

static inline void khook_arch_sm_init_one(khook_t *hook) {
    khook_stub_t *stub = KHOOK_STUB(hook);
    // E9 est un saut relatif, FF est un saut absolu.
    if (hook->target.addr[0] == (char)0xE9 ||
        hook->target.addr[0] == (char)0xCC) return;

    BUILD_BUG_ON(sizeof(khook_stub_template) > offsetof(khook_stub_t, nbytes));
    memcpy(stub, khook_stub_template, sizeof(khook_stub_template));
    // Configure l'étape 3
    stub_fixup(stub->hook, hook->fn);

    // Un saut relatif x86 fait 5 octets, nous devons donc sauvegarder au moins 5 octets d'instructions
    while (stub->nbytes < 5)
        stub->nbytes += khook_arch_lde_get_length(hook->target.addr + stub->nbytes);

    memcpy(stub->orig, hook->target.addr, stub->nbytes);
    // Configure l'étape 5
    x86_put_jmp(stub->orig + stub->nbytes, stub->orig + stub->nbytes, hook->target.addr + stub->nbytes);
    // Configure l'étape 2
    x86_put_jmp(hook->target.addr_map, hook->target.addr, stub->hook);
    hook->orig = stub->orig; // le seul lien du hook vers le stub
}

On voit que c'est ici que le contenu du stub est configuré.

  1. D'abord, on remplit le stub avec le contenu de khook_stub_template, qui provient de stub.inc.
  2. À l'étape 3, le stub doit sauter vers la fonction de crochet personnalisée, stub_fixup remplit cette adresse.
  3. On sauvegarde une partie du début de la fonction, qui doit faire au moins 5 octets.
  4. On configure l'adresse de retour à la fonction d'origine.
  5. On remplace le contenu de la fonction d'origine par une instruction de saut.

Les fonctions auxiliaires utilisées sont les suivantes :

// Place une instruction de saut à l'adresse @a depuis @f vers @t
static inline void x86_put_jmp(void *a, void *f, void *t)
{
    *((char *)(a + 0)) = 0xE9;
    *(( int *)(a + 1)) = (long)(t - (f + 5));
}

// Ce tableau est écrit dans stub.inc ou stub32.inc, représentant un modèle de stub
static const char khook_stub_template[] = {
# include KHOOK_STUB_FILE_NAME
};

// Dans stub32.inc, on trouve plusieurs 0xca consécutifs, on écrit ensuite la valeur (adresse de la fonction de crochet) après ces octets
static inline void stub_fixup(void *stub, const void *value) {
    while (*(int *)stub != 0xcacacaca) stub++;
    *(long *)stub = (long)value;
}

Étiquettes: noyau linux crochets Hooking khook x86

Publié le 23 juin à 22h35