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 handlersfamiliarity with Python 3.x, specifically:
format strings, i.e.
"{}".format(5)
becomes"5"
the
**
operator to pass keyword arguments from adict
, i.e.some_function(1, **{'a': "xyz", 'b': 3})
becomessome_function(1, a="xyz", b=3)
For the exploit itself
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
andredis_set
both dump strings read from query parameters in a request URL directly into the strings they send to the database.redis_get
andredis_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
andredis_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 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