Bài này thuộc sê-ri phòng thủ / giáo dục. Mọi lỗ hổng được tái hiện trong một lab cô lập (hai server HTTP chạy trong bộ nhớ, dữ liệu giả) với mục đích duy nhất là hiểu cơ chế để biết cách phòng. Đừng thử những kỹ thuật này lên hệ thống bạn không sở hữu hoặc không được phép kiểm thử — đó là hành vi phạm pháp.

Có một hiểu lầm phổ biến: "đăng nhập xong là xong phần bảo mật". Không. Đăng nhập chỉ trả lời câu hỏi "bạn LÀ AI?" (xác thực — authentication). Còn một câu hỏi thứ hai, quan trọng ngang ngửa, mà rất nhiều ứng dụng quên: "bạn ĐƯỢC LÀM GÌ?" (uỷ quyền — authorization). Khoảng trống giữa hai câu hỏi này chính là Broken Access Control — và từ 2021 nó đứng hạng #1 trong OWASP Top 10, vượt cả Injection. Lý do nó phổ biến đến vậy: nó không phải một kỹ thuật tấn công tinh vi, nó là một thứ bị quên. Bài này (phần 11 sê-ri Bảo mật) tái hiện dạng kinh điển nhất của nó — IDOR — trong lab, rồi vá bằng đúng một dòng.

Xác thực ≠ Uỷ quyền

Hai khái niệm hay bị gộp làm một, nhưng chúng tách bạch:

  • Xác thực (authentication): xác minh danh tính. Token/session cho server biết request này đến từ alice. Phần này thường được làm đúng — ai cũng nhớ bắt đăng nhập.
  • Uỷ quyền (authorization): xác minh quyền hạn. alice có được phép xem/sửa/xoá đối tượng cụ thể này không? Phần này hay bị bỏ sót, đặc biệt ở mức từng đối tượng (object-level).

IDOR (Insecure Direct Object Reference) là tên gọi khi uỷ quyền mức đối tượng bị thiếu: ứng dụng nhận một tham chiếu trực tiếp tới đối tượng (thường là id trong URL như /orders/1002) và trả về nó mà không kiểm người gọi có sở hữu đối tượng đó không. Có xác thực đầy đủ, nhưng thiếu uỷ quyền.

// Authentication: "bạn LÀ AI?"  -> token cho biết là alice (thường làm đúng)
// Authorization:  "bạn ĐƯỢC LÀM GÌ?" -> alice có quyền với order NÀY không? (hay quên)
// IDOR = có xác thực NHƯNG thiếu uỷ quyền ở mức đối tượng

Ảnh chụp đoạn mã nền tối minh hoạ broken access control IDOR và kiểm quyền phía server, lỗ hổng xếp hạng số 1 OWASP server biết bạn là ai nhưng quên kiểm bạn được làm gì. Xác thực khác uỷ quyền authentication bạn là ai token session cho biết là alice authorization bạn được làm gì alice có quyền với order này không IDOR là có xác thực nhưng thiếu uỷ quyền ở mức đối tượng. Chỉ xác thực không kiểm sở hữu IDOR u bằng currentUser biết là alice xác thực ok o bằng orders id lấy order theo id từ URL write o chấm Data trả về mà không kiểm o chấm Owner bằng u alice đổi id trong URL đọc order của bob. Kiểm sở hữu phía server mỗi request u bằng currentUser o ok bằng orders id if not ok hoặc o chấm Owner khác u thì http Error 403 return kiểm quyền write o chấm Data hoặc query WHERE id bằng và owner bằng currentUser

Hình 1: Xác thực (bạn là ai) tách bạch với uỷ quyền (bạn được làm gì). Handler lỗi lấy order theo id rồi trả về mà không kiểm o.Owner == u; handler an toàn thêm đúng một bước kiểm sở hữu trước khi trả dữ liệu.

Tái hiện trong lab: hai server, một dòng khác nhau

Mình dựng hai HTTP server trong go-lab, giống hệt nhau trừ một chỗ. Có hai user (alice, bob), mỗi người một đơn hàng; token trong header Authorization cho biết người gọi là ai (phần xác thực — cả hai server đều có).

Handler lỗ hổng chỉ có xác thực:

