IDOR và Broken Access Control: tại sao vẫn là #1 OWASP 2025 dù ai cũng biết

Broken Access Control — bao gồm IDOR (Insecure Direct Object Reference) — giữ vị trí #1 OWASP Top 10 lần thứ hai liên tiếp. 94% ứng dụng có lỗi access control ở mức độ nào đó. Lý do nó không biến mất không phải vì developer không biết khái niệm, mà vì **kiểm tra authorization bị bỏ sót ở cấp độ object**, không phải cấp độ route. Bài này đi qua 3 exploitation pattern thực tế và cách fix đúng trong ASP.NET Core. ---

Vấn đề

Nếu tôi là attacker và vừa đăng ký tài khoản trên một ứng dụng e-commerce, việc đầu tiên tôi làm sau khi đăng nhập là gọi API xem đơn hàng của mình:

GET /api/orders/10042
Authorization: Bearer eyJ...mytoken

Response trả về đúng đơn hàng của tôi. Bây giờ tôi thử:

GET /api/orders/10041
GET /api/orders/10043
GET /api/orders/9999

Nếu server trả về đơn hàng của người khác — không có lỗi 403, không có bất kỳ validation nào — thì đây là IDOR. Toàn bộ database đơn hàng của hệ thống có thể bị enumerate theo đúng nghĩa đen.

Tôi đã gặp pattern này trong các buổi audit không chỉ một lần.


Tại sao IDOR vẫn ở #1 sau 4 năm

Authentication (xác thực danh tính) và authorization (kiểm tra quyền) là hai thứ khác nhau hoàn toàn, nhưng rất hay bị nhầm lẫn trong implementation.

// Developer nghĩ: "endpoint này có [Authorize], vậy là an toàn"
[Authorize]
[HttpGet("orders/{orderId}")]
public async Task<IActionResult> GetOrder(int orderId)
{
    // [Authorize] chỉ verify: token hợp lệ, user đã đăng nhập
    // Không hề verify: user này có quyền xem order này không?

    var order = await _db.Orders.FindAsync(orderId);
    return Ok(order); // Trả về bất kỳ order nào nếu orderId tồn tại
}

[Authorize] attribute kiểm tra authentication — đảm bảo request đến từ user đã đăng nhập. Nhưng không có gì kiểm tra xem user đó có sở hữu resource được request hay không. Đây là gap mà IDOR khai thác.


3 exploitation pattern và cơ chế

Pattern 1: Sequential integer IDs — dễ nhất

https://app.com/invoices/1001  → Hóa đơn của tôi
https://app.com/invoices/1002  → Hóa đơn của người khác (attacker enumerate)
https://app.com/invoices/1003
...
https://app.com/invoices/9999

Sequential ID là worst case — attacker biết chính xác cần request bao nhiêu ID để dump toàn bộ data. Một script đơn giản có thể enumerate hàng nghìn records trong vài phút.

Pattern 2: Encoded IDs — false security

Nhiều team chuyển sang Base64 hoặc hash với hy vọng sẽ làm attacker khó hơn:

User ID: 42
Base64:  NDI=        ← Trivial decode
MD5:     a1d0c6e8... ← Crackable offline nếu attacker biết pattern

Base64 không phải encryption. MD5 không phải security mechanism. Nếu attacker biết scheme encoding, phá vỡ chỉ là vấn đề thời gian.

Pattern 3: API parameter manipulation

GET /api/reports?userId=123&month=2026-03  → Report của tôi
GET /api/reports?userId=124&month=2026-03  → Report của người khác

Thay đổi query parameter — không cần biết internal ID format. Attacker thay đổi userId và traverse toàn bộ user database.


Giải thích: object-level authorization là gì

Phần lớn access control implementation dừng ở route level:

