retour




Jouer avec la « libdl »

Notice

Le présent document est publié sous la licence « Verbatim Copying » telle que définie par la Free Software Fondation dans le cadre du projet GNU.


Toute copie ou diffusion de ce document dans son intégralité est permise (et même encouragée), quel qu'en soit le support à la seule condition que cette notice, incluant les copyrights, soit préservée.


Copyright (C) 2004, 2005 Yann LANGLAIS, ilay.


La plus récente version en date de ce document est sise à l'url http://ilay.org/yann/articles/dlfcn/.


Toute remarque, suggestion ou correction est bienvenue et peut être adressée à Yann LANGLAIS, ilay.org.

Introduction

En 1992, alors que je découvrais les joies de l'édition de texte avec emacs, une question m'est venue à l'esprit. Pourquoi diable les extensions d'emacs ont-elles été écrites en elisp (emacs-lisp) et non en C? La réponse était simple. Il n'existait pas alors de possibilité standard en C de charger et décharger des bibliothèques en cours d'exécution.

Quelques années plus tard, et bien qu'emacs ait été remplacé par vi dans mes habitudes dactylographiques, je découvris avec plaisir un outil qui rendait caduque mon explication. Je venais de tomber par hasard sur dlfcn.h.

Ce fichier d'entète défini les prototypes de points d'entrée forts appréciables et permettant de charger et décharcher des bibliothèques et de récupérer des points d'entrée ou des symboles (variables) pour les utiliser à volonté.

Aujourd'hui, et bien que ces fonctions soient à la base de la notion des "plug-in" en C et C++, leur utilisation me semble encore trop restreinte. Voyons à travers quelques exemples ce que nous pouvons en faire.

Avertissements

Les exemples fournis n'ont pas pour vocation d'être toujours justes ni d'effectuer tous les tests appropriés. Ils n'ont valeur d'exemple que pour le domaine très restreint de l'illustration du propos. En temps normal, il est en particulier nécessaire de tester toutes les valeurs retours de dlopen et dlsym avant de les utiliser.

L'article est basé sur Solaris et Linux.

Présentation de la libdl.so

Présentation générale

La libdl.so permet de charger (et de décharger) des bibliothèques partagées pendant l'exécution d'un programme et de mettre à disposition du programme tout ou partie des symboles (noms de fonctions ou de variables globales) chargés. C'est un éditeur de liens en cours d'exécution. Il effectue les mêmes tâches que ld lors de l'étape de l'édition de liens.

schéma récapitulatif du fonctionnement
Figure 1 : Synoptique du fonctionnement de dlopen, dlsym et dlclose

Cette bibliothèque est intimement liée au format de fichiers binaires ELF1 (Extecutable and Linkable Format) qui a permis d'obtenir du code librement déplaçable dans l'espace mémoire des processus. Il existe des plateformes qui ne supportent pas le format ELF. Il existe aussi des plateformes qui, bien que supportant les bibliothèques partagées, n'implémentent pas la libdl. Pour une partie de ces plateformes, il existe cependant un alternative avec la bibliothèque libtld.so fournie avec le paquet libtool2 de GNU. Les points d'entrée de la libdlt.so sont similaires à ceux de la libdl.so mais sont préfixés par « lt_ ».

Les variations entre les différentes plateformes viennent du relativement jeune age de cette biliothèque et donc de sa standardisation récente et imparfaite.

Initialement créées par SUN, la norme POSIX 1003.13 a standardisé les fonctions dlclose, dlerror, dlopen et dlsym. Ces fonctions sont aussi présentes dans la Linux Standard Base (LSB) version 1.34. Sun a par suite ajouté la fonction dladdr. GNU l'a reprise et a ajouté dlvsym.

Les différents drapeaux RTLD_* ne sont pas non plus tous standardisés, mais, au moins sur Solaris et Linux, les valeurs engendrent des comportements assez proches les uns des autres (à une exception importante près que nous verrons au paragraphe « Chaînage de fonctions »).

Description des points d'entrée

La bibliothèque contient 5 points d'entrée principaux : dlopen, dlsym, dlclose, dlerror et dladdr.

dlopen

extern void *   dlopen(const char *, int);

Cette fonction charge en mémoire la bibliothèque désignée par la chaîne de caractère passée en premier paramètre. Le second paramètre est un drapeau qui fixe le mode de fonctionnement de l'éditeur de lien à la volée. Ce second peut prendre les valeurs (documentées) suivantes :


En complément de ces valeurs, il existe une co-valeur RTLD_GLOBAL augmentant la portée des symboles en les rendant disponibles externes (i.e. non préfixes en C par static) pour les bibliothèques chargées par la suite.
Elle s'empoie en l'accolant par un ou binaire « | » à l'une des valeur possible du drapeau :
RTLD_LAZY | RTLD_GLOBAL ou RTLD_NOW | RTLD_GLOBAL

dlopen retourne un pointeur vers une poignée (handle) caractéristique de la bibliothèque sous forme d'un pointeur générique (void *). Celui-ci sera nul en cas d'échec.

Notons que si dlopen renvoie NULL, la variable globale errno ne devrait pas avoir été modifiée et que strerr() ne pourra donc fournir d'explication correcte. Il faudra pour cela appeler dlerror().

dlsym

extern void *   dlsym(void *, const char *);

dlsym se charge de retrouver (résoudre) l'adresse d'un symbole dont le nom est passé en paramètre.

