Hình dung hai kiểu phá một quán ăn. Kiểu thứ nhất: lùa cả nghìn người vào đứng chật cửa, ai cũng thấy ngay. Kiểu thứ hai tinh vi hơn nhiều: đúng một khách bước vào, gọi một món mà nhà bếp phải mất bốn tiếng chuẩn bị, thế là cả bếp tê liệt phục vụ mình anh ta trong khi trông vẫn như một khách bình thường. DoS băng thông là kiểu thứ nhất. DoS tầng ứng dụng — thứ bài này đo — là kiểu thứ hai, và nó nguy hiểm chính vì nó không trông giống một cuộc tấn công.
Từ chối dịch vụ (DoS) thường được hình dung là một cơn lũ lưu lượng — hàng triệu request làm nghẽn băng thông. Nhưng có một loại tinh vi hơn nhiều: DoS ở tầng ứng dụng, lợi dụng chi phí bất đối xứng. Kẻ tấn công gửi một request nhỏ, nhưng nó khiến máy chủ tiêu tốn rất nhiều — CPU, bộ nhớ, thời gian. Không cần lưu lượng lớn; chỉ cần tìm đúng chỗ một byte đầu vào tạo ra một giây xử lý. Tôi đo bốn kiểu bất đối xứng đó.
Toàn bộ chạy trong container tôi tự dựng, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, không nhắm vào hệ thống của ai.
Số đo
Ba lần chạy, kết quả nhất quán.
1. ReDoS — regex bùng nổ mũ:
| Độ dài input | Thời gian máy chủ bận |
|---|---|
| 20 ký tự | 0,03 giây |
| 24 ký tự | 0,4 giây |
| 28 ký tự | 5 giây |
| 30 ký tự | ~20 giây |
2. Giải nén — tỷ lệ nén khổng lồ:
| Giá trị | |
|---|---|
| Kích thước gửi lên (nén) | 49 KB |
| Giải ra | 50 MB (tỷ lệ 1.028 lần) |
3. Lạm dụng băm mật khẩu chậm:
| Giá trị | |
|---|---|
| Một lần băm bcrypt cost 12 | 0,17 giây |
| 100 request đăng nhập/giây | 16,9 giây CPU mỗi giây |
4. Tham số điều khiển khối lượng:
| Tham số | Thời gian |
|---|---|
?limit=10.000 |
~0 giây |
?limit=10.000.000 |
0,33 giây |
Ba điều bảng số nói thẳng
ReDoS là bất đối xứng cực đoan nhất — 30 ký tự đổi lấy 20 giây. Regex (a+)+$ trông vô hại đến mức ai cũng viết, nhưng với một chuỗi aaa...aX không khớp, bộ máy regex thử mọi cách chia nhóm các chữ a trước khi kết luận thất bại — số cách tăng theo hàm mũ với độ dài. Bảng cho thấy rõ: mỗi 4 ký tự thêm vào nhân thời gian lên hơn 10 lần. Một request 30 byte giữ một lõi CPU bận 20 giây; chỉ vài request như vậy là làm quá tải cả máy chủ. Đây là lý do ReDoS đặc biệt nguy hiểm: chi phí gửi gần bằng không, chi phí xử lý gần như vô hạn.
Giải nén biến vài KB thành hàng chục MB — và tỷ lệ có thể lớn hơn nhiều. 49 KB nở thành 50 MB, tỷ lệ 1.028 lần. Đây mới là zip bomb "hiền"; một zip bomb thật (nén lồng nhiều lớp) đạt tỷ lệ hàng triệu. Nếu máy chủ giải nén dữ liệu người dùng (upload nén, request gzip, tệp Office) mà không giới hạn kích thước sau khi giải, một tệp nhỏ có thể ngốn hết RAM. Điểm tinh vi mà tôi muốn gằn giọng: giới hạn kích thước upload không đủ — phải giới hạn kích thước sau khi giải nén.
Chính cơ chế bảo vệ có thể thành vũ khí. Băm mật khẩu chậm cố tình tốn 0,17 giây mỗi lần để chống bruteforce. Nhưng nếu route đăng nhập không giới hạn tần suất, kẻ tấn công gửi 100 request/giây và buộc máy chủ tiêu 16,9 giây CPU mỗi giây thực — nhiều hơn số lõi của phần lớn máy chủ. Cơ chế làm chậm kẻ dò mật khẩu giờ làm chậm chính máy chủ. Đây là lý do bài giới hạn tần suất và bài băm mật khẩu phải đi cùng nhau: băm chậm mà không giới hạn tần suất là tự tay mở một cửa DoS.
Đường cong ReDoS: con số cho thấy điều gì
Bảng ReDoS đáng nhìn kỹ vì hình dạng của nó chính là bằng chứng của lỗ hổng. Bốn ký tự thêm vào — từ 20 lên 24, 24 lên 28 — mỗi lần nhân thời gian lên hơn mười lần (0,03 → 0,4 → 5 → 20 giây). Đó là dấu hiệu của tăng trưởng theo hàm mũ: mỗi ký tự thêm khoảng gấp đôi số đường backtracking. Ngoại suy ra thì rợn người: một chuỗi 40 ký tự sẽ mất nhiều giờ, 50 ký tự mất nhiều ngày. Kẻ tấn công chẳng cần chuỗi dài — 35 ký tự đã đủ để giữ một lõi bận đến mức xem như treo.
Điều này khiến ReDoS khó phát hiện khi test đến khó chịu: một chuỗi 20 ký tự chạy trong 0,03 giây, hoàn toàn bình thường, và mọi test chức năng đều qua. Lỗ chỉ hiện ở đúng vùng độ dài mà không ai nghĩ đến chuyện thử — và đó chính là vùng kẻ tấn công nhắm tới. Giống các bài đo khác trong sê-ri, tín hiệu chỉ xuất hiện khi bạn đẩy input tới đúng quy mô, và quy mô đó thường nằm ngoài phạm vi test thông thường.
Vì sao bốn kiểu này cùng một họ
Bốn kiểu khác nhau nhưng chung một cấu trúc: chi phí xử lý của máy chủ tăng nhanh hơn nhiều so với chi phí gửi của kẻ tấn công. Vẽ tỷ lệ "byte gửi : công máy chủ":
- ReDoS: tuyến tính gửi → mũ xử lý. Bất đối xứng khủng khiếp nhất.
- Giải nén: 1 byte gửi → hàng nghìn byte bộ nhớ.
- Băm chậm: 1 request → 0,17 giây CPU cố định (cao).
- Tham số khối lượng: 1 con số → khối lượng tuyến tính theo con số đó.
Đây là điều làm DoS tầng ứng dụng khác hẳn DoS băng thông: nó không cần nhiều request, chỉ cần request đắt. Một kẻ tấn công một máy, một kết nối, có thể hạ một máy chủ nếu tìm được đúng điểm bất đối xứng. Phòng thủ băng thông (chặn IP gửi nhiều) không bắt được nó, vì lưu lượng nhìn hoàn toàn bình thường.
Nghĩa là gì trong công việc hằng ngày
- Đặt giới hạn ở mọi trục: thời gian, bộ nhớ, kích thước, khối lượng. Timeout cho mọi thao tác; giới hạn kích thước sau giải nén; trần cho mọi tham số điều khiển khối lượng (
limit,page_size, số vòng lặp). - Tránh regex có backtracking mũ, hoặc dùng một bộ máy regex tuyến tính (RE2,
re2của Google) cho input người dùng. Soát các regex nhận đầu vào tuỳ ý — đặc biệt các mẫu có nhóm lồng lượng từ ((a+)+,(a|a)*). - Giới hạn tần suất các endpoint đắt — đăng nhập (băm chậm), tìm kiếm, xuất dữ liệu, bất cứ gì tốn nhiều CPU mỗi request. Nối với bài giới hạn tần suất: đó là lớp chặn DoS tầng ứng dụng.
- Nghĩ theo chi phí bất đối xứng khi thiết kế. Với mỗi endpoint, hỏi: một request tệ nhất tốn bao nhiêu của máy chủ? Nếu câu trả lời tăng theo một giá trị người dùng điều khiển, đặt trần cho giá trị đó.
Cùng một cái bẫy bất đối xứng, đổi ngăn xếp
Chuyện "một đầu vào nhỏ ép ra một khối lượng công việc lớn" không riêng gì web Python — mỗi nền tảng có một góc bất đối xứng kinh điển của nó.
Trong Java, ReDoS là một CVE thật sự phổ biến vì java.util.regex dùng backtracking đúng như bài này, và đã có những lỗ hổng nổi tiếng (một mẫu trong chính thư viện chuẩn từng bị khai thác). Nhưng Java còn một cửa bất đối xứng riêng, dai dẳng cả thập kỷ: hash collision DoS — kẻ tấn công gửi hàng nghìn khoá được chọn để đụng độ trong HashMap, biến thao tác O(1) thành O(n) và làm một request parse form ngốn CPU khủng khiếp. Cách chữa (băm có seed ngẫu nhiên, chuyển bucket sang cây) chính là để phá cái bất đối xứng đó.
Trong Go, ngôn ngữ chọn cách phòng ngừa từ gốc: regexp của thư viện chuẩn dùng RE2 — bộ máy tuyến tính, không backtracking — nên ReDoS gần như không tồn tại nếu bạn dùng đúng thứ chuẩn. Đây là ví dụ đẹp của "an toàn theo mặc định" đưa vào thiết kế ngôn ngữ, đúng cái mục "dùng RE2" khuyên ở trên. Đổi lại, Go vẫn chia sẻ cửa zip bomb và cửa tham số khối lượng như mọi nền tảng khác.
Còn ở cơ sở dữ liệu, họ hàng gần nhất là một truy vấn rẻ về cú pháp nhưng đắt khủng khiếp về thực thi: một JOIN không điều kiện tạo tích Descartes, một LIKE '%...%' quét toàn bảng, hay một ORDER BY trên cột không index buộc sort cả triệu dòng. Vài chục byte SQL (hoặc vài tham số API dịch ra SQL đó) ép engine làm việc hàng giây — cùng cấu trúc bất đối xứng, chỉ nằm ở tầng CSDL. Đó là lý do có statement_timeout và trần phân trang.
Mẫu số chung: mọi lỗ DoS tầng ứng dụng đều là một chỗ mà chi phí của người gửi và chi phí của người xử lý mất cân đối — và phòng thủ luôn là đặt một cái trần lên phía đắt. Trần thời gian (timeout), trần bộ nhớ (giới hạn sau giải nén), trần độ phức tạp (regex tuyến tính, băm chống đụng độ), trần khối lượng (giới hạn tham số). Nên câu hỏi đúng cho mỗi endpoint không phải "request này trông có bình thường không" — vì kẻ tấn công cố tình làm nó trông bình thường — mà là "trong tình huống tệ nhất, một request đơn lẻ có thể ép máy chủ làm bao nhiêu việc, và tôi đã chặn trên nó ở đâu chưa".
Muốn tự soát, nhìn các endpoint của bạn và hỏi đúng một câu cho mỗi cái: một request tệ nhất có thể tốn bao nhiêu? Cụ thể: có regex nào chạy trên input người dùng không (kiểm ReDoS)? Có giải nén dữ liệu người dùng không (kiểm giới hạn sau-giải-nén)? Có endpoint tốn nhiều CPU mà không giới hạn tần suất không (đăng nhập, tìm kiếm)? Có tham số nào điều khiển số lượng công việc mà không có trần không (limit, size)? Mỗi câu trả lời "có mà không giới hạn" là một điểm DoS tầng ứng dụng đang chờ sẵn.