跳到主内容

水平越权避坑实录:IDOR 扫描定位与资源归属校验修复清单 ​

问题背景:登录成功不等于可以访问该资源 ​

很多后端系统把“用户是否登录”做成了统一中间件,却忽略了“当前用户是否有权操作这条资源”。例如接口 GET /api/v1/orders/{id} 只校验了 JWT 是否有效,随后直接按路径参数查询订单:

go
// 危险示例:只认证,不做资源归属校验
func GetOrder(c *gin.Context) {
    orderID := c.Param("id")
    var order Order
    // 攻击者只要枚举或猜测 order_id,就可能读取他人订单
    if err := db.First(&order, "id = ?", orderID).Error; err != nil {
        c.JSON(404, gin.H{"error": "order not found"})
        return
    }
    c.JSON(200, order)
}

这类问题通常被称为水平越权,也常对应 OWASP 中的 IDOR(Insecure Direct Object Reference,不安全的直接对象引用)。它的本质不是“没登录”,而是服务端信任了客户端传入的资源 ID,没有验证资源归属、租户边界或协作权限。

IDOR 的危害往往比普通未授权访问更隐蔽:攻击者持有合法账号,请求频率可以很低,参数也可能完全符合格式,因此传统 WAF、登录校验和参数合法性校验很难发现。订单、发票、病历、工单、文件下载链接、企业成员操作接口,都是高发位置。

方案对比:三类资源权限防护怎么做 ​

方案一:在 Controller 中手写 owner_id 判断 ​

最常见的修法是查询后判断 order.UserID == currentUser.ID。它简单直接,但依赖开发者记忆。项目一旦有几十个资源类型,就容易在新增查询、导出、批量操作、异步回调中漏判。

方案二:在 ORM 或数据访问层强制注入租户条件 ​

例如所有查询都追加 tenant_id = ?,适合多租户系统。优点是默认安全,缺点是只能解决强归属关系;遇到“成员可访问项目”“客服可处理指定部门工单”等复杂 ACL 时,需要额外抽象。

方案三:集中式资源归属校验服务 ​

在业务用例执行前,通过统一的资源解析器和授权器完成校验:先把 URL 或请求参数中的资源 ID 解析为标准资源,再根据动作执行 owner、tenant、role、ACL 等策略。该方案可测试、可审计,也更适合扫描和回归。

下面用一张图说明推荐的请求处理链路:

客户端请求携带资源 ID认证中间件解析用户身份资源归属校验owner/tenant/ACL业务用例执行读写操作认证失败 401身份无效或过期拒绝访问 403/404归属不匹配

扫描定位:如何快速找到 IDOR 风险接口 ​

IDOR 扫描不能只靠随机点测,建议按“资产识别、双账号对比、参数变异、结果判定”四步执行。

  1. 梳理资源型接口:从路由、OpenAPI、网关日志中提取包含 id、orderId、tenantId、fileId、userId 的接口。
  2. 准备两个同权限账号:账号 A 创建资源,账号 B 持有自己的资源和有效登录态。
  3. 替换资源标识:保留 B 的 Token,将路径、查询参数、JSON Body、表单字段中的资源 ID 替换为 A 的资源 ID。
  4. 观察响应和副作用:不仅比对状态码,还要检查响应体、导出文件、消息通知、数据库记录是否发生读写。

重点覆盖以下位置:

  • 路径参数:/orders/{id}、/files/{fileId}/download
  • 查询参数:?tenantId=1001、?userId=u_a
  • 请求体:批量删除、审批、转移负责人
  • 间接引用:Base64 ID、自增 ID、GraphQL Node ID
  • 上下游调用:内部 RPC 如果直接信任网关传参,也可能形成二次越权

判定时要注意状态码欺骗。有些接口返回 200 OK,但数据为空;有些接口返回 404,实际已经执行了状态变更;还有些接口在详情页拒绝访问,导出接口却泄露完整字段。

实操步骤:用 Go 实现集中式资源归属校验 ​

下面示例使用 Go、Gin、GORM 和 SQLite 演示可运行的最小防护闭环。示例包含订单资源、当前用户上下文、归属校验中间件和业务查询。

go
package main

