Nginx UI 权限绕过与备份解密漏洞?可能RCE哦 | CVE-2026-27944 复现&研究
Nginx UI是一个用于管理Nginx服务器的开源网页界面工具,在2.3.3 之前的版本中,/api/backup接口存在权限控制缺陷,允许未经身份验证的访问。同时,该接口在响应头的 X-Backup-Security字段中错误地公开了用于加密备份文件的密钥。攻击者可以远程下载完整的系统备份文件,并利用响应头中的密钥直接解密,从而获取服务器的敏感数据,包括用户凭据、会话令牌、SSL 私钥以及Ng
0x0 背景介绍
Nginx UI 是一个用于管理Nginx 服务器的开源网页界面工具。
在2.3.3 之前的版本中,/api/backup 接口存在权限控制缺陷,允许未经身份验证的访问。同时,该接口在响应头的 X-Backup-Security 字段中错误地公开了用于加密备份文件的密钥。
攻击者可以远程下载完整的系统备份文件,并利用响应头中的密钥直接解密,从而获取服务器的敏感数据,包括用户凭据、会话令牌、SSL 私钥以及Nginx 配置文件。
0x1 环境搭建
1.1、Ubuntu24+Docker搭建配置
- 另存为
install.sh运行
#!/bin/bash
# 检查并安装依赖
if ! command -v curl &> /dev/null || ! command -v wget &> /dev/null; then
echo "[*] 安装依赖工具..."
apt update && apt install -y curl wget
fi
# 检查 Docker 是否安装
if ! command -v docker &> /dev/null; then
echo "[*] 未安装 Docker,请检查安装"
else
echo "[+] Docker 已安装"
fi
# 检查 Docker Compose 插件
if ! docker compose version &> /dev/null; then
echo "[-] Docker Compose 插件不可用,请检查安装"
exit 1
else
echo "[+] Docker Compose 插件可用"
fi
echo "[*] 阶段1/4:创建 Nginx UI 工作目录..."
mkdir -p ~/nginx-ui && cd ~/nginx-ui || { echo "[-] 创建目录失败"; exit 1; }
mkdir -p nginx logs data config
echo "[+] 工作目录: $(pwd)"
echo "[*] 阶段2/4:生成 docker-compose.yml..."
cat > docker-compose.yml <<EOF
version: '3.8'
services:
nginx-ui:
image: uozi/nginx-ui:2.3.2
container_name: nginx-ui
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "9000:9000"
volumes:
- ./nginx:/etc/nginx
- ./logs:/var/log/nginx
- ./data:/data
- ./config:/etc/nginx-ui
environment:
- TZ=Asia/Shanghai
- NGINX_UI_IGNORE_DOCKER_SOCKET=true
EOF
echo "[+] docker-compose.yml 已生成"
echo "[*] 阶段3/4:启动 Docker 容器..."
docker compose pull
docker compose up -d
echo "[*] 等待服务启动(约30秒)..."
for i in {1..6}; do
echo -n "."
sleep 5
done
echo -e "\n[+] 容器已启动"
echo "[*] 检查 Nginx UI Web 服务是否就绪..."
RETRIES=0
MAX_RETRIES=12
until [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:9000)" = "200" ] || [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:9000)" = "302" ]; do
sleep 5
RETRIES=$((RETRIES+1))
if [ $RETRIES -ge $MAX_RETRIES ]; then
echo "[-] Nginx UI 服务未在规定时间内响应,请手动检查: docker compose logs"
exit 1
fi
echo -n "."
done
echo -e "\n[+] Nginx UI Web 服务已就绪 (HTTP 状态码 200/302)"
echo "=============================================="
echo " Nginx UI 2.3.2 部署完成!"
echo " - 访问管理界面: http://IP:9000"
echo " - 首次访问请完成安装向导"
echo ""
echo " - 配置文件目录: ~/nginx-ui/config"
echo " - Nginx 配置目录: ~/nginx-ui/nginx"
echo " - 日志目录: ~/nginx-ui/logs"
echo " - 数据目录: ~/nginx-ui/data"
echo ""
echo " - 容器管理命令:"
echo " docker compose up -d # 启动"
echo " docker compose down # 停止"
echo " docker compose logs -f # 查看日志"
echo "=============================================="
- 后续访问web服务进行搭建