Le premier paramètre passé est un pointeur vers la « poignée » retournée par dlopen.

Il existe en standard deux poignées paticulières définies lorsque _GNU_SOURCE est définie. Ces poignées sont définie par les symboles RTLD_DEFAULT et RTLD_NEXT.

RTLD_DEFAULT précise à dlsym qu'il faut rechercher la première occurence du symbole dans les bibliothèques déjà chargées en mémoire, dans l'ordre de leur chargement.

RTLD_NEXT stipule à dlsym qu'il lui faut chercher l'occurence suivante du symbole dans les bibliothèques suivant la bibliothèque en cours. Ce pseudo-pointeur ne fonctionne que pour les bibliothèques partagées.

Suivant les implémentations, on pourra aussi retrouver le symbole RTLD_SELF qui recherchera le symbole dans la bibliothèque en cours.

Le second paramètre est le symbole à rechercher.

dlsym retourne le pointeur sur le symbole demandé, ou, si celui-ci n'est pas retrouvé, un poineur NULL.

Attention :
dlsym() ne permet pas de résoudre que des noms de fonction, mais tous les symboles en général. On peut donc aussi retrouver des variables globales (i.e. variable définies hors fonctions et sans l'attribut « static »).

dlclose

extern int      dlclose(void *);

La fonction dlclose() a pour fonction d'éliminer les liens et de décharger des bibliothèques chargées par dlopen().

Elle prend en argument la poignée fournie par dlopen().

Elle retourne 0 quand tout ce passe bien, ou un code d'erreur positif en cas de soucis.

dlerror

extern char *   dlerror(void);

dlerror() retourne une chaîne de caractères contenant dernier message d'erreur généré par la libdl ou NULL si aucune erreur n'est survenue.

Après l'appel, dlerror() remet les codes d'erreur de la bibliothèque à 0. En conséquence, un second appel à dlerror suivant immédiatement le premier renverra NULL.

Les chaînes de catactères retournées ne doivent pas être libérées par free().

dladdr

extern int      dladdr(void *, Dl_info *);

dladdr() détermine si une adresse est localisée dans un objet en mémoire. Si tel est le cas, il retourne des informations relatives à cet objet.

dladdr() prend en premier paramêtre une adresse sous la forme d'un pointeur anonyme (void *). C'est cette adresse qu'il essaiera de retrouver parmi les segments de texte et de données présents en mémoire.
Le second paramètre est un pointeur vers une structure allouée de type DL_info qui sert de réceptacle aux informations de retour.
La DL_info contient les champs suivants :

typedef struct	dl_info {
	char		*dli_fname;
	void		*dli_fbase;
	char		*dli_sname;
	void		*dli_saddr;
} Dl_info;

dli_fname contient le nom du fichier dans lequel est stocké le pointeur fourni en argument,
dli_fbase contient l'adresse de base du fichier en mémoire,
dli_sname contient le nom du fichier source du bloc correspondant au pointeur fourni si les information de débug présentes. dli_sbase contient l'adresse du symbole contenant le pointeur fourni.

dladdr() retourne 0 s'il ne trouve pas de d'objet contenant le pointeur passé. Tout autre valeur que 0 signifie que des informations ont été trouvées et que la structure DL_info a été remplie.


Pour de plus amples informations sur ces points d'entrée, il est nécessaire de s'en remettre aux manuels5

D'autres informations concernant la programmations de bibiothèques partagées peuvent être trouvées en référence6.

Enfin, certaines variables d'envionnement influent sur les bibliothèques, ou plutôt sur l'éditeur de liens7. L'une d'entre elle sera particulièrement utile dans dans les paragraphes suivants.

Constructeur et destructeur

Il est parfois nécessaire d'accomplir certaines actions lors du chargement ou du déchargement d'une bibliothèque. On peut, par exemple devoir réserver de la mémoire et initialiser certaines variables lors du chargement, et libérer la mémoire avant le déchargement.

Pour ce faire, il n'y a malheureusement pas de norme, mais deux écoles distinctes. Il y a tout d'abord, la méthode « historique » qui est de définir deux fonctions void _init() et void _fini(). Ces 2 points d'entrée sont en général créés automatiquement par les différents linker. Le fait de les redéfinir dans nos bibliothèques et de demander au linker d'éviter de les générer permet donc de remplacer les fonctions standard par les notres. Seulement, en général, les éditeurs de liens ne génèrent pas des fonctions vides et le code manquant risque d'avoir des effets secondaires indésirables. Du reste, si Linux supporte encore la fonctinonalité en tant qu'« obsolete », Sun l'interdit (il en permet pas de demander au compilateur de ne pas générer la fonction standard.
Il est donc fortement recommandé d'éviter l'utilisation de ces fonctions.

La seconde méthode revient à utiliser des directives de compilation propriétaires. Par exemple, si l'on veut que la fonction void onload(void) soit appelée au chargement de la bibliothèque, et void onunload(void), on écrira sous Linux (compilateur gcc) dans le code source :

void __attribute__ ((constructor)) onload(void) {
	printf("loading\n");
}

void __attribute__ ((destructor)) onunload(void) {
	printf("unloading\n");
}

et, sous Sun (compilateur Forte 6)

#pragma init (onload)
void onload(void) {
	printf("loading\n");
}

#pragma fini (onunload)
void onload(void) {
	printf("unloading\n");
}

Il est clair que si l'on recherche la portabilité, il est nécessaire de faire en sorte de se passer de ce genre de chose, à moins d'utiliser les #ifdef.

Un exemple simple

Voyons l'utilisation de la libdl à travers un petit exemple simple.

Admettons que nous voulions écrire un programme qui fournit le sinus d'une liste de valeurs passées en paramètre. Admettons de plus que nous n'ajoutions pas la bibliothèque libm à la compilation.

Nous allons donc charger notre bibliothèque « manuellement » pendant l'exécution du programme.

cat > sin.c <<EOF
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>
int main(int n, char *a[]) {
    int i;
    void *lib;
    double (*sin)(double);
	
    /* load /usr/lib/libm.so into memory: */
    if (!(lib = dlopen("/usr/lib/libm.so", RTLD_LAZY))) return 1;
	
    /* resolve symbole "sin": */
    if (!(sin = (double (*)(double)) dlsym(lib, "sin"))) return 2;

    /* print sine of all arguments: 
    for (i = 1; i < n; i++) printf("sin(%f) = %f\n", atof(a[i]), sin(atof(a[i])));

    /* unload /usr/lib/libm.so */
    dlclose(lib);

    return 0;
}
EOF
gcc sin.c -o sin -ldl

Le lancement du programme sin donne :

sin 0 0.22 .5 1 3.14159
sin(0.000000) = 0.000000
sin(0.220000) = 0.218230
sin(0.500000) = 0.479426
sin(1.000000) = 0.841471
sin(3.141590) = 0.000003

Nous pouvons aussi rendre plus « générique » notre programme en lui faisant calculer autre chose que des sinus. Nous pouvons par exemple, avec un unique exécutable, faire calculer de nombreuses fonctions mathématiques.

Pour ce faire, nous pouvons, par exemple, tenter de charger la fonction qui porte le même nom que le programme (argument 0). Ainsi, en renommant le programme ou en faisant des liens symboliques vers d'autres symboles, notre programme aura des comportements différents :

cat > math.c <<EOF
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>
int main(int n, char *a[]) {
    int i;
    void *lib;
    double (*math)(double);

    /* load /usr/lib/libm.so into memory: */
    if (!(lib  = dlopen("/usr/lib/libm.so", RTLD_LAZY))) return 1;

    /* resolve symbole a[0]: */
    if (!(math = (double (*)(double)) dlsym(lib, a[0]))) return 2;

    /* call a[0] for all command line agruments: */
    for (i = 1; i < n; i++) printf("%s(%f) = %f\n", a[0], atof(a[i]), math(atof(a[i])));

    /* unload libm: */
    dlclose(lib);

    return 0;
}
EOF
gcc math.c -o sin -ldl

et voyons le résultat :

sin 0 0.22 .5 1 3.14159
sin(0.000000) = 0.000000
sin(0.220000) = 0.218230
sin(0.500000) = 0.479426
sin(1.000000) = 0.841471
sin(3.141590) = 0.000003
mv sin cos
cos 0 0.22 .5 1 3.14159
cos(0.000000) = 1.000000
cos(0.220000) = 0.975897
cos(0.500000) = 0.877583
cos(1.000000) = 0.540302
cos(3.141590) = -1.000000
ln -s cos tan
tan 0.22 .5 1 3.14159
tan(0.220000) = 0.223619
tan(0.500000) = 0.546302
tan(1.000000) = 1.557408
tan(3.141590) = -0.000003
mv tan log
log 1 2.718282 10
log(1.000000) = 0.000000
log(2.718282) = 1.000000
log(10.000000) = 2.302585
cp cos sqrt
sqrt 2 4 9
sqrt(2.000000) = 1.414214
sqrt(4.000000) = 2.000000
sqrt(9.000000) = 3.000000

Bien sur, cet exemple ne fonctionne que pour les foncions de la bibliothèque mathématique libm qui ont pour prototype double (*)(double). Si l'on renommait notre programme original en pow, le résultat est tout à fait aléatoire : la fonction pow de la libm ira chercher un second argument sur la pile et prendra ce qu'il trouvera.

Portée des symboles : le privé et le public

La portée des symboles d'une bibliothèque varie en fonction de la manière dont sont définis les symboles. Seuls les symboles définis en dehors d'une foncion sont accessibles de l'extérieur. La notion de portée est déterminée par les mots static et extern (ou rien).

Voyons cela à travers un exemple :

cat > libtest.c <<EOF
static int static_int = 55;
       int extern_int = 66;

static void static_function() {
    extern_int--;
}

void extern_function() {
    extern_int++;
}
EOF
gcc -G -shared -fPIC libtest.c -o libtest.so
cat > portee.c <<EOF
#include <stdio.h>
#include <dlfcn.h>

int main(void) {
    void *h;
    void *p;

    if (!(h = dlopen("libtest.so", RTLD_LAZY))) {
        fprintf(stderr, "error loading libtest.so: %s\n", dlerror());
        return 1;
    }
    if (!(p = dlsym(h, "static_int"))) 
        fprintf(stderr, "error resolving static_int: %s\n", dlerror());
    else 
        printf("libtest.static_int is at %x and its value is %d\n", p, *(int *) p);

    if (!(p = dlsym(h, "extern_int"))) 
        fprintf(stderr, "error resolving extern_int: %s\n", dlerror());
    else
        printf("libtest.extern_int is at %x and its value is %d\n", p, *(int *) p);

    if (!(p = dlsym(h, "static_function"))) 
        fprintf(stderr, "error resolving static_function: %s\n", dlerror());
    else 
        printf("libtest.static_function is at %x\n", p);

    if (!(p = dlsym(h, "extern_function"))) 
        fprintf(stderr, "error resolving extern_function: %s\n", dlerror());
    else 
        printf("libtest.error_function is at %x\n", p);
    
    dlclose(h);

    return 0;
}
EOF
cc -o portee portee.c -ldl

Vérifions tout d'abord la portée (Bind) des syboles avec nm (sous Solaris, le résultat de nm sous Linux est un peu moins parlant) :

nm libtest.so | egrep "Index|static_int|extern_int|static_function|extern_function"
[Index]   Value      Size    Type  Bind  Other Shndx   Name
[41]    |       672|      36|FUNC |GLOB |0    |5      |extern_function
[42]    |     66404|       4|OBJT |GLOB |0    |10     |extern_int
[31]    |       616|      36|FUNC |LOCL |0    |5      |static_function
[30]    |     66408|       4|OBJT |LOCL |0    |10     |static_int

On voit clairement que les symboles préfixés par static_ et définis localement n'ont qu'une portée locale (Bind vaut LOCL par opposition à GLOB pour « globale »).
Voila ce que l'on obtient en lançant le programme de test:

portee
error resolving static_int: ld.so.1: portee: fatal: static_int: can't find symbol
libtest.extern_int is at ff270364 and its value is 66
error resolving static_function: ld.so.1: portee: fatal: static_function: can't find symbol
libtest.error_function is at ff2602a0

Dans cet exemple, la seule manière d'accéder à static_int depuis notre programme de test serait d'ajouter des fonctions spécifiques :

cat > libtest2.c <<EOF
static int static_int = 55;
       int extern_int = 66;

static void static_function() {
    extern_int--;
}

void extern_function() {
    extern_int++;
}
int  static_int_get() {
    return static_int;
}
int  static_int_set(int i) {
    return static_int = i;
}
void static_int_print() {
    printf("static_int = %d\n", static_int);
}
EOF
gcc -G -shared -fPIC libtest2.c -o libtest2.so
cat > portee2.c <<EOF
#include <stdio.h>
#include <dlfcn.h>
int main(void) {
    void *h;
    void *p;
    int  (*get)();
    int  (*set)();
    void  (*print)();
    
    if (!(h = dlopen("libtest2.so", RTLD_LAZY))) {
        fprintf(stderr, "error loading libtest2.so: %s\n", dlerror());
        return 1;
    }
    if (!(get = (int (*)()) dlsym(h, "static_int_get"))) 
        fprintf(stderr, "error resolving static_int: %s\n", dlerror());
    else 
        printf("static_int_get() returns %d\n", get());

    if (!(set = (int (*)(int)) dlsym(h, "static_int_set"))) 
        fprintf(stderr, "error resolving static_int_set: %s\n", dlerror());
    else
        printf("setting static_int with static_int_get() to 33\n", set(33));

    if (!(print = (void (*)()) dlsym(h, "static_int_print"))) 
        fprintf(stderr, "error resolving static_int_print: %s\n", dlerror());
    else {
        printf("call static_int_print() : ");
        print();
    }
    dlclose(h);
    return 0;
}   
EOF
gcc portee2.c -o portee2 -ldl

Le nouveau programme de test donne :

portee2
static_int_get() returns 55
setting static_int with static_int_get() to 33
call static_int_print() : static_int = 33

Ce genre de restrictions et ces formes d'appels ressemblent bien à celle que l'on peut avoir avec les attributs private ou public du C++.

Vers l'auto-programmation

Le mythe de l'auto-programmation n'est pas récent. Dans la philosophie du lisp, par exemple, le programme est une liste et la liste est [potentiellement] un programme. Tout programme est donc capable de se créer des extensions.
En règle générale, les langages de script sont des instruments de choix pour l'autoprogrammation ou la génération de code spécifique conditionnel et son evaluation à la volée :

#!/bin/zsh
cmd="ls -alrt $1"
eval $cmd

Le BASIC des années 80 avait sa commande « merge » qui permettait de créer du code dans un fichier et de le reinjecter par la suite dans le programme en cours.
De nombreux programmeurs ont un peu été déroutés par l'absence d'équivalent en C.

Mais, à l'étape de compilation près, les fonctions dl* sont maintenant disponibles pour mimer ce fonctionnement :

cat > dltest.c <<EOF
#include <stdlib.h>
#include <stdio.h>
#include <sys/fcntl.h>
#include <string.h>
#include <dlfcn.h>

int main(void) {
    int fd;
    void *lib;
    void (*funct)();
    char code[] = "#include <stdio.h>\n\
void hello() { printf(\"hello, world\\\n\"); }\n";

    /* Create hello.c */
    if ((fd = open("./hello.c", O_WRONLY | O_CREAT, 0640)) < 1) {
        perror("cannot open \"hello.c\"");
        return 1;
    }
    write(fd, code, strlen(code));
    close(fd);

    /* compile hello.c */
    if (system("gcc -shared -fPIC -G hello.c -o hello.so") < 0) 
        return 2;
         
    /* read hello.so */
    if (!((lib = dlopen("./hello.so", RTLD_LAZY)))) 
        return 3;

    /* retrieve "hello" symbol pointer */
    if (!(funct = (void (*)()) dlsym(lib, "hello"))) {
        dlclose(lib);
        return 4;
    }

    /* call funct() */
    funct();

    dlclose(lib);
    return 0;
}
EOF
gcc dltest.c -o dltest -ldl
Attention :
Si vous recopiez ce code à la main ou avec un copier/coller il faut éliminer un des 3 « \ » dans la ligne :
void hello() { printf(\"hello, world\\\n\"); }\n";

Détournements et surcharge

La libdl permet aussi de faire des détournement de fonctions.

Admettons que nous voulions afficher un message à chaque malloc() et a chaque free(). Il nous suffit de réécrire nos deux fonctions et de les faire charger en lieux et place des points d'entrée système par un petit tour de passe-passe. Cependant, il nous faut aussi appeler les fonctions du système afin d'effectuer les opérations d'allocation ou de désallocation.

Le premier tour de passe-passe (le chargement de notre code au lieu du code système) peut se faire de deux façons :

Concentrons nous sur la seconde possibilité. Il nous suffit, avant lancement du programme cible, de renseigner la variable LD_PRELOAD :

# en sh/ksh/bash/zsh ...:
LD_PRELOAD=malib.so programme_cible
# en csh/tcsh:
(setenv LD_PRELOAD malib.so; programme_cible)

Prenons un programme qui ne fait que charger une bibliothèque dynamique et attendre:

#include <unistd.h>
#include <dlfcn.h>

int main(void) {
    void *p;
    p = dlopen("lib1.so", RTLD_LAZY);
    pause();
    return 0;
}

Un programme dépend explicitement de bibliothèques, comme la libc, par exemple. La liste de ces dépendances peut être visualisée grace à la commande ldd.

ldd pause

donnera sur Solaris :

        libdl.so.1 =>    /usr/lib/libdl.so.1
        libc.so.1 =>     /usr/lib/libc.so.1
        /usr/platform/SUNW,Sun-Fire/lib/libc_psr.so.1

En temps normal, dès que le code exécutable d'un programme est chargé en mémoire, les dépendances chargées les unes après les autres, chaque bibliothèque pouvant elle-meme dépendre d'autres bibliothèques.
Dans le cas d'une bibliothèque chargée par dlopen(), ici lib1.so, nous avons pendant l'exécution (commande pldd sous Solaris) :

/usr/lib/libdl.so.1
/usr/lib/libc.so.1
/usr/platform/sun4u/lib/libc_psr.so.1
lib1.so

La variable d'environnement indique aux routines de chargement des programme de charger le contenu de la variable en mémoire juste après le chargement du segment exécutable et juste avant le chargement de la première bibliothèque dans la liste des dépendances.

En lançant la commande :

LD_PRELOAD=lib2.so pause

On obtient :

lib2.so
/usr/lib/libdl.so.1
/usr/lib/libc.so.1
/usr/platform/sun4u/lib/libc_psr.so.1
lib1.so

schéma récapitulatif du fonctionnement
Figure 2 : Chargement normal d'un programme (1) et chargement avec LD_PRELOAD (2)

Supposons maintenant que notre programme cible tst_alloc soit le suivant :

#include <stdlib.h>

int main() {
    char *str;
    if (!(str = (char *) malloc(32))) return 1;
    free(str);
    return 0;
}

que nous compilons de la manière suivante :

gcc -o tst_alloc tst_alloc.c 

Supposons maintenant le code mymalloc.c suivant que nous voulons executer :

#include <stdio.h>
void * malloc(size_t size) {
    printf("malloc(%d)\n", size);
    return NULL;
}
void free(void *p) {
    printf("free(%x)\n", p); 
}

que nous compilerons de la façon suivante afin d'en faire une bibliothèque :

gcc -shared -fPIC -o libmymalloc.so mymalloc.c

Le lancement de tst_malloc seul s'effectue (normalement) sans erreur ni message et retournera 0 (la variable $? vaudra 0). Inserrons maintenant notre bibliothèque:

LD_PRELOAD=libmymalloc.so tst_malloc

affichera

malloc(32)

de plus, la commande echo $? retournera 1, ce qui est tout à fait normal, puisque notre fonction malloc retournera le pointeur 0 et n'effectuera pas l'allocation.

Nous avons surchargé dynamiquement la fonction standard d'allocation mémoire en insérant la notre.

Voyons maintenant comment appeler le VRAI malloc au sein de notre malloc. Pour cela, penchons nous sur l'aide de dlsym() (man dlsym). Nous voyons que certains defines peuvent remplacer le pointeur vers la bibliothèque (handle). En particuliers RTLD_NEXT. L'appel à dlsym(RTLD_NEXT, symbol) va rechercher le point d'entrée "symbol" dans la bibliothèque suivante (par rapport à celle en cours).

Notre bibliothèque libmymalloc.so va donc se compliquer de la sorte :

#include <stdio.h>
#include <dlfcn.h>
void * malloc(size_t size) {
    static void *(*sys_malloc)(size_t) = NULL;
    if (!sys_malloc) {
        if (!(sys_malloc = (void *(*)(size_t)) dlsym(RTLD_NEXT, "malloc"))) {
            perror("cannot fetch system malloc\n");
            exit(1);
        }
    }
    printf("malloc(%d)\n", size);
    return sys_malloc(size);
}
void free(void *p) {
    static void (*sys_free)(void *) = NULL;
    if (!sys_free) {
        if (!(sys_free = (void (*)(void *)) dlsym(RTLD_NEXT, "free"))) {
            perror("cannot fetch system free\n");
            exit(2);
        }
    }
    printf("free(%x)\n", p); 
    sys_free(p);
}

L'étape de compilation de la bibliothèque se voit aussi légèrement modifiée :

gcc -shared -fPIC -G -o libmymalloc.so mymalloc.c -ldl -D_GNU_SOURCE

La commande

LD_PRELOAD=libmymalloc.so tst_malloc

affichera

malloc(32)
free(20888)

(l'adresse en paramêtre de free est variable en fonction de l'architecture, entre autre) et la variable $? devrait maintenant être à 0

De nombreuses applications de cette technique sont possibles. Elles peuvent aller du débugger mémoire à l'écriture de chevaux de Troie ou portes dérobées... Certaines restrictions sont cependant tout naturellement imposées pour des questions de sécurité : LD_PRELOAD est ignoré par les programmes en suid :).

Le mécanisme de détournement implémente la notion de « surcharge » connue en C++. La libdl.so permet aussi d'appeler les fonctions surchargées à l'intérieur des surcharges.

Chaînage de fonctions : de l'héritage aux chaînes de traitements

Supposons que nous ayons en mémoire plusieurs bibliothèques qui possèdent des points d'entrée portant le même nom. En généralisant le processus de surcharge, la fonction dlsym permet de chaîner ces fonctions.

for i in 1 2 3 4
do
cat > lib$i.c <<EOF
lib1.c:
#include <stdio.h>
#include <dlfcn.h>
void foo() {
    void (*next_foo)(void);
    printf("lib$.foo()\n"); 
    next_foo = dlsym(RTLF_NEXT, "foo");
    next_foo();
}
EOF
gcc -shared -fPIC -G lib$i.c -o lib$i.so
done
cat > foo.c <<EOF
#include <dlfcn.h> 
extern void foo();
int main() {
    foo();
}
EOF
gcc foo.c -o foo -L. -l1 -l2 -l3 -l4 -ldl 

Pour cet exemple, il est nécessaire que la variable LD_LIBRARY_PATH contienne le répertoire courant. Le lancement de foo donne :

foo
lib1.foo()
lib2.foo()
lib3.foo()
lib4.foo()

L'ordre d'appel dépend de l'ordre d'inclusion des bibliothèques :

gcc foo.c -o foo -L. -l3 -l2 -l1 -l4 -ldl 
foo
lib3.foo()
lib2.foo()
lib1.foo()
lib4.foo()

Voyons s'il en va de même si les bibliothèques sont chargées dynamiquement avec dlopen. Il est nécessaire dans ce cas de préciser à l'éditeur de lien à la volée de résoudre tous les points d'entrée dès le chargement et de rendre les symboles globaux.

(1) Le programme charge dynamiquement les objets partagés lib1.so, lib2.so, lib3.so puis lib4.so.

(2) Le programme appelle la fonction foo() déclarée dans la première des bibliothèques chargées. La fonction foo() est appelée, effectue son traitement puis cherche à résoudre le symbole foo dans la bibliothèque suivante. Si ce symbole existe, elle appelle la fonction...

(3) Le programme décharge les bibliothèques.

Chaînage dynmique
Figure 3 : Chaînage dynamique de fonctions

On remplacera dont l'argument RTLD_LAZY de dlopen par l'argument RTLD_NOW|RTLD_GLOBAL.

for i in 1 2 3 4
cat > libchain$i.c <<EOF
#include <stdio.h>
#include <dlfcn.h>
void foo() {
	void (*next_foo)();
	printf("libchain$i.foo()\n");
	if ((next_foo = (void (*)()) dlsym(RTLD_NEXT, "foo"))) next_foo();
} 
EOF
gcc -shared -fPIC -G libchain$i.c -o libchain$i.so -ldl
done
cat > chain.c <<EOF
#include <dlfcn.h> 
int main() {
	void *l1, *l2, *l3, *l4;
	void (*foo)();
	l1 = dlopen("libchain1.so", RTLD_NOW | RTLD_GLOBAL);
	l2 = dlopen("libchain2.so", RTLD_NOW | RTLD_GLOBAL);
	l3 = dlopen("libchain3.so", RTLD_NOW | RTLD_GLOBAL);
	l4 = dlopen("libchain4.so", RTLD_NOW | RTLD_GLOBAL);
	foo = (void (*)()) dlsym(l1, "foo");
	foo();
	dlclose(l4);
	dlclose(l3);
	dlclose(l2);
	dlclose(l1);
	return 0;
}
EOF
gcc chain.c -o chain -ldl

Le lancement de chain sous Solaris nous donnera :

chain
libchain1.foo()
libchain2.foo()
libchain3.foo()
libchain4.foo()

Par contre sous Linux, chain ne nous donnera que :

chain
libchain1.foo()

Dans POSIX3, RTLD_NEXT est officiellement « réservé pour une utilisation future » (partie normative). On trouve cependant un section informative (i.e. non-normative), qui explique le fonctionnement que RTLD_NEXT devra avoir.
La LSB4 (depuis au moins la version 1.3) spécifie que « La valeur RTLD_NEXT, qui est réservée pour une utilisation furure, devrait être disponible, avec le comportement décrit dans ISO POSIX (2003) ».
La plupart des pages de manuels sont aussi en adéquation avec la section informative.
En conséquense, un bug a été déposé dans le BUGZILLA8 de la glibc. Cependant, eu égard aux positions assez radicales prises par Ulrich Drepper, responsable de la glibc, au sujet de la LSB (« Do you still think the LSB has some value? »), le bug a été, après quelques péripéties, considéré comme invalide par M. Drepper lui même.
Les linuxiens devront donc se rabattre sur la libtld.so pour tirer pleinement partie de cette fonctionnalité.

D'ailleurs que pourrait on bien en faire ? Le chaînage de fonction permet la création de chaînes de traitement, à l'instar d'une chaîne de montage industrielle. Ce chaînage permet la composition processus spécialisés.
Imaginons par exemple qui nous fassions du traitement d'image. Une image donnée nécessite une série d'opérations (filtres). Supposons que nous disposions d'une dizaine de filtres spécifiques (flou, rouge, vert, bleu, ...). Nous pouvons dynamiquement définir un ordre de traitement de notre image de base, puis, par sauts d'une bibliothèque à l'autre.

Chaîne de traitement
Figure 4 : Chaîne de traitement

Espionnage de greffons

Lorsqu'on programme des greffons, il arrive que ses greffons ne se chargent pas correctements, que certains symboles ne soient pas définis, ou encore que le greffon chargé ne soit pas celui attendu (problème classique du polymorphisme). Il arrive de plus que le programme greffé ne nous transmette pas les messages d'erreur de la libdl.so. Dans ces cas, il est assez difficile de trouver les causes des dysfonctionements.

Pour contourner le problème, on peut détourner les fonctions dlsym, dlopen, dlerror et dlclose. Oui, mais voilà, en détournant dlsym, comment récupérer le point d'entrée dlsym du système?

Certains systèmes nous facilitent la tache. Un nm de /usr/lib/libdl.so.1 sur Solaris 8 nous montre que les symboles dlsym, dlopen, dlclose et dlerror ont des contreparties en _dlsym, _dlopen, _dlclose et _dlerror :

nm /usr/lib/libdl.so.1 | egrep "dlsym|dlopen|dlclose|dlerror" 
[37]         |      2236|       8|FUNC |GLOB |0    |7      |_dlclose
[35]         |      2244|       8|FUNC |GLOB |0    |7      |_dlerror
[27]         |      2220|       8|FUNC |GLOB |0    |7      |_dlopen
[55]         |      2228|       8|FUNC |GLOB |0    |7      |_dlsym
[26]         |      2236|       8|FUNC |WEAK |0    |7      |dlclose
[53]         |      2244|       8|FUNC |WEAK |0    |7      |dlerror
[47]         |      2220|       8|FUNC |WEAK |0    |7      |dlopen
[44]         |      2228|       8|FUNC |WEAK |0    |7      |dlsym

Les points d'entrée standard semblent bien être des alias de plus faible priorité que les originaux préfixés par un souligné. Dans ce cas, il est possible d'appeler directement _dlsym à l'intérieur de notre fonction détournée.

Sous Linux, par contre, ces alias n'existent pas. un nm de la libdl.so ne nous apprendra en fait rien dans les distributions où les bibliothèques systèmes sont débarassées de leurs symboles (strip). L'utilitaire string nous donnera les points d'entrée. Las, aucun de ces points d'entrée, après consultation des sources de la glibc et quelques tests ne nous sera d'un grand secours.

Il faut alors se tourner vers la fonction dlvsym(void *handle, const char *name, const char *version_str) dont la documentation laisse encore à désirer, quelqu'en soit la source. Cette fonction nécessite, en plus du nom du point d'entrée, une chaîne de caractères représentant la version. Pour retrouver cette chaîne, il suffit d'écrire un petit bout de code faisant appel à dlsym, de le compiler et le lier avec la libdl.so, puis faire un nm sur le binaire.

nm dltest
...
08049840 W data_start
         U dlclose@@GLIBC_2.0
         U dlopen@@GLIBC_2.1
         U dlsym@@GLIBC_2.0
...

Cette chaîne est accolée au symbole dlsym sous la forme "dlsym@@version_st" :

nm dltest | grep dlsym | cut -d@ -f3  
GLIBC_2.0

Une fois cette chaîne trouvée, nous pouvons résoudre le point d'entré, et réécrire nos fonctions :

#include <stdio.h>
#include <stdarg.>
#ifdef  _LINUX_
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#endif
#include <dlfcn.h>

typedef struct {
    void *handle;
    void *(*open)(const char  *, int);
    void *(*sym)(void *, const char *);
    char *(*error)(void);
    int   (*close)(void *);
} dlspy_t;

dlspy_t __dlspy__ = { NULL, NULL, NULL, NULL, NULL };

#ifdef LINUX
#include "config.h"
#ifndef DLSPY_GLIBC_VERSION
#define DLSPY_GLIBC_VERSION "GLIBC_2.0"
#endif
#endif
void 
dlspy_init() {
    /* 1st step: save standard dl* functions */
#if defined(SUN)    
    __dlspy__.sym   = (void *(*)(void *, const char *)) _dlsym;
    __dlspy__.open  = (void *(*)(const char *, int))    _dlopen;
    __dlspy__.error = (char *(*)(void))                 _dlerror;
    __dlspy__.close = (int   (*)(void *))               _dlclose;
#elif defined(LINUX)
    if (!(__dlspy__.sym = dlvsym(RTLD_NEXT, "dlsym", DLSPY_GLIBC_VERSION))) {
        perror("dlspy fatal: cannot fetch system's dlsym entry point\n");
        exit(1);
    }
    __dlspy__.open  = (void *(*)(const char *, int)) __dlspy__.sym(RTLD_NEXT, "dlopen");
    __dlspy__.error = (char *(*)(void))              __dlspy__.sym(RTLD_NEXT, "dlerror");
    __dlspy__.close = (int   (*)(void *))            __dlspy__.sym(RTLD_NEXT, "dlclose");
#else 
#error ERROR: cannot get dl* original entry points.;
#endif
}

void 
dlspy_echo(char *fmt, ...) {
    va_list args;
    char buf[1024], buf2[1024];
    va_start(args, fmt);
    sprintf(buf2, "dlspy: %s", fmt);
    vsprintf(buf, buf2, args);
    fprintf(stderr, "%s\n", buf);
    va_end(args);
}

char *
dlerror(void) {
    static char *errstr = NULL;
    char *t;
    if (!__dlspy__.open) dlspy_init();
    t = __dlspy__.error();
    if (!t && errstr) return errstr;
    if (t) { 
        errstr = t;
        return t;
    }
    return (char *) strdup(" "); 
}

void *
dlopen(const char *name, int mode) {
    void *t = NULL;

    if (!__dlspy__.open) dlspy_init();
        
    dlspy_echo("try to open \"%s\" shared object with mode %d...", name, mode);
    if (!(t = __dlspy__.open(name, mode))) dlspy_echo("failed (%s)\n", dlerror());  
    else dlspy_echo("ok\n");
    return t;
}

void *
dlsym(void *h, const char *name) {
    void *t = NULL;
    if (!__dlspy__.open) dlspy_init();
    dlspy_echo("try to bind symbol \"%s\" as new entry point...", name);
    if (!(t = __dlspy__.sym(h, name))) dlspy_echo("failed (%s)\n", dlerror());  
    else dlspy_echo("ok at %x\n", t);
    return t;
}

int
dlclose(void *h) {
    if (!__dlspy__.open) dlspy_init();
    dlspy_echo("dlclose(%p)", h);
    return __dlspy__.close(h);
}

Peut-on aller plus loin ?

Une fonctionnalité intéressante serait de pouvoir tracer chaque appel aux fonctions résolues par dlsym(). Pour ce faire, il semble nécessaire de définir un pool de fonctions qui devront, lors du premier appel, prendre l'addresse du point d'entrée fraichement résolu, et dans, un second temps, appeler le point d'entrée avec les paramètres qui lui ont été passés, sans pour autant connaître leur nombre ni leur taille.

Ce problème n'est pas nouveau. La FAQ du forum comp.lang.c9 en parle comme d'un problème insoluble de manière générique. Des pointeurs ou axes de recherches y sont explorés. On peut le résoudre en grande parties par l'utilisation de certaines fonctionnalités obscures de gcc10, les __builtin_apply et __builtin_apply_args.

Et en C++ ?

Il est possible d'utiliser la libdl.so en C++. Cependant, il est nécessaire de faire un peu plus attention qu'en C. En effet, l'implémentation du polymorphisme (mais qui pourrait tout autant servir à vérifier le typage en cours d'exécution) en C++ passe par l'ajout de préfixes et de suffixes aux noms de fonction. C'est la technique de "mangling". Aussi, il est nécessaire, comme pour l'utilisation de pointeurs de fonctions, de définir les fonctions exportées en « extern "C" » afin d'éviter le mangling.

Pour aller plus loin sur le sujet, voir les documents sités en référence 11 et 12.

Conclusions

Cette présentation de la libdl est loin d'être exhaustive. Les possibilités qu'elle offre, comme la surcharge ou l'héritage à un niveau très différents d'un langage de programmation en font un outil très puissant dont la seule limite est celle notre propre imagination.

Références

1 Wikipedia, Executable and Linkable Format
Tool Intrerface Standard (TIS), Executable and Linkable Format (ELF) Specification v1.2
Tool Intrerface Standard (TIS), Generic ELF Specification ELFVERSION
Linkers and Loaders, John Levine, Chapter 10: Dynamic Linking and Loading
2 The GNU Libtool Homepage, GNU Libtool
The GNU Libtool Manual, Shared library support for GNU
3 The Open Group Base Specifications Issue 6 IEEE Std 1003.1, 2004 Edition (i.e. la norme POSIX), Single Unix Specification.
Voir les spécifications de dlopen, dlsym, dlerror et dlclose. Ce site demande un enregistrement gratuit préalable.
4 Linux Standard Base 3.0, 13.16. Interface Definitions for libdl, 13.15. Data Definitions for libdl et dlsym
5 Linux Programer's Manual, man dlopen
6 Linux HOWTO, http://www.faqs.org/docs/Linux-HOWTO/Program-Library-HOWTO.html
How to Write Shared Libraries, Ulrich Drepper
7 L'éditeur de lien et ses variables d'environnement, Samuel Dralet, Linux Magazine France numéro 63, Juillet/Août 2004, page 76
8 Descriptif et historique du bug 1319 sur le comportement de dlsym avec RTLD_NEXT
9 comp.lang.c Frequently Asked Questions 15. Variable-Length Argument Lists
10 gcc info Construction calls
gcc features, Clifford Wolf, Some demonstrations of nice/osbcure gcc features
11 C++ dlopen mini HOWTO
12 Linux Journal, James Norton, Dynamic Class Loading for C++ on Linux

D'autres articles techniques : http://ilay.org/yann/articles/

Source présentés dans cette page