1 pontos por GN⁺ 5 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • ymawky é um pequeno servidor HTTP estático para macOS escrito apenas em assembly aarch64, usando somente chamadas de sistema brutas do Darwin, sem wrappers da libc
  • Suporta GET, HEAD, PUT, OPTIONS, DELETE, requisições de intervalo de bytes, listagem de diretórios e páginas de erro personalizadas, mas não foi feito para substituir o nginx: é uma implementação que remove camadas de conveniência para entender como um servidor web realmente funciona
  • Parsing de requisições, decodificação de porcentagem, inspeção de cabeçalhos, conversão de valores de intervalo, tratamento de erros, fechamento de arquivos e geração de respostas precisam ser escritos manualmente; até algo equivalente a uma simples divisão de string em Python ou int(string) vira dezenas ou centenas de linhas de código de validação em assembly
  • O servidor usa uma arquitetura fork-on-request, chamando fork() a cada nova conexão; isso simplifica a implementação, mas reduz a capacidade de lidar com conexões simultâneas e pode deixá-lo vulnerável a slowloris, então são aplicados timeout de cabeçalhos e timeout do corpo com base em Content-Length
  • Em PUT, os dados são escritos primeiro em um arquivo temporário .ymawky_tmp_<pid> e substituídos no destino só em caso de sucesso; a segurança do sistema de arquivos também é tratada manualmente com prevenção de path traversal, O_NOFOLLOW_ANY, fstat64() e codificação de URL/escape HTML na listagem de diretórios

Visão geral e restrições do ymawky

  • ymawky é um pequeno servidor HTTP estático para macOS escrito apenas em assembly aarch64
  • Usa somente chamadas de sistema brutas do Darwin, sem wrappers da libc, e não utiliza bibliotecas externas nem parsers preexistentes
  • Os recursos suportados são GET, HEAD, PUT, OPTIONS, DELETE, requisições de intervalo de bytes, listagem de diretórios e páginas de erro personalizadas
  • As restrições do projeto são as seguintes
    • somente assembly aarch64
    • alvo macOS/Darwin
    • apenas syscalls brutas, sem wrappers da libc
    • apenas arquivos estáticos
    • sem parsers preexistentes
    • sem bibliotecas externas
  • O objetivo não é substituir o nginx, mas remover as camadas de conveniência para entender como um servidor web realmente funciona

O que é necessário ao criar um servidor web em assembly

  • Assembly é a camada entre código de máquina e linguagens de alto nível, e instruções como mov, add, ldr, str, cmp correspondem diretamente aos bytes do binário executável
  • svc #0x80 é a forma legível por humanos dos bytes D4 00 10 01 no binário executável
  • Como não existe tipo string, textos existem como regiões contíguas de bytes na memória; e, sem recursos de linguagem como struct do C, é preciso conhecer manualmente os offsets dos campos e o tamanho total
  • Como não há biblioteca HTTP, limpeza automática, exceções nem objetos, tarefas como parsing de requisições, tratamento de erros, fechamento de arquivos e geração de respostas precisam ser escritas manualmente
  • Mesmo que algo funcione errado, a CPU continuará executando sem aviso; o problema está nas instruções escritas e nos acessos à memória

Chamadas de sistema brutas e fluxo do servidor

  • Syscalls do Darwin

    • O ymawky chama o kernel diretamente, em vez de usar wrappers da libc
    • No Darwin aarch64, o número da syscall vai no registrador x16; no Linux aarch64, vai no x8
    • O número da syscall open() é 5, e o kernel é chamado com svc #0x80 após posicionar manualmente nos registradores argumentos como nome do arquivo e modo
    • Quando open() falha, o carry flag é definido, e a falha é tratada com um desvio que verifica esse flag, como b.cs open_failed
  • Funcionamento básico do servidor

    • O fluxo básico de um servidor web é receber a requisição, processá-la e retornar o código de status e os arquivos necessários
    • A configuração do socket é composta por etapas como socket(AF_INET, SOCK_STREAM, 0), setsockopt(... SO_REUSEADDR ...), bind(sockfd, &addr, 16), listen(sockfd, 5), accept(sockfd, NULL, NULL)
    • O ymawky é um servidor fork-on-request, chamando fork() a cada nova conexão
    • Esse modelo facilita o entendimento e a implementação por não compartilhar memória entre processamentos de requisições, mas aumenta a carga por causa do espaço de memória por processo e tem menor capacidade de conexões simultâneas que o modelo assíncrono, não bloqueante e orientado a eventos do nginx
    • À medida que o número de conexões simultâneas cresce, o kernel passa a gastar mais tempo alternando processos do que executando dentro deles
  • Trabalho necessário no processamento da requisição

    • Identificar se o método da requisição é GET, HEAD, OPTIONS, PUT ou DELETE
    • Extrair o caminho da requisição e decodificar percent-encoding como %20
    • Realizar verificações de segurança do caminho e fazer o parsing dos campos de cabeçalho enviados pelo cliente
    • Obter informações sobre o arquivo solicitado e distinguir se é diretório ou arquivo comum
    • Escrever o corpo de uma requisição PUT em arquivo temporário e gerar cabeçalhos e corpo da resposta
    • Fechar arquivos abertos e tratar erros para evitar que o servidor quebre

