Elegir la violencia del servidor
(cliffle.com)- Hubris es un sistema operativo donde tareas aisladas se comunican por IPC, y con la 13.ª llamada al sistema,
REPLY_FAULT, permite que un servidor termine una solicitud errónea del cliente con un fault en vez de devolver un valor de error - Desde la perspectiva del cliente, el IPC se ve como una llamada a función, pero como las tareas se compilan por separado, el compilador no puede impedir por completo códigos de operación incorrectos, bytes imposibles de interpretar o
loaned memoryinapropiada - En un programa normal de Hubris, estos errores casi no aparecen gracias a la configuración de compilación y al código Rust generado, así que obligar a que todas las llamadas usen
Result<T, IpcError>yunwrap()aumenta el tamaño del código y el costo en tiempo de ejecución - El kernel mata de inmediato, sin código de error, a una tarea que viola las precondiciones de una llamada al sistema, y
REPLY_FAULTextiende esa misma política de fail-fast hasta las respuestas del servidor - Este diseño hace visibles rápidamente los usos incorrectos de la API, pero complica las pruebas de fuzzing y las tareas de chaos que envían IPC y llamadas al sistema aleatorias, porque casi siempre se reinician de inmediato
El lugar de REPLY_FAULT en el IPC de Hubris
- Hubris usa un kernel pequeño e independiente de la aplicación, y deja la mayor parte del código —como drivers, lógica de aplicación y stack de red— en tareas aisladas compiladas por separado
- La comunicación entre tareas se realiza mediante llamadas al sistema de IPC implementadas por el kernel
RECV: toma el mensaje recibido de mayor prioridad o se bloquea hasta que llegue unoSEND: detiene al llamador, entrega el mensaje y el control a la tarea receptora, y espera hasta recibir una respuestaREPLY: entrega una respuesta a la tarea que antes hizoSENDpara que pueda volver a ejecutarse
- En Hubris, cliente y servidor no son identidades fijas, sino roles que asume una tarea
- Una tarea que usa
SENDcumple el rol de cliente - Una tarea que usa
RECVyREPLYcumple el rol de servidor - Una misma tarea puede ser servidor para unas tareas y cliente para otras
- Una tarea que usa
Errores que el compilador no detecta en el límite entre tareas
- En una llamada de función normal, el compilador y el enlazador garantizan en buena medida los tipos y el destino de la llamada
- Si una función de Rust recibe un argumento
String, el compilador impide que quien llama le pase unbool - Tampoco suele pasar que, por confusión de destino, se invoque
fire_missilescuando se quería llamar apet_cat
- Si una función de Rust recibe un argumento
- El IPC de Hubris cruza el límite entre tareas y cada tarea se compila como un programa separado, por lo que el compilador no puede verificar directamente toda la relación de IPC
- Los errores con los que puede encontrarse un servidor IPC se agrupan en tres tipos
- Códigos de operación que no coinciden con la interfaz, por ejemplo, recibir “operation number 48” en una interfaz que solo tiene dos operaciones
- Un conjunto de bytes imposible de interpretar en vez del tipo de mensaje esperado, o un mensaje demasiado corto o demasiado largo
- Ausencia de la
loaned memoryrequerida, o recepción de memoria de solo lectura cuando se necesita memoria escribible
Por qué no se obliga a manejar errores en programas normales
- En un programa normal de Hubris, estos errores de IPC están configurados para no ocurrir
- Las conexiones entre tareas se definen en la configuración del sistema de compilación, por lo que es difícil confundirlas entre sí
- El cliente construye y envía el IPC usando código Rust generado
- El servidor también procesa el resultado con código Rust generado por separado
- Si todas las operaciones IPC devolvieran
Result<T, IpcError>, un programa normal tendría que ponerunwrap()para errores que en realidad no puede encontrarseunwrap()tiene un costo importante en tamaño del código- También agrega el costo en tiempo de ejecución de revisar errores que no van a ocurrir
- Poner
unwrap()opanic!dentro del código generado centraliza el lugar del panic y reduce el impacto en tamaño de código, pero el costo en tiempo de ejecución sigue igual - Para soportar un código de error universal, todas las operaciones tendrían que seguir la misma convención de codificación de errores
- Todas las operaciones tendrían que poder devolver errores
- Todas las operaciones tendrían que codificarlos de la misma manera
- Incluso las operaciones que no pueden fallar tendrían que expresarse como si sí pudieran fallar
- En firmware basado en Hubris se siguieron encontrando operaciones que realmente no pueden fallar, y un ejemplo es la configuración de pines GPIO
La política agresiva de fault del kernel de Hubris
- Muchos sistemas operativos devuelven un código de error o dan oportunidad de manejar una excepción o señal aun cuando se violan las precondiciones de una llamada al sistema
- En Unix, si se hace
closesobre un descriptor de archivo que no está abierto, se devuelve un código de error - Incluso si a
opense le pasa un null pointer en vez de un nombre de ruta, también se devuelve un código de error
- En Unix, si se hace
- Hubris destruye de inmediato la tarea que rompe las precondiciones de una llamada al sistema
- La tarea ya no puede ejecutar más instrucciones
- La propia tarea no tiene oportunidad de recuperarse ni reanudarse
- La tarea supervisora de la aplicación recibe la notificación del fault y normalmente elimina la tarea y la reinicia
- El fault que genera el kernel es un synthetic fault
- Es análogo a un fault de hardware producido por la CPU, como una desreferencia de null pointer o una división entre cero
- Los faults de hardware surgen por violar reglas de la arquitectura del procesador, y los synthetic faults por violar reglas del kernel
- Por ejemplo, en una llamada
SEND, si el índice de la tarea receptora queda fuera del rango de la aplicación, o si el puntero al mensaje apunta a memoria sin permisos de acceso, se produce un synthetic fault - Hubris no permite faults recuperables ni reanudables
- Tanto si el fault es de hardware como si es synthetic, la tarea que lo recibe queda muerta
- Esta elección busca evitar modos de falla sutiles y simplificar el razonamiento sobre el sistema
Cómo un servidor responde al cliente con un fault
REPLY_FAULTes una llamada al sistema con la que un servidor entrega al cliente un fault en lugar de una respuesta normal- El flujo normal de
REPLYes el siguiente- Cuando el cliente usa
SEND, el kernel marca a la tarea cliente como “waiting to send” respecto de la tarea receptora - Cuando la tarea receptora usa
RECV, ese cliente pasa al estado “waiting for reply” - Cuando el servidor llama a
REPLY, el cliente vuelve al estado runnable
- Cuando el cliente usa
REPLY_FAULTse parece aREPLY, pero en vez de entregar un mensaje y devolver al cliente al estado ejecutable, entrega un fault y deja la tarea en estado muerto- El servidor no puede matar tareas arbitrarias
REPLY_FAULTsolo puede usarse sobre una tarea que ese servidor haya recibido conRECVy a la que aún no haya respondido conREPLY- Solo funciona sobre clientes que están esperando la respuesta de ese servidor específico
- Hubris usa
REPLY_FAULTpara manejar estos errores- Código de operación inválido
- Mensajes dañados, truncados o sin sentido
- Casos en que el cliente no envió el tipo correcto de
loaned memory
Errores de aplicación y experiencia fail-fast
REPLY_FAULTpuede usarse no solo para errores de formato IPC, sino también para errores específicos de la aplicación- El stack IP de Hubris asigna puertos IP a las tareas de forma estática
- Si una tarea intenta tocar el puerto IP de otra, el stack IP le provoca un fault
- Este enfoque reduce el manejo de errores “teóricos” que no deberían ocurrir en la práctica y hace visibles los usos incorrectos durante el desarrollo
- De forma parecida a como una violación de precondiciones en una función Rust normalmente provoca
panic!,REPLY_FAULTse convierte en una manera de que el servidor provoque unpanic!entre procesos sobre el proceso cliente - El cliente no necesita incluir código especial ni cooperar para eso
Enfoque de seguridad y limitaciones para pruebas
- Eliza Weissman describió a Hubris como “agresivamente hostil hacia programas maliciosos”
- Los intentos de explotación suelen empezar como errores o mal uso de la API, así que un sistema que borra el estado de un componente que se portó mal podría ser más difícil de explotar
- Esta hipótesis todavía no se ha probado
- Se incluye una invitación a que se pongan en contacto quienes estén interesados en intentar explotar Hubris
- Una desventaja observada es que el sistema es muy difícil de someter a pruebas de fuzzing
- Se implementó una pequeña tarea de chaos que genera IPC y llamadas al sistema aleatorias, pero casi cualquier cosa que hace termina provocando un reinicio inmediato
- Para comportarse de manera útil, tiene que basar sus decisiones en un contador de uptime del sistema que cambie de forma observable en cada arranque
REPLY_FAULTtambién ofrece una manera de que el servidor mate clientes al azar para forzar chaos, pero esta opción todavía no se ha evaluado por completo- Como las tareas normales de Hubris no generan dinámicamente mensajes IPC incorrectos de forma intencional, por lo general pueden ejecutarse sin ser conscientes de la existencia de
REPLY_FAULT
1 comentarios
Opiniones en Hacker News
REPLY_FAULT parece bueno cuando el sistema es pequeño y estrechamente integrado, y las aplicaciones también las escriben principalmente las mismas personas que diseñaron todo el sistema.
Pero desde el punto de vista de un desarrollador de aplicaciones, conectarse con código de terceros mediante un modelo de IPC en el que otro servicio puede devolverle a mi proceso una píldora de muerte instantánea en cualquier momento parece bastante aterrador.
No confío tanto en otros desarrolladores de aplicaciones. El mundo está lleno de malos conductores y de procesos en segundo plano hechos por desarrolladores presionados por sus jefes, que podrían meter por todos lados un REPLY_FAULT predeterminado potencialmente inapropiado con tal de poder irse antes de las 8.
No todas las precondiciones eran fáciles de entender, y también podían variar según el dispositivo o la versión del OS.
El alcance siempre tiende a crecer, pero no creo que uno quiera meter por la fuerza en una tarea de Hubris dentro de un controlador embebido trabajos que convendría manejar mejor en el host.
Si el servidor dice “ese cliente está mal”, el kernel mata a ese cliente. El punto central es que ambos no se entendieron.
Además, lo que uno imagina al decir “proceso” puede ser distinto. En Hubris, todos los hilos comparten el mismo espacio de direcciones.
¿REPLY_FAULT se encadena? Por ejemplo, si A hace
SENDa B y espera, y B haceSENDa C y espera, cuando C haceREPLY_FAULT, ¿A también muere junto con B?Si no es así, una tarea maliciosa simplemente podría delegar el experimento a una tarea auxiliar. Si sí, en cambio, el conjunto parece bastante frágil, aunque no conozco Hubris en profundidad.
Además, si
SENDpuede ser cíclico o mutuo, una tarea podría matarse a sí misma por accidente. En un caso como B → A → B, eso también podría ser un incentivo para no usar REPLY_FAULT.La razón por la que un servidor puede devolver el golpe a un cliente no es la seguridad, sino la confiabilidad. Se asume que los errores vienen de bugs, no de ataques intencionales, y la reacción extrema del kernel ayuda al desarrollador a encontrar el problema lo antes posible.
Por supuesto, hay solapamientos con la seguridad, y puede servir como una defensa adicional útil cuando un proceso intenta hacer algo que no debería.
Hubris y su depurador, Humility, son tecnologías en las que me gustaría profundizar si tuviera tiempo o una misión que lo exigiera. Lamentablemente, ahora no es posible.
En un sistema donde un solo equipo escribe todo el código, es interesante que la estrategia de volar a un cliente desde la órbita solo porque miró raro pueda acelerar el desarrollo iterativo.
Me resulta divertido leer esto por la mañana después de haberme quedado dormido leyendo sobre efectos algebraicos. Si se lo mira con un pequeño giro, este es un kernel que permite que el servidor realice efectos que el cliente no puede manejar.
La reutilización y composición de código probablemente se vuelvan mucho más difíciles, pero el modelo de ejecución se vuelve mucho más simple. En sistemas embebidos estáticos, sin duda es el compromiso correcto. Si se necesita reutilización, siempre se puede vendorear la tarea y modificarla.
Más bien, en Unix hay demasiados errores que pueden ignorarse, y personalmente creo que muchos de ellos deberían haber generado señales fatales. Eso habría mejorado bastante la calidad general del software.
Por ejemplo, llamar a
close()sobre un descriptor de archivo inválido suele ignorarse porque es un error no fatal. Pero en la práctica es muy peligroso, sobre todo en apps multihilo. La mayoría de las veces cerrar un descriptor de archivo incorrecto falla de forma harmless, pero en el 1% de los casos cierra un socket de logging, un archivo de bloqueo de base de datos o una conexión IPC no relacionada. Así se crea ese software inestable que todos odiamos.Me recuerda a la frase de Errand of Mercy: “Descubrirán que hay varias reglas y regulaciones. Serán publicadas. Violar la más pequeña de ellas se castiga con la muerte”.
Esto debería convertirse en un RFC de April Fools’ para HTTP.
Propongo HTTP 499 “Shame on you.”. Un cliente que reciba un 499, quizás solo para solicitudes iniciadas con algún header específico como
Strict: true, debería terminar la tarea que emitió esa solicitud de la forma correspondiente a cada lenguaje.Captura perfectamente ese equilibrio de “¿qué es esto?... aunque, en realidad, está bastante bien” que se ve en este contexto.
Me pareció una lectura muy interesante, y este enfoque de un único supervisor se parece a la forma en que en una startup anterior configurábamos la aplicación para hacer unwrap de todo.
También me recordó a uno de mis artículos favoritos, https://medium.com/@mattklein123/crash-early-and-crash-often...
Me pregunto si esto realmente es demasiado agresivo.
En Linux, no es posible hacer que otro programa con el que te estás comunicando solo mediante sockets se estrelle directamente, salvo si le envías datos incorrectos al socket.
Pero matarlo sí es definitivamente posible. Cualquier cosa que se ejecute como root puede matar a otra, e incluso puede reiniciar y bajar todo el sistema.
Es un poco más difícil y menos común, pero al menos en contenedores los permisos de root son habituales. Claro, existen los cgroups, así que está más limitado, pero ese es el punto.
También es un poco distinto de la sabiduría convencional de “ser liberal en lo que aceptas y conservador en lo que envías”. Aunque quizá eso esté más ligado a sistemas de red.
Aun así, tal vez sea inevitable que un sistema sea tolerante con lo que acepta. Si no, ¿cómo podrías cambiar levemente una API sin romper programas existentes?
Tengo entendido que tampoco permite nuevos tipos de procesos en tiempo de ejecución. Todos los ejecutables posibles deben estar definidos en tiempo de compilación.
Sobre la parte que dice “no hay forma de arreglar el problema y reanudar la tarea; esta fue una decisión consciente para evitar modos de falla sutiles y simplificar el razonamiento sobre el sistema”, me recuerda la famosa frase de Einstein: “tan simple como sea posible, pero no más simple”.
Este diseño parece violar la segunda condición. No me interesan los entornos operativos que no toleran en absoluto el caos del mundo real, y tampoco sé bien qué áreas comercialmente viables aceptarían algo así.
¿La idea termina siendo volver a un sistema init para que reintente una y otra vez? Pero ¿con qué mecanismo se entiende la falla ocurrida para intentar de nuevo de una mejor manera?
En cualquier caso, aplaudo la pureza de sus convicciones.
De hecho, como Cliff escribió en detalle en el blog, REPLY_FAULT fue una función que al principio pensamos que quizá era demasiado agresiva, pero la experiencia de construir, desplegar y, francamente, depurar el sistema nos dio la confianza de que haría a nuestro sistema más robusto, no que lo rompería de forma caprichosa.
Se puede ver más sobre la mentalidad y cómo se ve en la práctica en [0] y [1].
[0] https://www.mattkeeter.com/blog/2024-03-25-packing/
[1] https://cliffle.com/blog/who-killed-the-network-switch/
Incluso en proyectos hobby he visto que un bus I2C se queda colgado con frecuencia cuando se desacomoda un solo bit del protocolo, y termina bajando todo el sistema, así que este diseño me parece bastante inspirado.
Según entiendo, se trata de casos de error ya conocidos: no errores que se manejan, sino incompatibilidades de protocolo y cosas que nunca deberían ocurrir.
Como también señalaron otros comentarios, es un SO hecho a la medida. Así como no harías una UI en Erlang, Hubris parece encajar bien en el espacio que ocupa.
La causa puede ser un bug, un ataque o hardware dañado, y en cualquiera de esos casos no se debería seguir. El llamador tiene un problema grave y continuar solo causaría más daño.
Suena un poco parecido a la filosofía “let it crash” de Erlang/OTP. Erlang se usa en bastante hardware de misión crítica y es famoso por su confiabilidad, así que quizá en la práctica no sea una desventaja tan grande.
Fue escrito para correr en lo profundo de las entrañas de los racks de servidores de 0xide.
En la parte que dice “los intentos de explotación suelen manifestarse primero como errores o mal uso de la API, por lo que un sistema que borra el estado del componente que se comportó mal ante cualquier mal funcionamiento debería ser más difícil de explotar”, aquí en realidad se está haciendo que la aplicación verifique con un poco más de rigor lo que acepta.
Así que hay una ventaja de seguridad, pero no del tipo que uno podría estar imaginando. No se trata de destruir el progreso del atacante para hacerlo retroceder, sino de que ciertos estados incorrectos que antes podían encadenarse hacia estados incorrectos más deseables ya no sirven.
Entonces el atacante buscará en otra parte en lugar de intentar eso.