import (
    "errors"
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// User 是登录用户;Principal 表示当前认证主体。
type User struct {
    ID       int64  `gorm:"primaryKey" json:"id"`
    TenantID int64  `json:"tenant_id"`
    Name     string `json:"name"`
}

// Order 是强归属资源,必须同时隔离 user_id 与 tenant_id。
type Order struct {
    ID       int64  `gorm:"primaryKey" json:"id"`
    TenantID int64  `json:"tenant_id"`
    UserID   int64  `json:"user_id"`
    Item     string `json:"item"`
}

type contextKey string

const currentUserKey contextKey = "current_user"

var db *gorm.DB

func main() {
    var err error
    db, err = gorm.Open(sqlite.Open("idor_demo.db"), &gorm.Config{})
    must(err)
    must(db.AutoMigrate(&User{}, &Order{}))
    seed()

    r := gin.Default()
    r.Use(FakeAuthMiddleware())

    // 路由层声明该资源需要 read_order 权限
    r.GET("/api/v1/orders/:id",
        RequireResource("order", "id", "read_order"),
        GetOrder,
    )

    r.Run(":8080")
}

// FakeAuthMiddleware 用请求头模拟登录态;生产环境应替换为 JWT/Session 解析。
func FakeAuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        uid, err := strconv.ParseInt(c.GetHeader("X-User-Id"), 10, 64)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
            return
        }

        var user User
        if err := db.First(&user, "id = ?", uid).Error; err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid user"})
            return
        }
        c.Set(string(currentUserKey), &user)
        c.Next()
    }
}

// Resource 抽象资源归属信息,便于统一授权。
type Resource struct {
    TenantID int64
    OwnerID  int64
}

// LoadResource 根据资源类型和 ID 查询归属信息,避免业务函数自行拼装不安全条件。
func LoadResource(resourceType string, id int64) (*Resource, error) {
    switch resourceType {
    case "order":
        var order Order
        if err := db.Select("tenant_id", "user_id").
            First(&order, "id = ?", id).Error; err != nil {
            return nil, err
        }
        return &Resource{TenantID: order.TenantID, OwnerID: order.UserID}, nil
    default:
        return nil, errors.New("unknown resource type")
    }
}

// Authorize 集中表达租户边界、资源所有者和动作权限。
func Authorize(user *User, res *Resource, action string) bool {
    if user.TenantID != res.TenantID {
        return false
    }
    switch action {
    case "read_order", "cancel_order":
        return user.ID == res.OwnerID
    default:
        return false
    }
}

// RequireResource 是可复用的资源归属校验中间件。
func RequireResource(resourceType, param, action string) gin.HandlerFunc {
    return func(c *gin.Context) {
        id, err := strconv.ParseInt(c.Param(param), 10, 64)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid resource id"})
            return
        }

        user, ok := c.MustGet(string(currentUserKey)).(*User)
        if !ok {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing principal"})
            return
        }

        res, err := LoadResource(resourceType, id)
        if err != nil {
            // 不向攻击者暴露资源是否存在
            c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "resource not found"})
            return
        }

        if !Authorize(user, res, action) {
            // 跨租户或非所有者访问统一记录安全审计日志
            c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"})
            return
        }

        c.Set("resource_id", id)
        c.Next()
    }
}

func GetOrder(c *gin.Context) {
    id := c.MustGet("resource_id"). (int64)
    user := c.MustGet(string(currentUserKey)).(*User)

    var order Order
    // 即使前置中间件已校验,查询层仍保留租户和所有者条件,形成纵深防御。
    err := db.First(
        &order,
        "id = ? AND tenant_id = ? AND user_id = ?",
        id, user.TenantID, user.ID,
    ).Error
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "order not found"})
        return
    }
    c.JSON(http.StatusOK, order)
}

