내 삶에 의미(부족)를 주기 위해 aarch64 어셈블리로 웹 서버 만들기
(imtomt.github.io)- ymawky는 aarch64 어셈블리만으로 작성된 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 개요와 제약
- ymawky는 aarch64 어셈블리만으로 작성된 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로 중단해야 함
- HTTP 요청은 서버가 해석해야 하는 문자열이며, 예시는 다음과 같음
-
경로 추출
- 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 안에 특정 심볼릭 링크를 심을 수 있다면 이미 다른 문제가 생긴 상태일 가능성이 크지만, 이 플래그로 추가 방어가 가능함
- POSIX의
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로 거부됨
- Apple에는 실행 중인 프로세스와 그 자식 정보를 얻을 수 있는 잘 문서화되지 않은
결론과 프로젝트 정보
Lobste.rs 의견들
-
대단하네. 예전에 스마트 기기를 만드는 작은 회사와 연동하는 일을 했는데, 그 회사의 유일한 엔지니어가 어셈블리 언어밖에 몰랐음
하드웨어 제어 코드부터 서버 운영체제, 우리가 쓰던 JSON 웹 API까지 전부 어셈블리로 직접 작성돼 있었음
한 번은 웹 API가 엉뚱한 기기의 데이터를 반환하는 버그를 만났는데, 알고 보니 운영체제 스케줄링 시스템에 오프바이원 오류가 있어서 “데이터베이스”가 웹 서비스에 잘못된 행을 돌려주고 있었음- 혹시 그 사람 이름이 Mel은 아니었나?
-
“자살” 같은 표현을 다룰 때는 제발 콘텐츠 경고를 붙였으면 함. 더 낫게는 아예 언급하지 않는 편이 좋고
- 뭐지? 글 일부는 대충 읽긴 했지만 처음 읽을 때 자살 관련 언급을 못 봤음
이 댓글 보고 다시 찾아봤는데도 못 찾겠는데, 내가 뭔가 놓친 건가? - 유머 감각이 전혀 없는 쪽이 오히려 본인 건강과 사회 전체에 훨씬 더 위험함
- 뭐지? 글 일부는 대충 읽긴 했지만 처음 읽을 때 자살 관련 언급을 못 봤음
-
“전부 어셈블리로 작성”됐다는 얘기를 보니 Therac-25 조사 보고서가 떠오름