1 points par GN⁺ 4 시간 전 | 1 commentaires | Partager sur WhatsApp
  • 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 sur Content-Length sont 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, cmp correspondent directement aux octets du binaire exécutable
  • svc #0x80 est la forme lisible par un humain des octets D4 00 10 01 dans 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 struct de 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é dans x8
    • Le numéro d’appel système de open() est 5, et le noyau est appelé via svc #0x80 aprè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 comme b.cs open_failed pour 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, PUT ou DELETE
    • 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 PUT dans 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

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 cible index.html et la version HTTP HTTP/1.0
    • \r\n marque la fin d’une ligne, et \r\n\r\n la fin des en-têtes
    • Si \r\n\r\n n’est pas reçu, il faut arrêter le traitement avec 400 Bad Request
  • 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 / de HTTP/1.0 avec un chemin
    • Par exemple, GET HTTP/1.0\r\n\r\n contient un / dans HTTP/1.0 ; si l’octet précédent n’est pas un espace, il faut renvoyer 400 Bad Request
    • Comme PATH_MAX vaut 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 avec filename_buffer: .skip 4097
    • Si le chemin demandé est plus long que le buffer, il faut renvoyer 414 URI Too Long plutô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 de 0-9, a-f, A-F, puis les convertir en la valeur d’octet correspondante
    • GET peut avoir un en-tête Range: et PUT exige Content-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 \r n’est pas suivi de \n, ou si \n apparaît sans \r avant lui, les en-têtes sont invalides et il faut renvoyer 400 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
  • Comparaison de chaînes et conversion de nombres

    • Pour trouver Range: ou Content-Length:, il faut écrire une fonction streqn qui reçoit deux pointeurs de chaîne x0, x1 et une longueur maximale x2, 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 deux
      Range: bytes=10-
      Range: bytes=-10
      Range: bytes=5-10
      
    • Comme les valeurs de plage sont des chaînes, il faut une fonction de style atoi pour 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

Traitement de PUT et stratégie de fichier temporaire

  • PUT est une méthode idempotente : envoyer plusieurs fois la même requête produit le même état final côté serveur
  • PUT /file.txt crée file.txt ou écrase entièrement le fichier existant, et si l’on envoie 1234 deux fois, le contenu final du fichier est 1234, pas 12341234
  • Autoriser globalement PUT peut ê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-Length de 2 Ko mais n’envoie que 100 octets
    • le client envoie un Content-Length énorme, par exemple 50 Go
  • MAX_BODY_SIZE dans config.S vaut 1 Go par défaut, et si Content-Length dépasse cette limite, le serveur renvoie 413 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éro 20, puis le convertit en chaîne via un itoa() 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() 10 ou unlinkat() 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 si ALLOW_DIR_LISTING est activé dans config.S
  • Si le listing de répertoires est désactivé, le serveur renvoie 403 Forbidden
  • S’il est activé, l’appel système getdirentries64() 344 remplit 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, le href devient %26.-~%3E%3Cfoo et le texte affiché devient &amp;.-~&gt;&lt;foo, ce qui donne au final
    <a href="%26.-~%3E%3Cfoo">&amp;.-~&gt;&lt;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 zone href="...", 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_SECS défini dans config.S, le serveur envoie 408 Request Timeout puis 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_TIMEOUT dans config.S
  • Un simple timeout par opération de lecture ne suffit pas
    • Un client malveillant peut envoyer Content-Length: 1073741823 puis 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
  • Pour réduire ce risque, ymawky calcule un timeout à partir de Content-Length et d’un débit minimal en octets par seconde
    timeout = grace_period + content_length / min_bps
    
  • grace_period est le temps minimal accordé à tous les corps de requête, et min_bps la vitesse de transfert la plus lente autorisée par le serveur
  • La valeur par défaut de min_bps est 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 GET et HEAD, ymawky ouvre d’abord le chemin demandé, puis exécute l’appel système fstat64() 339 sur 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() 338 sur 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
  • 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 par DEFAULT_DIR dans config.S
    • Une requête vers /etc/shadow devient www/etc/shadow, ce qui aboutit à un 404 tant que www/etc/shadow n’existe pas réellement
    • En revanche, /../../../../etc/shadow devient www/../../../../etc/shadow et 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%2E devient .. 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_NOFOLLOW fait échouer open() si le dernier composant du chemin est un lien symbolique
    • Sur Darwin, O_NOFOLLOW_ANY fait é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

Comportements spécifiques à Apple

  • Gestion des timeouts et sigaction()

    • Pour implémenter les timeouts de requête, il faut envoyer SIGALRM après un certain délai via l’appel système setitimer() 83
    • Par défaut, SIGALRM tue le processus enfant, mais ymawky doit d’abord envoyer 408 Request Timeout
    • Pour cela, il utilise l’appel système sigaction() 46
    • La structure sigaction brute de Darwin expose le champ sa_tramp
    • En général, c’est la libc qui renseigne sa_tramp pour sauvegarder la pile et les registres, préparer sigreturn, 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_handler et sigreturn
  • 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, lsof ou top
    • 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 avec 503 Service Unavailable

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

 
GN⁺ 4 시간 전
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

    • Est-ce que cette personne ne s’appelait pas par hasard Mel ?
  • 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

    • Hein ? J’ai lu certaines parties en diagonale, mais je n’ai pas vu de mention du suicide à la première lecture
      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 ?
    • Les gens totalement dépourvus de sens de l’humour sont en réalité bien plus dangereux, pour leur propre santé comme pour la société dans son ensemble
  • Le fait que ce soit « entièrement écrit en assembleur » m’a rappelé le rapport d’enquête sur le Therac-25