Einen Webserver in aarch64-Assembly bauen, um meinem Leben etwas mehr (oder weniger) Sinn zu geben
(imtomt.github.io)- 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 vonContent-Lengthangewendet - Bei
PUTwird 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,cmpentsprechen direkt den Bytes der ausführbaren Binärdatei svc #0x80ist die menschenlesbare Form der BytesD4 00 10 01in der ausführbaren Datei- Es gibt keinen String-Typ, daher sind Strings einfach zusammenhängende Bytebereiche im Speicher; auch Sprachfunktionen wie C-
structexistieren 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
x16gelegt, unter Linux aarch64 inx8 - Die Systemaufrufnummer von
open()ist5; Argumente wie Dateiname und Modus werden direkt in Register gelegt und dann persvc #0x80an den Kernel übergeben - Wenn
open()fehlschlägt, wird das carry flag gesetzt, und über eine Prüfung wieb.cs open_failedwird 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,PUToderDELETEist - Der Request-Pfad wird extrahiert und Prozentkodierung wie
%20wird 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
PUTwird 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
- Es wird bestimmt, ob die Request-Methode
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 Zieldateiindex.htmlund die HTTP-VersionHTTP/1.0 \r\nmarkiert das Zeilenende, und\r\n\r\nmarkiert das Ende der Header- Wenn
\r\n\r\nnicht empfangen wird, muss mit400 Bad Requestabgebrochen werden
- Ein HTTP-Request ist ein String, den der Server interpretieren muss; ein Beispiel sieht so aus
-
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/inHTTP/1.0nicht fälschlich als Pfad erkannt wird - Beispielsweise enthält
GET HTTP/1.0\r\n\r\nein/inHTTP/1.0; ist das vorherige Byte kein Leerzeichen, wird400 Bad Requestzurückgegeben - Da
PATH_MAXauf den meisten Systemen 4096 Byte beträgt, verwendet ymawkyfilename_buffer: .skip 4097fü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 Longzurü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 aus0-9,a-f,A-Fsind, und dann in den entsprechenden Bytewert umgewandelt GETkann einenRange:-Header haben,PUTbenötigtContent-Length:- Diese Header stehen nicht an festen Positionen wie die Request-URL, daher muss der komplette Header zeichenweise durchlaufen werden
- Wenn auf
\rkein\nfolgt oder ein\nohne vorheriges\rauftaucht, gilt der Header als ungültig und es wird400 Bad Requestzurückgegeben - Wenn eine neue Header-Zeile mit einem Leerzeichen beginnt, wird ebenfalls
400 Bad Requestzurückgegeben, da Header-Felder nicht mit Leerraum beginnen dürfen
- Wenn im Pfad
-
Stringvergleich und Zahlenumwandlung
- Um
Range:oderContent-Length:zu finden, wird eine Funktionstreqngeschrieben, die zwei String-Zeigerx0,x1und eine maximale Längex2entgegennimmt und Zeichen für Zeichen vergleicht - Ein
Range:-Header kann wie folgt Anfang oder Ende weglassen, aber mindestens eines von beiden muss vorhanden seinRange: 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
- Um
PUT-Verarbeitung und Strategie mit temporären Dateien
PUTist eine idempotente Methode, bei der mehrfache identische Requests zum gleichen Endzustand des Servers führenPUT /file.txterstelltfile.txtoder überschreibt eine bestehende Datei vollständig; selbst wenn1234zweimal gesendet wird, ist der Dateiinhalt1234und nicht12341234- Ein global offen zugelassenes
PUTkann 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-Length2 KB, sendet aber nur 100 Byte - Der Client sendet einen sehr großen
Content-Length-Wert wie 50 GB
MAX_BODY_SIZEinconfig.Sist standardmäßig 1 GB; wennContent-Lengthdarüber liegt, wird413 Content Too Largezurü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()Nummer20wird die pid geholt und mit einer benutzerdefiniertenitoa()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()Nummer10oderunlinkat()Nummer472gelö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, obALLOW_DIR_LISTINGinconfig.Saktiviert ist - Wenn Verzeichnislisten deaktiviert sind, wird
403 Forbiddenzurückgegeben - Wenn sie aktiviert sind, wird der Dateiinformationspuffer des angeforderten Verzeichnisses mit dem Systemaufruf
getdirentries64()Nummer344gefü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
&.-~><fooist, wird daraus imhref%26.-~%3E%3Cfoound als sichtbarer Text&.-~><foo; die endgültige Ausgabe ist also<a href="%26.-~%3E%3Cfoo">&.-~><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 Bereichhref="..."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_SECSausconfig.Sempfangen wird, wird408 Request Timeoutgesendet und die Verbindung geschlossen - Wenn der Client beim Empfang des Request-Bodys zu lange keine Daten sendet, wird entsprechend
RECV_TIMEOUTinconfig.Sgenauso verfahren - Ein einfaches Timeout pro Lesevorgang reicht jedoch nicht aus
- Ein bösartiger Client kann
Content-Length: 1073741823senden 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
- Ein bösartiger Client kann
- Um das abzumildern, berechnet ymawky ein Timeout auf Basis von
Content-Lengthund einer minimalen Bytezahl pro Sekundetimeout = grace_period + content_length / min_bps grace_periodist die minimale Zeit, die jedem Body gewährt wird, undmin_bpsist die langsamste Übertragungsrate, die der Server erlaubt- Der Standardwert für
min_bpsist 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
GETundHEADwird zunächst der angeforderte Pfad geöffnet und dannfstat64()mit Systemaufrufnummer339auf dem Dateideskriptor ausgeführt, um Informationen wie Dateityp und Größe zu erhalten - Würde man zuerst
stat64()mit Systemaufrufnummer338auf 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
- Bei
-
docroot und Schutz vor Pfad-Traversal
- Vor jeden angeforderten Pfad wird das docroot gesetzt
- Das Standard-docroot ist
www/, definiert alsDEFAULT_DIRinconfig.S - Ein Request auf
/etc/shadowwird dadurch zuwww/etc/shadowund ergibt 404, sofernwww/etc/shadownicht tatsächlich existiert - Doch
/../../../../etc/shadowwürde zuwww/../../../../etc/shadowund 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%2Enach der Dekodierung zu..wird, muss diese Prüfung nach der Prozentdekodierung stattfinden
-
Umgang mit symbolischen Links
- Das POSIX-Flag
O_NOFOLLOWsorgt dafür, dassopen()fehlschlägt, wenn die letzte Pfadkomponente ein symbolischer Link ist - Darwins
O_NOFOLLOW_ANYlä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
- Das POSIX-Flag
Apple-spezifisches Verhalten
-
Timeout-Behandlung und
sigaction()- Um Request-Timeouts zu implementieren, muss per Systemaufruf
setitimer()Nummer83nach einer bestimmten ZeitSIGALRMgesendet werden - Standardmäßig würde
SIGALRMden Child-Prozess beenden, aber ymawky muss zuerst408 Request Timeoutsenden - Dafür wird der Systemaufruf
sigaction()Nummer46verwendet - Die rohe
sigaction-Struktur von Darwin legt das Feldsa_trampoffen - Normalerweise setzt libc
sa_tramp, speichert Stack und Register, bereitetsigreturnvor 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_handlersowiesigreturn
- Um Request-Timeouts zu implementieren, muss per Systemaufruf
-
proc_info()und Begrenzung der Zahl von Child-Prozessen- Apple besitzt den nur spärlich dokumentierten Systemaufruf
proc_info()Nummer336, mit dem Informationen über laufende Prozesse und deren Kindprozesse abgefragt werden können - Dieser Aufruf wird üblicherweise von Werkzeugen wie
ps,lsofundtopverwendet - 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 mit503 Service Unavailableabgewiesen
- Apple besitzt den nur spärlich dokumentierten Systemaufruf
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
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
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
Nach diesem Kommentar habe ich noch einmal danach gesucht und finde es immer noch nicht; habe ich etwas übersehen?
Die Aussage, dass es „komplett in Assembler geschrieben“ sei, erinnert mich an den Therac-25-Untersuchungsbericht