代码审计时,我经常碰到一种情况:开发人员信誓旦旦地说做好了权限控制,但一个简单的ID遍历就能把系统底裤扒光。这就是不安全的直接对象引用(IDOR),它不像SQL注入那样有华丽的攻击链,却像一把生锈的钥匙,轻轻一转就能打开别人的房门。今天不讲理论,直接看几个真实审计场景中的漏洞代码和修复方案,以及为什么有些“修复”其实根本没修好。

订单越权:最常见的IDOR重灾区

电商系统里,查看订单详情的接口是IDOR的高发地。典型漏洞代码长这样:

// 漏洞代码:直接根据传入的orderId查询,未校验所属用户
@GetMapping("/order/detail")
public Result getOrderDetail(Integer orderId) {
    Order order = orderService.getById(orderId);
    return Result.success(order);
}

攻击者只需把orderId从1001改成1002,就能看到别人的订单信息,包括收货地址、电话、购买的商品。这在渗透测试里几乎是必测项,一打一个准。修复方案不是简单地把ID加密,很多人以为把自增ID换成UUID就万事大吉,这是典型的自欺欺人。UUID只是让遍历变难了,但如果这个UUID通过其他接口泄露了,比如订单列表接口返回了完整UUID,攻击者照样能看别人的订单。正确的修复是从权限维度做校验:

// 修复代码:从会话中获取当前用户ID,校验订单归属
@GetMapping("/order/detail")
public Result getOrderDetail(Integer orderId) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    Order order = orderService.getOne(
        new LambdaQueryWrapper()
            .eq(Order::getId, orderId)
            .eq(Order::getUserId, currentUserId)
    );
    if (order == null) {
        throw new BusinessException("订单不存在或无权访问");
    }
    return Result.success(order);
}

注意这里有个细节:查询不到时统一返回“订单不存在或无权访问”,而不是分别提示“订单不存在”和“无权访问”。如果分开提示,攻击者可以通过返回信息的差异来判断订单ID是否有效,这本身就是一个信息泄露点。很多人修漏洞只修一半,留个信息泄露的尾巴,等于给攻击者递了个探测工具。

文件下载:路径遍历与IDOR的复合攻击

文件下载接口是另一个高危区。漏洞代码通常直接拼接文件路径:

// 漏洞代码:直接使用用户传入的文件名拼接路径
@GetMapping("/file/download")
public void downloadFile(String fileName, HttpServletResponse response) {
    String filePath = "/data/upload/contract/" + fileName;
    // 直接读取文件并输出
    File file = new File(filePath);
    FileUtils.copyFile(file, response.getOutputStream());
}

这个代码有两个问题:一是路径遍历,攻击者传fileName=../../etc/passwd就能读取系统文件;二是IDOR,即使限制了路径,攻击者遍历文件名也能下载其他用户的合同文件。修复要同时解决这两个问题:

// 修复代码:使用文件ID映射,校验权限,防止路径遍历
@GetMapping("/file/download")
public void downloadFile(Long fileId, HttpServletResponse response) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    
    // 通过fileId查询文件记录,同时校验归属
    FileRecord record = fileRecordService.getOne(
        new LambdaQueryWrapper()
            .eq(FileRecord::getId, fileId)
            .eq(FileRecord::getUserId, currentUserId)
    );
    
    if (record == null) {
        throw new BusinessException("文件不存在或无权访问");
    }
    
    // 使用白名单校验文件路径,防止路径遍历
    String basePath = "/data/upload/contract/";
    String filePath = basePath + record.getStoredFileName();
    if (!filePath.startsWith(basePath)) {
        throw new BusinessException("非法文件路径");
    }
    
    File file = new File(filePath);
    FileUtils.copyFile(file, response.getOutputStream());
}

这里的关键改动有三个:第一,前端只传fileId,不传实际文件名,文件名由服务端从数据库查询;第二,查询时同时校验fileId和userId的归属关系;第三,即使从数据库取出的storedFileName,也要做路径白名单校验,防止数据库被篡改后造成的二次攻击。安全是分层防御,不能把宝押在单点上。

API接口的水平越权:看似有认证实则裸奔

RESTful API设计中,资源ID放在URL路径里是常规做法,但权限校验经常漏掉。比如用户修改自己的资料:

// 漏洞代码:路径中的userId可被任意替换
@PutMapping("/api/user/{userId}/profile")
public Result updateProfile(@PathVariable Long userId, @RequestBody ProfileDto dto) {
    userService.updateProfile(userId, dto);
    return Result.success();
}

攻击者把userId改成别人的ID,就能修改任意用户的资料。有些开发人员觉得“反正要登录才能访问”,但登录只能证明你是合法用户,不能证明你有权操作那个特定的userId。修复的核心是把路径参数和会话中的用户ID做绑定:

// 修复代码:路径参数与会话用户强绑定
@PutMapping("/api/user/{userId}/profile")
public Result updateProfile(@PathVariable Long userId, @RequestBody ProfileDto dto) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    if (!currentUserId.equals(userId)) {
        throw new BusinessException("无权操作其他用户的数据");
    }
    userService.updateProfile(userId, dto);
    return Result.success();
}

更进一步的做法是直接从会话取用户ID,路径中根本不需要userId参数,因为当前用户只能修改自己的资料。这样从设计层面就杜绝了越权可能。接口设计时遵循“最小暴露原则”,不该让客户端传的参数就别传。

