~/

KVCloud (WeCTF 2020)

This solution was developed by teammates Jeff Delamare, Lydia Doza, and Evan Johnson (me) through several hours of remote collaboration.

The Challenge

Category Key Exploit
Web Server-side request forgery (SSRF)

Challenge info can be found here. Many thanks to the challenge authors and WeCTF organizers for putting this one together!

The target was a server running a vulnerable Flask application. The app’s “official” function was to serve as an intermediary between users and a Redis database, but the intended solution did not involve compromising the database.

Original prompt

Shou hates to use Redis by TCPing it. He instead built a HTTP wrapper for saving his key-value pairs.

Flag is at /flag.txt.

Hint: How to keep-alive a connection?

Handout: https://github.com/wectf/2020p/blob/master/kvcloud/handout.zip

Summary

The challenge handout was a zip archive containing source code for a small Flask app that implements a simple HTTP-based API to interact with a Redis database. The app provides ways to set and get specific keys. It also has some helper functions to interact with the backend database and a handler for a /debug route that executes user input as code (!!!). However, the /debug route handler refuses to do anything interesting unless it receives a POST request from 127.0.0.1 (i.e. localhost, the machine running the Flask app).

The Dockerfile in the handout instructs Docker to copy a file of the same name from the project directory. This implies that the flag on the server should be in /flag.txt. It also helpfully removes access to a bunch of Redis commands that could be abused to extract the flag. We took this as a hint that the easiest path to the flag would not be through Redis.

Helpful background knowledge

For reading the challenge source code

  • familiarity with Flask routing and the Request objects available inside route handlers

  • familiarity with Python 3.x, specifically:

    • format strings, i.e. "{}".format(5) becomes "5"

    • the ** operator to pass keyword arguments from a dict, i.e. some_function(1, **{'a': "xyz", 'b': 3}) becomes some_function(1, a="xyz", b=3)

For the exploit itself

  • HTTP request methods and headers

  • Python file I/O or use of the os module to run shell commands

Spotting vulns

exec stuck out like a sore thumb. It interprets a string as Python code and executes that code without a care in the world for what it might do. Knowing that, it was clear to us that if we could get the program to pass an input from us as the argument to exec, we could do pretty much anything.

However, inputs to that function had to come from HTTP requests originating (or appearing to originate) from the same machine. Spoofing the source IP of packets seemed plausible, but we felt there might be an easier way when we noticed a few key details:

  • redis_get and redis_set both dump strings read from query parameters in a request URL directly into the strings they send to the database.

  • redis_get and redis_set both allow custom destination addresses and ports to be passed as arguments, and the routes that use them pass the values from any URL query parameters named “redis_addr” and “redis_port” to these functions without any checks. So we could control where the command strings these functions construct by adding the appropriate query parameters to request URLs.

  • redis_get and redis_set send the command strings they construct directly through a TCP connection to the specified destination. (HTTP requests are also typically sent/received over TCP).

  • While both helpers always prefix their command strings with a hard-coded string, redis_get in particular starts its command string with "GET ". This correctly constructs a command for Redis, but it could also be the start of a HTTP GET request.

Combining those observations, we were very confident that we could craft a request URL that would cause the server to send itself HTTP requests from the redis_get function.

Putting the pieces together

From our initial observations, we had a possible way to execute arbitrary code and a way to make the target server send itself a GET request. However, the code path we needed for remote code execution (RCE) was only reachable by a POST request.

Jeff mentioned that HTTP request smuggling is a thing, and we started looking into that. A cursory glance at a few blogs seemed promising–following the GET with a POST was what we wanted to do. However, the examples we found didn’t work either on our local test instances or the challenge server (Hindsight: because it was SSRF, not request forgery!). As a result, it took some experimentation for us to make redis_get send two requests.

After some effort, we managed to get proof of concept on a local instance of the Flask app. Our POST request (appended to the GET constructed by abusing redis_get) could reach the exec call. However, our Python payload wasn’t getting through. Eventually, after a bit of research and much experimentation, we figured out the issue.

Payload breakdown

note: This is a cleaner, refined version of the solution we actually used. Our initial solution attempted to overwrite our copy of the flag in the database after we’d retrieved it. While we didn’t let it wait more than a few seconds, pausing execution in a Flask (single-threaded) web server is not a good thing to do, so I’d rather not encourage it by publishing that code. Also, it’s not needed to explain how our solution works.

