1 Punkte von GN⁺ 5 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • ymawky ist ein kleiner statischer HTTP-Server für macOS, der ausschließlich in aarch64-Assembly geschrieben ist und nur rohe Darwin-Systemaufrufe ohne libc-Wrapper verwendet
  • Unterstützt GET, HEAD, PUT, OPTIONS, DELETE, Byte-Range-Requests, Verzeichnisauflistungen und benutzerdefinierte Fehlerseiten, ist aber kein Ersatz für nginx, sondern eine Implementierung, die Komfortschichten entfernt, um zu verstehen, wie ein Webserver funktioniert
  • Request-Parsing, Prozentdekodierung, Header-Prüfung, Umwandlung von Range-Werten, Fehlerbehandlung, Schließen von Dateien und Antworterzeugung muss alles von Hand geschrieben werden; selbst etwas, das in Python einem einfachen String-Split oder int(string) entspricht, wird in Assembly zu Dutzenden bis Hunderten Zeilen Validierungscode
  • Der Server verwendet für jede neue Verbindung eine fork-on-request-Architektur mit fork(), was die Implementierung vereinfacht, aber den Durchsatz bei gleichzeitigen Verbindungen senkt und anfällig für slowloris machen kann; deshalb werden Header-Timeouts und Body-Timeouts auf Basis von Content-Length angewendet
  • Bei PUT wird zuerst in eine temporäre Datei .ymawky_tmp_<pid> geschrieben und erst bei Erfolg ersetzt; außerdem werden Dateisystemsicherheit wie Schutz vor Pfad-Traversal, O_NOFOLLOW_ANY, fstat64() sowie URL-Encoding und HTML-Escaping bei Verzeichnislisten direkt selbst umgesetzt

Überblick und Einschränkungen von ymawky

  • ymawky ist ein kleiner statischer HTTP-Server für macOS, der vollständig in aarch64-Assembly geschrieben ist
  • Er verwendet nur rohe Darwin-Systemaufrufe ohne libc-Wrapper und nutzt weder externe Bibliotheken noch vorhandene Parser
  • Unterstützte Funktionen sind GET, HEAD, PUT, OPTIONS, DELETE, Byte-Range-Requests, Verzeichnisauflistungen und benutzerdefinierte Fehlerseiten
  • Die Projektbeschränkungen sind wie folgt
    • nur aarch64 assembly
    • Zielplattform macOS/Darwin
    • nur raw syscalls, keine libc wrappers
    • nur statische Dateien
    • keine vorgefertigten Parser
    • keine externen Bibliotheken
  • Das Ziel ist nicht, nginx zu ersetzen, sondern durch das Entfernen von Komfortschichten zu verstehen, wie ein Webserver tatsächlich arbeitet

Was man braucht, um einen Webserver in Assembly zu bauen

  • Assembly ist die Schicht zwischen Maschinencode und Hochsprachen; Befehle wie mov, add, ldr, str, cmp entsprechen direkt den Bytes der ausführbaren Binärdatei
  • svc #0x80 ist die menschenlesbare Form der Bytes D4 00 10 01 in der ausführbaren Datei
  • Es gibt keinen String-Typ, daher sind Strings einfach zusammenhängende Bytebereiche im Speicher; auch Sprachfunktionen wie C-struct existieren nicht, sodass Feld-Offsets und Gesamtgrößen direkt bekannt sein müssen
  • Da es keine HTTP-Bibliothek, keine automatische Bereinigung, keine Exceptions und keine Objekte gibt, muss alles wie Request-Parsing, Fehlerbehandlung, Dateischließen und Antworterzeugung selbst geschrieben werden
  • Selbst wenn etwas falsch läuft, macht die CPU einfach ohne Warnung weiter; das Problem liegt dann in den geschriebenen Befehlen und Speicherzugriffen

