Summary
This is a security advisory for a bug that I discovered in Resolv::getaddresses
that enabled me to bypass multiple Server-Side Request Forgery filters. Applications such as GitLab and HackerOne were affected by this bug. The disclosure of all reports referenced in this advisory follow HackerOne’s Vulnerability Disclosure Guidelines.
This bug was assigned CVE-2017-0904.
Vulnerability Details
Resolv::getaddresses
is OS-dependent, therefore by playing around with different IP formats one can return blank values. This bug can be abused to bypass exclusion lists often used to protect against SSRF.
💻 Machine 1 | 💻 Machine 2 |
---|---|
ruby 2.3.3p222 (2016-11-21) [x86_64-linux-gnu] | ruby 2.3.1p112 (2016-04-26) [x86_64-linux-gnu] |
💻 Machine 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"]
💻 Machine 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")
=> [] # 😱
This issue is reproducible in the latest stable build of 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")
=> []
Proof of concept
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
Root cause
The following section describes the root cause of this bug. I have added some comments in the code snippets to help the reader follow along.
When we run irb in debug mode (irb -d
) the following error is returned:
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
=> []
So the exception stems from fetch_resource()
[1]. The “NXDOMAIN” response indicates that the resolver cannot find a corresponding PTR record. No surprise there, since, as we will see later on, resolv.rb
uses the operating system’s resolver.
# 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
Now the following code snippets demonstrate why Resolv::getaddresses
is OS-dependent.
getaddresses
takes the address (name
) and passes it on to each_address
where once it has been resolved it is appended to the ret
array.
# 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
runs the name
through @resolvers
.
# 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
is initialised in initialize()
.
# File lib/resolv.rb, line 109
def initialize(resolvers=[Hosts.new, DNS.new])
@resolvers = resolvers
end
Further on, initialize
is actually initialised by setting config_info
to nil
which uses the default configuration in this case /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
Here is the default configuration:
# File lib/resolv.rb, line 959
def Config.default_config_hash(filename="https://f.cybernoz.com/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
This demonstrates that Resolv::getaddresses
is OS-dependent and that getaddresses
returns an empty ret
array when supplied with an IP address that fails during a reverse DNS lookup.
Mitigation
I suggest staying away from Resolv::getaddresses
altogether and using the Socket
library.
irb(main):002:0> Resolv.getaddresses("127.1")
=> []
irb(main):003:0> Socket.getaddrinfo("127.1", nil).sample[3]
=> "127.0.0.1"
The Ruby Core dev team suggested using the same library.
% ruby -rsocket -e '
as = Addrinfo.getaddrinfo("192.168.0.1", nil)
p as
p as.map {|a| a.ipv4_private? }
'
[#, #, #]
[true, true, true]
Affected Applications and gems
GitLab Community Edition and Enterprise Edition
Link to report: https://hackerone.com/reports/215105
The fix for Mustafa Hasan’s report (!17286) could be easily bypassed by abusing this bug. GitLab introduced an exclusion list, but would resolve the user-supplied address using Resolv::getaddresses
and then compare the output to the values in the exclusion list. This meant that one could no longer use certain addresses such as http://127.0.0.1
and http://localhost/
, which Mustafa Hasan used in the original report. The bypasses allowed me to scan a GitLab intance’s internal network.
GitLab have provided a patch: https://about.gitlab.com/2017/11/08/gitlab-10-dot-1-dot-2-security-release/.
private_address_check by John Downey
Link to report: https://github.com/jtdowney/private_address_check/issues/1
private_address_check is a Ruby gem that helps prevent SSRF. The actual filtering takes place in lib/private_address_check.rb
. The process starts by attempting to resolve the user-supplied URL with Resolv::getaddresses
and then compares the returned value with a the values in the blacklist. Once again I was able to use the same technique as before with GitLab to bypass this filter.
# 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
Consequently, HackerOne was affected by this bypass, because they use the private_address_check gem to prevent SSRF on the “Integrations” panel: https://hackerone.com/{BBP}/integrations
.
Unfortunately, I was unable to exploit this SSRF and therefore the issue only consisted of a filter bypass. HackerOne still encouraged me to report it, because they take any potential security issue into consideration and this bypass demonstrated a potential risk.
This issue was patched in version 0.4.0.
Unaffected applications and gems
ssrf_filter by Arkadiy Tetelman
This gem is not vulnerable, because it checks if the value returned is empty.
# 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 '
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 '
from (irb):2
from /usr/bin/irb:11:in `'
faraday-restrict-ip-addresses by Ben Lavender
This gem uses Addrinfo.getaddrinfo
as recommended by the Ruby Code dev team.
# 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
Conclusion
The author would like to acknowledge the help provided by Tom Hudson and Yasin Soliman during the discovery of the bug.
Both John Downey and Arkadiy Tetelman were extremely responsive. John Downey was able to immediately provide a patch, and Arkadiy Tetelman helped me figure out why their gem was not affected by the issue.
Finally, whatever you do, please do not view the source code of this write-up.
Update (Friday, 10 November 2017): I expanded the “Root cause” section in order to better explain the actual issue.