1 puntos por GN⁺ 5 시간 전 | 1 comentarios | Compartir por WhatsApp
  • ymawky es un pequeño servidor HTTP estático para macOS escrito únicamente en ensamblador aarch64, y usa solo llamadas al sistema nativas de Darwin sin wrappers de libc
  • Soporta GET, HEAD, PUT, OPTIONS, DELETE, solicitudes de rango por bytes, listado de directorios y páginas de error personalizadas, pero no busca reemplazar a nginx, sino eliminar capas de conveniencia para entender cómo funciona un servidor web
  • Hay que escribir a mano todo: parseo de solicitudes, decodificación porcentual, validación de encabezados, conversión de valores de rango, manejo de errores, cierre de archivos y generación de respuestas; incluso tareas equivalentes a una simple separación de cadenas en Python o int(string) se convierten en decenas o cientos de líneas de validación en ensamblador
  • El servidor usa una arquitectura fork-on-request que llama a fork() por cada conexión nueva; esto facilita la implementación, pero reduce el rendimiento con conexiones concurrentes y puede hacerlo vulnerable a slowloris, por lo que aplica timeouts de encabezados y del cuerpo basados en Content-Length
  • PUT escribe primero en un archivo temporal .ymawky_tmp_<pid> y reemplaza el destino solo si tiene éxito; además, maneja directamente la seguridad del sistema de archivos, como la prevención de path traversal, O_NOFOLLOW_ANY, fstat64(), y la codificación URL y escape HTML en los listados de directorios

Resumen y limitaciones de ymawky

  • ymawky es un pequeño servidor HTTP estático para macOS escrito únicamente en ensamblador aarch64
  • Usa solo llamadas al sistema nativas de Darwin sin wrappers de libc, y no utiliza bibliotecas externas ni parsers preexistentes
  • Las funciones compatibles son GET, HEAD, PUT, OPTIONS, DELETE, solicitudes de rango por bytes, listado de directorios y páginas de error personalizadas
  • Las restricciones del proyecto son las siguientes
    • solo ensamblador aarch64
    • objetivo macOS/Darwin
    • solo syscalls directas, sin wrappers de libc
    • solo archivos estáticos
    • sin parsers preexistentes
    • sin bibliotecas externas
  • No pretende reemplazar a nginx, sino ser una implementación que elimina capas de conveniencia para entender cómo funciona realmente un servidor web

Trabajo necesario para crear un servidor web en ensamblador

  • El ensamblador es la capa entre el lenguaje máquina y los lenguajes de alto nivel, y las instrucciones como mov, add, ldr, str, cmp corresponden directamente a los bytes del binario ejecutable
  • svc #0x80 es la forma legible para humanos de los bytes D4 00 10 01 en el binario ejecutable
  • No existe un tipo string, así que las cadenas son regiones contiguas de bytes en memoria, y como tampoco hay funciones del lenguaje como struct de C, hay que conocer manualmente los offsets de cada campo y el tamaño total
  • Como no hay bibliotecas HTTP, limpieza automática, excepciones ni objetos, tareas como parsear solicitudes, manejar errores, cerrar archivos y generar respuestas deben escribirse todas a mano
  • Aunque algo funcione mal, la CPU seguirá ejecutándolo sin advertencias, así que el problema está en las instrucciones y accesos a memoria que se escribieron

Syscalls directas y flujo del servidor

  • Syscalls de Darwin

    • ymawky llama directamente al kernel en lugar de usar wrappers de libc
    • En Darwin aarch64, el número de syscall se pone en el registro x16, mientras que en Linux aarch64 se usa x8
    • El número de syscall de open() es 5, y tras colocar manualmente en registros argumentos como el nombre del archivo y el modo, se invoca al kernel con svc #0x80
    • Si open() falla, se activa el carry flag, y se puede saltar al código de error comprobándolo con algo como b.cs open_failed
  • Funcionamiento básico del servidor

    • El flujo básico de un servidor web consiste en recibir una solicitud, procesarla y devolver un código de estado junto con el archivo necesario
    • La configuración del socket incluye pasos como socket(AF_INET, SOCK_STREAM, 0), setsockopt(... SO_REUSEADDR ...), bind(sockfd, &addr, 16), listen(sockfd, 5), accept(sockfd, NULL, NULL)
    • ymawky es un servidor fork-on-request que llama a fork() por cada conexión nueva
    • Este enfoque facilita la comprensión y la implementación porque no comparte memoria entre solicitudes, pero la sobrecarga por espacio de memoria por proceso es mayor y el rendimiento con conexiones concurrentes es inferior al modelo asíncrono no bloqueante y basado en eventos de nginx
    • A medida que aumentan las conexiones concurrentes, el kernel termina dedicando más tiempo al cambio de procesos que a la ejecución dentro de cada proceso
  • Trabajo necesario en el procesamiento de solicitudes

    • Determinar si el método de la solicitud es GET, HEAD, OPTIONS, PUT o DELETE
    • Extraer la ruta solicitada y decodificar codificación porcentual como %20
    • Realizar verificaciones de seguridad de la ruta y parsear los encabezados enviados por el cliente
    • Obtener información del archivo solicitado para distinguir si es un directorio o un archivo regular
    • Escribir el cuerpo de una solicitud PUT en un archivo temporal y generar los encabezados y cuerpo de la respuesta
    • Cerrar los archivos abiertos y manejar errores para evitar que el servidor se caiga

