No FA picoCTF 2026 Solution

Published: March 20, 2026

Description

Seems like some data has been leaked! Can you get the flag?

Launch the challenge instance and open the web application.

The site appears to have a login protected by two-factor authentication (2FA/OTP).

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Dump the SQLite database and crack the admin SHA-256 hash
    Observation
    I noticed the challenge provided downloadable application source code and a SQLite database, and inspecting the users table revealed SHA-256 hashed passwords, which suggested dumping the database and running the hash through an offline cracker like CrackStation or hashcat.
    Download the application code and the SQLite database. Dump the database with sqlite3 to find the admin's SHA-256 hash. Paste it into CrackStation - the password is 'apple@123'.
    bash
    sqlite3 users.db .dump
    bash
    # Find the admin's SHA-256 hash in the dump
    bash
    # Paste the hash into crackstation.net
    bash
    # Result: apple@123
    bash
    bash
    # Or crack with hashcat:
    bash
    echo '<admin_sha256_hash>' > hash.txt
    bash
    hashcat -m 1400 hash.txt /usr/share/wordlists/rockyou.txt
    bash
    # -m 1400 = SHA-256

    Expected output

    {'user': 'admin', 'otp': 3816}
    What didn't work first

    Tried: Running hashcat with -m 1000 (NTLM) instead of -m 1400 (SHA-256) against the hash

    NTLM and SHA-256 produce completely different digests, so hashcat will compare the wrong computed values against the stored hash and find no match. The sqlite3 dump column name or the hash length (64 hex characters) confirms SHA-256; -m 1400 is the correct mode.

    Tried: Trying to brute-force the admin login form directly without cracking the hash first

    The web app rate-limits or lacks a brute-force endpoint for passwords, and a dictionary attack through HTTP is orders of magnitude slower than offline hashcat. Cracking the hash offline against rockyou.txt finds 'apple@123' in seconds because it is a precomputed entry.

    Learn more

    The application uses SHA-256 to hash passwords. While SHA-256 is cryptographically sound for data integrity, it is too fast for password hashing: a modern GPU can compute billions of SHA-256 hashes per second, making dictionary attacks fast. Passwords like apple@123 are in CrackStation's precomputed tables.

    Modern password storage uses purpose-built slow hashing functions: bcrypt, scrypt, Argon2, and PBKDF2. These are deliberately slow and memory-intensive. They also include per-user salts to prevent rainbow table attacks.

  2. Step 2
    Log in as admin and reach the 2FA page
    Observation
    I noticed that after cracking the admin password to 'apple@123', the application redirected to an OTP verification page, and reading the source code revealed the OTP was a 4-digit number stored in the Flask session cookie, which suggested logging in first to obtain that cookie.
    Log in with the cracked password. The server presents an OTP verification page. Read the source code: the OTP is a random number 1000-9999 stored in the Flask session token.
    bash
    # In the browser: log in as admin with password apple@123
    bash
    # The server redirects to an OTP verification page
    bash
    # Open the browser's DevTools > Application > Cookies and find the session cookie
    What didn't work first

    Tried: Attempting to brute-force the 4-digit OTP (1000-9999) by submitting all 9000 combinations through the verify form

    Even without a rate-limit, cycling 9000 POST requests manually is slow and noisy. The Flask session cookie is already in your browser and contains the OTP in plaintext - reading it directly takes seconds and requires no guessing at all.

    Tried: Trying to forge a new session cookie with a different OTP by modifying the base64 payload

    Flask signs the session with an HMAC using the server's secret key. Changing any byte in the payload invalidates the signature, and the server rejects the request with a 400 or resets the session. You do not need to forge anything - the real OTP is readable in the existing cookie without modification.

    Learn more

    Flask session tokens are base64-encoded, JSON-formatted data that is cryptographically signed but not encrypted. This means the contents can be read by anyone who has the cookie, but they cannot be modified without the server's secret key (the signature would be invalid). The OTP value is stored inside this readable session cookie.

  3. Step 3
    Decode the Flask session cookie to read the OTP
    Observation
    I noticed Flask session cookies are signed but not encrypted, meaning the base64-encoded JSON payload is readable by any client, which suggested decoding the payload directly to extract the OTP value without needing to brute-force or forge anything.
    Flask session cookies are not encrypted - just signed. Decode the cookie's base64 payload to read the stored OTP value directly. Use a browser extension like Cookie Editor or a Flask session decoder.
    bash
    # Method 1: Use a browser extension (Cookie Editor in Firefox/Chrome)
    bash
    # Find the 'session' cookie, look for 'decode Flask session' or similar
    bash
    bash
    # Method 2: Decode manually - Flask session is base64(json) + signature
    python
    python3 - <<'EOF'
    import base64, json
    
    # Paste your session cookie value here (the part before the first dot):
    session_cookie = "eyJ1c2VyIjoiYWRtaW4iLCAib3RwIjogMzgxNn0"  # example
    
    # Decode (add padding if needed):
    padded = session_cookie + '=' * (4 - len(session_cookie) % 4)
    decoded = base64.b64decode(padded)
    print(json.loads(decoded))
    EOF
    bash
    # The output will show the OTP value, e.g.: {'user': 'admin', 'otp': 3816}
    What didn't work first

    Tried: Trying to decode the entire cookie string (including the dot-separated signature) as a single base64 blob

    The Flask session cookie format is 'payload.timestamp.signature' where each segment is separated by dots. Base64-decoding the full string including the dots produces garbage. You must split on the first dot and decode only the first segment (the payload) to get the JSON.

    Tried: Using flask-unsign to forge a new signed session with a chosen OTP instead of just reading the existing one

    flask-unsign's --sign mode requires the server's secret key, which you do not have. Signing a crafted payload without the key produces an invalid signature that the server rejects. The correct approach is to read the OTP out of your own existing session cookie - no key, no forgery needed.

    Learn more

    Flask's default session implementation (itsdangerous.URLSafeTimedSerializer) stores session data as a URL-safe base64-encoded JSON payload, followed by a dot and the HMAC signature. The payload is readable by anyone - only the signature is secret. Tools like flask-unsign or a simple base64 decode expose the session contents.

    This is a known Flask security gotcha: developers sometimes store sensitive data (like OTP codes, privilege levels, or user IDs) in the session assuming it is encrypted, when it is actually only signed. Any client can read the session data; they just cannot forge a new session without the secret key.

  4. Step 4
    Enter the OTP to get the flag
    Observation
    I noticed the OTP was now known in plaintext from the decoded session cookie, which meant I could submit it directly to the /verify endpoint to complete the 2FA check and retrieve the flag.
    Use the OTP value you read from the session cookie to complete the 2FA verification and get the flag.
    bash
    # Enter the OTP you read from the session cookie into the verification form
    bash
    # Or with curl:
    bash
    curl -b 'session=<YOUR_SESSION>' -d 'otp=<VALUE_FROM_COOKIE>' http://<HOST>:<PORT_FROM_INSTANCE>/verify
    Learn more

    The OTP is stored in the session cookie in plaintext. By reading it directly from the cookie, you bypass the entire 2FA security model. This is why sensitive values like OTP codes should be stored server-side (in a database or server-side session store), never in a client-side cookie - even a signed one.

