21 KiB
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 débordement de tampon de pile, digne d'un manuel (CWE-120), plus quelques 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.0ou à une vraie interface réseau. Il est volontairement exploitable à distance. - Pointer
foocvers 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(), etfoodles reape, donc les crashs ne s'accumulent pas. Si vous retrouvez ensuite des dizaines deshqui traînent,pkill -x shest 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
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 :
./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 :
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
foodaffiche :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 :
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 |
— |
| N'utilisez pas de chemins non fiables | validez et openat() sous un répertoire fixe |
Traversal de chemin (CWE-22) | — |
| 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
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 :
-
Changez
FOOD_BUFSZen 128. Relancezfooc. Ç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 entrebufet les registres sauvegardés, et voyez la reconnaissance automatique s'en charger. -
Ajoutez
-Wformat-securityet voyez ce que fait le chemin de chaîne de format. Envoyez%p %p %p %net voyezfoodfuyter la pile. -
Utilisez gdb.
make debug, puis :(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 ret2winLe handler SIGSEGV journalise
REG_RIPetREG_RSP, doncfood.logvous dit si l'overtake a atterri, même quand l'enfant meurt avant que vous puissiez vous attacher. -
Supprimez le correctif d'alignement dans
fooc.cet voyez l'erreur#GPavec la signaturesi_addr = 0. Lisez ensuite/proc/sys/kernel/randomize_va_spaceet réfléchissez à ce qu'ASLR randomise, et à ce qu'il ne randomise pas. -
Cassez la résolution de symboles de la libc et voyez
foocs'adapter. Tout l'intérêt de l'approche/proc/self/mapsest qu'aucun offset n'est hardcodé. -
Écrivez une quatrième technique. Une chaîne de type
ret2csusi vous trouvez__libc_csu_init, ou une chaîne SROP (les cadressigreturnvous 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. -
Corrigez
food.cproprement, un bug à la fois, et relancez l'exploit après chaque correctif. L'ordre du tableau en haut defood.cest à 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
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é
pendant la construction de ce lab.