내 삶에 의미(부족)를 주기 위해 aarch64 어셈블리로 웹 서버 만들기

4 hours ago 1
  • ymawkyaarch64 어셈블리만으로 작성된 macOS용 소형 정적 HTTP 서버이며, libc 래퍼 없이 Darwin 원시 시스템 호출만 사용함
  • GET, HEAD, PUT, OPTIONS, DELETE, 바이트 범위 요청, 디렉터리 목록, 사용자 정의 오류 페이지를 지원하지만, nginx 대체가 아니라 웹 서버 동작을 이해하기 위해 편의 계층을 제거한 구현임
  • 요청 파싱, 퍼센트 디코딩, 헤더 검사, 범위 값 변환, 오류 처리, 파일 닫기, 응답 생성까지 모두 직접 작성해야 하며, Python의 간단한 문자열 분리나 int(string)에 해당하는 작업도 어셈블리에서는 수십~수백 줄의 검증 코드가 됨
  • 서버는 새 연결마다 fork()를 호출하는 fork-on-request 구조라 구현은 쉽지만 동시 연결 처리량이 낮고 slowloris에 취약할 수 있어, 헤더 타임아웃과 Content-Length 기반 본문 타임아웃을 적용함
  • PUT은 .ymawky_tmp_<pid> 임시 파일에 먼저 쓰고 성공 시 교체하며, 경로 순회 방지, O_NOFOLLOW_ANY, fstat64(), 디렉터리 목록의 URL 인코딩·HTML 이스케이프 등 파일시스템 안전성을 직접 처리함

ymawky 개요와 제약

  • ymawkyaarch64 어셈블리만으로 작성된 macOS용 소형 정적 HTTP 서버임
  • libc 래퍼 없이 Darwin 원시 시스템 호출만 사용하며, 외부 라이브러리나 기존 파서는 쓰지 않음
  • 지원 기능은 GET, HEAD, PUT, OPTIONS, DELETE, 바이트 범위 요청, 디렉터리 목록, 사용자 정의 오류 페이지임
  • 프로젝트 제약은 다음과 같음
    • aarch64 assembly only
    • macOS/Darwin 대상
    • raw syscalls only, libc wrappers 없음
    • static files only
    • preexisting parsers 없음
    • external libraries 없음
  • nginx를 대체하려는 목적은 아니며, 웹 서버가 실제로 어떻게 동작하는지 이해하기 위해 편의 계층을 제거한 구현임

어셈블리로 웹 서버를 만들 때 필요한 작업

  • 어셈블리는 기계어와 고수준 언어 사이의 계층이며, mov, add, ldr, str, cmp 같은 명령은 실행 바이너리의 바이트와 직접 대응함
  • svc #0x80은 실행 바이너리의 D4 00 10 01 바이트에 해당하는 사람이 읽을 수 있는 형태임
  • 문자열 타입이 없어서 문자열은 메모리에 연속된 바이트 영역으로 존재하며, C의 struct 같은 언어 기능도 없어 필드 오프셋과 전체 크기를 직접 알아야 함
  • HTTP 라이브러리, 자동 정리, 예외, 객체가 없기 때문에 요청 파싱, 오류 처리, 파일 닫기, 응답 생성 같은 작업을 모두 직접 작성해야 함
  • 잘못 동작해도 CPU는 경고 없이 그대로 실행하므로, 문제는 작성한 명령과 메모리 접근에 있음

