Description
Seems like some data has been leaked! Can you get the flag?
Setup
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Dump the SQLite database and crack the admin SHA-256 hash
ObservationThe 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'.bashsqlite3 users.db .dumpbash# Find the admin's SHA-256 hash in the dumpbash# Paste the hash into crackstation.netbash# Result: apple@123bash# Or crack with hashcat:bashecho '<admin_sha256_hash>' > hash.txtbashhashcat -m 1400 hash.txt /usr/share/wordlists/rockyou.txtbash# -m 1400 = SHA-256Expected 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@123are 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.
Step 2Log in as admin and reach the 2FA page
ObservationWith 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@123bash# The server redirects to an OTP verification pagebash# Open the browser's DevTools > Application > Cookies and find the session cookieWhat 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.
Step 3Decode the Flask session cookie to read the OTP
ObservationFlask 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 similarbash# Method 2: Decode manually - Flask session is base64(json) + signaturepythonpython3 - <<'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)) EOFbash# 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.
Step 4Enter the OTP to get the flag
ObservationThe 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 formbash# Or with curl:bashcurl -b 'session=<YOUR_SESSION>' -d 'otp=<VALUE_FROM_COOKIE>' http://<HOST>:<PORT_FROM_INSTANCE>/verifyLearn 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
How to prevent this
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-9999996-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.