CNVD-2017-20077任意文件上传漏洞分析

CNVD-2017-20077任意文件上传漏洞分析

Gat1ta 4,464 2021-04-01

UEditor是一款所见即所得的开源富文本编辑器,具有轻量、可定制、用户体验优秀等特点,被广大WEB应用程序所使用。本次爆出的高危漏洞属于.NET版本,其它的版本暂时不受影响。漏洞成因是在抓取远程数据源的时候未对文件后缀名做验证导致任意文件写入漏洞,黑客利用此漏洞可以在服务器上执行任意指令,综合评级高危。

最近公司在做渗透测试的时候,有一个应用服务器被测试出存在Ueditor任意文件上传漏洞,本着学习了解的态度,来详细分析一下漏洞原理。

0x0 简介:

该任意文件上传漏洞存在于1.4.3.3、1.5.0和1.3.6版本中,并且只有**.NET**版本受该漏洞影响。黑客可以利用该漏洞上传木马文件,执行命令控制服务器。

该漏洞是由于上传文件时,使用的CrawlerHandler类未严格对文件类型进行校验,导致可以轻松绕过文件类型检查,导致了任意文件上传。1.4.3.3和1.5.0版本利用方式稍有不同,1.4.3.3需要一个正确解析的域名。而1.5.0用IP和普通域名就可以,相对来输送1.5.0更容易触发此漏洞;而1.4.3.3版本中需要提供一个正常的域名就可以绕过判断。

0x1 代码审计:

在Github上下载1.4.3.3版本的代码进行代码审计。

首先从Controller.ashx控制器文件开始审计:

public class UEditorHandler : IHttpHandler
{
    public void ProcessRequest(HttpContext context)
    {
        Handler action = null;
        switch (context.Request["action"])
        {
            case "config":
                action = new ConfigHandler(context);
                break;
            case "uploadimage":
                action = new UploadHandler(context, new UploadConfig()
                {
                    AllowExtensions = Config.GetStringList("imageAllowFiles"),
                    PathFormat = Config.GetString("imagePathFormat"),
                    SizeLimit = Config.GetInt("imageMaxSize"),
                    UploadFieldName = Config.GetString("imageFieldName")
                });
                break;
            case "uploadscrawl":
                action = new UploadHandler(context, new UploadConfig()
                {
                    AllowExtensions = new string[] { ".png" },
                    PathFormat = Config.GetString("scrawlPathFormat"),
                    SizeLimit = Config.GetInt("scrawlMaxSize"),
                    UploadFieldName = Config.GetString("scrawlFieldName"),
                    Base64 = true,
                    Base64Filename = "scrawl.png"
                });
                break;
            case "uploadvideo":
                action = new UploadHandler(context, new UploadConfig()
                {
                    AllowExtensions = Config.GetStringList("videoAllowFiles"),
                    PathFormat = Config.GetString("videoPathFormat"),
                    SizeLimit = Config.GetInt("videoMaxSize"),
                    UploadFieldName = Config.GetString("videoFieldName")
                });
                break;
            case "uploadfile":
                action = new UploadHandler(context, new UploadConfig()
                {
                    AllowExtensions = Config.GetStringList("fileAllowFiles"),
                    PathFormat = Config.GetString("filePathFormat"),
                    SizeLimit = Config.GetInt("fileMaxSize"),
                    UploadFieldName = Config.GetString("fileFieldName")
                });
                break;
            case "listimage":
                action = new ListFileManager(context, Config.GetString("imageManagerListPath"), Config.GetStringList("imageManagerAllowFiles"));
                break;
            case "listfile":
                action = new ListFileManager(context, Config.GetString("fileManagerListPath"), Config.GetStringList("fileManagerAllowFiles"));
                break;
            case "catchimage":
                action = new CrawlerHandler(context);
                break;
            default:
                action = new NotSupportedHandler(context);
                break;
        }
        action.Process();
    }
    public bool IsReusable
    {
        get
        {
            return false;
        }
    }
}

控制器存在多个动作的调用,包括config、uploadimage、uploadscrawl、uploadvideo、uploadfile、listimage、listfile、catchimage,这些动作默认都可以远程访问,本次漏洞出现在catchimage动作中,这个动作实例化了CrawlerHandler这个类,详细代码如下:

public class CrawlerHandler : Handler
{
    private string[] Sources;
    private Crawler[] Crawlers;
    public CrawlerHandler(HttpContext context) : base(context) { }
    public override void Process()
    {
        Sources = Request.Form.GetValues("source[]");
        if (Sources == null || Sources.Length == 0)
        {
            WriteJson(new
            {
                state = "参数错误:没有指定抓取源"
            });
            return;
        }
        Crawlers = Sources.Select(x => new Crawler(x, Server).Fetch()).ToArray();
        WriteJson(new
        {
            state = "SUCCESS",
            list = Crawlers.Select(x => new
            {
                state = x.State,
                source = x.SourceUrl,
                url = x.ServerUrl
            })
        });
    }
}

可以看到,代码首先获取了source[]的值,并且判断是否为空,如果为空则返回“参数错误:没有指定抓取源”。否则就实例化Crawler类并且调用Fetch函数,Crawler类代码如下:

public class Crawler
{
    public string SourceUrl { get; set; }
    public string ServerUrl { get; set; }
    public string State { get; set; }
    private HttpServerUtility Server { get; set; }
    public Crawler(string sourceUrl, HttpServerUtility server)
    {
        this.SourceUrl = sourceUrl;
        this.Server = server;
    }
    public Crawler Fetch()
    {
        if (!IsExternalIPAddress(this.SourceUrl))
        {
            State = "INVALID_URL";
            return this;
        }
        var request = HttpWebRequest.Create(this.SourceUrl) as HttpWebRequest;
        using (var response = request.GetResponse() as HttpWebResponse)
        {
            if (response.StatusCode != HttpStatusCode.OK)
            {
                State = "Url returns " + response.StatusCode + ", " + response.StatusDescription;
                return this;
            }
            if (response.ContentType.IndexOf("image") == -1)
            {
                State = "Url is not an image";
                return this;
            }
            ServerUrl = PathFormatter.Format(Path.GetFileName(this.SourceUrl), Config.GetString("catcherPathFormat"));
            var savePath = Server.MapPath(ServerUrl);
            if (!Directory.Exists(Path.GetDirectoryName(savePath)))
            {
                Directory.CreateDirectory(Path.GetDirectoryName(savePath));
            }
            try
            {
                var stream = response.GetResponseStream();
                var reader = new BinaryReader(stream);
                byte[] bytes;
                using (var ms = new MemoryStream())
                {
                    byte[] buffer = new byte[4096];
                    int count;
                    while ((count = reader.Read(buffer, 0, buffer.Length)) != 0)
                    {
                        ms.Write(buffer, 0, count);
                    }
                    bytes = ms.ToArray();
                }
                File.WriteAllBytes(savePath, bytes);
                State = "SUCCESS";
            }
            catch (Exception e)
            {
                State = "抓取错误:" + e.Message;
            }
            return this;
        }
    }

可以看到,Fetch函数中,首先调用了IsExternalIPAddress函数来判断URL是否为可以被DNS解析的URL,详细代码如下:

 private bool IsExternalIPAddress(string url)
    {
        var uri = new Uri(url);
        switch (uri.HostNameType)
        {
            case UriHostNameType.Dns:
                var ipHostEntry = Dns.GetHostEntry(uri.DnsSafeHost);
                foreach (IPAddress ipAddress in ipHostEntry.AddressList)
                {
                    byte[] ipBytes = ipAddress.GetAddressBytes();
                    if (ipAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
                    {
                        if (!IsPrivateIP(ipAddress))
                        {
                            return true;
                        }
                    }
                }
                break;
            case UriHostNameType.IPv4:
                return !IsPrivateIP(IPAddress.Parse(uri.DnsSafeHost));
        }
        return false;
    }
    private bool IsPrivateIP(IPAddress myIPAddress)
    {
        if (IPAddress.IsLoopback(myIPAddress)) return true;
        if (myIPAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
        {
            byte[] ipBytes = myIPAddress.GetAddressBytes();
            // 10.0.0.0/24 
            if (ipBytes[0] == 10)
            {
                return true;
            }
            // 172.16.0.0/16
            else if (ipBytes[0] == 172 && ipBytes[1] == 16)
            {
                return true;
            }
            // 192.168.0.0/16
            else if (ipBytes[0] == 192 && ipBytes[1] == 168)
            {
                return true;
            }
            // 169.254.0.0/16
            else if (ipBytes[0] == 169 && ipBytes[1] == 254)
            {
                return true;
            }
        }
        return false;
    }

通过URL判断后,就开始跟URL建立链接,准备开始抓取图片,建立链接之后,开始判断文件类型,在这里只判断了文件的ContentType:

if (response.ContentType.IndexOf("image") == -1)
            {
                State = "Url is not an image";
                return this;
            }</span>
这里我们可以通过一个图片马绕过文件类型检查,通过文件类型检查后,编辑器根据文件配置信息创建对应的目录结构在保存文件,代码如下:

<span style="font-family:Courier New,Courier,monospace">try
            {
                var stream = response.GetResponseStream();
                var reader = new BinaryReader(stream);
                byte[] bytes;
                using (var ms = new MemoryStream())
                {
                    byte[] buffer = new byte[4096];
                    int count;
                    while ((count = reader.Read(buffer, 0, buffer.Length)) != 0)
                    {
                        ms.Write(buffer, 0, count);
                    }
                    bytes = ms.ToArray();
                }
                File.WriteAllBytes(savePath, bytes);
                State = "SUCCESS";
            }
            catch (Exception e)
            {
                State = "抓取错误:" + e.Message;
            }

那么现在有一个问题,我们上传的是一个图片马,怎么让服务器当作脚本文件来执行呢?因为这种上传操作实际上是指定一个文件链接,由服务器主动去请求这个链接去获取文件的,并不是从本地直接上传到服务器,这样就不可以通过BP等抓包工具修改ContentType直接绕过,所以我们在指定文件URL的时候后缀一定要是图片类型的,比如JPG/PNG,但是这种文件上传后无法以脚本来执行,所以想要让图片马以脚本执行,还需要看一下服务器保存文件的时候如何确认的文件后缀:

 ServerUrl = PathFormatter.Format(Path.GetFileName(this.SourceUrl), Config.GetString("catcherPathFormat"));
            var savePath = Server.MapPath(ServerUrl);
            if (!Directory.Exists(Path.GetDirectoryName(savePath)))
            {
                Directory.CreateDirectory(Path.GetDirectoryName(savePath));
            }

可以看到,服务器通过filename获取最后一个点后面的内容当作文件后缀,但是我们指定文件名的时候不能用.asp为结尾,这时候就可以通过在图片后缀后面加上?.asp来达到目的,比如图片马的链接是xxxx/1.jpg,我们可以指定文件链接为xxxx/1.jpg?.asp,在URL中1.jpg?.aspx会被默认当成1.jpg解析,因为问号后面的内容会被当做参数,这在请求图片的时候不受影响,但是在服务器确认要保存的文件后缀的时候,是利用链接的最有一个点后面的后缀确认的文件后缀,所以保存的文件的后缀名会是asp,这样我们就可以让图片马以脚本的方式执行了。

0x2 漏洞利用:

首先我们要构建一个本地HTML表单,表单的action填写具有Ueditor漏洞的链接,详细代码如下:

<form action="http://xxxxxxxxx/controller.ashx?action=catchimage"enctype="application/x-www-form-urlencoded"  method="POST">
  <p>shell addr:<input type="text" name="source[]" /></p >
  <inputtype="submit" value="Submit" />
</form>

然后构建一个图片马,放到一个可以被外网访问的服务器上,要求有可以被解析的域名。

然后在我们构建的HTML文件中输入图片马的URL,后面加上?.asp。

点击提交,结果如下所示:

image.png

看到这个界面,证明我们的图片马上传成功,并且也被保存为了ASP文件,然后直接用菜刀链接就OK了。

0x3 总结:

这个漏洞的主要问题在于对文件类型的检查不够严谨,并且保存文件时的文件名生成存在可以被利用的漏洞。

解决办法可以通过将保存文件的文件夹设置为不可执行,或者手动更改代码,添加更严谨的文件类型检查代码。