Implementar el parseo HTTP manualmente

  • Línea de solicitud y fin de encabezados

    • Una solicitud HTTP es una cadena que el servidor debe interpretar, por ejemplo:
      GET /index.html HTTP/1.0\r\n
      Range: bytes=1-5\r\n\r\n
      
    • La primera línea contiene una solicitud GET, el archivo destino index.html y la versión HTTP HTTP/1.0
    • \r\n marca el final de una línea, y \r\n\r\n indica el final de los encabezados
    • Si no se recibe \r\n\r\n, hay que abortar con 400 Bad Request
  • Extracción de la ruta

    • ymawky determina el tipo de solicitud comparando el método soportado y los primeros bytes, y luego extrae la ruta
    • Recorre el encabezado byte por byte buscando / o *, pero verifica que el byte anterior a / sea un espacio para no confundir la / dentro de HTTP/1.0 con una ruta
    • Por ejemplo, en GET HTTP/1.0\r\n\r\n aparece una / dentro de HTTP/1.0, así que si el byte anterior no es un espacio, devuelve 400 Bad Request
    • Como en la mayoría de sistemas PATH_MAX es 4096 bytes, ymawky reserva un buffer de nombre de archivo de 4096 bytes más 1 byte para el terminador nulo con filename_buffer: .skip 4097
    • Si la ruta solicitada es más larga que el buffer, debe devolver 414 URI Too Long en lugar de sobrescribir memoria arbitraria
    • Una operación parecida a text.split("GET /")[1].split(" ")[0] en Python termina ocupando unas 200 líneas en ensamblador al incluir también validaciones de legalidad HTTP
  • Decodificación porcentual y validación de encabezados

    • Al encontrar % en la ruta, verifica si los dos bytes siguientes son dígitos hexadecimales válidos de 0-9, a-f o A-F, y los convierte al valor de byte correspondiente
    • GET puede incluir un encabezado Range:, y PUT requiere Content-Length:
    • Como estos encabezados no están en una posición fija como la URL de la solicitud, hay que recorrer todo el encabezado carácter por carácter
    • Si después de \r no viene \n, o aparece \n sin un \r previo, se considera un encabezado inválido y se devuelve 400 Bad Request
    • Si una nueva línea de encabezado comienza con un espacio, se devuelve 400 Bad Request porque un campo de encabezado no puede empezar con espacios
  • Comparación de strings y conversión numérica

    • Para encontrar Range: o Content-Length:, se implementa una función streqn que recibe dos punteros a cadena x0, x1 y una longitud máxima x2, y compara carácter por carácter
    • El encabezado Range: puede omitir el inicio o el final, pero al menos uno de los dos debe existir, como en:
      Range: bytes=10-
      Range: bytes=-10
      Range: bytes=5-10
      
    • Como los valores de rango son strings, se necesita una función estilo atoi para convertir dígitos ASCII a enteros
    • Para evitar overflow en registros de 64 bits, si el número tiene 19 dígitos o más se trata como error
    • Incluso una operación equivalente a int(string) en Python exige implementar manualmente validación numérica, multiplicación, suma y señales de éxito o fallo basadas en carry flag

