Aller au contenu principal
own2pwn
elf/elf-ld.tsx

Le dynamic linker : ce qui tourne avant ton main

Static vs dynamic linking sous ELF : structures Elf32_Rel et Elf64_Sym, sections dynamiques (DT_STRTAB, DT_SYMTAB, DT_JMPREL), entrées PLT/GOT.PLT et resolution paresseuse via _dl_runtime_resolve.

Maxime Jérôme··7 min de lecture

Prérequis

Hello ! o/

Dans cet article, on va explorer comment ELF lie les différentes bibliothèques qu'un programme importe. Pour commencer, il est essentiel de différencier deux types de linkage : statique et dynamique. Le linkage statique se fait avec le flag GCC -static, tandis que le dynamique est le comportement par défaut.

La différence entre ces deux méthodes réside dans la taille de l'exécutable. En linkage statique, vous importez le code source de toutes les fonctions et constantes externes directement dans votre exécutable. En linkage dynamique, vous n'avez qu'à établir une passerelle avec la PLT (Procedure Linkage Table) et la GOT (Global Offset Table) pour accéder à ces fonctions et constantes.

Cela influence aussi les performances. Comme plusieurs programmes accèdent à la même page mémoire de bibliothèque, les instructions seront probablement déjà en cache. À l'inverse, une bibliothèque chargée statiquement à des adresses différentes sera plus lente à charger au premier accès.

Static Linkage

Pas grand-chose à dire sur le linkage statique : tout est mis dans la section .text. Dans "tout", j'entends les constantes globales et les fonctions/procédures externes utilisées.

Dynamic Linkage

Le linkage dynamique est un peu plus complexe. Voici quelques structures de données issues de elf.h :

c
typedef uint32_t Elf32_Addr;
typedef uint32_t Elf32_Word;

typedef struct elf32_rel {
  Elf32_Addr    r_offset;               /* Adresse */
  Elf32_Word    r_info;                 /* Type de relocation et index du symbole */
} Elf32_Rel;

typedef struct elf64_rel {
  Elf64_Addr    r_offset;               /* Adresse */
  Elf64_Xword   r_info;                 /* Type de relocation et index du symbole */
} Elf64_Rel;

/* Comment extraire et insérer des informations dans le champ r_info. */
#define ELF32_R_SYM(val)                ((val) >> 8)
#define ELF32_R_TYPE(val)               ((val) & 0xff)

typedef struct elf32_sym {
  Elf32_Word    st_name;   /* Nom du symbole (Index dans DT_STRTAB) */
  Elf32_Addr    st_value;  /* Valeur du symbole */
  Elf32_Word    st_size;   /* Taille du symbole */
  unsigned char st_info;   /* Binding et type du symbole */
  unsigned char st_other;  /* Visibilité du symbole sous glibc >= 2.2 */
  Elf32_Section st_shndx;  /* Index de la section */
} Elf32_Sym;

typedef struct elf64_sym {
  Elf64_Word    st_name;   /* Nom du symbole (Index dans DT_STRTAB) */
  unsigned char st_info;   /* Binding et type du symbole */
  unsigned char st_other;  /* Aucune définition, 0 */
  Elf64_Half    st_shndx;  /* Index de la section */
  Elf64_Addr    st_value;  /* Valeur du symbole */
  Elf64_Xword   st_size;   /* Taille du symbole */
} Elf64_Sym;

Sections Dynamiques

Quelques sections dynamiques à connaitre sur ELF :

Tag de Section DynamiqueDescription
DT_STRTABPointe sur la table des strings. Cette table contient simplement des strings séparées par un null byte ('\x00').
DT_SYMTABPointe sur la table des symboles. Cette table contient les informations pour louer et relouer les définitions et références symboliques du programme, définies par les structures Elf32_Sym et Elf64_Sym.
DT_JMPRELPointe sur les entrées de relocation associées à la PLT.
DT_VERSYMPointe sur l'adresse de la section .gnu.version qui contient les indices des versions des symboles.

Entrée PLT et GOT.PLT

Une entrée sur la PLT (Procedure Linkage Table) peut ressembler à ceci (x86) :

asm
0x804xxx4 <some_func>:     jmp    *some_func_dyn_reloc_entry    ; Entrée de la relocation
0x804xxxa <some_func+6>:   push   $reloc_offset                 ; Offset de relocation
0x804xxxf <some_func+11>:  jmp    beginning_of_.plt_section     ; Début de la PLT

Exemple pour puts@plt :

asm
0x0804xxx0 <+0>:    jmp    DWORD PTR ds:0x804xxxc   ; puts@got.plt
0x0804xxx6 <+6>:    push   0x0                      ; Offset
0x0804xxxb <+11>:   jmp    0x804xxx0                ; Début de la PLT

Génériquement, on peut définir cet algorithme :

asm
.plt:
    push .got.plt[link_map] ; == .got.plt[1]
    jmp .got.plt[_dl_runtime_resolve] ; == .got.plt[2]