批量操作的IDOR:一个循环搞垮整个系统

批量删除、批量导出这类接口,IDOR的危害会成倍放大。看这个漏洞代码:

// 漏洞代码:接收ID列表,逐个处理但未校验归属
@PostMapping("/order/batchDelete")
public Result batchDelete(@RequestBody List orderIds) {
    for (Integer id : orderIds) {
        orderService.removeById(id);
    }
    return Result.success();
}

攻击者传一个包含任意订单ID的列表,就能批量删除全站订单。修复时不能简单地在循环里加校验,那样性能太差,而且事务处理会变得复杂。正确的做法是先批量校验归属,再批量操作:

// 修复代码:先批量校验所有订单归属,再执行删除
@PostMapping("/order/batchDelete")
@Transactional
public Result batchDelete(@RequestBody List orderIds) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    
    // 查询当前用户实际拥有的订单ID
    List ownedOrderIds = orderService.listObjs(
        new LambdaQueryWrapper()
            .in(Order::getId, orderIds)
            .eq(Order::getUserId, currentUserId),
        obj -> (Integer) obj
    );
    
    // 如果请求的ID数量与归属ID数量不一致,说明包含无权操作的订单
    if (orderIds.size() != ownedOrderIds.size()) {
        throw new BusinessException("部分订单不存在或无权操作,请刷新后重试");
    }
    
    // 只删除校验通过的订单
    orderService.removeByIds(ownedOrderIds);
    return Result.success();
}

这里有个容易被忽略的点:如果请求的ID数量和校验后的数量不一致,要直接拒绝整个操作,而不是静默地只删除有权限的那部分。因为攻击者可能故意混入别人的订单ID来探测,如果系统只删了一部分还返回成功,攻击者就知道哪些ID是自己的、哪些不是,这又回到了信息泄露的问题。

看似安全的间接引用:映射表不是银弹

有些团队用映射表来替代直接ID引用,给每个资源生成一个随机的accessKey,前端只传accessKey不传真实ID。这确实增加了遍历难度,但如果映射关系本身没有做权限绑定,仍然存在越权风险。比如分享功能生成的分享链接:

// 漏洞代码:通过shareKey查询但未校验访问者身份
@GetMapping("/share/{shareKey}")
public Result accessShare(@PathVariable String shareKey) {
    ShareRecord share = shareService.getOne(
        new LambdaQueryWrapper()
            .eq(ShareRecord::getShareKey, shareKey)
    );
    if (share == null || share.getExpireTime().before(new Date())) {
        throw new BusinessException("分享链接无效或已过期");
    }
    // 直接返回被分享的资源内容
    return Result.success(contentService.getById(share.getContentId()));
}

这个代码的问题在于,shareKey虽然是随机生成的,但只要任何人拿到了这个key就能访问。如果分享链接本意是只给特定人看的,那就要在访问时校验访问者身份。修复方案取决于业务场景:如果是公开分享,那没问题;如果是定向分享,就要校验访问者是否在授权名单内。映射表只是把直接引用变成了间接引用,并没有解决授权问题,别把混淆当安全。

审计中发现的几个隐蔽IDOR变种

除了上面这些典型场景,还有一些容易被忽略的IDOR变种。第一个是排序参数的注入,有些接口允许前端传排序字段和排序方向,如果排序字段没有做白名单校验,攻击者可以通过排序结果反推其他用户的数据特征。第二个是导出功能的过滤条件,有些导出接口接收一个filter对象,攻击者修改filter里的userId范围就能导出全量数据。第三个是WebSocket消息订阅,很多实时推送功能用用户ID作为订阅频道,如果频道名可以被客户端指定,就能订阅别人的消息。

这些变种的共同特点是:开发人员根本没意识到这些参数也属于“对象引用”。在他们的认知里,只有那种赤裸裸的ID参数才算直接对象引用,而藏在排序、过滤、频道订阅里的参数被忽略了。所以代码审计时不能只看明显的CRUD接口,所有接收外部参数并用于数据定位的地方都要审查。

建立系统化的IDOR防御体系

单点修复永远追不上漏洞产生的速度,需要在架构层面建立防御机制。第一层是数据访问层统一拦截,使用ORM框架的拦截器或者AOP切面,对所有查询自动注入当前用户的租户ID或用户ID条件。第二层是接口参数规范化,禁止在请求参数中直接传递用户ID、订单ID等敏感资源标识,改为从服务端会话中获取当前用户信息,资源ID通过加密token或签名方式传递。第三层是测试用例覆盖,在功能测试阶段就加入水平越权和垂直越权的测试用例,把IDOR检测集成到CI/CD流水线里。

实际落地时,统一拦截方案要注意性能开销和误拦截。不是所有查询都需要绑定用户ID,比如字典表、公共配置这些全局数据就不该加用户过滤条件。比较好的做法是用注解标记哪些接口或方法需要做权限过滤,然后在拦截器里根据注解决定是否注入过滤条件。这样既保证了安全性,又不会影响公共数据的访问效率。

说到底,IDOR的根因是服务端对客户端传来的资源标识无条件信任。每次看到代码里直接用请求参数去查数据库,脑子里就应该响起警报:这个资源的归属校验在哪里?如果找不到,那就是一个潜在的IDOR漏洞。养成这个条件反射,代码审计的效率和准确率会提升一大截。