foo/suid/README.FR.md

404 lines
20 KiB
Markdown
Raw Normal View History

2026-09-29 09:39:24 +02:00
# Lab RCE racine par SUID — `foosd` (démon) + `foosc` (exploit)
Un compagnon du laboratoire principal (`food` / `fooc`, un démon ordinaire où
un débordement de tampon vous donne un shell *utilisateur*). Celui-ci ajoute le
changement d'un seul caractère le plus dangereux d'Unix : **le bit setuid**.
> `chmod u+s` transforme « l'attaquant peut exécuter du code sur cette machine »
> en « l'attaquant peut exécuter du code en tant que **root** sur cette
> machine ».
Cette phrase, c'est tout le laboratoire. Tout ce qui suit est le mécanisme
qu'il y a dessous, écrit noir sur blanc, pour que lorsque vous écrivez votre
propre logiciel, vous sachiez précisément quels deux ou trois attributs de
système de fichiers et flags de compilateur décident si un bug de sécurité
mémoire dans votre code est une nuisance ou un shell root.
La démo finale, quand `foosd` est setuid-root, est un **shell root** ouvert sur
le réseau en exécutant 32 octets de shellcode écrite à la main.
---
## 1. Ce que fait réellement le bit setuid
Chaque processus Linux porte trois user-ID, et le bit setuid touche à la
relation entre eux :
| ID | Nom | Signification |
|----|------|---------|
| `ruid` | user-ID réel | le compte qui a *démarré* le processus |
| `euid` | user-ID effectif | ce que le noyau vérifie quand il applique les accès |
| (saved) | set-user-ID sauvegardé | un « créneau » auquel un processus privilégié peut revenir plus tard |
Un programme normal a `ruid == euid`. Quand vous exécutez un binaire avec le
bit setuid posé, appartenant à root :
```text
ruid = vous (ex. 1000, « hanez »)
euid = le propriétaire (ex. 0, « root »)
```
Le processus a donc **l'autorité de root**, même si l'utilisateur qui l'a
lancé est parfaitement ordinaire. Chaque contrôle que le noyau effectue — ce
processus peut-il lire `/etc/shadow` ? écrire un fichier ? tuer un autre
processus ? — est tranché avec `euid`, donc « oui, il est root ».
`foosd` est un démon réseau. Il lie un port puis `fork()` un enfant par
connexion. Un fork *hérite* de l'euid, donc chaque enfant qui traite une
connexion est aussi root. Le débordement dans `vulnerable_handler()` de
`foosd` est donc un débordement *à l'intérieur d'un processus root*.
**Diagnostiquez-le vous-même quand le démon tourne :**
```console
$ ./foosd ... # voyez la ligne de log qu'il affiche au démarrage
[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process
```
et depuis l'exploit :
```console
$ ./foosc -t leak
foosc: target euid=0 ruid=1000
```
---
## 2. Le lab en un coup d'œil
| Fichier | Rôle |
|------|------|
| `foosd.c` | Le démon volontairement vulnérable (propriétaire des bugs). Exécutez-le en *binaire setuid-root* pour la démo du shell root. |
| `foosc.c` | L'exploit. Utilise par défaut la technique de shellcode `setreuid + execve` de 32 octets. |
| `shellcode.S` | La shellcode de référence ; `make verify` la diff contre le tableau d'octets dans `foosc.c`. |
| `tests/pty_suid_test.c` | Harnesse de test. Conduit `foosc` à travers un pseudo-terminal et prouve à la fois « un shell a tourné » *et* « il était root » (`uid=0(`). |
| `Makefile` | Compilation, helpers `setuid`/`unsetuid`, matrice de test. |
| `README.md` | Ce fichier. |
> **Pourquoi un pty ?** La dernière action de l'exploit est de relayer votre
> terminal vers le shell qui tourne sur la victime. Un pipe ou un here-doc
> arrive du mauvais côté du relais ; un vrai terminal est requis.
---
## 3. Démarrage rapide
```console
$ make # compilez tout, en tant que votre utilisateur normal
$ make setuid # une fois, demande sudo : chown root + chmod u+s
$ make run # démarre foosd sur 127.0.0.1:2343
$ make test-suid # matrice complète ; shellcode + ret2win-root doivent donner root
```
Test de fumée interactif :
```console
$ ./foosc -t shellcode
...
foosc: target euid=0 ruid=1000
foosc: shell is on the victim (root if foosd is SUID); relaying
# id
uid=0(root) gid=0(root) groups=0(root) <-- vous êtes root, sur la victime
# exit
```
Quand vous avez fini :
```console
$ make stop
$ make unsetuid # hygiène : ne laissez jamais un binaire root SUID traîner
```
---
## 4. *Quand dois-je poser le bit SUID ?* — la réponse que vous avez demandée
Exactement **une fois, après la compilation, avant de démarrer le démon pour
les démos de shell root** — et uniquement sur une machine qui est à vous,
bonne à jeter et déconnectée du réseau :
```console
$ make # compilez foosd, foosc, les tests
$ make setuid # <-- L'INSTANT. sudo chown root:root foosd && sudo chmod u+s foosd
$ make run # démarrez APRÈS avoir posé le bit
```
Deux règles plus importantes que le moment précis :
1. **Posez-le seulement quand le binaire est fini.** Si vous recompilez
(`make` / `make clean`) après avoir posé le bit, vous tombez sur un
« Permission denied » en écrivant les fichiers de sortie appartenant à root
— et si vous forcez la recompilation, la chaîne d'outils recrée le fichier
**sans** le `s` et défait silencieusement la configuration. L'ordre
canonique à chaque recompilation est donc
```console
$ make unsetuid && make && make setuid
```
2. **Enlevez-le quand vous avez fini.** `make unsetuid`. Un binaire setuid
vivant, appartenant à root, avec un bug exploitable dans votre arborescence,
ce n'est pas un outil pédagogique, c'est un trou root avec une erreur de
compilation entre lui et rien. Sur une machine partagée ou de production :
**ne faites rien de tout cela.** Le démon refuse d'ailleurs par défaut de se
lier ailleurs qu'en loopback (voir §7).
Si vous exécutez l'exploit *sans* jamais poser le bit, rien ne casse — la
payload atterrit toujours, et vous obtenez toujours un shell. La différence
tient en un seul chiffre, et l'exploit le dit à voix haute :
```console
foosc: WARNING: the daemon is NOT running with euid 0.
The payload will still land, but the shell will be
a plain user shell, not root.
Fix: sudo make setuid
```
Le résultat « ça marche, mais pas root » fait lui-même partie du lab. Gardez-le
en tête pour la section suivante.
---
## 5. Le mécanisme — et la pirouette qui rend SUID intéressant
### 5.1 Le débordement (identique à `food`)
Le handler de `foosd` donne à un `read()` 512 octets de confiance en lui
tendant un tampon de pile de 64 octets :
```c
char buf[64];
n = read(fd, buf, 512); /* <- CWE-120 : 448 octets par-dessus le bord */
```
Sur x86-64, la pile croît vers le bas. L'exploit écrit 64 octets de bourrage
pour remplir `buf`, 8 pour remplir le pointeur de trame sauvegardé et 8 de
plus pour remplacer l'**adresse de retour sauvegardée**. Quand
`vulnerable_handler` exécute `ret`, le CPU pousse la valeur de l'attaquant
dans `RIP` — une exécution de code contrôlée par l'attaquant. L'exploit trouve
la distance exacte (88 octets pour cette compilation) en analysant la sortie
de `objdump` au lieu de la hardcoder, donc le chiffre survit aux
recompilations.
### 5.2 La pirouette : le shell refuse d'être root
Voici où penser « bug SUID → spawn /bin/sh → root » irait de travers, et
pourquoi ce lab a exactement la forme qu'il a.
Quand un programme setuid-root tourne, son `ruid` est toujours l'utilisateur
qui l'a lancé, et son `euid` est root. Si le programme — ou l'attaquant — lance
maintenant un shell :
* `execve("/bin/sh")` ne change **pas** les uids ; le nouveau processus hérite
de `(ruid=1000, euid=0)`.
* bash (et dash) **vérifie exactement cet état au démarrage**. D'après le
manuel de bash : *« If the shell is started with the effective user (group)
id not equal to the real user (group) id, and the -p option is not supplied,
… the effective user id is set to the real user id. »*
Donc le shell se regarde et *lâche root* — une défense que les auteurs de
shell ont construite précisément contre cette attaque (la justification
historique était le problème des shell setuid / scripts setuid). Le résultat,
ce sont les cas « ça marche, mais pas root » :
| Technique | Ce qu'elle exécute | uid résultant |
|-----------|------------------|---------------|
| `ret2win` | `win()` de `foosd` → `execl("/bin/sh")` | **1000** — shell atterri, root réinitialisé par bash |
| `ret2libc` | `system("/bin/sh")` → `sh -c '/bin/sh'` tout frais | **1000** — même réinitialisation, un niveau plus bas |
| `ret2win-root` | `win_root()` de `foosd` → `setreuid(0,0); execl("/bin/sh")` | **0** — ruid nettoyé depuis le C |
| `shellcode` | 32 octets : `setreuid(0,0); execve("/bin/sh")` | **0** — ruid nettoyé depuis le code machine |
Celles qui atteignent root diffèrent de celles qui ne l'atteignent pas par
exactement une idée : **elles nettoient l'uid *réel*, pas seulement
l'effectif.**
```c
setuid(0) /* met euid à 0, mais ruid reste 1000 :
bash voit toujours euid != ruid et réinitialise QUAND MÊME. */
setreuid(0, 0) /* met LES DEUX : ruid = euid = 0.
bash voit des uids égaux et garde root. */
```
C'est pourquoi la shellcode `/bin/sh` classique que vous trouvez partout sur
Internet commence par un syscall de nettoyage d'uid — et c'est pourquoi la
shellcode fait ici 32 octets au lieu de 23 : les cinq premières instructions
sont
```asm
xor edi, edi ; ruid = 0
xor esi, esi ; euid = 0
push 0x71 ; 113 = __NR_setreuid
pop rax
syscall
```
### 5.3 Alors, c'est quoi l'exploit, de bout en bout ?
1. `foosc` lit le banner de `foosd` sur la socket. Il obtient :
- `ids=0/1000` — euid/ruid (l'autodiagnostic SUID)
- `stack=…` et `libc=…` — des pointeurs (les fuites ASLR)
- `BUF=…` — l'adresse exacte du tampon qu'il s'apprête à faire déborder
2. Depuis le binaire cible (via `objdump`), il apprend `rip_off` et les
adresses de `win()` / `win_root()`.
3. Depuis *sa propre* libc (via `/proc/self/maps` + `dlsym` + un scan mémoire),
il mesure les offsets de `system`, `read`, `/bin/sh` et un gadget
`pop rdi; ret` — rien n'est hardcodé.
4. Il assemble la payload. Pour `-t shellcode`, c'est :
`[code setreuid+execve de 32 octets][bourrage jusqu'à RIP][ret-fix][adresse de buf]`.
5. Le `read()` de `foosd` déborde ; le `ret` atterrit sur la shellcode ; le
noyau exécute `setreuid(0,0)` (pas de souci : euid 0 est privilégié) puis
`execve` de `/bin/sh`. bash démarre avec `ruid == euid == 0` et reste root.
6. `foosc` relaie votre terminal vers ce shell root, jusqu'à ce que vous
tapiez `exit`.
Un détail de commodité qui coûte cher à beaucoup de gens s'il est manqué :
l'exploit teste chaque comportement de nettoyage d'uid **sans** avoir besoin du
bit setuid au préalable. Lancez `make test` avant `make setuid`, et vous verrez
chaque technique atterrir un shell avec `ROOT=MISSING` ; lancez `make test-suid`
après `make setuid`, et `ROOT=SEEN` apparaît pour les deux techniques qui
nettolent l'uid réel. Ce A/B est toute la leçon, jouée en dix secondes.
---
## 6. Les vieux one-liners — et pourquoi la plupart sont morts
Si vous avez lu sur SUID, vous avez lu sur les détournements de `PATH`, sur
`LD_PRELOAD` et sur les shells setuid. Les trois sont classiques, et les trois
échouent sur un système moderne contre *ce programme*. Ça vaut le coup de
savoir exactement pourquoi, parce que les raisons sont les défenses que vous
avez gratuitement :
| Classe d'attaque | Vieille affirmation | Pourquoi elle échoue sur une machine moderne |
|--------------|-----------|------------------------------|
| `LD_PRELOAD` d'une bibliothèque malveillante | « Le programme setuid charge mon `.so` et exécute mon code en tant que root. » | Le noyau marque un binaire setuid comme **AT_SECURE** ; glibc ignore alors `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_DEBUG` et compagnie. L'environnement est traité comme *entrée non fiable*. `LD_PRELOAD` contre un binaire setuid est un no-op. |
| Détournement de `PATH` (`system("ls")` avec un PATH empoisonné) | « Pointez PATH vers un répertoire avec mon faux `ls` ; le programme root l'exécutera. » | Un autre visage de la même défense : un processus AT_SECURE reçoit un **PATH assaini** (une valeur sûre par défaut, grossièrement `/usr/local/bin:/usr/bin:/bin`) pour `system()`/`execvp`, donc le répertoire empoisonné n'est jamais consulté. |
| Injection de commande `system()` setuid | « La commande injectée s'exécute avec euid 0. » | `system()` exécute la commande dans un `/bin/sh` tout frais, et ce shell — §5.2 — réinitialise `euid = ruid` au démarrage. La commande injectée s'exécute avec l'uid *réel*. (C'est toujours un bug ; ça n'escalade juste plus via `/bin/sh`.) |
| Shell root setuid sur disque (`cp /bin/sh /tmp; chmod u+s`) | « Exécute-le, obtiens root. » | Exactement la défense ci-dessus, et c'est pourquoi les distros modernes ne livrent aucun shell root setuid. Même si vous réussissez à en fabriquer un, bash refuse de garder euid 0 sauf s'il est lancé avec `-p`. |
Ce qui reste vivant, et c'est ce lab : **le programme est *déjà* root quand il
tourne.** Vous n'avez besoin ni de l'environnement ni de `system()` ; vous avez
besoin que le programme exécute *votre* code (via un bug de corruption
mémoire) pendant qu'il est privilégié, et que votre code soit assez soigneux
pour corriger lui-même le mismatch d'uid — `setreuid(0,0)` — avant de vous
tendre un shell. Corruption mémoire + SUID est la combinaison qui finit encore
en `uid=0`, et c'est exactement pourquoi les langages sûrs en mémoire, les
canaries et les piles no-execute ne sont pas une décision de mode.
---
## 7. Les garde-fous intégrés au démon
`foosd` est volontairement le *pire* morceau de logiciel de ce dépôt, alors il
porte aussi le plus de garde-fous :
1. **Loopback seulement, imposé.** `foosd` refuse toute adresse de bind hors
loopback, sauf si vous passez `-L`. Un listener setuid-root sur une vraie
interface est un service root distant ; le refus est la valeur par défaut,
pour que l'état dangereux doive être tapé délibérément.
2. **Autodiagnostic.** Au démarrage, il journalise `ruid`/`euid` et s'il
tourne en root, pour que la console montre l'état dont dépend l'exploit.
3. **Le log n'atteint jamais le client.** Le démon réserve un descripteur de
log privé avant que les sockets ne remplacent fd 1, pour que la sortie du
crash-reporter et les chemins internes ne puissent pas être relus sur le
fil par l'attaquant.
4. **Crash-reporter.** Un handler SIGSEGV journalise `RIP`/`RSP` — la valeur
que l'attaquant a écrite dans l'adresse de retour — pour qu'une prise de
contrôle réussie soit visible dans `foosd.log` au lieu d'être une mort
silencieuse.
5. **`make unsetuid`.** Retirer le bit est scripté, parce que le laisser posé
est le mode d'échec que les gens ont réellement.
---
## 8. Contre-mesures — ce que chacune arrête et ce qu'elle *n'arrête pas*
Appliquées à `foosd` via `make hardened`, une par une ou ensemble :
| Contre-mesure | Ce qu'elle arrête | Ce qu'elle *n'arrête pas* |
|------------|---------------|-------------------------|
| `-fstack-protector-strong` (canary) | Le débordement : `ret` détecte une canary écrasée et abort avant que l'adresse de l'attaquant soit utilisée. Arrête ici **les quatre** techniques — elles partagent le même `read()` vulnérable. | Rien par *conception* : le binaire est toujours setuid-root ; un autre bug (format-string-`%n`, heap-overflow, use-after-free) n'a pas de canary à déclencher. |
| `-fPIE -pie` (ASLR pour le binaire) | L'utilisation d'adresses `win()`/`win_root()` prévisibles (les techniques ret2win). | La technique shellcode, si une adresse de pile fuite encore (ligne `BUF=`). |
| `-z noexecstack` (NX / W^X) | La shellcode : le CPU refuse de chercher des instructions sur une page data-only, donc un saut vers `buf` est un SIGSEGV. | ROP — exécuter du code qui existe déjà (`ret2libc`). |
| Les trois ensemble | Un binaire difficile à déborder, randomisé, avec une pile non exécutable. Voilà à quoi ressemble une build durcie normale. | Le bit setuid. **Un binaire SUID durci reste un binaire SUID.** S'il survit un bug mémoire atteignable, c'est toujours « bug dans un processus root ». |
La preuve console, c'est `make test-hardened`, qui échange la build durcie et
montre les techniques mourir à la canary, pendant que `foosd_hardened.log`
capture `*** stack smashing detected ***`.
Deux contre-mesures de niveau conception, qu'aucun flag de compilateur ne
fournit, et que le lab principal (`food`) utilise aussi :
- **Moindre privilège.** Un démon pour un port non privilégié (2343 > 1024)
n'a aucun besoin légitime de root. Un `foosd` correct lierait puis ferait
`setgroups`/`setgid`/`setuid` vers un compte non privilégié et *confirmerait
que ça a tenu* (la version correcte est dans la source sous le nom de
`drop_privs()`, jamais appelée — le non-appel est le bug n° 3 du lab).
- **Limitez le read.** `n = read(fd, buf, sizeof(buf) - 1)`. Une ligne correcte
surpasse tous les flags de compilateur du tableau.
---
## 9. Le protocole filaire (pour que vous puissiez lire le démon avec netcat)
```text
FOOSD 1.0 - deliberately vulnerable SUID service
Type 'quit' to disconnect. Buffer = 64 bytes, read accepts 512.
FOOSD 1.0 ids=0/1000 leak stack=0x7ffd... libc=0x7f...
BUF=0x7ffd...
```
* `ids=euid/ruid` — ne pouvait pas être affiché comme `euid=`/`ruid=`, parce
que la harnesse de test prouve un shell en grepant le `uid=` littéral, et le
banner ne doit pas le contenir (une sonde qui partage la signature de la
réponse est un piège classique de faux positif ; voir le commentaire dans
`foosd.c`). La harnesse exige en outre la forme stricte de sortie `id` —
`uid=NNN(...)` — pour que rien de ce que le démon ou l'exploit affiche ne
puisse satisfaire le contrôle par accident : le propre « target euid=… ruid=… »
de `foosc` contient `uid=` comme sous-chaîne, ce qui a un jour fait
rapporter à un test durci un shell qui n'avait jamais tourné.
* `stack=`, `libc=`, `BUF=` — les fuites ASLR : elles permettent à la
shellcode et à ret2libc de calculer des adresses exactes.
---
## 10. Exercices
1. **Considérez la descente non-root.** Lancez `make test` *avant* `make
setuid`, puis encore après. Expliquez le passage à `ROOT=SEEN` avec
l'histoire ruid/euid de la §5.2.
2. **Lisez le crash.** Lancez `./foosc -t demo -n` puis lisez `foosd.log`.
La ligne `RIP=0x4141414141414141` est le bourrage de l'attaquant — la
preuve que c'est le débordement, pas le hasard, qui contrôle l'exécution.
3. **Ajoutez la canary.** `make hardened` et modifiez vous-même la boucle
`test-hardened` ; la ligne de log `*** stack smashing detected ***` est la
défense qui fonctionne.
4. **Désactivez la fuite.** Commentez la ligne `BUF=` dans `foosd.c`, recompilez
et voyez `-t shellcode` passer de déterministe à jeu de devinettes. Cette
seule ligne est la raison pour laquelle les vrais bypass d'ASLR sont tout un
domaine.
5. **L'expérience `-p`.** Dans une copie de `win()`, changez `execl("/bin/sh",
"sh", NULL)` en `execl("/bin/sh", "sh", "-p", NULL)` et observez root. `-p`
est la sortie de secours documentée du gardien du shell — et la raison pour
laquelle le conseil « spawn juste un shell » des vieux write-ups est
incomplet.
6. **Pourquoi pas `setuid(0)` ?** Réécrivez la shellcode pour appeler
`setuid(0)` au lieu de `setreuid(0,0)` (syscall 105). Le shell atterrit
quand même — et retombe quand même à `uid=1000`. C'est l'expérience d'une
seule ligne la plus instructive de tout le dépôt.
---
## 11. Sécurité et nettoyage
- Loopback uniquement, par défaut et par conception ; `-L` lie plus loin, et
seule une VM bonne à jeter devrait même l'envisager.
- C'est un lab de shell root. Ne le faites pas tourner sur une machine qui
compte, et ne pointez pas `foosc -h` vers quelque chose que vous ne possédez
pas.
- Rituel de nettoyage : `make stop` puis `make unsetuid`, et si vous voulez
l'arborescence impeccable à nouveau : `sudo make clean`.
```console
$ make stop
$ make unsetuid
```