1 pontos por GN⁺ 2024-04-28 | 1 comentários | Compartilhar no WhatsApp
  • 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> e unwrap() 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_FAULT estende 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 chegue
    • SEND: pausa o chamador, entrega a mensagem e o controle à tarefa receptora e espera até receber uma resposta
    • REPLY: entrega uma resposta à tarefa que fez SEND anteriormente, 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 SEND atua como cliente
    • Uma tarefa que usa RECV e REPLY atua como servidor
    • Uma mesma tarefa pode ser servidor para algumas tarefas e cliente para outras

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 um bool
    • Também normalmente não acontece confundir o alvo e chamar fire_missiles quando se pretendia chamar pet_cat
  • 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 colocar unwrap() 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
  • Colocar unwrap() ou panic! 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 close em um descritor de arquivo não aberto retorna um código de erro
    • Passar um null pointer para open no lugar de um pathname também retorna um código de erro
  • 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
  • O REPLY_FAULT é parecido com REPLY, 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_FAULT só pode ser usado em uma tarefa que esse servidor recebeu com RECV e à qual ainda não respondeu com REPLY
    • Ele só funciona para clientes que estão aguardando resposta daquele servidor específico
  • O Hubris usa REPLY_FAULT para 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_FAULT pode 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!, o REPLY_FAULT se torna um meio para o servidor causar um panic! 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_FAULT també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

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

    • Isso parece ser o design pretendido, e é justamente esse tipo de ambiente que Hubris mira.
    • Isso de fato aconteceu no Symbian. Um servidor IPC podia provocar um panic no cliente, e, para desenvolvedores de aplicativos sem acesso ao código-fonte do SO, era algo bem horrível.
      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.
    • Matar rapidamente o que se desvia é uma forma de manter o sistema bem coeso. O próprio escopo projetado provavelmente já o manterá pequeno de qualquer maneira.
      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.
    • Em um ambiente embarcado, parece melhor resolver esse tipo de mal-entendido imediatamente, não importa de quem seja a culpa.
      Se o servidor diz “aquele cliente está errado”, o kernel mata esse cliente. O ponto central é que os dois não se entenderam.
    • Aqui, dá para ver o serviço como uma interface do SO. Em um kernel monolítico, também é razoável que o SO mate um processo que faça uma chamada de kernel inválida.
      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 SEND para B e espera, B faz SEND para C e espera, e C faz REPLY_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 SEND puder 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.

    • Hubris não parece ter sido projetado como um sistema operacional de uso geral. Os processos são definidos em tempo de build.
      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.
    • Se B sofrer fault, acho que A receberá um erro dizendo que o servidor morreu e terá a chance de reenviar a mesma mensagem ao servidor recém-reiniciado. Não parece ser uma falha em cadeia.
  • 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.

    • Se houver uma boa separação entre erros esperados, como arquivo inexistente, e erros inesperados, como um opcode inválido, não acho que a reutilização pioraria muito nem em programas comuns.
      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?

    • Hubris não é um SO de uso geral; ele roda em processadores de baixo nível dentro dos racks de servidores da Oxide
      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

    • Hubris não é um experimento acadêmico. Ele roda no centro de todos os elementos principais dos racks da Oxide — compute sleds, switches e controladores das power shelves —, e seu projeto se baseia, acima de tudo, na utilidade que entrega na prática
      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/
    • Watchdog timers matam ou reiniciam de bom grado processos que não os cutucam periodicamente
      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
    • Vejo isso como uma ideia a ser aplicada a problemas que são claramente resultado de um estado incorreto do programa. Por isso, não há como recuperar de forma razoável
      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
    • Este é um kernel de sistema embarcado em Rust com 2000 linhas que não dá suporte à adição de novas tarefas em tempo de execução
      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