1 Punkte von GN⁺ 2024-04-28 | 1 Kommentare | Auf WhatsApp teilen
  • Hubris ist ein OS, in dem isolierte Tasks per IPC kommunizieren; mit dem 13. Systemaufruf REPLY_FAULT kann 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> und unwrap() erzwingen, nähmen Codegröße und Laufzeitkosten zu
  • Der Kernel tötet Tasks, die Vorbedingungen von Systemaufrufen verletzen, sofort ohne Fehlercode; REPLY_FAULT erweitert 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 eintrifft
    • SEND: Hält den Aufrufer an, übergibt Nachricht und Kontrolle an den empfangenden Task und wartet dann auf eine Antwort
    • REPLY: Übermittelt eine Antwort an den Task, der zuvor SEND aufgerufen hat, sodass dieser weiterlaufen kann
  • Client und Server sind in Hubris keine festen Identitäten, sondern Rollen, die ein Task einnimmt
    • Ein Task, der SEND verwendet, nimmt die Client-Rolle ein
    • Ein Task, der RECV und REPLY verwendet, nimmt die Server-Rolle ein
    • Ein Task kann gegenüber einem Task Server und gegenüber einem anderen Task Client sein

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 ein bool übergibt
    • Auch eine Verwechslung des Ziels, etwa versehentlich fire_missiles statt pet_cat aufzurufen, kommt normalerweise nicht vor
  • 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ügen
    • unwrap() 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() oder panic! 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 close auf einen nicht geöffneten Dateideskriptor anwendet
    • Auch wenn man open statt eines Pfadnamens einen Null-Pointer übergibt, wird ein Fehlercode zurückgegeben
  • 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_FAULT ist 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 SEND verwendet, markiert der Kernel den Client-Task gegenüber dem empfangenden Task als „waiting to send“
    • Wenn der empfangende Task RECV verwendet, wird dieser Client in den Zustand „waiting for reply“ versetzt
    • Ruft der Server REPLY auf, kehrt der Client in den runnable-Zustand zurück
  • REPLY_FAULT ähnelt REPLY, 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_FAULT kann nur auf Tasks angewendet werden, von denen dieser Server per RECV empfangen und auf die er noch nicht per REPLY geantwortet hat
    • Es funktioniert nur für Clients, die auf die Antwort dieses bestimmten Servers warten
  • Hubris nutzt REPLY_FAULT zur 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_FAULT kann 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 ein panic! auftritt; es wird zu einem Mittel, mit dem ein Server gegenüber dem Client-Prozess einen prozessübergreifenden panic! 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_FAULT bietet 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_FAULT zu beachten

1 Kommentare

 
GN⁺ 2024-04-28
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.

    • Das scheint so beabsichtigt zu sein, und genau diese Art von Umgebung ist offenbar das Ziel von Hubris.
    • Genau so etwas gab es tatsächlich bei Symbian. IPC-Server konnten Clients in Panik versetzen, und für Anwendungsentwickler ohne Zugriff auf den OS-Quellcode war das ziemlich furchtbar.
      Nicht alle Vorbedingungen waren leicht zu verstehen, und sie konnten sich je nach Gerät oder OS-Version unterscheiden.
    • Abweichungen schnell zu töten ist eine Methode, ein System eng zusammenzuhalten. Schon der vorgesehene Umfang dürfte es ohnehin eher klein halten.
      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.
    • In Embedded-Umgebungen scheint es sinnvoll, solche Missverständnisse sofort zu beheben, egal wessen Schuld sie sind.
      Wenn der Server sagt: „Dieser Client liegt falsch“, tötet der Kernel diesen Client. Der Kernpunkt ist, dass die beiden einander nicht verstanden haben.
    • Den Dienst kann man hier als OS-Schnittstelle verstehen. Wenn man in einem monolithischen Kernel einen ungültigen Kernel-Aufruf macht, ist es ebenfalls plausibel, dass das OS den Prozess beendet.
      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 SEND an B macht und wartet, B ein SEND an C macht und wartet, und C dann REPLY_FAULT ausfü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 SEND zyklisch 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.

    • Hubris scheint nicht als Allzweck-Betriebssystem entworfen zu sein. Prozesse werden zur Build-Zeit definiert.
      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.
    • Wenn B faultet, bekommt A vermutlich einen Fehler, dass der Server gestorben ist, und erhält die Chance, dieselbe Nachricht an den neu gestarteten Server erneut zu senden. Es dürfte also kein Kaskadenabsturz sein.
  • 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.

    • Wenn man sauber zwischen erwartbaren Fehlern, etwa „Datei nicht gefunden“, und unerwarteten Fehlern wie einem ungültigen Opcode trennt, dürfte die Wiederverwendbarkeit auch bei normalen Programmen nicht stark leiden.
      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: true begonnen 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?

    • Hubris ist kein Allzweck-OS, sondern läuft auf Low-Level-Prozessoren innerhalb der Oxide-Server-Racks
      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

    • Hubris ist kein akademisches Experiment. Es läuft im Zentrum aller Kernelemente des Oxide-Racks, also Compute-Sleds, Switches und Controller der Power-Shelves, und sein Design basiert vor allem auf dem tatsächlich gelieferten Nutzen
      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/
    • Watchdog-Timer beenden oder starten Prozesse, die nicht regelmäßig angestoßen werden, bereitwillig neu
      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
    • Ich sehe darin eine Idee, die auf Probleme angewandt werden soll, die eindeutig das Ergebnis eines fehlerhaften Programmzustands sind. Daher kann man sich davon nicht sinnvoll erholen
      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
    • Das ist ein 2000 Zeilen großer Embedded-System-Kernel in Rust, der das Hinzufügen neuer Tasks zur Laufzeit nicht unterstützt
      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