원시 시스템 호출과 서버 흐름

  • Darwin 시스템 호출

    • ymawky는 libc 래퍼 대신 커널을 직접 호출함
    • Darwin aarch64에서는 시스템 호출 번호를 x16 레지스터에 넣고, Linux aarch64에서는 x8에 넣음
    • open() 시스템 호출 번호는 5이며, 파일명과 모드 같은 인자를 레지스터에 직접 배치한 뒤 svc #0x80으로 커널을 호출함
    • open() 실패 시 carry flag가 설정되고, b.cs open_failed처럼 carry flag를 검사해 실패 처리 코드로 분기함
  • 기본 서버 동작

    • 웹 서버의 기본 흐름은 요청을 받고, 처리하고, 상태 코드와 필요한 파일을 반환하는 구조임
    • 소켓 설정은 socket(AF_INET, SOCK_STREAM, 0), setsockopt(... SO_REUSEADDR ...), bind(sockfd, &addr, 16), listen(sockfd, 5), accept(sockfd, NULL, NULL) 같은 단계로 구성됨
    • ymawky는 새 연결마다 fork()를 호출하는 fork-on-request 서버임
    • 이 방식은 요청 처리 간 메모리를 공유하지 않아 이해와 구현이 쉽지만, 프로세스별 메모리 공간 때문에 부하가 커지고 nginx의 이벤트 기반 비동기 논블로킹 모델보다 동시 연결 처리량이 낮음
    • 동시 연결이 늘어나면 커널이 프로세스 내부 실행보다 프로세스 전환에 더 많은 시간을 쓰게 됨
  • 요청 처리에서 필요한 작업

    • 요청 메서드가 GET, HEAD, OPTIONS, PUT, DELETE 중 무엇인지 판별함
    • 요청 경로를 추출하고 %20 같은 퍼센트 인코딩을 디코딩함
    • 경로 안전성 검사를 수행하고, 클라이언트가 보낸 헤더 필드를 파싱함
    • 요청 파일 정보를 가져와 디렉터리인지 일반 파일인지 구분함
    • PUT 요청 본문은 임시 파일에 쓰고, 응답 헤더와 본문을 생성함
    • 열린 파일을 닫고 서버가 충돌하지 않도록 오류를 처리함

HTTP 파싱 직접 구현하기

  • 요청 라인과 헤더 종료

    • HTTP 요청은 서버가 해석해야 하는 문자열이며, 예시는 다음과 같음 GET /index.html HTTP/1.0\r\n Range: bytes=1-5\r\n\r\n
    • 첫 줄은 GET 요청, 대상 파일 index.html, HTTP 버전 HTTP/1.0을 담음
    • \r\n은 줄의 끝이고, \r\n\r\n은 헤더의 끝임
    • \r\n\r\n을 받지 못하면 400 Bad Request로 중단해야 함
  • 경로 추출

    • ymawky는 지원 메서드와 첫 바이트들을 비교해 요청 유형을 판별한 뒤 경로를 추출함
    • 헤더를 한 바이트씩 스캔해 / 또는 *를 찾지만, HTTP/1.0 안의 /를 경로로 오해하지 않도록 / 직전 바이트가 공백인지 확인함
    • 예를 들어 GET HTTP/1.0\r\n\r\n에는 HTTP/1.0 안에 /가 있으므로, 직전 바이트가 공백이 아니면 400 Bad Request를 반환함
    • 대부분의 시스템에서 PATH_MAX가 4096바이트이므로, ymawky는 4096바이트 파일명 버퍼와 널 종료 문자 1바이트를 위한 filename_buffer: .skip 4097을 둠
    • 요청 경로가 버퍼보다 길면 임의 메모리를 덮어쓰는 대신 414 URI Too Long을 반환해야 함
    • Python의 text.split("GET /")[1].split(" ")[0]에 가까운 작업이 어셈블리에서는 HTTP 적법성 검사까지 포함해 약 200줄이 됨
  • 퍼센트 디코딩과 헤더 필드 검사

    • 경로에서 %를 만나면 다음 두 바이트가 0-9, a-f, A-F에 해당하는 유효한 16진수인지 확인하고, 해당 바이트 값으로 변환함
    • GET은 Range: 헤더를 가질 수 있고, PUT은 Content-Length:가 필요함
    • 이 헤더들은 요청 URL처럼 고정된 위치에 있지 않으므로 헤더 전체를 문자 단위로 순회해야 함
    • \r 다음에 \n이 없거나, 앞선 \r 없이 \n이 나오면 잘못된 헤더로 보고 400 Bad Request를 반환함
    • 새 헤더 줄이 공백으로 시작하면 헤더 필드는 공백으로 시작할 수 없으므로 400 Bad Request를 반환함
  • 문자열 비교와 숫자 변환

    • Range:나 Content-Length:를 찾기 위해 두 문자열 포인터 x0, x1과 최대 길이 x2를 받아 문자 단위로 비교하는 streqn 함수를 작성함
    • Range: 헤더는 다음처럼 시작과 끝 중 하나가 생략될 수 있지만, 둘 중 하나는 반드시 있어야 함 Range: bytes=10- Range: bytes=-10 Range: bytes=5-10
    • 범위 값은 문자열이므로 ASCII 숫자를 정수로 바꾸는 atoi 스타일 함수가 필요함
    • 64비트 레지스터 오버플로를 피하기 위해 숫자가 19자리 이상이면 오류로 처리함
    • Python의 int(string)에 해당하는 작업도 어셈블리에서는 숫자 검사, 곱셈, 덧셈, carry flag 기반 성공·실패 신호를 직접 구현해야 함

