Criando um servidor web em assembly aarch64 para dar (falta de) sentido à minha vida
(imtomt.github.io)- 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 emContent-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,cmpcorrespondem diretamente aos bytes do binário executável svc #0x80é a forma legível por humanos dos bytesD4 00 10 01no 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
structdo 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 nox8 - O número da syscall
open()é5, e o kernel é chamado comsvc #0x80apó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, comob.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,PUTouDELETE - 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
PUTem arquivo temporário e gerar cabeçalhos e corpo da resposta - Fechar arquivos abertos e tratar erros para evitar que o servidor quebre
- Identificar se o método da requisição é
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 destinoindex.htmle a versão HTTPHTTP/1.0 \r\nmarca o fim de uma linha, e\r\n\r\nmarca o fim dos cabeçalhos- Se
\r\n\r\nnão for recebido, o processamento deve ser interrompido com400 Bad Request
- Uma requisição HTTP é uma string que o servidor precisa interpretar; um exemplo é o seguinte
-
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 deHTTP/1.0com o caminho - Por exemplo, em
GET HTTP/1.0\r\n\r\n, existe um/dentro deHTTP/1.0; se o byte anterior não for espaço, deve retornar400 Bad Request - Como
PATH_MAXé 4096 bytes na maioria dos sistemas, o ymawky usafilename_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 Longem 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 em0-9,a-f,A-F, e então convertê-los para o valor correspondente GETpode ter o cabeçalhoRange:, ePUTexigeContent-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
\rnão vier\n, ou se aparecer\nsem um\ranterior, o cabeçalho é inválido e deve retornar400 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
- Ao encontrar
-
Comparação de strings e conversão de números
- Para encontrar
Range:ouContent-Length:, foi escrita uma funçãostreqnque recebe dois ponteiros de stringx0,x1e um comprimento máximox2, 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 abaixoRange: bytes=10- Range: bytes=-10 Range: bytes=5-10 - Como os valores de intervalo são strings, é necessária uma função estilo
atoipara 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
- Para encontrar
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 finalPUT /file.txtcriafile.txtou sobrescreve completamente o arquivo existente; enviar1234duas vezes faz com que o conteúdo final seja1234, e não12341234- Um
PUTamplamente 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-Lengthde 2 KB e enviar apenas 100 bytes - o cliente declarar um
Content-Lengthmuito grande, como 50 GB
- Em
config.S,MAX_BODY_SIZEé 1 GB por padrão; seContent-Lengthultrapassar esse valor, a resposta deve ser413 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úmero20, e convertido em string por umaitoa()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úmero10ouunlinkat()número472 - 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 seALLOW_DIR_LISTINGemconfig.Sestá 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úmero344 - 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, ohrefvira%26.-~%3E%3Cfooe o texto exibido vira&.-~><foo, produzindo a saída final abaixo<a href="%26.-~%3E%3Cfoo">&.-~><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 dehref="...", 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_SECSemconfig.S, o servidor envia408 Request Timeoute 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_TIMEOUTdeconfig.S - Um timeout simples por leitura não é suficiente
- um cliente malicioso pode enviar
Content-Length: 1073741823e 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
- um cliente malicioso pode enviar
- Para mitigar isso, o ymawky calcula o timeout com base em
Content-Lengthe em um número mínimo de bytes por segundotimeout = grace_period + content_length / min_bps grace_periodé o tempo mínimo dado a qualquer corpo de requisição, emin_bpsé a velocidade de transmissão mais lenta que o servidor aceita- O
min_bpspadrã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
GETeHEAD, o servidor abre o caminho requisitado e só então executa a syscallfstat64()número339no descritor de arquivo para obter informações como tipo e tamanho - Se primeiro executasse
stat64()número338no 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
- Em
-
docroot e prevenção de path traversal
- Todo caminho requisitado recebe o prefixo do docroot
- O docroot padrão é
www/, definido porDEFAULT_DIRemconfig.S - Uma requisição para
/etc/shadowvirawww/etc/shadow, então retornará 404 a menos quewww/etc/shadowrealmente exista - Mas
/../../../../etc/shadowvirawww/../../../../etc/shadowe 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%2Evira..após a decodificação, essa verificação precisa ser feita depois do percent-decoding
-
Tratamento de links simbólicos
- O flag
O_NOFOLLOWdo POSIX fazopen()falhar se o componente final do caminho for um link simbólico - O
O_NOFOLLOW_ANYdo 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
- O flag
Comportamentos específicos da Apple
-
Tratamento de timeout e
sigaction()- Para implementar timeouts de requisição, é preciso usar a syscall
setitimer()número83para enviarSIGALRMapós um determinado tempo - Por padrão,
SIGALRMmata o processo filho, mas o ymawky precisa antes enviar408 Request Timeout - Para isso, ele usa a syscall
sigaction()número46 - A estrutura bruta de
sigactionno Darwin expõe o camposa_tramp - Normalmente a libc configura
sa_tramppara salvar pilha e registradores, prepararsigreturne 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_handleresigreturn
- Para implementar timeouts de requisição, é preciso usar a syscall
-
proc_info()e limite do número de processos filhos- A Apple tem uma syscall
proc_info()número336, pouco documentada, que permite obter informações sobre processos em execução e seus filhos - Essa chamada costuma ser usada por ferramentas como
ps,lsofetop - 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 com503 Service Unavailable
- A Apple tem uma syscall
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
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
Quando forem tratar de expressões como “suicídio”, por favor coloquem um aviso de conteúdo. Melhor ainda seria nem mencionar isso
Vi este comentário, fui procurar de novo e ainda não achei; deixei passar alguma coisa?
Essa história de ter sido “tudo escrito em assembly” me fez lembrar do relatório de investigação do Therac-25