20 KiB
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+stransforme « 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 :
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 :
$ ./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 :
$ ./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
$ 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 :
$ ./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 :
$ 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 :
$ 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 :
-
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 leset défait silencieusement la configuration. L'ordre canonique à chaque recompilation est donc$ make unsetuid && make && make setuid -
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 :
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 :
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.
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
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 ?
foosclit le banner defoosdsur la socket. Il obtient :ids=0/1000— euid/ruid (l'autodiagnostic SUID)stack=…etlibc=…— des pointeurs (les fuites ASLR)BUF=…— l'adresse exacte du tampon qu'il s'apprête à faire déborder
- Depuis le binaire cible (via
objdump), il apprendrip_offet les adresses dewin()/win_root(). - Depuis sa propre libc (via
/proc/self/maps+dlsym+ un scan mémoire), il mesure les offsets desystem,read,/bin/shet un gadgetpop rdi; ret— rien n'est hardcodé. - Il assemble la payload. Pour
-t shellcode, c'est :[code setreuid+execve de 32 octets][bourrage jusqu'à RIP][ret-fix][adresse de buf]. - Le
read()defoosddéborde ; leretatterrit sur la shellcode ; le noyau exécutesetreuid(0,0)(pas de souci : euid 0 est privilégié) puisexecvede/bin/sh. bash démarre avecruid == euid == 0et reste root. fooscrelaie votre terminal vers ce shell root, jusqu'à ce que vous tapiezexit.
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 :
- Loopback seulement, imposé.
foosdrefuse 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. - Autodiagnostic. Au démarrage, il journalise
ruid/euidet s'il tourne en root, pour que la console montre l'état dont dépend l'exploit. - 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.
- 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 dansfoosd.logau lieu d'être une mort silencieuse. 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
foosdcorrect lierait puis feraitsetgroups/setgid/setuidvers un compte non privilégié et confirmerait que ça a tenu (la version correcte est dans la source sous le nom dedrop_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)
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é commeeuid=/ruid=, parce que la harnesse de test prouve un shell en grepant leuid=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 dansfoosd.c). La harnesse exige en outre la forme stricte de sortieid—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=… » defoosccontientuid=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
- Considérez la descente non-root. Lancez
make testavantmake setuid, puis encore après. Expliquez le passage àROOT=SEENavec l'histoire ruid/euid de la §5.2. - Lisez le crash. Lancez
./foosc -t demo -npuis lisezfoosd.log. La ligneRIP=0x4141414141414141est le bourrage de l'attaquant — la preuve que c'est le débordement, pas le hasard, qui contrôle l'exécution. - Ajoutez la canary.
make hardenedet modifiez vous-même la boucletest-hardened; la ligne de log*** stack smashing detected ***est la défense qui fonctionne. - Désactivez la fuite. Commentez la ligne
BUF=dansfoosd.c, recompilez et voyez-t shellcodepasser de déterministe à jeu de devinettes. Cette seule ligne est la raison pour laquelle les vrais bypass d'ASLR sont tout un domaine. - L'expérience
-p. Dans une copie dewin(), changezexecl("/bin/sh", "sh", NULL)enexecl("/bin/sh", "sh", "-p", NULL)et observez root.-pest 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. - Pourquoi pas
setuid(0)? Réécrivez la shellcode pour appelersetuid(0)au lieu desetreuid(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 ;
-Llie 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 -hvers quelque chose que vous ne possédez pas. - Rituel de nettoyage :
make stoppuismake unsetuid, et si vous voulez l'arborescence impeccable à nouveau :sudo make clean.
$ make stop
$ make unsetuid