func seed() {
    db.Exec("DELETE FROM orders")
    db.Exec("DELETE FROM users")
    db.Create(&User{ID: 1, TenantID: 100, Name: "Alice"})
    db.Create(&User{ID: 2, TenantID: 100, Name: "Bob"})
    db.Create(&User{ID: 3, TenantID: 200, Name: "Carol"})
    db.Create(&Order{ID: 9001, TenantID: 100, UserID: 1, Item: "Alice Laptop"})
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

初始化依赖后即可运行:

bash
mkdir idor-demo && cd idor-demo
go mod init idor-demo
go get github.com/gin-gonic/gin gorm.io/gorm gorm.io/driver/sqlite
# 将上面的代码保存为 main.go,然后执行:
go run main.go

验证方式如下:

bash
# Alice 可以访问自己的订单
curl -H 'X-User-Id: 1' http://localhost:8080/api/v1/orders/9001

# Bob 与 Alice 同租户但不是所有者,应被拒绝
curl -i -H 'X-User-Id: 2' http://localhost:8080/api/v1/orders/9001

# Carol 属于其他租户,也应被拒绝
curl -i -H 'X-User-Id: 3' http://localhost:8080/api/v1/orders/9001

对于列表接口,不要先查出分页数据再在内存中过滤,而应在 SQL 条件中加入租户和用户约束。对于批量操作,应使用集合归属校验:WHERE id IN ? AND tenant_id = ? AND user_id = ?,并比对请求资源数量与命中数量,防止“部分成功”。

修复验收清单:从代码修到测试与审计 ​

修复 IDOR 时建议把验收项固化到代码评审和安全测试中:

  • 每个资源型路由都明确动作:read、update、delete、approve、export。
  • 查询条件同时包含资源 ID、租户 ID、所有者或 ACL 关系。
  • 不相信客户端传入的 tenantId、userId、deptId,这些字段必须来自服务端认证主体。
  • 写操作先授权再执行,不能等数据库更新后再判断影响行。
  • 批量接口逐个或按集合验证,不允许一个合法 ID 混带多个越权 ID。
  • 403、404 的选择符合信息暴露策略,并记录用户、资源、动作、结果和请求追踪 ID。
  • 单测覆盖本人、同租户他人、跨租户、不存在资源、已删除资源五类用例。

避坑总结:水平越权修复中的高频错误 ​

1. 只在详情接口校验,漏掉导出和操作接口 ​

常见现象是 GET /orders/9001 返回 403,但 GET /export?orderId=9001 能下载文件,或者 POST /orders/cancel 在 Body 中传 order_id 后仍可取消。修正方法是按资源和动作建立统一授权表,所有入口复用同一个 Authorize(user, resource, action),不要为导出、回调、内部接口另写一套“默认放行”逻辑。

2. 使用自增 ID 并依赖不可猜测性 ​

有些团队把主键改成 UUID 后就认为问题解决了。UUID 只能提高枚举成本,无法阻止 ID 从日志、分享链接、客服消息、前端埋点或历史响应中泄露。修正方法是继续执行服务端资源归属校验;如果需要降低枚举风险,可以使用无规律外部 ID,但它只能作为辅助手段。

3. 只判断 owner_id,漏掉租户边界 ​

错误代码通常是 WHERE id = ? AND user_id = ?。在数据合并、测试库还原或 ID 生成异常时,跨租户数据可能因所有者条件遗漏而暴露。更稳妥的方式是所有业务查询都强制携带 tenant_id,并在数据库层建立 (tenant_id, id) 唯一约束或索引。

4. 先查资源再鉴权,造成信息泄露 ​

如果未授权请求返回“订单不属于你”,攻击者就能确认该订单存在;某些接口还会在错误信息中回显手机号、邮箱、文件名。修正方法是资源不存在和无权访问按统一策略返回 404 或模糊化 403,详细原因只写入受保护的审计日志。

5. 内部服务调用绕过鉴权 ​

网关已经校验用户身份,并不代表订单服务、文件服务可以信任请求中的 X-User-Id。内网请求一旦被 SSRF、失陷容器或调试端口利用,就可能横向读取数据。修正方法是在服务间传递签名后的身份令牌,并在目标服务重新认证、授权;高风险内部接口也应绑定 mTLS、来源服务身份和细粒度权限。

水平越权防护的关键不是补几个 if,而是把“资源是谁的、当前主体是谁、要执行什么动作”沉淀为统一、可测试、可审计的服务端能力。配合双账号扫描、批量接口测试和审计告警,IDOR 才能从反复出现的线上漏洞,变成在开发和测试阶段即可被稳定拦截的工程问题。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。