Reversing Linux : Comprendre le rôle des sections PLT et GOT dans l’édition de liens dynamique
25 juillet 2008 – 11:04J’ai récemment eu la volonté de comprendre plus en détails comment Linux parvient à résoudre les symboles (tels que les fonctions) liés dynamiquement dans les programmes. Ne disposant pas d’Internet et n’ayant pas les sources du noyau sous la main, il a fallu faire avec les moyens du bord : un éditeur de texte, gcc, gdb, et un peu de connaissance relative à l’édition de liens en général. Je préfère donc préciser que cet article n’a pas pour objectif d’être exhaustif et de décrire le fonctionnement exact de l’édition de liens dynamique et de la résolution des symboles sous Linux. Il se contente de présenter la démarche que j’ai eu pour comprendre les choses à ma manière. En particulier, le rôle des sections PLT et GOT sera expliqué. Si le sujet vous intéresse, vous trouverez sans doute d’autres papiers certainement plus détaillés.
Rappels théoriques
Tout d’abord, il convient de faire quelques rappels sur la compilation et l’édition de liens des binaires. Quand on compile un programme qui fait appel à des fonctions situées dans d’autres bibliothèques (telles que la librairie standard), l’édition de liens peut être faite de deux façons différentes. La première méthode, dite statique, consiste à intégrer à l’exécutable toutes les librairies dont il a besoin pour fonctionner. A l’exécution, tous les symboles sont donc résolus, et les appels sont immédiats. Si cette méthode a été la plus utilisée dans les versions anciennes des OS, elle est toutefois largement dépassée. En effet, il s’agit d’un gouffre à espace disque, puisqu’elle oblige à dupliquer chaque librairie autant de fois qu’il y a d’exécutables qui l’utilisent. Les exécutables générés sont donc volumineux, puisqu’il suffit par exemple d’un simple appel à printf()
pour que toute la librairie standard du C soit intégrée à l’exécutable !
Depuis les versions « récentes » de Linux, c’est la deuxième méthode d’édition de liens, dite dynamique, qui est utilisée par défaut. Avec cette méthode, chaque librairie est compilée une fois pour toute dans une librairie dynamique, ou partagée (shared) ayant l’extension .so (équivalent des .dll sous Windows). Lorsque l’on compile un programme qui y fait référence, on y insère juste le nom du symbole (fonction ou variable) dont il a besoin, ainsi que le nom de la librairie. C’est à l’exécution du programme que l’éditeur de liens dynamique (ou dynamic linker), nommé ld.so, charge les libraires nécessaire et effectue la résolution des symboles manquants en temps réel. C’est donc la vitesse d’exécution qui s’en retrouve pénalisée, même si nous verrons que cette perte est toutefois relative car compensée par un système de mise en cache des adresses.
Enfin, il convient de clarifier la notion de PIC, ou Position Independant Code. Un code exécutable est dit PIC s’il peut être mappé à l’importe quelle région mémoire tout en pouvant s’exécuter convenablement. Dans de tels exécutables, aucune adresse absolue ne doit apparaître, puisque si l’exécutable se retrouve translaté en mémoire, les adresses absolues ne seront plus valides. Dans Linux, les librairies dynamiques sont en PIC. C’est le linker dynamique, ld.so, qui les charge en mémoire à l’exécution, et leur place en mémoire peut varier d’une exécution à une autre. Ainsi, l’adresse des fonctions de la libraire standard, telles que printf()
, changent de place à chaque exécution. Pourtant, un programme qui utilise printf()
n’est compilé qu’une seule fois. Comment les processus arrivent-ils donc à s’exécuter tout en prenant en compte cette variation d’adresses ? C’est là tout l’objectif de cet article…
Un programme de test
Place à la pratique ! Dans la suite, je considérerais que nous somme sur une Ubuntu Hardy (noyau 2.6.24), avec gcc 4.2.3 et gdb 6.8. Nous allons utiliser le programme en C suivant :
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(){ char c1[] = "chaine1"; char c2[] = "chaine2"; int res = strcmp(c1, c2); printf("c1 == c2 ? %dn", res); return 0; }
Pour illustrer ce que nous venons de voir, compilons-le avec les deux méthodes, statique et dynamique :
$ gcc -o bin_str bin_str.c $ gcc -static -o bin_str_static bin_str.c
Notez au passage que gcc linke les exécutable dynamiquement par défaut, et que l’option -static permet de forcer l’édition de liens statique. A l’exécution, les deux produisent exactement le même résultat :
$ ./bin_str c1 == c2 ? -1 $ ./bin_str_static c1 == c2 ? -1
Cependant, quand on compare la taille des exécutables…
$ ls -lh bin_str bin_str_static -rwxrwxrwx 1 root root 6,6K 2008-07-21 19:12 bin_str -rwxrwxrwx 1 root root 545K 2008-07-21 19:12 bin_str_static
On constate que le binaire lié statiquement (545K) est 80 fois plus volumineux que le binaire lié dynamiquement (qui ne pèse que 6.6K) ! En effet, c’est toute la librairie standard qui a été incorporée au binaire durant l’édition de liens.
Lançons-les avec gdb et désassemblons le main de chacun :
$ gdb ./bin_str_static (gdb) disas main Dump of assembler code for function main: ... 0x0804823b <main+75>: call 0x804dd70 <strcmp> ... 0x08048251 <main+97>: call 0x8048c70 <printf> ... (gdb) quit $ gdb ./bin_str (gdb) disas main Dump of assembler code for function main: ... 0x0804844f <main+75>: call 0x8048364 <strcmp@plt> ... 0x08048465 <main+97>: call 0x8048344 <printf@plt> ...
On constate que dans le binaire lié statiquement, printf()
et strcmp()
ont bien été intégrées dans la région .text de l’exécutable. Dans le binaire lié dynamiquement, les deux appels n’ont cependant pas lieu dans la .text, mais dans une région spécifique à l’édition de lien dynamique : la .plt. La PLT, pour Procedure Linkage Table, est une table sertvant à faire le lien avec les fonctions situées dans des bibliothèques dynamiques.
La PLT et la GOT
Dans la suite, on ne travaillera évidemment qu’avec le binaire lié dynamiquement. Listons les régions actuellement mapées dans l’espace mémoire de l’exécutable:
(gdb) info files ... 0x080482e4 - 0x08048314 is .init 0x08048314 - 0x08048374 is .plt 0x08048380 - 0x0804852c is .text 0x0804852c - 0x08048548 is .fini 0x08048548 - 0x0804856f is .rodata 0x08048570 - 0x08048574 is .eh_frame 0x08049574 - 0x0804957c is .ctors 0x0804957c - 0x08049584 is .dtors 0x08049584 - 0x08049588 is .jcr 0x08049588 - 0x08049658 is .dynamic 0x08049658 - 0x0804965c is .got 0x0804965c - 0x0804967c is .got.plt 0x0804967c - 0x08049688 is .data 0x08049688 - 0x0804968c is .bss
La région .plt est donc mappée entre les adresses 0×08048314 et 0×08048374. Il est important de noter que cette section se trouve à des adresses fixes. On vérifie au passage que les adresses appelées par les deux calls du main (0×8048364 pour strcmp
et 0×8048344 pour printf
) appartiennent bien à cette zone. Désassemblons à présent la région .plt :
(gdb) disas 0x08048314 0x08048374 Dump of assembler code from 0x8048314 to 0x8048374: 0x08048314 <_init+48>: pushl 0x8049660 0x0804831a <_init+54>: jmp *0x8049664 0x08048320 <_init+60>: add %al,(%eax) 0x08048322 <_init+62>: add %al,(%eax) 0x08048324 <__gmon_start__@plt+0>: jmp *0x8049668 0x0804832a <__gmon_start__@plt+6>: push $0x0 0x0804832f <__gmon_start__@plt+11>: jmp 0x8048314 <_init+48> 0x08048334 <__libc_start_main@plt+0>: jmp *0x804966c 0x0804833a <__libc_start_main@plt+6>: push $0x8 0x0804833f <__libc_start_main@plt+11>: jmp 0x8048314 <_init+48> 0x08048344 <printf@plt+0>: jmp *0x8049670 0x0804834a <printf@plt+6>: push $0x10 0x0804834f <printf@plt+11>: jmp 0x8048314 <_init+48> 0x08048354 <__stack_chk_fail@plt+0>: jmp *0x8049674 0x0804835a <__stack_chk_fail@plt+6>: push $0x18 0x0804835f <__stack_chk_fail@plt+11>: jmp 0x8048314 <_init+48> 0x08048364 <strcmp@plt+0>: jmp *0x8049678 0x0804836a <strcmp@plt+6>: push $0x20 0x0804836f <strcmp@plt+11>: jmp 0x8048314 <_init+48> End of assembler dump.
Globalement, on constate que la section .plt est composée de plusieurs sous-sections de taille égale (0×10 soit 16 octets), que nous appellerons entrées. L’entrée 0 (composée des 4 premières instructions) est un peu spéciale ; nous y reviendrons plus tard. Les autres entrées, à compter de la 1 jusqu’à la dernière, sont composées de 3 instructions qui suivent toujours le même schéma. Un premier jmp
a lieu, dont l’adresse de saut n’est pas explicitement donnée dans l’instruction, mais par l’intermédiaire d’un pointeur. Par exemple, dans le cas de l’entrée 3 correspondant à printf
, l’adresse de saut se trouve dans les 4 octets pointés par l’adresse 0×8049670 qui fait donc office de pointeur. On remarque que la 2ème instruction de l’entrée 0 comporte aussi un jmp
avec pointeur. En regardant de plus près chaque pointeur, y compris celui de l’entrée 0 (0×8049664), on s’aperçoit qu’ils se trouvent tous les uns à la suite des autres. En effet, on a ici : 0×8049664, 0×8049668, 0x804966c, 0×8049670, 0×8049674, et 0×8049678. Pour savoir où se trouvent ces pointeurs, il suffit de regarder à nouveau la liste des sections mappées en mémoire :
(gdb) info files ... 0x0804957c - 0x08049584 is .dtors 0x08049584 - 0x08049588 is .jcr 0x08049588 - 0x08049658 is .dynamic 0x08049658 - 0x0804965c is .got <===== 0x0804965c - 0x0804967c is .got.plt
Ces adresses se situent donc dans la section nommée .got.plt. Les régions nommées ici .got et .got.plt font en réalité partie d’une table, que l’on nomme la GOT, pour Global Offset Table. Le rôle de cette table sera explicité dans la suite.
Affichons maintenant son contenu. Étant donné que cette région contient des pointeurs, il s’agit de données et non d’instructions ; pour afficher son contenu il convient donc d’utiliser l’instruction x
de gdb, et non pas disas
. De plus, la commande précédente nous indique que la section fait 0×20 = 32 octets de long, soit 8 pointeurs de 4 octets.
(gdb) x/8x 0x0804965c 0x804965c <_GLOBAL_OFFSET_TABLE_>: 0x08049588 0x00000000 0x00000000 0x0804832a 0x804966c <_GLOBAL_OFFSET_TABLE_+16>: 0x0804833a 0x0804834a 0x0804835a 0x0804836a
Je rappelle que pour le moment, le programme n’a pas encore été lancé. Le contenu de cette section est fixe, du moins avant chaque exécution. Il est tout à fait possible de le voir en utilisant d’autres outils tels que objdump.
Comme nous venons de le voir, les entrées de la PLT référencent des pointeurs situés dans la GOT. Pour comprendre le rôle de ces deux tables, regardons comment se déroule un appel à strcmp()
. Cette fonction est située dans l’entrée 3 de la PLT :
0x08048364 <strcmp@plt+0>: jmp *0x8049678 0x0804836a <strcmp@plt+6>: push $0x20 0x0804836f <strcmp@plt+11>: jmp 0x8048314 <_init+48>
Le 1er jmp référence un pointeur (0×8049678) situé dans la GOT. Voyons ce qu’il contient :
(gdb) x 0x8049678 0x8049678 <_GLOBAL_OFFSET_TABLE_+28>: 0x0804836a
Sa valeur veut 0x0804834a, qui correspond… à une adresse de la PLT, et plus précisément à l’adresse de l’instruction juste après le jmp
. En d’autres termes, lorsque strcmp()
sera appelée, on saute dans la PLT, et on exécute le 1er jmp
, qui se contente de sauter sur l’instruction suivante. Cela peut paraître inutile à première vue, mais nous verrons l’astuce qui se cache derrière plus tard. Continuons le fil de l’exécution : après ce 1er jmp
, on rencontre un push
, qui empile une certaine valeur, ici 0×20. Enfin, on rencontre un jmp
, et en examinant l’adresse de saut on s’aperçoit qu’il nous emmène sur l’entrée 0 de la PLT.
Une fois sur l’entrée 0, on rencontre deux instructions (les deux suivantes ne sont pas vraiment des instructions, mais correspondent aux opcodes 0×0000) :
0x08048314 <_init+48>: pushl 0x8049660 0x0804831a <_init+54>: jmp *0x8049664
On commence par empiler une valeur, qui se révèle être l’entrée 1 de la GOT. On effectue ensuite un jmp
sur le contenu d’un pointeur, se situant lui aussi dans la GOT mais à l’entrée 2. Pour le moment, ce pointeur ne contient que des zéros, vu que l’exécution n’a pas encore commencée. Plaçons un breakpoint sur l’appel à strcmp()
, et examinons la valeur du pointeur.
(gdb) b *0x0804844f //Correspond au call 0x8048364 <strcmp@plt> Breakpoint 1 at 0x804844f (gdb) r Starting program: /media/echange/Hacking/Reversing_Linux/plt_got/bin_str Breakpoint 1, 0x0804844f in main () Current language: auto; currently asm (gdb) x 0x8049664 0x8049664 <_GLOBAL_OFFSET_TABLE_+8>: 0xb7f12c40
On constate qu’une fois le programme lancé, la valeur du pointeur a été modifiée. C’est la procédure de lancement de l’exécutable qui a en fait initialisé cette valeur. Où pointe-t-elle ?
(gdb) info files ... 0x08049658 - 0x0804965c is .got 0x0804965c - 0x0804967c is .got.plt 0x0804967c - 0x08049688 is .data 0x08049688 - 0x0804968c is .bss 0xb7f000f4 - 0xb7f001b4 is .hash in /lib/ld-linux.so.2 0xb7f001b4 - 0xb7f00298 is .gnu.hash in /lib/ld-linux.so.2 0xb7f00298 - 0xb7f00468 is .dynsym in /lib/ld-linux.so.2 0xb7f00468 - 0xb7f005fc is .dynstr in /lib/ld-linux.so.2 0xb7f005fc - 0xb7f00636 is .gnu.version in /lib/ld-linux.so.2 0xb7f00638 - 0xb7f00700 is .gnu.version_d in /lib/ld-linux.so.2 0xb7f00700 - 0xb7f00760 is .rel.dyn in /lib/ld-linux.so.2 0xb7f00760 - 0xb7f00788 is .rel.plt in /lib/ld-linux.so.2 0xb7f00788 - 0xb7f007e8 is .plt in /lib/ld-linux.so.2 0xb7f007f0 - 0xb7f157af is .text in /lib/ld-linux.so.2 <===== 0xb7f157b0 - 0xb7f158e1 is __libc_freeres_fn in /lib/ld-linux.so.2 0xb7f15900 - 0xb7f19320 is .rodata in /lib/ld-linux.so.2 0xb7f19320 - 0xb7f1941c is .eh_frame_hdr in /lib/ld-linux.so.2 0xb7f1941c - 0xb7f19850 is .eh_frame in /lib/ld-linux.so.2 0xb7f1acc0 - 0xb7f1af28 is .data.rel.ro in /lib/ld-linux.so.2 0xb7f1af28 - 0xb7f1afe0 is .dynamic in /lib/ld-linux.so.2 0xb7f1afe0 - 0xb7f1afe8 is .got in /lib/ld-linux.so.2 0xb7f1aff4 - 0xb7f1b014 is .got.plt in /lib/ld-linux.so.2 0xb7f1b020 - 0xb7f1b5b0 is .data in /lib/ld-linux.so.2 0xb7f1b5b0 - 0xb7f1b5b4 is __libc_subfreeres in /lib/ld-linux.so.2 0xb7f1b5b4 - 0xb7f1b668 is .bss in /lib/ld-linux.so.2 0xb7d9c174 - 0xb7d9c194 is .note.ABI-tag in /lib/tls/i686/cmov/libc.so.6 0xb7d9c194 - 0xb7d9fcbc is .gnu.hash in /lib/tls/i686/cmov/libc.so.6 0xb7d9fcbc - 0xb7da8a8c is .dynsym in /lib/tls/i686/cmov/libc.so.6 0xb7da8a8c - 0xb7dae274 is .dynstr in /lib/tls/i686/cmov/libc.so.6 ...
La région contenant cette adresse d’est rien d’autre que la .text du linker dynamique, ld.so ! On se trouve dans la fonction du linker permettant d’appeler la véritable fonction strcmp()
.
La résolution des symboles
Mais comment le linker sait-il qu’il faut appeler strcmp()
et pas une autre fonction ? Tout simplement grâce au push 0×20 précédent : 0×20 correspond en fait à un offset correspondant ici à strcmp()
. Les instructions de la PLT l’ont empilé afin de le passer en paramètre à la fonction du linker.
Désassemblons cette fonction :
(gdb) disas 0xb7ff6c40 0xb7ff6c40+28 //Le +28 a été trouvé en tatonnant jusqu'à trouver un ret Dump of assembler code from 0xb7f12c40 to 0xb7f12c5c: 0xb7f12c40: push %eax 0xb7f12c41: push %ecx 0xb7f12c42: push %edx 0xb7f12c43: mov 0x10(%esp),%edx 0xb7f12c47: mov 0xc(%esp),%eax 0xb7f12c4b: call 0xb7f0d350 0xb7f12c50: pop %edx 0xb7f12c51: mov (%esp),%ecx 0xb7f12c54: mov %eax,(%esp) 0xb7f12c57: mov 0x4(%esp),%eax 0xb7f12c5b: ret $0xc End of assembler dump.
Cette fonction est relativement courte ; elle ne fait en réalité qu’appeler la véritable fonction de résolution des adresses. Lorsqu’elle est appelée, le sommet de pile contient une adresse (correspondant à GOT[1], qui a été pushée à l’entrée 0 de la PLT), et juste en dessous se trouve l’index de strcmp
, soit 0×20. Vérifions en plaçant un breakpoint au début et en examinant la pile :
(gdb) b *0xb7f12c40 Breakpoint 2 at 0xb7f12c40 (gdb) c Continuing. Breakpoint 2, 0xb7f12c40 in ?? () from /lib/ld-linux.so.2 (gdb) x/2x $esp 0xbfbf50b4: 0xb7f1b668 0x00000020
Vérifions que le 1er pointeur correspond bien à l’entrée 1 de la GOT :
(gdb) x 0x8049660 0x8049660 <_GLOBAL_OFFSET_TABLE_+4>: 0xb7f1b668
Bingo ! On a bien en sommet de pile l’adresse contenue dans GOT[1] et juste en dessous l’index de strcmp(), 0×20.
Revenons au code de cette fonction.
(gdb) disas 0xb7f12c40 0xb7f12c40+28 Dump of assembler code from 0xb7f12c40 to 0xb7f12c5c: 0xb7f12c40: push %eax 0xb7f12c41: push %ecx 0xb7f12c42: push %edx 0xb7f12c43: mov 0x10(%esp),%edx 0xb7f12c47: mov 0xc(%esp),%eax 0xb7f12c4b: call 0xb7f0d350 0xb7f12c50: pop %edx 0xb7f12c51: mov (%esp),%ecx 0xb7f12c54: mov %eax,(%esp) 0xb7f12c57: mov 0x4(%esp),%eax 0xb7f12c5b: ret $0xc End of assembler dump.
Elle commence par 3 push, permettant de sauvegarder des registres. Ainsi, nos deux valeurs en sommet de pile vont être décalées de 3*4 = 12 octets. Juste après ces 3 push
, on a deux mov
. Le premier place dans %edx une valeur située sur la pile à l’offset 0×10 soit 16 = 4 * 4 octets. Il s’agit donc de l’index de strcmp()
, 0×20. Le second place dans %eax la valeur suivante, soit celle de GOT[1]. Puis un appel de fonction a lieu.
On arrive alors dans une fonction relativement complexe, qui se situe toujours dans la section .text de ld.so. C’est elle qui est chargée d’effectuer la résolution des symbolesen recherchant dans les librairies. Si vous essayez de la désassembler, vous constaterez qu’elle est relativement longue et complexe. Comme ici le but n’est pas d’être exhaustif, je ne la détaillerai pas. En plus, je n’ai pas encore eu le courage de la reverser dans sesmoindres détails…
Continuons donc. Plaçons un breakpoint juste après le call de cette fonction, en 0xb7f12c50.
(gdb) b *0xb7f12c50 Breakpoint 3 at 0xb7f12c50 (gdb) c Continuing. Breakpoint 3, 0xb7f12c50 in ?? () from /lib/ld-linux.so.2
Les instructions suivantes manipulent des registres. Dans le cadre de cet article, seules deux instructions sont intressantes :
0xb7f12c54: mov %eax,(%esp) et 0xb7f12c5b: ret $0xc
Le mov place la valeur de %eax en sommet de pile, tandis que le ret ordonne au CPU de continuer l’exécution du code à l’adresse située sur le sommet de pile. Autrement dit, juste après le call de la fonction de résolution des symboles, on saute sur l’adresse contenue dans %eax ! Regardons ce que vaut ce registre…
(gdb) info registers eax 0xb7e0dd20 -1210000096 ecx 0x0 0 edx 0x8049678 134518392 ebx 0xb7ee6ff4 -1209110540 esp 0xbfbf50a8 0xbfbf50a8 ebp 0xbfbf50f8 0xbfbf50f8 esi 0xb7f1ace0 -1208898336 edi 0x0 0 eip 0xb7f12c50 0xb7f12c50 ...
Que peut bien représenter l’adresse 0xb7e0dd20 ?
(gdb) info files ... 0xb7d9c174 - 0xb7d9c194 is .note.ABI-tag in /lib/tls/i686/cmov/libc.so.6 0xb7d9c194 - 0xb7d9fcbc is .gnu.hash in /lib/tls/i686/cmov/libc.so.6 0xb7d9fcbc - 0xb7da8a8c is .dynsym in /lib/tls/i686/cmov/libc.so.6 0xb7da8a8c - 0xb7dae274 is .dynstr in /lib/tls/i686/cmov/libc.so.6 0xb7dae274 - 0xb7daf42e is .gnu.version in /lib/tls/i686/cmov/libc.so.6 0xb7daf430 - 0xb7daf730 is .gnu.version_d in /lib/tls/i686/cmov/libc.so.6 0xb7daf730 - 0xb7daf770 is .gnu.version_r in /lib/tls/i686/cmov/libc.so.6 0xb7daf770 - 0xb7db2140 is .rel.dyn in /lib/tls/i686/cmov/libc.so.6 0xb7db2140 - 0xb7db2188 is .rel.plt in /lib/tls/i686/cmov/libc.so.6 0xb7db2188 - 0xb7db2228 is .plt in /lib/tls/i686/cmov/libc.so.6 0xb7db2230 - 0xb7eb2d84 is .text in /lib/tls/i686/cmov/libc.so.6 <===== 0xb7eb2d90 - 0xb7eb3de8 is __libc_freeres_fn in /lib/tls/i686/cmov/libc.so.6 0xb7eb3df0 - 0xb7eb4082 is __libc_thread_freeres_fn in /lib/tls/i686/cmov/libc.so.6 0xb7eb40a0 - 0xb7ecf090 is .rodata in /lib/tls/i686/cmov/libc.so.6 0xb7ecf090 - 0xb7ecf0a3 is .interp in /lib/tls/i686/cmov/libc.so.6 0xb7ecf0a4 - 0xb7ed1c90 is .eh_frame_hdr in /lib/tls/i686/cmov/libc.so.6 0xb7ed1c90 - 0xb7ee1544 is .eh_frame in /lib/tls/i686/cmov/libc.so.6 0xb7ee1544 - 0xb7ee19b0 is .gcc_except_table in /lib/tls/i686/cmov/libc.so.6 0xb7ee19b0 - 0xb7ee4d28 is .hash in /lib/tls/i686/cmov/libc.so.6 0xb7ee51ec - 0xb7ee51f4 is .tdata in /lib/tls/i686/cmov/libc.so.6 ...
Tiens, elle se trouve dans la .text… Par hasard, ce ne serait pas l’adresse de strcmp
?
(gdb) p strcmp $1 = {<text variable, no debug info>} 0xb7e0dd20 <strcmp>
Eh si ! Autrement dit, la fonction de résolution des symboles a résolu correctement strcmp
et a placé son adresse dans %eax.
Et la GOT dans tout ça ?
Nous venons de voir le cheminement (d’un point de vue assez haut niveau) d’un appel de fonction situé dans une bibliothèque partagée. Comme on a pu le constater, chaque appel de fonction entraîne à priori une résolution de symbole, ce qui paraît fastidieux. Fort heureusement, par défaut, ld.so ne résoud pas un symbole à chaque fois qu’on tente d’y accéder, mais uniquement la 1ère fois. Par exemple, si vous avez 10 appels à strcmp() dans un programme, le 1er appel entraînera une résolution, et l’adresse de strcmp() sera gardée en mémoire pour les 9 appels suivant. C’est ce que l’on appelle l’évaluation fainéante : on ne fait que le minimum d’opération, et juste à temps.
Où et comment les adresses des symboles sont-elles gardées en mémoire une fois résolues ? Réponse : dans la GOT ! Pour le comprendre, relançons le programme et plaçons un breakpoint dans l’entrée de la PLT correspondant à strcmp
.
$ gdb ./bin_str (gdb) disas 0x08048314 0x08048374 //Les adresses de la PLT, qui restent fixes Dump of assembler code from 0x8048314 to 0x8048374: 0x08048314 <_init+48>: pushl 0x8049660 0x0804831a <_init+54>: jmp *0x8049664 0x08048320 <_init+60>: add %al,(%eax) 0x08048322 <_init+62>: add %al,(%eax) 0x08048324 <__gmon_start__@plt+0>: jmp *0x8049668 0x0804832a <__gmon_start__@plt+6>: push $0x0 0x0804832f <__gmon_start__@plt+11>: jmp 0x8048314 <_init+48> 0x08048334 <__libc_start_main@plt+0>: jmp *0x804966c 0x0804833a <__libc_start_main@plt+6>: push $0x8 0x0804833f <__libc_start_main@plt+11>: jmp 0x8048314 <_init+48> 0x08048344 <printf@plt+0>: jmp *0x8049670 0x0804834a <printf@plt+6>: push $0x10 0x0804834f <printf@plt+11>: jmp 0x8048314 <_init+48> 0x08048354 <__stack_chk_fail@plt+0>: jmp *0x8049674 0x0804835a <__stack_chk_fail@plt+6>: push $0x18 0x0804835f <__stack_chk_fail@plt+11>: jmp 0x8048314 <_init+48> 0x08048364 <strcmp@plt+0>: jmp *0x8049678 0x0804836a <strcmp@plt+6>: push $0x20 0x0804836f <strcmp@plt+11>: jmp 0x8048314 <_init+48> End of assembler dump. (gdb) b *0x08048364 Breakpoint 1 at 0x8048364 (gdb) r Starting program: /media/echange/Hacking/Reversing_Linux/plt_got/bin_str Breakpoint 1, 0x08048364 in strcmp@plt () Current language: auto; currently asm (gdb) x 0x8049678 0x8049678 <_GLOBAL_OFFSET_TABLE_+28>: 0x0804836a
L’entrée correspondante de la GOT contient toujours l’adresse de l’instruction suivante dans la PLT. Quel intérêt ? A ce moment, il faut noter que le symbole strcmp
n’est pas encore résolu, donc il est normal que cette entrée ne comporte aucune valeur intéressante. Plaçons un watchpoint sur cette entrée de la GOT afin de voir si elle change au fil du temps.
(gdb) watch *0x8049678 Hardware watchpoint 2: *134518392 (gdb) c Continuing. Hardware watchpoint 2: *134518392 Old value = 134513514 New value = -1209639648 0xb7f6545d in ?? () from /lib/ld-linux.so.2
Apparamment, la valeur de l’entrée a changé ! Observons sa nouvelle valeur :
(gdb) x 0x8049678 0x8049678 <_GLOBAL_OFFSET_TABLE_+28>: 0xb7e65d20 (gdb) p strcmp $1 = {<text variable, no debug info>} 0xb7e65d20 <strcmp>
Ainsi on s’aperçoit qu’elle correspond désormais à l’adresse de strcmp()
. Désormais, si le programme souhaîte faire d’autres appels à strcmp()
, il n’aura plus à effectuer la résolution de symbole puisque le jmp situé dans l’entrée de la PLT référence directement l’adresse de strcmp()
!
Quand cette valeur a-t-elle été écrite ? Pour cela, il suffit de regarder %eip
et de voir dans quelle zone nous sommes.
(gdb) info registers eax 0xb7e65d20 -1209639648 ecx 0x0 0 edx 0x8049678 134518392 ebx 0xb7f72ff4 -1208537100 esp 0xbfae4f58 0xbfae4f58 ebp 0xbfae4f90 0xbfae4f90 esi 0xb7f56858 -1208653736 edi 0xb7f73668 -1208535448 eip 0xb7f6545d 0xb7f6545d eflags 0x246 [ PF ZF IF ] cs 0x73 115 ss 0x7b 123 ds 0x7b 123 es 0x7b 123 fs 0x0 0 gs 0x33 51 (gdb) disas $eip-3 $eip+20 Dump of assembler code from 0xb7f6545a to 0xb7f65471: 0xb7f6545a: mov %eax,(%edx,%ecx,1) 0xb7f6545d: lea -0xc(%ebp),%esp 0xb7f65460: pop %ebx 0xb7f65461: pop %esi 0xb7f65462: pop %edi 0xb7f65463: pop %ebp 0xb7f65464: ret 0xb7f65465: xor %edx,%edx 0xb7f65467: jmp 0xb7f653de 0xb7f6546c: lea -0x2477(%ebx),%eax ...
En cherchant un peu, on remarque qu’on se trouve juste à la fin de la fonction de résolution des symboles. L’instruction responsable de l’écriture de l’adresse dans la GOT est le mov
%eax,(%edx,%ecx,1). En inspectant les registres, on voit que cela correspond bien à l’opération *0×8049678 = 0xb7e65d20 (l’adresse de strcmp
).
On notera que cette méthode est comparable aux systèmes de caches des processeurs : l’objectif est d’accelérer les accès futurs aux fonctions en accédant une fois pour toute à une donnée et en la plaçant dans une zone d’accès plus rapide. Pour information, il est tout à fait possible de désactiver ce système de mise en cache en utilisant des variables d’environnement reconnues par ld.so. Par exemple, la variable d’environnement LD_BIND_NOT
, si elle est définie, permet ainsi de désactiver complétement l’écriture des adresses dans la GOT, tandis que la variable LD_BIND_NOW
indique à ld.so d’effectuer toutes les résolutions dès le début, donc de remplir la GOT dès le lancement de l’exécutable. Pour plus de renseignements, consultez le man de ld.so.
Récapitulatif
Le schéma suivant retrace le fil de l’exécution lors d’un appel à strcmp()
, et récapitule les différents liens entre la PLT et la GOT.
1er appel de strcmp : symbole non encore résolu
main: ... call 0x8048364 <strcmp@plt> ----+ mov %eax,-0x1c(%ebp) | ... | | | 0x8048364 (Entrée de la PLT) : <--+ jmp *0x8049678 -----------------------> 0x8049678 (Entrée de la GOT) : push $0x20 <------------------------------ 0x0804836a jmp 0x8048314 <_init+48> --------+ | | 0x8048314 (Entrée 0 de la PLT) : <--+ pushl 0x8049660 jmp *0x8049664 -----------------------> 0x8049664 (Header de la GOT) : 0xb7f12c40 --+ | | 0xb7f12c40 (.text de ld.so, résolution + appel) : <--------+ push %eax // Sauvegarde de registres push %ecx push %edx mov 0x10(%esp),%edx // Récupération du code de la fonction strcmp (0x20) mov 0xc(%esp),%eax call 0xb7f4c350 -------> Appel de la fonction de résolution des symboles L'adresse du symbole (strcmp) est placé dans %eax. L'entrée de la .got est patchée avec cette adresse. ... <---------------------- Retour de la fonction mov %eax,(%esp) // L'adresse de strcmp (0xb7e65d20) est empilée ... ret $0xc ------------------------------+ // On saute sur strcmp | | 0xb7e65d20 (strcmp, .text de la libc): <--+ ... ret // Retour au main
2ème appel de strcmp : symbole déjà résolu
main: ... call 0x8048364 <strcmp@plt> ---+ mov %eax,-0x1c(%ebp) | ... | | | 0x8048364 (Entrée de la .plt) : <-+ jmp *0x8049678 ----------------> 0x8049678 (Entrée de la GOT) : 0xb7e65d20 --+ // Symbole résolu ! push $0x20 | // Les instructions jmp 0x8048314 <_init+48> | // suivantes ne sont pas | // exécutées. | 0xb7e65d20 (strcmp, .text de la libc): <------------+ ... ret // Retour au main
Références
Comme le précise l’introduction, je n’avais pas Internet quand j’ai réalisé cet article ; je ne peux donc pas citer de page Web. Je me suis beaucoup aidé de la documentation off-line des programmes que j’avais sous la main, à savoir :
- Le man de ld.so (man
ld.so
) - L’aide de gdb (commande
help
de gdb)
14 réponses à “Reversing Linux : Comprendre le rôle des sections PLT et GOT dans l’édition de liens dynamique”
Ah tiens je savais pas que t’aimais tant l’informatique
Par Emilien Girault le 25 août 2008
Merci, j’ai enfin compris la GOT et PLT.
Beau boulot !!
Par jacques le 28 octobre 2008
Un typo s’est glissée dans le texte. Il faut remplacer
« La région .plt est donc mappée entre les adresses 0×08048380 et 0×0804852c. » par « [...] les adresses 0×08048314 et 0×08048374″ (tu as pris la section .text par erreur).
Par poz le 10 décembre 2008
Très juste! C’est corrigé.
Merci
Par Emilien Girault le 10 décembre 2008
Juste une petite remarque : dans le cas du binaire lié statiquement (avec printf et strcmp), ce n’est pas toute la librairie standard qui est incorporée, mais uniquement les fichiers objets contenant ces 2 fonctions (ainsi que les fichiers objets contenant les fonctions appelées par ces 2 fonctions).
On peut les voir avec objdump ou ar sur la librairie statique.
strcmp est contenu dans strcmp.o et printf dans printf.o
Par goundoulf le 15 décembre 2008
Très bon exemple pour utiliser gdb, merci !
Par Steflinux le 27 septembre 2010
Très bon tuto!…jusqu’à maintenant… là j’ai relevé un truc qui me chiffonne alors…
J’ai l’impression que tu as inversé l’ordre de la pile quand tu expliques la petite fonction du linker dynamique. Je peux très bien me tromper car je ne suis pas expert en assembleur et j’ai toujours eu du mal avec les offsets! Tu écris donc
« Le premier place dans %edx une valeur située sur la pile à l’offset 0×10 soit 16 = 4 * 4 octets. Il s’agit donc de l’index de strcmp(), 0×20. Le second place dans %eax la valeur suivante, soit celle de GOT[1]. »
J’aurais dis l’inverse vu que l’index de strcmp() est en dessous de GOT[1] dans la pile.
Par Katoriak le 21 février 2011
Je confirme très bon tuto!
Le récapitulatif doit être modifié aussi si j’ai raison concernant la pile.
Par Katoriak le 21 février 2011
Merci
Tu oublies par contre que sur les processeurs Intel, la pile croît vers le bas. Donc les adresses basses correspondent au haut de la pile.
Ainsi, 0×10(esp) (l’index de strcmp) est situé à une adresse supérieure, donc « en dessous » de la précédente, 0xc(%esp), si on regarde par rapport au bas de la pile. Effectivement les expressions « au dessus » / « au dessous » méritaient quelques clarifications…
Par Emilien Girault le 23 février 2011
Merci pour le Tuto très sympa.
Par contre il y a une petite coquille :
Sa valeur veut 0x0804834a
devrait être « Sa valeur vaut 0x0804836a »
Par yaya le 31 juillet 2013