Cash Cache

Points: 496 (Dynamic, 11 solves)

Description:

Mr. Krabs has a new scheme to make money, the cash cache! However, he cheaped out on development by hiring Patrick so please make sure he did a good job!

Given: Source code

TL;DR

Abuse the request smuggling in the Python reverse proxy to reach the Javascript backend service and overwrite a pickled object in Redis that will be later loaded by the Python service, achieving RCE.

Analyzing the Services

This challenge consists of 4 services. The frontend container runs a nginx proxy that is configured to correctly set the X-Forwarded-For header. The cache-storage container is running the latest version of Redis.

The backend container is running two applications:

  • A Python application that serves as a reverse proxy. This application can be reached via the nginx proxy in the frontend container.
  • A Javascript application that is reachable via the Python reverse proxy in the same container.

The Python application implements a reverse proxy with some particularly interesting functionalities.

When parsing a HTTP request it looks for the Cash-Encoding header. The presence of this header triggers the parseCash function. The first thing I noticed when analyzing the code is that if we can somehow control the output of the function (more specifically the stream_text variable) we can trick the parseHTTPReq function to return an array with more than one request. This hints towards a case of Request Smuggling.

while (stream_text):
    ...
    if ("Cash-Encoding" in Headers and Headers["Cash-Encoding"] == "Money!"):
        body, stream_text, spent = parseCash(stream_text)
    else:
        body = stream_text
        stream_text = ""
    Headers['Content-Length'] = len(body)
    req = HTTPReq(method, route, version, Headers, body)
    Requests.append(req)

The other interesting functionality of this proxy consists of a caching system. To cache the requests this application makes use of the Redis service. It uses the uid cookie to identify a user. When a user makes a request it fetches the cached requests for that user from Redis and looks up the path of the current request to try to obtain a valid response. The behavior is very similar when storing a request in the user’s cache.

The interesting property of this cache is that all objects that are present in the Redis instance are base64 encoded pickled objects.

if (cookies and 'uid' in cookies and REDIS_CLIENT.exists(cookies['uid'])):
    cash_elem = pickle.loads(base64.b64decode(
        REDIS_CLIENT.get(cookies['uid'])))
    cached = cash_elem.get_resp(request.route)
    if (cached):
        cached.headers['X-Cache-Hit'] = "HIT!"
        cached.headers['X-CashSpent'] = cash_elem.spent
        cached.headers['X-CachedRoutes'] = len(cash_elem.resps)
        RESP += cached.get_raw_resp()
        continue

As for the Javascript service, it only contains 2 endpoints. This first one is a landing page that sets the X-Cache-UID header and the uid cookie. The /debug endpoint is a bit more interesting since it lets us set a value to an arbitrary key in the Redis instance. However, we cannot use this functionality directly since it checks if the last IP in the X-Forwarded-For header is 127.0.0.1 and as we saw earlier the nginx proxy was correctly setting this header which makes it impossible to reach in normal circumstations.

The Attack Chain

To build the attack chain I like to think backwards. First of all, we need to analyze what is our goal. We want to read the flag that is only present in the file system. Ideally, we want to achieve Remote Code Execution. If not possible we can also get away with an arbitrary file read.

To achieve this we can use a known pickle deserialization exploit. As per the pickle documentation here we can see that it has a big red popup hinting at the possibility of executing arbitrary code if we can control the data that is being deserialized (or unpickled if you want the funny term).

By looking further down in the documentation we can see that when a pickled object is unpickled the __reduce__ magic function is called. Therefore, to execute our code we can just pickle an object that implements the __reduce__ function and this function will be loaded and executed by the Python interpreter in the challenge’s server.

The next problem in the chain is to control the data that is being unpickled. To achieve this we need to poison an entry in the Redis instance with our arbitrary data. We can leverage the /debug endpoint in the Javascript application to achieve this once again.

Now we need to move down another step in our attack chain. How can we reach the /debug endpoint if it verifies the X-Forwarded-For header and we cannot control that header?

To achieve this we need to abuse the Cash-Encoding logic and smuggle a request inside another.

Our goal is to send a specially crafted request in which the nginx proxy will think it is a single request but the python reverse proxy will think that it contains two requests.

This is possible due to the differences in parsing the HTTP requests between nginx and the Python application. By quickly looking at the code in parseHTTPReq we can observe that it does not use the Content-Length header to calculate the body size. Therefore, we can hide the second request in the body of the first one.

The only available option to make the Python application think we sent multiple requests in a single connection is by abusing the parseCash logic as I referred to earlier. This function looks at the first line of the body and expects it to have the following format: AMOUNT UNIT. It then tries to convert the AMOUNT part into a float.

MINIMUM_CASH = 10000000.0

def parseCash(stream_text):
    spent = 0
    body = ""
    while (spent < MINIMUM_CASH):
        cur, _, stream_text = stream_text.partition("\r\n")
        amount, units = cur.split(' ')
        if (units == "DOLLARS"):
            amount = float(amount)
        elif (units == "CENTS"):
            amount = float(amount)/100
        else:
            raise Exception("I can't understand the units!")
        index = round(amount) if amount <= len(stream_text) else len(stream_text) - len(cur)
        cur = stream_text[:index]
        stream_text = stream_text[index:]
        if (len(cur) < amount or amount < 0):
            raise Exception("Are you trying to steal from me?")
        spent += amount
        body += cur
    return body, stream_text, spent

There are several problems with this code. To bypass the while loop check we would need to send ~10MB which is not ideal since the default maximum request size in nginx is 1MB. We also cannot easily trick the checks in the ternary operator and the last if statement.

However, to solve this problem we can use a special form of float called Not a Number or nan. By evaluating float("nan") we can check that a nan float is returned. This float has very interesting properties since float("nan") < 123 is false but float("nan") > 123 and float("nan") == 123 are also false. Therefore, all of the checks in the parseCash function are bypassed.

Sweet! Now we can smuggle a request, right? Well… Kinda. The problem is that we can only smuggle exactly 11 bytes. If we check the ternary operator we can see that index will be len(stream_text) - len(cur) and therefore stream_text[index:] will only yield the last 11 bytes of the body (which is the size of the cur string nan DOLLARS).

We face a roadblock once again and this is the place where I spent most of my time. I was messing around with the Python interpreter trying to inject characters in the float constructor and I accidentally hit the tab key. And to my surprise it happily evaluated the weird string to nan.

Python 3.11.6 (main, Oct  8 2023, 05:06:43) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> float("     nan")
nan

If we can control the length of the cur string by injecting \t characters we can control how many bytes we want to smuggle!

So the chain will look as follows:

  1. Issue a legitimate request to cache an object and retrieve its cache uid cookie
  2. Craft a pickle object that contains a __reduce__ function that reads the flag and exfiltrates it to an attacker-controlled server
  3. Craft the smuggled request to the /debug endpoint that contains the X-Forwarded-For: 127.0.0.1 header and sets the cache entry for the uid key to the base64 encoded pickled object that we produced in the above step
  4. Craft a request that contains the Cash-Encoding: Money! header and abuses the parseCash logic as explained above and contains the smuggled request in its body
  5. Issue another request containing the uid cookie to trigger the deserialization of our controlled data
  6. Profit

Bragging Time

We got first blood on this challenge. Wooooo!

Full Exploit

exploit.py