Hijacking redis_get

The /debug route where a form field’s contents are passed to Python’s exec function rejects requests made from remote hosts, so we need to send a request to that route from the server itself. exec executes a string as Python code without safety checks, so it provides easy remote code execution–if we can get to it. To do that, we need to trick the server into sending itself a POST to /debug.

Send data to chosen destination

The function takes keyword arguments to specify alternative destinations for a string sent directly over TCP. By default, the destination is some Redis database server. However, the code handling the /get route allows the alternate destination to be set using query parameters in the request URL.

Turn a database command into a HTTP request

The redis_get function always prefixes the string it sends with ‘GET ’, and otherwise just uses whatever input the /get route found in the key query parameter. If the resulting string is sent over TCP to a Redis database server, the database will treat it as a command to get the value of some key. If it is instead sent to an HTTP server, it will be interpreted as an HTTP GET request! So setting the correct destination address and port for this function causes the Flask app to send itself a GET request.

The URL

http://kvcloud.sf.ctf.so:80/get?redis_port=80&redis_addr=localhost&key=[payload]

The payload needs to be URL encoded so it can be sent as the value of a request parameter. The target Flask app will un-encode the payload before passing it to redis_get. redis_addr=localhost and redis_port=80 set the custom destination address and port, respectively. The port needs to be the port on which the server handles HTTP requests. This is specified on the last line of app.py.

POSTing for RCE

The redis_get function sends our payload input to any destination we choose, but it always prefixes our input with GET! But a POST request is the only way to reach the exec call.

Following the mandatory GET request with a POST took some tuning, because we started out with the wrong mechanism (request smuggling) in mind. Ultimately, we found that we needed two sequential requests: a minimal GET request and a POST request carrying Python code to retrieve the flag.

Both requests get sent by the target server, to the target server. The GET request’s only job is to consume a hard-coded prefix in a format string and not make a mess, so it doesn’t need to request a particular URL. (We chose the URL for a site designed to respond slowly for app testing purposes.)

All that matters is that the POST request goes to the /debug route. The request will pass the origin IP check because it came from the server itself.

Request text

GET http://slowwly.robertomurray.co.uk/ HTTP/1.1
Host: robertomurray.co.uk

POST http://localhost/debug HTTP/1.1
Host: localhost
Keep-Alive: timeout=200 max=1000
Content-Type: application/x-www-form-urlencoded
Content-Length: 61

cmd=redis_set('ohplease', str(open('/flag.txt', 'rb').read())

The initial GET (with a space) is hard-coded into the redis_get function. Everything after those four characters is the string we needed passed as the key argument to redis_get. That entire string must be URL encoded.

Flag exfiltration

We tried to send data directly back over to the redis_get call using Flask’s send_file function, but couldn’t get it to work. Ultimately, we settled on using the Redis database itself, since we could retrieve and set keys using the target server’s intended functionality. The Python code needed for this is just redis_set('ohplease', open('/flag.txt', 'r').read()).

During competition, we used multiple statements separated by semicolons to avoid any confusion or extra complexity around newlines in the request construction. This was our first time smuggling a request and we were working hard enough to figure things out, so we didn’t want to have to think about any rules for passing newlines in a form field. We also used binary read mode in our initial solve because we were tired and in a hurry to get the flag before the CTF game ended.

After our payload script stored the flag in the Redis database, Lydia retrieved it using the /get route for its stated purpose. I then used the /set route to set a new value for the key we’d used.

How it works

open('/flag.txt', 'r') returns a handle to a file object in read (text) mode. That object’s read() method that gets all of the file’s contents as a string.

redis_set('ohplease', value) sends “SET ohplease " to the default destination, which is the Redis database server. This sets the value of key 'ohplease' to whatever string `value` represents. In this case, it's the contents of the flag file.

What I learned

My first SSRF!

I didn’t know about SSRF going into this challenge. Since our breakthrough arose from discussion of request smuggling, that was the term on my mind while solving.

My understanding after further review and reading is that our attack was not request smuggling, but rather simply sending two valid requests in sequence. Since we tricked the server into sending a request for us, this was a server-side request forgery (SSRF) attack. Request smuggling is different: it uses differences in the handling of malformed requests by a front-end and back-end server to sneak a request from the attacker to their target.

Resources

SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery

Request Smuggling: https://portswigger.net/web-security/request-smuggling