Manejo de PUT y estrategia de archivo temporal

  • PUT es un método idempotente: aunque se envíe la misma solicitud varias veces, el estado final del servidor debe ser el mismo
  • PUT /file.txt crea file.txt o sobrescribe completamente el existente, y si se envía 1234 dos veces, el contenido final no será 12341234, sino 1234
  • Permitir PUT globalmente puede ser riesgoso, y entre los problemas a considerar durante su procesamiento están:
    • que el proceso falle durante el manejo de la solicitud
    • que el cliente declare Content-Length de 2 KB y solo envíe 100 bytes
    • que el cliente envíe un Content-Length enorme, como 50 GB
  • MAX_BODY_SIZE en config.S es de 1 GB por defecto, y si Content-Length lo supera, devuelve 413 Content Too Large
  • Si se abre y escribe directamente sobre un archivo existente, un fallo puede dejar un archivo escrito a medias, así que ymawky primero escribe en un archivo temporal con formato .ymawky_tmp_<pid>
  • Obtiene el pid con la syscall getpid() número 20 y lo convierte en string con una itoa() personalizada, comprobando desbordamientos de buffer
  • Si el cuerpo enviado por el cliente se escribe completo en el archivo temporal y todo sale bien, el archivo temporal se renombra al nombre definitivo y el archivo solicitado aparece en el servidor
  • Si el cliente corta la conexión inesperadamente, ocurre un timeout o envía un cuerpo inválido, el archivo temporal se elimina con la syscall unlink() 10 o unlinkat() 472
  • El archivo existente solo se sobrescribe después de que una solicitud completa se haya transferido correctamente

Listado de directorios y manejo de escapes

  • Al recibir una solicitud GET /somedir/, se comprueba si ALLOW_DIR_LISTING en config.S está habilitado
  • Si el listado de directorios está desactivado, devuelve 403 Forbidden
  • Si está activado, llena un buffer con información de archivos del directorio solicitado mediante la syscall getdirentries64() 344
  • El buffer incluye cada nombre de archivo y la longitud de ese nombre, y ymawky lo usa para generar HTML clickeable
  • Para cada archivo, la forma básica enviada al cliente es:
    <a href="filename">filename</a>
    
  • El nombre dentro de href="..." debe codificarse con percent-encoding como segmento de ruta URL, mientras que el texto visible en pantalla debe llevar escape HTML
  • Si el nombre del archivo es &.-~><foo, el href pasa a ser %26.-~%3E%3Cfoo, y el texto visible &amp;.-~&gt;&lt;foo, dando como salida final:
    <a href="%26.-~%3E%3Cfoo">&amp;.-~&gt;&lt;foo</a>
    
  • Así se evita la ejecución de nombres capaces de provocar XSS en el cuerpo, como <script>something evil</script>, o en el área href="...", como "><script>something dastardly</script>

Seguridad de red y timeouts

  • slowloris es un ataque de denegación de servicio que mantiene muchas conexiones abiertas sin terminar las solicitudes, ocupando recursos del servidor
  • Como ymawky usa una estructura fork-on-request, puede ser vulnerable a slowloris
  • Si el encabezado completo no se recibe dentro de HEADER_REQ_TIMEOUT_SECS de config.S, envía 408 Request Timeout y cierra la conexión
  • Si durante la recepción del cuerpo de la solicitud el cliente deja de enviar datos por demasiado tiempo, se maneja igual según RECV_TIMEOUT en config.S
  • Un timeout simple por cada lectura no es suficiente
    • un cliente malicioso podría enviar Content-Length: 1073741823 y luego mandar 1 byte cada 9 segundos; como el tamaño del contenido es 1 byte menor que el máximo permitido, pasaría la validación y con un timeout de 10 segundos por lectura el servidor podría esperar más de 300 años
  • Para reducir esto, ymawky calcula el timeout en función de Content-Length y una velocidad mínima de bytes por segundo:
    timeout = grace_period + content_length / min_bps
    
  • grace_period es el tiempo mínimo concedido a cualquier cuerpo, y min_bps es la velocidad de transferencia más lenta que el servidor permite
  • El valor por defecto de min_bps es 16 KB/s, suficientemente amplio, pero no infinito
  • Este enfoque no bloquea por completo los ataques de denegación de servicio, pero sí limita cuánto tiempo puede un ataque específico retener recursos