func vulnerable(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)            // xác thực: biết là alice
	if u == "" {
		http.Error(w, "chua dang nhap", http.StatusUnauthorized)
		return
	}
	id := r.URL.Query().Get("id")
	o, ok := orders[id]
	if !ok {
		http.Error(w, "khong thay", http.StatusNotFound)
		return
	}
	// THIẾU: không kiểm o.Owner == u
	fmt.Fprintf(w, "Don hang cua %s: %s", o.Owner, o.Data)
}

Handler an toàn chỉ thêm một dòng — kiểm sở hữu:

func secure(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)
	if u == "" {
		http.Error(w, "chua dang nhap", http.StatusUnauthorized)
		return
	}
	id := r.URL.Query().Get("id")
	o, ok := orders[id]
	if !ok || o.Owner != u {          // KIỂM QUYỀN: không phải chủ -> 403
		http.Error(w, "khong co quyen", http.StatusForbidden)
		return
	}
	fmt.Fprintf(w, "Don hang cua %s: %s", o.Owner, o.Data)
}

Rồi mình đóng vai Alice (đã đăng nhập hợp lệ), gọi hai lần vào mỗi server: một lần với id đơn hàng của chính mình (1001), một lần đổi id sang đơn của Bob (1002).

Ảnh chụp bảng kết quả chạy thật trong go lab output thật golang 1.23 hai user alice bob HTTP server. Vulnerable chỉ xác thực không kiểm sở hữu Alice đọc order của mình 1001 HTTP 200 Đơn hàng của Alice 2 triệu, Alice đổi id sang order của Bob 1002 HTTP 200 Đơn hàng của Bob 50 triệu nhạy cảm, chỉ cần đổi số id trong URL Alice đọc được dữ liệu của Bob bằng IDOR. Secure kiểm sở hữu phía server o chấm Owner bằng currentUser Alice đọc order của mình 1001 HTTP 200 Đơn hàng của Alice 2 triệu, Alice đổi id sang order của Bob 1002 HTTP 403 không có quyền bị chặn, cùng request đổi id nhưng server kiểm o chấm Owner khác currentUser trả về 403. Xác thực cho biết Alice là ai uỷ quyền kiểm Alice có quyền với đối tượng này không phải kiểm ở server mỗi request ẩn nút trên giao diện không chặn được vì client đổi URL trực tiếp

Hình 2: Kết quả đo thật. Server lỗ hổng: Alice đổi id sang 1002 vẫn nhận HTTP 200 kèm đơn hàng nhạy cảm của Bob. Server an toàn: cùng request đó trả HTTP 403 "khong co quyen". Khác biệt duy nhất giữa hai server là một dòng kiểm o.Owner != u.

Đọc kết quả thật:

  • Server lỗ hổng (IDOR): Alice đọc đơn của mình (1001) → HTTP 200, trả Don hang cua Alice: 2 trieu — đúng và hợp lệ. Nhưng khi Alice đổi id trong URL sang 1002 → vẫn HTTP 200, trả Don hang cua Bob: 50 trieu (nhay cam). Alice đọc được dữ liệu của Bob chỉ bằng cách sửa một con số trên URL. Không cần công cụ hacking, không cần vượt tường lửa — gõ số khác là xong. Đây chính là IDOR.
  • Server an toàn: cùng hai request. Đọc đơn của mình → HTTP 200. Nhưng đổi id sang 1002 → HTTP 403 "khong co quyen". Server kiểm o.Owner != u, thấy đơn 1002 thuộc Bob chứ không phải Alice, nên từ chối. Dữ liệu của Bob được bảo vệ.

Điều đáng sợ của IDOR nằm ở sự tầm thường của nó: lỗ hổng không phải ở thuật toán mã hoá hay cấu hình phức tạp, mà ở một dòng kiểm bị quên. Và cách khai thác đơn giản tới mức một người dùng bình thường có thể vấp phải tình cờ khi nghịch URL.

Vì sao phải kiểm ở SERVER, mỗi request

Một cái bẫy tư duy hay gặp: "mình đã ẩn nút Xem đơn của người khác trên giao diện rồi, người dùng đâu bấm được". Sai hoàn toàn. Giao diện (client) không phải ranh giới bảo mật. Client chạy trên máy của người dùng — họ toàn quyền sửa HTML, gọi thẳng API bằng curl, hay chỉ đơn giản là gõ URL khác vào thanh địa chỉ. Mọi quyết định uỷ quyền phải nằm ở server, nơi người dùng không can thiệp được.