Rohe Systemaufrufe und Server-Ablauf

  • Darwin-Systemaufrufe

    • ymawky ruft statt libc-Wrappern direkt den Kernel auf
    • Unter Darwin aarch64 wird die Systemaufrufnummer in das Register x16 gelegt, unter Linux aarch64 in x8
    • Die Systemaufrufnummer von open() ist 5; Argumente wie Dateiname und Modus werden direkt in Register gelegt und dann per svc #0x80 an den Kernel übergeben
    • Wenn open() fehlschlägt, wird das carry flag gesetzt, und über eine Prüfung wie b.cs open_failed wird in den Fehlerbehandlungspfad verzweigt
  • Grundlegendes Serververhalten

    • Der Grundablauf eines Webservers besteht darin, einen Request entgegenzunehmen, ihn zu verarbeiten und Statuscode sowie gegebenenfalls Dateien zurückzugeben
    • Das Aufsetzen des Sockets besteht aus Schritten wie socket(AF_INET, SOCK_STREAM, 0), setsockopt(... SO_REUSEADDR ...), bind(sockfd, &addr, 16), listen(sockfd, 5), accept(sockfd, NULL, NULL)
    • ymawky ist ein fork-on-request-Server, der für jede neue Verbindung fork() aufruft
    • Dieser Ansatz ist leicht zu verstehen und zu implementieren, weil Requests keinen Speicher teilen, verursacht aber wegen getrennter Prozessspeicherräume mehr Overhead und erreicht weniger gleichzeitige Verbindungen als das ereignisbasierte asynchrone Non-Blocking-Modell von nginx
    • Mit zunehmender Zahl gleichzeitiger Verbindungen verbringt der Kernel mehr Zeit mit Prozesswechseln als mit der eigentlichen Ausführung innerhalb der Prozesse
  • Aufgaben bei der Request-Verarbeitung

    • Es wird bestimmt, ob die Request-Methode GET, HEAD, OPTIONS, PUT oder DELETE ist
    • Der Request-Pfad wird extrahiert und Prozentkodierung wie %20 wird dekodiert
    • Es werden Pfadsicherheitsprüfungen durchgeführt und die vom Client gesendeten Header-Felder geparst
    • Dateiinformationen zum angeforderten Pfad werden geholt und es wird unterschieden, ob es sich um ein Verzeichnis oder eine reguläre Datei handelt
    • Der Request-Body von PUT wird in eine temporäre Datei geschrieben, und Antwort-Header sowie Body werden erzeugt
    • Geöffnete Dateien werden geschlossen und Fehler werden so behandelt, dass der Server nicht abstürzt

HTTP-Parsing direkt implementieren

  • Request-Zeile und Header-Ende

    • Ein HTTP-Request ist ein String, den der Server interpretieren muss; ein Beispiel sieht so aus
      GET /index.html HTTP/1.0\r\n
      Range: bytes=1-5\r\n\r\n
      
    • Die erste Zeile enthält den GET-Request, die Zieldatei index.html und die HTTP-Version HTTP/1.0
    • \r\n markiert das Zeilenende, und \r\n\r\n markiert das Ende der Header
    • Wenn \r\n\r\n nicht empfangen wird, muss mit 400 Bad Request abgebrochen werden
  • Pfadextraktion

    • ymawky erkennt den Request-Typ, indem es unterstützte Methoden und die ersten Bytes vergleicht, und extrahiert dann den Pfad
    • Es durchsucht den Header Byte für Byte nach / oder *, prüft aber, ob das Byte direkt vor / ein Leerzeichen ist, damit / in HTTP/1.0 nicht fälschlich als Pfad erkannt wird
    • Beispielsweise enthält GET HTTP/1.0\r\n\r\n ein / in HTTP/1.0; ist das vorherige Byte kein Leerzeichen, wird 400 Bad Request zurückgegeben
    • Da PATH_MAX auf den meisten Systemen 4096 Byte beträgt, verwendet ymawky filename_buffer: .skip 4097 für einen Dateinamenpuffer von 4096 Byte plus ein Byte für das Null-Terminierungszeichen
    • Ist der angeforderte Pfad länger als der Puffer, muss 414 URI Too Long zurückgegeben werden, statt beliebigen Speicher zu überschreiben
    • Eine Aufgabe, die in Python etwa text.split("GET /")[1].split(" ")[0] entspricht, wird in Assembly inklusive HTTP-Gültigkeitsprüfung zu rund 200 Zeilen
  • Prozentdekodierung und Header-Feld-Prüfung

    • Wenn im Pfad % auftaucht, wird geprüft, ob die nächsten zwei Bytes gültige Hex-Zeichen aus 0-9, a-f, A-F sind, und dann in den entsprechenden Bytewert umgewandelt
    • GET kann einen Range:-Header haben, PUT benötigt Content-Length:
    • Diese Header stehen nicht an festen Positionen wie die Request-URL, daher muss der komplette Header zeichenweise durchlaufen werden
    • Wenn auf \r kein \n folgt oder ein \n ohne vorheriges \r auftaucht, gilt der Header als ungültig und es wird 400 Bad Request zurückgegeben
    • Wenn eine neue Header-Zeile mit einem Leerzeichen beginnt, wird ebenfalls 400 Bad Request zurückgegeben, da Header-Felder nicht mit Leerraum beginnen dürfen
  • Stringvergleich und Zahlenumwandlung

    • Um Range: oder Content-Length: zu finden, wird eine Funktion streqn geschrieben, die zwei String-Zeiger x0, x1 und eine maximale Länge x2 entgegennimmt und Zeichen für Zeichen vergleicht
    • Ein Range:-Header kann wie folgt Anfang oder Ende weglassen, aber mindestens eines von beiden muss vorhanden sein
      Range: bytes=10-
      Range: bytes=-10
      Range: bytes=5-10
      
    • Da die Range-Werte Strings sind, wird eine atoi-artige Funktion benötigt, die ASCII-Ziffern in Integer umwandelt
    • Um Overflow in 64-Bit-Registern zu vermeiden, werden Zahlen mit 19 oder mehr Stellen als Fehler behandelt
    • Selbst etwas, das in Python int(string) entspricht, erfordert in Assembly die direkte Implementierung von Zahlenprüfung, Multiplikation, Addition und Erfolgs-/Fehlersignalen auf Basis des carry flag

