Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions lib/aikido/zen/scanners/ssrf/private_ip_checker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ def resolved_in_current_context
# Matches strings that contain only characters that appear in some
# form of IP address.
#
# An IPv6 zone ID (e.g. "%eth0") may follow the address.
#
# Hostnames are not expected to match, allowing `parse_address` to
# skip the `Socket.getaddrinfo` call.
ADDRESS_REGEXP = /\A[0-9a-fx:.]+\z/i
ADDRESS_REGEXP = /\A[0-9a-fx:.]+(?:%[^%\s]+)?\z/i

# Parses `address` as an IP address, in any form that is accepted
# by `getaddrinfo`:
Expand All @@ -96,6 +98,7 @@ def resolved_in_current_context
# * Plain integer IPv4 notation, in decimal, octal, or hexadecimal
# (e.g. "2130706433", "017700000001", "0x7f000001")
# * Shorthand dotted IPv4 notation (e.g. "127.1")
# * IPv6 notation with zone ID (e.g. "fe80::1%eth0")
#
# Delegates to `Socket.getaddrinfo` with the `AI_NUMERICHOST` flag,
# which never performs a DNS lookup.
Expand All @@ -105,8 +108,10 @@ def resolved_in_current_context
def parse_address(address)
return nil unless address.is_a?(String) && ADDRESS_REGEXP.match?(address)

Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST)
.map { |info| IPAddr.new(info[3]) }
Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST).map do |info|
# `IPAddr.new` only accepts a zone ID on Ruby 3.1+.
IPAddr.new(info[3].partition("%").first)
end
rescue SocketError
nil
end
Comment on lines 108 to 117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High - RFC 6874 encoded zone IDs still bypass private-IP detection

A caller sends a valid RFC 6874 URL such as http://[fe80::1%25lo] through a supported HTTP sink. The URI hostname retains the encoded %25, but parse_address forwards it unchanged to getaddrinfo; when that scope lookup fails, resolution falls through without checking the underlying IPv6 address. The scanner therefore treats a link-local/private IPv6 target as external and permits an SSRF when the HTTP client accepts and connects the encoded URL.

Show fix
Suggested change
def parse_address(address)
return nil unless address.is_a?(String) && ADDRESS_REGEXP.match?(address)
Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST)
.map { |info| IPAddr.new(info[3]) }
Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST).map do |info|
# `IPAddr.new` only accepts a zone ID on Ruby 3.1+.
IPAddr.new(info[3].partition("%").first)
end
rescue SocketError
nil
end
def parse_address(address)
return nil unless address.is_a?(String) && ADDRESS_REGEXP.match?(address)
address = address.sub(/%25/i, "%")
Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST).map do |info|
# `IPAddr.new` only accepts a zone ID on Ruby 3.1+.
IPAddr.new(info[3].partition("%").first)
end
rescue SocketError
nil
end

More info - Reply on this comment to give feedback or ignore the issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The builtin URI class does not implement RFC 6874, and raises URI::InvalidURIError when given a URI containing an IPv6 address with a zone ID to parse. How best to accommodate other URI implementations is currently being considered.

Expand Down
12 changes: 12 additions & 0 deletions test/aikido/zen/scanners/ssrf/private_ip_checker_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ def refute_private(address)
refute_private "0X01020304" # 1.2.3.4
end

test "detects link-local IPv6 addresses with a numeric zone ID" do
assert_private "fe80::1%1"
end

test "detects link-local IPv6 addresses with a named zone ID" do
interface_names = Socket.getifaddrs.map(&:name)
loopback = %w[lo lo0].find { |name| interface_names.include?(name) }
skip "no loopback interface found" unless loopback

assert_private "fe80::1%#{loopback}"
end

test "detects _actually_ private (RFC 1918/RFC 4193) addresses" do
# 10.0.0.0/8
assert_private "10.0.0.0"
Expand Down
Loading