Một thông báo lỗi chi tiết giống như tấm bản đồ toà nhà mà lính cứu hoả cần khi có cháy: sơ đồ từng phòng, vị trí cầu thang, chỗ đặt van nước. Cực kỳ hữu ích cho người bên trong. Nhưng dán chính tấm bản đồ ấy lên cửa ra vào cho bất kỳ ai đi qua đọc, thì kẻ trộm cũng vừa được tặng đúng thứ nó cần để lẻn vào. Trang lỗi debug là tấm bản đồ đó, và bài này tôi đo xem khi dán ra ngoài, nó tiết lộ chính xác bao nhiêu.

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 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. Tôi đ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ôi tự dựng, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, 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ì

Ba điều đáng gằn giọng

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 nằm ở kích thước, mà ở 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, đâ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ó ngay 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 "chỉ là thông tin" vẫn nguy hiểm

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 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 công việc hằng ngày

  • 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=True só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.

Cùng một cái bẫy "lỗi nói quá nhiều", đổi ngăn xếp

Chuyện "thông báo lỗi mặc định của khung tiện cho lập trình viên nhưng rò cấu trúc cho kẻ tấn công" không riêng gì Flask — mỗi hệ có trang debug hào phóng của nó, và cùng một cấu hình sót lại.

Trong thế giới Java, tương đương chính xác là Spring Boot với server.error.include-stacktrace hay whitelabel error page phơi stack trace, cộng cái bẫy cổ điển của các app cũ: một SQLException.getMessage() hay printStackTrace() lọt thẳng vào response. Spring có ResponseEntityExceptionHandler để chuẩn hoá lỗi thành JSON trung tính — và CLAUDE.md của chính blog này ghi lại rằng bắt Exception bừa còn đôn mọi lỗi thành 500 kèm thông điệp sai, nên cách đúng là kế thừa handler đó và chỉ đổi thân sang JSON, không lộ nội bộ.

Trong Go, ngôn ngữ nghiêng về tường minh nên ít có "trang debug tự động" như Flask/Spring, nhưng cái bẫy dời sang thói quen: http.Error(w, err.Error(), 500) — trả thẳng err.Error() ra client — là một dòng vô cùng dễ viết và rò đúng nội dung lỗi gốc (đường dẫn, lỗi driver DB). Chuẩn an toàn là log err phía server, trả một chuỗi trung tính ra ngoài.

Còn ở cơ sở dữ liệu, đây là nguồn của toàn bộ vấn đề: message lỗi của Postgres/MySQL cố tình chi tiết — tên ràng buộc, tên cột, đoạn câu lệnh — vì chúng sinh ra cho DBA đọc. Chi tiết đó là tài sản khi ở trong log, là lỗ hổng khi ra tới trình duyệt. Ranh giới không nằm ở chính message, mà ở chỗ nó đi tới đâu.

Mẫu số chung: thông tin chẩn đoán không tốt hay xấu tự thân — nó chỉ đặt đúng kênh hay sai kênh; đầy đủ trong log riêng là vô giá, cũng chính nó rò ra kênh công khai là một tấm bản đồ tặng kẻ tấn công. Mọi khung đều có một mặc định hào phóng dành cho lúc phát triển, và an toàn production luôn quy về một việc: tách kênh — chi tiết vào log, trung tính (kèm mã tham chiếu) ra ngoài. Nên câu hỏi đúng không phải "thông báo lỗi này có hữu ích không" mà là "ai đọc được nó, và người đọc đó có phải người tôi muốn cho biết không".

Muốn tự soát, trên môi trường production (hoặc staging giống production), 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.