Wie Sie einen E-Mail-Server mit einer einzigen E-Mail zum Absturz bringen
Joran Greef
1. August 2018
0 Min. LesezeitBei fünf der beliebtesten E-Mail-Parser für Node.js wurde kürzlich eine triviale Denial-of-Service-Schwachstelle (DoS) entdeckt. Sie lässt sich ausnutzen, indem mehrere Millionen leere Anhänge in eine E-Mail gepackt werden, die typische Größenbeschränkungen für E-Mails (üblicherweise 20 MB oder weniger) umgeht. Wird die E-Mail an einen verwundbaren E-Mail-Server gesendet, blockiert sie aufgrund der schieren Anzahl an Anhängen die Node.js-Ereignisschleife mehrere Sekunden lang. Der Speicherverbrauch steigt auf 2 GB oder mehr an, da für jeden Anhang interne Objekte erstellt werden. Das reicht üblicherweise aus, um den gesamten Server durch einen Absturz wegen Speichermangels lahmzulegen. Verarbeitet Ihr Node.js-Server also E-Mails? Wissen Sie, welchen E-Mail-Parser Sie verwenden? Bevor Sie nachsehen, schauen wir uns an, wen das betrifft.
Bevor wir weitermachen, hier das obligatorische XKCD.

Ein Denial-of-Service-Angriff sollte doch nicht so einfach sein, oder?
Die Schwachstelle lässt sich leicht erklären und ausnutzen und betrifft Tausende von Systemen. Die Bibliothek mailparser verzeichnet beispielsweise bis zu 249.400 Downloads pro Monat und wird von 214 weiteren Projekten als Abhängigkeit verwendet, darunter Sendgrid. Eine weitere betroffene Bibliothek ist Haraka, die unter anderem von Craigslist, Fort Anti-Spam und ThreatWave eingesetzt wurde.
Die Lösung ist eine einfache Ein-Zeilen-Änderung. Sie brauchen dafür keine Cloudflare. Sie müssen lediglich Benutzerdaten validieren. Dazu könnten Sie die Anzahl der Anhänge (einschließlich Textteilen) zählen und reagieren, wenn es mehr als etwa 1.000 sind. Wann haben Sie zuletzt eine E-Mail mit 10.000 Anhängen gesehen? Wann mussten Sie jemals 100.000 Anhänge versenden? Und wenn Sie nach einer Million Anhängen immer noch weiterparsen … nun, dann wissen Sie, dass Sie zu weit gegangen sind!
Moment mal, wie konnte uns das entgehen? Wenn die Schwachstelle so einfach zu beheben ist, wie wir behaupten, warum haben dann mindestens fünf Implementierungen denselben Fehler gemacht? Und wie haben wir die Schwachstelle entdeckt, und was können wir tun, um es besser zu machen?
Stellen Sie sich vor, Sie schreiben einen E-Mail-Parser …
Sie wissen, wie viele RFCs Sie lesen (und interpretieren) müssen. Sie wissen, wie viele Tests Sie schreiben müssen, um sicherzustellen, dass Sie die RFCs so konform wie möglich umsetzen. Sie kennen das Software-Mantra „Erst muss es funktionieren, dann muss es schnell sein“. Doch E-Mail-Parser sind schwierig, und Sie sind am Ende froh, wenn es überhaupt funktioniert. Sobald Sie einen geschrieben haben, verstehen Sie, warum Sie vielleicht nicht schnell eine Überschlagsrechnung anstellen, um abzuschätzen, wie viel Speicher ein einzelnes Multipart-Objekt in Ihrem Entwurf belegen könnte. Wenn Sie eine Komplexitätsanalyse durchführen:
messen Sie wahrscheinlich nur die CPU-Auslastung und nicht den Speicherverbrauch.\nWahrscheinlich messen Sie nicht den typischen Speicherbedarf.\nSie bleiben gegenüber SMTP-Umgebungen gleichgültig und versuchen daher nicht, Ihren Parser für schnelle Sonderfälle zu optimieren, obwohl 90 % der verarbeiteten E-Mails Spam sind.\nSie vermeiden zu viele strenge Richtlinien in Ihrem Parser.\nIhre Nutzerinnen und Nutzer könnten eine Begrenzung der Anhänge pro E-Mail als störend empfinden.\nSie würden lieber alles parsen und es den Nutzern überlassen, die E-Mail bei Bedarf während der SMTP-Transaktion abzulehnen. Schließlich sind Sie ja nicht die Administratorin oder der Administrator des E-Mail-Servers.
Stellen Sie sich vor, Sie betreiben einen E-Mail-Server …
Als Erstes legen Sie wahrscheinlich die Größenbeschränkung für E-Mails fest. Je größer die E-Mail, desto unwahrscheinlicher ist es, dass andere Server sie annehmen. Sie setzen die Größenbeschränkung auf nur 20 MB fest und denken, dass damit auch die Verarbeitungszeit in einem vernünftigen Rahmen bleibt. Sie entscheiden sich für einen beliebten, praxiserprobten E-Mail-Parser. Sie vertrauen darauf, dass die Komplexität Ihres E-Mail-Parsers linear ist, also O(N) bezogen auf die E-Mail-Größe. Sie messen die CPU-Auslastung bei einer maximalen E-Mail-Größe von 20 MB und erwarten, dass Ihr Server Tausende Nachrichten pro Sekunde verarbeiten kann. Sie gehen davon aus, dass alle 20-MB-E-Mails ungefähr gleich lange brauchen. Sie denken, dass schon 8 GB RAM für 200 gleichzeitig verarbeitete E-Mails mit jeweils 20 MB pro Sekunde ausreichen sollten, da Sie nicht erwarten, dass Ihre Nutzerbasis so schnell wächst. Wenn jemand mit Ihnen wetten würde, ob Ihr Server 10 gleichzeitige E-Mails mit jeweils 20 MB bewältigt, wären Sie zuversichtlich. Das Letzte, woran Sie denken, ist ein Anhang mit 0 Byte. Welchen Schaden hat eine leere Datei jemals angerichtet?
Und dann sehen Sie das:
Wie haben wir die Schwachstelle entdeckt?
Ronomon ist ein E-Mail-Startup in der geschlossenen Beta-Phase. Als ich anfing, hatte ich eine unangenehme Begegnung mit eingehenden E-Mails, die dazu führten, dass V8s Garbage Collector die Node.js-Ereignisschleife jeweils für mehrere zehn Sekunden blockierte. Zwei Tage meines Urlaubs verbrachte ich damit, alle paar Minuten Feature-Flags umzuschalten, um die Last zu reduzieren und herauszufinden, was vor sich ging. Schließlich kommentierte ich mit Hilfe von Vyacheslav Egorov die internen Abläufe der V8-Funktion CollectAllAvailableGarbage aus. Diese führte fröhlich eine willkürliche, siebenfache Speicherbereinigung eines riesigen, mehrere Gigabyte großen Heaps durch. Rückblickend war das eine großartige Lernerfahrung. Seitdem bin ich äußerst vorsichtig, wenn es darum geht, Objekte auf dem Heap zu allokieren und die Ereignisschleife zu blockieren.
Anfang letzten Jahres machte ich mich daran, einen neuen E-Mail-Parser zu schreiben, der inzwischen als @ronomon/mime Open Source veröffentlicht wurde. Mein Ziel war, ihn zehnmal schneller als den vorherigen Parser zu machen, mit möglichst wenigen Allokationen, Verarbeitung direkt auf Rohdatenpuffern, vollständiger RFC-Konformität und 100 % Testabdeckung einschließlich Fuzz-Tests. Das war schwer zu erreichen und erforderte unter anderem, Puffer-zu-String-Konvertierungen zu vermeiden und Fehlvorhersagen von Verzweigungen zu reduzieren, die durch Base64-Zeilenumbrüche nach 78 Zeichen verursacht werden.
Dabei lernte ich, dass manche Richtlinienentscheidungen besser im E-Mail-Parser als auf dem E-Mail-Server getroffen werden – und umgekehrt. Dazu gehörten Entscheidungen wie das Zurückweisen offensichtlich bösartiger, beschädigter oder abgeschnittener Base64-, Quoted-Printable- oder Zeichenkodierungen, das Zurückweisen von Duplikaten kritischer Header, die Begrenzung der Anzahl von Multipart-Teilen und die Begrenzung des Backtrackings aufgrund falsch positiver Multipart-Grenzen.
Anfang dieses Jahres nahm ich Kontakt zu Jamie Davis auf, der einen hervorragenden Leitfaden dazu geschrieben hat, wie man die Node.js-Ereignisschleife nicht blockiert. Jamie forschte gerade zu Angriffen auf Ereignisschleifen, und ich vermutete, dass einige der verfügbaren E-Mail-Parser für einen Multipart-Angriff anfällig sein könnten. Zu meiner Überraschung bestätigte sich diese Vermutung bei jedem Node.js-E-Mail-Parser, den ich getestet habe.
Zehn Dinge, die wir besser machen können
Hier sind zehn Tipps und bewährte Vorgehensweisen, die Sie berücksichtigen sollten:
DoS-Angriffe nutzen knappe Ressourcen aus. Zeigen Sie mechanisches Einfühlungsvermögen und denken Sie daran, dass Sie für eine Maschine schreiben. Gehen Sie sorgsam mit den mechanischen Ressourcen um, die Ihnen anvertraut wurden. Handeln Sie verantwortungsvoll. Verschwenden Sie nichts. Sorgen Sie nicht nur bei der CPU-Auslastung für O(N), sondern auch bei Speicherallokationen und jeder anderen Ressource, die Sie beanspruchen. Wenn Ihr Code CPU, Speicher, Festplatte und Netzwerk effizient nutzt, sind Sie weniger anfällig für Ressourcenknappheit und können eher vernünftige Grenzen für alle Systemressourcen festlegen.
Stellen Sie bei jedem Entwurf von Anfang an Überschlagsrechnungen für alle Ressourcendimensionen an. So erkennen Sie mangelhafte Entwürfe frühzeitig und vermeiden es, „unmögliche“ Implementierungen in Angriff zu nehmen. Leistung und Sicherheit lassen sich nicht nachträglich optimieren oder hinzufügen. Sie müssen von Anfang an eingeplant werden. Warten Sie nicht, bis Ihr Modul beliebt wird.
Verteilen Sie die Ressourcennutzung ausgewogen auf alle Dimensionen. Vielleicht haben Sie genügend CPU-Kapazität, um Ihre Durchsatzziele zu erreichen, aber geht Ihnen vorher der Speicher aus? Auch hier helfen Überschlagsrechnungen dabei, die Nutzung über alle Ressourcendimensionen hinweg im Verhältnis zu halten und Engpässe in Ihrem Entwurf zu vermeiden.
Denken Sie daran: Ein Leistungsproblem ist ein DoS-Angriff, der nur darauf wartet, einzutreten. Das gilt besonders, wenn Sie mit einer Ereignisschleife arbeiten. Wenn Nutzer das nächste Mal ein Leistungsproblem melden, sehen Sie darin eine Gelegenheit, ein Sicherheitsproblem zu verhindern.
Validieren Sie sämtliche Benutzerdaten – nicht nur, „wie viel“, sondern auch, „wie viele“. Oft müssen Sie gerade auf die kleinen Dinge achten, denn sie kommen häufig vor und lassen sich leichter vervielfachen und gegen Sie verstärken.
Fragen Sie sich immer wieder: Was halte ich für realistisch? Lassen Sie nichts zu, was zehnmal über Ihrer realistischen Schwelle liegt. Verankern Sie Ihre Erwartungen im Code, den Sie schreiben.
Achten Sie auf die Lücken zwischen Modulgrenzen. Gehen Sie nicht davon aus, dass „sich schon jemand anderes darum kümmern wird“. Lassen Sie Richtlinienentscheidungen nicht unter den Tisch fallen. Möglicherweise müssen Sie Ihre Abhängigkeiten besser verstehen.
Behandeln Sie offensichtlich fehlerhafte Daten wie Gift. Berühren Sie sie nicht einmal mit einer zehn Fuß langen Stange. Werden Sie sie so schnell wie möglich los.
Fragen Sie sich: Was würde ein böswilliger Nutzer tun? Überprüfen Sie Ihren Code nicht nur – versuchen Sie aktiv, ihn auszunutzen. Denken Sie mindestens drei Angriffsmöglichkeiten pro Modul durch und beheben Sie sie, bevor Sie es veröffentlichen. Setzen Sie sich ein Ziel und finden Sie sie. Sie sind immer da draußen. Sie werden überrascht sein.
Fuzz-Tests sind unglaublich einfallsreich. Schreiben Sie einfache eigene Fuzz-Tests, die ein zufälliges Spektrum gültiger und ungültiger Argumente erzeugen. Vergleichen Sie die Rückgabewerte Ihrer Funktion bei gültigen Eingaben mit einer anderen Implementierung, um die Korrektheit zu prüfen, und testen Sie bei ungültigen Eingaben, ob Ausnahmen ausgelöst werden. Fuzz-Tests, die Millionen von Permutationen von Funktionsargumenten durchlaufen, sind Linus’ Gesetz in seiner extremsten Form: Sie simulieren in wenigen Sekunden die Fähigkeit Tausender Augen, Fehler aufzuspüren.
Zeitplan der vertraulichen und öffentlichen Offenlegung
Die Schwachstelle wurde den Verantwortlichen der betroffenen Module am 23. April 2018 vertraulich gemeldet. Einige Tage vor Ablauf der 90-tägigen Frist für die öffentliche Offenlegung erhielten die Verantwortlichen die Möglichkeit, diese aus beliebigem Grund zu verschieben. Außerdem wurden die aktivsten abhängigen Module kontaktiert (sofern auf GitHub Kontaktdaten verfügbar waren) und auf die öffentliche Offenlegung am 25. Juni 2018 vorbereitet.
Vielen Dank an Karen Yavine, Simon Maple und Danny Grander von Snyk für die Unterstützung bei der öffentlichen Offenlegung, den Vorschlag für diesen Blogbeitrag und die weiteren Untersuchungen. Besonderer Dank gilt auch Matt Sergeant von Haraka für seine schnelle Reaktion.
haraka
https://snyk.io/vuln/npm:haraka:20180625
mailparser
https://snyk.io/vuln/npm:mailparser:20180625
emailjs-mime-parser
https://snyk.io/vuln/npm:emailjs-mime-parser:20180625
mailsplit
https://snyk.io/vuln/npm:mailsplit:20180625
mailparser-mit
https://snyk.io/vuln/npm:mailparser-mit:20180625
Starten Sie mit Capture the Flag
Erfahren Sie in unserem virtuellen On-Demand-Workshop für Einsteiger, wie Sie Capture-the-Flag-Herausforderungen lösen.
