Description
Seems like some data has been leaked! Can you get the flag?
Setup
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.
Step 1
Dump the SQLite database and crack the admin SHA-256 hashObservationI 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'.bashsqlite3 users.db .dumpbash# Find the admin's SHA-256 hash in the dumpbash# Paste the hash into crackstation.netbash# Result: apple@123bashbash# 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 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@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 2
Log in as admin and reach the 2FA pageObservationI 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@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 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.
Step 3
Decode the Flask session cookie to read the OTPObservationI 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 similarbashbash# 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
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.
Step 4
Enter the OTP to get the flagObservationI 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 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.
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.