修改订单号即可查看他人收货信息 水平越权漏洞真实案例分析与防御策略
先说个吓人的事儿——前阵子有个朋友跟我吐槽,他收到一条短信,说是有个”某东订单”显示他的收货地址被泄露了。他一开始以为是钓鱼,结果去查才发现,对方只是把订单号数字改了一下,就看到了他的完整姓名、手机号、甚至小区门牌号。这种漏洞在安全圈里有个专业名字:水平越权(Horizontal Privilege Escalation)。
听起来很高大上?其实道理特别简单,就像你去餐厅取号等位,窗口喊”36号!”,你拿了餐就走。但如果系统没验证,你直接喊”367号”,也可能把隔壁桌的餐端走——而餐厅根本没查你的身份证。
今天咱就把这事儿掰开揉碎讲清楚。
一、这漏洞到底是个啥?用大白话讲
水平越权漏洞,说白了就是系统没搞对”你是谁”和”你能看什么”之间的匹配关系。
想象一下:
- 你有张会员卡,上面写着”用户A,订单号1001”
- 系统查询订单时,只认订单号,不看你这张卡是不是你自己的
- 你把订单号改成”1002”,系统就会给你展示用户B的收货信息
关键点:系统信任了客户端传过来的订单号,却没有去验证”这个订单号是否真的属于当前登录的用户”。
这跟垂直越权不一样——垂直越权是小角色(普通用户)变成了大角色(管理员),而水平越权是同级用户之间互相偷看信息。
二、真实案例:一个电商平台的”订单号猜解”事件
事件经过
2024年,某中型电商平台被白帽子安全研究员发现了一个严重的信息泄露漏洞。
具体情况是这样的:
研究员登录了自己的账号,成功下了一个订单,订单号是 ORD-2024-000156。他看到了订单详情页面,里面有他的收货人姓名、手机号、详细地址、商品明细。
然后他做了一个测试:
把URL中的订单号从 000156 改成 000157,刷新页面——
页面竟然正常加载了,里面是另一个用户的收货信息。
他继续试了 000158、000159、000160……每一个订单号对应的收货信息都完整展示出来了,没有任何鉴权拦截。
最终,研究员确认了至少几千条订单信息可以被任意浏览。
攻击路径还原
让我把这个漏洞的前后端交互流程画出来:
用户登录 → 拿到session/cookie → 访问 /order/detail?orderId=XXX
↓
后端收到orderId
↓
直接查数据库
↓
返回订单信息(无任何用户绑定验证)
代码层面的问题可能长这样(Java伪代码):
// 存在漏洞的代码示例
@GetMapping("/order/detail")
public ResponseEntity<OrderVO> getOrderDetail(
@RequestParam String orderId,
HttpSession session) {
// ❌ 问题:只查询了订单,没有验证订单是否属于当前用户
Order order = orderService.getByOrderId(orderId);
if (order == null) {
return ResponseEntity.notFound().build();
}
// 直接返回订单详情,完全没有检查order.getUserId()
// 是否等于 session中用户的userId
return ResponseEntity.ok(OrderConverter.toVO(order));
}
你看,这就是典型的只认ID不认人。
三、漏洞为什么这么普遍?
我调研了好几个公开的安全报告,发现这类漏洞在以下场景中特别高发:
1. 前后端分离架构的”信任盲区”
很多团队在前端做了大量业务逻辑,但后端接口缺乏统一的鉴权层。开发者默认”前端已经验证过了”,结果前端随便传个参数,后端就照单全收。
2. 订单号/ID 的可预测性
有些系统的订单号是按时间戳+序列号生成的,比如 202405200001、202405200002……这种ID几乎是可以穷举的。哪怕系统做了鉴权,攻击者也能通过枚举找到有效ID,然后再看有没有越权。
3. 框架默认行为被误解
很多开发者用Spring、Django这些框架,觉得框架会自动处理鉴权。但实际上框架只提供了能力,不会自动帮你做业务逻辑校验。
框架能帮你做的:用户是否登录?有没有管理员权限?
框架不会帮你做的:这个订单是不是你的?这张卡是不是你的?
四、水平越权的几种常见形态
除了刚才讲的”改订单号”,水平越权还有很多变体,我给你整理一下:
形态一:ID遍历(IDOR)
这就是上面说的,直接修改URL参数中的ID。
GET /api/user/profile?id=10001 → 看到用户A的个人信息
GET /api/user/profile?id=10002 → 看到用户B的个人信息
形态二:接口参数篡改
有些系统用POST请求传JSON,但没校验字段:
// 用户A发送的请求
{
"orderId": "ORD-2024-000156",
"userId": 10001
}
// 用户A把userId改成别人的
{
"orderId": "ORD-2024-000156",
"userId": 10002 // ← 这一改,就能看别人订单
}
形态三:分享链接泄露
有些系统给用户生成”订单分享链接”,链接里带了订单号。用户A把链接发给用户B,用户B点开就能看到完整收货信息。
形态四:API批量查询绕过
GET /api/orders?userId=10001&page=1 → 正常返回自己的订单
GET /api/orders?userId=10002&page=1 → 返回了别人的订单列表
五、防御策略:从根上堵住这个漏洞
光讲漏洞不讲防御,那就是耍流氓。下面我分层次来讲防御方案,从代码层到架构层都有。
第一层:代码层——永远验证”资源归属权”
这是最基础也最核心的一步。每次查询敏感资源时,必须验证当前用户和该资源的关系。
正确的写法(Java/Spring):
@GetMapping("/order/detail")
public ResponseEntity<OrderVO> getOrderDetail(
@RequestParam String orderId,
@LoginUser User currentUser, // 从session/token中获取当前登录用户
HttpSession session) {
// ✅ 第一步:查询订单
Order order = orderService.getByOrderId(orderId);
if (order == null) {
return ResponseEntity.status(404).body(null);
}
// ✅ 第二步:关键!验证订单是否属于当前用户
if (!order.getUserId().equals(currentUser.getUserId())) {
// 不属于当前用户,拒绝访问
return ResponseEntity.status(403).body(null);
}
return ResponseEntity.ok(OrderConverter.toVO(order));
}
Python/Django 的写法:
from django.shortcuts import get_object_or_404
from django.contrib.auth.decorators import login_required
@login_required
def order_detail(request, order_id):
# ✅ 关键:用 user 作为查询条件之一
order = get_object_or_404(
Order.objects.filter(
id=order_id,
user=request.user # 只有属于当前用户的订单才能查到
)
)
return render(request, 'order/detail.html', {'order': order})
看到了吗?核心就一句话:查数据的时候,把当前用户的ID带上。
第二层:架构层——统一鉴权拦截器
如果你有很多接口都要做这种校验,一个一个写太麻烦了。建议用拦截器或中间件来做统一处理。
Spring Boot 拦截器方案:
@Component
public class ResourceOwnershipInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 只拦截需要校验的接口
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod method = (HandlerMethod) handler;
// 检查方法上是否有 @RequireOwnership 注解
if (!method.hasMethodAnnotation(RequireOwnership.class)) {
return true;
}
// 获取当前用户
User currentUser = (User) request.getSession().getAttribute("currentUser");
if (currentUser == null) {
response.setStatus(401);
return false;
}
// 获取请求中的资源ID
String resourceId = request.getParameter("id");
if (resourceId == null) {
resourceId = request.getParameter("orderId");
}
// 验证资源归属(这里用反射调用Service的校验方法)
String resourceName = method.getMethodAnnotation(RequireOwnership.class).value();
boolean owned = checkOwnership(currentUser, resourceName, resourceId);
if (!owned) {
response.setStatus(403);
response.getWriter().write("无权访问该资源");
return false;
}
return true;
}
private boolean checkOwnership(User user, String resourceType, String resourceId) {
// 根据资源类型调用不同的校验逻辑
switch (resourceType) {
case "order":
return orderService.existsByUserIdAndOrderId(user.getUserId(), resourceId);
case "invoice":
return invoiceService.existsByUserIdAndId(user.getUserId(), resourceId);
default:
return false;
}
}
}
// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireOwnership {
String value() default "default";
}
然后在Controller上这样用:
@RequireOwnership("order")
@GetMapping("/order/detail")
public ResponseEntity<OrderVO> getOrderDetail(
@RequestParam String orderId) {
Order order = orderService.getByOrderId(orderId);
return ResponseEntity.ok(OrderConverter.toVO(order));
}
这样你只需要在需要校验的接口上加一个注解,剩下的事情拦截器帮你做了。
第三层:ID设计层——让ID不可预测
如果订单号是 ORD-2024-000156 这种格式,攻击者很容易猜。改成用UUID或者Snowflake ID就安全多了:
// 生成订单号时使用 UUID(推荐)
String orderId = UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
// 结果类似:A3F7B2E1C9D4F6A8
// 或者使用雪花算法(适合高并发场景)
long orderId = snowflake.nextId();
// 结果类似:1847563928475612160
UUID的优势是几乎无法预测,攻击者没法通过枚举来试探。
第四层:监控层——异常行为检测
哪怕代码写对了,也可能有漏网之鱼。所以要在运行时做监控:
# 简单的异常请求检测示例
from collections import defaultdict
import time
# 记录每个用户的请求频率
user_request_count = defaultdict(list)
def detect_abuse(user_id, resource_id):
now = time.time()
# 只保留最近1分钟内的记录
user_request_count[user_id] = [
t for t in user_request_count[user_id]
if now - t < 60
]
user_request_count[user_id].append(now)
# 如果1分钟内请求超过50次,触发告警
if len(user_request_count[user_id]) > 50:
alert_security_team(user_id, f"疑似越权探测: {user_request_count[user_id]}")
return True
return False
五、给开发者的检查清单
每次上线新功能,尤其是涉及用户敏感数据的接口,对照这个清单过一遍:
| 检查项 | 说明 |
|---|---|
| □ 所有查询接口是否都带上了当前用户ID作为过滤条件? | 最基础也最容易漏 |
| □ 是否存在通过ID遍历就能获取他人数据的接口? | 重点检查列表接口和详情接口 |
| □ 前端传过来的ID参数是否做了白名单校验? | 防止类型注入 |
| □ 订单号/资源ID是否可预测? | 避免使用时间戳+递增数字 |
| □ 是否有统一的鉴权拦截层? | 不要每个接口都手动写校验 |
| □ 错误返回是否泄露了过多信息? | 404不要区分”不存在”和”无权访问” |
关于错误信息的一个小细节
很多开发者习惯这样返回错误:
// ❌ 不好的做法
{
"code": 404,
"message": "订单不存在"
}
// 攻击者能判断:这个ID存在但我不属于它 → 返回403
// 这个ID根本不存在 → 返回404
// 这样就能反推出哪些ID是有效的
应该统一返回:
// ✅ 好的做法
{
"code": 403,
"message": "无法访问该资源"
}
不管是”不存在”还是”无权访问”,都返回同一个信息,让攻击者无法通过错误信息来判断。
六、一些真实场景的边界情况
讲到这里,我想补充几个实际开发中容易踩坑的场景:
场景一:子账号/共享账号
有些系统支持”家庭成员共享订单”,比如淘宝的家庭账户。这时候订单归属关系就变成了多对多。
-- 错误的查询
SELECT * FROM orders WHERE id = ? AND user_id = ?
-- 正确的查询(考虑共享关系)
SELECT * FROM orders o
JOIN order_shared_users osu ON o.id = osu.order_id
WHERE o.id = ? AND (o.user_id = ? OR osu.user_id = ?)
场景二:客服后台查询
客服需要能查看所有订单,这时候不能简单地在客服接口里也加用户归属校验——那就查不了了。
解决方案是区分角色,给客服接口单独设计权限模型:
// 用户端接口:需要归属校验
@RequireOwnership("order")
@GetMapping("/user/order/detail")
// 客服端接口:需要管理员权限
@RequireRole(Role.ADMIN)
@GetMapping("/admin/order/detail")
场景三:第三方API回调
有些系统会接收第三方回调,比如支付成功的通知。回调里带了订单号,系统直接根据订单号更新状态。这时候如果有恶意请求伪装成回调,也可能造成越权。
// ❌ 危险:只校验订单号
@PostMapping("/payment/callback")
public void handleCallback(@RequestBody CallbackRequest req) {
Order order = orderService.getByOrderId(req.getOrderId());
order.setStatus("PAID");
}
// ✅ 安全:额外校验签名
@PostMapping("/payment/callback")
public void handleCallback(@RequestBody CallbackRequest req) {
// 先验签,确认请求来自合法的第三方支付平台
if (!signatureVerifier.verify(req)) {
throw new SecurityException("签名验证失败");
}
Order order = orderService.getByOrderId(req.getOrderId());
order.setStatus("PAID");
}
七、最后说几句掏心窝的话
我见过太多开发者在写安全代码时的心态是:
“这个功能只有内部用,不用做鉴权了吧?” “用户ID是前端传过来的,前端肯定改不了吧?” “订单号只是数字,应该没什么问题吧?”
事实是:
- 内部系统被攻击的案例比比皆是,内网穿透、社工、钓鱼都能让内部系统暴露
- 前端传过来的任何东西都是不可信的,浏览器里改个请求参数比改浏览器本身简单一万倍
- 数字ID虽然不像字母那么显眼,但枚举起来一样方便,特别是序列号连续的时候
水平越权漏洞最让人头疼的地方在于:它通常不会被功能测试发现。测试人员会测试”我的订单能不能正常查看”,但不会去测试”能不能看别人的订单”。这个漏洞只有在安全测试中才会暴露。
所以,把安全当作功能的一部分,而不是上线前的附加项,这才是最重要的。
希望这篇文章能帮你建立起对水平越权漏洞的正确认知。如果你们团队正在做相关的安全改造,记得先从所有带ID参数的接口入手,逐个加上归属校验,这一步就能挡住90%的简单攻击。
安全这条路,一步都不能省。
