Voici un deuxième tutoriel sur les buffer overflow, après le premier qui n’était pour moi qu’une initiation. J’ai depuis approfondis le sujet sur la base du mythique Smashing the stack for fun and profit duquel je me suis largement inspiré.
Cet article requiert un minimum de connaissance en assembleur et sur l’architecture d’un programme en mémoire. On travaillera sur un processeur 32 bits intel x86 sous Linux.
Objectif
Considérons le programme suivant :
/* buf.c */
#include <stdio.h>
void t() {
}
int main(){
int x;
x = 0;
t();
x = 1;
printf("%d\n",x);
}
Tel quel, le programme affiche simplement "1" :
$ gcc buf.c -o buf && ./buf
1
Le but est de remplir la fonction "t()" pour modifier la variable "x" et faire en sorte que le programme affiche "0". Comme vous l’aurez remarqué en tant que lecteur attentif, la variable "x" est locale à la fonction "main()", et est donc théoriquement inaccessible de la fonction "t()". De plus, la valeur de "x" est forcée APRES l’appel de la fonction "t()".
Plus précisément, nous allons voir comment ignorer cette affectation "x=1 ;" pour conserver l’ancienne valeur de x.
Assembleur et appel de procédure
Examinons le programme suivant :
/* test.c */
#include <stdio.h>
void t() {
char * buffer[1];
}
int main(){
t();
}
On le compile :
$ gcc test.c -o test
Et on examine le code assembleur de la fonction "main()" :
$ gdb test
GNU gdb 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu"...
(gdb) disas main
Dump of assembler code for function main:
0x0804837c <main+0>: lea 0x4(%esp),%ecx
0x08048380 <main+4>: and $0xfffffff0,%esp
0x08048383 <main+7>: pushl -0x4(%ecx)
0x08048386 <main+10>: push %ebp
0x08048387 <main+11>: mov %esp,%ebp
0x08048389 <main+13>: push %ecx
0x0804838a <main+14>: sub $0x4,%esp
0x0804838d <main+17>: call 0x8048374 <t>
0x08048392 <main+22>: add $0x4,%esp
0x08048395 <main+25>: pop %ecx
0x08048396 <main+26>: pop %ebp
0x08048397 <main+27>: lea -0x4(%ecx),%esp
0x0804839a <main+30>: ret
End of assembler dump.
Pour l’instant, contentons nous de noter l’appel à la procédure t par l’instruction "CALL 0x8048374".
Rappel sur CALL et RET
L’instruction CALL
empile l’adresse de retour ( push 0x08048392 )
décrémente ESP de 4 octets ( sub $0x4,%esp ) pour prendre en compte l’empilement de l’adresse de retour ( ESP doit pointer le sommet le la pile )
copie l’adresse de la procédure dans le registre EIP ( pour effectivement exécuter cette procédure )
L’instruction RET
dépile EIP ( il va donc recevoir l’adresse 0x08048392 )
incrémente ESP de 0x4
Assembleur et procédure
Examinons maintenant le code assembleur de la fonction "t()" :
(gdb) disas t
Dump of assembler code for function t:
0x08048374 <t+0>: push %ebp
0x08048375 <t+1>: mov %esp,%ebp
0x08048377 <t+3>: sub $0x10,%esp
0x0804837a <t+6>: leave
0x0804837b <t+7>: ret
End of assembler dump.
On voit d’abord ce qu’on appelle "le prologue" de la procédure :
0x08048374 <t+0>: push %ebp
0x08048375 <t+1>: mov %esp,%ebp
0x08048377 <t+3>: sub $0x10,%esp
et l’épilogue :
0x0804837a <t+6>: leave
0x0804837b <t+7>: ret
Le prologue
sauve (push) l’ancien EBP (pointeur sur le début de la pile courante, relative à la procédure en cours)
réinitialise EBP à l’ancien ESP (pointeur de sommet de pile)
déplace ESP de manière à réserver de la mémoire aux variables locales à la fonction C ( ici, buffer )
L’épilogue
restaure l’état des pointeurs de pile : l’instruction LEAVE réinitialise ESP à EBP ( mov %ebp, %esp ) et pop l’ancien EBP ( pop %ebp )
appelle l’instruction qui suit le call de la procédure courante ( instruction RET )
Notons que 0x10 unités de mémoire (soit 16 octets) sont alloués pour la variable "char * buffer[1]" qui n’en utilise que 4. Pour une raison que j’ignore, les allocations se font par lot de 16 octets, une partie restant inutilisée :
| <— sommet de la pile | |
| 12 octets | 4 octets (buffer) |
Etat de la pile
La clef de ce tutoriel est ici. On va résumer les étapes précédentes et déduire l’état de la pile juste avant l’épilogue de la procédure t.
main+17 : call 0x8048374 t
L’instruction CALL empile l’adresse de retour 0x08048392
t+0 : push %ebp
On empile l’ancien EBP
t+3 : sub $0x10,%esp
Ici on alloue 16 octets dont 12 seront inutilisés et 4 représenteront la variable locale "buffer".
| <— sommet de la pile | |||
| 12 octets | 4 octets | 4 octets | 4 octets |
| ESP | buffer | ancien EBP | RET ( 0x0804837a ) |
Le fin mot de l’histoire
L’astuce va être de modifier directement l’adresse de retour de la procédure comme le C permet de le faire :
buffer + 1 = ancien EBP pushé
buffer + 2 = adresse de retour de la procédure
Revenons au programme initial ( buf.c ) et désassemblons son main :
(gdb) disas main
Dump of assembler code for function main:
0x080483bf <main+0>: lea 0x4(%esp),%ecx
0x080483c3 <main+4>: and $0xfffffff0,%esp
0x080483c6 <main+7>: pushl -0x4(%ecx)
0x080483c9 <main+10>: push %ebp
0x080483ca <main+11>: mov %esp,%ebp
0x080483cc <main+13>: push %ecx
0x080483cd <main+14>: sub $0x24,%esp
0x080483d0 <main+17>: movl $0x0,-0x8(%ebp)
0x080483d7 <main+24>: call 0x80483a4 <t>
0x080483dc <main+29>: movl $0x1,-0x8(%ebp)
0x080483e3 <main+36>: mov -0x8(%ebp),%eax
0x080483e6 <main+39>: mov %eax,0x4(%esp)
0x080483ea <main+43>: movl $0x80484c0,(%esp)
0x080483f1 <main+50>: call 0x80482d8 <printf@plt>
0x080483f6 <main+55>: add $0x24,%esp
0x080483f9 <main+58>: pop %ecx
0x080483fa <main+59>: pop %ebp
0x080483fb <main+60>: lea -0x4(%ecx),%esp
0x080483fe <main+63>: ret
End of assembler dump.
L’enjeu est d’ignorer le "main+29 : movl $0x1,-0x8(%ebp)" qui correspond à l’affectation "x=1 ;". Pour cela, l’adresse de retour de la procédure devra donc passer de 0x080483dc à 0x080483e3, soit une différence de 0x7.
On va donc incrémenter de 7 la valeur du pointeur (buffer+2) :
/* buf.c */
#include <stdio.h>
void t() {
char * buffer[1];
(*(buffer+2))+=7;
}
int main(){
int x;
x = 0;
t();
x = 1;
printf("%d\n",x);
}
$ gcc buf.c -o buf && ./buf
0