某电商员工改个数字就看了上万用户隐私 水平越权漏洞原理与内部防控方案
一个”小改动”引发的惊天泄露
2023年夏天,某头部电商平台的内部数据库审计系统中,出现了一批异常频繁的查询记录。安全团队起初只是例行巡检,却在日志中发现了一个令人脊背发凉的现象——某个内部员工的账号,在短短72小时内,查询了超过1.2万条用户隐私数据,包括姓名、手机号、收货地址、订单详情,甚至部分用户的支付信息。
更让人震惊的是,这个员工本来只负责客服工单处理,他的工作范围根本不需要访问这么多用户数据。
安全团队立刻调取了操作日志,发现了一个共同点:每一次查询,参数里的”用户ID”都是一个6位纯数字,而这些ID连续递增,像是有人在系统地”遍历”整个用户库。
当技术人员复盘代码时,发现了一个简单的逻辑漏洞——系统后台的”用户信息查询”接口,只验证了操作者的登录状态,却没有校验该操作员是否有权查询指定用户的数据。
也就是说,只要知道另一个员工的工号,或者通过某种方式猜到用户ID,就能在后台直接查看任何用户的隐私信息。
一个数字,改变了查询对象,整个系统就像敞开了大门。
什么是水平越权?用小朋友都能懂的话说
想象一下,你们学校有一个图书馆,每个学生都有自己的借书证。图书管理员的工作是帮学生借书还书,但他们不应该随便翻看其他同学的借阅记录,对吧?
有一天,小明发现了一个秘密:图书馆的系统里,借书记录是这样查询的:
管理员借书时,系统会问:你要查哪个学生?小明输入”001”,系统就显示了学生张三的借阅记录;输入”002”,就显示李四的。
小明心想:”我又不知道张三李四是谁,但我可以一个个试啊!”
于是他开始从”001”开始,一个一个试下去……试到”500”的时候,他已经看到了500个同学的借书记录,包括他们借了什么书、什么时候借的、有没有逾期。
这就是水平越权漏洞——你没有权限做某件事,但系统没有拦住你,让你做了。
在电商场景里,情况更严重。因为用户隐私不只是”借了什么书”,而是你的地址、电话、买了什么、花了多少钱,甚至你的消费习惯。这些信息被泄露,轻则被骚扰营销电话轰炸,重则被诈骗分子利用。
水平越权漏洞的三种常见形式
在深入原理之前,我们先认识一下这个”漏洞家族”的三位成员:
第一种:IDOR——直接对象引用漏洞
IDOR(Insecure Direct Object Reference)是最常见的水平越权形式。
系统在设计时,假设”只有用户自己能看自己的数据”,但在代码层面,却忘记了加这个限制。比如:
# 用户查询自己订单的接口(有问题的代码)
@app.route('/api/my_orders')
def get_my_orders():
user_id = request.args.get('user_id') # 从参数里直接取user_id
orders = db.query("SELECT * FROM orders WHERE user_id = ?", user_id)
return jsonify(orders)
注意看这一行:user_id = request.args.get('user_id')。
意思是,系统完全相信前端传来的”user_id”就是当前登录用户自己的ID。如果有人把参数改成别人的ID呢?
比如正常请求是:
GET /api/my_orders?user_id=10001
改成:
GET /api/my_orders?user_id=10002
系统不会问:”你是10002吗?你有权限查他吗?”——它只会直接返回10002的订单数据。
漏洞根源:服务端没有从”会话/Token”里取当前用户身份,而是信任了客户端传来的参数。
第二种:批量查询无边界控制
有些系统为了”方便运营”,提供了批量导出用户数据的功能。比如客服主管可以导出”所有订单”,但系统没有做限制:
-- 管理员导出用户订单
SELECT user_id, name, phone, address, order_no, amount
FROM orders
WHERE created_at BETWEEN '2023-01-01' AND '2023-12-31'
正常情况下,这个查询只能导出1000条。但如果有人修改了SQL语句,去掉LIMIT限制:
SELECT user_id, name, phone, address, order_no, amount
FROM orders
WHERE created_at BETWEEN '2023-01-01' AND '2023-12-31'
-- 没有LIMIT,返回全部数据
一次查询就能拿到所有用户的订单信息。
更危险的是,有些系统用”分页”来防批量查询,但分页参数也是从客户端传来的:
# 有问题的分页查询
page = request.args.get('page', 1)
size = request.args.get('size', 100)
data = db.query(f"SELECT * FROM users LIMIT {size} OFFSET {(page-1)*size}")
攻击者可以把size改成10000,一次拿到1万条用户数据。
漏洞根源:分页/批量参数没有做服务端校验和上限限制。
第三种:角色权限校验缺失
这是最隐蔽的一种。系统有”员工”和”普通用户”的区别,但在查询数据时,没有校验”这个员工有没有权限查这个用户”。
比如电商后台,有客服、运营、财务、仓管等不同角色。正常逻辑应该是:
- 客服只能看自己负责的用户
- 运营可以看所有用户的基本信息,但不能看手机号和地址
- 财务只能看订单金额,不能看收货地址
但如果代码里只写了:
# 有问题的权限检查
@app.route('/api/user/detail')
def get_user_detail():
if not current_user.is_authenticated: # 只检查了有没有登录
return jsonify({"error": "未登录"}), 401
user_id = request.args.get('user_id')
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
return jsonify(user)
这里只判断了”你是否登录了”,但没有判断”你有没有权限查这个用户”。
任何一个登录了系统的内部员工,都可以随便查任何用户。
漏洞根源:权限校验只做了”是否登录”的检查,没有做”是否有权访问该资源”的检查。
那个电商员工是怎么做到的?
让我们回到最初那个案例,把技术细节还原出来。
那个员工叫张明(化名),是某电商平台的客服主管。他的日常工作是处理用户投诉,系统给了他一个权限——可以查询自己经手订单对应的用户信息。
但系统里有一个”高级查询”功能,允许客服主管输入用户ID,查看该用户的完整档案。这个功能的设计初衷是:当用户投诉时,主管可以快速了解该用户的购买历史,以便更好的处理。
问题出在哪里?
系统只验证了张明是”客服主管”身份,但没有验证张明是否有权查询”指定用户ID”对应的数据。
张明发现这个漏洞,是在一个加班的晚上。他本来只是想查一个用户的订单,输入用户ID之后,系统返回了该用户的完整信息。他突然想到:如果我改一下ID呢?
于是他写了一个简单的脚本,自动遍历用户ID,把查询结果保存到本地:
import requests
import json
base_url = "https://internal-api.example.com/api/user/detail"
headers = {
"Authorization": "Bearer <张明的token>",
"Content-Type": "application/json"
}
results = []
for uid in range(10000, 20000): # 假设用户ID从10000开始
response = requests.get(
base_url,
headers=headers,
params={"user_id": uid}
)
if response.status_code == 200:
data = response.json()
results.append(data)
print(f"[{uid}] 查询成功: {data.get('name')}")
else:
print(f"[{uid}] 查询失败: {response.status_code}")
# 保存到本地
with open("leaked_users.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
这个脚本跑了不到一个小时,就保存了12000多条用户数据。张明把这些数据存到了自己的U盘里。
他为什么要这么做?后来他交代:他只是觉得好奇,想看看系统到底有多不安全,没有想过要出售这些信息。
但这个”好奇”,已经造成了严重的后果。
水平越权为什么这么普遍?
你可能会问:都2024年了,这种低级漏洞为什么还存在?
原因有几个:
第一,开发速度快,安全跟不上。 电商行业竞争激烈,功能上线以”天”为单位。安全团队人手有限,很多代码在上线前没有经过严格的渗透测试。
第二,权限设计”太想当然”。 很多系统的权限模型是早期设计的,当时用户量少、风险低,开发者觉得”内部系统而已,不需要那么严格”。结果用户量涨到几千万,漏洞依然没有修复。
第三,第三方组件的隐患。 很多电商系统使用了开源的RBAC(基于角色的访问控制)组件,但这些组件的默认配置往往只做了”角色级别”的权限控制,没有做”数据级别”的权限控制。比如:你是什么角色,能访问哪些接口。但”你能访问这个角色的哪些数据”,系统没有管。
第四,内部威胁被低估。 大多数安全团队把精力放在防外部攻击,对内部员工的权限管控相对宽松。毕竟,”谁会偷自己的数据呢?”——但这种想法,恰恰是漏洞的温床。
如何防范?一套完整的内部防控方案
既然漏洞存在,我们就要从技术和管理两个层面来封堵。以下是一套经过实践验证的防控方案:
技术层面
1. 强制使用服务端会话中的用户身份
永远不要信任客户端传来的用户ID。正确的做法是:从登录会话(Session)或JWT Token中获取当前用户身份,而不是从请求参数里取。
# 错误的做法:从参数中取user_id
user_id = request.args.get('user_id')
# 正确的做法:从会话中取
user_id = session['user_id'] # 或 jwt.decode(token)['user_id']
如果接口确实需要查询其他用户的数据(比如客服查用户信息),应该额外增加权限校验:
@app.route('/api/user/detail')
def get_user_detail():
current_user_id = session['user_id'] # 当前操作员ID
current_role = session['role'] # 当前操作员角色
target_user_id = request.args.get('user_id')
# 权限校验:只有管理员或该用户的主管才能查看
if current_role not in ['admin', 'supervisor']:
return jsonify({"error": "无权访问"}), 403
# 进一步校验:主管只能查自己负责的用户
if current_role == 'supervisor':
assigned_users = db.query(
"SELECT user_id FROM user_assignments WHERE supervisor_id = ?",
current_user_id
)
if target_user_id not in assigned_users:
return jsonify({"error": "无权访问该用户"}), 403
user = db.query("SELECT * FROM users WHERE id = ?", target_user_id)
return jsonify(user)
2. 对批量查询做严格限制
任何涉及”多条数据”的查询,都必须加限制:
# 分页查询必须有上限
MAX_PAGE_SIZE = 100
page = request.args.get('page', 1, type=int)
size = request.args.get('size', 50, type=int)
# 校验范围
if size > MAX_PAGE_SIZE:
size = MAX_PAGE_SIZE
if page < 1:
page = 1
offset = (page - 1) * size
data = db.query("SELECT * FROM users LIMIT ? OFFSET ?", size, offset)
同时,对导出功能,应该设置每日导出上限和敏感字段脱敏:
# 导出时,手机号和地址脱敏
def format_sensitive_fields(user_data):
user_data['phone'] = user_data['phone'][:3] + '****' + user_data['phone'][7:]
user_data['address'] = user_data['address'][:10] + '***'
return user_data
3. 实施细粒度的数据权限模型
传统的RBAC只控制”谁能访问什么接口”,但更好的做法是ABAC(基于属性的访问控制),控制”谁能访问什么数据”。
比如:
| 角色 | 可访问的数据范围 |
|---|---|
| 普通员工 | 只能看自己的数据 |
| 客服 | 只能看自己负责的用户 |
| 主管 | 可以看团队所有用户的基本信息,但手机号和地址需要审批 |
| 管理员 | 可以看所有数据,但所有操作需记录审计日志 |
实现方式是在数据库层加”数据隔离”:
-- 每个查询自动带上数据范围限制
SELECT * FROM users
WHERE id = ?
AND (
-- 管理员可以看所有
? = 'admin'
OR
-- 客服只能看自己负责的
EXISTS (
SELECT 1 FROM user_assignments
WHERE user_id = ? AND supervisor_id = ?
)
)
4. 操作日志与异常检测
所有的敏感操作都必须记录日志,包括:谁、在什么时间、查询了哪个用户、用了什么参数。
同时,建立异常行为检测机制:
# 异常行为检测规则
def detect_anomaly(user_id, query_count, time_window):
# 规则1:短时间大量查询
if query_count > 100 and time_window < 3600: # 1小时内超过100次
return True, "疑似批量查询"
# 规则2:非工作时间查询
if is_off_hours(): # 晚上10点到早上6点
return True, "非工作时间操作"
# 规则3:查询了超出权限范围的数据
if query_count > get_user_allowed_limit(user_id):
return True, "超出权限范围"
return False, "正常"
一旦检测到异常,立即:
- 冻结账号
- 通知安全团队
- 记录详细日志供后续调查
管理层面
技术防控是基础,但管理手段同样重要。
1. 最小权限原则(PoLP)
每个员工只应该拥有完成工作所必需的最小权限。
具体做法:
- 入职时,根据岗位分配角色,而不是”先给高权限,再慢慢降”
- 每季度审查一次权限,移除不再需要的权限
- 离职时,立即吊销所有账号和权限
2. 双人复核机制
对于敏感操作(如批量导出用户数据、查看支付信息),必须双人复核:
操作请求 → 主管审批 → 安全团队复核 → 执行操作
这样即使一个人想违规操作,也需要另一个人配合,大大增加了作恶成本。
3. 定期安全培训
很多内部员工并不知道自己的操作可能有风险。定期培训可以提高他们的安全意识:
- 每季度一次安全培训,讲解常见的内部威胁
- 模拟钓鱼测试,看员工是否能识别社交工程攻击
- 建立”安全举报”渠道,鼓励员工报告可疑行为
4. 数据脱敏与加密
即使数据被泄露,也要让泄露的数据”没用”:
- 静态数据加密:数据库中的敏感字段(手机号、身份证、支付信息)必须加密存储
- 动态脱敏:在查询结果中,对非授权字段进行脱敏处理
- 水印追踪:在导出的数据中加入”隐形水印”,一旦泄露可以追溯到具体人员
5. 第三方审计与红队测试
定期邀请第三方安全公司进行渗透测试,模拟内部攻击:
红队攻击流程:
1. 获取一个普通员工的账号
2. 尝试越权访问其他用户数据
3. 尝试提升权限到管理员
4. 尝试批量导出敏感数据
5. 生成详细报告,列出所有发现的漏洞
一个完整的防护示例
下面是一个相对完整的用户查询接口实现,展示了多个防护层的叠加:
from functools import wraps
from flask import session, request, jsonify
import logging
logger = logging.getLogger(__name__)
# 记录审计日志
def audit_log(action, user_id, target_id, ip_address):
logger.info(f"[AUDIT] action={action}, operator={user_id}, target={target_id}, ip={ip_address}")
# 权限装饰器
def require_permission(required_role):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session or 'role' not in session:
return jsonify({"error": "未登录"}), 401
current_role = session['role']
if current_role not in required_role:
return jsonify({"error": "权限不足"}), 403
return f(*args, **kwargs)
return decorated_function
return decorator
# 查询限制
MAX_QUERY_PER_MINUTE = 30
query_counts = {}
def check_query_limit(user_id):
import time
now = int(time.time())
minute_key = now // 60
if user_id not in query_counts:
query_counts[user_id] = {}
if minute_key not in query_counts[user_id]:
query_counts[user_id][minute_key] = 0
query_counts[user_id][minute_key] += 1
if query_counts[user_id][minute_key] > MAX_QUERY_PER_MINUTE:
return False
return True
@app.route('/api/user/detail', methods=['GET'])
@require_permission(['admin', 'supervisor', 'cs_staff'])
def get_user_detail():
current_user_id = session['user_id']
current_role = session['role']
target_user_id = request.args.get('user_id')
if not target_user_id:
return jsonify({"error": "缺少用户ID"}), 400
# 权限校验:普通客服只能查自己负责的用户
if current_role == 'cs_staff':
is_assigned = db.query(
"SELECT 1 FROM user_assignments WHERE user_id = ? AND cs_id = ?",
target_user_id, current_user_id
)
if not is_assigned:
audit_log("unauthorized_access", current_user_id, target_user_id, request.remote_addr)
return jsonify({"error": "无权访问该用户"}), 403
# 查询限制
if not check_query_limit(current_user_id):
return jsonify({"error": "查询频率过高,请稍后重试"}), 429
# 执行查询(自动带上数据隔离)
user = db.query(
"""SELECT id, name, phone, address, order_count, total_spent
FROM users WHERE id = ? AND (
? = 'admin' OR
EXISTS (SELECT 1 FROM user_assignments WHERE user_id = ? AND cs_id = ?)
)""",
target_user_id, current_role, target_user_id, current_user_id
)
if not user:
return jsonify({"error": "用户不存在"}), 404
# 脱敏处理
if current_role != 'admin':
user['phone'] = user['phone'][:3] + '****' + user['phone'][7:]
user['address'] = user['address'][:10] + '***'
audit_log("user_query", current_user_id, target_user_id, request.remote_addr)
return jsonify(user)
这个示例中,我们可以看到多个防护层的叠加:
- 登录校验:
require_permission装饰器确保只有登录用户才能访问 - 角色校验:不同角色有不同的访问范围
- 数据隔离:SQL查询中自动带上权限条件
- 频率限制:每分钟最多查询30次
- 审计日志:所有操作都记录日志
- 数据脱敏:非管理员看不到完整手机号和地址
漏洞曝光之后:那个电商是怎么做的?
张明的案件曝光后,该电商平台做了以下几件事:
第一,立即修复漏洞。 所有涉及用户查询的接口,都加上了数据权限校验。同时,对所有内部系统进行了全面的安全审计,修复了类似的问题。
第二,追责与教育。 张明被暂停工作,接受内部调查。最终,他因为”未经授权访问用户数据”被解除劳动合同,并移送司法机关处理。公司同时召开了全员安全会议,以这个案例教育所有员工。
第三,建立举报机制。 公司设立了匿名举报通道,鼓励员工报告发现的安全漏洞。对于报告漏洞的员工,给予现金奖励。
第四,加强技术防控。 引入了DLP(数据防泄漏)系统,对所有出口流量进行监控,防止敏感数据被导出。同时,建立了安全运营中心(SOC),7×24小时监控异常行为。
第五,定期渗透测试。 每季度邀请第三方安全公司进行红队测试,确保漏洞不会被再次利用。
写在最后
这个案例告诉我们一个道理:安全不是一道防线,而是一层层叠加的防护。
任何一个环节的疏忽,都可能让黑客(或内部人员)轻易突破。对于电商来说,用户数据是最核心的资产,保护这些数据,就是保护公司的命脉。
水平越权漏洞看起来”低级”,但它之所以普遍存在,是因为它挑战的是开发者的”思维定势”——我们总是默认”系统是安全的”,却忘记了去验证”这个用户是否真的有权做这件事”。
记住:永远不要信任客户端传来的任何用户身份信息,永远不要假设内部系统就是安全的。
安全不是功能,安全是一种习惯。
如果你正在负责一个系统的安全,不妨从这几个问题开始:
- 你的系统里,有没有接口只校验了”登录状态”,没有校验”数据权限”?
- 你的批量查询接口,有没有设置上限?
- 你的操作日志,能不能追溯到每一次敏感查询?
- 你的员工,有没有每季度接受安全培训?
如果任何一个问题的答案是”没有”,那可能就是下一个张明发现漏洞的地方。
