Bài từ chối dịch vụ trước cho thấy chi phí bất đối xứng — input nhỏ, chi phí server lớn. Phân tích đầu vào (parse JSON, XML) là một trong những nơi bất đối xứng nhất, vì kẻ tấn công điều khiển cấu trúc của dữ liệu, không chỉ kích thước. Một tài liệu nhỏ về byte có thể lồng sâu hàng trăm nghìn cấp, chứa một con số hàng triệu chữ số, hay một mảng hàng triệu phần tử — mỗi cái tấn công một tài nguyên khác nhau của parser. Bài này đo ba trục bùng nổ đó 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.
Số đo
Ba lần chạy, kết quả nhất quán.
1. Độ 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 |
2. 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 |
3. 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 |
Điều đáng 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: 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.
Vì sao số nguyên khổng lồ là một CVE thật
Đáng dừng ở trục thứ hai 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ố. 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. Đây là 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.
Vì sao
Ba trục tấn công ba tài nguyên khác nhau, và đó là lý do một giới hạn không đủ:
- Độ sâu → stack (giới hạn cứng của tiến trình). Chặn bằng giới hạn độ sâu lồng khi parse.
- Độ phức tạp giá trị (số khổng lồ) → CPU (chuyển đổi O(n²)). Đã được Python vá mặc định; ngôn ngữ khác cần kiểm.
- Kích thước (mảng lớn) → bộ nhớ (tuyến tính). Chặn bằng giới hạn kích thước body.
Điểm mấu chốt: giới hạn kích thước body — biện pháp phổ biến nhất — chỉ chặn được 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 — đúng bài học từ bài DoS trước, áp cụ thể vào tầng parse.
Nghĩa là gì trong thực tế
- Giới hạn kích thước body request — trần cơ bản, chặn mảng/dict khổng lồ. Đặt ở tầng web server hoặc framework, trước khi parse.
- Giới hạn độ sâu lồng nhau khi parse. Nhiều parser JSON/XML có tuỳ chọn
max_depth— bật nó. Nếu không có, kiểm độ sâu trước khi parse, 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. Python 3.11+ làm sẵn; kiểm ngôn ngữ khác của bạn.
- Nghĩ theo trục, không chỉ theo kích thước. Khi nhận dữ liệu có cấu trúc từ ngoài, hỏi: độ 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.
Chỗ tôi không kết luận được
Tôi đo trên json chuẩn của Python. Các parser khác (XML với DTD — bài XXE và "billion laughs", YAML với anchor bùng nổ, parser nhị phân) có các trục bùng nổ riêng, một số nguy hiểm hơn. "Billion laughs" của XML nhân bản thực thể theo cấp số nhân — một trục mà JSON không có. Tôi đo ba trục phổ biến của JSON, không phải mọi trục của mọi định dạng.
Và giới hạn int_max_str_digits = 4300 của Python là mặc định có thể tắt (sys.set_int_max_str_digits(0)). Một ứng dụng vô tình tắt nó — hoặc dùng Python cũ — sẽ mở lại trục bignum. "Mặc định an toàn" là điểm khởi đầu, không phải đảm bảo vĩnh viễn.
Thử ba mươi giây
Tìm mọi chỗ ứng dụng của bạn parse JSON, XML, hay YAML từ nguồn ngoài.
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)? 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ề một 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.