Die Gewaltbereitschaft des Servers wählen
(cliffle.com)- Hubris ist ein OS, in dem isolierte Tasks per IPC kommunizieren; mit dem 13. Systemaufruf
REPLY_FAULTkann ein Server eine fehlerhafte Client-Anfrage nicht mit einem Fehlerwert beantworten, sondern per Fault beenden - Aus Sicht des Clients sieht IPC wie ein Funktionsaufruf aus, doch weil Tasks separat kompiliert werden, kann der Compiler falsche Operationscodes, nicht interpretierbare Bytes oder ungeeigneten loaned memory nicht vollständig verhindern
- Normale Hubris-Programme stoßen dank Build-Konfiguration und generiertem Rust-Code kaum auf solche Fehler; würde man für jeden Aufruf
Result<T, IpcError>undunwrap()erzwingen, nähmen Codegröße und Laufzeitkosten zu - Der Kernel tötet Tasks, die Vorbedingungen von Systemaufrufen verletzen, sofort ohne Fehlercode;
REPLY_FAULTerweitert dieselbe Fail-Fast-Policy auf Serverantworten - Dieses Design macht falsche API-Nutzung schnell sichtbar, erschwert aber Fuzz Tests oder Chaos-Tasks, die zufällige IPC- und Systemaufrufe senden, weil sie fast sofort neu gestartet werden
Hubris-IPC und die Rolle von REPLY_FAULT
- Hubris hat einen kleinen, anwendungsunabhängigen Kernel und legt den Großteil des Codes – etwa Treiber, Anwendungslogik und Netzwerk-Stack – in separat kompilierte, isolierte Tasks
- Die Kommunikation zwischen Tasks erfolgt über vom Kernel implementierte IPC-Systemaufrufe
RECV: Holt die empfangene Nachricht mit der höchsten Priorität ab oder blockiert, bis eine Nachricht eintrifftSEND: Hält den Aufrufer an, übergibt Nachricht und Kontrolle an den empfangenden Task und wartet dann auf eine AntwortREPLY: Übermittelt eine Antwort an den Task, der zuvorSENDaufgerufen hat, sodass dieser weiterlaufen kann
- Client und Server sind in Hubris keine festen Identitäten, sondern Rollen, die ein Task einnimmt
- Ein Task, der
SENDverwendet, nimmt die Client-Rolle ein - Ein Task, der
RECVundREPLYverwendet, nimmt die Server-Rolle ein - Ein Task kann gegenüber einem Task Server und gegenüber einem anderen Task Client sein
- Ein Task, der
Fehler, die der Compiler an Task-Grenzen übersieht
- Bei gewöhnlichen Funktionsaufrufen stellen Compiler und Linker Typen und Aufrufziel in erheblichem Maße sicher
- Wenn eine Rust-Funktion ein
String-Argument erwartet, verhindert der Compiler, dass der Aufrufer einboolübergibt - Auch eine Verwechslung des Ziels, etwa versehentlich
fire_missilesstattpet_cataufzurufen, kommt normalerweise nicht vor
- Wenn eine Rust-Funktion ein
- Hubris-IPC überschreitet Task-Grenzen, und jeder Task wird als separates Programm kompiliert; daher kann der Compiler die gesamten IPC-Beziehungen nicht direkt verifizieren
- Fehler, mit denen ein IPC-Server konfrontiert werden kann, fallen grob in drei Kategorien
- Ein Operationscode, der nicht zur Schnittstelle passt, etwa wenn bei einer Schnittstelle mit nur zwei Operationen „operation number 48“ eingeht
- Statt des erwarteten Nachrichtentyps kommt ein nicht interpretierbares Bytebündel an, oder die Nachricht ist zu kurz oder zu lang
- Erforderlicher loaned memory fehlt, oder es wird beschreibbarer Speicher benötigt, aber nur lesbarer Speicher übergeben
Warum normale Programme nicht zu Fehlerbehandlung gezwungen werden
- Normale Hubris-Programme sind so aufgebaut, dass solche IPC-Fehler nicht auftreten
- Task-Verbindungen werden über die Build-System-Konfiguration festgelegt, sodass Verwechslungen schwerfallen
- Clients konstruieren und senden IPC über generierten Rust-Code
- Auch Server verarbeiten Ergebnisse über separaten generierten Rust-Code
- Würde jede IPC-Operation
Result<T, IpcError>zurückgeben, müssten normale Programme für Fehler, die sie praktisch nie sehen können,unwrap()einfügenunwrap()ist in Bezug auf die Codegröße eine erhebliche Belastung- Auch zur Laufzeit entstehen Kosten für die Prüfung von Fehlern, die nicht auftreten werden
- Wenn man
unwrap()oderpanic!in generierten Code einfügt, kann man die Panic-Stelle zentralisieren und so die Auswirkung auf die Codegröße verringern; die Laufzeitkosten bleiben jedoch bestehen - Um universelle Fehlercodes zu unterstützen, müssten alle Operationen denselben Regeln für die Fehlercodierung folgen
- Jede Operation müsste Fehler zurückgeben können
- Jede Operation müsste diese Fehler auf dieselbe Weise codieren
- Auch Operationen, die nicht fehlschlagen können, müssten in einer fehlerfähigen Form ausgedrückt werden
- In Hubris-basierter Firmware wurden immer wieder Operationen gefunden, die tatsächlich nicht fehlschlagen können; GPIO-Pin-Konfiguration ist ein Beispiel dafür
Die aggressive Fault-Policy des Hubris-Kernels
- Viele Betriebssysteme geben bei verletzten Vorbedingungen von Systemaufrufen dennoch Fehlercodes zurück oder geben Gelegenheit zur Behandlung per Exception oder Signal
- Unter Unix wird ein Fehlercode zurückgegeben, wenn man
closeauf einen nicht geöffneten Dateideskriptor anwendet - Auch wenn man
openstatt eines Pfadnamens einen Null-Pointer übergibt, wird ein Fehlercode zurückgegeben
- Unter Unix wird ein Fehlercode zurückgegeben, wenn man
- Hubris zerstört den betreffenden Task sofort, wenn er Vorbedingungen eines Systemaufrufs verletzt
- Der Task kann keine weiteren Befehle mehr ausführen
- Der Task selbst erhält keine Gelegenheit zur Wiederherstellung oder Fortsetzung
- Der Supervisor-Task der Anwendung wird über den Fault benachrichtigt und löscht den Task normalerweise und startet ihn neu
- Vom Kernel erzeugte Faults sind synthetic faults
- Sie ähneln Hardware-Faults, die die CPU erzeugt, etwa bei Null-Pointer-Dereferenzierung oder Division durch null
- Hardware-Faults entstehen aus Verstößen gegen Regeln der Prozessorarchitektur, synthetic faults aus Verstößen gegen Kernel-Regeln
- Wenn etwa bei einem
SEND-Aufruf der Index des empfangenden Tasks außerhalb des Anwendungsbereichs liegt oder der Nachrichten-Pointer auf Speicher zeigt, für den keine Zugriffsrechte bestehen, entsteht ein synthetic fault - Hubris erlaubt keine wiederherstellbaren oder fortsetzbaren Faults
- Ob Hardware-Fault oder synthetic fault: Ein Task, der einen Fault erhält, ist tot
- Diese Entscheidung soll subtile Fehlermodi vermeiden und das Nachdenken über das System vereinfachen
Wie ein Server dem Client mit einem Fault antwortet
REPLY_FAULTist ein Systemaufruf, mit dem ein Server dem Client statt einer normalen Antwort einen Fault zustellt- Der gewöhnliche
REPLY-Ablauf sieht so aus- Wenn ein Client
SENDverwendet, markiert der Kernel den Client-Task gegenüber dem empfangenden Task als „waiting to send“ - Wenn der empfangende Task
RECVverwendet, wird dieser Client in den Zustand „waiting for reply“ versetzt - Ruft der Server
REPLYauf, kehrt der Client in den runnable-Zustand zurück
- Wenn ein Client
REPLY_FAULTähneltREPLY, aber statt eine Nachricht zu übermitteln und den Task ausführbar zu machen, stellt es einen Fault zu und versetzt den Task in den toten Zustand- Ein Server kann nicht beliebige Tasks töten
REPLY_FAULTkann nur auf Tasks angewendet werden, von denen dieser Server perRECVempfangen und auf die er noch nicht perREPLYgeantwortet hat- Es funktioniert nur für Clients, die auf die Antwort dieses bestimmten Servers warten
- Hubris nutzt
REPLY_FAULTzur Behandlung folgender Fehler- Falscher Operationscode
- Beschädigte, abgeschnittene oder bedeutungslose Nachricht
- Der Client hat nicht die richtige Art von loaned memory gesendet
Anwendungsfehler und Fail-Fast-Erfahrung
REPLY_FAULTkann nicht nur für IPC-Formatfehler, sondern auch für anwendungsspezifische Fehler verwendet werden- Der Hubris-IP-Stack weist IP-Ports statisch Tasks zu
- Wenn ein Task versucht, den IP-Port eines anderen Tasks anzurühren, gibt der IP-Stack diesem Task einen Fault
- Dieser Ansatz reduziert die Behandlung „theoretischer“ Fehler, die in der Praxis nicht auftreten sollten, und macht falsche Nutzung während der Entwicklung schnell sichtbar
REPLY_FAULTähnelt dem Modell, bei dem bei Verletzung von Vorbedingungen eines Rust-Funktionsaufrufs üblicherweise einpanic!auftritt; es wird zu einem Mittel, mit dem ein Server gegenüber dem Client-Prozess einen prozessübergreifendenpanic!auslöst- Der Client muss dafür keinen Code enthalten und nicht kooperieren
Sicherheitsausrichtung und Einschränkungen beim Testen
- Eliza Weissman beschreibt Hubris als „aggressiv feindselig gegenüber Schadprogrammen“
- Ausnutzungsversuche zeigen sich oft zunächst als API-Fehler oder Fehlgebrauch; ein System, das den Zustand fehlverhaltender Komponenten verwirft, könnte daher schwerer auszunutzen sein
- Diese Hypothese wurde noch nicht getestet
- Es ist eine Bitte enthalten, sich zu melden, wenn man an Versuchen interessiert ist, Hubris zu exploiten
- Der beobachtete Nachteil ist, dass das System sehr schwer per Fuzz Test zu testen ist
- Ein kleiner Chaos-Task, der zufällige IPC- und Systemaufrufe erzeugt, wurde implementiert, wird aber fast egal bei welcher Aktion sofort zurückgesetzt
- Um sinnvoll zu arbeiten, muss er seine Entscheidungen bei jedem Start von einem beobachtbar unterschiedlichen System-Uptime-Counter abhängig machen
REPLY_FAULTbietet auch eine Möglichkeit, Chaos zu erzwingen, indem ein Server Clients zufällig tötet; diese Option wurde jedoch noch nicht vollständig bewertet- Gewöhnliche Hubris-Tasks erzeugen absichtlich keine fehlerhaften IPC-Nachrichten dynamisch und können daher normalerweise laufen, ohne die Existenz von
REPLY_FAULTzu beachten
1 Kommentare
Meinungen auf Hacker News
REPLY_FAULT wirkt gut, wenn das System klein und eng verzahnt ist und auch die Anwendungen überwiegend von den Leuten geschrieben werden, die das Gesamtsystem entworfen haben.
Aus Sicht eines Anwendungsentwicklers wäre es aber ziemlich beängstigend, Third-Party-Code über ein IPC-Modell anzubinden, bei dem ein anderer Dienst meinem Prozess jederzeit eine sofort tödliche Pille zurückgeben kann.
Ich vertraue anderen Anwendungsentwicklern nicht in diesem Maß. Die Welt ist voll von schlechten Fahrern und von Hintergrundprozessen, die von Entwicklern unter Druck ihrer Manager gebaut wurden und die, wenn sie dadurch nur vor 20 Uhr nach Hause kommen, vermutlich massenhaft vielleicht unangebrachte Standard-REPLY_FAULTs einbauen würden.
Nicht alle Vorbedingungen waren leicht zu verstehen, und sie konnten sich je nach Gerät oder OS-Version unterscheiden.
Der Umfang wächst zwar immer, aber ich glaube nicht, dass man Arbeiten, die besser auf dem Host erledigt werden, unbedingt in Hubris-Tasks innerhalb eines Embedded-Controllers schieben will.
Wenn der Server sagt: „Dieser Client liegt falsch“, tötet der Kernel diesen Client. Der Kernpunkt ist, dass die beiden einander nicht verstanden haben.
Außerdem ist „Prozess“ hier möglicherweise etwas anderes als das, woran man dabei denkt. In Hubris teilen sich alle Threads denselben Adressraum.
Verkettet sich REPLY_FAULT? Wenn zum Beispiel A ein
SENDan B macht und wartet, B einSENDan C macht und wartet, und C dannREPLY_FAULTausführt: Stirbt A dann zusammen mit B?Falls nicht, könnte ein bösartiger Task Experimente einfach an einen Hilfstask delegieren. Falls doch, wirkt das insgesamt ziemlich fragil, auch wenn ich Hubris nicht näher kenne.
Außerdem könnten Tasks, wenn
SENDzyklisch oder wechselseitig sein kann, sich versehentlich selbst töten. In einem Fall wie B → A → B könnte das ein Anreiz sein, REPLY_FAULT nicht zu verwenden.Dass ein Server auf den Client zurückschießen kann, dient nicht der Sicherheit, sondern der Zuverlässigkeit. Man geht davon aus, dass Fehler nicht aus absichtlichen Angriffen entstehen, sondern aus Bugs, und die extreme Reaktion des Kernels hilft Entwicklern, das Problem möglichst schnell zu finden.
Natürlich gibt es Überschneidungen mit Sicherheit, und es kann eine nützliche zusätzliche Verteidigung sein, wenn ein Prozess etwas zu tun versucht, was er nicht tun sollte.
Hubris und der Debugger Humility sind Technologien, in die ich mich gern gründlich einarbeiten würde, wenn ich Zeit hätte oder eine Aufgabe, die es erfordert. Leider geht das im Moment nicht.
Interessant ist, dass in einem System, in dem ein Team den gesamten Code schreibt, ein Ansatz, bei dem ein Client schon dafür aus dem Orbit geschossen wird, dass er komisch schaut, die Iterationsgeschwindigkeit erhöhen kann.
Nachdem ich beim Lesen über algebraische Effekte eingeschlafen bin und diesen Artikel am Morgen gelesen habe, finde ich das unterhaltsam. Leicht verdreht betrachtet ist das ein Kernel, der Servern erlaubt, Effekte auszuführen, die der Client nicht verarbeiten kann.
Wiederverwendung und Komposition von Code dürften deutlich schwieriger werden, aber das Ausführungsmodell wird viel einfacher. Für statische Embedded-Systeme ist das eindeutig ein passender Trade-off. Wenn man Wiederverwendung braucht, kann man einen Task jederzeit vendorn und anpassen.
Im Gegenteil: Unix hat zu viele Fehler, die man ignorieren kann, und persönlich finde ich, dass viele davon fatale Signale hätten auslösen sollen. Das hätte die allgemeine Softwarequalität deutlich verbessert.
Zum Beispiel ist ein Aufruf von
close()auf einem ungültigen Dateideskriptor ein nicht fataler Fehler und wird deshalb oft ignoriert. Tatsächlich ist er aber besonders in Multithread-Apps sehr gefährlich. Meistens schlägt das Schließen eines ungültigen Dateideskriptors harmlos fehl, aber in 1 % der Fälle schließt man damit einen Logging-Socket, eine Datenbank-Lockdatei oder eine nicht damit zusammenhängende IPC-Verbindung. So entsteht die instabile Software, die alle hassen.Ich muss an die Zeile aus Errand of Mercy denken: „Sie werden feststellen, dass es eine Reihe von Regeln und Vorschriften gibt. Sie werden veröffentlicht. Schon der geringste Verstoß gegen eine davon wird mit dem Tod bestraft.“
Daraus sollte man ein Aprilscherz-RFC für HTTP machen.
Ich schlage HTTP 499 „Shame on you.“ vor. Ein Client, der 499 erhält, sollte — vielleicht nur bei Requests, die mit einem bestimmten Header wie
Strict: truebegonnen haben — den Task, der diesen Request ausgegeben hat, auf sprachspezifische Weise beenden.Das trifft in diesem Kontext perfekt die Balance von „Was ist das denn … aber eigentlich ist es gar nicht schlecht?“
Ich fand das sehr unterhaltsam zu lesen, und dieser Ansatz mit einem einzigen Supervisor ähnelt der Art, wie wir bei einem früheren Startup die Anwendung so strukturiert hatten, dass alles unwrapt.
Es erinnerte mich auch an einen meiner Lieblingsartikel: https://medium.com/@mattklein123/crash-early-and-crash-often...
Ich frage mich, ob das wirklich so aggressiv ist
Unter Linux ist es nicht möglich, ein anderes Programm, das gerade nur über Sockets kommuniziert, direkt zum Absturz zu bringen – abgesehen davon, dass man fehlerhafte Daten an den Socket schickt
Aber es zu beenden, ist definitiv möglich. Alles, was als root läuft, kann alles andere beenden und auch das gesamte System durch einen Reboot herunterfahren
Etwas schwieriger und weniger üblich ist es zwar, aber zumindest in Containern sind root-Rechte verbreitet. Natürlich gibt es cgroups, die das weiter einschränken, aber darum geht es im Kern
Es weicht auch etwas von der üblichen Weisheit „sei großzügig beim Empfangen, konservativ beim Senden“ ab. Allerdings ist das vielleicht stärker an Netzwerksysteme gebunden
Trotzdem kommt ein System vielleicht nicht darum herum, großzügig darin zu sein, was es akzeptiert. Andernfalls gäbe es doch keine Möglichkeit, eine API leicht zu ändern, ohne bestehende Programme zu beschädigen?
Soweit ich weiß, erlaubt es zur Laufzeit auch keine neuen Arten von Prozessen. Alle möglichen ausführbaren Dateien müssen zur Compile-Zeit festgelegt sein
Zu der Passage „Es gibt keine Möglichkeit, das Problem zu beheben und die Task fortzusetzen. Das war eine bewusste Entscheidung, um subtile Fehlermodi zu vermeiden und das Nachdenken über das System zu vereinfachen“ fällt mir Einsteins berühmter Satz ein: „So einfach wie möglich, aber nicht einfacher“
Dieses Design scheint gegen die zweite Bedingung zu verstoßen. Ich habe kein Interesse an einer Betriebsumgebung, die das Chaos der realen Welt überhaupt nicht toleriert, und ich weiß auch nicht, welcher kommerziell tragfähige Bereich so etwas akzeptieren würde
Läuft es am Ende darauf hinaus, zum init-System zurückzukehren und es immer wieder neu versuchen zu lassen? Aber mit welchem Mechanismus kann es den aufgetretenen Fault verstehen und auf bessere Weise erneut versuchen?
Jedenfalls applaudierte ich der Reinheit der Überzeugung
Wie Cliff im Blog ausführlich geschrieben hat, war REPLY_FAULT tatsächlich eine Funktion, von der wir anfangs dachten, sie könnte zu aggressiv sein. Aber die Erfahrungen beim Bauen, Ausrollen und, ehrlich gesagt, Debuggen des Systems gaben uns die Überzeugung, dass sie unser System nicht launisch kaputtmachen, sondern robuster machen würde
Mehr zu dieser Denkweise und dazu, wie es in der Praxis aussieht, findet sich unter [0] und [1]
[0] https://www.mattkeeter.com/blog/2024-03-25-packing/
[1] https://cliffle.com/blog/who-killed-the-network-switch/
Selbst in Hobbyprojekten habe ich gesehen, dass ein I2C-Bus häufig hängen bleibt und das gesamte System herunterzieht, wenn nur ein Protokoll-Bit durcheinandergerät; deshalb finde ich dieses Design ziemlich inspirierend
Soweit ich es verstehe, geht es hier nicht um bereits bekannte Fehlerfälle, also behandelte Fehler, sondern um Protokollinkonsistenzen und Dinge, die absolut nie passieren dürfen
Wie andere Kommentare ebenfalls angemerkt haben, ist es ein zweckgebundenes OS. So wie man mit Erlang keine UI bauen würde, scheint Hubris gut in den Bereich zu passen, den es besetzt
Die Ursache kann ein Bug, ein Angriff oder beschädigte Hardware sein; in jedem Fall sollte man nicht einfach weitermachen. Beim Aufrufer liegt ein ernstes Problem vor, und wenn er weitermacht, richtet er nur größeren Schaden an
Das klingt ein wenig nach der „let it crash“-Philosophie von Erlang/OTP. Erlang wird in ziemlich viel missionskritischer Hardware eingesetzt und ist für Zuverlässigkeit bekannt, also ist das in der Praxis vielleicht kein so großes Ausschlusskriterium
Er wurde dafür geschrieben, tief im Inneren von 0xide-Server-Racks zu laufen
Bei der Aussage „Missbrauchsversuche zeigen sich oft zuerst als API-Fehler oder -Missbrauch; daher sollte ein System, das bei jedem Fehlverhalten den Zustand der fehlhandelnden Komponente löscht, schwerer auszunutzen sein“ geht es hier gewissermaßen darum, dass die Anwendung etwas strenger prüft, was sie akzeptiert
Das hat also Sicherheitsvorteile, aber andere als die, an die man vielleicht denkt. Es zerstört nicht den Fortschritt des Angreifers und wirft ihn zurück, sondern sorgt dafür, dass bestimmte fehlerhafte Zustände, die man früher zu einem noch wünschenswerteren fehlerhaften Zustand hätte verketten können, nicht mehr funktionieren
Dann wird der Angreifer eher woanders suchen, statt es damit zu versuchen