Skip to main content

No FA picoCTF 2026 Solution

A web login protected by two-factor authentication. Find a way past it to access the flag.

Published: March 20, 2026Updated: September 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 1Dump the SQLite database and crack the admin SHA-256 hash
    Observation
    The challenge ships the application source and a SQLite database, and the users table holds SHA-256 password hashes. Dump it and run the admin hash through an offline cracker.
    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
    # 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 different digests, so hashcat compares the wrong values and finds nothing. The column name in the dump, and the 64-character hash length, both say SHA-256, which is mode 1400.

    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 2Log in as admin and reach the 2FA page
    Observation
    With the admin password cracked, logging in lands on an OTP page. The source shows that OTP is a 4-digit number kept in the Flask session cookie, so log in first and take the 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 over the server's secret key, so changing any byte invalidates the signature and the server rejects or resets the session. There is nothing to forge: the real OTP is readable in the cookie as it stands.

    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 3Decode the Flask session cookie to read the OTP
    Observation
    Flask session cookies are signed but not encrypted, so the base64 JSON payload is readable by whoever holds it. Decode it and the OTP is right there, with nothing to brute-force or forge.
    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
    # 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

    Signing mode needs the server's secret key, which you do not have, and a payload signed without it is rejected. Read the OTP out of the cookie you already hold instead.

    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 4Enter the OTP to get the flag
    Observation
    The OTP is now in plaintext, so submit it to the verify endpoint and the second factor is satisfied.
    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.
  • Hash IdentifierIdentify unknown hash types by length and prefix. Covers MD5, SHA-1, SHA-256, SHA-512, bcrypt, NTLM, and more.

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

A Flask session cookie is signed but not encrypted, so anyone holding it reads the JSON after one base64 decode. Putting an OTP, a privilege flag, or any other secret into the session hands it to the user and undoes whatever check depends on it. State the client must not read belongs server-side, with only an opaque session ID 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

Useful tools for Web Exploitation

Where to go next