<func>@plt:
    jmp .got.plt[x + n]
    push n
    jmp .plt

Ce qui signifie en bon français :

  1. Jump sur la section .got.plt pour y trouver func@got.plt.
  2. Si .got.plt n'a pas encore loué l'adresse sur la bibliothèque :
    1. L'adresse à .got.plt[x + n] renvoie sur func@plt.
    2. Mettre l'offset en argument.
    3. Mettre link_map de .got.plt en argument.
    4. Appeler _dl_runtime_resolve() pour louer l'adresse de la fonction.
  3. Sinon, appeler la fonction sur la lib.
plt-got-resolution.txt
  Premier appel à puts()
  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  call puts@plt                                       │
  │       │                                              │
  │       ▼                                              │
  │  puts@plt :                                          │
  │    jmp [puts@got.plt]  ──► adresse non résolue       │
  │                              │  (pointe sur puts@plt+6)
  │                              ▼                       │
  │    push offset_puts          │                       │
  │    jmp .plt                  │                       │
  │       │                      │                       │
  │       ▼                      │                       │
  │  .plt (stub 0) :             │                       │
  │    push link_map             │                       │
  │    jmp _dl_runtime_resolve ◄─┘                       │
  │       │                                              │
  │       ▼                                              │
  │  _dl_runtime_resolve() :                             │
  │    1. trouve le nom "puts" dans DT_SYMTAB/DT_STRTAB  │
  │    2. cherche l'adresse dans les libs chargées       │
  │    3. écrit l'adresse réelle dans puts@got.plt       │
  │    4. jump sur puts() dans libc                      │
  │                                                      │
  │  Appels suivants :                                   │
  │    jmp [puts@got.plt]  ──► adresse réelle libc::puts │
  │                             (résolution directe)     │
  └──────────────────────────────────────────────────────┘
Resolution paresseuse via PLT/GOT.PLT : au premier appel, _dl_runtime_resolve est invoqué et l'adresse réelle est écrite dans la GOT.

_dl_*()

_dl_runtime_resolve() est une fonction écrite en assembleur.

asm
Dump of assembler code for function _dl_runtime_resolve_xsave:
   0x00007ffff7fea430 <+0>:    push   rbx
   ...
   0x00007ffff7fea4ee <+190>:  mov    rbx,QWORD PTR [rsp]
   0x00007ffff7fea4f2 <+194>:  add    rsp,0x18
   0x00007ffff7fea4f6 <+198>:  bnd jmp r11

Tout ce qu'il faut comprendre : elle sauvegarde les registres, appelle _dl_fixup() et jump ensuite sur sa valeur de retour.

Au plus simple, _dl_fixup() fait :

  1. Trouver le nom de la fonction dans la section dynamique adéquate.
  2. Chercher dans toutes les libs chargées et trouver son adresse.
  3. Écrire son adresse dans la GOT.PLT (Global Offset Table, section .got.plt).
Lazy binding
Ce mécanisme s'appelle le lazy binding (résolution paresseuse) : une fonction externe n'est résolue qu'au premier appel, pas au chargement du programme. C'est ce comportement que les techniques RELRO ou BIND_NOW cherchent à remplacer pour des raisons de sécurité.

Conclusion

Pour le linkage dynamique, la procédure complète est la suivante :

  1. On charge le programme sur le CPU avec ses segments et sections en mémoire.
  2. Lors d'un appel de fonction :
    1. Si c'est la premiere fois :
      1. On appelle _dl_runtime_resolve().
      2. On trouve le nom de la fonction dans notre programme.
      3. On cherche dans les libs chargées la fonction.
      4. On réécrit la valeur pour les prochaines fois.
      5. On appelle la fonction.
    2. Sinon : la valeur sur laquelle on est redirigé pointe directement sur la fonction dans la lib.
dynamic-linking-overview.txt
  Programme ELF en mémoire
  ┌──────────────────────────────────────────────────────────────┐
  │  .text                                                       │
  │    call puts@plt  ────────────────┐                          │
  │                                   │                          │
  │  .plt                             ▼                          │
  │    puts@plt:                                                 │
  │      jmp [.got.plt + offset] ─────┐                          │
  │                                   │                          │
  │  .got.plt                         ▼                          │
  │    [puts entry]  (non résolu) ──► stub PLT → _dl_runtime_resolve
  │    [puts entry]  (résolu)     ──► adresse réelle libc::puts  │
  │                                                              │
  │  ld.so (link editor / dynamic linker)                        │
  │    _dl_runtime_resolve()                                     │
  │      └─► _dl_fixup()                                         │
  │            ├─ lit DT_SYMTAB + DT_STRTAB → nom "puts"        │
  │            ├─ parcourt les libs chargées (DT_NEEDED)         │
  │            └─ écrit l'adresse dans .got.plt                  │
  └──────────────────────────────────────────────────────────────┘
Vue d'ensemble du dynamic linking ELF : du call initial jusqu'a la resolution dans la libc.

Merci pour votre lecture :)