PUT-Verarbeitung und Strategie mit temporären Dateien

  • PUT ist eine idempotente Methode, bei der mehrfache identische Requests zum gleichen Endzustand des Servers führen
  • PUT /file.txt erstellt file.txt oder überschreibt eine bestehende Datei vollständig; selbst wenn 1234 zweimal gesendet wird, ist der Dateiinhalt 1234 und nicht 12341234
  • Ein global offen zugelassenes PUT kann gefährlich sein; bei der Verarbeitung müssen unter anderem folgende Probleme bedacht werden
    • Der Prozess stürzt während der Request-Verarbeitung ab
    • Der Client behauptet bei Content-Length 2 KB, sendet aber nur 100 Byte
    • Der Client sendet einen sehr großen Content-Length-Wert wie 50 GB
  • MAX_BODY_SIZE in config.S ist standardmäßig 1 GB; wenn Content-Length darüber liegt, wird 413 Content Too Large zurückgegeben
  • Wenn direkt in die bestehende Datei geschrieben würde, könnte bei einem Fehler eine halb geschriebene Datei zurückbleiben; deshalb schreibt ymawky zuerst in eine temporäre Datei im Format .ymawky_tmp_<pid>
  • Mit dem Systemaufruf getpid() Nummer 20 wird die pid geholt und mit einer benutzerdefinierten itoa() in einen String umgewandelt, wobei auf Pufferüberlauf geprüft wird
  • Wenn der komplette Client-Body erfolgreich in die temporäre Datei geschrieben wurde, wird sie auf den endgültigen Dateinamen umbenannt, wodurch die angeforderte Datei auf dem Server entsteht
  • Wenn der Client die Verbindung unerwartet trennt, ein Timeout auftritt oder ein ungültiger Body gesendet wird, wird die temporäre Datei über den Systemaufruf unlink() Nummer 10 oder unlinkat() Nummer 472 gelöscht
  • Eine bestehende Datei wird erst überschrieben, nachdem ein vollständiger Request erfolgreich übertragen wurde

Verzeichnislisten und Escaping

  • Bei einem GET /somedir/-Request wird geprüft, ob ALLOW_DIR_LISTING in config.S aktiviert ist
  • Wenn Verzeichnislisten deaktiviert sind, wird 403 Forbidden zurückgegeben
  • Wenn sie aktiviert sind, wird der Dateiinformationspuffer des angeforderten Verzeichnisses mit dem Systemaufruf getdirentries64() Nummer 344 gefüllt
  • Der Puffer enthält für jede Datei den Dateinamen und dessen Länge; ymawky verwendet diese Daten, um klickbares HTML zu erzeugen
  • Die Grundform für jede Datei, die an den Client gesendet wird, ist
    <a href="filename">filename</a>
    
  • Innerhalb von href="..." muss der Dateiname als URL-Pfadsegment prozentkodiert werden, während der sichtbare Text im Dokument HTML-escaped werden muss
  • Wenn der Dateiname &.-~><foo ist, wird daraus im href %26.-~%3E%3Cfoo und als sichtbarer Text &amp;.-~&gt;&lt;foo; die endgültige Ausgabe ist also
    <a href="%26.-~%3E%3Cfoo">&amp;.-~&gt;&lt;foo</a>
    
  • Selbst Namen wie <script>something evil</script>, die im sichtbaren Bereich XSS auslösen könnten, oder "><script>something dastardly</script>, die XSS im Bereich href="..." ermöglichen könnten, werden so kodiert, dass sie nicht ausgeführt werden

