A escolha da violência do servidor
(cliffle.com)- O Hubris é um sistema operacional em que tarefas isoladas se comunicam via IPC, e a 13ª chamada de sistema,
REPLY_FAULT, permite que um servidor encerre uma solicitação incorreta de um cliente com fault em vez de retornar um valor de erro - Do ponto de vista do cliente, o IPC parece uma chamada de função, mas como as tarefas são compiladas separadamente, o compilador não consegue impedir totalmente códigos de operação incorretos, bytes impossíveis de interpretar ou memória emprestada inadequada
- Como programas Hubris normais quase nunca encontram esses erros graças à configuração de build e ao código Rust gerado, forçar
Result<T, IpcError>eunwrap()em todas as chamadas aumenta o tamanho do código e o custo em tempo de execução - O kernel elimina imediatamente tarefas que violam pré-condições de chamadas de sistema sem retornar código de erro, e o
REPLY_FAULTestende essa mesma política fail-fast às respostas de servidores - Esse design revela rapidamente usos incorretos da API, mas torna difíceis testes de fuzzing ou tarefas de caos que enviam IPCs e chamadas de sistema aleatórias, porque elas são reiniciadas quase imediatamente
O papel do Hubris IPC e do REPLY_FAULT
- O Hubris usa um pequeno kernel independente da aplicação e coloca a maior parte do código, como drivers, lógica de aplicação e pilha de rede, em tarefas isoladas compiladas separadamente
- A comunicação entre tarefas é feita por chamadas de sistema de IPC implementadas pelo kernel
RECV: obtém a mensagem recebida de maior prioridade ou bloqueia até que uma mensagem chegueSEND: pausa o chamador, entrega a mensagem e o controle à tarefa receptora e espera até receber uma respostaREPLY: entrega uma resposta à tarefa que fezSENDanteriormente, permitindo que ela volte a executar
- No Hubris, cliente e servidor não são identidades fixas, mas papéis desempenhados pela tarefa
- Uma tarefa que usa
SENDatua como cliente - Uma tarefa que usa
RECVeREPLYatua como servidor - Uma mesma tarefa pode ser servidor para algumas tarefas e cliente para outras
- Uma tarefa que usa
Erros que o compilador não captura nas fronteiras entre tarefas
- Em uma chamada de função comum, compilador e linker garantem em boa parte os tipos e o destino da chamada
- Se uma função Rust recebe um argumento
String, o compilador impede que o chamador passe umbool - Também normalmente não acontece confundir o alvo e chamar
fire_missilesquando se pretendia chamarpet_cat
- Se uma função Rust recebe um argumento
- O IPC do Hubris cruza a fronteira entre tarefas e cada tarefa é compilada como um programa separado, então o compilador não consegue verificar diretamente toda a relação de IPC
- Os erros que um servidor IPC pode encontrar se dividem em três grandes grupos
- Códigos de operação fora da interface, como receber “operation number 48” em uma interface que só tem duas operações
- Um conjunto de bytes impossível de interpretar em vez do tipo de mensagem esperado, ou uma mensagem curta ou longa demais
- Ausência da loaned memory necessária, ou envio de memória somente leitura quando era preciso memória gravável
Por que não forçar tratamento de erro em programas normais
- Em programas Hubris normais, esses erros de IPC são evitados por construção
- As conexões entre tarefas são configuradas no sistema de build, o que dificulta confundir umas com as outras
- O cliente monta e envia o IPC com código Rust gerado
- O servidor também processa o resultado com código Rust gerado separadamente
- Se toda operação de IPC passasse a retornar
Result<T, IpcError>, programas normais teriam de colocarunwrap()para erros que, na prática, nunca podem ocorrer- O
unwrap()pesa em termos de tamanho do código - Também há custo em runtime para verificar erros que não vão acontecer
- O
- Colocar
unwrap()oupanic!dentro do código gerado pode centralizar o local do panic e reduzir o impacto no tamanho do código, mas o custo em runtime continua o mesmo - Para suportar um código de erro universal, todas as operações teriam de seguir a mesma regra de codificação de erro
- Todas as operações precisariam poder retornar erro
- Todas as operações precisariam codificar esse erro da mesma forma
- Até operações que não podem falhar teriam de ser representadas como potencialmente falhas
- Em firmware baseado em Hubris, continuaram aparecendo operações que de fato não podem falhar, e configurar pinos GPIO é um exemplo
A política agressiva de fault no kernel do Hubris
- Muitos sistemas operacionais retornam um código de erro ou dão chance de tratar exceções e sinais mesmo quando as pré-condições de uma chamada de sistema são violadas
- No Unix, chamar
closeem um descritor de arquivo não aberto retorna um código de erro - Passar um null pointer para
openno lugar de um pathname também retorna um código de erro
- No Unix, chamar
- No Hubris, se uma pré-condição de chamada de sistema é violada, a tarefa correspondente é destruída imediatamente
- A tarefa não pode mais executar instruções
- A própria tarefa não recebe oportunidade de se recuperar nem de continuar
- A tarefa supervisora da aplicação é notificada do fault e normalmente apaga a tarefa e a reinicia
- Os faults produzidos pelo kernel são synthetic faults
- São análogos a faults de hardware produzidos pela CPU, como dereferência de null pointer ou divisão por zero
- Faults de hardware surgem de violação das regras da arquitetura do processador, enquanto synthetic faults surgem de violação das regras do kernel
- Por exemplo, em uma chamada
SEND, se o índice da tarefa receptora estiver fora do intervalo da aplicação ou se o ponteiro da mensagem apontar para memória sem permissão de acesso, ocorre um synthetic fault - O Hubris não permite faults recuperáveis nem retomáveis
- Seja fault de hardware ou synthetic fault, a tarefa que o recebe passa ao estado de morta
- Essa escolha busca evitar modos de falha sutis e simplificar o raciocínio sobre o sistema
Como o servidor responde ao cliente com fault
REPLY_FAULTé a chamada de sistema pela qual o servidor entrega um fault ao cliente em vez de uma resposta normal- O fluxo normal com
REPLYé o seguinte- Quando o cliente usa
SEND, o kernel marca a tarefa cliente como “waiting to send” para a tarefa receptora - Quando a tarefa receptora usa
RECV, esse cliente passa ao estado “waiting for reply” - Quando o servidor chama
REPLY, o cliente volta ao estado executável
- Quando o cliente usa
- O
REPLY_FAULTé parecido comREPLY, mas em vez de entregar uma mensagem e tornar a tarefa executável, ele entrega um fault e coloca a tarefa no estado de morta - O servidor não pode matar uma tarefa arbitrária
REPLY_FAULTsó pode ser usado em uma tarefa que esse servidor recebeu comRECVe à qual ainda não respondeu comREPLY- Ele só funciona para clientes que estão aguardando resposta daquele servidor específico
- O Hubris usa
REPLY_FAULTpara tratar os seguintes erros- Código de operação incorreto
- Mensagem corrompida, truncada ou sem sentido
- Caso o cliente não tenha enviado o tipo correto de loaned memory
Erros de aplicação e a experiência fail-fast
- O
REPLY_FAULTpode ser usado não só para erros de formato de IPC, mas também para erros específicos da aplicação - A pilha IP do Hubris atribui portas IP estaticamente às tarefas
- Se uma tarefa tentar mexer na porta IP de outra, a pilha IP aplica um fault nessa tarefa
- Essa abordagem reduz o tratamento de erros “teóricos” que não deveriam ocorrer na prática e expõe usos incorretos rapidamente durante o desenvolvimento
- De forma parecida com o modelo em que uma violação de pré-condição numa chamada de função Rust normalmente leva a
panic!, oREPLY_FAULTse torna um meio para o servidor causar umpanic!entre processos no processo cliente - O cliente não precisa incluir código para isso nem cooperar com o mecanismo
Viés de segurança e limitações nos testes
- Eliza Weissman descreveu o Hubris como “agressivamente hostil a programas maliciosos”
- Tentativas de exploração muitas vezes aparecem primeiro como erros ou mau uso de API, então um sistema que apaga o estado de um componente malcomportado pode ser mais difícil de explorar
- Essa hipótese ainda não foi testada
- O texto inclui um pedido para que interessados em tentar explorar o Hubris entrem em contato
- Uma desvantagem observada é que o sistema é muito difícil de testar com fuzzing
- Foi implementada uma pequena tarefa de caos que gera IPCs e chamadas de sistema aleatórias, mas ela é reiniciada quase imediatamente, não importa quase o que faça
- Para funcionar de modo útil, ela precisa basear decisões em um contador de uptime do sistema que muda de forma observável a cada inicialização
- O
REPLY_FAULTtambém oferece um meio de o servidor matar clientes aleatoriamente para forçar caos, mas essa opção ainda não foi totalmente avaliada - Como tarefas Hubris comuns não geram dinamicamente mensagens de IPC intencionalmente inválidas, em geral elas conseguem executar sem sequer perceber a existência do
REPLY_FAULT
1 comentários
Opiniões do Hacker News
REPLY_FAULT parece bom quando o sistema é pequeno e bem integrado, e os aplicativos também são escritos principalmente pelas pessoas que projetaram o sistema inteiro.
Mas, do ponto de vista de um desenvolvedor de aplicativos, parece bastante assustador conectar-se a código de terceiros por um modelo de IPC em que outro serviço pode, a qualquer momento, devolver uma pílula de morte instantânea ao meu processo.
Não confio tanto assim em outros desenvolvedores de aplicativos. O mundo está cheio de motoristas péssimos e de processos em segundo plano feitos por desenvolvedores sofrendo pressão de gerentes, que podem muito bem enfiar um monte de REPLY_FAULT padrão talvez inadequados só para conseguir sair antes das 20h.
Nem todas as pré-condições eram fáceis de entender, e elas também podiam variar conforme o dispositivo ou a versão do SO.
O escopo sempre tende a crescer, mas acho que ninguém vai querer empurrar para uma tarefa do Hubris dentro de um controlador embarcado algo que seria melhor tratado no host.
Se o servidor diz “aquele cliente está errado”, o kernel mata esse cliente. O ponto central é que os dois não se entenderam.
Além disso, pode ser diferente do que você imagina ao dizer “processo”. No Hubris, todas as threads compartilham o mesmo espaço de endereçamento.
REPLY_FAULT se propaga em cadeia? Por exemplo, se A faz
SENDpara B e espera, B fazSENDpara C e espera, e C fazREPLY_FAULT, fico curioso se A também morre junto com B.Se não, uma tarefa maliciosa pode simplesmente delegar o experimento a uma tarefa auxiliar. Por outro lado, se sim, o conjunto parece bastante frágil, embora eu não conheça Hubris tão bem.
Além disso, se
SENDpuder ser circular ou mútuo, uma tarefa poderia matar a si mesma por engano. Em um caso como B → A → B, isso talvez crie um incentivo para não usar REPLY_FAULT.O motivo de um servidor poder revidar contra um cliente não é segurança, mas confiabilidade. A ideia é que erros venham de bugs, não de ataques intencionais, e a reação extrema do kernel ajuda o desenvolvedor a encontrar o problema o mais rápido possível.
Claro que há sobreposição com segurança, e isso pode servir como uma defesa reserva útil quando um processo tenta fazer algo que não deveria.
Hubris e o depurador Humility são tecnologias nas quais eu gostaria de mergulhar fundo se tivesse tempo, ou uma missão que exigisse isso. Infelizmente, agora não dá.
Em um sistema no qual uma única equipe escreve todo o código, é interessante que explodir um cliente da órbita só porque ele olhou torto possa acelerar o ciclo de desenvolvimento iterativo.
Depois de pegar no sono lendo sobre efeitos algébricos e ler este texto pela manhã, achei divertido. Com uma leve distorção, este é um kernel que permite ao servidor executar um efeito que o cliente não consegue tratar.
Reutilização e composição de código devem ficar muito mais difíceis, mas o modelo de execução fica muito mais simples. Em sistemas embarcados estáticos, certamente é uma troca adequada. Se precisar de reutilização, sempre dá para fazer vendor da tarefa e modificá-la.
Pelo contrário, no Unix há erros ignoráveis demais e, pessoalmente, acho que muitos deles deveriam ter gerado sinais fatais. Isso teria melhorado bastante a qualidade geral do software.
Por exemplo, chamar
close()em um descritor de arquivo inválido é um erro não fatal, então muitas vezes é ignorado. Mas, na prática, especialmente em apps multithread, isso é muito perigoso. Na maioria das vezes, fechar o descritor errado falha de forma inofensiva, mas em 1% dos casos fecha um socket de logging, um arquivo de lock de banco de dados ou uma conexão IPC não relacionada. É assim que se cria aquele software instável que todo mundo odeia.Isso me lembra a fala de Errand of Mercy: “Você descobrirá que há várias regras e regulamentos. Eles serão publicados. A violação até mesmo do menor deles é punida com a morte”.
Isso deveria virar uma RFC de Primeiro de Abril para HTTP.
Proponho HTTP 499 “Shame on you.”. Um cliente que receba 499 deve encerrar, de forma específica à linguagem, a tarefa que emitiu aquela requisição, talvez apenas para requisições iniciadas com um header específico como
Strict: true.Isso acerta perfeitamente o equilíbrio de “o que é isso… mas, na verdade, até que faz sentido?” que aparece nesse contexto.
Foi uma leitura muito divertida, e essa abordagem de um supervisor único lembra a forma como, em uma startup anterior, estruturávamos a aplicação para dar unwrap em tudo.
Também me lembrou um dos meus textos favoritos, https://medium.com/@mattklein123/crash-early-and-crash-often...
Fico me perguntando se isso é realmente agressivo demais
No Linux, é impossível fazer outro programa que está se comunicando apenas por sockets travar diretamente, exceto enviando dados inválidos para o socket
Mas certamente é possível matá-lo. Qualquer coisa em execução como root pode matar outra coisa, e também pode reiniciar e derrubar o sistema inteiro
É um pouco mais difícil e menos comum, mas ao menos em contêineres permissões de root são comuns. Claro que há cgroups, então fica mais restrito, mas esse é o ponto
Também é um pouco diferente da sabedoria convencional de “seja liberal no que recebe, conservador no que envia”. Mas talvez isso esteja mais ligado a sistemas de rede
Ainda assim, talvez seja inevitável que um sistema seja liberal no que aceita. Caso contrário, não haveria como mudar levemente uma API sem quebrar programas existentes, não?
Pelo que sei, ele também não permite novos tipos de processos em tempo de execução. Todos os executáveis possíveis precisam ser definidos em tempo de compilação
Sobre o trecho “não há como corrigir o problema e retomar a tarefa. Essa foi uma escolha consciente para evitar modos de falha sutis e simplificar o raciocínio sobre o sistema”, isso me faz lembrar a famosa frase de Einstein: “tão simples quanto possível, mas não mais simples”
Este projeto parece violar a condição da parte final. Não tenho interesse em um ambiente operacional que não tolere nenhuma bagunça do mundo real, e também não sei bem que área comercialmente viável aceitaria isso
No fim, a ideia é voltar para o sistema init e ficar tentando de novo? Mas por qual mecanismo ele entenderia a falha ocorrida e tentaria novamente de uma forma melhor?
De qualquer modo, aplaudo a pureza da convicção
Na verdade, como Cliff escreveu em detalhes no blog, REPLY_FAULT foi inicialmente um recurso que achávamos que talvez fosse agressivo demais, mas a experiência de construir, implantar e, francamente, depurar o sistema nos deu confiança de que ele tornaria nosso sistema mais robusto, não quebradiço de forma caprichosa
Dá para ver mais sobre a mentalidade e a realidade aqui em [0] e [1]
[0] https://www.mattkeeter.com/blog/2024-03-25-packing/
[1] https://cliffle.com/blog/who-killed-the-network-switch/
Mesmo em projetos de hobby, já vi barramentos I2C travarem com frequência quando um único bit do protocolo se embaralhava e derrubarem o sistema inteiro, então acho esse projeto bastante inspirador
Pelo que entendi, a questão é lidar não com casos de erro já conhecidos — ou seja, erros tratados —, mas com incompatibilidades de protocolo e coisas que nunca deveriam acontecer
Como outros comentários também apontaram, é um SO feito sob medida. Assim como você não faria uma UI em Erlang, Hubris também parece se encaixar bem no espaço que ocupa
A causa pode ser um bug, um ataque ou hardware danificado, e em qualquer um dos casos não se deve continuar. O chamador tem um problema sério, e prosseguir só causaria danos maiores
Isso soa um pouco parecido com a filosofia “let it crash” do Erlang/OTP. Erlang é usado em bastante hardware mission-critical e é famoso pela confiabilidade, então talvez na prática isso não seja uma desqualificação tão grande
Ele foi escrito para rodar nas entranhas profundas de um rack de servidores da 0xide
No trecho “tentativas de exploração muitas vezes aparecem primeiro como erros ou uso indevido de API, portanto um sistema que apaga o estado do componente que se comportou mal diante de qualquer mau funcionamento deve ser mais difícil de explorar”, aqui a aplicação acaba verificando de forma um pouco mais rigorosa aquilo que aceita
Portanto há um benefício de segurança, mas não do tipo que você está imaginando. Não é que ele destrua o progresso do atacante e o faça recuar; é que certos estados inválidos, que antes poderiam ser encadeados até um estado inválido mais desejado, deixam de funcionar
Então o atacante passa a procurar outro lugar em vez de tentar isso