Và phải kiểm mỗi request, trên mỗi đối tượng được truy cập — không phải một lần lúc đăng nhập. Xác thực xảy ra một lần (lúc đăng nhập, cấp token); uỷ quyền phải xảy ra mỗi lần chạm vào một tài nguyên, vì mỗi tài nguyên có chủ sở hữu/quyền khác nhau.

Một mẹo kiến trúc rất hiệu quả: đẩy điều kiện quyền xuống tận câu truy vấn. Thay vì SELECT * FROM orders WHERE id = ? rồi mới kiểm chủ trong code (dễ quên), viết thẳng SELECT * FROM orders WHERE id = ? AND owner = ? với tham số thứ hai là người dùng hiện tại. Khi đó, đơn không thuộc về họ sẽ không có trong kết quả ngay từ đầu — không có đối tượng để lỡ tay trả về. Điều kiện quyền trở thành một phần của phép lấy dữ liệu, không phải một bước kiểm có thể bị bỏ sót.

Đánh đổi và những góc khuất

ID khó đoán KHÔNG phải uỷ quyền. Một phản xạ sai là đổi id tuần tự (1001, 1002…) thành UUID ngẫu nhiên để "không ai đoán được id của người khác". Đó là security through obscurity — nó làm khó việc dò id, nhưng không phải kiểm quyền. id vẫn lộ ra ở muôn nơi: URL chia sẻ, log, lịch sử trình duyệt, Referer header. Dùng UUID là tốt (giảm bề mặt dò quét), nhưng nó bổ sung chứ không thay thế cho kiểm sở hữu phía server.

Access control phức tạp hơn "chủ sở hữu". Lab này dùng mô hình đơn giản nhất: một đối tượng, một chủ. Thực tế có vai trò (admin xem được mọi đơn), chia sẻ (Bob cho Alice quyền xem đơn của mình), phân cấp (quản lý xem được đơn của nhân viên dưới quyền). Khi đó logic uỷ quyền nên tách ra một tầng riêng (policy/RBAC/ABAC) thay vì rải if o.Owner != u khắp nơi — rải rác là công thức để quên một chỗ. Nhưng nguyên tắc không đổi: server quyết định, mỗi request.

Đừng quên các thao tác GHI. IDOR hay được minh hoạ bằng đọc (như lab này), nhưng nguy hiểm nhất thường là ghi: POST /orders/1002/cancel hay DELETE /users/1002 mà không kiểm quyền cho phép Alice huỷ đơn hoặc xoá tài khoản của Bob. Mọi endpoint đụng tới một đối tượng — đọc, sửa, xoá — đều cần cùng một lớp kiểm.

Ba ý mang về

  1. Broken Access Control đứng #1 OWASP vì nó là thứ bị quên, không phải thứ khó. Xác thực (bạn là ai) được làm đúng gần như mọi nơi; uỷ quyền mức đối tượng (bạn có quyền với cái này không) hay bị bỏ sót. Đo thật trong lab: server chỉ xác thực để Alice đổi id trên URL và đọc đơn 50 triệu của Bob với HTTP 200.
  2. Bản vá là một dòng, nhưng phải đặt đúng chỗ: ở SERVER, mỗi request, mỗi đối tượng. Thêm if o.Owner != u { return 403 } biến cùng request IDOR đó thành HTTP 403. Tốt hơn nữa: đẩy điều kiện quyền xuống truy vấn (WHERE id = ? AND owner = ?) để không còn đối tượng nào để lỡ tay trả về.
  3. Client không phải ranh giới bảo mật, và id khó đoán không phải uỷ quyền. Ẩn nút trên giao diện hay dùng UUID chỉ làm khó, không chặn — người dùng sửa URL/gọi thẳng API được. Và nhớ áp lớp kiểm cho cả thao tác ghi (sửa/xoá), không chỉ đọc.

Nguồn

Phần sau là bài tổng kết sê-ri: ánh xạ 11 bài đã học vào OWASP Top 10, một checklist bảo mật để chạy trước khi lên production, và cây quyết định review code dưới góc nhìn an ninh.