0x2 漏洞复现
2.1、手动复现
-
漏洞复现

-
python参考:
https://github.com/Kai-One001/cve-/blob/main/CVE-2026-27944-Nginx-UI.py
-
信息泄露+解密

-
意想不到的结果,通过获取的
X-Backup-Security进行恶意上传,详情见下文分析EXP没公开哈,按需索取或者自行研究,你与第一只差一步之遥

-
容器验证

2.2、复现流量特征 (PCAP)
-
未授权请求
backup接口
-
其他利用手段,上传恶意文件

0x3 漏洞原理分析
3.1 架构与模块定位
Nginx UI 的后端使用 Gin 路由分组实现分层访问控制:最外层统一挂在 /api,其中一部分路由(例如登录、公用接口、自检)允许匿名访问;另一部分路由通过 middleware.AuthRequired() 强制认证。
这次漏洞恰好落在 “匿名访问区” 的边界上:备份模块 api/backup 被初始化在 /api 根组上,而该根组只做了 IP 白名单校验(且默认空白名单等价于全放行),没有任何身份认证。
3.2 漏洞链条涉及的核心文件
| 层级 | 文件 | 角色定位 | 在漏洞链条中的职责 |
|---|---|---|---|
| 入口层(路由) | router/routers.go |
全局路由与中间件编排 | 决定 backup.InitRouter(root) 落在哪个路由组(是否受 AuthRequired 保护) |
| 入口层(模块路由) | api/backup/router.go |
备份模块路由声明 | 注册 GET /backup 与 POST /restore(均未显式加鉴权) |
| 控制器层(危险输出) | api/backup/backup.go |
备份下载接口 | 生成备份并把 AESKey:IV 写进 X-Backup-Security 响应头 |
| 逻辑层(备份构建) | internal/backup/backup.go |
备份打包与加密 | 收集配置与数据库文件、压缩、AES-CBC 加密,并把 key/iv 作为结果返回上层 |
| 逻辑层(敏感文件选择) | internal/backup/backup_nginx_ui.go |
nginx-ui 文件收集 | 明确把 app.ini 与 *.db(用户 / 令牌等)纳入备份 |
| 安全边界(中间件) | internal/middleware/middleware.go、internal/middleware/ip_whitelist.go |
认证与 IP 白名单 | AuthRequired() 负责真正的认证;IPWhiteList() 默认空白名单全放行,构成 “看似有门禁,实则无门禁” 的错觉 |
3.3 锁定关键路径:从 /api/backup 开始
3.3.1 第一步:路由层确认 “它到底挂在哪个访问域”
在 router/routers.go 里,/api 根组只绑定了 middleware.IPWhiteList(),然后直接初始化了一批 “无需认证” 的路由模块,其中就包含 backup.InitRouter(root):
//61:103:d:\环境-下载中心[1]nginx-ui-2.3.2\nginx-ui-2.3.2\router\routers.go
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-only routes (no proxy) - authorization required
local := root.Group("/", middleware.AuthRequired())
{
llm.InitLocalRouter(local)
}
// Authorization required and not websocket request
g := root.Group("/", middleware.AuthRequired(), middleware.Proxy())
{
// ... lots of private routers ...
backup.InitAutoBackupRouter(g)
}
- 这段代码就是 “危险开关面板”:同样叫 backup 的模块,
InitAutoBackupRouter(g)被放进了需要认证的私有组,而InitRouter(root)却被放进了匿名组。这意味着GET /api/backup和POST /api/restore都天然不受AuthRequired()保护。
3.3.2 第二步:模块路由确认 “具体暴露了什么端点”
在 api/backup/router.go:
//8:21:\nginx-ui-2.3.2\api\backup\router.go
func InitRouter(r *gin.RouterGroup) {
r.GET("/backup", CreateBackup)
r.POST("/restore", middleware.EncryptedForm(), RestoreBackup)
}
到这里已经把漏洞入口锁定为(这两个接口都没有在这里添加任何鉴权中间件。):
GET /api/backup(下载备份)POST /api/restore(上传恢复)
3.3.3 第三步:追到 “最后一道失守的防线”
接着看 CreateBackup 的实现,需特别留意两点:
- 是否会把备份内容直接回传给客户端(大文件下载通常意味着高价值数据流出)
- 是否会在响应头 / 响应体泄露解密要素(key/iv/token)
//15:42:\nginx-ui-2.3.2\api\backup\backup.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
// Prepare response content
reader := bytes.NewReader(result.BackupContent)
modTime := time.Now()
// Set HTTP headers for file download
fileName := result.BackupName
c.Header("Content-Description", "File Transfer")
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.Header("Content-Transfer-Encoding", "binary")
c.Header("X-Backup-Security", securityToken) // Pass security token in header
c.Header("Expires", "0")
c.Header("Cache-Control", "must-revalidate")
c.Header("Pragma", "public")
// Send file content
http.ServeContent(c.Writer, c.Request, fileName, modTime, reader)
}
最后一道失守的防线就是这里的:
c.Header("X-Backup-Security", securityToken),它把本应只存在于服务器内存 / 安全存储的AESKey与AESIv(base64)拼接后直接给了客户端- 结合上一节的 “匿名可访问”,这就形成了 CVE 描述的核心链:未认证下载 + 同包返回解密密钥。
3.4 剖析边界缺失:预期安全设计 vs 实际实现
3.4.1 预期的安全设计(合理推断)
从 router/routers.go 的分组风格可以看出项目的安全意图:
/api根组:允许匿名访问一些 “公共 / 初始化 / 自检” 接口/api私有组(middleware.AuthRequired()):业务管理面板、配置管理等
备份接口在 “业务语义” 上属于高危操作(读取配置、读取数据库、打包私钥 / 证书、导出会话令牌等),按常识应该属于私有组。
此外,备份逻辑里确实做了 AES 加密,看起来像是 “就算备份泄露,也不会被读懂”。但这个设计要成立,有一个前提:解密密钥不能随备份一并发给同一个匿名请求方。
3.4.2 实际实现:边界为什么等价于 “无”
边界缺失点 A:路由组放错位置
backup.InitRouter(root) 被放到了匿名组 root := r.Group("/api", middleware.IPWhiteList())。这意味着:
- 只要 IP 白名单不生效(默认空、或被部署者忽略),该端点对公网直接暴露。
边界缺失点 B:IP 白名单的默认行为是 “全放行”
IPWhiteList() 的关键逻辑:
//11:25:\nginx-ui-2.3.2\internal\middleware\ip_whitelist.go
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(),等价于不做任何限制。
补充证据:默认配置的 “零值陷阱”
- 追踪至
internal/kernel/boot.go中的初始化逻辑,系统在检测到配置文件缺失时会自动生成默认配置并持久化:
// internal/kernel/boot.go
func InitNodeSecret() {
if settings.NodeSettings.Secret == "" {
// ... 生成随机 Secret ...
err := settings.Save() // 关键点:将包含零值配置的 struct 写入磁盘
if err != nil {
logger.Error("Error save settings", err)
}
}
}
- 在
Go语言机制中,结构体AuthSettings中的IPWhiteList字段(类型为[] string)在未显式赋值时,其零值(Zero Value)为空切片 [] - 这意味着:任何全新部署的
Nginx UI实例(尤其是Docker容器化环境),在首次启动自动生成app.ini时,其IP白名单必然为空 - 结合
middleware/ip_whitelist.go中 “空名单即放行” 的逻辑,这导致所有新安装的环境在默认状态下即对公网完全暴露高危接口,无需管理员进行任何错误的配置操作
边界缺失点 C:加密并没有提供防泄露能力,因为 key/iv 同步泄露
在 internal/backup/backup.go 中,备份会生成随机 key、iv,并以 base64形式返回给上层:
//58:184:\nginx-ui-2.3.2\internal\backup\backup.go
key, err := GenerateAESKey()
// ...
iv, err := GenerateIV()
// ...
// Encode encryption keys as base64 for safe transmission/storage
keyBase64 := base64.StdEncoding.EncodeToString(key)
ivBase64 := base64.StdEncoding.EncodeToString(iv)
// Assemble final backup result
result := Result{
BackupContent: buffer.Bytes(),
BackupName: backupName,
AESKey: keyBase64,
AESIv: ivBase64,
}
// encryptFile 内部逻辑推断 (基于 Go 标准实践):
// block, _ := aes.NewCipher(key) // 32 bytes key -> AES-256
// mode := cipher.NewCBCEncrypter(block, iv)
// paddedData := pkcs7Pad(data, block.BlockSize())
// mode.CryptBlocks(ciphertext, paddedData)
- 在逻辑层看来,把
key/iv放进Result也许是为了展示,让用户下载后能恢复。但控制器层的做法是把它们塞进响应头,导致任何能触发下载的人都能同步获得解密材料 - 在请求方面,只需使用任何支持 AES-256-CBC 的标准工具(如 Python
pycryptodome、OpenSSL或Go原生库),配合响应头中的X-Backup-Security(格式为Base64(Key):Base64(IV)), 即可在毫秒级时间内完成解密
加密在此处仅起到了 “混淆” 作用,而未提供任何实质性的机密性保护
3.4.3 一个 “误导性的安全感” 细节:前端的使用方式
前端 app/src/api/backup.ts 明确写了,为了拿响应头,要使用 returnFullResponse,这间接佐证了设计意图:X-Backup-Security 的确被当作正常功能的一部分:
//46:76:\nginx-ui-2.3.2\app\src\api\backup.ts
createBackup() {
return http.get('/backup', {
responseType: 'blob',
returnFullResponse: true,
})
},
restoreBackup(options: RestoreOptions) {
// ...
return http.post('/restore', formData, {
headers: {
'Content-Type': 'multipart/form-data;charset=UTF-8',
},
crypto: true,
})
},
- 这意味着漏洞不是 “偶然泄露”,而是 “按功能设计输出”。当这个功能被放到匿名可访问的
AP组里时,风险才被放大。
3.5 推导最大危害
那么就不能局限在泄露,试着扩大下战果
3.5.1 备份内容包含什么敏感物
internal/backup/backup_nginx_ui.go 明确收集两类:
- nginx-ui 配置文件:
cosysettings.ConfPath→ 固定写入备份目录为app.ini - nginx-ui 数据库文件:
<dbName>.db(SQLite 文件常见命名)
16:46:\nginx-ui-2.3.2\internal\backup\backup_nginx_ui.go
// Always save the config file as app.ini, regardless of its original name
destConfigPath := filepath.Join(destDir, "app.ini")
// ...
dbName := settings.DatabaseSettings.GetName()
dbFile := dbName + ".db"
// Database directory is the same as config file directory
dbDir := filepath.Dir(configPath)
dbPath := filepath.Join(dbDir, dbFile)
// Copy database file
- 此外,
backupNginxFiles会把 nginx 配置目录整体复制进备份:
//51:65:\nginx-ui-2.3.2\internal\backup\backup_nginx_ui.go
func backupNginxFiles(destDir string) error {
nginxConfigDir := nginx.GetConfPath()
// Copy nginx config directory
if err := copyDirectory(nginxConfigDir, destDir); err != nil {
return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, err.Error())
}
return nil
}
综合来看,攻击者解密备份后,常见可得信息包括(取决于你的部署内容):
- 用户凭据 / 哈希、会话令牌、
2FA/Passkey相关数据(通常存于*.db) Nginx站点配置(可能包含upstream内网地址、鉴权口令、反代目标、访问控制策略)TLS证书与私钥(如果你的Nginx conf目录里存放 / 引用了可读取的key文件或把key放进了同目录)app.ini中的JWT secret、Node secret、数据库连接信息、第三方通知凭据等(具体字段取决于配置)
3.5.2 典型攻击链(未认证 → 全量备份 → 即时解密)
把上面的实现拼起来,攻击链几乎是 “一步到位”:
1. **匿名请求** `GET /api/backup`
2. 服务端返回 `application/zip` 的备份文件流
3. 同时在响应头给出 `X-Backup-Security: <base64key>:<base64iv>`
4. 攻击者本地 AES-CBC 解密内部 `hash_info.txt`、`nginx-ui.zip`、`nginx.zip`,再解压拿到配置与数据库
- 这就是 “加密存在但不构成安全性” 的典型反例:密钥与密文同路返回时,加密只剩下格式上的复杂度,而没有任何保密价值。
3.5.3 进一步的 “极限危害” 推演:结合未鉴权恢复接口
追踪至 api/backup/restore.go中的 RestoreBackup函数,我们发现该接口不仅缺乏身份认证,还直接暴露了服务重启的控制权:
/**虽然`CVE` 描述重点是下载与解密,但在该版本里 `POST /api/restore` 同样位于匿名组(见上文),这就有一个更具破坏性的链条可能性**/
// api/backup/restore.go
func RestoreBackup(c *gin.Context) {
// 1. 直接读取表单参数,无任何鉴权
restoreNginx := c.PostForm("restore_nginx") == "true"
// ...
securityToken := c.PostForm("security_token")
// 2. 执行解密与文件覆盖 (内部逻辑包含直接写入系统配置目录)
result, err := backup.Restore(options)
// 3. 【高危】若参数为 true,异步触发 Nginx 重启,使恶意配置立即生效
if restoreNginx {
go func() {
time.Sleep(2 * time.Second)
nginx.Restart() // 攻击者控制的配置在此刻被加载
}()
}
// ...
}
- 攻击者先下载并解密备份,获得完整
nginx配置与nginx-ui配置 / 数据库; - 然后构造恢复请求,触发
restoreNginxConfigs()对nginx conf目录执行清理与覆盖,并在api/backup/restore.go里异步触发nginx.Restart(); - 若攻击者能将恶意
nginx配置写入(例如开启危险模块、反代内网管理端、篡改站点路由),可能造成:
PS:虽然不能写入/etc/passwd等系统文件,但覆盖Nginx配置文件本身足以导致RCE(通过proxy_pass劫持流量或lua_exec执行命令)- 内网横向与
SSRF入口的建立(通过Nginx反代策略) - 访问控制绕过(重写
allow/deny、auth_basic等) - 服务中断(配置清空 / 写入无效导致
Nginx无法启动)
- 内网横向与
构造恢复请求
恢复接口为 POST /api/restore,需要使用 multipart/form-data 格式提交以下字段(接口只校验非空与 `:` 分割、base64 解码可行):
restore_nginx:设置为 true(触发 Nginx 重启)
restore_nginx_ui:可选,true 或 false(触发 Nginx UI 重启,可能造成 UI 中断)
verify_hash:设置为 false(跳过哈希校验,避免因文件被篡改而失败)
security_token:<base64Key>:<base64Iv>,即从 X-Backup-Security 获取的值
backup_file:上传的文件,即之前下载的 backup.zip

- 从
api/backup/restore.go可以看到,若restore_nginx=true,将异步触发nginx.Restart();若restore_nginx_ui=true,将触发risefront.Restart()(见RestoreBackup末尾的 goroutine)。
这里需要强调:internal/backup/restore.go 对 zip-slip(目录穿越)做了相对严格的防护(.. 检测 + Abs 前缀校验 + symlink 限制),所以不能将它直接定性为 “任意文件写入”。但 ** 未授权触发 “清理并覆盖nginx conf + 重启”* 本身就足以构成高危(受限目录下的配置文件覆盖 )
0x4 修复建议
修复方案
-
升级最新版本:将组件升级至
2.3.3以上版本:GitHub-nginx-ui -
临时防护措施:
限制访问:临时限制对/api/backup模块外部访问
防火墙拦截:配置规则,拦截对模块异常的请求(/api/backup, /api/restore),关注敏感请求
增加认证:只有经过严格认证的用户才能访问,启用HTTP Basic Auth(基本认证)
免责声明:本文仅用于安全研究目的,未经授权不得用于非法渗透测试活动。
更多推荐

所有评论(0)