Netzwerksicherheit und Timeouts

  • slowloris ist ein Denial-of-Service-Angriff, bei dem viele Verbindungen offen gehalten werden, ohne den Request zu beenden, sodass Serverressourcen blockiert bleiben
  • Da ymawky auf einer fork-on-request-Architektur basiert, kann es für slowloris anfällig sein
  • Wenn der komplette Header nicht innerhalb von HEADER_REQ_TIMEOUT_SECS aus config.S empfangen wird, wird 408 Request Timeout gesendet und die Verbindung geschlossen
  • Wenn der Client beim Empfang des Request-Bodys zu lange keine Daten sendet, wird entsprechend RECV_TIMEOUT in config.S genauso verfahren
  • Ein einfaches Timeout pro Lesevorgang reicht jedoch nicht aus
    • Ein bösartiger Client kann Content-Length: 1073741823 senden und dann alle 9 Sekunden nur 1 Byte schicken; weil die Content-Length nur 1 Byte unter dem Maximum liegt, wäre das erlaubt, und bei Timeouts in 10-Sekunden-Schritten könnte der Server über 300 Jahre warten
  • Um das abzumildern, berechnet ymawky ein Timeout auf Basis von Content-Length und einer minimalen Bytezahl pro Sekunde
    timeout = grace_period + content_length / min_bps
    
  • grace_period ist die minimale Zeit, die jedem Body gewährt wird, und min_bps ist die langsamste Übertragungsrate, die der Server erlaubt
  • Der Standardwert für min_bps ist 16 KB/s; großzügig, aber nicht unbegrenzt
  • Diese Methode verhindert Denial-of-Service-Angriffe nicht vollständig, begrenzt aber die Zeit, in der bestimmte Angriffe Ressourcen blockieren können

Dateisystemsicherheit

  • Reihenfolge bei der Dateiinformationsprüfung

    • Bei GET und HEAD wird zunächst der angeforderte Pfad geöffnet und dann fstat64() mit Systemaufrufnummer 339 auf dem Dateideskriptor ausgeführt, um Informationen wie Dateityp und Größe zu erhalten
    • Würde man zuerst stat64() mit Systemaufrufnummer 338 auf dem Pfad ausführen und danach die Datei öffnen, könnte zwischen Prüfung und Nutzung eine TOCTOU race condition entstehen, bei der sich die Datei verändert
  • docroot und Schutz vor Pfad-Traversal

    • Vor jeden angeforderten Pfad wird das docroot gesetzt
    • Das Standard-docroot ist www/, definiert als DEFAULT_DIR in config.S
    • Ein Request auf /etc/shadow wird dadurch zu www/etc/shadow und ergibt 404, sofern www/etc/shadow nicht tatsächlich existiert
    • Doch /../../../../etc/shadow würde zu www/../../../../etc/shadow und könnte außerhalb des docroot aufgelöst werden, weshalb zusätzlicher Schutz nötig ist
    • ymawky lehnt nicht pauschal jeden Pfad mit der Zeichenfolge .. ab, sondern nur dann, wenn ein Pfadsegment exakt .. ist
    • Da %2E%2E nach der Dekodierung zu .. wird, muss diese Prüfung nach der Prozentdekodierung stattfinden
  • Umgang mit symbolischen Links

    • Das POSIX-Flag O_NOFOLLOW sorgt dafür, dass open() fehlschlägt, wenn die letzte Pfadkomponente ein symbolischer Link ist
    • Darwins O_NOFOLLOW_ANY lässt den Aufruf fehlschlagen, wenn irgendeine Pfadkomponente ein symbolischer Link ist
    • Wenn jemand innerhalb des docroot gezielt bestimmte symbolische Links platzieren kann, gibt es vermutlich ohnehin schon ein anderes Problem, aber dieses Flag bietet eine zusätzliche Schutzschicht

