#!/usr/bin/env python3 """ XenForo XSS CVE Scanner Author: Xasthur Passive detection for CVE-2026-35055, CVE-2026-35054, CVE-2026-35057. Authorized use only. Usage: python xenforo_xss_scanner.py /path/to/xenforo python xenforo_xss_scanner.py https://forum.example.com/ --i-understand-authorized-use-only python xenforo_xss_scanner.py --print-advisory """ from __future__ import annotations AUTHOR = "Xasthur" CXSECURITY_ADVISORY = """ ================================================================================ TITLE ================================================================================ XenForo XSS CVE Scanner — Passive Detection Tool for CVE-2026-35055, CVE-2026-35054, CVE-2026-35057 ================================================================================ AUTHOR ================================================================================ Xasthur ================================================================================ CATEGORY ================================================================================ Tool / Security Scanner / Vulnerability Assessment ================================================================================ SEVERITY (DETECTED CVEs) ================================================================================ Medium (per vendor advisories) ================================================================================ AFFECTED SOFTWARE ================================================================================ XenForo 2.2.x and 2.3.x forum installations ================================================================================ SUMMARY ================================================================================ XenForo XSS CVE Scanner is a passive security assessment tool that detects whether a XenForo installation is likely vulnerable to publicly disclosed stored/reflected XSS issues tracked as CVE-2026-35055, CVE-2026-35054, and CVE-2026-35057. The tool does NOT exploit vulnerabilities, does NOT post malicious content, and does NOT perform unauthorized intrusion. It performs version analysis, static code pattern checks, optional PHP formatter simulation, and optional authorized remote asset verification (lightbox.js). Intended for forum administrators, security researchers, and penetration testers with explicit authorization on target systems. ================================================================================ COVERED CVEs ================================================================================ [1] CVE-2026-35055 — Lightbox XSS (Medium) - XSS when lightbox renders post media/captions - Fixed: XenForo 2.2.18 (2.2 branch) / 2.3.9 (2.3 branch) - Detection: version threshold + static analysis of js/xf/lightbox.js [2] CVE-2026-35054 — BB Code Stored XSS (Medium) - Affects XenForo 2.3.x only (before 2.3.9) - Fixed: XenForo 2.3.9 - Detection: version threshold (NOT_APPLICABLE on 2.2.x) [3] CVE-2026-35057 — Structured Mention Stored XSS (Medium) - Stored XSS via structured text mention placeholder collision - Fixed: XenForo 2.2.19 (2.2 branch) / 2.3.10 (2.3 branch) - Detection: version threshold + Formatter.php + optional PHP probe ================================================================================ TECHNICAL DESCRIPTION ================================================================================ Reads XenForo version from src/XF.php (version + versionId). CVE-2026-35055 => versionId >= 2022180 (2.2.18 / 2.3.9) CVE-2026-35054 => versionId >= 2023090 (2.3.9 only) CVE-2026-35057 => versionId >= 2022190 (2.2.19 / 2.3.10) Additional checks: - lightbox.js static analysis (Mustache {{{extra_html}}}) - Formatter.php mention/placeholder chain - xenforo_formatter_probe.php simulation (CVE-35057) - Remote /js/xf/lightbox.js fetch (authorized targets) - WAF/Cloudflare detection (INCONCLUSIVE / BLOCKED) Exit codes: 0 = no VULNERABLE findings 1 = at least one VULNERABLE finding ================================================================================ REQUIREMENTS ================================================================================ - Python 3.8+ - XenForo root (src/XF.php, index.php) - PHP (optional, formatter probe) - requests (optional, remote scan) ================================================================================ INSTALLATION ================================================================================ pip install -r requirements.txt Files: - xenforo_xss_scanner.py - xenforo_formatter_probe.php - requirements.txt - README.md ================================================================================ USAGE (AUTHORIZED TARGETS ONLY) ================================================================================ python xenforo_xss_scanner.py /path/to/xenforo python xenforo_xss_scanner.py --path C:\\xampp\\htdocs\\Xenforo --php C:\\xampp\\php\\php.exe python xenforo_xss_scanner.py https://forum.example.com/ --i-understand-authorized-use-only python xenforo_xss_scanner.py --print-advisory ================================================================================ SAMPLE OUTPUT ================================================================================ ============================================================ XenForo XSS CVE Scanner ============================================================ Version : 2.2.11 Version ID : 2021171 CVE-2026-35055 -> VULNERABLE CVE-2026-35054 -> NOT_APPLICABLE CVE-2026-35057 -> VULNERABLE ------------------------------------------------------------ Remediation: upgrade to XenForo 2.2.19+ or 2.3.10+ (licensed). ================================================================================ REMEDIATION ================================================================================ 1. Upgrade: 2.2.19+ (2.2.x) or 2.3.10+ (2.3.x) 2. Admin > Tools > File health check 3. Rotate admin passwords, enable 2FA 4. Review add-ons and core file modifications ================================================================================ LIMITATIONS / DISCLAIMER ================================================================================ - Passive detection only, not a full pentest - Version check may miss backported patches - Formatter probe may not trigger on every config - WAF may cause INCONCLUSIVE remote results - Authorized use only. Author (Xasthur) not responsible for misuse. ================================================================================ REFERENCES ================================================================================ - https://nvd.nist.gov/vuln/detail/CVE-2026-35055 - https://nvd.nist.gov/vuln/detail/CVE-2026-35054 - https://nvd.nist.gov/vuln/detail/CVE-2026-35057 - https://xenforo.com/community/threads/xenforo-2-3-9-inc-xfmg-2-2-18-released-security-fix.235659/ - https://xenforo.com/community/threads/xenforo-2-3-10-add-ons-and-2-2-19-released-includes-security-fix.236249/ ================================================================================ CREDIT ================================================================================ Tool Author: Xasthur Original CVE research: XenForo Ltd. / community security advisories ================================================================================ CONTACT ================================================================================ Email: [YOUR_EMAIL_HERE] GitHub: [YOUR_GITHUB_URL_HERE] """ def print_advisory() -> None: """Print CXSecurity advisory text to stdout.""" print(CXSECURITY_ADVISORY.strip()) import argparse import hashlib import json import re import subprocess import sys import time from dataclasses import dataclass, field from pathlib import Path from typing import Any from urllib.parse import urljoin, urlparse try: import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry except ImportError: requests = None # type: ignore HTTPAdapter = None # type: ignore Retry = None # type: ignore PATCH_VERSIONS = { "CVE-2026-35055": 2022180, "CVE-2026-35054": 2023090, "CVE-2026-35057": 2022190, } FIXED_VERSION_STRINGS = { "CVE-2026-35055": {"2.2": "2.2.18", "2.3": "2.3.9"}, "CVE-2026-35054": {"2.3": "2.3.9"}, "CVE-2026-35057": {"2.2": "2.2.19", "2.3": "2.3.10"}, } CVE_META = { "CVE-2026-35055": { "title": "Lightbox XSS", "severity": "Medium", "fixed_22": "2.2.18", "fixed_23": "2.3.9", "applies_22": True, }, "CVE-2026-35054": { "title": "BB code stored XSS", "severity": "Medium", "fixed_22": "N/A (2.3 only)", "fixed_23": "2.3.9", "applies_22": False, }, "CVE-2026-35057": { "title": "Structured mention stored XSS", "severity": "Medium", "fixed_22": "2.2.19", "fixed_23": "2.3.10", "applies_22": True, }, } BROWSER_UA = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" ) LIGHTBOX_PATHS = ( "js/xf/lightbox.js", "js/xf/lightbox.min.js", "js/xf/lightbox-compiled.js", ) REMOTE_ASSET_PATHS = ( "", "js/xf/core.js", "js/xf/core.min.js", ) @dataclass class Finding: cve: str status: str details: list[str] = field(default_factory=list) @dataclass class HttpFetchResult: url: str status_code: int | None ok: bool body: str headers: dict[str, str] error: str | None = None protection: list[str] = field(default_factory=list) blocked: bool = False challenge: bool = False @dataclass class LightboxValidation: genuine: bool = False confidence: str = "NONE" # HIGH, MEDIUM, LOW, NONE vulnerable: bool = False patched: bool = False blocked: bool = False size_bytes: int = 0 content_type: str | None = None sha256_prefix: str | None = None checks: list[str] = field(default_factory=list) failures: list[str] = field(default_factory=list) @dataclass class RemoteScanResult: reachable: bool = False is_xenforo: bool = False version: str | None = None notes: list[str] = field(default_factory=list) protection: list[str] = field(default_factory=list) lightbox: HttpFetchResult | None = None core_js: HttpFetchResult | None = None lightbox_validation: LightboxValidation | None = None homepage: HttpFetchResult | None = None any_blocked: bool = False any_challenge: bool = False def is_url(value: str) -> bool: parsed = urlparse(value) return parsed.scheme in ("http", "https") and bool(parsed.netloc) def normalize_url(url: str) -> str: return url.rstrip("/") def version_tuple(version: str) -> tuple[int, ...] | None: m = re.match(r"^(\d+)\.(\d+)\.(\d+)", version.strip()) if not m: return None return tuple(int(x) for x in m.groups()) def version_branch(version: str) -> str | None: t = version_tuple(version) if not t or len(t) < 2: return None return f"{t[0]}.{t[1]}" def version_is_below(current: str, fixed: str) -> bool | None: cur = version_tuple(current) fix = version_tuple(fixed) if cur is None or fix is None: return None return cur < fix def parse_version_from_xf_php(path: Path) -> tuple[str | None, int | None]: xf_php = path / "src" / "XF.php" if not xf_php.is_file(): return None, None text = xf_php.read_text(encoding="utf-8", errors="ignore") version = re.search(r"public static \$version = '([^']+)'", text) version_id = re.search(r"public static \$versionId = (\d+)", text) return ( version.group(1) if version else None, int(version_id.group(1)) if version_id else None, ) def version_status(cve: str, version_id: int | None, version: str | None = None) -> str: if version_id is None and version: branch = version_branch(version) or "2.2" fixed_map = FIXED_VERSION_STRINGS.get(cve, {}) if branch == "2.2" and cve == "CVE-2026-35054": return "NOT_APPLICABLE" if branch not in fixed_map: return "UNKNOWN" below = version_is_below(version, fixed_map[branch]) if below is None: return "UNKNOWN" if not CVE_META[cve]["applies_22"] and branch == "2.2": return "NOT_APPLICABLE" return "VULNERABLE" if below else "LIKELY_PATCHED" if version_id is None: return "UNKNOWN" meta = CVE_META[cve] if version and version_branch(version) == "2.2" and not meta["applies_22"]: return "NOT_APPLICABLE" if cve == "CVE-2026-35054" and version and version_branch(version) == "2.2": return "NOT_APPLICABLE" threshold = PATCH_VERSIONS[cve] return "VULNERABLE" if version_id < threshold else "LIKELY_PATCHED" def build_http_session(verify_ssl: bool = True) -> Any: if requests is None: return None session = requests.Session() session.verify = verify_ssl session.headers.update({ "User-Agent": BROWSER_UA, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9,tr;q=0.8", "Cache-Control": "no-cache", }) if HTTPAdapter and Retry: retry = Retry( total=2, connect=2, read=2, backoff_factor=0.4, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=("GET", "HEAD"), ) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter) return session def detect_protection(status_code: int | None, headers: dict[str, str], body: str) -> tuple[list[str], bool, bool]: tags: list[str] = [] blocked = False challenge = False lower = body.lower() hdr = {k.lower(): v for k, v in headers.items()} if hdr.get("cf-ray") or hdr.get("server", "").lower().startswith("cloudflare"): tags.append("Cloudflare") if hdr.get("cf-mitigated"): tags.append("Cloudflare mitigation") blocked = True if any(x in lower for x in ( "just a moment", "checking your browser", "cf-browser-verification", "challenge-platform", "_cf_chl", "attention required! | cloudflare", )): tags.append("Cloudflare challenge page") challenge = True blocked = True if any(x in lower for x in ("incapsula", "imperva", "_incapsula_resource")): tags.append("Imperva/Incapsula WAF") blocked = True if "sucuri website firewall" in lower or "cloudproxy" in lower: tags.append("Sucuri WAF") blocked = True if "access denied" in lower and status_code in (403, 406): tags.append("WAF/access denied page") blocked = True if status_code == 403: tags.append("HTTP 403 Forbidden") blocked = True if status_code == 429: tags.append("HTTP 429 rate limited") blocked = True return list(dict.fromkeys(tags)), blocked, challenge def looks_like_html_response(text: str) -> bool: stripped = text.lstrip()[:500].lower() if stripped.startswith("<!doctype") or stripped.startswith("<html"): return True html_markers = ( "<head>", "<body", "<meta ", "<title>", "<script src=", "just a moment", "checking your browser", "access denied", "request blocked", "captcha", "challenge-platform", ) return any(m in stripped for m in html_markers) def looks_like_waf_body(text: str) -> bool: lower = text.lower() return any(token in lower for token in ( "incapsula", "imperva", "sucuri website firewall", "attention required! | cloudflare", "cf-browser-verification", "errors.edgesuite.net", "akamai ghost", "datadome", )) def looks_like_xenforo_core_js(text: str) -> bool: if len(text) < 5000 or looks_like_html_response(text): return False markers = ("XF.config", "XF.activate", "XF.extend", "XenForo") return sum(1 for m in markers if m in text) >= 2 def looks_like_lightbox_js(text: str) -> bool: if len(text) < 2000 or looks_like_html_response(text): return False required = ("updateCaption", "Mustache.render") optional = ("XF.Lightbox", "XF.extend", "fancybox", "lb-caption-extra-html") return all(r in text for r in required) and sum(1 for m in optional if m in text) >= 2 def content_type_is_javascript(headers: dict[str, str]) -> bool | None: ct = headers.get("Content-Type") or headers.get("content-type") or "" if not ct: return None lower = ct.lower() if "javascript" in lower or lower.startswith("text/plain"): return True if "html" in lower: return False return None def extract_update_caption_template(text: str) -> str | None: m = re.search( r"updateCaption\s*:\s*function\s*\([^)]*\)\s*\{[\s\S]{0,2500}?template\s*=\s*('[^']+'|\"[^\"]+\")", text, ) return m.group(1) if m else None def validate_lightbox_response(fetch: HttpFetchResult, core_ok: bool = False) -> LightboxValidation: v = LightboxValidation( blocked=fetch.blocked or fetch.challenge or fetch.status_code != 200, size_bytes=len(fetch.body), content_type=(fetch.headers.get("Content-Type") or fetch.headers.get("content-type")), ) if fetch.body: v.sha256_prefix = hashlib.sha256(fetch.body.encode("utf-8", errors="ignore")).hexdigest()[:16] def pass_check(msg: str) -> None: v.checks.append(msg) def fail(msg: str) -> None: v.failures.append(msg) if fetch.error: fail(f"HTTP error: {fetch.error}") return v if fetch.status_code != 200: fail(f"HTTP status {fetch.status_code}") return v if fetch.blocked or fetch.challenge: fail("Response flagged as WAF/challenge") return v if fetch.protection: pass_check(f"CDN/WAF present on path ({', '.join(fetch.protection)}) but response not blocked") ct_ok = content_type_is_javascript(fetch.headers) if ct_ok is True: pass_check("Content-Type is JavaScript") elif ct_ok is False: fail("Content-Type is HTML, not JavaScript") else: pass_check("Content-Type header missing (common on static CDN)") if looks_like_html_response(fetch.body): fail("Body looks like HTML, not JavaScript") return v if looks_like_waf_body(fetch.body): fail("Body contains WAF/challenge markers") return v if v.size_bytes < 3000: fail(f"Body too small for XenForo lightbox.js ({v.size_bytes} bytes)") return v structural = { "XF.Lightbox or XF.extend": "XF.Lightbox" in fetch.body or "XF.extend" in fetch.body, "updateCaption": "updateCaption" in fetch.body, "Mustache.render": "Mustache.render" in fetch.body, "fancybox": "fancybox" in fetch.body, "lb-caption-extra-html": "lb-caption-extra-html" in fetch.body, } for name, ok in structural.items(): if ok: pass_check(f"Structural marker: {name}") else: fail(f"Missing structural marker: {name}") template_literal = extract_update_caption_template(fetch.body) if template_literal: pass_check("updateCaption Mustache template block located") if "{{{extra_html}}}" in template_literal: v.vulnerable = True pass_check("CVE-35055: unescaped {{{extra_html}}} in updateCaption template") elif "{{extra_html}}" in template_literal: v.patched = True pass_check("Patched pattern: escaped {{extra_html}} only (no triple braces)") else: fail("updateCaption template found but extra_html slot missing") elif "{{{extra_html}}}" in fetch.body: fail("{{{extra_html}}} found outside updateCaption template — needs context confirmation") elif "{{extra_html}}" in fetch.body and "lb-caption-extra-html" in fetch.body: v.patched = True pass_check("Likely patched: extra_html present without triple-brace template") passed_structural = sum(1 for ok in structural.values() if ok) v.genuine = ( not v.blocked and not v.failures and passed_structural >= 4 and not looks_like_html_response(fetch.body) ) if v.genuine and v.vulnerable and core_ok: v.confidence = "HIGH" elif v.genuine and v.vulnerable: v.confidence = "MEDIUM" elif v.genuine and v.patched: v.confidence = "HIGH" elif v.vulnerable and passed_structural >= 3: v.confidence = "LOW" v.genuine = False else: v.confidence = "NONE" return v def looks_like_xenforo_html(text: str) -> bool: lower = text.lower() return any(token in lower for token in ( "xenforo", "data-xf-init", "xf.config", "js/xf/", "xfes.js" )) def is_xenforo_version(version: str) -> bool: t = version_tuple(version) return t is not None and t[0] == 2 def extract_version_from_text(text: str) -> str | None: patterns = [ r'data-version=["\'](2\.\d+\.\d+)["\']', r"XF\.\$version\s*=\s*['\"](2\.\d+\.\d+)['\"]", r"public static \$version = '(2\.\d+\.\d+)'", r"p-footer-version[^>]*>v?(2\.\d+\.\d+)", r'"version"\s*:\s*"(2\.\d+\.\d+)"', ] for pattern in patterns: m = re.search(pattern, text, re.I) if m and is_xenforo_version(m.group(1)): return m.group(1) return None def http_get(url: str, session: Any, timeout: int, *, as_javascript: bool = False) -> HttpFetchResult: headers = None if as_javascript: headers = { "Accept": "application/javascript,text/javascript,*/*;q=0.8", } try: resp = session.get(url, timeout=timeout, allow_redirects=True, headers=headers) body = resp.text[:800000] headers = {k: v for k, v in resp.headers.items()} protection, blocked, challenge = detect_protection(resp.status_code, headers, body) ok = resp.status_code == 200 and not blocked and not challenge return HttpFetchResult( url=url, status_code=resp.status_code, ok=ok, body=body, headers=headers, protection=protection, blocked=blocked, challenge=challenge, ) except requests.RequestException as exc: return HttpFetchResult( url=url, status_code=None, ok=False, body="", headers={}, error=str(exc), blocked=True, ) def scan_remote_url(url: str, timeout: int = 15, verify_ssl: bool = True) -> RemoteScanResult: result = RemoteScanResult() session = build_http_session(verify_ssl=verify_ssl) if session is None: result.notes.append("requests not installed — pip install requests") return result base = normalize_url(url) combined_text = "" for rel in REMOTE_ASSET_PATHS: endpoint = urljoin(base + "/", rel) if rel else base + "/" fetch = http_get(endpoint, session, timeout, as_javascript=rel.endswith(".js")) if rel == "": result.homepage = fetch elif rel.startswith("js/xf/core"): if fetch.status_code == 200 and not fetch.blocked and not fetch.challenge: result.core_js = fetch if fetch.error: result.notes.append(f"{endpoint}: {fetch.error}") continue if fetch.status_code == 200: result.reachable = True if looks_like_xenforo_html(fetch.body): result.is_xenforo = True if not (fetch.blocked or fetch.challenge): combined_text += "\n" + fetch.body result.protection.extend(fetch.protection) result.any_blocked = result.any_blocked or fetch.blocked result.any_challenge = result.any_challenge or fetch.challenge core_ok = bool(result.core_js and looks_like_xenforo_core_js(result.core_js.body)) for rel in LIGHTBOX_PATHS: endpoint = urljoin(base + "/", rel) fetch = http_get(endpoint, session, timeout, as_javascript=True) if fetch.status_code == 200 and looks_like_lightbox_js(fetch.body) and not ( fetch.blocked or fetch.challenge ): result.lightbox = fetch combined_text += "\n" + fetch.body break result.protection.extend(fetch.protection) if not result.lightbox and fetch.status_code: result.notes.append( f"{rel}: HTTP {fetch.status_code}" + (f" ({', '.join(fetch.protection)})" if fetch.protection else "") ) if result.lightbox: result.lightbox_validation = validate_lightbox_response(result.lightbox, core_ok=core_ok) lv = result.lightbox_validation if lv.sha256_prefix: result.notes.append(f"lightbox.js fingerprint: sha256:{lv.sha256_prefix}… ({lv.size_bytes} bytes)") if core_ok: result.notes.append("core.js corroboration: genuine XenForo JavaScript confirmed") elif result.core_js: result.notes.append("core.js fetched but failed XenForo structural validation") else: result.notes.append("core.js not available for corroboration") for check in lv.checks: result.notes.append(f"verify OK: {check}") for failure in lv.failures: result.notes.append(f"verify FAIL: {failure}") if lv.confidence == "HIGH" and lv.vulnerable: result.notes.append("CVE-35055: HIGH confidence — genuine lightbox.js with vulnerable template") elif lv.confidence == "MEDIUM" and lv.vulnerable: result.notes.append("CVE-35055: MEDIUM confidence — vulnerable pattern without core.js corroboration") elif lv.patched and lv.confidence == "HIGH": result.notes.append("CVE-35055: HIGH confidence — genuine lightbox.js appears patched") elif lv.vulnerable and lv.confidence == "LOW": result.notes.append("CVE-35055: LOW confidence — pattern seen but file authenticity uncertain") result.protection = list(dict.fromkeys(result.protection)) result.version = extract_version_from_text(combined_text) if result.reachable and not result.version: result.notes.append("Version not exposed in public HTML/JS (common with XenForo)") if result.any_challenge: result.notes.append("Cloudflare/WAF challenge detected — static JS may be blocked for bots") if result.lightbox and not result.lightbox.blocked and not result.lightbox.challenge: result.notes.append("Homepage challenged; static /js/xf/lightbox.js still reachable") if not result.lightbox: result.notes.append("Could not fetch valid lightbox.js through protection layer") elif result.lightbox.blocked or result.lightbox.challenge: result.notes.append("lightbox.js response looks blocked/challenged — result may be inconclusive") return result def analyze_lightbox_js(text: str, source: str) -> list[str]: notes: list[str] = [] if "{{{extra_html}}}" in text: notes.append( f"{source}: unescaped Mustache {{{{extra_html}}}} (CVE-2026-35055 indicator)" ) elif re.search(r"updateCaption[\s\S]{0,2000}\{\{extra_html\}\}", text): notes.append(f"{source}: patched Mustache {{extra_html}} (CVE-2026-35055 mitigated)") if "lb-caption-extra-html" in text: notes.append(f"{source}: data-lb-caption-extra-html used in lightbox caption") return notes def check_cve_35055_files(path: Path) -> list[str]: lightbox = path / "js" / "xf" / "lightbox.js" if not lightbox.is_file(): return ["lightbox.js not found locally"] return analyze_lightbox_js(lightbox.read_text(encoding="utf-8", errors="ignore"), "Local") def check_cve_35057_files(path: Path) -> list[str]: notes: list[str] = [] formatter = path / "src" / "XF" / "Str" / "Formatter.php" if not formatter.is_file(): return ["Formatter.php not found"] text = formatter.read_text(encoding="utf-8", errors="ignore") if "removeHtmlPlaceholders" in text and "linkStructuredTextMentions" in text: notes.append("Formatter.php: mention placeholder chain present (CVE-2026-35057 surface)") if re.search(r"removeHtmlPlaceholders\(\$match\[3\]\)", text): notes.append("Formatter.php: removeHtmlPlaceholders on username before escape") return notes def run_formatter_probe(path: Path, php_bin: str) -> dict[str, Any] | None: script_dir = Path(__file__).resolve().parent probe = next( (p for p in ( script_dir / "xenforo_formatter_probe.php", path / "security_tools" / "xenforo_formatter_probe.php", ) if p.is_file()), None, ) if probe is None: return None try: proc = subprocess.run( [php_bin, str(probe)], capture_output=True, text=True, timeout=30, cwd=str(path), ) except FileNotFoundError: return {"error": f"PHP binary not found: {php_bin}"} except subprocess.TimeoutExpired: return {"error": "PHP probe timed out"} if proc.returncode != 0: return {"error": proc.stderr.strip() or proc.stdout.strip() or "PHP probe failed"} try: return json.loads(proc.stdout) except json.JSONDecodeError: return {"error": "Invalid JSON from PHP probe", "raw": proc.stdout[:500]} def print_report( target: str, scan_mode: str, version: str | None, version_id: int | None, findings: list[Finding], remote: RemoteScanResult | None, extra: dict[str, Any] | None, elapsed_ms: int, ) -> None: print("=" * 60) print("XenForo XSS CVE Scanner") print("=" * 60) print(f"Target : {target}") print(f"Scan mode : {scan_mode}") if scan_mode.startswith("remote"): print("Scope : Authorized remote scan — static public assets only") if remote and remote.protection: print(f"Protection : {', '.join(remote.protection)}") elif remote and remote.any_blocked: print("Protection : possible WAF/block (see details below)") print(f"Version : {version or 'unknown'}") print(f"Version ID : {version_id or 'unknown'}") if version_id: for cve, threshold in PATCH_VERSIONS.items(): meta = CVE_META[cve] st = version_status(cve, version_id, version) print(f" {cve} threshold: {threshold} ({meta['fixed_22']} / {meta['fixed_23']}) -> {st}") elif version: for cve in PATCH_VERSIONS: st = version_status(cve, None, version) meta = CVE_META[cve] branch = version_branch(version) or "?" fixed_v = FIXED_VERSION_STRINGS.get(cve, {}).get(branch, "n/a") print(f" {cve} fixed: {fixed_v} (branch {branch}) -> {st}") if remote and remote.notes: print("-" * 60) print("Remote verification:") for note in remote.notes: print(f" - {note}") print("-" * 60) for f in findings: print(f"\n[{f.status}] {f.cve} — {CVE_META[f.cve]['title']} ({CVE_META[f.cve]['severity']})") for line in f.details: print(f" - {line}") if extra: print("\n" + "-" * 60) print("Formatter probe (CVE-2026-35057):") if "error" in extra: print(f" ERROR: {extra['error']}") elif "results" in extra: for name, result in extra["results"].items(): flag = "VULNERABLE" if result.get("vulnerable") else "not triggered" print(f" - vector '{name}': {flag}") print("\n" + "=" * 60) print(f"Scan completed in {elapsed_ms} ms") if scan_mode.startswith("remote"): print("WAF note: VULNERABLE requires genuine lightbox.js validation, not just pattern match.") print("HIGH = structural JS checks + core.js corroboration + {{{extra_html}}} in updateCaption.") print("If BLOCKED/INCONCLUSIVE, test from server with --path or whitelist your IP.") print("Remediation: upgrade to XenForo 2.2.19+ or 2.3.10+ (licensed).") print("LEGAL: Only scan systems you own or have written permission to test.") def build_findings( path: Path | None, version: str | None, version_id: int | None, php_bin: str | None, remote: RemoteScanResult | None, remote_only: bool, ) -> tuple[list[Finding], dict[str, Any] | None]: findings: list[Finding] = [] probe_data: dict[str, Any] | None = None branch = version_branch(version) if version else None # CVE-2026-35054 st54 = version_status("CVE-2026-35054", version_id, version) details54 = ["Affects XenForo 2.3.x before 2.3.9 only."] if branch == "2.2" or st54 == "NOT_APPLICABLE": details54.append("2.2.x installs: NOT_APPLICABLE.") elif remote_only and not version: details54.append("URL scan: version unknown — cannot confirm 2.3.x exposure.") findings.append(Finding("CVE-2026-35054", st54, details54)) # CVE-2026-35055 st55 = version_status("CVE-2026-35055", version_id, version) details55 = [ "XSS when lightbox renders post media/captions.", f"Fixed in {CVE_META['CVE-2026-35055']['fixed_22']} (2.2) / {CVE_META['CVE-2026-35055']['fixed_23']} (2.3).", ] if path: details55.extend(check_cve_35055_files(path)) lv = remote.lightbox_validation if remote else None if remote and remote.lightbox: if remote.lightbox.url: details55.append(f"Fetched: {remote.lightbox.url}") if lv: details55.append(f"Authenticity: {lv.confidence} confidence ({lv.size_bytes} bytes)") if lv.sha256_prefix: details55.append(f"SHA256 prefix: {lv.sha256_prefix}") for check in lv.checks: if "CVE-35055" in check or "Patched" in check or "Structural" in check: details55.append(check) elif looks_like_lightbox_js(remote.lightbox.body): details55.extend(analyze_lightbox_js(remote.lightbox.body, "Remote")) elif remote_only: if remote and (remote.any_challenge or remote.any_blocked) and not remote.lightbox: st55 = "BLOCKED" details55.append("WAF/Cloudflare blocked lightbox.js — cannot confirm remotely") elif remote and remote.lightbox and not looks_like_lightbox_js(remote.lightbox.body): st55 = "INCONCLUSIVE" details55.append("Response was not valid XenForo lightbox.js (possible WAF HTML page)") elif remote_only and not remote.lightbox: st55 = "INCONCLUSIVE" details55.append("lightbox.js not retrieved — try from origin server with --path") if lv: if lv.blocked: st55 = "BLOCKED" details55.append("lightbox.js response blocked by WAF — no reliable detection") elif lv.patched and lv.confidence in ("HIGH", "MEDIUM"): st55 = "LIKELY_PATCHED" details55.append("Genuine XenForo lightbox.js uses escaped {{extra_html}} (not {{{extra_html}}})") elif lv.vulnerable and lv.confidence == "HIGH": st55 = "VULNERABLE" details55.append("Verified: genuine XenForo lightbox.js + unescaped {{{extra_html}}} in updateCaption") elif lv.vulnerable and lv.confidence == "MEDIUM": st55 = "VULNERABLE" details55.append("Probable: vulnerable template found; core.js corroboration missing") elif lv.vulnerable and lv.confidence == "LOW": st55 = "INCONCLUSIVE" details55.append("Pattern match only — file failed authenticity checks (possible WAF/cache noise)") elif not lv.genuine: st55 = "INCONCLUSIVE" details55.append("Could not verify response as genuine XenForo lightbox.js") elif path and not remote_only: patched_local = any("mitigated" in d.lower() for d in details55) vuln_local = any("unescaped" in d.lower() for d in details55) if patched_local and not vuln_local: st55 = "LIKELY_PATCHED" details55.append("Local lightbox.js manually patched for CVE-35055") elif vuln_local and st55 in ("VULNERABLE", "UNKNOWN"): st55 = "VULNERABLE" details55.append("Local lightbox.js contains CVE-35055 indicator") elif remote and remote.lightbox and looks_like_lightbox_js(remote.lightbox.body): has_indicator = any("extra_html" in d.lower() or "unescaped" in d.lower() for d in details55) if has_indicator and st55 not in ("BLOCKED", "LIKELY_PATCHED"): st55 = "VULNERABLE" details55.append("Lightbox indicator found (legacy check — see verify notes above)") findings.append(Finding("CVE-2026-35055", st55, details55)) # CVE-2026-35057 st57 = version_status("CVE-2026-35057", version_id, version) details57 = [ "Stored XSS via structured text mention placeholder collision.", f"Fixed in {CVE_META['CVE-2026-35057']['fixed_22']} (2.2) / {CVE_META['CVE-2026-35057']['fixed_23']} (2.3).", ] if path: details57.extend(check_cve_35057_files(path)) if php_bin: probe_data = run_formatter_probe(path, php_bin) if probe_data and "results" in probe_data: triggered = [n for n, r in probe_data["results"].items() if r.get("vulnerable")] if triggered: st57 = "VULNERABLE" details57.append(f"Formatter simulation triggered: {', '.join(triggered)}") else: details57.append("Formatter simulation: built-in vectors not triggered") elif probe_data and "error" in probe_data: details57.append(f"Formatter probe error: {probe_data['error']}") fmt_text = (path / "src" / "XF" / "Str" / "Formatter.php").read_text(encoding="utf-8", errors="ignore") if path else "" if "CVE-2026-35057" in fmt_text and st57 == "VULNERABLE": if probe_data and "results" in probe_data and not any(r.get("vulnerable") for r in probe_data["results"].values()): st57 = "LIKELY_PATCHED" details57.append("Formatter.php manually patched; probe vectors not triggered") elif remote_only: details57.append("URL scan: Formatter.php not reachable over HTTP") if version and version_status("CVE-2026-35057", version_id, version) == "VULNERABLE": st57 = "VULNERABLE" details57.append(f"Version {version} below fixed release") elif st57 == "UNKNOWN": details57.append("Use --path on the server for full CVE-35057 check") findings.append(Finding("CVE-2026-35057", st57, details57)) return findings, probe_data def resolve_targets( positional: str | None, path_arg: str | None, url_arg: str | None, ) -> tuple[Path | None, str | None, str, str]: if positional: if is_url(positional): url = normalize_url(positional) return None, url, url, "remote (URL)" local = Path(positional).resolve() return local, normalize_url(url_arg) if url_arg else None, str(local), "local (path)" if url_arg and not path_arg: url = normalize_url(url_arg) return None, url, url, "remote (URL)" if path_arg: local = Path(path_arg).resolve() url = normalize_url(url_arg) if url_arg else None mode = "local + remote" if url else "local (path)" return local, url, str(local) + (f" + {url}" if url else ""), mode default = Path(__file__).resolve().parent.parent return default.resolve(), None, str(default), "local (path)" def main() -> int: epilog = """ Examples: Remote site (authorized only): python xenforo_xss_scanner.py https://forum.example.com/ python xenforo_xss_scanner.py https://forum.example.com/ --timeout 20 Local + remote: python xenforo_xss_scanner.py --path C:\\xampp\\htdocs\\forum --url https://forum.example.com CXSecurity advisory: python xenforo_xss_scanner.py --print-advisory LEGAL: Only scan systems you own or have written permission to test. """ parser = argparse.ArgumentParser( description="XenForo XSS CVE scanner (authorized use only)", epilog=epilog, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("target", nargs="?", help="URL or local XenForo path") parser.add_argument("--path", help="Local XenForo root") parser.add_argument("--url", help="Remote forum URL") parser.add_argument("--php", default="php", help="PHP binary for local formatter probe") parser.add_argument("--timeout", type=int, default=15, help="HTTP timeout seconds (default 15)") parser.add_argument("--insecure", action="store_true", help="Skip SSL certificate verification") parser.add_argument("--no-php-probe", action="store_true", help="Skip local PHP probe") parser.add_argument( "--i-understand-authorized-use-only", action="store_true", help="Confirm you have permission to scan the target URL", ) parser.add_argument( "--print-advisory", action="store_true", help="Print CXSecurity advisory text and exit", ) args = parser.parse_args() if args.print_advisory: print_advisory() return 0 started = time.perf_counter() path, url, display_target, scan_mode = resolve_targets(args.target, args.path, args.url) remote_only = path is None and url is not None if remote_only and not args.i_understand_authorized_use_only: print( "Error: Remote URL scan requires --i-understand-authorized-use-only\n" "You must own the site or have written permission to test it.", file=sys.stderr, ) return 2 if remote_only and requests is None: print("Error: pip install -r requirements.txt", file=sys.stderr) return 2 version: str | None = None version_id: int | None = None remote_result: RemoteScanResult | None = None if path is not None: if not (path / "src" / "XF.php").is_file(): print(f"Error: XenForo not found at {path}", file=sys.stderr) return 1 version, version_id = parse_version_from_xf_php(path) if url: remote_result = scan_remote_url(url, timeout=args.timeout, verify_ssl=not args.insecure) if not remote_result.reachable: print(f"Error: Could not reach {url}", file=sys.stderr) for note in remote_result.notes: print(f" - {note}", file=sys.stderr) return 1 if not remote_result.is_xenforo: print(f"Warning: {url} may not be XenForo", file=sys.stderr) if remote_result.version and version is None: version = remote_result.version php_bin = None if args.no_php_probe or remote_only else args.php findings, probe_data = build_findings( path, version, version_id, php_bin, remote_result, remote_only ) elapsed_ms = int((time.perf_counter() - started) * 1000) print_report(display_target, scan_mode, version, version_id, findings, remote_result, probe_data, elapsed_ms) bad = [f for f in findings if f.status in ("VULNERABLE", "BLOCKED", "INCONCLUSIVE")] return 1 if any(f.status == "VULNERABLE" for f in findings) else (2 if bad else 0) if __name__ == "__main__": raise SystemExit(main()) Example 1: Findings from a forum. XenForo XSS CVE Scanner ============================================================ Target : https://example.com/forum Scan mode : remote (URL) Scope : Authorized remote scan — static public assets only Protection : Cloudflare, Cloudflare challenge page Version : unknown Version ID : unknown ------------------------------------------------------------ Remote verification: - lightbox.js fingerprint: sha256:7ee81d0d2a931139… (22056 bytes) - core.js corroboration: genuine XenForo JavaScript confirmed - verify OK: CDN/WAF present on path (Cloudflare) but response not blocked - verify OK: Content-Type is JavaScript - verify OK: Structural marker: XF.Lightbox or XF.extend - verify OK: Structural marker: updateCaption - verify OK: Structural marker: Mustache.render - verify OK: Structural marker: fancybox - verify OK: Structural marker: lb-caption-extra-html - verify OK: updateCaption Mustache template block located - verify OK: CVE-35055: unescaped {{{extra_html}}} in updateCaption template - CVE-35055: HIGH confidence — genuine lightbox.js with vulnerable template - Version not exposed in public HTML/JS (common with XenForo) - Cloudflare/WAF challenge detected — static JS may be blocked for bots - Homepage challenged; static /js/xf/lightbox.js still reachable ------------------------------------------------------------ [UNKNOWN] CVE-2026-35054 — BB code stored XSS (Medium) - Affects XenForo 2.3.x before 2.3.9 only. - URL scan: version unknown — cannot confirm 2.3.x exposure. [VULNERABLE] CVE-2026-35055 — Lightbox XSS (Medium) - XSS when lightbox renders post media/captions. - Fixed in 2.2.18 (2.2) / 2.3.9 (2.3). - Fetched: https://example.com/forum/js/xf/lightbox.js - Authenticity: HIGH confidence (22056 bytes) - SHA256 prefix: 7ee81d0d2a931139 - Structural marker: XF.Lightbox or XF.extend - Structural marker: updateCaption - Structural marker: Mustache.render - Structural marker: fancybox - Structural marker: lb-caption-extra-html - CVE-35055: unescaped {{{extra_html}}} in updateCaption template - Verified: genuine XenForo lightbox.js + unescaped {{{extra_html}}} in updateCaption [UNKNOWN] CVE-2026-35057 — Structured mention stored XSS (Medium) - Stored XSS via structured text mention placeholder collision. - Fixed in 2.2.19 (2.2) / 2.3.10 (2.3). - URL scan: Formatter.php not reachable over HTTP - Use --path on the server for full CVE-35057 check ============================================================ Scan completed in 1321 ms WAF note: VULNERABLE requires genuine lightbox.js validation, not just pattern match. HIGH = structural JS checks + core.js corroboration + {{{extra_html}}} in updateCaption. If BLOCKED/INCONCLUSIVE, test from server with --path or whitelist your IP. Remediation: upgrade to XenForo 2.2.19+ or 2.3.10+ (licensed). LEGAL: Only scan systems you own or have written permission to test.
References:
Tool type: Passive detection / assessment scanner (NOT an exploit, NOT original CVE discovery) Detects exposure to publicly disclosed XenForo XSS issues: - CVE-2026-35055 (Lightbox XSS) - CVE-2026-35054 (BB Code Stored XSS, 2.3.x) - CVE-2026-35057 (Structured Mention Stored XSS) Authorized use only. Does not post malicious content or run active exploitation. Vendor advisories:
https://xenforo.com/community/threads/xenforo-2-3-9-inc-xfmg-2-2-18-released-security-fix.235659/
https://xenforo.com/community/threads/xenforo-2-3-10-add-ons-and-2-2-19-released-includes-security-fix.236249/
NVD:
https://nvd.nist.gov/vuln/detail/CVE-2026-35055
https://nvd.nist.gov/vuln/detail/CVE-2026-35054
https://nvd.nist.gov/vuln/detail/CVE-2026-35057
Tool author: Xasthur
{{ x.nick }}
{{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1 {{ x.comment }} |