Implementando o parsing de HTTP manualmente

  • Linha da requisição e fim dos cabeçalhos

    • Uma requisição HTTP é uma string que o servidor precisa interpretar; um exemplo é o seguinte
      GET /index.html HTTP/1.0\r\n
      Range: bytes=1-5\r\n\r\n
      
    • A primeira linha contém a requisição GET, o arquivo de destino index.html e a versão HTTP HTTP/1.0
    • \r\n marca o fim de uma linha, e \r\n\r\n marca o fim dos cabeçalhos
    • Se \r\n\r\n não for recebido, o processamento deve ser interrompido com 400 Bad Request
  • Extração do caminho

    • O ymawky identifica o tipo de requisição comparando os primeiros bytes com os métodos suportados, e então extrai o caminho
    • Ele percorre o cabeçalho um byte por vez procurando / ou *, mas verifica se o byte anterior a / é um espaço para não confundir o / dentro de HTTP/1.0 com o caminho
    • Por exemplo, em GET HTTP/1.0\r\n\r\n, existe um / dentro de HTTP/1.0; se o byte anterior não for espaço, deve retornar 400 Bad Request
    • Como PATH_MAX é 4096 bytes na maioria dos sistemas, o ymawky usa filename_buffer: .skip 4097, com um buffer de 4096 bytes para o nome do arquivo e 1 byte para o terminador nulo
    • Se o caminho requisitado for maior que o buffer, ele deve retornar 414 URI Too Long em vez de sobrescrever memória arbitrária
    • Algo próximo de text.split("GET /")[1].split(" ")[0] em Python vira cerca de 200 linhas em assembly quando se inclui a validação de conformidade HTTP
  • Decodificação de porcentagem e inspeção de campos de cabeçalho

    • Ao encontrar % no caminho, é preciso verificar se os dois bytes seguintes formam um hexadecimal válido em 0-9, a-f, A-F, e então convertê-los para o valor correspondente
    • GET pode ter o cabeçalho Range:, e PUT exige Content-Length:
    • Como esses cabeçalhos não ficam em posições fixas como a URL da requisição, é necessário percorrer todo o cabeçalho caractere por caractere
    • Se após \r não vier \n, ou se aparecer \n sem um \r anterior, o cabeçalho é inválido e deve retornar 400 Bad Request
    • Se uma nova linha de cabeçalho começar com espaço em branco, ela também deve retornar 400 Bad Request, porque campos de cabeçalho não podem começar com espaço
  • Comparação de strings e conversão de números

    • Para encontrar Range: ou Content-Length:, foi escrita uma função streqn que recebe dois ponteiros de string x0, x1 e um comprimento máximo x2, e compara caractere por caractere
    • O cabeçalho Range: pode omitir o início ou o fim, mas um dos dois precisa existir, como nos exemplos abaixo
      Range: bytes=10-
      Range: bytes=-10
      Range: bytes=5-10
      
    • Como os valores de intervalo são strings, é necessária uma função estilo atoi para converter dígitos ASCII em inteiros
    • Para evitar overflow em registradores de 64 bits, números com 19 dígitos ou mais são tratados como erro
    • Até algo equivalente a int(string) em Python exige, em assembly, implementar manualmente validação de dígitos, multiplicação, soma e sinalização de sucesso ou falha baseada no carry flag

