外观
水平越权避坑实录: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 等策略。该方案可测试、可审计,也更适合扫描和回归。
下面用一张图说明推荐的请求处理链路:
扫描定位:如何快速找到 IDOR 风险接口
IDOR 扫描不能只靠随机点测,建议按“资产识别、双账号对比、参数变异、结果判定”四步执行。
- 梳理资源型接口:从路由、OpenAPI、网关日志中提取包含
id、orderId、tenantId、fileId、userId的接口。 - 准备两个同权限账号:账号 A 创建资源,账号 B 持有自己的资源和有效登录态。
- 替换资源标识:保留 B 的 Token,将路径、查询参数、JSON Body、表单字段中的资源 ID 替换为 A 的资源 ID。
- 观察响应和副作用:不仅比对状态码,还要检查响应体、导出文件、消息通知、数据库记录是否发生读写。
重点覆盖以下位置:
- 路径参数:
/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 才能从反复出现的线上漏洞,变成在开发和测试阶段即可被稳定拦截的工程问题。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。