Hãy tưởng tượng bạn vừa xây một ngôi nhà và tự tin liệt kê: nhà tôi có ba cửa — cửa chính, cửa bếp, cửa ga-ra. Nhưng khi một người lạ đi vòng quanh, họ đếm ra: ba cửa bạn nói, cộng bốn cửa sổ tầng trệt mở được, một cửa hầm bạn quên, một ô thông gió người gầy chui lọt, và cái cửa phụ mà nhà thầu lắp sẵn không báo bạn. Ngôi nhà "ba cửa" trong đầu bạn thật ra có mười lối vào. Bề mặt tấn công của một ứng dụng đúng là ngôi nhà đó, và bài này tôi đi vòng quanh đếm cho ra khoảng cách giữa cái bạn nghĩ và cái thật.
Mô hình mối đe doạ bắt đầu bằng một câu hỏi tưởng như dễ: ứng dụng của tôi có những cửa nào? Bạn không thể bảo vệ thứ bạn không biết là mình có. Tôi không lý thuyết hoá chuyện đó — tôi đo, trên một ứng dụng Flask bình thường do chính mình dựng trong container, số cửa thật so với số cửa lập trình viên nghĩ mình có.
Toàn bộ sê-ri này chỉ tấn công ứng dụng tự dựng, tự dọn sau khi đo. Tôi không nhắm vào hệ thống của ai khác.
Bảng số liệu
Ứng dụng là một API quán ăn nhỏ: trang chủ, đăng nhập, hồ sơ, tìm kiếm, vài route API, trang quản trị, một health check. Tám dòng @app.route. Tôi đếm bề mặt tấn công ở ba tầng: đọc mã nguồn, hỏi chính Flask, và gõ cửa từ bên ngoài bằng tám phương thức HTTP trên mười bốn đường dẫn. Ba lần, kết quả giống hệt.
| Tầng | Cách đếm | Con số |
|---|---|---|
| Lập trình viên nghĩ | Đếm @app.route trong mã |
8 |
| Khai báo tường minh | Đếm cặp (đường dẫn, phương thức) đã viết | 12 |
| Flask đăng ký | Hỏi url_map |
9 rule, 31 cặp |
| Thực tế trả lời | Gõ cửa từ ngoài | 37 điểm |
Tám route trong đầu, ba mươi bảy điểm trả lời thật. Khoảng cách đó — 25 cặp không hề có trong mã nguồn — chính là phần bề mặt tấn công mà mô hình mối đe doạ vẽ bằng trí nhớ sẽ bỏ sót.
Những cửa bạn không viết mà vẫn có
Mỗi route tự mọc thêm hai phương thức bạn không viết. Flask tự động thêm HEAD và OPTIONS cho mọi route GET. HEAD trả về đúng header của GET nhưng không có thân — nếu route GET của bạn rò rỉ thông tin trong header (độ dài, kiểu nội dung, thời điểm sửa), thì HEAD rò rỉ y hệt, và nó thường lọt qua mọi bộ lọc chỉ nghĩ tới GET. OPTIONS thì công khai tuyên bố những phương thức nào được phép — nó là bản đồ miễn phí trao cho kẻ dò đường.
Điều này không chỉ là lý thuyết. Gõ đúng hai phương thức tự-mọc ấy vào /admin — một route tôi không hề muốn công khai — cho về:
OPTIONS /admin -> Allow: OPTIONS, GET, HEAD
Server: Werkzeug/3.1.8 Python/3.12.14
HEAD /admin -> Content-Length: 12
Server: Werkzeug/3.1.8 Python/3.12.14
Chưa cần GET, tôi đã biết /admin tồn tại, biết nó nhận những phương thức nào, biết phiên bản máy chủ và Python, và biết thân phản hồi dài đúng 12 byte. Đó là bốn mẩu thông tin trinh sát từ hai phương thức mà lập trình viên không viết dòng nào.
Cả một cây thư mục xuất hiện từ một dòng bạn không viết. /static/<path:filename> không có trong tám route của tôi — Flask tạo nó mặc định. Nó phục vụ bất kỳ tệp nào trong thư mục static, với <path:filename> cho phép cả dấu gạch chéo. Đây là điểm mà một cấu hình sai — đặt nhầm tệp .env, một bản sao lưu, một khoá riêng — biến thành lỗ rò công khai, và không dòng mã nào của bạn nhắc tới nó.
Con số "khai báo" còn nhỏ hơn con số Flask đăng ký. Tôi viết 12 cặp tường minh, nhưng url_map báo 31. Chênh lệch không chỉ là HEAD/OPTIONS: nó gồm cả /static mà tôi chưa từng đếm. Nghĩa là ngay cả người cẩn thận ngồi liệt kê route bằng tay cũng đã sai, vì framework thêm cửa sau lưng.
Vì sao bề mặt thật luôn lớn hơn mã nguồn
Bề mặt tấn công thật là tổng của bốn nguồn, và chỉ nguồn đầu tiên nằm trong tầm mắt lập trình viên:
- Mã bạn viết — tám route.
- Mặc định của framework —
/static,HEAD,OPTIONS, trang lỗi, đôi khi cả một endpoint debug. - Hành vi của phương thức — một route khai
GETvẫn trả lờiHEAD; một route khaiPOSTmà không chặnGETsẽ trả 405, và 405 cũng là thông tin: nó xác nhận đường dẫn tồn tại. - Tầng dưới — máy chủ web, reverse proxy, sidecar, tất cả có thể thêm đường dẫn riêng.
Mô hình mối đe doạ vẽ trong đầu chỉ thấy nguồn thứ nhất. Đó là lý do bước đầu tiên của nó không phải là nghĩ xem có cửa nào, mà là liệt kê — hỏi chính framework và gõ cửa từ ngoài, rồi so hai danh sách.
Một chi tiết dễ đọc sai: 405 không phải "an toàn"
Trong lúc đo, tôi đếm được 51 lần trả về 405 (Method Not Allowed). Phản xạ đầu tiên là gạt chúng sang nhóm "bị chặn, không sao". Nhưng 405 khác hẳn 404: 404 nói "không có gì ở đây", còn 405 nói "có route, chỉ là phương thức này không được phép". Với kẻ dò đường, 405 là câu trả lời khẳng định. Gõ DELETE /admin mà nhận 405 thay vì 404 là vừa biết chắc /admin tồn tại. Tôi giữ lại 405 và 404 ở hai nhóm riêng trong phép đếm chính vì lý do đó — gộp chúng vào một rổ "không trả lời" sẽ giấu mất rằng đường dẫn ấy đã tự lộ diện.
Nghĩa là gì trong công việc hằng ngày
- Liệt kê, đừng nhớ.
app.url_map(Flask),rails routes,php artisan route:list— mọi framework có cách in ra danh sách route thật. So nó với mô hình trong đầu bạn; phần chênh là phần bạn chưa bảo vệ. - Xử lý
/staticnhư một endpoint, không phải một thư mục. Biết chính xác thư mục đó chứa gì, và đảm bảo không có gì ngoài tài nguyên công khai lọt vào — vì<path:filename>phục vụ tất cả. - Đưa
OPTIONSvàHEADvào mô hình. Nếu bạn kiểm soát truy cập bằng cách chặn phương thức, hãy kiểm rằng bạn chặn tất cả phương thức không mong muốn, chứ không chỉGETvàPOST. - Coi 405 là thông tin bị rò. Nếu bạn không muốn lộ sự tồn tại của một đường dẫn, nó phải trả 404 cho phương thức sai, không phải 405.
- Che header
Server. Trong phép đo, mọi phản hồi đều kèmServer: Werkzeug/3.1.8 Python/3.12.14— tên và phiên bản chính xác của cả máy chủ lẫn ngôn ngữ, đủ để tra thẳng danh sách lỗ hổng đã biết. Một dòng cấu hình xoá nó đi không làm ứng dụng an toàn hơn, nhưng khiến bước trinh sát của kẻ tấn công tốn công hơn hẳn.
Cùng một khoảng cách "khai báo vs thực tế", đổi ngăn xếp
Chuyện "framework tự thêm endpoint sau lưng lập trình viên, và bề mặt thật lớn hơn mã nguồn" không riêng gì Flask — mọi framework đều làm vậy, và mỗi hệ có góc bị bỏ sót riêng.
Trong thế giới Java, Spring Boot là ví dụ kinh điển đến mức nguy hiểm: chỉ cần thêm spring-boot-starter-actuator là hàng loạt endpoint /actuator/** tự xuất hiện — /actuator/health, /actuator/env, và ở cấu hình lỏng còn cả /actuator/heapdump, /actuator/mappings (in ra toàn bộ route như url_map). Đã có vô số vụ rò rỉ vì Actuator để lộ biến môi trường hay heap dump ra Internet. Và Spring MVC cũng tự thêm HEAD/OPTIONS y hệt Flask. Cách đúng: dùng mvn spring-boot:.../actuator's mapping endpoint để liệt kê thay vì nhớ.
Trong Go, cái bẫy tinh tế nhất là net/http/pprof: chỉ cần import _ "net/http/pprof" cho side effect là nó tự đăng ký /debug/pprof/** vào http.DefaultServeMux — không một dòng route nào của bạn nhắc tới nó, nhưng nếu bạn phục vụ trên DefaultServeMux, cả bộ endpoint profiling (đọc được stack, heap) phơi ra Internet. Đây đúng là "/static của Go": một import lặng lẽ mở cửa.
Còn ở cơ sở dữ liệu, họ hàng gần nhất là các schema và view hệ thống mặc định: information_schema, pg_catalog, các bảng/hàm built-in mà một tài khoản DB được cấp quyền lỏng có thể đọc để lập bản đồ toàn bộ cấu trúc — dù DBA chỉ "tạo vài bảng". Bề mặt CSDL thật gồm cả metadata mà engine tự cung cấp, không chỉ bảng bạn viết.
Mẫu số chung: bề mặt tấn công thật luôn là tổng của cái bạn viết cộng cái nền tảng tự thêm — và cái thứ hai vô hình với trí nhớ, chỉ hiện ra khi bạn hỏi chính hệ thống hoặc gõ cửa từ ngoài. Mọi mô hình mối đe doạ đáng tin đều bắt đầu bằng liệt kê (enumerate), không bằng nhớ (recall), vì con người chỉ nhớ được nguồn thứ nhất trong bốn nguồn. Nên câu hỏi đúng không phải "tôi đã viết những route nào" mà là "nếu tôi hỏi framework và gõ mọi phương thức lên mọi đường dẫn, nó trả lời ở bao nhiêu điểm" — và khoảng cách giữa hai con số là phần bạn đang để hở.
Muốn tự đo khoảng cách đó, chạy lệnh in route của framework bạn đang dùng, đếm số dòng. Rồi mở đầu tệp định tuyến và đếm số route bạn tự viết. Nếu hai con số bằng nhau, framework của bạn không thêm gì — hiếm. Nếu con số đầu lớn hơn, phần chênh là những cửa đang mở mà bạn chưa từng đưa vào mô hình mối đe doạ của mình.