Tratamento de PUT e estratégia com arquivo temporário

  • PUT é um método idempotente: enviar a mesma requisição várias vezes leva o servidor ao mesmo estado final
  • PUT /file.txt cria file.txt ou sobrescreve completamente o arquivo existente; enviar 1234 duas vezes faz com que o conteúdo final seja 1234, e não 12341234
  • Um PUT amplamente aberto pode ser perigoso, e os problemas a considerar durante o processamento incluem os seguintes
    • o processo cair durante o tratamento da requisição
    • o cliente declarar Content-Length de 2 KB e enviar apenas 100 bytes
    • o cliente declarar um Content-Length muito grande, como 50 GB
  • Em config.S, MAX_BODY_SIZE é 1 GB por padrão; se Content-Length ultrapassar esse valor, a resposta deve ser 413 Content Too Large
  • Se o arquivo existente for aberto diretamente para escrita, uma falha pode deixar um arquivo parcialmente gravado; por isso, o ymawky primeiro escreve em um arquivo temporário no formato .ymawky_tmp_<pid>
  • O PID é obtido pela syscall getpid() número 20, e convertido em string por uma itoa() customizada com verificação de buffer overflow
  • Se todo o corpo enviado pelo cliente for gravado com sucesso no arquivo temporário, esse arquivo é então renomeado para o nome final, criando o arquivo solicitado no servidor
  • Se o cliente encerrar a conexão inesperadamente, houver timeout ou o corpo for inválido, o arquivo temporário é removido com a syscall unlink() número 10 ou unlinkat() número 472
  • O arquivo existente só é sobrescrito depois que uma requisição completa é transmitida com sucesso

Listagem de diretórios e tratamento de escape

  • Ao receber GET /somedir/, o servidor verifica se ALLOW_DIR_LISTING em config.S está habilitado
  • Se a listagem de diretórios estiver desativada, retorna 403 Forbidden
  • Se estiver ativada, preenche um buffer com informações dos arquivos do diretório solicitado usando a syscall getdirentries64() número 344
  • O buffer contém o nome de cada arquivo e o tamanho do nome, e o ymawky usa isso para gerar HTML clicável
  • Para cada arquivo, o formato básico enviado ao cliente é o seguinte
    <a href="filename">filename</a>
    
  • O nome do arquivo dentro de href="..." precisa ser percent-encoded como segmento de caminho de URL, enquanto o texto visível na página precisa passar por escape HTML
  • Se o nome do arquivo for &.-~><foo, o href vira %26.-~%3E%3Cfoo e o texto exibido vira &amp;.-~&gt;&lt;foo, produzindo a saída final abaixo
    <a href="%26.-~%3E%3Cfoo">&amp;.-~&gt;&lt;foo</a>
    
  • Assim, nomes como <script>something evil</script>, que poderiam permitir XSS na área do conteúdo, ou nomes como "><script>something dastardly</script>, que poderiam permitir XSS dentro de href="...", são codificados para não serem executados

Segurança de rede e timeouts

  • slowloris é um ataque de negação de serviço que mantém muitas conexões abertas sem concluir as requisições, prendendo recursos do servidor
  • Como o ymawky usa a arquitetura fork-on-request, ele pode ser vulnerável a slowloris
  • Se todos os cabeçalhos não forem recebidos dentro de HEADER_REQ_TIMEOUT_SECS em config.S, o servidor envia 408 Request Timeout e fecha a conexão
  • Se durante o recebimento do corpo da requisição o cliente ficar tempo demais sem enviar dados, o mesmo tipo de tratamento é aplicado com base em RECV_TIMEOUT de config.S
  • Um timeout simples por leitura não é suficiente
    • um cliente malicioso pode enviar Content-Length: 1073741823 e depois mandar 1 byte a cada 9 segundos; como o tamanho é apenas 1 byte menor que o máximo permitido, ele seria aceito, e com timeouts de 10 segundos por leitura o servidor poderia esperar por mais de 300 anos
  • Para mitigar isso, o ymawky calcula o timeout com base em Content-Length e em um número mínimo de bytes por segundo
    timeout = grace_period + content_length / min_bps
    
  • grace_period é o tempo mínimo dado a qualquer corpo de requisição, e min_bps é a velocidade de transmissão mais lenta que o servidor aceita
  • O min_bps padrão é 16 KB/s, generoso, mas não infinito
  • Esse método não impede completamente ataques de negação de serviço, mas limita por quanto tempo um ataque específico pode manter recursos ocupados