PUT 처리와 임시 파일 전략

  • PUT은 같은 요청을 여러 번 보내도 최종 서버 상태가 같아지는 멱등(idempotent) 메서드임
  • PUT /file.txt는 file.txt를 만들거나 기존 파일을 완전히 덮어쓰며, 1234를 두 번 보내도 파일 내용은 12341234가 아니라 1234임
  • 전역으로 열린 PUT은 위험할 수 있으며, 처리 중 고려할 문제는 다음과 같음
    • 요청 처리 중 프로세스가 충돌하는 경우
    • 클라이언트가 Content-Length를 2KB라고 말하고 100바이트만 보내는 경우
    • 클라이언트가 Content-Length를 50GB처럼 매우 크게 보내는 경우
  • config.S의 MAX_BODY_SIZE는 기본 1GB이며, Content-Length가 이를 넘으면 413 Content Too Large를 반환함
  • 기존 파일을 바로 열어 쓰면 실패 시 반쯤 쓰인 파일이 남을 수 있으므로, ymawky는 먼저 .ymawky_tmp_<pid> 형식의 임시 파일에 씀
  • getpid() 시스템 호출 번호 20으로 pid를 얻고, 사용자 정의 itoa()로 문자열로 변환하되 버퍼 오버플로를 검사함
  • 클라이언트 본문을 임시 파일에 모두 쓰고 성공하면 임시 파일을 제자리 이름으로 바꿔 요청 파일이 서버에 생김
  • 클라이언트가 예기치 않게 연결을 끊거나, 시간 초과가 나거나, 잘못된 본문을 보내면 임시 파일을 unlink() 시스템 호출 10 또는 unlinkat() 시스템 호출 472로 삭제함
  • 기존 파일은 완전한 요청이 성공적으로 전송된 뒤에만 덮어씀

디렉터리 목록과 이스케이프 처리

  • GET /somedir/ 요청을 받으면 config.S의 ALLOW_DIR_LISTING이 켜져 있는지 확인함
  • 디렉터리 목록이 비활성화되어 있으면 403 Forbidden을 반환함
  • 활성화되어 있으면 getdirentries64() 시스템 호출 344로 요청 디렉터리의 파일 정보 버퍼를 채움
  • 버퍼에는 각 파일 이름과 파일명 길이가 포함되며, ymawky는 이를 사용해 클릭 가능한 HTML을 생성함
  • 각 파일에 대해 클라이언트로 보내는 기본 형태는 다음과 같음 <a href="filename">filename</a>
  • href="..." 안의 파일명은 URL 경로 세그먼트로 퍼센트 인코딩해야 하고, 화면에 보이는 본문 텍스트는 HTML 이스케이프해야 함
  • 파일명이 &.-~><foo이면 href는 %26.-~%3E%3Cfoo, 표시 텍스트는 &.-~><foo가 되어 최종 출력은 다음과 같음 <a href="%26.-~%3E%3Cfoo">&.-~><foo</a>
  • <script>something evil</script>처럼 본문 영역에서 XSS가 가능한 이름이나, "><script>something dastardly</script>처럼 href="..." 영역에서 XSS가 가능한 이름도 실행되지 않도록 인코딩됨

네트워크 보안과 타임아웃

  • slowloris는 많은 연결을 열어두고 요청을 끝내지 않아 서버 리소스를 묶어두는 서비스 거부 공격임
  • ymawky는 fork-on-request 구조라 slowloris에 취약할 수 있음
  • 전체 헤더가 config.S의 HEADER_REQ_TIMEOUT_SECS 안에 수신되지 않으면 408 Request Timeout을 보내고 연결을 닫음
  • 요청 본문 수신 중 클라이언트가 너무 오래 데이터를 보내지 않으면 config.S의 RECV_TIMEOUT에 따라 같은 방식으로 처리함
  • 단순한 읽기별 타임아웃만으로는 충분하지 않음
    • 악의적 클라이언트가 Content-Length: 1073741823을 보내고 9초마다 1바이트씩 보내면, 콘텐츠 길이는 최대치보다 1바이트 작아 허용되고 10초 단위 타임아웃에서는 300년 넘게 기다릴 수 있음
  • 이를 줄이기 위해 ymawky는 Content-Length와 최소 초당 바이트 수를 기반으로 타임아웃을 계산함 timeout = grace_period + content_length / min_bps
  • grace_period는 모든 본문에 주는 최소 시간이고, min_bps는 서버가 허용하는 가장 느린 전송 속도임
  • 기본 min_bps는 16KB/s로 넉넉하지만 무한하지 않음
  • 이 방식이 서비스 거부 공격을 완전히 막지는 않지만, 특정 공격이 리소스를 묶어두는 시간을 제한함

