Mười phần trước xoay quanh mật mã: bắt tay, chứng chỉ, chữ ký, băm. Phần này là một kiểu tấn công không đụng tới mật mã chút nào, và vì thế dễ bị bỏ quên. Slowloris không gửi gói độc, không khai thác lỗi tràn bộ đệm, không thử mật khẩu. Nó chỉ mở nhiều kết nối hợp lệ rồi gửi dữ liệu thật chậm, giữ cho mỗi kết nối "đang dở" càng lâu càng tốt, cho tới khi server hết chỗ cho người khác. Điều làm nó đáng sợ là băng thông gần như bằng không, một client trên mạng di động cũng đủ, và điều làm nó dễ phòng là chỉ cần một dòng cấu hình mà http.Server{} mặc định của Go không có sẵn. Bài này dựng cuộc tấn công thật giữa hai máy, đo server không phòng thủ sập ra sao, rồi thêm từng lớp phòng thủ và đo lại.
Cuộc tấn công và ba lớp phòng thủ

Hình 1: Cách slowloris giữ kết nối, ba kịch bản phòng thủ, và lý do hạ file descriptor xuống 256 cùng đo bằng ticker trong server.
Cuộc tấn công đơn giản đến mức khó tin. Client mở 400 kết nối TCP tới server và trên mỗi kết nối gửi dòng đầu của một HTTP request, GET / HTTP/1.1 và Host:, rồi nhỏ giọt các dòng header giả như X-pad-0: keep với nhịp một dòng mỗi 500 ms, và không bao giờ gửi dòng trống đánh dấu hết header. Theo đặc tả HTTP, server phải chờ cho tới khi nhận đủ header mới xử lý được request, nên mỗi kết nối này treo server trong trạng thái đọc header vô thời hạn. Với một server không đặt timeout, mỗi kết nối chiếm một goroutine kẹt trong readRequest và một file descriptor, giữ tới khi client tự đóng. Không có gì bất hợp lệ ở đây: mỗi byte là HTTP đúng chuẩn, chỉ chậm. Tổng băng thông của 400 kết nối gửi một byte mỗi 500 ms là khoảng 6,4 Kbit mỗi giây, ít hơn một bức ảnh thu nhỏ.
Ba kịch bản chạy trên cùng một server, với file descriptor hạ xuống 256 để ngưỡng cạn kiệt hiện ra ở quy mô đo được thay vì cần cả triệu kết nối. A là http.Server{} mặc định, không timeout nào. B2 chỉ thêm ReadHeaderTimeout 3 giây, đóng kết nối chưa gửi xong header sau 3 giây, để xem timeout một mình có đủ không. B thêm một listener bọc ngoài chặn quá 50 kết nối đang mở mỗi IP ngay ở bước Accept. Mỗi 2 giây, một ticker trong server ghi số goroutine và file descriptor vào log; client thật gửi 20 rồi 10 request giữa lúc bị tấn công để đo tỉ lệ thành công. Ticker là cần thiết vì khi server bị bão hoà, chính endpoint /stats qua HTTP cũng không nối tới được, nên phải để server tự ghi trạng thái nội bộ mới nhìn được bên trong.
Đo thật: 252 goroutine, cạn fd, client thật rớt còn hai phần mười