Route /admin/* → chỉ admin
Route /api/orders/* → chỉ authenticated user

Nhưng IDOR tấn công ở level thấp hơn — object level. Câu hỏi không phải "user có được phép vào route này không?" mà là "user có được phép truy cập object cụ thể này không?"

Object-level authorization yêu cầu kiểm tra ownership tại mỗi lần access, không phải một lần tại route.


Fix đúng cách: ownership verification

Fix cơ bản — always bind to authenticated user

[Authorize]
[HttpGet("orders/{orderId}")]
public async Task<IActionResult> GetOrder(int orderId)
{
    // Lấy userId từ token đã authenticate — không từ request body/query
    var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    // MANDATORY: filter bằng cả orderId VÀ userId cùng lúc
    var order = await _db.Orders
        .FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == userId);

    // Nếu order không tồn tại HOẶC không thuộc user này → Forbid
    if (order == null)
        return Forbid(); // 403, không phải 404 — không leak thông tin order có tồn tại không

    return Ok(new OrderResponse
    {
        Id = order.Id,
        TotalAmount = order.TotalAmount,
        Status = order.Status,
        CreatedAt = order.CreatedAt
    });
}

Hai điểm quan trọng:

  1. userId lấy từ token — không bao giờ từ request parameter, vì attacker kiểm soát được request parameter
  2. Trả về Forbid() (403) thay vì NotFound() (404) — để không tiết lộ resource có tồn tại không (information leakage)

Fix nâng cao — Policy-based authorization

Với nhiều resource type, viết ownership check lặp đi lặp lại cho mỗi endpoint là DRY violation. Dùng ASP.NET Core Policy:

// OrderAuthorizationHandler.cs
public class OrderOwnerRequirement : IAuthorizationRequirement { }

public class OrderAuthorizationHandler 
    : AuthorizationHandler<OrderOwnerRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        OrderOwnerRequirement requirement,
        Order resource)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        if (resource.UserId == userId)
            context.Succeed(requirement);
        // Fail silently — nếu không Succeed, mặc định là Forbid

        return Task.CompletedTask;
    }
}

// Program.cs
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("OrderOwner", policy =>
        policy.Requirements.Add(new OrderOwnerRequirement()));
});
builder.Services.AddScoped<IAuthorizationHandler, OrderAuthorizationHandler>();

// Controller
[Authorize]
[HttpGet("orders/{orderId}")]
public async Task<IActionResult> GetOrder(
    int orderId,
    [FromServices] IAuthorizationService authService)
{
    var order = await _db.Orders.FindAsync(orderId);
    if (order == null) return NotFound();

    // Authorization check tách biệt, reusable
    var authResult = await authService.AuthorizeAsync(User, order, "OrderOwner");
    if (!authResult.Succeeded)
        return Forbid();

    return Ok(order);
}

Policy-based approach tách authorization logic thành handler riêng — testable, reusable, không lặp code.

Fix cho indirect reference — UUID thay integer

Nếu cần giải quyết tận gốc pattern sequential ID:

// Migration: thêm PublicId (UUID) bên cạnh internal Id (integer)
public class Order
{
    public int Id { get; set; }           // Internal, không expose
    public Guid PublicId { get; set; }    // Expose ra API
    public string UserId { get; set; }
    // ...
}

// API dùng PublicId, không dùng internal Id
[HttpGet("orders/{publicId:guid}")]
public async Task<IActionResult> GetOrder(Guid publicId)
{
    var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

    var order = await _db.Orders
        .FirstOrDefaultAsync(o => o.PublicId == publicId && o.UserId == userId);

    if (order == null) return Forbid();
    return Ok(order);
}

UUID không sequential — attacker không thể enumerate. Nhưng không thay thế ownership check — vẫn cần o.UserId == userId vì UUID có thể bị leak qua channel khác.


Kiểm tra IDOR trong codebase hiện có

Manual checklist để review:

# Tìm các endpoint trả về data theo ID parameter
grep -rn "FindAsync\|FirstOrDefaultAsync\|SingleOrDefaultAsync" --include="*.cs" \
  | grep -v "userId\|UserId\|ownerId\|OwnerId\|currentUser"
# → Kết quả cần human review: endpoint nào query by ID mà không filter bằng userId?

# Tìm controller actions với route parameters nhưng không có ownership filter
grep -rn "\[HttpGet.*{.*Id" --include="*.cs" -A 10 \
  | grep -v "AuthorizeAsync\|UserId\|userId"

Đây là starting point — kết quả cần người đọc xác nhận từng case.


Testing IDOR: two-account method

Cách test đơn giản nhất — dùng hai tài khoản:

  1. Account A: tạo resource (order, invoice, report...)
  2. Account B: copy ID của resource từ Account A, request bằng token của Account B
  3. Kết quả mong muốn: Account B nhận 403 Forbidden
  4. Kết quả dính lỗi: Account B nhận 200 OK với data của Account A

Test case này cần có trong bộ integration test của mọi resource endpoint nhạy cảm.

[Fact]
public async Task GetOrder_WhenRequestedByDifferentUser_ReturnsForbid()
{
    // Arrange
    var ownerToken = await GetTokenForUser("user-a@example.com");
    var attackerToken = await GetTokenForUser("user-b@example.com");

    // Tạo order bằng Account A
    var orderId = await CreateOrder(ownerToken);

    // Act: Account B cố access order của Account A
    _client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("Bearer", attackerToken);
    var response = await _client.GetAsync($"/api/orders/{orderId}");

    // Assert: phải là 403, không được là 200
    Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}

Kết

IDOR ở #1 OWASP vì nó là loại lỗi invisible — code trông đúng về mặt syntax, test unit pass, nhưng missing một kiểm tra logic quan trọng. Fix không phức tạp: luôn filter resource bằng cả resource ID và authenticated user ID cùng lúc. Không có shortcut nào thay thế được kiểm tra này.

Bài tiếp theo: API security checklist 2026 — 20 điểm kiểm tra cụ thể trước khi go-live, bao gồm rate limiting, input validation, response sanitization, và monitoring.


Thiết Kiếm — BKGlobal Tech Team

#BKGlobal #appsec #security #owasp #idor #securecoding

Bảo mật & AppSec

Xem tất cả