Apple-spezifisches Verhalten

  • Timeout-Behandlung und sigaction()

    • Um Request-Timeouts zu implementieren, muss per Systemaufruf setitimer() Nummer 83 nach einer bestimmten Zeit SIGALRM gesendet werden
    • Standardmäßig würde SIGALRM den Child-Prozess beenden, aber ymawky muss zuerst 408 Request Timeout senden
    • Dafür wird der Systemaufruf sigaction() Nummer 46 verwendet
    • Die rohe sigaction-Struktur von Darwin legt das Feld sa_tramp offen
    • Normalerweise setzt libc sa_tramp, speichert Stack und Register, bereitet sigreturn vor und verzweigt dann zum Handler
    • Der Timeout-Handler von ymawky sendet 408 Request Timeout, schließt die nötigen Ressourcen und beendet den Child-Prozess, sodass keine Rückkehr notwendig ist
    • Daher zeigt der Trampoline-Slot direkt auf Code, der die Timeout-Antwort ausführt, und umgeht sa_handler sowie sigreturn
  • proc_info() und Begrenzung der Zahl von Child-Prozessen

    • Apple besitzt den nur spärlich dokumentierten Systemaufruf proc_info() Nummer 336, mit dem Informationen über laufende Prozesse und deren Kindprozesse abgefragt werden können
    • Dieser Aufruf wird üblicherweise von Werkzeugen wie ps, lsof und top verwendet
    • ymawky nutzt proc_info(), um die Zahl aktiver Child-Prozesse zu zählen
    • Da die maximale Zahl gleichzeitiger Verbindungen konfigurierbar ist, muss die Zahl lebender Child-Prozesse bekannt sein
    • proc_info() schreibt Informationen über Child-Prozesse in einen Puffer; da die Größe jedes Elements bekannt ist, kann die Zahl der Child-Prozesse aus den geschriebenen Bytes berechnet werden
    • Wenn die Zahl der Child-Prozesse MAX_PROCS überschreitet, werden neue Verbindungen mit 503 Service Unavailable abgewiesen

Fazit und Projektinformationen

  • Die schwierigen Teile eines statischen Webservers lagen nicht im Öffnen von Sockets und listen, sondern im Parsing von Requests und in der Behandlung aller Randfälle
  • Requests, Pfade und Antworten sind letztlich nur Bytes; Range-Requests müssen exakt sein, und Dateinamen müssen je nach Position unterschiedlich escaped werden
  • Assembly zwingt dazu, Request-Parsing, Speicherverwaltung, Fehlerbehandlung, String-Umwandlung, Timeouts und Dateisicherheit vollständig selbst zu schreiben
  • ymawky wird von imtomt gepflegt

1 Kommentare

 
GN⁺ 5 시간 전
Lobste.rs-Kommentare
  • Beeindruckend. Ich habe früher an der Anbindung eines kleinen Unternehmens gearbeitet, das Smart Devices herstellt, und der einzige Ingenieur dort konnte nur Assemblersprache
    Vom Hardware-Steuerungscode über das Server-Betriebssystem bis hin zur JSON-Web-API, die wir genutzt haben, war alles direkt in Assembler geschrieben
    Einmal stießen wir auf einen Bug, bei dem die Web-API Daten vom falschen Gerät zurückgab. Wie sich herausstellte, lag im Scheduling-System des Betriebssystems ein Off-by-one-Fehler vor, sodass die „Datenbank“ der Web-Service-Schicht die falsche Zeile zurücklieferte

    • Hieß die Person zufällig Mel?
  • Bei Ausdrücken wie „Selbstmord“ hätte ich bitte gern eine Content-Warnung. Noch besser wäre es, so etwas gar nicht erst zu erwähnen

    • Wie bitte? Ich habe Teile des Artikels nur überflogen, aber beim ersten Lesen keinen Bezug zu Selbstmord gesehen
      Nach diesem Kommentar habe ich noch einmal danach gesucht und finde es immer noch nicht; habe ich etwas übersehen?
    • Ein völliger Mangel an Sinn für Humor ist für die eigene Gesundheit und für die Gesellschaft insgesamt weitaus gefährlicher
  • Die Aussage, dass es „komplett in Assembler geschrieben“ sei, erinnert mich an den Therac-25-Untersuchungsbericht