Seguridad del sistema de archivos

  • Orden para comprobar la información del archivo

    • En GET y HEAD, primero se abre la ruta solicitada y luego se ejecuta la syscall fstat64() 339 sobre el file descriptor para obtener información como el tipo y el tamaño del archivo
    • Si primero se ejecuta stat64() 338 sobre la ruta y después se abre el archivo, puede aparecer una TOCTOU race condition en la que el archivo cambie entre la verificación y el uso
  • docroot y prevención de path traversal

    • A toda ruta solicitada se le antepone el docroot
    • El docroot por defecto es www/, definido como DEFAULT_DIR en config.S
    • Una solicitud a /etc/shadow se convierte en www/etc/shadow, así que será 404 salvo que www/etc/shadow exista realmente
    • Pero /../../../../etc/shadow se convierte en www/../../../../etc/shadow y podría resolverse fuera del docroot, por lo que se necesita defensa adicional
    • ymawky no rechaza simplemente cualquier ruta que contenga la cadena .., sino aquellas cuyos segmentos de ruta sean exactamente ..
    • Como %2E%2E se convierte en .. tras decodificarse, esta verificación debe ejecutarse después de la decodificación porcentual
  • Manejo de enlaces simbólicos

    • El flag O_NOFOLLOW de POSIX hace que open() falle si el componente final de la ruta es un enlace simbólico
    • O_NOFOLLOW_ANY de Darwin hace que falle si cualquier componente de la ruta es un enlace simbólico
    • Si alguien ya puede plantar ciertos enlaces simbólicos dentro del docroot, probablemente ya exista otro problema, pero este flag añade una capa extra de defensa

Comportamientos específicos de Apple

  • Manejo de timeouts y sigaction()

    • Para implementar timeouts de solicitud, hay que hacer que setitimer() syscall 83 envíe SIGALRM tras cierto tiempo
    • Por defecto, SIGALRM mata al child, pero ymawky primero necesita enviar 408 Request Timeout
    • Para ello usa la syscall sigaction() 46
    • La estructura raw de sigaction en Darwin expone el campo sa_tramp
    • Normalmente, libc configura sa_tramp para guardar stack y registros, preparar sigreturn y luego saltar al handler
    • El handler de timeout de ymawky envía 408 Request Timeout, cierra lo necesario y termina el child, por lo que no necesita retornar
    • Por eso apunta el slot del trampoline directamente al código que responde al timeout, evitando sa_handler y sigreturn
  • proc_info() y límite de procesos child

    • En Apple existe una syscall poco documentada, proc_info() 336, que permite obtener procesos en ejecución y datos de sus children
    • Esta llamada suele usarse en herramientas como ps, lsof y top
    • ymawky usa proc_info() para contar la cantidad de procesos child activos
    • Como el máximo de conexiones es configurable, necesita conocer cuántos children siguen vivos
    • proc_info() escribe información de los child processes en un buffer, y como se conoce el tamaño de cada elemento, el número de children se calcula a partir de los bytes escritos
    • Si la cantidad de children supera MAX_PROCS, las conexiones nuevas se rechazan con 503 Service Unavailable

Conclusión e información del proyecto

  • En un servidor web estático, la parte difícil no fue abrir sockets y hacer listen, sino parsear solicitudes y manejar todas las condiciones de borde
  • Las solicitudes, rutas y respuestas son solo bytes; las solicitudes de rango deben ser exactas y los nombres de archivo deben escaparse de forma distinta según el contexto
  • El ensamblador obliga a escribir manualmente todo: parseo de solicitudes, manejo de memoria, tratamiento de errores, conversión de strings, timeouts y seguridad de archivos
  • ymawky es mantenido por imtomt

1 comentarios

 
GN⁺ 5 시간 전
Comentarios de Lobste.rs
  • Impresionante. Hace tiempo trabajé integrando con una pequeña empresa que fabricaba dispositivos inteligentes, y su único ingeniero solo sabía lenguaje ensamblador
    Desde el código de control de hardware hasta el sistema operativo del servidor, e incluso la API web JSON que usábamos, todo estaba escrito directamente en ensamblador
    Una vez nos topamos con un bug en el que la API web devolvía datos del dispositivo equivocado, y resultó que había un error off-by-one en el sistema de planificación del sistema operativo, así que la “base de datos” le devolvía la fila incorrecta al servicio web

    • ¿De casualidad esa persona no se llamaba Mel?
  • Cuando se tratan expresiones como “suicidio”, por favor pongan una advertencia de contenido. Mejor aún, sería preferible no mencionarlo en absoluto

    • ¿Eh? Leí por encima partes del artículo, pero la primera vez no vi ninguna mención al suicidio
      Volví a buscarlo después de ver este comentario y tampoco lo encontré; ¿se me pasó algo?
    • La falta total de sentido del humor es mucho más peligrosa, tanto para la salud de uno mismo como para la sociedad en general
  • Eso de que estaba “todo escrito en ensamblador” me hizo pensar en el informe de investigación del Therac-25