Créer un serveur web en assembleur aarch64 pour donner un sens (ou compenser son absence) à ma vie
(imtomt.github.io)- ymawky est un petit serveur HTTP statique pour macOS écrit uniquement en assembleur aarch64, qui n’utilise que les appels système bruts de Darwin, sans wrappers libc
- Il prend en charge
GET,HEAD,PUT,OPTIONS,DELETE, les requêtes de plage d’octets, le listing de répertoires et les pages d’erreur personnalisées, mais ce n’est pas un remplaçant de nginx : c’est une implémentation qui retire les couches de confort pour comprendre le fonctionnement réel d’un serveur web - Il faut tout écrire à la main, du parsing des requêtes au décodage en pourcentage, à l’inspection des en-têtes, à la conversion des valeurs de plage, à la gestion des erreurs, à la fermeture des fichiers et à la génération des réponses ; même l’équivalent d’un simple découpage de chaîne Python ou de
int(string)devient en assembleur des dizaines ou des centaines de lignes de code de validation - Le serveur adopte une architecture fork-on-request qui appelle
fork()pour chaque nouvelle connexion : c’est simple à implémenter, mais le débit en connexions simultanées est faible et l’ensemble peut être vulnérable à slowloris ; des timeouts d’en-têtes et de corps basés surContent-Lengthsont donc appliqués PUTécrit d’abord dans un fichier temporaire.ymawky_tmp_<pid>, puis remplace le fichier cible en cas de succès ; la sécurité du système de fichiers est gérée directement, avec prévention de la traversée de chemin,O_NOFOLLOW_ANY,fstat64(), encodage URL et échappement HTML pour les listings de répertoires
Vue d’ensemble et contraintes de ymawky
- ymawky est un petit serveur HTTP statique pour macOS écrit uniquement en assembleur aarch64
- Il n’utilise que des appels système bruts de Darwin sans wrappers libc, et n’emploie ni bibliothèques externes ni parseurs existants
- Les fonctions prises en charge sont
GET,HEAD,PUT,OPTIONS,DELETE, les requêtes de plage d’octets, le listing de répertoires et les pages d’erreur personnalisées - Les contraintes du projet sont les suivantes
- assembleur aarch64 uniquement
- cible macOS/Darwin
- appels système bruts uniquement, sans wrappers libc
- fichiers statiques uniquement
- aucun parseur préexistant
- aucune bibliothèque externe
- Le but n’est pas de remplacer nginx, mais de retirer les couches de confort pour comprendre comment un serveur web fonctionne réellement
Ce qu’il faut faire pour créer un serveur web en assembleur
- L’assembleur est la couche entre le langage machine et les langages de haut niveau, et des instructions comme
mov,add,ldr,str,cmpcorrespondent directement aux octets du binaire exécutable svc #0x80est la forme lisible par un humain des octetsD4 00 10 01dans le binaire exécutable- Il n’existe pas de type chaîne : une chaîne n’est qu’une zone contiguë d’octets en mémoire, et il n’y a pas non plus de fonctionnalités de langage comme les
structde C, donc il faut connaître soi-même les offsets des champs et la taille totale - Comme il n’y a ni bibliothèque HTTP, ni nettoyage automatique, ni exceptions, ni objets, il faut tout écrire soi-même : parsing des requêtes, gestion des erreurs, fermeture des fichiers, génération des réponses
- Même si le programme se comporte mal, le CPU continue à exécuter sans avertissement ; le problème vient donc des instructions écrites et des accès mémoire
Appels système bruts et flux du serveur
-
Appels système Darwin
- ymawky appelle directement le noyau au lieu d’utiliser les wrappers libc
- Sur Darwin aarch64, le numéro d’appel système est placé dans le registre
x16, alors que sur Linux aarch64 il est placé dansx8 - Le numéro d’appel système de
open()est5, et le noyau est appelé viasvc #0x80après avoir placé manuellement dans les registres les arguments comme le nom de fichier et le mode - En cas d’échec de
open(), le carry flag est positionné ; le code vérifie ce flag avec quelque chose commeb.cs open_failedpour bifurquer vers le traitement d’erreur
-
Fonctionnement de base du serveur
- Le flux de base d’un serveur web consiste à recevoir une requête, la traiter, puis renvoyer un code d’état et les fichiers nécessaires
- La configuration du socket suit des étapes comme
socket(AF_INET, SOCK_STREAM, 0),setsockopt(... SO_REUSEADDR ...),bind(sockfd, &addr, 16),listen(sockfd, 5),accept(sockfd, NULL, NULL) - ymawky est un serveur fork-on-request qui appelle
fork()pour chaque nouvelle connexion - Cette approche facilite la compréhension et l’implémentation car la mémoire n’est pas partagée entre les traitements de requête, mais elle est plus coûteuse à cause de l’espace mémoire par processus et offre un débit de connexions simultanées inférieur au modèle non bloquant asynchrone piloté par événements de nginx
- Quand le nombre de connexions simultanées augmente, le noyau finit par passer plus de temps à changer de processus qu’à exécuter du code dans chacun d’eux
-
Ce qu’il faut faire lors du traitement des requêtes
- Déterminer si la méthode est
GET,HEAD,OPTIONS,PUTouDELETE - Extraire le chemin demandé et décoder les encodages en pourcentage comme
%20 - Effectuer des vérifications de sécurité sur le chemin et parser les champs d’en-tête envoyés par le client
- Récupérer les informations sur le fichier demandé pour savoir s’il s’agit d’un répertoire ou d’un fichier ordinaire
- Écrire le corps d’une requête
PUTdans un fichier temporaire, puis générer les en-têtes et le corps de la réponse - Fermer les fichiers ouverts et gérer les erreurs pour éviter que le serveur ne plante
- Déterminer si la méthode est
Implémenter soi-même le parsing HTTP
-
Ligne de requête et fin des en-têtes
- Une requête HTTP est une chaîne que le serveur doit interpréter ; voici un exemple
GET /index.html HTTP/1.0\r\n Range: bytes=1-5\r\n\r\n - La première ligne contient une requête
GET, le fichier cibleindex.htmlet la version HTTPHTTP/1.0 \r\nmarque la fin d’une ligne, et\r\n\r\nla fin des en-têtes- Si
\r\n\r\nn’est pas reçu, il faut arrêter le traitement avec400 Bad Request
- Une requête HTTP est une chaîne que le serveur doit interpréter ; voici un exemple
-
Extraction du chemin
- ymawky identifie le type de requête en comparant les méthodes prises en charge et les premiers octets, puis extrait le chemin
- Il parcourt les en-têtes octet par octet pour trouver
/ou*, mais vérifie que l’octet précédent/est bien un espace afin de ne pas confondre le/deHTTP/1.0avec un chemin - Par exemple,
GET HTTP/1.0\r\n\r\ncontient un/dansHTTP/1.0; si l’octet précédent n’est pas un espace, il faut renvoyer400 Bad Request - Comme
PATH_MAXvaut 4096 octets sur la plupart des systèmes, ymawky définit un buffer de nom de fichier de 4096 octets plus 1 octet pour le terminateur nul avecfilename_buffer: .skip 4097 - Si le chemin demandé est plus long que le buffer, il faut renvoyer
414 URI Too Longplutôt que d’écraser une zone mémoire arbitraire - Une opération proche de
text.split("GET /")[1].split(" ")[0]en Python représente environ 200 lignes en assembleur si l’on inclut la validation de conformité HTTP
-
Décodage en pourcentage et inspection des en-têtes
- Lorsqu’un
%apparaît dans le chemin, il faut vérifier que les deux octets suivants sont bien des chiffres hexadécimaux valides de0-9,a-f,A-F, puis les convertir en la valeur d’octet correspondante GETpeut avoir un en-têteRange:etPUTexigeContent-Length:- Comme ces en-têtes ne sont pas à une position fixe contrairement à l’URL de la requête, il faut parcourir l’intégralité des en-têtes caractère par caractère
- Si
\rn’est pas suivi de\n, ou si\napparaît sans\ravant lui, les en-têtes sont invalides et il faut renvoyer400 Bad Request - Si une nouvelle ligne d’en-tête commence par un espace, il faut également renvoyer
400 Bad Request, car un champ d’en-tête ne peut pas commencer par un espace
- Lorsqu’un
-
Comparaison de chaînes et conversion de nombres
- Pour trouver
Range:ouContent-Length:, il faut écrire une fonctionstreqnqui reçoit deux pointeurs de chaînex0,x1et une longueur maximalex2, puis compare caractère par caractère - Un en-tête
Range:peut omettre la borne de début ou de fin, mais pas les deuxRange: bytes=10- Range: bytes=-10 Range: bytes=5-10 - Comme les valeurs de plage sont des chaînes, il faut une fonction de style
atoipour convertir des chiffres ASCII en entiers - Pour éviter le dépassement de capacité des registres 64 bits, un nombre de 19 chiffres ou plus est traité comme une erreur
- Même l’équivalent de
int(string)en Python exige en assembleur d’implémenter à la main la vérification des chiffres, les multiplications, les additions et le signalement succès/échec via le carry flag
- Pour trouver
Traitement de PUT et stratégie de fichier temporaire
PUTest une méthode idempotente : envoyer plusieurs fois la même requête produit le même état final côté serveurPUT /file.txtcréefile.txtou écrase entièrement le fichier existant, et si l’on envoie1234deux fois, le contenu final du fichier est1234, pas12341234- Autoriser globalement
PUTpeut être dangereux, et il faut tenir compte de plusieurs cas pendant le traitement- le processus plante pendant le traitement de la requête
- le client annonce une taille de
Content-Lengthde 2 Ko mais n’envoie que 100 octets - le client envoie un
Content-Lengthénorme, par exemple 50 Go
MAX_BODY_SIZEdansconfig.Svaut 1 Go par défaut, et siContent-Lengthdépasse cette limite, le serveur renvoie413 Content Too Large- Écrire directement dans le fichier existant risquerait de laisser un fichier à moitié écrit en cas d’échec ; ymawky écrit donc d’abord dans un fichier temporaire nommé
.ymawky_tmp_<pid> - Il récupère le pid avec l’appel système
getpid()numéro20, puis le convertit en chaîne via unitoa()maison, avec vérification des dépassements de buffer - Si tout le corps envoyé par le client est écrit avec succès dans le fichier temporaire, celui-ci est renommé au nom final et le fichier demandé apparaît sur le serveur
- Si le client coupe la connexion de façon inattendue, si un timeout survient ou si le corps est invalide, le fichier temporaire est supprimé via l’appel système
unlink()10ouunlinkat()472 - Le fichier existant n’est écrasé qu’une fois la requête complète transmise avec succès
Listing de répertoires et échappement
- Lorsqu’une requête
GET /somedir/arrive, ymawky vérifie siALLOW_DIR_LISTINGest activé dansconfig.S - Si le listing de répertoires est désactivé, le serveur renvoie
403 Forbidden - S’il est activé, l’appel système
getdirentries64()344remplit un buffer avec les informations sur les fichiers du répertoire demandé - Ce buffer contient chaque nom de fichier et sa longueur, et ymawky s’en sert pour générer du HTML cliquable
- Pour chaque fichier, la forme de base envoyée au client est
<a href="filename">filename</a> - Le nom dans
href="..."doit être encodé en pourcentage comme segment de chemin d’URL, tandis que le texte affiché doit être échappé en HTML - Si le nom du fichier est
&.-~><foo, lehrefdevient%26.-~%3E%3Cfooet le texte affiché devient&.-~><foo, ce qui donne au final<a href="%26.-~%3E%3Cfoo">&.-~><foo</a> - Cela empêche l’exécution de noms exploitables pour du XSS dans le corps, comme
<script>something evil</script>, ou dans la zonehref="...", comme"><script>something dastardly</script>
Sécurité réseau et timeouts
- slowloris est une attaque par déni de service qui ouvre de nombreuses connexions sans jamais terminer les requêtes, afin de monopoliser les ressources du serveur
- Comme ymawky suit une architecture fork-on-request, il peut être vulnérable à slowloris
- Si l’ensemble des en-têtes n’est pas reçu dans le délai
HEADER_REQ_TIMEOUT_SECSdéfini dansconfig.S, le serveur envoie408 Request Timeoutpuis ferme la connexion - Si, pendant la réception du corps de la requête, le client reste trop longtemps sans envoyer de données, le traitement suit la même logique selon
RECV_TIMEOUTdansconfig.S - Un simple timeout par opération de lecture ne suffit pas
- Un client malveillant peut envoyer
Content-Length: 1073741823puis 1 octet toutes les 9 secondes ; la longueur du contenu reste inférieure de 1 octet à la limite maximale et, avec un timeout de 10 secondes par lecture, le serveur pourrait attendre pendant plus de 300 ans
- Un client malveillant peut envoyer
- Pour réduire ce risque, ymawky calcule un timeout à partir de
Content-Lengthet d’un débit minimal en octets par secondetimeout = grace_period + content_length / min_bps grace_periodest le temps minimal accordé à tous les corps de requête, etmin_bpsla vitesse de transfert la plus lente autorisée par le serveur- La valeur par défaut de
min_bpsest 16 Ko/s : c’est généreux, mais pas infini - Cette approche n’empêche pas totalement les attaques par déni de service, mais elle limite la durée pendant laquelle une attaque donnée peut monopoliser les ressources
Sécurité du système de fichiers
-
Ordre de vérification des informations de fichier
- Pour
GETetHEAD, ymawky ouvre d’abord le chemin demandé, puis exécute l’appel systèmefstat64()339sur le descripteur de fichier pour récupérer le type de fichier, sa taille et d’autres informations - Si l’on exécute d’abord
stat64()338sur le chemin, puis qu’on ouvre le fichier ensuite, on introduit une condition de concurrence TOCTOU entre le moment de la vérification et celui de l’utilisation
- Pour
-
Docroot et prévention de la traversée de chemin
- Tous les chemins demandés sont préfixés par le docroot
- Le docroot par défaut est
www/, défini parDEFAULT_DIRdansconfig.S - Une requête vers
/etc/shadowdevientwww/etc/shadow, ce qui aboutit à un 404 tant quewww/etc/shadown’existe pas réellement - En revanche,
/../../../../etc/shadowdevientwww/../../../../etc/shadowet peut être résolu en dehors du docroot ; une défense supplémentaire est donc nécessaire - ymawky ne rejette pas naïvement tous les chemins contenant la chaîne
.., mais rejette les segments de chemin qui sont exactement.. %2E%2Edevient..après décodage, donc cette vérification doit être effectuée après le décodage en pourcentage
-
Gestion des liens symboliques
- Le drapeau POSIX
O_NOFOLLOWfait échoueropen()si le dernier composant du chemin est un lien symbolique - Sur Darwin,
O_NOFOLLOW_ANYfait échouer l’ouverture si n’importe quel composant du chemin est un lien symbolique - Si quelqu’un peut déjà déposer un lien symbolique particulier dans le docroot, il y a probablement déjà un autre problème, mais ce drapeau ajoute une défense supplémentaire
- Le drapeau POSIX
Comportements spécifiques à Apple
-
Gestion des timeouts et
sigaction()- Pour implémenter les timeouts de requête, il faut envoyer
SIGALRMaprès un certain délai via l’appel systèmesetitimer()83 - Par défaut,
SIGALRMtue le processus enfant, mais ymawky doit d’abord envoyer408 Request Timeout - Pour cela, il utilise l’appel système
sigaction()46 - La structure
sigactionbrute de Darwin expose le champsa_tramp - En général, c’est la libc qui renseigne
sa_tramppour sauvegarder la pile et les registres, préparersigreturn, puis brancher vers le gestionnaire - Le gestionnaire de timeout de ymawky envoie
408 Request Timeout, ferme ce qu’il faut puis termine le processus enfant, sans avoir besoin de revenir - Il fait donc pointer l’emplacement du trampoline directement vers le code qui produit la réponse de timeout, en contournant
sa_handleretsigreturn
- Pour implémenter les timeouts de requête, il faut envoyer
-
proc_info()et limitation du nombre de processus enfants- Apple dispose d’un appel système peu documenté,
proc_info()336, qui permet d’obtenir des informations sur les processus en cours d’exécution et leurs enfants - Cet appel est généralement utilisé par des outils comme
ps,lsofoutop - ymawky utilise
proc_info()pour compter le nombre de processus enfants actifs - Comme le nombre maximal de connexions est configurable, il faut connaître le nombre d’enfants encore vivants
proc_info()écrit les informations sur les processus enfants dans un buffer et, comme la taille de chaque élément est connue, il est possible de déduire le nombre d’enfants à partir du nombre d’octets écrits- Si le nombre d’enfants dépasse
MAX_PROCS, les nouvelles connexions sont rejetées avec503 Service Unavailable
- Apple dispose d’un appel système peu documenté,
Conclusion et informations sur le projet
- Dans un serveur web statique, le plus difficile n’était pas d’ouvrir un socket et d’appeler
listen, mais de parser les requêtes et de traiter tous les cas limites - Les requêtes, les chemins et les réponses ne sont que des octets ; les requêtes de plage doivent être exactes et les noms de fichiers doivent être échappés différemment selon l’endroit où ils apparaissent
- L’assembleur impose d’écrire soi-même tout ce qui touche au parsing des requêtes, à la gestion mémoire, à la gestion d’erreurs, à la conversion de chaînes, aux timeouts et à la sécurité des fichiers
- ymawky est maintenu par imtomt
1 commentaires
Avis sur Lobste.rs
Impressionnant. J’ai déjà travaillé avec une petite entreprise qui fabriquait des appareils intelligents, et son unique ingénieur ne connaissait que l’assembleur
Tout, du code de contrôle matériel au système d’exploitation du serveur, jusqu’à l’API web JSON que nous utilisions, était entièrement écrit à la main en assembleur
Un jour, nous avons rencontré un bug où l’API web renvoyait les données du mauvais appareil ; après vérification, il s’est avéré qu’une erreur de décalage d’une unité dans le système d’ordonnancement de l’OS faisait que la « base de données » renvoyait la mauvaise ligne au service web
Quand on aborde des expressions comme le « suicide », j’aimerais vraiment qu’on mette un avertissement de contenu. Mieux encore, il vaudrait mieux ne pas en parler du tout
J’ai cherché à nouveau après avoir vu ce commentaire, mais je ne la trouve toujours pas ; est-ce que j’ai raté quelque chose ?
Le fait que ce soit « entièrement écrit en assembleur » m’a rappelé le rapport d’enquête sur le Therac-25