Tạo web server bằng assembly aarch64 để mang lại ý nghĩa (hoặc sự thiếu ý nghĩa) cho cuộc đời tôi
(imtomt.github.io)- ymawky là một máy chủ HTTP tĩnh nhỏ gọn cho macOS, được viết hoàn toàn bằng assembly aarch64, chỉ dùng syscall thô của Darwin mà không có wrapper libc
- Hỗ trợ
GET,HEAD,PUT,OPTIONS,DELETE, yêu cầu byte range, liệt kê thư mục và trang lỗi tùy chỉnh, nhưng không nhằm thay thế nginx mà là một cách loại bỏ các lớp tiện lợi để hiểu web server thực sự hoạt động như thế nào - Từ phân tích request, giải mã percent, kiểm tra header, chuyển đổi giá trị range, xử lý lỗi, đóng file đến tạo response đều phải tự viết thủ công; ngay cả những việc tương đương với tách chuỗi đơn giản hay
int(string)trong Python cũng trở thành hàng chục đến hàng trăm dòng mã kiểm chứng trong assembly - Server dùng kiến trúc fork-on-request, gọi
fork()cho mỗi kết nối mới, nên dễ triển khai nhưng thông lượng xử lý kết nối đồng thời thấp và có thể dễ bị slowloris; vì vậy nó áp dụng timeout cho header và timeout cho body dựa trênContent-Length - Với
PUT, dữ liệu được ghi trước vào file tạm.ymawky_tmp_rồi mới thay thế khi thành công; an toàn hệ thống tệp như chống path traversal,O_NOFOLLOW_ANY,fstat64(), mã hóa URL và escape HTML trong danh sách thư mục đều được xử lý thủ công
Tổng quan và các ràng buộc của ymawky
- ymawky là một máy chủ HTTP tĩnh nhỏ gọn cho macOS được viết hoàn toàn bằng assembly aarch64
- Nó chỉ dùng syscall thô của Darwin mà không có wrapper libc, không dùng thư viện ngoài hay parser có sẵn
- Các tính năng được hỗ trợ gồm
GET,HEAD,PUT,OPTIONS,DELETE, yêu cầu byte range, liệt kê thư mục và trang lỗi tùy chỉnh - Các ràng buộc của dự án như sau
- chỉ dùng aarch64 assembly
- nhắm tới macOS/Darwin
- chỉ dùng raw syscalls, không có wrapper libc
- chỉ phục vụ file tĩnh
- không dùng parser có sẵn
- không dùng thư viện ngoài
- Mục tiêu không phải thay thế nginx, mà là loại bỏ các lớp tiện lợi để hiểu web server thực sự vận hành ra sao
Những gì cần làm khi viết web server bằng assembly
- Assembly là lớp trung gian giữa mã máy và ngôn ngữ bậc cao; các lệnh như
mov,add,ldr,str,cmpánh xạ trực tiếp tới các byte trong binary thực thi svc #0x80là dạng con người có thể đọc của các byteD4 00 10 01trong binary thực thi- Không có kiểu chuỗi, nên chuỗi chỉ tồn tại như một vùng byte liên tiếp trong bộ nhớ; cũng không có các tính năng ngôn ngữ như
structcủa C, nên phải tự biết offset của từng field và kích thước tổng thể - Vì không có thư viện HTTP, tự động dọn dẹp, exception hay object, nên mọi việc như phân tích request, xử lý lỗi, đóng file, tạo response đều phải tự viết
- Dù chương trình chạy sai, CPU vẫn tiếp tục thực thi mà không cảnh báo, nên vấn đề nằm ở chính các lệnh và truy cập bộ nhớ đã viết
Syscall thô và luồng hoạt động của server
-
Syscall Darwin
- ymawky gọi trực tiếp kernel thay vì dùng wrapper libc
- Trên Darwin aarch64, số syscall được đặt trong thanh ghi
x16, còn trên Linux aarch64 làx8 - Số syscall của
open()là5; sau khi đặt trực tiếp các đối số như tên file và mode vào thanh ghi, kernel được gọi bằngsvc #0x80 - Khi
open()thất bại, carry flag được bật; có thể kiểm tra carry flag bằng nhánh nhưb.cs open_failedđể chuyển tới mã xử lý lỗi
-
Hoạt động cơ bản của server
- Luồng cơ bản của web server là nhận request, xử lý rồi trả về mã trạng thái và file cần thiết
- Thiết lập socket gồm các bước như
socket(AF_INET, SOCK_STREAM, 0),setsockopt(... SO_REUSEADDR ...),bind(sockfd, &addr, 16),listen(sockfd, 5),accept(sockfd, NULL, NULL) - ymawky là server fork-on-request, gọi
fork()cho mỗi kết nối mới - Cách này không chia sẻ bộ nhớ giữa các lần xử lý request nên dễ hiểu và dễ triển khai, nhưng tốn tài nguyên hơn do không gian bộ nhớ theo tiến trình, và thông lượng kết nối đồng thời thấp hơn mô hình event-driven bất đồng bộ non-blocking của nginx
- Khi số kết nối đồng thời tăng lên, kernel sẽ dành nhiều thời gian hơn cho việc chuyển đổi tiến trình thay vì chạy mã bên trong từng tiến trình
-
Những việc cần làm khi xử lý request
- Xác định request dùng phương thức nào trong
GET,HEAD,OPTIONS,PUT,DELETE - Trích xuất đường dẫn request và giải mã percent như
%20 - Kiểm tra an toàn đường dẫn và phân tích các header do client gửi lên
- Lấy thông tin file được yêu cầu rồi phân biệt đó là thư mục hay file thường
- Với request
PUT, ghi body vào file tạm và tạo header cùng body cho response - Đóng các file đã mở và xử lý lỗi để server không bị crash
- Xác định request dùng phương thức nào trong
Tự triển khai HTTP parsing
-
Dòng request và kết thúc header
- HTTP request là một chuỗi mà server phải tự diễn giải, ví dụ như sau
GET /index.html HTTP/1.0\r\n Range: bytes=1-5\r\n\r\n - Dòng đầu chứa request
GET, file đíchindex.htmlvà phiên bản HTTPHTTP/1.0 \r\nlà kết thúc dòng, còn\r\n\r\nlà kết thúc header- Nếu không nhận được
\r\n\r\nthì phải dừng với400 Bad Request
- HTTP request là một chuỗi mà server phải tự diễn giải, ví dụ như sau
-
Trích xuất đường dẫn
- ymawky so sánh các phương thức được hỗ trợ và các byte đầu tiên để xác định loại request rồi mới trích xuất đường dẫn
- Nó quét header từng byte để tìm
/hoặc*, nhưng để không nhầm/trongHTTP/1.0là đường dẫn, nó kiểm tra xem byte ngay trước/có phải là dấu cách hay không - Ví dụ, trong
GET HTTP/1.0\r\n\r\ncó/bên trongHTTP/1.0, nên nếu byte trước đó không phải dấu cách thì phải trả về400 Bad Request - Vì trên hầu hết hệ thống
PATH_MAXlà 4096 byte, ymawky dùngfilename_buffer: .skip 4097để có buffer tên file 4096 byte cộng thêm 1 byte cho ký tự kết thúc null - Nếu đường dẫn request dài hơn buffer thì phải trả về
414 URI Too Longthay vì ghi đè bộ nhớ tùy ý - Một thao tác gần tương đương
text.split("GET /")[1].split(" ")[0]trong Python, khi viết bằng assembly và kèm kiểm tra tính hợp lệ của HTTP, lại thành khoảng 200 dòng mã
-
Giải mã percent và kiểm tra header field
- Khi gặp
%trong đường dẫn, chương trình kiểm tra hai byte tiếp theo có phải là hex hợp lệ trong0-9,a-f,A-Fhay không, rồi chuyển thành giá trị byte tương ứng GETcó thể có headerRange:, cònPUTthì bắt buộc cầnContent-Length:- Các header này không nằm ở vị trí cố định như URL trong request, nên phải duyệt toàn bộ header từng ký tự một
- Nếu sau
\rkhông có\n, hoặc xuất hiện\nmà không có\rtrước đó, thì header bị coi là sai và trả về400 Bad Request - Nếu một dòng header mới bắt đầu bằng khoảng trắng thì cũng trả về
400 Bad Request, vì header field không được bắt đầu bằng khoảng trắng
- Khi gặp
-
So sánh chuỗi và chuyển đổi số
- Để tìm
Range:hayContent-Length:, tác giả viết hàmstreqnnhận hai con trỏ chuỗix0,x1và độ dài tối đax2, rồi so sánh từng ký tự - Header
Range:có thể bỏ trống phần đầu hoặc phần cuối như sau, nhưng bắt buộc phải có ít nhất một trong haiRange: bytes=10- Range: bytes=-10 Range: bytes=5-10 - Vì giá trị range là chuỗi, cần một hàm kiểu
atoiđể đổi chữ số ASCII thành số nguyên - Để tránh tràn thanh ghi 64 bit, nếu số có từ 19 chữ số trở lên thì xử lý như lỗi
- Ngay cả thao tác tương đương
int(string)trong Python, khi viết bằng assembly, cũng phải tự triển khai kiểm tra chữ số, nhân, cộng và tín hiệu thành công/thất bại dựa trên carry flag
- Để tìm
Xử lý PUT và chiến lược file tạm
PUTlà phương thức idempotent, nghĩa là gửi cùng một request nhiều lần vẫn đưa server về cùng một trạng thái cuối cùngPUT /file.txtsẽ tạofile.txthoặc ghi đè hoàn toàn file cũ; gửi1234hai lần thì nội dung file vẫn là1234, không phải12341234- Mở
PUTtoàn cục có thể nguy hiểm, và các vấn đề cần cân nhắc khi xử lý gồm- tiến trình bị crash trong lúc xử lý request
- client khai
Content-Lengthlà 2KB nhưng chỉ gửi 100 byte - client khai
Content-Lengthcực lớn như 50GB
MAX_BODY_SIZEtrongconfig.Smặc định là 1GB; nếuContent-Lengthvượt quá mức này thì trả về413 Content Too Large- Nếu mở trực tiếp file hiện có để ghi, khi thất bại có thể để lại file bị ghi dở; vì vậy ymawky trước tiên ghi vào file tạm dạng
.ymawky_tmp_ - Nó lấy pid bằng syscall
getpid()số20, rồi chuyển sang chuỗi bằngitoa()tự viết, đồng thời kiểm tra tràn buffer - Sau khi ghi toàn bộ body của client vào file tạm và thành công, file tạm được đổi tên tại chỗ để trở thành file đích trên server
- Nếu client ngắt kết nối bất ngờ, hết thời gian chờ, hoặc gửi body không hợp lệ, file tạm sẽ bị xóa bằng syscall
unlink()số10hoặcunlinkat()số472 - File hiện có chỉ bị ghi đè sau khi toàn bộ request hợp lệ đã được truyền thành công
Liệt kê thư mục và xử lý escape
- Khi nhận request
GET /somedir/, server kiểm traALLOW_DIR_LISTINGtrongconfig.Scó được bật hay không - Nếu tính năng liệt kê thư mục bị tắt thì trả về
403 Forbidden - Nếu được bật, nó dùng syscall
getdirentries64()số344để điền buffer thông tin file của thư mục được yêu cầu - Buffer này chứa tên từng file và độ dài tên file, và ymawky dùng chúng để tạo HTML có thể bấm được
- Với mỗi file, dạng cơ bản gửi cho client là
filename - Tên file trong
href="..."phải được percent-encode như một segment của URL path, còn phần văn bản hiển thị trên màn hình phải được escape HTML - Nếu tên file là `&.-~>](%26.-~%3E%3Cfoo)
- Nhờ vậy, các tên có thể gây XSS trong phần body như
something evilhoặc trong vùnghref="..."như">something dastardlysẽ không bị thực thi
Bảo mật mạng và timeout
- slowloris là một kiểu tấn công từ chối dịch vụ giữ mở rất nhiều kết nối mà không bao giờ hoàn tất request, khiến tài nguyên server bị chiếm dụng
- Vì ymawky dùng cấu trúc fork-on-request nên có thể dễ bị slowloris
- Nếu toàn bộ header không được nhận trong thời gian
HEADER_REQ_TIMEOUT_SECScủaconfig.Sthì server gửi408 Request Timeoutvà đóng kết nối - Nếu trong lúc nhận body mà client không gửi dữ liệu quá lâu, server cũng xử lý tương tự theo
RECV_TIMEOUTtrongconfig.S - Chỉ dùng timeout cho từng lần đọc là chưa đủ
- Một client độc hại có thể gửi
Content-Length: 1073741823rồi gửi 1 byte mỗi 9 giây; vì độ dài nội dung chỉ nhỏ hơn giới hạn tối đa 1 byte nên vẫn hợp lệ, và với timeout 10 giây cho từng khoảng, server có thể phải chờ hơn 300 năm
- Một client độc hại có thể gửi
- Để giảm rủi ro này, ymawky tính timeout dựa trên
Content-Lengthvà số byte tối thiểu mỗi giâytimeout = grace_period + content_length / min_bps grace_periodlà thời gian tối thiểu cấp cho mọi body, cònmin_bpslà tốc độ truyền chậm nhất mà server chấp nhận- Giá trị mặc định của
min_bpslà 16KB/s, đủ rộng rãi nhưng không vô hạn - Cách này không thể chặn hoàn toàn tấn công từ chối dịch vụ, nhưng có thể giới hạn thời gian mà một kiểu tấn công cụ thể giữ tài nguyên bị khóa
An toàn hệ thống tệp
-
Thứ tự kiểm tra thông tin file
- Với
GETvàHEAD, sau khi mở đường dẫn được yêu cầu, server gọi syscallfstat64()số339trên file descriptor để lấy loại file, kích thước và các thông tin khác - Nếu trước tiên gọi syscall
stat64()số338trên path rồi mới mở file, có thể phát sinh TOCTOU race condition khi file thay đổi giữa thời điểm kiểm tra và thời điểm sử dụng
- Với
-
Docroot và chống path traversal
- Mọi đường dẫn request đều được gắn thêm docroot ở phía trước
- Docroot mặc định là
www/, tứcDEFAULT_DIRtrongconfig.S - Request
/etc/shadowsẽ trở thànhwww/etc/shadow, nên nếuwww/etc/shadowthực sự không tồn tại thì sẽ trả về 404 - Nhưng
/../../../../etc/shadowsẽ thànhwww/../../../../etc/shadowvà có thể được diễn giải ra ngoài docroot, nên cần thêm biện pháp phòng vệ - ymawky không đơn giản từ chối mọi đường dẫn có chứa chuỗi
.., mà chỉ từ chối khi một path segment chính xác là.. - Vì
%2E%2Esau khi giải mã sẽ trở thành.., nên kiểm tra này phải được thực hiện sau bước giải mã percent
-
Xử lý symbolic link
- Cờ
O_NOFOLLOWcủa POSIX làm choopen()thất bại nếu thành phần cuối cùng của đường dẫn là symbolic link O_NOFOLLOW_ANYcủa Darwin làm cho việc mở thất bại nếu bất kỳ thành phần nào trong đường dẫn là symbolic link- Nếu ai đó đã có thể cài một symbolic link cụ thể vào bên trong docroot thì có lẽ hệ thống đã có vấn đề khác từ trước, nhưng cờ này vẫn là một lớp phòng vệ bổ sung
- Cờ
Hành vi riêng của Apple
-
Xử lý timeout và
sigaction()- Để triển khai timeout cho request, cần dùng syscall
setitimer()số83để gửiSIGALRMsau một khoảng thời gian nhất định - Mặc định
SIGALRMsẽ giết child process, nhưng ymawky cần gửi408 Request Timeouttrước - Vì vậy nó dùng syscall
sigaction()số46 - Cấu trúc
sigactionthô của Darwin để lộ fieldsa_tramp - Thông thường libc sẽ thiết lập
sa_trampđể lưu stack và thanh ghi, chuẩn bịsigreturn, rồi mới nhảy vào handler - Nhưng timeout handler của ymawky chỉ cần gửi
408 Request Timeout, đóng những gì cần đóng rồi thoát child process, nên không cần quay lại - Vì thế nó cho slot trampoline trỏ thẳng tới mã thực hiện response timeout, bỏ qua
sa_handlervàsigreturn
- Để triển khai timeout cho request, cần dùng syscall
-
proc_info()và giới hạn số child process- Trên Apple có syscall
proc_info()số336, ít được tài liệu hóa nhưng có thể lấy thông tin về tiến trình đang chạy và các child của nó - Syscall này thường được dùng trong các công cụ như
ps,lsof,top - ymawky dùng
proc_info()để đếm số child process đang hoạt động - Vì số kết nối tối đa có thể cấu hình được, nó cần biết có bao nhiêu child còn sống
proc_info()ghi thông tin child process vào buffer, và do kích thước mỗi phần tử là đã biết, có thể tính số child từ tổng số byte đã ghi- Nếu số child vượt quá
MAX_PROCSthì các kết nối mới sẽ bị từ chối với503 Service Unavailable
- Trên Apple có syscall
Kết luận và thông tin dự án
- Phần khó của một web server tĩnh không phải là mở socket và listen, mà là phân tích request và xử lý mọi điều kiện biên
- Request, path và response đều chỉ là byte; range request phải chính xác, và tên file phải được escape khác nhau tùy vị trí sử dụng
- Assembly buộc người viết phải tự xử lý mọi thứ như parsing request, quản lý bộ nhớ, xử lý lỗi, chuyển đổi chuỗi, timeout và an toàn file
- ymawky được duy trì bởi imtomt
1 bình luận
Ý kiến trên Lobste.rs
Ghê thật. Trước đây tôi từng làm công việc tích hợp với một công ty nhỏ làm thiết bị thông minh, và kỹ sư duy nhất ở đó chỉ biết ngôn ngữ assembly
Từ mã điều khiển phần cứng, hệ điều hành máy chủ cho đến JSON Web API mà bên tôi dùng đều được viết thủ công hoàn toàn bằng assembly
Có lần chúng tôi gặp một lỗi khiến web API trả về dữ liệu của nhầm thiết bị, rồi hóa ra là do hệ thống lập lịch của hệ điều hành có lỗi off-by-one nên “cơ sở dữ liệu” đã trả sai hàng cho dịch vụ web
Khi đề cập đến những cách diễn đạt như “tự sát”, xin hãy gắn cảnh báo nội dung. Tốt hơn nữa là đừng nhắc tới ngay từ đầu
Sau khi đọc bình luận này tôi cũng quay lại tìm mà vẫn không thấy, có phải tôi đã bỏ sót gì không?
Nghe nói là “mọi thứ đều được viết bằng assembly” làm tôi nhớ tới báo cáo điều tra Therac-25