Thông báo lỗi tồn tại để giúp lập trình viên sửa lỗi — nhưng khi hiển thị cho người dùng cuối, chúng cũng giúp kẻ tấn công. Một trang lỗi chi tiết là một bản đồ: nó tiết lộ cấu trúc mã, tên bảng cơ sở dữ liệu, đường dẫn tệp máy chủ, phiên bản thư viện — chính xác những gì kẻ tấn công cần để tìm lỗ tiếp theo. Bài này đo lượng thông tin rò qua thông báo lỗi ở chế độ debug so với production, và nối với một chủ đề đã gặp: lỗi cơ sở dữ liệu để lộ cấu 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ách đo
Tôi dựng một ứng dụng Flask với hai loại lỗi. Một là lỗi ứng dụng (chia cho 0) — đo với chế độ DEBUG bật. Hai là lỗi cơ sở dữ liệu (câu SQL hỏng) — đo hai cách trả về: nguyên văn lỗi SQL, và một thông báo chung. Rồi đếm những mẩu thông tin nhạy cảm nào xuất hiện trong phản hồi.
Số đo
Ba lần chạy, kết quả giống hệt:
Lỗi ứng dụng với DEBUG bật — trang lỗi 14.276 ký tự:
| Thông tin rò | Có trong phản hồi? |
|---|---|
| Đường dẫn tệp máy chủ | có |
| Số dòng mã | có |
| Tên hàm nội bộ | có |
| Toàn bộ traceback | có |
| Giá trị biến cục bộ | có |
Lỗi cơ sở dữ liệu:
| Cách trả về | Nội dung |
|---|---|
| Nguyên văn lỗi SQL | ... FROM bang_khong_ton_tai WHERE id = 'x — lộ tên bảng và câu SQL |
| Thông báo chung | Đã xảy ra lỗi nội bộ — không lộ gì |
Điều đáng nhớ
Trang debug rò gấp hàng trăm lần một thông báo chung. Con số cụ thể: 14.276 ký tự cho trang debug, so với khoảng 20 ký tự cho Đã xảy ra lỗi nội bộ — chênh khoảng 700 lần. Nhưng vấn đề không phải kích thước, mà là nội dung: trang debug chứa đường dẫn tệp (lộ cấu trúc thư mục và hệ điều hành), số dòng và tên hàm (lộ logic mã), toàn bộ traceback (lộ chuỗi gọi và thư viện dùng), và giá trị biến cục bộ — có thể gồm cả dữ liệu nhạy cảm đang được xử lý lúc lỗi. Mỗi mẩu là một manh mối cho kẻ tấn công lập bản đồ hệ thống của bạn.
Lỗi SQL nguyên văn là một mũi tên chỉ thẳng vào cấu trúc cơ sở dữ liệu. Dòng thứ hai của bảng cho thấy bang_khong_ton_tai — tên bảng — rò ra cùng với hình dạng câu truy vấn. Với kẻ tấn công đang dò SQL injection (bài SQL injection), đây là vàng: thông báo lỗi xác nhận câu SQL trông thế nào, tên bảng và cột là gì, và mỗi lỗi khác nhau (cú pháp, kiểu, ràng buộc) là một tín hiệu để tinh chỉnh payload. Đây chính là lý do CLAUDE.md của dự án blog này ghi rõ: không trả e.getMessage() của DataIntegrityViolationException ra ngoài — nó chứa nguyên câu SQL và tên ràng buộc.
Thông báo chung ra ngoài, log chi tiết vào trong — không phải một hoặc kia. Bí quyết không phải là vứt bỏ thông tin lỗi mà là đặt nó đúng chỗ. Người dùng (và kẻ tấn công) chỉ thấy "Đã xảy ra lỗi nội bộ" cộng một mã tham chiếu. Toàn bộ traceback, câu SQL, biến cục bộ đi vào log máy chủ — nơi lập trình viên đọc được nhưng kẻ tấn công không. Bạn không mất khả năng gỡ lỗi; bạn chỉ chuyển thông tin gỡ lỗi từ một kênh công khai sang một kênh riêng.
Nối với một bài học đã có trong dự án này
Chính blog bạn đang đọc mang bài học này trong tài liệu nội bộ của nó, và đáng dẫn lại vì nó tinh tế hơn "đừng in traceback". Ghi chú trong CLAUDE.md: không được trả e.getMessage() của DataIntegrityViolationException ra ngoài — nó chứa nguyên câu SQL và tên ràng buộc. Và một điểm sâu hơn: cũng đừng đoán nguyên nhân trong thông báo, vì cùng một ngoại lệ đó có thể là chuỗi quá dài, trùng khoá, hoặc ký tự cơ sở dữ liệu không lưu được — đoán sai làm người gỡ lỗi đi lạc hướng.
Đây là hai mặt của cùng một nguyên tắc. Mặt bảo mật: lỗi nguyên văn rò cấu trúc cho kẻ tấn công. Mặt chất lượng: một thông báo lỗi đoán mò còn tệ hơn một thông báo trung tính, vì nó sai một cách tự tin. Cách đúng cho cả hai là như nhau — bắt ngoại lệ, log đầy đủ vào trong (để chẩn đoán đúng), trả một thông báo trung tính ra ngoài (không rò, không đoán).
Vì sao
Rò rỉ qua lỗi khác các lỗ trong sê-ri ở một điểm: nó không cho kẻ tấn công làm gì trực tiếp — nó cho chúng biết. Nhưng biết là bước đầu của mọi cuộc tấn công:
- Một tên bảng rò ra biến SQL injection từ "dò mù" thành "nhắm trúng".
- Một đường dẫn tệp rò ra giúp path traversal (bài tải tệp lên) biết cần đi tới đâu.
- Một phiên bản thư viện rò ra cho kẻ tấn công tra danh sách CVE đã biết của đúng phiên bản đó.
Đây là lý do rò rỉ thông tin nằm trong OWASP như một hạng mục riêng dù nó "chỉ là thông tin": nó là chất xúc tác nhân sức mạnh của mọi lỗ khác. Một hệ thống câm lặng về nội bộ của mình buộc kẻ tấn công phải đoán; một hệ thống nói quá nhiều làm việc đoán đó hộ chúng.
Nghĩa là gì trong thực tế
- Tắt DEBUG ở production — và kiểm rằng nó thật sự tắt. Đây là lỗi cấu hình phổ biến nhất và nghiêm trọng nhất trong nhóm này. Một
DEBUG=Truesót lại là một trang debug rò 14.000 ký tự cho mọi lỗi. - Trả thông báo lỗi chung cho người dùng, log chi tiết vào máy chủ. Kèm một mã tham chiếu (ID lỗi) để người dùng báo và bạn tra được trong log.
- Đừng bao giờ chuyển tiếp nguyên văn lỗi cơ sở dữ liệu. Nó chứa câu SQL, tên bảng, tên ràng buộc. Bắt ngoại lệ, log nó, trả về thông báo trung tính.
- Cẩn thận với cả sự khác biệt trong thông báo. Đôi khi rò rỉ tinh vi hơn một traceback: "email không tồn tại" so với "sai mật khẩu" cho kẻ tấn công biết email nào đã đăng ký. Thông báo lỗi nên nhất quán ở những chỗ nhạy cảm.
Chỗ tôi không kết luận được
Tôi đo lỗi trả về trong phản hồi HTTP. Còn các kênh rò khác — thời gian phản hồi khác nhau giữa "email tồn tại" và "không tồn tại" (timing oracle), header tiết lộ phiên bản server, thông báo lỗi trong log phía client (JavaScript) — mỗi cái là một mặt trận riêng mà tôi không đo hết. Rò rỉ thông tin rộng hơn "trang lỗi chi tiết".
Và tôi đo Flask/Python. Mỗi framework có cách bật/tắt debug và định dạng trang lỗi khác nhau, nhưng nguyên tắc — chi tiết cho log, chung cho người dùng — là chung cho tất cả.
Thử ba mươi giây
Trên môi trường production (hoặc staging giống production) của bạn, cố tình gây một lỗi: gửi một tham số sai kiểu, gọi một endpoint không tồn tại, hoặc một giá trị làm hỏng truy vấn.
Nhìn phản hồi. Nếu bạn thấy một traceback, một đường dẫn tệp như /app/src/..., một câu SQL, hay tên một bảng — bạn đang rò thông tin cho mọi người gây được lỗi đó. Nó phải trả về một thông báo chung và một mã tham chiếu, không hơn.