Server-Side Request Forgery is a web security vulnerability in which we attempt to trick the server to access resources that we, the client who did the request, would normally not be able to access. In practice this usually means trying to access other resources within the network the server is running in or other services on the same host.
This usually happens when there’s a way for an attacker to control the URL a server is going to access. This is fairly prevalent in federated protocols as we often need to go in and dereference resources (like media) but can happen in all kinds of scenarios if a URL can somehow be passed into the backend.
A very common target for this are the various metadata APIs that are prevalent
in cloud environments and running on http://169.254.169.254
. Some databases
provide REST APIs that are also an interesting target, but it can also be used
to explore a network and trying to access services running in private IP
address space that would otherwise be unaccessible from the outside.
In most cases backends might filter out URL/requests directly containing an IP
address, but when the attacker can control the domain they can also control the
DNS resolution. So even though the request might be for https://example.net
,
if the attacker controls the DNS for example.net
they can make that lookup
return 10.0.0.1
.
It’s also important to note that these protections should not be limited to IP addresses only. You should also check the port you’re connecting to. If you don’t ever expect to connect to something over UDP, it’s probably worth checking for that too.
Implementing SSRF protections thus usually comes down to:
- Do the DNS resolution so you get the IP you will connect to
- Check which protocols, ports and IPs are permitted or denied and limit the request accordingly
Which destinations to block
Knowing all of this, how do we then get the IP addresses we should block? A few immediately come to mind like the RFC 1918 Private Address ranges. You’ll already be familiar with them:
10.x.x.x/8
192.168.0.x/24
172.16.0.0/12
(172.16.0.0
-172.31.255.255
)
But, there’s a ton more special IP ranges that aren’t globally routable unicast
addresses. 127.x.x.x/8
comes to mind for example, 198.51.100.x/24
which is
one of the “for documentation purposes” ranges or multicast 224.0.0.0/4
(224.0.0.0
- 239.255.255.255
). There’s more and then of course there’s
IPv6. These special purpose networks aren’t static either, every now and then a
new one gets defined and sometimes an existing one gets reused for something
else.
Someone: But Daenney, surely nobody would actually use a reserved prefix like 198.18.0.0/15 for something other than what the RFC dictates? And surely the OS wouldn’t actually route packets anywhere for a prefix that has explicitly been qualified as an invalid destination or non-forwardable?
Me: Oh sweet summer child
It’s also very easy to forget certain special cases, like 0.x.x.x/8
or IPv6’s
special destination ::/128
. With these special cases, behaviour can
also differ between the Linux kernel, the BSDs, Windows and macOS. So depending
on which platform you run your backend there may or may not be an extra gotcha
or two lurking in the dark.
Looking around at different SSRF modules in Go and other languages like Python and Ruby, we can see that what ends up on the block list seems to largely be based on what the maintainer managed to come up with. Over time other folks have contributed additional ranges so most implementations are reasonably complete, but often don’t cover everything and sometimes disagree between them. Many implementations are complete enough for IPv4 but are generally not correct for IPv6 and the special IPv4-mapped-IPv6 addresses are usually not handled correctly either.
This is an intermezzo about some of the special ranges. What’s stated in this section applies to the Linux kernel. The behaviour on the BSDs, macOS and Windows might be different. Feel free to skip this bit.
IPv4 has the special 0.0.0.0/32
IP meaning “this machine on this network” and
IPv6 has the roughly equivalent ::/128
“unspecified address”. Because both of
these addresses are defined as not valid in an IP packet for the destination
and that they should not be forwarded, the kernel handles them specially. When
we hand off a packet to the kernel to send with either of those destinations,
the kernel substitutes them for localhost. Since they’re now destined for
localhost, they get sent out on the loopback interface and never leave your
machine. This makes them functionally equivalent to 127.0.0.1
and ::1
so
you want to be sure to block them instead of letting them reach the kernel.
However, even though 0.0.0.0/8
, the subnet that contains 0.0.0.0/32
is also
defined as an invalid destination and not forwardable, it does not receive that
same treatment. This means that a packet with destination 0.0.0.1
will
actually leave your system. Depending on how the router at your edge is
configured, this may make it all the way to your ISP until someone or something
decides that “actually, there’s no route for this prefix so bye”. Since that’s
fairly useless, we can block all of 0.0.0.0/8
to begin with, which also
covers 0.0.0.0/32
.
I’m not sure why this is different, but many TCP stacks have been around forever and have a lot of history in them. Even if we could techincally fix this, it might break something somewhere so folks might be hesitant to do so. Having too many of these checks in the kernel can also be problematic, since ranges can be repurposed over time and then we suddenly wouldn’t be able to connect to them without updating our kernel. This is not likely to happen for this particular range since that would break the world.
How can we do better
Thankfully the Internet Assigned Numbers Authority is here to save us. As with many things, IANA maintains lists (registries) of known “things”. These things are usually numbers, surprise, that in a certain contexts have special meaning. These numbers in turn usually come from IETF RFCs. There’s lists for well-known port numbers, TLS cipher suites, OIDs in use in SNMP and many other things.
For our purposes, IANA maintains the IPv4 and IPv6 Special Purpose Registries which contain most, yes most not all, IP ranges you probably don’t want to allow connections to. Aside from the prefixes in these registries we also don’t want to allow connections to IPv4 and IPv6 multicast addresses.
Some of the prefixes listed in the IANA registries also overlap, as they call out each subnet that has been assigned specific meaning. This means that in our case we also need to reduce this list down to the biggest prefix that encompasses them in order to limit the amount of prefixes we need to check.
Go
In Go, we can use the Control
method on a net.Dialer
to
prevent a connection to a destination from being established. The Control
method sits at the perfect point in time in the chain. DNS resolution has
already taken place so we have an IP, but we haven’t connected to the
destination yet.
Since we’re using the net.Dialer
for this, the protections apply to any
protocol using TCP or UDP. It’s not limited to HTTP.
Using the
Control
method this way is not what it was initially designed for. This came up in a discussion in the Go issue tracker, but no other hook currently exists for this purpose either. Every SSRF mitigation solution for Go thus ends up using it and there doesn’t really seem to be an issue with doing so in practice.
The SSRF package I’ve recently created does exactly that, but it differs from other implementations in that most prefixes on that list are generated directly from the IANA registries. A generator is also included that runs twice a month through GitHub Actions to keep these up to date. Running it twice a month is overkill because these things only change every couple of years, but there’s no harm in it. It also ensures we exercise this automation regularly which is important to ensure things keep working.
My package makes use of the new netip
module in Go 1.18. This package makes
life a bit easier and also correctly classifies IPv4-mapped-IPv6 addresses as
IPv6, instead of the net
package which sees them as IPv4. This works out
great in our case because that prefix is contained in the IPv6 Special Purpose
Registry, not the IPv4 one. One less special case to deal with.
It also provides ways to explicitly allow an otherwise blocked prefix, add additional blocked prefixes and control which ports and protocols can be used.
Conclusion
Implementing SSRF protections is an important aspect of keeping your services secure on the internet. Many packages exist in different languages to help with this but they all seem to have an artisanally curated list of prefixes. Many don’t implement the right protections for IPv6 and certain especially tricky ranges whose implications might vary across OS are also often forgotten about.
The SSRF package I’ve published for Go tries to avoid all these issues by using the IANA registries to create the denylists. A number of additional prefixes are added to complete the set and the package is kept up to date by regenerating the prefixes from the registries on a regular cadence.