导语:概要 本文是针对我们在Resolv::getaddresses中发现的一个漏洞的安全建议,攻击者可以利用该漏洞绕过多种服务器端请求伪造过滤器,诸如GitLab和HackerOne之类的应用程序都受到这个漏洞的威胁。本文披露的内容均遵循HackerOne的“漏洞披露指
概要
本文是针对我们在Resolv::getaddresses中发现的一个漏洞的安全建议,攻击者可以利用该漏洞绕过多种服务器端请求伪造过滤器,诸如GitLab和HackerOne之类的应用程序都受到这个漏洞的威胁。本文披露的内容均遵循HackerOne的“漏洞披露指南”(链接地址:https://www.hackerone.com/disclosure-guidelines)。
该漏洞已分配的编号为CVE-2017-0904(链接地址:http://www.cve.mitre.org/cgi-bin/cvename.cgi?name=2017-0904)。
漏洞详细信息
由于Resolv::getaddresses与操作系统高度相关,因此可以通过使用不同的IP格式使其返回空值。如果这个漏洞被利用的话,可以令攻击者绕过用于抵御SSRF攻击的黑名单。
机器1
irb(main):002:0> Resolv.getaddresses("127.0.0.1") => ["127.0.0.1"] irb(main):003:0> Resolv.getaddresses("localhost") => ["127.0.0.1"] irb(main):004:0> Resolv.getaddresses("127.000.000.1") => ["127.0.0.1"]
机器2
irb(main):008:0> Resolv.getaddresses("127.0.0.1") => ["127.0.0.1"] irb(main):009:0> Resolv.getaddresses("localhost") => ["127.0.0.1"] irb(main):010:0> Resolv.getaddresses("127.000.000.1") => [] # ?
这个安全问题可以在最新稳定版的Ruby上进行重现:
$ ruby -v ruby 2.4.3p201 (2017-10-11 revision 60168) [x86_64-linux] $ irb irb(main):001:0> require 'resolv' => true irb(main):002:0> Resolv.getaddresses("127.000.001") => []
POC代码
irb(main):001:0> require 'resolv' => true irb(main):002:0> uri = "0x7f.1" => "0x7f.1" irb(main):003:0> server_ips = Resolv.getaddresses(uri) => [] # The bug! irb(main):004:0> blocked_ips = ["127.0.0.1", "::1", "0.0.0.0"] => ["127.0.0.1", "::1", "0.0.0.0"] irb(main):005:0> (blocked_ips & server_ips).any? => false # Bypass
引发漏洞的根本原因
下面,我们介绍引发这个安全漏洞的根本原因。为了提高可读性,我在下面的代码片段中添加了相应的注释。
当我们在调试模式(irb-d)下运行irb时,将返回如下所示的错误:
irb(main):002:0> Resolv.getaddresses "127.1" Exception `Resolv::DNS::Config::NXDomain' at /usr/lib/ruby/2.3.0/resolv.rb:549 - 127.1 Exception `Resolv::DNS::Config::NXDomain' at /usr/lib/ruby/2.3.0/resolv.rb:549 - 127.1 => []
从上面可以看出,该异常来自fetch_resource() [1](链接地址:https://github.com/ruby/ruby/blob/e16bd0f4d81ef74035712853a5eb527f28abb342/lib/resolv.rb#L514-L554)。 “NXDOMAIN”响应表明,发生异常的原因是解析器无法找到相应的PTR记录。实际上,这没有什么好奇怪的,因为我们稍后将会看到,resolv.rb使用的是操作系统的解析器。
# Reverse DNS lookup on ? Machine 1. $ nslookup 127.0.0.1 Server: 127.0.0.53 Address: 127.0.0.53#53 Non-authoritative answer: 1.0.0.127.in-addr.arpa name = localhost. Authoritative answers can be found from: $ nslookup 127.000.000.1 Server: 127.0.0.53 Address: 127.0.0.53#53 Non-authoritative answer: Name: 127.000.000.1 Address: 127.0.0.1 # NXDOMAIN for 127.1. $ nslookup 127.1 Server: 127.0.0.53 Address: 127.0.0.53#53 ** server can't find 127.1: NXDOMAIN
接下来,通过下面的代码,读者就会明白我们之前为什么说Resolv::getaddresses是与具体的操作系统高度相关的。
getaddresses用来接收地址(名称)并将其传递给each_address,一旦解析完成,它将被附加到ret数组中。
# File lib/resolv.rb, line 100 def getaddresses(name) # This is the "ret" array. ret = [] # This is where "address" is appended to the "ret" array. each_address(name) {|address| ret << address} return ret end
each_address通过@resolvers来处理name。
# File lib/resolv.rb, line 109 def each_address(name) if AddressRegex =~ name yield name return end yielded = false # "name" is passed on to the resolver here. @resolvers.each {|r| r.each_address(name) {|address| yield address.to_s yielded = true } return if yielded } end
@resolvers是利用initialize()函数来完成初始化的。
# File lib/resolv.rb, line 109 def initialize(resolvers=[Hosts.new, DNS.new]) @resolvers = resolvers end
更进一步来说,初始化工作实际上是通过将config_info设置为nil来完成的,就本例来说,这里使用的是默认配置/etc/resolv.conf。
# File lib/resolv.rb, line 308 # Set to /etc/resolv.conf ¯_(ツ)_/¯ def initialize(config_info=nil) @mutex = Thread::Mutex.new @config = Config.new(config_info) @initialized = nil end
下面展示的是默认的配置:
# File lib/resolv.rb, line 959 def Config.default_config_hash(filename="/etc/resolv.conf") if File.exist? filename config_hash = Config.parse_resolv_conf(filename) else if /mswin|cygwin|mingw|bccwin/ =~ RUBY_PLATFORM require 'win32/resolv' search, nameserver = Win32::Resolv.get_resolv_info config_hash = {} config_hash[:nameserver] = nameserver if nameserver config_hash[:search] = [search].flatten if search end end config_hash || {} end
这表明Resolv::getaddresses是与操作系统高度相关的,并且,如果提供的IP地址在反向DNS查找期间失败的话,getaddresses将返回一个空的ret数组。
缓解措施
我建议放弃Resolv::getaddresses,转而使用Socket库。
irb(main):002:0> Resolv.getaddresses("127.1") => [] irb(main):003:0> Socket.getaddrinfo("127.1", nil).sample[3] => "127.0.0.1"
Ruby核心开发团队也是建议使用Socket库。
既然网络地址是通过操作系统的解析器来解析的,那么地址检查的正确方式,就是使用操作系统的解析器,而非resolv.rb。 例如,可以使用socket库的Addrinfo.getaddrinfo。
——Tanaka Akira % ruby -rsocket -e ' as = Addrinfo.getaddrinfo("192.168.0.1", nil) p as p as.map {|a| a.ipv4_private? } ' [#<Addrinfo: 192.168.0.1 TCP>, #<Addrinfo: 192.168.0.1 UDP>, #<Addrinfo: 192.168.0.1 SOCK_RAW>] [true, true, true]
受影响的应用程序和Gem
GitLab社区版和企业版
报告的链接地址:https://hackerone.com/reports/215105
对于Mustafa Hasan(链接地址:https://hackerone.com/strukt)的报告(链接地址:https://hackerone.com/reports/135937)(!17286(https://gitlab.com/gitlab-org/gitlab-ce/issues/17286))中提供的修复方法,可以通过利用这个漏洞轻易绕过。 虽然GitLab引入了一个黑名单,但仍然会使用Resolv::getaddresses来解析用户提供的地址,然后将输出与黑名单中的值进行比较。那么,这意味着人们不能再使用某些地址,http://127.0.0.1和http://localhost/,而这些正是Mustafa Hasan在原始报告中使用的地址。通过利用这个绕过漏洞,攻击者就可以扫描GitLab intance的内部网络。
GitLab提供了一个补丁:https://about.gitlab.com/2017/11/08/gitlab-10-dot-1-dot-2-security-release/。
John Downey(链接地址:https://twitter.com/jtdowney)提供的private_address_check(链接地址:https://github.com/jtdowney/private_address_check)
报告的链接地址:https://github.com/jtdowney/private_address_check/issues/1
private_address_check(链接地址:https://github.com/jtdowney/private_address_check)是一个用来防御SSRF的Ruby Gem。实际上,真正的过滤代码位于lib / private_address_check.rb中。该程序首先尝试使用Resolv::getaddresses来解析用户提供的URL,然后将返回的值与黑名单中的值进行比较。同样,攻击者可以使用前面介绍的技术来绕过该过滤器。
# File lib/private_address_check.rb, line 32 def resolves_to_private_address?(hostname) ips = Resolv.getaddresses(hostname) ips.any? do |ip| private_address?(ip) end end
因此,HackerOne(链接地址:https://hackerone.com/reports/287245)也受此绕过漏洞的影响,因为它们也是通过private_address_check gem来防御“Integrations”面板上的SSRF攻击的:
https://hackerone.com/{BBP}/integrations.
令人遗憾的是,我无法利用这个SSRF漏洞,因为这里只包括一个过滤器绕过问题。不过,HackerOne仍然为这份安全报告提供了奖励,因为他们认为任何潜在的安全问题都应该得到重视,而这个绕过漏洞也是一个潜在的风险。
这个安全问题已在0.4.0版(链接地址:https://github.com/jtdowney/private_address_check/commit/58a0d7fe31de339c0117160567a5b33ad82b46af)中进行了修复。
未受该漏洞影响的应用程序和Gem
Arkadiy Tetelman(链接地址:https://twitter.com/arkadiyt)提供的ssrf_filter(链接地址:https://github.com/arkadiyt/ssrf_filter)
这个gem不会受到该漏洞的影响,因为它会检查返回的值是否为空。
# File lib/ssrf_filter/ssrf_filter.rb, line 116 raise UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty? irb(main):001:0> require 'ssrf_filter' => true irb(main):002:0> SsrfFilter.get("http://127.1/") SsrfFilter::UnresolvedHostname: Could not resolve hostname '127.1' from /var/lib/gems/2.3.0/gems/ssrf_filter-1.0.2/lib/ssrf_filter/ssrf_filter.rb:116:in `block (3 levels) in <class:SsrfFilter>' from /var/lib/gems/2.3.0/gems/ssrf_filter-1.0.2/lib/ssrf_filter/ssrf_filter.rb:107:in `times' from /var/lib/gems/2.3.0/gems/ssrf_filter-1.0.2/lib/ssrf_filter/ssrf_filter.rb:107:in `block (2 levels) in <class:SsrfFilter>' from (irb):2 from /usr/bin/irb:11:in `<main>'
Ben Lavender(链接地址:https://github.com/bhuga)提供的faraday-restrict-ip-addresses(链接地址:https://rubygems.org/gems/faraday-restrict-ip-addresses/versions/0.1.1)
这个gem使用的是Ruby核心开发团队推荐的Addrinfo.getaddrinfo。
# File lib/faraday/restrict_ip_addresses.rb, line 61 def addresses(hostname) Addrinfo.getaddrinfo(hostname, nil, :UNSPEC, :STREAM).map { |a| IPAddr.new(a.ip_address) } rescue SocketError => e # In case of invalid hostname, return an empty list of addresses [] end
小结
我要特别感谢Tom Hudson(链接地址:https://twitter.com/TomNomNom)和Yasin Soliman(链接地址:https://twitter.com/SecurityYasin)在我挖掘这个漏洞期间提供的帮助。
此外,在编写本文过程中,John Downey(链接地址:https://twitter.com/jtdowney)和Arkadiy Tetelman(链接地址:https://twitter.com/arkadiyt)也给予了积极的响应。其中,John Downey迅速为我们提供了补丁,而Arkadiy Tetelman则帮我弄清了为什么他们的gem不受这个问题的影响。
最后需要说明的一点是,这篇文章的源代码不是太讲究,敬请谅解。