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ó. Phần này không lý thuyết hoá chuyện đó — nó đo, trên một ứng dụng Flask bình thường do chính tôi 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. Không nhắm vào hệ thống của ai khác.

Ba tang con so: route viet ra, route Flask dang ky, diem tra loi that

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.

Điều đáng nhớ

Mỗi route tự mọc thêm hai phương thức bạn không viết. Flask tự động thêm HEADOPTIONS 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 tấn công thật luôn lớn hơn mã nguồn, vì nó 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 GET vẫn trả lời HEAD; một route khai POST mà không chặn GET sẽ 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 thực tế

  • 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ý /static như 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 OPTIONSHEAD và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ỉ GETPOST.
  • 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èm Server: 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.

Chỗ tôi không kết luận được

Con số 37 gắn với đúng ứng dụng này, đúng phiên bản Flask này. Ứng dụng thật có nhiều route hơn, nên khoảng cách tuyệt đối sẽ lớn hơn nhiều — nhưng tỷ lệ mới là điều đáng mang đi: bề mặt thật ở đây gấp hơn ba lần số route viết ra, và tỷ lệ ấy không nhỏ đi khi ứng dụng lớn lên, nó thường lớn thêm.

Tôi cũng chỉ đo tầng ứng dụng. Một triển khai thật còn có reverse proxy, cân bằng tải, có thể cả một service mesh — mỗi tầng thêm đường dẫn và header riêng mà phép đếm này không chạm tới. Đây là sàn của bề mặt tấn công, không phải trần.

Thử ba mươi giây

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.