Khi ta nghĩ "chặn đầu vào quá lớn", ta gần như luôn nghĩ tới số byte — đặt trần body ở 1 MB là xong. Nhưng kích thước chỉ là một chiều của một khối hộp ba chiều, và kẻ tấn công parse điều khiển cả ba: một tài liệu có thể mỏng về byte nhưng sâu kinh khủng, hoặc chứa một giá trị nhỏ trên giấy mà đắt khủng khiếp khi tính. Giống như một kiện hàng: giới hạn cân nặng không ngăn được một cây sào dài ba mét thò ra đâm vào người khác. Bài từ chối dịch vụ trước cho thấy chi phí bất đối xứng; bài này tôi đo ba trục bùng nổ khi parse, và trần cần đặt cho từng trục.
Toàn bộ chạy trong container tự dựng, tự dọn; không nhắm vào hệ thống của ai. (Các con số là thời gian và RAM đo trong container, không phải perf counter phần cứng.)
Số đo
Ba lần chạy, kết quả nhất quán. Trục thứ nhất — độ sâu lồng nhau [[[...]]] tấn công stack:
| Độ sâu | Kết quả | Kích thước input |
|---|---|---|
| 100 | ok | 0 KB |
| 1.000 | ok | 1 KB |
| 100.000 | RecursionError | 195 KB |
Trục thứ hai — số nguyên khổng lồ tấn công CPU:
| Số chữ số | Kết quả |
|---|---|
| 1.000 | ok |
| 100.000 | ValueError — bị chặn ngay khi parse |
| 1.000.000 | ValueError |
Trục thứ ba — mảng khổng lồ tấn công bộ nhớ:
| Số phần tử | Thời gian | RAM tăng |
|---|---|---|
| 10.000 | ~0 | ~0 |
| 1.000.000 | 0,03 giây | ~1 MB |
| 5.000.000 | 0,16 giây | ~18 MB |
Ba trục, ba tài nguyên bị đánh
Độ sâu lồng nhau là trục nguy hiểm nhất — 195 KB đủ làm tràn stack. JSON lồng sâu [[[...]]] khiến parser đệ quy: mỗi cấp một khung stack. Ở 100.000 cấp — một tài liệu chỉ 195 KB — parser hết stack và ném RecursionError. Điểm bất đối xứng nằm ở chỗ: kích thước input nhỏ (byte tuyến tính theo độ sâu) nhưng nó chạm tới một giới hạn cứng của tiến trình (kích thước stack) chứ không phải một tài nguyên co giãn. Đây là loại tấn công một request có thể làm sập cả tiến trình, không chỉ làm chậm.
Python 3.11+ đã chặn số nguyên khổng lồ mặc định — mặc-định-an-toàn lần thứ tư trong sê-ri này. Đây là bất ngờ. Một số 9999... (100.000 chữ số) không chỉ chậm để tính — nó bị từ chối ngay lúc json.loads, với ValueError. Lý do: sau CVE-2020-10735 (một số nguyên khổng lồ chuyển sang chuỗi tốn O(n²) CPU, một vector DoS thật), Python 3.11 đặt giới hạn mặc định int_max_str_digits = 4300 — chuyển một chuỗi quá 4300 chữ số thành int bị chặn. Giống cái bẫy Referer, XXE và header injection ở các bài trước, đây là một lỗ kinh điển được vá ở tầng ngôn ngữ; nếu bạn dùng Python hiện đại, trục này đã được che.
Mảng khổng lồ vẫn thủng — bộ nhớ tuyến tính, không có trần mặc định. 5 triệu phần tử ngốn 18 MB RAM, và không gì chặn nó — parser vui vẻ cấp phát. Đây là trục còn hở: không có giới hạn mặc định cho số phần tử một mảng JSON. Với đủ request như vậy song song, bộ nhớ cạn. Cách chặn duy nhất là giới hạn kích thước body trước khi parse — một mảng 18 MB RAM đến từ một body 9 MB, nên chặn body ở, ví dụ, 1 MB là chặn được cả trục này.
Đáng dừng lại ở trục số nguyên khổng lồ vì nó phản trực giác nhất: một con số làm sao gây DoS? Python có số nguyên độ chính xác tuỳ ý (bignum) — nó đọc được số bao nhiêu chữ số cũng được, không tràn. Vấn đề nằm ở chuyển đổi: biến một số nguyên khổng lồ từ dạng nhị phân sang chuỗi thập phân (để in, để log, để trả JSON) tốn thời gian O(n²) theo số chữ số, nên một số một triệu chữ số cần hàng nghìn tỷ phép toán chỉ để in ra. Đây chính là CVE-2020-10735: kẻ tấn công gửi {"n": "999...9"} với hàng triệu số 9, ứng dụng parse rồi vô tình chuyển nó về chuỗi ở đâu đó, và một request đơn giữ CPU bận rất lâu. Cách vá của Python — giới hạn 4300 chữ số cho chuyển đổi chuỗi↔int — là một quyết định thú vị: nó phá vỡ tương thích (một số chương trình hợp lệ dùng số rất lớn sẽ lỗi), nhưng cộng đồng chấp nhận đánh đổi đó vì vector DoS đủ nghiêm trọng — một ví dụ hiếm về việc một ngôn ngữ hy sinh tính đúng đắn tuyệt đối để đóng một lỗ bảo mật ở tầng mặc định.
Cái mấu chốt là ba trục tấn công ba tài nguyên khác nhau: độ sâu → stack (giới hạn cứng của tiến trình), độ phức tạp giá trị → CPU (chuyển đổi O(n²)), kích thước → bộ nhớ (tuyến tính). Và vì thế giới hạn kích thước body — biện pháp phổ biến nhất — chỉ chặn được đúng trục thứ ba: một body 195 KB lồng sâu vẫn lọt qua giới hạn 1 MB và làm tràn stack, một body nhỏ chứa số khổng lồ vẫn tốn CPU (nếu ngôn ngữ không vá). Phòng thủ đầy đủ cần giới hạn nhiều trục, không chỉ kích thước. Cụ thể: giới hạn kích thước body request ở tầng web server hoặc framework trước khi parse (chặn mảng/dict khổng lồ); giới hạn độ sâu lồng nhau khi parse (nhiều parser JSON/XML có max_depth — bật nó, hoặc kiểm độ sâu trước, hoặc dùng parser có bảo vệ đệ quy); dùng ngôn ngữ/thư viện đã vá bignum hoặc đặt giới hạn tường minh nếu chưa; và nghĩ theo trục — khi nhận dữ liệu có cấu trúc từ ngoài, hỏi đủ ba câu: độ sâu tối đa? số phần tử tối đa? giá trị lớn nhất? Mỗi câu cần một trần.
Mẫu số chung
Cái sai lầm gốc ở đây — "tôi đã giới hạn kích thước rồi" trong khi kẻ tấn công điều khiển cấu trúc chứ không chỉ kích thước — lặp ở mọi parser, mọi tầng.
Trong thế giới Java, Jackson (thư viện JSON phổ biến nhất) đã học đúng bài này và thêm StreamReadConstraints với maxNestingDepth, maxNumberLength, maxStringLength — ba nút cho gần đúng ba trục ở đây, bật mặc định từ Jackson 2.15 chính vì các vector DoS parser thật. Trước đó vô số dịch vụ Java sập vì một JSON lồng sâu, đúng cái RecursionError phóng thành StackOverflowError.
Trong Go, encoding/json giới hạn độ sâu lồng để tránh tràn stack, và cộng đồng luôn nhắc io.LimitReader bọc quanh body trước khi decode — cùng tinh thần "chặn kích thước ở cổng vào". Với số, Go không có bignum mặc định trong JSON nên trục hai ít gặp hơn, nhưng json.Decoder vẫn cần trần số phần tử qua giới hạn body.
Còn ở cơ sở dữ liệu, cùng bài toán "đầu vào điều khiển cấu trúc" hiện ra rõ nhất ở GraphQL: một query ngắn có thể lồng quan hệ sâu vô hạn (friends { friends { friends ... } }) và nổ thành hàng triệu phép join — nên các server GraphQL nghiêm túc đặt depth limit và complexity limit, đúng như parser đặt max_depth; và mọi API trả danh sách đều cần một trần số dòng (LIMIT bắt buộc), song sinh với trần số phần tử mảng.
Mẫu số chung: khi đầu vào từ ngoài có cấu trúc, kẻ tấn công điều khiển nhiều chiều cùng lúc — độ sâu, độ phức tạp, số lượng, kích thước — và một cái trần chỉ bịt một chiều, để hở các chiều còn lại; phòng thủ đúng là đặt trần trên mọi chiều mà cấu trúc cho phép nở ra. Vì thế câu hỏi đúng khi nhận dữ liệu có cấu trúc không phải "nó có quá lớn không" mà là "nó có thể nở ra theo những chiều nào, và tôi đã đặt trần cho từng chiều chưa" — vì kẻ tấn công sẽ luôn đẩy đúng cái chiều bạn quên.
Muốn tự soát trong ba mươi giây, tìm mọi chỗ ứng dụng parse JSON, XML hay YAML từ nguồn ngoài và kiểm ba câu cho mỗi chỗ: có giới hạn kích thước body không (chặn mảng lớn)? parser có giới hạn độ sâu lồng không (chặn tràn stack)? ngôn ngữ có vá số khổng lồ không (chặn bignum DoS)? Rồi thử gửi một JSON lồng sâu 50.000 cấp — chỉ khoảng 100 KB — vào một endpoint. Nếu nó trả về lỗi 500 với RecursionError hoặc làm tiến trình chết, bạn có một điểm bùng nổ tài nguyên mà giới hạn kích thước thông thường không bắt được.