[Bug / Security] Dead Auth Guard in Challenge Check & Hint Endpoints: Anonymous Sessions Bypass Attempt Tracking and Hint Consumption
Summary
Two challenge-interaction endpoints : POST /api/v1/challenges/{challenge_id}/check and
POST /api/v1/challenges/{challenge_id}/hint :contain an authentication guard that
can never fire. The guard reads:
if not session_context:
raise HTTPException(status_code=401, detail="Authentication required")
But session_context is injected via the get_session_context FastAPI dependency
(finbot/core/auth/middleware.py L156–161), which always returns a valid
SessionContext object : it simply returns request.state.session_context, which the
SessionMiddleware sets for every request including anonymous/temporary sessions.
Because session_context is never None or falsy, the if not session_context
condition always evaluates to False. The guard is dead code. Unauthenticated
visitors (temporary session, no email bound) can call both endpoints freely.
Affected Files & Lines
| File |
Line(s) |
Dead Guard |
finbot/apps/ctf/routes/challenges.py |
222–223 |
check_challenge endpoint |
finbot/apps/ctf/routes/challenges.py |
268–269 |
use_hint endpoint |
Root Cause : Why the Guard Can Never Fire
get_session_context is defined in finbot/core/auth/middleware.py:
# middleware.py: L156–161
async def get_session_context(request: Request) -> SessionContext:
"""FastAPI dependency to get normal session context
- This is used for routes that don't explicitly require authentication
- May or may not be bound to an email address (temporary vs persistent)
"""
return request.state.session_context
And SessionMiddleware.dispatch unconditionally sets request.state.session_context
for every request (temporary or not) before the route handler runs:
# middleware.py: L116–125
new_session = session_manager.create_session(...)
return new_session, "session_created"
There is no code path in the middleware where request.state.session_context is left
unset or set to None. Therefore, get_session_context always returns a truthy
SessionContext, and the guard if not session_context evaluates to False 100% of
the time.
Vulnerable Code
check_challenge (challenges.py L210–258)
@router.post("/challenges/{challenge_id}/check", response_model=CheckResult)
def check_challenge(
challenge_id: str,
session_context: SessionContext = Depends(get_session_context), # ← weaker dep
db: Session = Depends(get_db),
):
if not session_context: # ← DEAD CODE: always False
raise HTTPException(status_code=401, detail="Authentication required")
...
progress_repo.record_attempt(challenge_id) # ← runs for temporary sessions
...
use_hint (challenges.py L261–303)
@router.post("/challenges/{challenge_id}/hint", response_model=HintResponse)
def use_hint(
challenge_id: str,
session_context: SessionContext = Depends(get_session_context), # ← weaker dep
db: Session = Depends(get_db),
):
if not session_context: # ← DEAD CODE: always False
raise HTTPException(status_code=401, detail="Authentication required")
...
updated_progress = progress_repo.use_hint(challenge_id, cost) # ← runs for temp sessions
...
Comparison with Other Endpoints
Every other endpoint that guards personal-data writes uses the correct dependency:
| Endpoint |
Auth Dependency |
GET /api/v1/profile |
get_authenticated_session_context ✅ |
PUT /api/v1/profile |
get_authenticated_session_context ✅ |
PUT /api/v1/profile/featured-badges |
get_authenticated_session_context ✅ |
GET /api/v1/sidecar |
get_authenticated_session_context ✅ (fixed in #511/#512) |
POST /api/v1/challenges/{id}/check |
get_session_context + dead if not check ❌ |
POST /api/v1/challenges/{id}/hint |
get_session_context + dead if not check ❌ |
The correct dependency, get_authenticated_session_context, raises HTTP 401 whenever
session_context.is_temporary is True. The manual if not session_context guard was
clearly intended as a substitute, but it is structurally broken because the dependency
never yields None.
Impact
1. Unauthenticated Attempt Inflation (check_challenge)
progress_repo.record_attempt(challenge_id) is called inside check_challenge for any
caller that has no prior progress record. Because temporary sessions are accepted:
- A bot or automated script can spam
POST /api/v1/challenges/{id}/check without
ever binding an email address.
- Each call increments the attempt counter stored in
UserChallengeProgress under the
temporary session's user_id.
- While this doesn't affect other users' progress, it creates phantom progress rows
in the database for throwaway sessions, polluting the UserChallengeProgress table
indefinitely.
- The intended design is that only authenticated (email-bound) users have meaningful
challenge progress tracked.
2. Unauthenticated Hint Consumption (use_hint)
progress_repo.use_hint(challenge_id, cost) is called inside use_hint. For a
temporary session:
- A temporary user can call
POST /api/v1/challenges/{id}/hint repeatedly.
- The server returns real hint text (
hint["text"]) for each hint that hasn't been used
under that temporary session.
- The hint text is fully revealed : there is no sanitization for unauthenticated
callers.
- The
hints_cost deduction is recorded against the temporary session's progress row
(which is meaningless, but the hint content itself is exposed without authentication).
- This means all challenge hints can be scraped without ever registering or binding
an email address.
Summary of Effects
| Scenario |
Effect |
Temporary session calls POST /api/v1/challenges/{id}/check |
Returns 200 OK with challenge status instead of 401 Unauthorized. Attempt counter is incremented for throwaway session. |
Temporary session calls POST /api/v1/challenges/{id}/hint |
Returns 200 OK with full hint text instead of 401 Unauthorized. Hints are revealed to unauthenticated callers. |
| Automated scraper with no email |
Can extract every hint for every challenge by cycling through challenge IDs without authentication. |
Steps to Reproduce
Reproduce the Dead Guard (check_challenge)
# Step 1: Get a fresh session cookie (no email bound — just visit the app once)
# The middleware auto-creates a temporary session.
# Step 2: Call check on any valid challenge ID with the temporary session cookie
curl -X POST https://owasp-finbot-ctf.org/api/v1/challenges/some-challenge-id/check \
-H "Cookie: session=<your_temporary_session_cookie>"
# Expected: HTTP 401 Unauthorized
# Actual: HTTP 200 OK → {"status": "available", "detected": false, ...}
Reproduce the Hint Leak (use_hint)
# Step 1: Same : get a temporary session cookie with no email bound.
# Step 2: Call hint endpoint to retrieve hint text
curl -X POST https://owasp-finbot-ctf.org/api/v1/challenges/some-challenge-id/hint \
-H "Cookie: session=<your_temporary_session_cookie>"
# Expected: HTTP 401 Unauthorized
# Actual: HTTP 200 OK → {"hint_index": 0, "hint_text": "<actual hint revealed>", ...}
Proposed Fix
Replace the broken get_session_context + dead if not pattern with
get_authenticated_session_context - the same dependency used by every other
personal-data endpoint.
challenges.py : check_challenge
-from finbot.core.auth.middleware import get_session_context
+from finbot.core.auth.middleware import get_authenticated_session_context
@router.post("/challenges/{challenge_id}/check", response_model=CheckResult)
def check_challenge(
challenge_id: str,
- session_context: SessionContext = Depends(get_session_context),
+ session_context: SessionContext = Depends(get_authenticated_session_context),
db: Session = Depends(get_db),
):
- if not session_context:
- raise HTTPException(status_code=401, detail="Authentication required")
...
challenges.py : use_hint
@router.post("/challenges/{challenge_id}/hint", response_model=HintResponse)
def use_hint(
challenge_id: str,
- session_context: SessionContext = Depends(get_session_context),
+ session_context: SessionContext = Depends(get_authenticated_session_context),
db: Session = Depends(get_db),
):
- if not session_context:
- raise HTTPException(status_code=401, detail="Authentication required")
...
The dead if not session_context checks can be removed entirely since
get_authenticated_session_context raises HTTP 401 on its own when the session is
temporary.
Note: GET /api/v1/challenges and GET /api/v1/challenges/{id} intentionally use
get_session_context because they are designed to be publicly browsable (they show
locked hints and anonymised status for unauthenticated visitors). Only the write
operations — check and hint — should require a persistent session.
Additional Scope : Wider Audit Note
As noted in #511's "Additional Notes" section, activity.py, stats.py,
badges.py, and toolkit.py also use get_session_context. Unlike the endpoints
above, those routes do not contain a dead guard — they either intentionally support
anonymous browsing or handle the unauthenticated case inline. However, toolkit.py's
dead-drop and exfil endpoints expose intercepted emails and network captures without
requiring a persistent session. A follow-up review of whether those routes should also
enforce get_authenticated_session_context may be warranted.
The immediate priority is check_challenge and use_hint because they:
- Contain dead code that gives a false sense of security.
- Leak full hint text to unauthenticated callers.
- Write phantom rows to the database for throwaway sessions.
Affected Files
| File |
Lines |
Issue |
finbot/apps/ctf/routes/challenges.py |
210–258 |
check_challenge uses get_session_context; dead if not guard at L222–223 |
finbot/apps/ctf/routes/challenges.py |
261–303 |
use_hint uses get_session_context; dead if not guard at L268–269 |
Type of Issue
[Bug / Security] Dead Auth Guard in Challenge Check & Hint Endpoints: Anonymous Sessions Bypass Attempt Tracking and Hint Consumption
Summary
Two challenge-interaction endpoints :
POST /api/v1/challenges/{challenge_id}/checkandPOST /api/v1/challenges/{challenge_id}/hint:contain an authentication guard thatcan never fire. The guard reads:
But
session_contextis injected via theget_session_contextFastAPI dependency(
finbot/core/auth/middleware.pyL156–161), which always returns a validSessionContextobject : it simply returnsrequest.state.session_context, which theSessionMiddlewaresets for every request including anonymous/temporary sessions.Because
session_contextis neverNoneor falsy, theif not session_contextcondition always evaluates to
False. The guard is dead code. Unauthenticatedvisitors (temporary session, no email bound) can call both endpoints freely.
Affected Files & Lines
finbot/apps/ctf/routes/challenges.pycheck_challengeendpointfinbot/apps/ctf/routes/challenges.pyuse_hintendpointRoot Cause : Why the Guard Can Never Fire
get_session_contextis defined infinbot/core/auth/middleware.py:And
SessionMiddleware.dispatchunconditionally setsrequest.state.session_contextfor every request (temporary or not) before the route handler runs:
There is no code path in the middleware where
request.state.session_contextis leftunset or set to
None. Therefore,get_session_contextalways returns a truthySessionContext, and the guardif not session_contextevaluates toFalse100% ofthe time.
Vulnerable Code
check_challenge(challenges.py L210–258)use_hint(challenges.py L261–303)Comparison with Other Endpoints
Every other endpoint that guards personal-data writes uses the correct dependency:
GET /api/v1/profileget_authenticated_session_context✅PUT /api/v1/profileget_authenticated_session_context✅PUT /api/v1/profile/featured-badgesget_authenticated_session_context✅GET /api/v1/sidecarget_authenticated_session_context✅ (fixed in #511/#512)POST /api/v1/challenges/{id}/checkget_session_context+ deadif notcheck ❌POST /api/v1/challenges/{id}/hintget_session_context+ deadif notcheck ❌The correct dependency,
get_authenticated_session_context, raises HTTP 401 wheneversession_context.is_temporaryisTrue. The manualif not session_contextguard wasclearly intended as a substitute, but it is structurally broken because the dependency
never yields
None.Impact
1. Unauthenticated Attempt Inflation (
check_challenge)progress_repo.record_attempt(challenge_id)is called insidecheck_challengefor anycaller that has no prior progress record. Because temporary sessions are accepted:
POST /api/v1/challenges/{id}/checkwithoutever binding an email address.
UserChallengeProgressunder thetemporary session's
user_id.in the database for throwaway sessions, polluting the
UserChallengeProgresstableindefinitely.
challenge progress tracked.
2. Unauthenticated Hint Consumption (
use_hint)progress_repo.use_hint(challenge_id, cost)is called insideuse_hint. For atemporary session:
POST /api/v1/challenges/{id}/hintrepeatedly.hint["text"]) for each hint that hasn't been usedunder that temporary session.
callers.
hints_costdeduction is recorded against the temporary session's progress row(which is meaningless, but the hint content itself is exposed without authentication).
an email address.
Summary of Effects
POST /api/v1/challenges/{id}/check200 OKwith challenge status instead of401 Unauthorized. Attempt counter is incremented for throwaway session.POST /api/v1/challenges/{id}/hint200 OKwith full hint text instead of401 Unauthorized. Hints are revealed to unauthenticated callers.Steps to Reproduce
Reproduce the Dead Guard (check_challenge)
Reproduce the Hint Leak (use_hint)
Proposed Fix
Replace the broken
get_session_context+ deadif notpattern withget_authenticated_session_context- the same dependency used by every otherpersonal-data endpoint.
challenges.py:check_challengechallenges.py:use_hint@router.post("/challenges/{challenge_id}/hint", response_model=HintResponse) def use_hint( challenge_id: str, - session_context: SessionContext = Depends(get_session_context), + session_context: SessionContext = Depends(get_authenticated_session_context), db: Session = Depends(get_db), ): - if not session_context: - raise HTTPException(status_code=401, detail="Authentication required") ...The dead
if not session_contextchecks can be removed entirely sinceget_authenticated_session_contextraisesHTTP 401on its own when the session istemporary.
Additional Scope : Wider Audit Note
As noted in #511's "Additional Notes" section,
activity.py,stats.py,badges.py, andtoolkit.pyalso useget_session_context. Unlike the endpointsabove, those routes do not contain a dead guard — they either intentionally support
anonymous browsing or handle the unauthenticated case inline. However,
toolkit.py'sdead-drop and exfil endpoints expose intercepted emails and network captures without
requiring a persistent session. A follow-up review of whether those routes should also
enforce
get_authenticated_session_contextmay be warranted.The immediate priority is
check_challengeanduse_hintbecause they:Affected Files
finbot/apps/ctf/routes/challenges.pycheck_challengeusesget_session_context; deadif notguard at L222–223finbot/apps/ctf/routes/challenges.pyuse_hintusesget_session_context; deadif notguard at L268–269Type of Issue
if not session_contextguard can never fire)