A Dating App With Secrets
ValenFind is a Valentine's Day-themed CTF challenge — a Flask dating platform where you browse profiles, send likes, and find your match. Beneath the pink UI, the app is riddled with vulnerabilities that chain into complete compromise: from reading /etc/passwd all the way to downloading the entire user database and capturing the flag.
This writeup walks through the methodology step by step, from initial reconnaissance to the final flag.
Mapping the Application
First step: manual walkthrough of the entire application — no tools, just a browser. Mapping every visible surface revealed the following pages:
/ · /register · /login · /dashboard · /my_profile · /profile/<username> · /like/<id> · /logout
The dashboard immediately stood out — it listed every registered user including an admin account, with sequential integer IDs exposed in the Like form actions (/like/1, /like/2…). Classic IDOR setup. Also present: missing CSRF tokens on all POST forms, and plaintext-looking like counts. But what caught my eye most was the profile for cupid:
"I keep the database secure. No peeking."
The irony of that bio would become apparent very quickly.
Path Traversal via fetch_layout
Viewing cupid's profile, I opened DevTools and inspected the JavaScript. A function called loadTheme() stood out immediately:
profile.html — JavaScript
function loadTheme(layoutName) {
// Feature: Dynamic Layout Fetching
fetch(`/api/fetch_layout?layout=${layoutName}`)
.then(r => r.text())
.then(html => {
let rendered = html.replace('__USERNAME__', username)
.replace('__BIO__', bioText);
document.getElementById('bio-container').innerHTML = rendered;
});
}
The layout parameter is passed directly to a server-side file fetch with no validation beyond a blocklist. The value comes from a <select> dropdown — client-side only, trivially bypassed. First test: the classic traversal payload.
It worked. The server returned /etc/passwd in full — confirming unauthenticated path traversal. No path canonicalization, and a broken blocklist that only checked for cupid.db or the .db extension.
CWE-22 — Path Traversal in fetch_layout
Arbitrary file read on the server. The blocklist-based defence is trivially bypassed. Any file the app process can read is exposed — configuration, keys, source code, system files.
Source Code via /proc/self
With traversal confirmed, the next goal was locating the application source. Rather than guessing paths, I used /proc/self/cmdline — a Linux pseudo-file that reveals the exact command used to launch the current process:
This revealed the Python interpreter and — most importantly — the exact application root: /opt/Valenfind/. No guessing. I read the source directly:
The source was fully readable. Two findings jumped out within seconds:
CWE-798 — Hardcoded Admin API Key
ADMIN_API_KEY = "CUPID_MASTER_KEY_2024_XOXO"
CWE-284 — Unauthenticated Database Export Endpoint
@app.route('/api/admin/export_db')
def export_db():
auth_header = request.headers.get('X-Valentine-Token')
if auth_header == ADMIN_API_KEY:
return send_file(DATABASE, as_attachment=True)
The entire SQLite database can be downloaded by anyone who knows the API key — no login session, no IP restriction, no rate limit. The "auth" is a single header check against a hardcoded string we already have.
Full Database Exfiltration
With the hardcoded key in hand, exploiting the export endpoint was a single command:
bash
curl http://10.48.133.124:5000/api/admin/export_db \
-H "X-Valentine-Token: CUPID_MASTER_KEY_2024_XOXO" \
--output valenfind.db
sqlite3 valenfind.db "SELECT * FROM users;"
The flag was in the address field of the cupid account — the system administrator with 999 likes and the bio "I keep the database secure. No peeking." The dump also revealed every user's plaintext password — no hashing, no salting — and an XSS payload in the admin account's name field, suggesting it was also an intended test vector.
How the Vulnerabilities Chained
?layout=/proc/self/cmdline → app pathapp.py → hardcoded keyNo single vulnerability was enough on its own. Path traversal opened the door to source code; source code handed over the key; the key unlocked the database; the database contained the flag. This is why vulnerability chaining is a core skill — individually these might score low on CVSS, but together they represent total compromise.
All Findings at a Glance
| Finding | Severity | CWE | Impact |
|---|---|---|---|
Path traversal in fetch_layout |
Critical | CWE-22 | Arbitrary file read |
| Hardcoded admin API key in source | Critical | CWE-798 | Full admin API access |
| Unauthenticated database export endpoint | Critical | CWE-284 | Full DB exfiltration |
| Plaintext password storage | High | CWE-256 | All credentials compromised |
| No CSRF tokens on POST forms | High | CWE-352 | Silent action forgery |
innerHTML with server-fetched content |
High | CWE-79 | Stored/reflected XSS |
| User enumeration via dashboard + sequential IDs | Medium | CWE-200 | IDOR surface, account enumeration |
| Internal filenames exposed in HTML select | Low | CWE-538 | Aids traversal enumeration |
How to Fix These Issues
1. Replace the blocklist with an allowlist
The existing blocklist check ("block if it contains cupid.db") is fundamentally broken — there are infinite ways to reference a file. Allowlists are the only reliable pattern:
app.py — fixed fetch_layout
ALLOWED_LAYOUTS = {'theme_classic.html', 'theme_modern.html', 'theme_romance.html'}
def fetch_layout():
layout_file = request.args.get('layout', 'theme_classic.html')
if layout_file not in ALLOWED_LAYOUTS:
return "Invalid layout", 400
2. Never hardcode secrets — use environment variables
app.py — secret handling
# Bad
ADMIN_API_KEY = "CUPID_MASTER_KEY_2024_XOXO"
# Good
ADMIN_API_KEY = os.getenv('ADMIN_API_KEY')
if not ADMIN_API_KEY:
raise RuntimeError("ADMIN_API_KEY not set")
3. Hash passwords with bcrypt or Argon2
app.py — password hashing
from werkzeug.security import generate_password_hash, check_password_hash
# On register
hashed = generate_password_hash(password)
# On login
if user and check_password_hash(user['password'], password):
...
4. Sanitize before injecting into the DOM
profile.html — XSS fix
// Bad
document.getElementById('bio-container').innerHTML = rendered;
// Good — sanitize before injecting
document.getElementById('bio-container').innerHTML = DOMPurify.sanitize(rendered);
Key Lessons
No single bug here was catastrophic in isolation. Path traversal alone gives file read. Hardcoded keys are useless without knowing the endpoint. The chain is what causes total compromise — always think about how findings connect.
The developer tried to block .db and cupid.db. But there are infinite ways to reference a file — double encoding, case variation, symlinks. Allowlists are the only reliable pattern.
When you have path traversal on Linux, /proc/self/cmdline and /proc/self/environ are the fastest path to app secrets and config — no guessing required.
ValenFind was a well-constructed CTF that reflects real-world vulnerability patterns. The cupid bio — "I keep the database secure. No peeking." — was the perfect troll from the challenge author. The database was never secure, and we definitely peeked.