Hãy hình dung một khách sạn phát cho bạn chìa khoá phòng 301. Chìa mở đúng phòng bạn — nhưng vì lý do nào đó, nó cũng mở luôn phòng 302, 303, và mọi phòng khác trên tầng. Lễ tân đã kiểm rất kỹ rằng bạn là khách có đặt phòng; họ chỉ quên kiểm rằng cái chìa này chỉ được mở phòng của bạn. Đó chính xác là IDOR: hệ thống xác nhận bạn đã đăng nhập, rồi quên xác nhận rằng thứ bạn đang mở là của bạn. Bài này tôi cầm cái chìa vạn năng đó đi mở phòng người khác, đo thật.
Phân quyền hỏng đứng đầu OWASP Top 10 — phổ biến hơn cả injection hay XSS. Dạng thường gặp nhất của nó có tên IDOR (Insecure Direct Object Reference): ứng dụng kiểm bạn đã đăng nhập, nhưng quên kiểm bạn có quyền với đúng tài nguyên bạn đang yêu cầu. Kết quả: đổi một con số trong URL là đọc được dữ liệu của người khác. Tôi dựng lại lỗ đó và đo cách chặn.
Toàn bộ chạy trên ứng dụng tôi tự dựng trong container, tự dọn. Tôi chỉ tấn công dữ liệu do chính mình tạo, không nhắm vào hệ thống của ai.
Cuộc tấn công
Ứng dụng có ba đơn hàng, mỗi đơn thuộc một người dùng. an đăng nhập bằng tài khoản của chính mình — hợp lệ — và xem đơn của mình qua /don/1. Rồi an thử đổi số: /don/2. Đơn số 2 thuộc về binh, chứa số thẻ của binh. Câu hỏi duy nhất: ứng dụng có trả về không?
Số đo
Ba lần chạy, kết quả giống hệt:
| Tình huống | Kết quả |
|---|---|
An xem đơn của mình (/don/v0/1) |
trả về — đúng |
An đổi id, xem đơn của Bình (/don/v0/2) |
trả về — lỗ IDOR! |
An đổi id trên route có kiểm quyền (/don/v1/2) |
403 — chặn |
Kẻ tấn công có UUID của đơn người khác (/don/v2) |
trả về — id khó đoán không cứu |
Ba điều đáng gằn giọng
"Đã đăng nhập" không phải "được phép". Đây là gốc của mọi IDOR. Route v0 kiểm đúng một điều: người gọi đã đăng nhập. Nó không kiểm người gọi có phải chủ đơn không. Với an đã đăng nhập thật, nó trả về mọi đơn an hỏi tới — kể cả đơn của binh. Xác thực (bạn là ai) và phân quyền (bạn được làm gì) là hai bước tách biệt, và IDOR là khi bước thứ hai bị bỏ quên. Số thẻ của binh rò ra không phải vì mật khẩu yếu hay injection — mà vì một câu if không được viết.
Cách chặn đúng là kiểm quyền sở hữu ở phía server, cho mọi truy cập. Route v1 thêm đúng một dòng: đơn này có chủ là người đang đăng nhập không? Nếu không, 403. Đơn giản đến mức tầm thường, nhưng nó là toàn bộ sự khác biệt giữa an toàn và rò rỉ. Điểm mấu chốt: phép kiểm phải nằm ở server, gắn với danh tính từ phiên (không phải từ tham số client gửi lên), và chạy cho mọi endpoint chạm tới tài nguyên — xem, sửa, xoá, liệt kê.
Id khó đoán là che giấu, không phải phân quyền — và số đo chứng minh điều đó. Dòng cuối bảng: route v2 dùng UUID ngẫu nhiên 32 ký tự thay cho số thứ tự. Nghe có vẻ an toàn — kẻ tấn công không thể đếm 1, 2, 3... để dò. Nhưng UUID vẫn rò ra: qua log máy chủ, qua header Referer, qua lịch sử trình duyệt, qua một link chia sẻ, qua URL dán vào chat. Ngay khi kẻ tấn công có UUID của đơn người khác, v2 trả về nội dung — vì nó không kiểm quyền sở hữu, chỉ dựa vào việc id khó đoán. Đây là security through obscurity: làm khó, không làm bất khả. Một id khó đoán là lớp bảo vệ chiều sâu tốt, nhưng không bao giờ được thay cho phép kiểm quyền.
IDOR không chỉ ở "xem" — và không chỉ ở id số
Ví dụ trong bài là đọc dữ liệu qua id số, nhưng IDOR rộng hơn thế. Cùng lỗ thiếu-kiểm-quyền xuất hiện ở nhiều dạng:
- Ghi, không chỉ đọc.
POST /don/2/huymà không kiểm chủ sở hữu cho phép An huỷ đơn của Bình, không chỉ xem. Hậu quả nặng hơn nhiều. - Id ẩn trong thân request, không chỉ URL. Một form gửi
{"don_id": 2, ...}cũng là IDOR nếu server tindon_idmà không kiểm quyền. Đổi URL hay đổi thân request là như nhau với kẻ tấn công. - Phân quyền cấp trường. Đôi khi bạn được xem một bản ghi nhưng không được xem mọi trường của nó. Một API trả về cả object khi bạn chỉ nên thấy vài trường công khai là một biến thể tinh vi của cùng vấn đề: kiểm quyền ở mức bản ghi nhưng quên ở mức trường.
Điểm chung: ở đâu ứng dụng dùng một định danh do client cung cấp để tra dữ liệu, ở đó phải có một phép kiểm "người này có quyền với định danh này không". Không có ngoại lệ nào an toàn để bỏ qua.
Vì sao IDOR phổ biến đến vậy
IDOR đứng đầu bảng vì nó là thiếu một phép kiểm, không phải có một lỗi — và thiếu sót khó thấy hơn hiện diện nhiều:
- Injection, XSS có một payload nhìn thấy được; người rà soát mã có thể tìm chuỗi đáng ngờ. IDOR không có payload — request
/don/2trông y hệt request hợp lệ/don/1. Chỉ khác con số. - Test chức năng thường chạy với một người dùng, xem dữ liệu của họ — và mọi thứ xanh. Lỗ chỉ hiện khi một người dùng chạm tới dữ liệu của người khác, mà kịch bản đó hiếm khi được viết ra.
- Framework tự lo xác thực (middleware đăng nhập), nhưng phân quyền theo tài nguyên thì không tự làm được — nó phụ thuộc logic nghiệp vụ "ai sở hữu cái gì", mà chỉ ứng dụng biết.
Đó là lý do phân quyền hỏng đứng đầu bảng: nó là lỗi của sự vắng mặt, ở đúng chỗ công cụ tự động và test thông thường không nhìn tới.
Nghĩa là gì trong công việc hằng ngày
- Với mỗi truy cập tài nguyên, hỏi "người này có được chạm vào cái này không" — không chỉ "người này đã đăng nhập chưa". Viết phép kiểm quyền sở hữu ngay tại nơi truy vấn dữ liệu.
- Lấy danh tính từ phiên, không từ request. Nếu ứng dụng nhận
user_idtừ tham số URL rồi tin nó, kẻ tấn công đổi tham số là xong. Chủ sở hữu phải suy ra từ phiên đã xác thực. - Kiểm ở mọi động từ. Một endpoint
GET /don/{id}được bảo vệ nhưngDELETE /don/{id}thì quên là một IDOR đầy đủ. Xem, sửa, xoá, liệt kê — tất cả cần cùng phép kiểm. - Dùng id khó đoán như lớp thêm, không như lớp duy nhất. UUID tốt cho chiều sâu phòng thủ, nhưng phép kiểm quyền mới là lớp chặn thật.
Cùng một phép kiểm bị thiếu, đổi ngăn xếp
Chuyện "framework lo xác thực nhưng phân quyền theo tài nguyên phải tự viết, và người ta hay quên" không riêng gì Flask — mỗi hệ có công cụ riêng, và cùng một chỗ dễ bỏ sót.
Trong thế giới Java, Spring Security phân biệt rõ hai tầng này: xác thực và phân quyền theo vai (@PreAuthorize("hasRole('ADMIN')")) thì dễ và hay được dùng — nhưng đó không phải cái IDOR cần. IDOR đòi phân quyền theo instance: "user này có sở hữu đơn hàng này không", và Spring có @PreAuthorize("@ownerCheck.isOwner(#id, authentication)") hay ACL, nhưng chúng phải được viết tay cho từng tài nguyên. CLAUDE.md của chính blog này ghi lại đúng tinh thần đó: tác giả chỉ xem/sửa được bài của mình, getForEdit kiểm quyền sở hữu chứ không chỉ kiểm vai — vai AUTHOR không đủ, phải đúng chủ.
Trong Go, không có framework auth mặc định, nên phép kiểm sở hữu là code nghiệp vụ trần trụi trong handler: if order.UserID != session.UserID { http.Error(w, "", 403); return }. Trần trụi có cái hay — nó hiện rõ trong review — nhưng cũng có cái dở: dễ copy-paste một handler mới mà quên chép luôn dòng kiểm đó. Pattern an toàn là đưa phép kiểm vào tầng truy vấn: luôn WHERE user_id = ? cùng với WHERE id = ?.
Còn ở cơ sở dữ liệu, đây là nơi có lớp phòng thủ mạnh nhất mà ít ai dùng: Row-Level Security. PostgreSQL cho phép định nghĩa policy USING (user_id = current_setting('app.user_id')) để CSDL tự lọc chỉ những dòng thuộc về người dùng hiện tại — nên ngay cả khi lập trình viên quên câu if, một truy vấn SELECT * FROM don WHERE id = 2 vẫn trả về rỗng nếu đơn đó không phải của họ. Đây là "kiểm quyền ở mức không thể quên" vì nó nằm dưới cả tầng ứng dụng.
Mẫu số chung: xác thực trả lời "bạn là ai", còn cái ngăn IDOR là phân quyền theo từng đối tượng — "bạn có được chạm vào chính cái này không" — và không framework nào tự làm hộ, vì chỉ ứng dụng biết ai sở hữu cái gì. Mọi cách chặn đúng đều quy về việc ràng danh tính (lấy từ phiên, không từ request) vào phép tra dữ liệu, ở mọi động từ, lý tưởng là đẩy xuống tận tầng truy vấn để không thể quên. Nên câu hỏi đúng cho mỗi endpoint nhận id không phải "người này đã đăng nhập chưa" mà là "nếu họ đổi id này thành id của người khác, có gì chặn không".
Muốn tự soát, tìm trong ứng dụng của bạn một endpoint nhận id tài nguyên trong URL — /hoa-don/{id}, /tin-nhan/{id}, /ho-so/{id}. Đăng nhập bằng một tài khoản, rồi đổi id sang một tài nguyên bạn biết thuộc về người khác. Nếu nó trả về dữ liệu, bạn vừa tìm thấy một IDOR — lỗ đứng đầu OWASP Top 10, và là một câu if bị thiếu. Nếu nó trả 403 hoặc 404, kiểm tiếp các động từ khác (sửa, xoá) trên cùng endpoint: phép kiểm phải có ở tất cả, không chỉ ở GET.