파일시스템 안전성

  • 파일 정보 확인 순서

    • GET과 HEAD에서는 요청 경로를 열고 나서 파일 디스크립터에 대해 fstat64() 시스템 호출 339를 실행해 파일 종류와 크기 같은 정보를 얻음
    • 경로에 대해 먼저 stat64() 시스템 호출 338을 실행하고 그다음 파일을 열면, 검사 시점과 사용 시점 사이에 파일이 바뀌는 TOCTOU race condition이 생길 수 있음
  • docroot와 경로 순회 방지

    • 모든 요청 경로 앞에는 docroot가 붙음
    • 기본 docroot는 config.S의 DEFAULT_DIR인 www/임
    • /etc/shadow 요청은 www/etc/shadow가 되어, www/etc/shadow가 실제로 존재하지 않는 한 404가 됨
    • 하지만 /../../../../etc/shadow는 www/../../../../etc/shadow가 되어 docroot 밖으로 해석될 수 있으므로 추가 방어가 필요함
    • ymawky는 단순히 문자열 ..가 들어간 모든 경로를 거부하지 않고, 경로 세그먼트가 정확히 ..인 경우를 거부함
    • %2E%2E는 디코딩 후 ..가 되므로, 이 검사는 퍼센트 디코딩 이후에 수행해야 함
  • 심볼릭 링크 처리

    • POSIX의 O_NOFOLLOW 플래그는 최종 경로 구성요소가 심볼릭 링크이면 open()이 실패하게 함
    • Darwin의 O_NOFOLLOW_ANY는 경로의 어떤 구성요소라도 심볼릭 링크이면 실패하게 함
    • docroot 안에 특정 심볼릭 링크를 심을 수 있다면 이미 다른 문제가 생긴 상태일 가능성이 크지만, 이 플래그로 추가 방어가 가능함

Apple 전용 동작

  • 타임아웃 처리와 sigaction()

    • 요청 타임아웃을 구현하려면 setitimer() 시스템 호출 83으로 일정 시간이 지난 뒤 SIGALRM을 보내야 함
    • 기본적으로 SIGALRM은 child를 죽이지만, ymawky는 먼저 408 Request Timeout을 보내야 함
    • 이를 위해 sigaction() 시스템 호출 46을 사용함
    • Darwin의 원시 sigaction 구조체는 sa_tramp 필드를 노출함
    • 일반적으로 libc가 sa_tramp를 설정해 스택과 레지스터를 저장하고 sigreturn을 준비한 뒤 핸들러로 분기함
    • ymawky의 타임아웃 핸들러는 408 Request Timeout을 보내고 필요한 항목을 닫은 뒤 child를 종료하므로 반환할 필요가 없음
    • 그래서 trampoline 슬롯을 타임아웃 응답을 직접 수행하는 코드로 가리키게 하고, sa_handler와 sigreturn을 우회함
  • proc_info()와 child process 수 제한

    • Apple에는 실행 중인 프로세스와 그 자식 정보를 얻을 수 있는 잘 문서화되지 않은 proc_info() 시스템 호출 336이 있음
    • 이 호출은 보통 ps, lsof, top 같은 도구에서 쓰임
    • ymawky는 활성 child process 수를 세는 데 proc_info()를 사용함
    • 최대 연결 수가 설정 가능하므로 살아 있는 child 수를 알아야 함
    • proc_info()는 child process 정보를 버퍼에 쓰고, 각 요소 크기가 알려져 있으므로 기록된 바이트 수로 child 수를 계산할 수 있음
    • child 수가 MAX_PROCS를 넘으면 새 연결은 503 Service Unavailable로 거부됨

결론과 프로젝트 정보

  • 정적 웹 서버에서 어려운 부분은 소켓을 열고 listen하는 작업보다 요청 파싱과 모든 경계 조건 처리였음
  • 요청, 경로, 응답은 모두 바이트이며, 범위 요청은 정확해야 하고 파일명은 위치에 따라 다르게 이스케이프해야 함
  • 어셈블리는 요청 파싱, 메모리 관리, 오류 처리, 문자열 변환, 타임아웃, 파일 안전성 같은 모든 작업을 직접 작성하게 만듦
  • ymawkyimtomt가 유지 관리함
Read Entire Article