1 điểm bởi GN⁺ 5 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • 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ên Content-Length
  • Với PUT, dữ liệu được ghi trước vào file tạm .ymawky_tmp_<pid> 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 #0x80 là dạng con người có thể đọc của các byte D4 00 10 01 trong 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ư struct củ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()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ằng svc #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

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 đích index.html và phiên bản HTTP HTTP/1.0
    • \r\n là kết thúc dòng, còn \r\n\r\n là kết thúc header
    • Nếu không nhận được \r\n\r\n thì phải dừng với 400 Bad Request
  • 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 / trong HTTP/1.0 là đườ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\n/ bên trong HTTP/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_MAX là 4096 byte, ymawky dùng filename_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 Long thay 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ệ trong 0-9, a-f, A-F hay không, rồi chuyển thành giá trị byte tương ứng
    • GET có thể có header Range:, còn PUT thì bắt buộc cần Content-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 \r không có \n, hoặc xuất hiện \n mà không có \r trướ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
  • So sánh chuỗi và chuyển đổi số

    • Để tìm Range: hay Content-Length:, tác giả viết hàm streqn nhận hai con trỏ chuỗi x0, x1 và độ dài tối đa x2, 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 hai
      Range: 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

Xử lý PUT và chiến lược file tạm

  • PUT là 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ùng
  • PUT /file.txt sẽ tạo file.txt hoặc ghi đè hoàn toàn file cũ; gửi 1234 hai lần thì nội dung file vẫn là 1234, không phải 12341234
  • Mở PUT toà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-Length là 2KB nhưng chỉ gửi 100 byte
    • client khai Content-Length cực lớn như 50GB
  • MAX_BODY_SIZE trong config.S mặc định là 1GB; nếu Content-Length vượ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_<pid>
  • Nó lấy pid bằng syscall getpid() số 20, rồi chuyển sang chuỗi bằng itoa() 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ố 10 hoặc unlinkat() 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 tra ALLOW_DIR_LISTING trong config.S có đượ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à
    <a href="filename">filename</a>
    
  • 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à &.-~><foo thì href sẽ là %26.-~%3E%3Cfoo, còn văn bản hiển thị sẽ là &amp;.-~&gt;&lt;foo, tạo ra kết quả cuối cùng như sau
    <a href="%26.-~%3E%3Cfoo">&amp;.-~&gt;&lt;foo</a>
    
  • Nhờ vậy, các tên có thể gây XSS trong phần body như <script>something evil</script> hoặc trong vùng href="..." như "><script>something dastardly</script> sẽ 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_SECS của config.S thì server gửi 408 Request Timeout và đó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_TIMEOUT trong config.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: 1073741823 rồ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
  • Để giảm rủi ro này, ymawky tính timeout dựa trên Content-Length và số byte tối thiểu mỗi giây
    timeout = grace_period + content_length / min_bps
    
  • grace_period là thời gian tối thiểu cấp cho mọi body, còn min_bps là tốc độ truyền chậm nhất mà server chấp nhận
  • Giá trị mặc định của min_bps là 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 GETHEAD, sau khi mở đường dẫn được yêu cầu, server gọi syscall fstat64() số 339 trê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ố 338 trê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
  • 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ức DEFAULT_DIR trong config.S
    • Request /etc/shadow sẽ trở thành www/etc/shadow, nên nếu www/etc/shadow thực sự không tồn tại thì sẽ trả về 404
    • Nhưng /../../../../etc/shadow sẽ thành www/../../../../etc/shadow và 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à ..
    • %2E%2E sau 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_NOFOLLOW của POSIX làm cho open() thất bại nếu thành phần cuối cùng của đường dẫn là symbolic link
    • O_NOFOLLOW_ANY củ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

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ửi SIGALRM sau một khoảng thời gian nhất định
    • Mặc định SIGALRM sẽ giết child process, nhưng ymawky cần gửi 408 Request Timeout trước
    • Vì vậy nó dùng syscall sigaction() số 46
    • Cấu trúc sigaction thô của Darwin để lộ field sa_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_handlersigreturn
  • 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_PROCS thì các kết nối mới sẽ bị từ chối với 503 Service Unavailable

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

    • Có phải người đó tên là Mel không?
  • 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

    • Gì cơ? Tôi có đọc lướt qua một phần bài viết, nhưng lúc đọc lần đầu tôi không thấy đề cập đến tự sát
      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?
    • Phía hoàn toàn không có khiếu hài hước mới là thứ nguy hiểm hơn rất nhiều cho chính sức khỏe của họ và cho toàn xã hội
  • 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