改一个数字就能看到别人的订单 越权漏洞凭什么20年没治好
原创
紫禁
紫禁
紫禁玄科
2026年10月13日 00:10
四川
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
SECURITY · INTELLIGENCE
改一个数字就能看到别人的订单 越权漏洞凭什么20年没治好
2026年10月12日 · 8 min
网络安全#漏洞拆解#Web安全
01
那张只差一个数字的订单
我之前在给一个电商客户做渗透测试项目时,遇到一件让客户后背发凉的事。他们的订单详情接口长这样:把订单号放在地址栏里,登录之后就能看。我登录自己的测试账号,打开自己的订单,记下地址栏里那串编号——然后,把最后一个数字改了改。
页面刷新了一下,没有任何报错。返回的是另一个用户的订单:收货人姓名、手机号、详细到门牌号的地址、买了什么、多少钱,全在上面。我换了一个数字,又是一单别人的。客户在旁边看着,脸色一点点变了:这等于把所有客户的快递面单,挂在了一个可预测的门牌号上。
我改的这一个数字,撬开的漏洞有个学名叫越权访问(IDOR,不安全的直接对象引用)。它不是什么新东西——2007 年就被写进 OWASP 十大漏洞,到今天依然是各大厂商赏金榜上的常客。今天这篇把它拆开:它到底怎么运作、真实世界里咬过谁、为什么二十年治不好,以及两个你今天就能跑起来的自查脚本。
越权漏洞:权限校验不能只看登录
02
系统到底忘了问哪句话
先把机制说透。一个正常的\"看订单\"流程,服务端应该连着回答两个问题:第一,你是谁(认证);第二,这条订单是不是你的(授权)。越权漏洞的本质就一句话:系统只回答了第一个问题,忘了第二个。
用伪代码把两次请求摆在一起看,差别一目了然:
IDOR-DEMO · 两次请求的差别(伪代码,勿直接照抄进生产)
正常请求: GET /api/order?id=1001 会话=用户A
-> 200 OK,返回 A 自己的订单 ✓
改包请求: GET /api/order?id=1002 会话=仍是用户A
-> 200 OK,返回了 B 的订单 ← 越权
正确行为: GET /api/order?id=1002 会话=用户A
-> 403 Forbidden ✓
服务端缺的那一步: WHERE id=? AND owner=当前会话用户
看出来了吗?漏洞不在于\"能传 id\",而在于拿 id 取数据时没把归属条件写进查询。攻击者甚至不需要任何工具——浏览器地址栏里改一个数字就够了。
它有三个常见变种:水平越权,同级用户互相看,比如买家 A 看买家 B 的订单;垂直越权,低权限调高权限功能,比如普通用户调到管理员的删号接口;上下文越权,流程跳步,比如跳过支付环节直接把订单状态推到\"已完成\"。三个变种,同一根病根:授权校验缺位。
顺便把边界划清楚:本文所有内容只针对授权范围内的安全测试。在自己的系统上测、在拿过书面授权的客户环境里测,是安全工作;手痒去改一改别人网站的参数看返回什么,那不是\"帮忙测漏洞\",是实打实的违法行为——后面讲判例时你就知道代价了。
03
真实世界里它咬过谁
有人觉得\"改参数看数据\"算什么大危害,无非是看一眼。那把三个真实案件摆出来看。
先看大洋彼岸的电信巨头。2022 年 11 月起,攻击者滥用 T-Mobile 的一个 API 接口,该接口没有做好对象级授权校验,前后持续约六周,拿走了约 3700 万客户的姓名、出生日期、电话号码、账单地址与套餐信息。2023 年 1 月 T-Mobile 对外披露,中文安全媒体(FreeBuf)2023 年 2 月的复盘把根因写得很直白:一个 API 的授权缺口,换 3700 万人的资料裸奔。
第二起在澳大利亚。2022 年 9 月,电信运营商 Optus 披露:一个未做授权校验的 API 端点暴露了约 980 万用户的姓名、出生日期、电话、邮箱,其中相当一部分还带驾照号码。号码这种东西改不了,后果就是这批人长期活在身份冒用的风险里。
一个接口,数千万人失守
再看国内的司法口径,这是很多人没意识到的部分。
公开裁判文书里有一起刘某非法获取计算机信息系统数据案,法院判决:有期徒刑三年、缓刑三年,并处罚金人民币两万元。一起乐某某等四人非法爬取某企业 11 万条商品数据案,2026 年 1 月检察机关依法提起公诉,四人均被认定构成非法获取计算机信息系统数据罪,法院按主犯、从犯作出差异化判决。
更早的一起是最高人民检察院第九批指导性案例里的卫梦龙等三人案——超出授权范围抓取数据,同样构成犯罪,北京海淀法院判决三人构成非法获取计算机信息系统数据罪。2026 年 1 月北京法院的一篇案例解读把\"侵入\"的实质说得更白:违背他人意愿进入他人计算机信息系统,就是侵入。\"我只是改了个参数\"\"数据是系统自己吐出来的\",在法庭上不是免责理由。
所以这个漏洞的另一面是:它既是开发者的责任清单,也是每个普通用户的法律红线。修它,和别去试它,两件事同样重要。
04
20年了为什么治不好
2007 年 OWASP 就把\"不安全的直接对象引用\"列进了十大漏洞;2023 年的 OWASP API 安全十大里,它换了个名字叫 BOLA(对象级授权失效),依然排在第一位。二十年过去,防护产品换了一茬又一茬,它还站在原地。为什么?
框架只管\"登录了没\",不管\"这条是谁的\"。各种 Web 框架都内置了登录认证,但\"取到对象之后校验归属\"这一步,没有任何框架替你写——它必须出现在每个接口里。接口一多,漏一个就是漏洞。
前端隐藏按钮不等于授权。很多人以为\"界面上看不到入口就没人访问得到\",可请求是自己发的。隐藏一个按钮只要改改样式,服务端校验才是真门锁。
编号连续可猜。自增主键让\"下一个 id\"永远可预测,改一个数字就是一次探测,一个脚本就能把全量订单刷一遍。
测试只测\"正常用户路径\"。功能测试永远用本人账号走一遍主流程,绿灯放行;\"拿别人的 id 再来一次\"这种用例,绝大多数测试用例集里根本没有。
说白了,这是个工程纪律问题,不是技术难题。防御方案二十年前就写清楚了,缺的是让每个接口都执行到位的那股认真劲。
05
自查一:把代码里的地雷扫出来
先管存量代码。我把自己在审计里反复踩到的几类高危写法规则化,写成一个纯标准库的静态自查脚本:按客户端传入的 id 直取对象、直接信任请求里的 user\_id、原生 SQL 按 id 查询不带归属条件,全在扫描范围内。函数上下文里若已出现归属校验写法,会自动降级为提示,减少误报。
AUTHZ-AUDIT · 越权风险静态自查
\"\"\"越权风险静态自查:扫描\"取对象不验归属\"的高危写法。纯标准库,可进 CI。\"\"\"
import re, sys, os, time
RULES = [
(\"AUTHZ-001\", \"HIGH\", r\"\.(?:query\.)?get\(\s\(?:request\.(?:args|form|json|values)\.get\(|\w\id\w\\s\[,)])\",
\"按客户端传入的 id 直取对象,函数内未见归属校验\"),
(\"AUTHZ-002\", \"HIGH\", r\"request\.(?:args|form|json|values)\.get\(\s\*['\\"]user\_id['\\"]\",
\"直接信任客户端提交的 user\_id,身份应来自服务端会话\"),
(\"AUTHZ-003\", \"HIGH\", r\"SELECT\s+\\\s+FROM\s+\w+\s+WHERE\s+id\s\=\",
\"原生 SQL 按 id 查询,WHERE 里没有 owner 条件\"),
]
SAFE\_HINTS = (\"current\_user\", \"owner\_id\", \"user\_id == \", \"check\_owner\",
\"require\_owner\", \"permission\")
def scan\_file(path, rules):
hits = []
try:
text = open(path, encoding=\"utf-8\", errors=\"ignore\").read()
except OSError:
return hits
lines = text.splitlines()
for i, line in enumerate(lines, 1):
for rid, sev, pat, desc in rules:
if re.search(pat, line):
ctx = \"\
\".join(lines[max(0, i - 16):i])
down = any(h in ctx for h in SAFE\_HINTS)
hits.append((rid, \"LOW\" if down else sev, path, i,
line.strip()[:90], desc))
return hits
def main():
root = sys.argv[1] if len(sys.argv) > 1 else \".\"
print(\"== 越权风险静态自查报告 ==\")
print(\"扫描时间:\", time.strftime(\"%Y-%m-%d %H:%M:%S\"))
print(\"扫描目录:\", os.path.abspath(root))
total = {\"HIGH\": 0, \"MED\": 0, \"LOW\": 0}
for dirpath, \_dirs, files in os.walk(root):
for fn in sorted(files):
if not fn.endswith(\".py\"):
continue
for rid, sev, path, ln, src, desc in scan\_file(
os.path.join(dirpath, fn), RULES):
total[sev] = total.get(sev, 0) + 1
print(f\"[{sev:4}] {rid} {os.path.relpath(path, root)}:{ln}\")
print(f\" {desc}\")
print(f\" > {src}\")
print(\"-\" \* 46)
print(f\"合计: HIGH {total['HIGH']} / MED {total['MED']} / LOW {total['LOW']}\")
print(\"处置建议: HIGH 必修(补归属校验)、MED 逐条 review、LOW 记录即可\")
return 0
if \\_name\\ == \"\\main\\_\":
sys.exit(main())
拿一个刻意留了坑的演示项目(订单接口按 id 直取对象、资料更新信任客户端 user\_id、病历导出拼 SQL)跑一遍:
实跑结果 · 2026-10-12
== 越权风险静态自查报告 ==
扫描时间: 2026-10-12 22:25:31
扫描目录: /root/wx\_articles/vuln\_20261012/demo\_project
[HIGH] AUTHZ-001 app\_orders.py:8
按客户端传入的 id 直取对象,函数内未见归属校验
> order = Order.query.get(order\_id)
[HIGH] AUTHZ-002 app\_orders.py:14
直接信任客户端提交的 user\_id,身份应来自服务端会话
> uid = request.form.get(\"user\_id\")
[HIGH] AUTHZ-003 app\_orders.py:22
原生 SQL 按 id 查询,WHERE 里没有 owner 条件
> rows = db.execute(\"SELECT \* FROM medical\_records WHERE id=\" + rid)
合计: HIGH 3 / MED 0 / LOW 0
处置建议: HIGH 必修(补归属校验)、MED 逐条 review、LOW 记录即可
三处 HIGH,一处不落全抓到,每条都带行号和原文,照着补校验就行。我的习惯是把它挂进 CI——越权这种漏洞,靠人眼 review 是盯不住的,新增接口一多必漏;机器每晚扫一遍,心里才有底。
脚本里没有一行攻击代码,全是读源码做模式匹配,可以放心跑在自己的仓库上。
06
自查二:给接口补一道归属门
管完存量,管增量。修复的核心动作就一个:每次对象访问都带归属校验。与其在一百个接口里各写一遍,不如把它收敛成一个装饰器,让\"不验归属就出不了门\"变成默认行为:
AUTHZ-GUARD · 归属校验装饰器 + 回归测试
\"\"\"服务端归属校验装饰器 + 越权回归测试。防御代码,纯标准库可直接运行。\"\"\"
import time
from functools import wraps
ADMIN\_ROLES = (\"admin\",)
class HttpError(Exception):
def \\_init\\_(self, code, msg):
super().\\_init\\_(msg); self.code, self.msg = code, msg
def log\_hit(req, obj\_id, decision):
ts = time.strftime(\"%Y-%m-%d %H:%M:%S\")
print(f\"[{ts}] user={req['user\_id']} obj={obj\_id} -> {decision}\")
def require\_owner(loader, owner\_field=\"user\_id\"):
\"\"\"装饰器:按 id 取到对象后,先验归属再放行。admin 走角色白名单。\"\"\"
def deco(fn):
@wraps(fn)
def wrapper(req, obj\_id, \args, \\*kwargs):
obj = loader(obj\_id)
if obj is None:
log\_hit(req, obj\_id, \"404 NOT\_FOUND\")
raise HttpError(404, \"object not found\")
ok = getattr(obj, owner\_field) == req[\"user\_id\"] \
or req.get(\"role\") in ADMIN\_ROLES
if not ok:
log\_hit(req, obj\_id, \"403 FORBIDDEN\")
raise HttpError(403, \"forbidden: not your object\")
log\_hit(req, obj\_id, \"200 OK\")
return fn(req, obj, \args, \\*kwargs)
return wrapper
return deco
class Order: # 演示用内存数据
def \\_init\\_(self, oid, uid, amount):
self.order\_id, self.user\_id, self.amount = oid, uid, amount
ORDERS = {\"1001\": Order(\"1001\", \"u\_wang\", 299.0),
\"1002\": Order(\"1002\", \"u\_li\", 1580.5)}
@require\_owner(lambda oid: ORDERS.get(str(oid)))
def get\_order\_detail(req, order):
return {\"code\": 200, \"data\": {\"order\_id\": order.order\_id,
\"amount\": order.amount}}
def main():
cases = [
({\"user\_id\": \"u\_wang\", \"role\": \"user\"}, \"1001\", \"本人订单应放行\"),
({\"user\_id\": \"u\_wang\", \"role\": \"user\"}, \"1002\", \"改 id 拿别人订单应拦下\"),
({\"user\_id\": \"u\_ops\", \"role\": \"admin\"}, \"1002\", \"admin 走角色白名单\"),
]
print(\"== 归属校验回归测试 ==\")
print(\"测试时间:\", time.strftime(\"%Y-%m-%d %H:%M:%S\"))
for req, oid, desc in cases:
try:
r = get\_order\_detail(req, oid)
print(f\" {desc}: code={r['code']}\")
except HttpError as e:
print(f\" {desc}: code={e.code} {e.msg}\")
print(\"结论: 每条请求都过了归属门,改 id 也拿不到别人的数据\")
return 0
if \\_name\\ == \"\\main\\_\":
raise SystemExit(main())
脚本自带一组回归用例,直接跑:
实跑结果 · 2026-10-12
== 归属校验回归测试 ==
测试时间: 2026-10-12 22:25:31
[2026-10-12 22:25:31] user=u\_wang obj=1001 -> 200 OK
本人订单应放行: code=200
[2026-10-12 22:25:31] user=u\_wang obj=1002 -> 403 FORBIDDEN
改 id 拿别人订单应拦下: code=403 forbidden: not your object
[2026-10-12 22:25:31] user=u\_ops obj=1002 -> 200 OK
admin 走角色白名单: code=200
结论: 每条请求都过了归属门,改 id 也拿不到别人的数据
三行用例就是三种命运:本人访问 200、改 id 拿别人订单 403、admin 走角色白名单 200。每一次判定都带时间戳日志,谁在什么时候碰了哪条数据,出了事有据可查。
每条请求都要验归属
修复清单压成三句话:身份从服务端会话取,不信客户端传的任何身份参数;取对象的查询永远带 owner 条件,写成 WHERE id=? AND user\_id=?;鉴权失败记日志而不是默默返回空——静默失败会把攻击探测藏进正常流量里。
07
一次越权自查该怎么排
把前面的活串成一条流水线,新项目上线前照着走一遍:
准备两个测试账号(一个看自己的,一个用来\"越界\")→ 抓一条正常请求 → 把请求里的用户标识/对象编号换成另一个账号的 → 对比响应:如果别人的数据显示了出来,记下接口和参数 → 补归属校验 → 用同样的步骤复测到全绿。
越权自查流程
两个容易踩空的地方提醒一下:一是别只测主接口,导出、下载、打印、批量查询这些\"边角接口\"往往漏得最多——T-Mobile 那 3700 万,就是从一个不起眼的 API 走的;二是别只看 GET,修改和删除接口的越权(改别人的收货地址、删别人的订单)危害更大。
08
说给不写代码的你
你可能一辈子不写代码,但这条漏洞离你一点也不远。你的订单、病历、快递面单、聊天记录,都躺在某台服务器的接口后面。三件事可以做:
一,留意\"连号\"的凭证。订单号、取件码、成绩查询号如果是连续数字或纯日期,本身就值得警惕——它们是越权漏洞最喜欢的门牌号。好的系统会用随机长编号,或在查询时强制校验是不是本人。
二,发现自己能看到别人的数据,停手、截图、举报。不要好奇多点几下,更不要\"帮对方测一下\"。正确姿势是:停止验证,保留证据,通过网络违法犯罪举报网站或 12321 举报平台反馈给平台和监管。你多看的那几眼,可能就是前面判例里的\"情节严重\"。
三,把两步验证打开。越权经常和撞库连环作案:先用泄露的密码登录,再顺着越权接口把\"自己账号能看到的数据\"放大成别人的数据。两步验证能掐断第一步。
我这些年测过的系统里,几乎每个团队听到\"改一个数字就能看别人的订单\"时,第一反应都是\"不可能,我们做了登录校验\"。然后我当着他们的面刷新一下页面。
登录只回答了\"你是谁\",永远别忘了替数据问一句:\"这条,是你的吗?\"
这句话值不了多少技术含量,但它护住的,是每一个用户的全部隐私。
觉得有用?点个「在看」让更多人看到
紫禁玄科
专注网络安全 · 深度分析
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论