Interactive tools
  • JWT DecoderDecode JSON Web Tokens and inspect the header, payload, and signature. Useful for web exploitation challenges.
  • Flask Session DecoderDecode Flask / itsdangerous session cookies. Splits payload, decompresses zlib, parses JSON, and verifies the HMAC signature when given the secret.

Flag

Reveal flag

picoCTF{n0_r4t3_n0_4uth_...}

SHA-256 hash cracks to 'apple@123' (admin password). After logging in, the OTP (1000-9999) is stored unencrypted in the Flask session cookie. Decode the base64 session payload to read the OTP directly - no brute force needed.

Key takeaway

Flask session cookies are signed but not encrypted, meaning the JSON payload is readable by any client who holds the cookie after a simple base64 decode. Storing secrets like OTP codes, privilege flags, or other sensitive values in the session object exposes them to the user, completely undermining any security check built around them. Sensitive state that the client must not read should live server-side in a database or server-side session store, with only an opaque session ID traveling in the cookie.

How to prevent this

2FA only adds security if the OTP path is hardened the same way as the login path.

  • Use the full 000000-999999 6-digit space, generated from a CSPRNG or HOTP/TOTP secret. Never accept a 4-digit OTP for anything that protects real value.
  • Rate-limit the OTP endpoint per account (not per IP), with exponential backoff and a lockout after 5-10 failures. Brute force at 50 RPS should be impossible.
  • Bind the OTP to a single login attempt: invalidate it after one wrong guess instead of letting the attacker keep trying within the 30-second window. Notify the user on repeated failures.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for Web Exploitation

What to try next