Segurança do sistema de arquivos

  • Ordem da verificação de informações do arquivo

    • Em GET e HEAD, o servidor abre o caminho requisitado e só então executa a syscall fstat64() número 339 no descritor de arquivo para obter informações como tipo e tamanho
    • Se primeiro executasse stat64() número 338 no caminho e só depois abrisse o arquivo, poderia ocorrer uma condição de corrida TOCTOU em que o arquivo mudasse entre a verificação e o uso
  • docroot e prevenção de path traversal

    • Todo caminho requisitado recebe o prefixo do docroot
    • O docroot padrão é www/, definido por DEFAULT_DIR em config.S
    • Uma requisição para /etc/shadow vira www/etc/shadow, então retornará 404 a menos que www/etc/shadow realmente exista
    • Mas /../../../../etc/shadow vira www/../../../../etc/shadow e pode ser resolvido para fora do docroot, então é necessária proteção adicional
    • O ymawky não rejeita simplesmente todo caminho que contenha a string ..; ele rejeita apenas quando um segmento do caminho é exatamente ..
    • Como %2E%2E vira .. após a decodificação, essa verificação precisa ser feita depois do percent-decoding
  • Tratamento de links simbólicos

    • O flag O_NOFOLLOW do POSIX faz open() falhar se o componente final do caminho for um link simbólico
    • O O_NOFOLLOW_ANY do Darwin faz a operação falhar se qualquer componente do caminho for um link simbólico
    • Se alguém puder inserir determinados links simbólicos dentro do docroot, provavelmente já há outro problema, mas esse flag ainda oferece uma camada extra de defesa

Comportamentos específicos da Apple

  • Tratamento de timeout e sigaction()

    • Para implementar timeouts de requisição, é preciso usar a syscall setitimer() número 83 para enviar SIGALRM após um determinado tempo
    • Por padrão, SIGALRM mata o processo filho, mas o ymawky precisa antes enviar 408 Request Timeout
    • Para isso, ele usa a syscall sigaction() número 46
    • A estrutura bruta de sigaction no Darwin expõe o campo sa_tramp
    • Normalmente a libc configura sa_tramp para salvar pilha e registradores, preparar sigreturn e então desviar para o handler
    • O handler de timeout do ymawky envia 408 Request Timeout, fecha o que for necessário e encerra o processo filho, então não precisa retornar
    • Por isso, o slot do trampoline é apontado para um código que executa diretamente a resposta de timeout, contornando sa_handler e sigreturn
  • proc_info() e limite do número de processos filhos

    • A Apple tem uma syscall proc_info() número 336, pouco documentada, que permite obter informações sobre processos em execução e seus filhos
    • Essa chamada costuma ser usada por ferramentas como ps, lsof e top
    • O ymawky usa proc_info() para contar o número de processos filhos ativos
    • Como o número máximo de conexões é configurável, ele precisa saber quantos filhos ainda estão vivos
    • proc_info() grava as informações dos filhos em um buffer; como o tamanho de cada elemento é conhecido, o número de filhos pode ser calculado a partir da quantidade de bytes gravados
    • Se o número de filhos ultrapassar MAX_PROCS, novas conexões são recusadas com 503 Service Unavailable

Conclusão e informações do projeto

  • Em um servidor web estático, a parte difícil não foi abrir sockets e chamar listen, mas sim fazer o parsing das requisições e tratar todas as condições de borda
  • Requisições, caminhos e respostas são todos bytes; requisições de intervalo precisam ser exatas, e nomes de arquivo precisam de escapes diferentes dependendo de onde aparecem
  • Assembly força a escrever manualmente tudo: parsing de requisições, gerenciamento de memória, tratamento de erros, conversão de strings, timeouts e segurança de arquivos
  • ymawky é mantido por imtomt

1 comentários

 
GN⁺ 5 시간 전
Opiniões no Lobste.rs
  • Impressionante. Há um tempo trabalhei integrando com uma empresa pequena que fazia dispositivos inteligentes, e o único engenheiro de lá só sabia linguagem assembly
    Do código de controle de hardware ao sistema operacional do servidor, passando pela JSON Web API que usávamos, tudo tinha sido escrito diretamente em assembly
    Uma vez encontramos um bug em que a API web retornava dados do dispositivo errado, e no fim descobrimos que havia um erro de off-by-one no sistema de escalonamento do sistema operacional, fazendo o “banco de dados” devolver a linha errada para o serviço web

    • Será que o nome dessa pessoa não era Mel?
  • Quando forem tratar de expressões como “suicídio”, por favor coloquem um aviso de conteúdo. Melhor ainda seria nem mencionar isso

    • Como assim? Eu li o texto meio por cima, mas na primeira vez não vi nenhuma menção a suicídio
      Vi este comentário, fui procurar de novo e ainda não achei; deixei passar alguma coisa?
    • A falta total de senso de humor é muito mais perigosa tanto para a própria pessoa quanto para a sociedade como um todo
  • Essa história de ter sido “tudo escrito em assembly” me fez lembrar do relatório de investigação do Therac-25