SSRF to Redis CTF Solution
by Steve Marx on
The HashCache Capture the Flag (CTF) challenge has fallen to Pierre Rosenzweig, a pentester and cybersecurity consultant at Wavestone France. Congratulations, Pierre!
In this post, I’ll describe the solution step by step. If you still want to try to solve the challenge yourself, stop reading now. Spoilers are coming.
I just solved @smarx challenge !😊🎉
— Pierre ROSENZWEIG (@pierrosen) September 7, 2020
What a great challenge, enjoyed and learned a lot !
Try it here guys : https://t.co/dlGD52yAbN
It's time for the write-up...😉 pic.twitter.com/okxvDQ6eLQ
The application to be hacked
If you haven’t already, take a look at the README for the challenge. The high-level goal is to read the flag
key from Redis.
The system you’re attacking is pretty simple. There’s a web application written in Nim and a Redis server that is used to cache values (and hold the flag
).
The HashCache web application can be used to get the hash of the content at a given URL. It caches these hashes for 5 minutes. The web application has two endpoints:
/
just returns the contents ofindex.html
. This is just an HTML form that’s used to invoke/hash
./hash
fetches a given URL, using a specified HTTP method and an optionalAuthorization
header. It then hashes the body of the HTTP response and returns that. It also stores the resulting hash in Redis under the keyhash:<SHA-1 hash of the query string>
.
Server-side request forgery
Any time you see a system that makes user-specified HTTP requests, you should immediately think about server-side request forgery (SSRF) attacks.
The goal of an SSRF attack is to reach a network resource that you couldn’t otherwise reach, such as admin interfaces and databases. Those are often restricted to local network access, so you can’t attack them directly. In an SSRF attack, you (ab)use the target application itself to make the network connection for you.
In the case of the HashCache CTF, you have a web application that will happily make any HTTP request you ask it to. This means you can request, for example, http://redis:6379
to reach the Redis server. (If you’re running the system locally, you’ll probably be using http://127.0.0.1:6379
instead.)
Redis protocol
The existence of an SSRF is promising, but the only protocol allowed is HTTP, and Redis doesn’t accept HTTP requests. The task now becomes crafting an HTTP request that will look to Redis like valid instructions that help reveal the flag.
Redis speaks the REdis Serialization Protocol (RESP), a fairly simple text-based protocol. A good first step is to examine an HTTP request and seeing Redis interprets it. Below, I’m running the app with nimble run
, and I’m using netcat
to listen on port 12345. Then I can browse to the app’s UI on port 8080 and ask it to fetch the URL http://127.0.0.1:12345
. Here’s the resulting HTTP request:
$ nc -l -p 12345
GET / HTTP/1.1
Host: 127.0.0.1:12345
Connection: Keep-Alive
content-length: 0
user-agent: Nim httpclient/1.2.6
Now I can connect to Redis and type those lines one by one to see what happens:
$ nc 127.0.0.1 6379
GET / HTTP/1.1
-ERR wrong number of arguments for 'get' command
Host: 127.0.0.1:12345
The RESP protocol uses \r\n
(CRLF) to separate different commands. This is the same line ending used by HTTP, so Redis treats each line of the HTTP request as a separate command.
The response to the first command is no surprise. Redis has a command called GET
, but it only accepts a single parameter.
The response to the second command is a problem. Redis hangs up the connection! This is because Redis added protection against this sort of SSRF attack back in 2017. To avoid being duped by HTTP requests, Redis hangs up as soon as it sees the command POST
or Host:
(both case insensitive).
What can you do before the Host:
line?
Digging through Nim’s httpClient
code, you can see that a Host:
header is always sent as the second line of the HTTP request, so Redis will always hang up after this line is sent.
That means if you’re going to do something, it has to be part of the first line. You have control of three different parameters:
url
, which impacts the first line of the HTTP request as well as theHost
headermethod
, which is the very first thing sent as part of the requestauthorization
, which is a header that always appears after theHost
header
CRLF injection
You may have heard of CRLF injection, which is where an attacker injects their own CRLF into some field of an HTTP request. This can modify the HTTP request. For example, suppose you supplied http://127.0.0.1:6379/foo\r\nSteve was here!\r\n
as the URL. The resulting HTTP request would be something like this:
GET /foo
Steve was here!
HTTP/1.1
Host: 127.0.0.1:6379
This looks very promising because it lets you sneak in commands, on lines by themselves, before the Host:
line.
Unfortunately, Nim is one step ahead of you. httpClient
explicitly asserts that the URL has no carriage return or line feed characters. That assert wasn’t always there, leading to CVE-2020-15693 in Nim 1.2.4, but it’s fixed in Nim 1.2.6, the version the CTF uses.
CRLF injection in the HTTP method
Nim blocked you from using the URL, but remember that you also control the HTTP method, which is right at the beginning of the HTTP request.
This turns out to work. If you use GET\r\nSteve was here!\r\n
as your method
parameter, you’ll get this HTTP request:
GET
STEVE WAS HERE!
/ HTTP/1.1
Host: 127.0.0.1:6379
Congratulations! You can now send arbitrary commands to Redis. Well, arbitrary commands with one caveat. The HTTP method is always converted to uppercase. This is actually a problem for collecting the flag, which is under the lowercase key flag
.
My intention was that the challenge cannot be solved solely by injecting something into the method
parameter, but it’s possible I missed something. If you can think of a way to do it with just all-uppercase commands, please let me know!
CRLF in the Authorization
header
Nim’s httpClient
doesn’t prevent CRLF injections into headers either, so you can use the same trick there. An authorization
parameter of \r\nSteve was here!\r\n
yields this request:
GET / HTTP/1.1
Host: 127.0.0.1:12345
Connection: Keep-Alive
content-length: 0
user-agent: Nim httpclient/1.2.6
authorization:
Steve was here!
This is nice because it’s another place to inject Redis commands, but it occurs after the Host:
line, so Redis will hang up before it even sees it.
Putting it all together
Recapping:
- The URL lets you reach Redis.
- The method lets you do CRLF injection, but it converts everything to uppercase.
- The
authorization
header lets you do CRLF injection too, but this appears after thehost
header, and Redis hangs up as soon as it sees that.
There’s no way to prevent the host
header from being sent, and there’s no way to use the authorization
header if Redis sees the host
header first.
The key is that we need Redis to not see the host header. Fortunately, the Redis protocol gives us a way to do that. I said earlier that the Redis protocol uses CRLF line endings, but Redis supports arbitrary binary strings, so it has to somehow accept strings containing those characters.
The Redis protocol uses “bulk strings”, which are length-prefixed strings. When Redis sees a bulk string, it reads the given number of bytes without attempting to parse them. Using this, you can get Redis to skip past the host
header altogether.
When using explicit RESP encoding, a Redis command is an array, where the first element is a string command and subsequent elements are parameters. An array of five elements is introduced with *5\r\n
, and then the next 5 things parsed are the elements of the array. A bulk string of size 8 is introduced with $8\r\n
. The next 8 bytes are the content of that string, and the next two bytes have to be \r\n
to terminate the bulk string.
We can put those things together to get Redis to view the host
header as just part of a string. Here are the parameters:
url = "http://127.0.0.1:6379"
method = "*1\r\n$20\r\n"
authorization = "\r\nGET flag\r\n"
I picked the length 20 (in $20
) somewhat arbitrarily. It has to be long enough to jump past at least the h
in host:
, and it has to be short enough that Redis will still see my command GET flag
.
Here’s what that looks like URL-encoded into a curl
command:
curl 'http://127.0.0.1:8080/hash?url=http://127.0.0.1:6379&method=*1%0D%0A$20%0D%0A&authorization=%0D%0AGET%20flag%0D%0A'
And here’s the resulting HTTP request:
*1
$20
/ HTTP/1.1
Host: 127.0.0.1:12345
Connection: Keep-Alive
content-length: 0
user-agent: Nim httpclient/1.2.6
authorization:
GET flag
But there’s one final wrinkle… the web application doesn’t actually give you the response body! The above instructs Redis to give up the flag, but there’s no way for you to see it.
Data exfiltration
The last step of the puzzle is to craft the right payload so the flag can be exfiltrated.
There are a lot of potential options here, but I tried to eliminate most of them in redis.conf
. Without Lua scripting and file access, you’ll have to find an application-specific way to read the data.
The challenge app only provides one way to read data from Redis. When you make a request to the /hash
endpoint, the query string is SHA-1 hashed, and then the key hash:<SHA-1 hash>
is checked. If there’s a value there, it’s returned. So if you can find a way to get the value of the flag
key into a key like hash:<SHA-1(foo=bar)>
, you can then just request /hash?foo=bar
to retrieve the flag.
The RENAME
command fits the bill. Instead of trying to directly GET
the flag, rename it to a hash you know you can access.
Below I’ve used the SHA1 hash of “gimme=now”, which is 37573C6A2DE58DFBD1C7507016D45291CBEFC92C
. Be sure to convert your hash to uppercase because that’s what Nim does. Also be careful to use the right Redis hostname. If you’re running locally, outside of Docker, then you just want to use 127.0.0.1
or wherever you installed Redis. But in the deployed version or in a local version you ran with docker-compose
, you’ll want to use redis
as your URL hostname because that’s what the Docker setup uses.
$ curl 'http://hashcache-ctf.smarx.com/hash?url=http://redis:6379&method=*1%0D%0A$20%0D%0A&authorization=%0D%0ARENAME%20flag%20hash:37573C6A2DE58DFBD1C7507016D45291CBEFC92C%0D%0A'
ERROR: Failed to retrieve content.
$ curl http://hashcache-ctf.smarx.com/hash?gimme=now
flag{SSRF_2_R3d1s}
Takeaways
SSRF attacks exploit a server-side component that will contact protected endpoints on behalf of an attacker. If your application makes network requests, be careful about what parameters you let an attacker control. If you have to support potentially dangerous URLs, consider forwarding requests through a proxy server that’s isolated from your internal network.
Just because an attacker can only make HTTP requests doesn’t mean they can’t exploit non-HTTP services. Remember that an HTTP request is just some bytes sent over TCP. Many protocols, particularly text-based ones, can be tricked into handling an HTTP request as though it were something else.
Use passwords, even for internal services. This CTF would have been impossible if I had configured Redis to require a password on all inbound connections.
That was fun!
It was more work than I expected to craft this CTF challenge, but I had a lot of fun doing it. I also learned a bit as I went. I knew I wanted to show an SSRF attack to Redis, but I hadn’t anticipated a few of the defenses Nim and Redis have in place. This made the solution a little trickier than I had initially envisioned, but part of the reward of a good CTF challenge is overcoming those real-world stumbling blocks.
For those of you who tried to complete the challenge, thank you! I hope to make more of these challenges in the future, so please subscribe to the RSS feed and sign up for the newsletter so you don’t miss them.