Hình 2: Server không timeout bị giữ 252 goroutine và từ chối client thật; ReadHeaderTimeout một mình hồi sau một spike; thêm giới hạn mỗi IP thì không cả spike.
| kịch bản | goroutine đỉnh | file descriptor | client thật giữa tấn công | too many open files |
|---|---|---|---|---|
| A không timeout | 252, giữ suốt | cạn | 2/10, 150 ms | 37 lần |
| B2 chỉ ReadHeaderTimeout 3 s | 252, spike 2 s | cạn 2 s rồi hồi | 10/10, 0,3 ms | 10 lần |
| B timeout + 50 kết nối/IP | 2, không trèo | 7, không đổi | 10/10, 0,2 ms | 0 lần |
Server không timeout bị một client gần như rảnh làm tê liệt. Trước tấn công, server ở trạng thái nghỉ: 2 goroutine, 7 file descriptor, client thật thành công 20/20 với độ trễ 0,3 ms. Khi slowloris giữ 400 kết nối, server trèo lên 252 goroutine, đúng bằng 248 kết nối chậm được chấp nhận tới khi chạm trần 256 file descriptor cộng vài goroutine nền, và từ đó không nhận thêm được: log ghi too many open files 37 lần trong thời gian tấn công. Client thật rớt từ 20/20 còn 2/10, với độ trễ 150 ms cho hai request may mắn lọt qua; 150 phần nghìn kết nối còn lại không nối được. Trạng thái này giữ nguyên suốt thời gian slowloris còn giữ, rồi server tự hồi về 2 goroutine và 20/20 khi client buông. Con số đáng nhớ là băng thông tấn công: 400 kết nối gửi một byte mỗi 500 ms là 6,4 Kbit mỗi giây, một phần nghìn của một kết nối video; server 10 lõi bị tê liệt không phải vì hết CPU hay băng thông mà vì hết một tài nguyên rẻ tiền là file descriptor và goroutine mà không ai đặt trần.
ReadHeaderTimeout một mình đủ để không sập, nhưng vẫn có spike. Thêm ReadHeaderTimeout 3 giây, không gì khác: làn sóng kết nối đầu tiên vẫn lọt vào trong 3 giây trước khi timeout kịp cắt, nên ở giây thứ 2 server vẫn trèo lên 252 goroutine và ghi too many open files 10 lần. Nhưng từ giây thứ 3, ReadHeaderTimeout bắt đầu đóng mọi kết nối chưa gửi xong header, và ở giây thứ 4 goroutine đã tụt xuống 152, file descriptor còn 157; tới giây thứ 8, server về lại 2 goroutine và giữ ổn định, client thật thành công 10/10. Đây là bài học chính: ReadHeaderTimeout biến một kết nối chậm vô hạn thành một kết nối sống tối đa 3 giây, nên slowloris không còn tích luỹ được. Server không sập, chỉ có một spike ngắn mỗi khi kẻ tấn công mở một đợt kết nối mới, và với timeout đủ ngắn, spike đó quá nhỏ để thành vấn đề.
Giới hạn kết nối mỗi IP chặn ngay ở cửa, không để spike xảy ra. Thêm một listener bọc ngoài đếm kết nối đang mở theo IP và đóng ngay ở Accept khi một IP vượt 50: cuộc tấn công 400 kết nối từ một IP bị đóng thẳng 350, biến perip_rejected lên 350, và 50 kết nối lọt vào cũng bị ReadHeaderTimeout đóng sau 3 giây. Server giữ 2 goroutine suốt từ đầu đến cuối, 7 file descriptor, 0 lần too many open files, client thật 10/10 với 0,2 ms không đổi. Khác biệt với B2 là chỗ đóng: giới hạn mỗi IP chặn ở tầng Accept, trước khi server tốn một goroutine hay file descriptor nào cho kết nối thừa; ReadHeaderTimeout là lưới an toàn phía sau cho những kết nối đã lọt qua. Hai lớp bù nhau: giới hạn mỗi IP vô hiệu với tấn công phân tán từ nhiều IP, còn timeout thì áp cho mọi kết nối bất kể nguồn.
Đánh đổi cần cân nhắc
Luôn đặt timeout, vì mặc định của Go không có. http.Server{} để trống ReadHeaderTimeout, ReadTimeout, WriteTimeout và IdleTimeout, nghĩa là mọi server Go viết theo ví dụ trong tài liệu đều dễ bị slowloris cho tới khi lập trình viên đặt tay. ReadHeaderTimeout là lớp quan trọng nhất và rẻ nhất, vài giây là đủ; IdleTimeout ngăn kết nối keep-alive nằm không chiếm chỗ; WriteTimeout phòng biến thể slow-read nơi kẻ tấn công nhận response thật chậm. Đặt cả bốn, với giá trị hợp với ứng dụng; API nội bộ có thể chặt hơn, dịch vụ nhận upload lớn cần ReadTimeout nới nhưng vẫn giữ ReadHeaderTimeout ngắn.
Giới hạn mỗi IP hợp khi đứng trực tiếp ra Internet, nhưng cẩn thận NAT. Giới hạn kết nối mỗi IP chặn được slowloris từ một nguồn, nhưng văn phòng, trường học và mạng di động dùng chung một IP qua NAT, nên trần quá thấp sẽ chặn người dùng thật, đúng cảnh báo ở phần về giới hạn tần suất của sê-ri. Trần 50 trong bài là để minh hoạ; giá trị thật phải tính theo số kết nối đồng thời hợp lý của một IP chung. Tấn công thật thường phân tán từ nhiều IP, nên lớp này nên đặt ở tầng hạ tầng như một reverse proxy hoặc CDN có khả năng nhìn toàn cục, không chỉ ở ứng dụng.
Phạm vi đo. File descriptor hạ xuống 256 để thấy ngưỡng; máy thật thường 1 triệu, nên cần nhiều kết nối hơn để cạn, nhưng cơ chế không đổi và slowloris thật nhắm vào cả số kết nối lẫn goroutine. Chưa đo biến thể slow POST gửi body chậm thay vì header, nơi ReadHeaderTimeout không áp mà cần ReadTimeout; và chưa đo HTTP/2, nơi một kết nối mang nhiều stream làm bức tranh khác đi, với các giới hạn riêng về số stream đồng thời. Reverse proxy như nginx có bộ đệm header riêng hấp thụ slowloris trước khi tới app, lý do nữa để không phơi server Go trực tiếp ra Internet.
Ba ý mang về
- Slowloris cần 6,4 Kbit mỗi giây để hạ một server 10 lõi, nếu server quên đặt timeout. 400 kết nối chậm đẩy server lên 252 goroutine, cạn file descriptor 37 lần, và từ chối 80% client thật suốt thời gian tấn công.
ReadHeaderTimeoutlà lớp phòng thủ chính vàhttp.Server{}không có sẵn. Nó biến kết nối chậm vô hạn thành sống tối đa vài giây, đủ để server không tích luỹ kết nối và tự hồi sau mỗi đợt.- Giới hạn kết nối mỗi IP chặn ở tầng Accept, trước khi tốn goroutine; timeout là lưới sau. Hai lớp bù nhau: mỗi IP vô hiệu với tấn công phân tán, timeout áp cho mọi kết nối. Cẩn thận NAT khi chọn trần mỗi IP.
Nguồn
- Go
net/http—Server.ReadHeaderTimeout,ReadTimeout,WriteTimeout,IdleTimeout: https://pkg.go.dev/net/http#Server - Cloudflare — The complete guide to Golang net/http timeouts: https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/
- OWASP — Slow Loris / Slow HTTP DoS attack: https://owasp.org/www-community/attacks/Slow_Loris_attack
- RFC 9110 — HTTP Semantics, yêu cầu server nhận đủ header trước khi xử lý: https://www.rfc-editor.org/rfc/rfc9110
- Go
golang.org/x/net/netutil—LimitListener, giới hạn tổng số kết nối đồng thời: https://pkg.go.dev/golang.org/x/net/netutil#LimitListener - Linux
getrlimit(2)—RLIMIT_NOFILE, trần file descriptor mỗi tiến trình: https://man7.org/linux/man-pages/man2/getrlimit.2.html
Phần sau là phần cuối của sê-ri: gom mười một phần thành một checklist bảo mật kết nối, mỗi mục kèm con số đã đo để đối chiếu. Từ cấu hình TLS tối thiểu, chọn khi nào dùng mTLS, xoay chứng chỉ, cất secret, băm mật khẩu và token, tới timeout, mỗi quyết định đi với một phép đo thật thay cho một lời khuyên chung.