FreeScout CVE-2026-28289 detection
| Reporter | Title | Published | Views | Family All 18 |
|---|---|---|---|---|
| CVE-2026-28289 | 3 Mar 202622:59 | – | attackerkb | |
| CVE-2026-28289 | 4 Mar 202600:48 | – | circl | |
| FreeScout 代码问题漏洞 | 3 Mar 202600:00 | – | cnnvd | |
| CVE-2026-28289 | 3 Mar 202622:59 | – | cve | |
| CVE-2026-28289 FreeScout 1.8.206 Patch Bypass for CVE-2026-27636 via Zero-Width Space Character Leads to Remote Code Execution | 3 Mar 202622:59 | – | cvelist | |
| EUVD-2026-9347 | 3 Mar 202622:59 | – | euvd | |
| CVE-2026-28289 | 8 Oct 202620:46 | – | kitploit | |
| CVE-2026-27636 | 8 Oct 202618:30 | – | kitploit | |
| CVE-2026-28289 | 3 Mar 202622:59 | – | mend | |
| FreeScout Unauthenticated RCE via ZWSP .htaccess Bypass | 1 Mar 202600:00 | – | metasploit |
10
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
from typing import Optional, Tuple
from urllib.parse import urljoin, urlparse
from kittysploit import *
from lib.protocols.http.http_client import Http_client
_AFFECTED_MAX = "1.8.206"
_FIXED_VERSION = "1.8.207"
class Module(Scanner, Http_client):
__info__ = {
"name": "FreeScout CVE-2026-28289 detection",
"description": (
"Fingerprints FreeScout and compares the reported version against "
f"CVE-2026-28289 (<= {_AFFECTED_MAX}, fixed in {_FIXED_VERSION}). "
"Optional admin credentials can be used only to read the version from "
"the authenticated footer. Version check only — no exploitation."
),
"author": ["KittySploit Team"],
"severity": "critical",
"cve": "CVE-2026-28289",
"references": [
"https://nvd.nist.gov/vuln/detail/CVE-2026-28289",
"https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-5gpc-65p8-ffwp",
"https://github.com/freescout-help-desk/freescout/releases/tag/1.8.207",
],
"modules": [],
"tags": [
"web",
"scanner",
"freescout",
"laravel",
"version",
"cve-2026-28289",
],
"agent": {
"risk": "active",
"effects": ["network_probe"],
"expected_requests": 3,
"reversible": True,
"approval_required": False,
"produces": ["tech_hints", "risk_signals", "endpoints"],
"cost": 1.0,
"noise": 0.3,
"value": 1.0,
"requires": {
"min_endpoints": 0,
"min_params": 0,
"tech_hints_any": [],
"tech_hints_all": [],
"specializations_any": [],
"risk_signals_any": [],
"auth_session": False,
"capabilities_any": [],
"capabilities_all": [],
"confidence_min": {},
"confidence_min_any": {},
"endpoint_pattern_any": ["/login"],
"param_any": [],
"api_surface_ready": False,
},
"chain": {
"produces_capabilities": [],
"consumes_capabilities": [],
"option_bindings": {},
"suggested_followups": [],
},
},
}
port = OptPort(80, "HTTP port", True)
ssl = OptBool(False, "Use HTTPS", True, advanced=True)
base_path = OptString("/", "URL path prefix if FreeScout is not at site root", required=False)
version = OptString(
"",
"Known FreeScout version override (e.g. 1.8.206) when auto-detection fails",
required=False,
advanced=True,
)
email = OptString("", "Optional FreeScout admin email to read footer version", required=False)
password = OptString("", "Optional FreeScout admin password", required=False)
_PROBE_REL = ("/", "/login")
_VERSION_RES = (
re.compile(
r"(?:freescout|app\.version|help\s*desk)[^0-9]{0,40}(\d+\.\d+\.\d+)",
re.I,
),
re.compile(r'["\']version["\']\s*[:=]\s*["\'](\d+\.\d+\.\d+)["\']', re.I),
re.compile(r">\s*(\d+\.\d+\.\d+)\s*<"),
)
_FINGERPRINT_MARKERS = (
"freescout",
"free open source help desk",
"freescout-help-desk",
"/storage/js/vars.js",
"freescout.app",
)
_SUPPORTING_MARKERS = (
"shared mailbox",
"shared inbox",
"/css/style.css",
"/js/main.js",
)
@staticmethod
def _version_tuple(value: str) -> Tuple[int, ...]:
parts = []
for token in re.findall(r"\d+", value or ""):
parts.append(int(token))
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3])
def _version_lte(self, value: str, limit: str) -> bool:
return self._version_tuple(value) <= self._version_tuple(limit)
def _normalize_base(self) -> str:
path = str(self.base_path or "/").strip() or "/"
if not path.startswith("/"):
path = "/" + path
return path.rstrip("/")
def _path(self, suffix: str) -> str:
base = self._normalize_base()
if not suffix.startswith("/"):
suffix = "/" + suffix
return f"{base}{suffix}" if base else suffix
def _is_vulnerable(self, version: str) -> bool:
return self._version_lte(version, _AFFECTED_MAX)
def _looks_like_freescout(self, body: str) -> bool:
low = (body or "").lower()
if not low:
return False
if "freescout" in low:
return True
strong_hits = sum(1 for marker in self._FINGERPRINT_MARKERS if marker in low)
if strong_hits >= 2:
return True
support_hits = sum(1 for marker in self._SUPPORTING_MARKERS if marker in low)
return strong_hits >= 1 and support_hits >= 2
def _extract_version(self, body: str) -> str:
text = body or ""
for pattern in self._VERSION_RES:
match = pattern.search(text)
if match:
return match.group(1).strip()
return ""
def _extract_csrf(self, body: str) -> str:
patterns = (
re.compile(r'name=["\']_token["\']\s+value=["\']([^"\']+)["\']', re.I),
re.compile(r'name=["\']csrf-token["\']\s+content=["\']([^"\']+)["\']', re.I),
re.compile(r'content=["\']([^"\']+)["\']\s+name=["\']csrf-token["\']', re.I),
)
for pattern in patterns:
match = pattern.search(body or "")
if match:
return match.group(1).strip()
return ""
def _login_still_on_login(self, response) -> bool:
if not response:
return True
final = urlparse(str(getattr(response, "url", "") or "")).path.rstrip("/")
return final.endswith("/login") or final.endswith("login")
def _fetch_authenticated_version(self, timeout: int) -> Optional[str]:
email = str(self.email or "").strip()
password = str(self.password or "")
if not email or not password:
return None
login_path = self._path("/login")
login_page = self.http_request(
method="GET",
path=login_path,
allow_redirects=True,
timeout=timeout,
)
if not login_page or not login_page.text:
print_warning("Could not load FreeScout login page for authenticated version check")
return None
csrf = self._extract_csrf(login_page.text)
if not csrf:
print_warning("CSRF token not found on FreeScout login page")
return None
login_resp = self.http_request(
method="POST",
path=login_path,
data={"_token": csrf, "email": email, "password": password},
headers={"Referer": urljoin(str(getattr(login_page, "url", "") or ""), login_path)},
allow_redirects=True,
timeout=timeout,
)
if not login_resp or self._login_still_on_login(login_resp):
print_warning("FreeScout login failed; cannot read authenticated version")
return None
body = login_resp.text or ""
# Prefer a second authenticated page where the admin footer is rendered.
for rel in ("/", "/system/status", "/users/profile"):
page = self.http_request(
method="GET",
path=self._path(rel),
allow_redirects=True,
timeout=timeout,
)
if page and page.text:
body += "\n" + page.text
version = self._extract_version(body)
if version:
print_info(f"Authenticated FreeScout version: {version}")
else:
print_warning(
"Login succeeded but version was not found "
"(admin account required for footer version)"
)
return version or None
def run(self):
try:
timeout = max(int(self.timeout or 10), 10)
detected = False
evidence_path = ""
combined = ""
for rel in self._PROBE_REL:
path = self._path(rel)
response = self.http_request(
method="GET",
path=path,
allow_redirects=True,
timeout=timeout,
)
if not response or not response.text:
continue
body = response.text
if self.is_same_as_index(response, path=path):
continue
combined += "\n" + body
if self._looks_like_freescout(body):
detected = True
evidence_path = path
break
if not detected:
return False
print_success(f"FreeScout detected at {evidence_path}")
version = str(self.version or "").strip()
source = "option" if version else ""
if not version:
version = self._extract_version(combined)
if version:
source = "public"
if not version:
auth_version = self._fetch_authenticated_version(timeout)
if auth_version:
version = auth_version
source = "authenticated"
if not version:
self.set_info(
severity="info",
cve="CVE-2026-28289",
reason=(
"FreeScout detected but version could not be extracted; "
f"set VERSION or admin EMAIL/PASSWORD to compare against "
f"<= {_AFFECTED_MAX}"
),
path=evidence_path,
confidence="low",
)
return True
print_info(f"FreeScout version {version} ({source})")
if self._is_vulnerable(version):
self.set_info(
severity="critical",
cve="CVE-2026-28289",
reason=(
f"FreeScout {version} is within CVE-2026-28289 range "
f"(<= {_AFFECTED_MAX}, fixed in {_FIXED_VERSION})"
),
version=version,
path=evidence_path,
confidence="high" if source in ("option", "authenticated", "public") else "medium",
)
return True
self.set_info(
severity="info",
reason=(
f"FreeScout {version} detected; appears patched for "
f"CVE-2026-28289 (>= {_FIXED_VERSION})"
),
version=version,
path=evidence_path,
)
return False
except Exception as exc:
print_error(f"Scanner failed: {exc}")
return False
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
06 Aug 2026 12:23Current
8.6High risk
Vulners AI Score8.6
CVSS 3.18.1 - 10
EPSS0.02661
SSVC