foo/README.FR.md

434 lines
21 KiB
Markdown
Raw Permalink Normal View History

2026-09-29 09:39:24 +02:00
# food / fooc — un débordement de tampon de pile, des deux côtés
Un laboratoire de sécurité en C99 en deux moitiés :
- **`food.c`** — un démon TCP volontairement vulnérable. Il contient un vrai
2026-09-29 10:04:38 +02:00
débordement de tampon de pile, digne d'un manuel ([CWE-120](https://cwe.mitre.org/data/definitions/120.html)), plus quelques
2026-09-29 09:39:24 +02:00
bugs en prime.
- **`fooc.c`** — un exploit contre lui. Il calcule l'offset du débordement en
désassemblant le programme cible à l'exécution, lit les fuites d'adresses du
démon et obtient un shell sur la « victime » en écrasant une adresse de
retour sauvegardée.
Le but n'est pas le shell. Le but est que vous puissiez suivre de bout en bout
comment un bug de sécurité mémoire devient une exécution de code arbitraire —
puis voir exactement quelles contre-mesures arrêtent chaque maillon de cette
chaîne. Chaque ligne des deux programmes est commentée, parce que le mécanisme
est la leçon.
```
votre terminal
|
./fooc (exploit)
|
TCP 127.0.0.1:2342
|
./food (démon vulnérable)
|
fork() -> vulnerable_handler() -> overflow -> ret -> votre code
```
---
## ⚠️ Lisez ceci d'abord
**`food` est un service réseau volontairement cassé. Il ne se lie qu'à
`127.0.0.1`, et cette valeur par défaut est volontaire — laissez-la.**
- Ne l'exécutez **pas** sur une machine à laquelle vous tenez, ni sur quelque
chose qui contient des données.
- Ne le liez **pas** à `0.0.0.0` ou à une vraie interface réseau. Il est
volontairement exploitable à distance.
- Pointer `fooc` vers une machine que vous ne possédez pas ou que vous n'avez
pas l'autorisation écrite de tester est une infraction informatique dans la
plupart des juridictions — y compris en vertu de l'UK Computer Misuse Act et
de l'US Computer Fraud and Abuse Act.
- Il se lie à un port non privilégié (>1024), donc pas besoin de root. Ne
l'« améliorez » pas en ajoutant des capabilities ou en l'exécutant comme
service système.
- Chaque connexion est traitée dans un enfant `fork()`, et `food` les reape,
donc les crashs ne s'accumulent pas. Si vous retrouvez ensuite des dizaines
de `sh` qui traînent, `pkill -x sh` est le nettoyage.
En cas de doute : ce lab est fait pour une machine virtuelle ou un conteneur,
sur un réseau que vous contrôlez, sur une machine sans rien que vous
regretteriez.
---
## Démarrage rapide
```sh
make # compile food, fooc et les harnesses de test
make run # démarre food sur 127.0.0.1:2342, détaché en arrière-plan
make test # exécute les trois techniques d'exploit
make stop # arrête le démon
```
Ensuite, à la main :
```sh
./fooc -t leak # regardez les fuites d'adresses que food divulgue
./fooc -t demo -v # envoyez du bourrage ; voyez food mourir d'un SIGSEGV
./fooc -t ret2win -i # sautez vers une fonction qui existe déjà -> shell
```
### Prérequis
| Outil | Pour quoi | Remarques |
|---|---|---|
| `gcc` (ou clang) | compilation | C99. Testé avec gcc 16.2 |
| `objdump` | `fooc` | binutils. `fooc` l'appelle à l'exécution |
| `nasm` | `make verify` | uniquement pour recouper la shellcode ; ignoré s'il manque |
| `gdb` | `make debug` | optionnel |
| Linux, x86-64 | les deux | la payload et la chasse aux gadgets dépendent de l'architecture |
`fooc` a aussi besoin de `-ldl` pour `dlsym()` ; le Makefile s'en charge.
---
## Le bug
Une ligne dans `food.c` est toute la surface d'attaque :
```c
char buf[FOOD_BUFSZ]; /* 64 octets */
n = read(fd, buf, FOOD_READMAX); /* jusqu'à 512 octets depuis le réseau */
```
64 octets de destination, 512 acceptés. L'attaquant écrit 448 octets au-delà
de la fin du tampon, et comme la pile croît vers le bas, « au-delà de la fin »
signifie « dans le cadre au-dessus » — et c'est exactement là que se trouvent
le pointeur de trame sauvegardé et l'**adresse de retour sauvegardée**.
Dans une fonction x86-64 compilée à `-O0` :
```
adresses hautes
+------------------------+ rbp + 16 : locales de l'appelant
| ... |
+------------------------+ rbp + 8 : ADRESSE DE RETOUR SAUVEGARDÉE <-- devient RIP
| saved rbp (8 bytes) |
+------------------------+ rbp : notre pointeur de trame
| line[128] |
| buf[64] | <- rsp : ce que read() remplit
+------------------------+
adresses basses
```
Quand la fonction retourne, `leave; ret` pousse les 8 octets dans `RIP`, et le
CPU saute là où l'attaquant l'a décidé. Tout le reste dans ce lab est de
l'arithmétique sur où pointer.
Pour cette compilation, les chiffres sont : `buf` fait 64 octets, le `rbp`
sauvegardé fait 8, donc l'adresse de retour est à l'offset **88** du début de
`buf`. `fooc` ne hardcode pas ça — il désassemble `food` et trouve le
`lea -0x50(%rbp)` devant `call read@plt`, donc ça continue de marcher si vous
changez `FOOD_BUFSZ`.
> gcc vous le dit déjà. Compiler `food` affiche :
> `warning: 'read' writing 512 bytes into a region of size 64 overflows the
> destination [-Wstringop-overflow=]`. N'étouffez jamais cet avertissement
> dans du vrai code. C'est de la sécurité gratuite.
---
## Les trois techniques
`fooc -t <technique>`. Elles sont dans l'ordre où un vrai attaquant s'y
prendrait, parce que chacune a besoin de ce que la précédente vous a appris.
### 1. `ret2win` — contrôlez le pointeur d'instruction
```
[ 88 octets de bourrage ][ l'adresse du win() de food ]
^ saved rbp
^ devient RIP
```
`win()` est une fonction du programme cible qui exec `/bin/sh`. Écraser
l'adresse de retour avec son adresse, c'est tout l'exploit.
**Ce que ça apprend :** vous avez un contrôle arbitraire du pointeur
d'instruction. Pas besoin de fuite non plus, car le binaire est compilé avec
`-no-pie`, donc `win()` est à une adresse fixe pour toujours.
**L'équivalent dans le monde réel** n'est pas « les attaques sont faciles »,
mais « ne livrez pas de portes dérobées non documentées dans des binaires
réseau ». S'il existe une fonction comme `win()` dans votre binaire, un
débordement de tampon la trouvera. C'est littéralement la classe des CVE de
backdoor Juniper ScreenOS.
**Défense :** `-fPIE` (ou ASLR) randomise l'adresse de chargement, donc
l'attaquant doit connaître l'adresse — ce qui signifie en pratique qu'il lui
faut d'abord une fuite. C'est pourquoi `ret2win` échoue contre
`food_hardened`.
### 2. `ret2libc` — appelez n'importe quoi, par son nom
```
[ bourrage ][ pop rdi; ret ][ adresse de "/bin/sh" ][ adresse de system() ]
^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
met rdi la chaîne à envoyer la fonction à appeler
```
À l'exécution : `ret` pousse `pop rdi; ret` dans RIP ; ça pousse le pointeur
`"/bin/sh"` dans `RDI` ; son `ret` pousse `system()` dans RIP, pendant que
`RDI` tient toujours la chaîne. `system("/bin/sh")` s'exécute.
Les gadgets (`pop rdi; ret`) ne sont pas dans `food` — cette glibc n'a pas de
`__libc_csu_init` — donc `fooc` les trouve en scannant la mémoire live de la
libc à la recherche de la paire d'octets `5f c3`. Il localise la libc via
`/proc/self/maps`, trouve les offsets de `system` et `"/bin/sh"` avec
`dlsym()` et calcule la base à partir de la fuite que `food` divulgue. Rien
n'est hardcodé, donc ça survit à une mise à jour de la libc.
**Ce que ça apprend :** une fois que vous contrôlez `RIP`, vous pouvez
enchaîner des instructions *existantes*. C'est la programmation orientée
retour (return-oriented programming), et c'est à ça que ressemblent presque
tous les vrais exploits, parce que ça ne nécessite pas de mémoire exécutable
fournie par l'attaquant.
**Défense :** aucun des flags du compilateur ne l'arrête seul. Ça marche
contre un binaire PIE, avec NX, avec canary — tant que l'attaquant a une
fuite. Les défenses sont « n'ayez pas le débordement » et « ne fuytez pas
d'adresses ». Voir le tableau ci-dessous.
### 3. `shellcode` — exécutez votre propre code machine
23 octets, placés au début du tampon, avec `RIP` pointant dessus :
```asm
xor esi, esi ; envp = NULL
xor edx, edx ; argv = NULL
movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" en 8 octets bruts
push rdi ; dépose la chaîne sur la pile
mov rdi, rsp ; rdi = &"/bin/sh"
push 0x3b ; 59 = __NR_execve
pop rax
syscall ; nous voilà un shell
```
C'est la forme la plus pure du bug : l'attaquant fournit les *instructions*,
pas seulement l'adresse d'instructions qui existent déjà. Aucun offset de
libc nécessaire, donc ça marche en principe contre une cible statiquement
liée, entièrement randomisée.
`make verify` assemble `shellcode.S` et le diff contre le tableau d'octets
intégré dans `fooc.c`, pour que les deux ne puissent pas diverger.
**Défense :** **NX** (aussi appelé W^X, « no execute »). Marquer la pile comme
non exécutable fait que le matériel refuse d'y chercher des instructions, et
le `ret` atterrit sur une page qui ne peut pas tourner. C'est pourquoi
`make food` passe `-z execstack` : une pile Linux normale est `rw-p`, pas
`rwx`, et la technique meurt d'un SIGSEGV à `RIP = l'adresse de la payload`.
La leçon la plus importante du lab est que chacun de ces octets ne fonctionne
que parce qu'on a dit au compilateur de rendre la pile exécutable. Ce flag est
activé pour le bien de personne.
### Aussi inclus
| Mode | Ce qu'il fait |
|---|---|
| `-t leak` | se connecte, affiche les fuites, n'envoie rien |
| `-t demo` | envoie `rip_off + 8` octets de `0x41`, donc `RIP` devient `0x4141...` et le démon meurt. Prouve le bug sans aucune connaissance d'adresse |
| `-t sled` | un ret-sled, conservé volontairement comme exemple **échec**. Sans fuite, vous brute-foreeriez ASLR en remplissant le tampon avec l'adresse d'un `ret`. Impossible ici : `food` accepte 512 octets, donc le sled a ~53 emplacements contre ~28 bits d'entropie. Implémenté pour que vous puissiez le voir échouer et confirmer que le mécanisme est vraiment « le CPU suit une chaîne de rets » |
---
## Le tableau des contre-mesures
C'est la partie à retenir. Chaque ligne est une vraie défense, et la colonne
de droite montre ce qu'elle fait réellement à la chaîne des événements.
| Contre-mesure | Comment l'activer | Ce qu'elle arrête | Ce qu'elle *n'arrête pas* |
|---|---|---|---|
| **Limitez read** | `n = read(fd, buf, sizeof buf - 1);` | **Tout.** Le bug n'existe pas, donc rien en aval n'a d'importance | Rien — c'est le seul correctif complet |
| **Canary de pile** | `-fstack-protector-strong` (par défaut chez gcc) | Le `ret` : la canary est vérifiée à la sortie de la fonction, la corruption est donc détectée et le processus abort avant que `RIP` soit poussé | Un bug dans une fonction *sans* tableau (rien à protéger) ; un débordement qui reste sous la canary ; tout ce qui ne retourne pas normalement |
| **NX / W^X** | `-z noexecstack` (la valeur par défaut) | La shellcode. Les instructions de la payload ne peuvent pas être cherchées | ret2win et ret2libc complètement. C'est *la raison* pour laquelle ROP existe |
| **PIE + ASLR** | `-fPIE` + ASLR=2 (tous deux par défaut) | Les adresses hardcodées de ret2win. Tout bouge à chaque exécution | Tout ce où l'attaquant a une fuite. ASLR augmente le prix d'un exploit ; ce n'est pas un correctif. Notez que la pile, le tas et mmap sont randomisés, mais pas le *contenu* du binaire principal — c'est ce que les chaînes ROP utilisent |
| **Ne fuytez rien** | aucun `printf("%p")` vers les clients ; initialisez avant d'afficher | La fuite d'information qui rend ASLR « gratuit » au lieu de « cher » | — |
| **N'utilisez pas `printf(user_data)`** | `printf("%s", buf)` au lieu de `printf(buf)` | Les bugs de chaîne de format : lectures de pile `%x`, écritures arbitraires `%n` — une *autre* voie vers RCE | — |
2026-09-29 10:04:38 +02:00
| **N'utilisez pas de chemins non fiables** | validez et `openat()` sous un répertoire fixe | Traversal de chemin ([CWE-22](https://cwe.mitre.org/data/definitions/22.html)) | — |
2026-09-29 09:39:24 +02:00
| **CET / shadow stack** | `-fcf-protection=full`, support noyau et CPU | Le `ret` lui-même : la shadow stack mémorise la *vraie* adresse de retour et fault en cas de mismatch. Attrape les chaînes ROP qui utilisent le `ret` matériel | Les attaques qui ne `ret` jamais (call-oriented, ou écraser la cible d'un pointeur de fonction avec une chaîne de gadgets qui n'a pas besoin de retour) |
| **Langages sûrs** | Rust, Go, C# pour le nouveau code | Toute la classe. Les vérifications de bornes sont imposées à l'exécution, pas espérées à la revue | — |
### Voyez par vous-même
```sh
make run # démon vulnérable
make test # les trois techniques fonctionnent
make test-hardened # même code source, contre-mesures activées
```
`test-hardened` compile `food_hardened` avec `-fstack-protector-strong -fPIE
-pie -z noexecstack`, l'échange, rejoue les trois techniques puis remet la
version vulnérable en place. Vous verrez :
```
### stack segment: 'rw-p' (NOT executable) is what you want to see
--- ret2win was stopped by the mitigations (as expected)
--- ret2libc was stopped by the mitigations (as expected)
--- shellcode was stopped by the mitigations (as expected)
```
Et dans le log du démon durci, la canary qui se déclenche :
```
*** stack smashing detected ***: terminated
```
Lisez bien, car c'est la ligne la plus importante de tout le lab : **la canary
a attrapé ret2win, pas PIE.** Les trois techniques meurent à la canary, parce
que les trois passent par le même `read()` et écrasent le même cadre. NX
n'arrête en plus que le *code* de la shellcode ; PIE ne casse en plus que
l'adresse hardcodée. Activez-les une par une, et vous découvrirez que la
plupart des contre-mesures isolées vous laissent exposé à quelque chose.
---
## Fichiers
| Fichier | Rôle |
|---|---|
| `food.c` | le démon vulnérable. 6 bugs numérotés, chacun avec son correctif en commentaire |
| `fooc.c` | l'exploit. Reconnaissance d'offset par objdump, reconnaissance de la libc via `/proc`, 4 constructeurs de payload |
| `shellcode.S` | les 23 octets de shellcode en assembly, pour être lisibles et vérifiables. `fooc` les embarque en ligne et n'en a pas besoin à l'exécution |
| `Makefile` | compile, teste et la comparaison durcie |
| `tests/pty_test.c` | conduit `fooc` à travers un pseudo-terminal et vérifie une vraie sortie de shell |
| `tests/sock_test.c` | vérificateur indépendant sur une socket brute, pour que le résultat ne dépende pas de `fooc` |
| `food.log` | le log du démon. Votre preuve de ce qui s'est passé |
---
## Deux bugs de ce lab qui valent la peine d'être compris
Ce ne sont pas les bugs du programme cible. Ce sont des bugs de l'exploit et
de sa harnesse de test, et les deux ont produit des mensonges convaincants.
Ils sont documentés dans la source où ils vivent ; ils sont ici parce que les
schémas d'échec sont instructifs.
### Alignement de pile : le crash qui n'est pas une déréférence NULL
**Symptôme.** L'overtake atterrit correctement — `gdb` vous montre dans
`win()` — et puis la toute première chose que fait `win()`, un `dprintf()`,
meurt. Le handler SIGSEGV rapporte `RIP` profond dans le formatter de glibc
et une adresse d'erreur de `(nil)`, ce qui ressemble exactement à un pointeur
corrompu.
**Cause.** L'ABI System V AMD64 exige un alignement de pile de 16 octets. Un
`ret` normal restaure `%rsp` exactement comme le `call` correspondant l'avait
stocké, donc l'invariant est préservé gratuitement. Notre `ret` nu ne le fait
pas : après lui, `%rsp = buf + rip_off`. Ici, `buf` est aligné sur 16 octets
et `rip_off` vaut 88, donc le callee reçoit une pile à 8 mod 16. glibc est
compilé avec SSE2, et `movaps` **fault** sur un opérande mal aligné. Sur
x86, ça soulève `#GP`, pas `#PF`, donc le noyau n'a pas d'adresse d'erreur et
rapporte `si_addr = 0`. Ce NULL est l'indice : une erreur d'alignement
déguisée en déréférence NULL.
**Correctif.** Un gadget `ret` *à l'offset `rip_off`*, qui décale la vraie
cible de 8 octets, parce que chaque `ret` ajoute exactement 8 à `%rsp`.
L'ordre est critique : une version précédente collait le `ret` *après* la
cible et produisait `[ padding | target | ret ]`, où le `ret` final n'est
jamais atteint et le correctif ne fait silencieusement rien. Un `ret` égaré
qui ressemble à un bug est presque toujours intentionnel.
### Une socket, deux lecteurs : l'octet disparu
**Symptôme.** La shellcode était rapportée comme fonctionnant. Puis la harness
pty a été durcie (désactivation de `ECHO`, pour que le terminal arrête de
s'échoir sa propre ligne de commande), et la technique a commencé à échouer.
Plus profondément, chaque technique perdait exactement un octet au début de
chaque morceau de sortie : `uid=1000(hanez)` était affiché comme
`id=1000(hanez)`, `PWNED-OK` comme `WNED-OK`, `Linux 7.2.7` comme
`inux 7.2.7`.
**Cause.** `fooc` faisait un `dup2()` de la socket sur son propre stdin/stdout
et un `execv()` d'un `/bin/sh` *local*, pendant qu'un enfant relay forké
lisait aussi la même socket pour déplacer la sortie vers le terminal. Le
noyau se moque que les deux coopèrent. Une socket stream a **une** curseur de
lecture, et chaque lecteur la déplace, donc les octets sont répartis entre eux
de façon imprévisible. Le shell local — un shell de connexion interactif —
lisait exactement un octet et le jetait, à chaque fois. `strace -f` l'a montré
immédiatement :
```
read(0, "u", 1) <- le shell local, mange un octet
read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- le relay, 1 octet de trop court
```
**Correctif.** Il n'y a aucun shell de ce côté-ci, point. Il y a exactement un
shell dans tout le tableau, et il est sur la victime, dans le processus
détourné, avec la connexion TCP comme stdin/stdout. Ce côté ne fait que
déplacer des octets. Si vous avez un jour besoin de deux consommateurs d'un
flux, ce flux a besoin d'un lecteur unique qui le démultiplexe délibérément.
**La meta-leçon.** Le premier résultat « fonctionnel » était un faux positif,
produit par le fait que le pty s'échoit sa propre ligne de commande, et le
correctif de ce faux positif est ce qui a révélé le vrai bug. Des tests qui ne
peuvent pas échouer sont pires que pas de tests, parce qu'ils transforment « je
ne sais pas » en « ça marche ». Une harnesse de test mérite la même suspicion
que le code qu'elle teste.
---
## Bidouiller dessus
Des choses qui valent le coup d'essayer, à peu près dans l'ordre où vous en
apprenez le plus :
1. **Changez `FOOD_BUFSZ` en 128.** Relancez `fooc`. Ça devrait continuer de
marcher sans modification, parce qu'il lit l'offset dans le désassemblage.
Cassez-le ensuite à la main — hardcodez 88 — et voyez-le crasher. Ajoutez
puis un deuxième tableau entre `buf` et les registres sauvegardés, et voyez
la reconnaissance automatique s'en charger.
2. **Ajoutez `-Wformat-security` et voyez ce que fait le chemin de chaîne de
format.** Envoyez `%p %p %p %n` et voyez `food` fuyter la pile.
3. **Utilisez gdb.** `make debug`, puis :
```gdb
(gdb) break food.c:393 # le read() qui déborde
(gdb) run -p 2342
(gdb) info registers rsp rbp
(gdb) x/24gx $rsp # remarquez où se trouve l'adresse de retour
(gdb) c # dans un autre terminal : ./fooc -t ret2win
```
Le handler SIGSEGV journalise `REG_RIP` et `REG_RSP`, donc `food.log` vous
dit si l'overtake a atterri, même quand l'enfant meurt avant que vous
puissiez vous attacher.
4. **Supprimez le correctif d'alignement** dans `fooc.c` et voyez l'erreur
`#GP` avec la signature `si_addr = 0`. Lisez ensuite
`/proc/sys/kernel/randomize_va_space` et réfléchissez à ce qu'ASLR
randomise, et à ce qu'il ne randomise pas.
5. **Cassez la résolution de symboles de la libc** et voyez `fooc`
s'adapter. Tout l'intérêt de l'approche `/proc/self/maps` est qu'aucun
offset n'est hardcodé.
6. **Écrivez une quatrième technique.** Une chaîne de type `ret2csu` si vous
trouvez `__libc_csu_init`, ou une chaîne SROP (les cadres `sigreturn`
vous laissent contrôler tous les registres d'un coup). Les deux sont du ROP
pur et n'ont besoin d'aucune mémoire exécutable.
7. **Corrigez `food.c` proprement**, un bug à la fois, et relancez l'exploit
après chaque correctif. L'ordre du tableau en haut de `food.c` est à peu
près le bon ordre de pensée : limitez d'abord le read, car rien d'autre
n'importe tant que le bug n'est pas parti.
---
## Nettoyage
```sh
make stop # arrête food
make clean # supprime les produits de compilation ; laisse food.log tranquille
pkill -x sh # seulement si vous avez des shells qui traînent d'un test raté
```
Notez : `pkill -x food` matche le **nom** du processus exactement. N'utilisez
pas `pkill -f ./food` — ce motif matche aussi le shell dans lequel vous le
tapez et tue votre propre session. Ce n'est pas une hypothèse ; c'est arrivé
2026-09-29 10:04:38 +02:00
pendant la construction de ce lab.