#!/usr/bin/env python3 """ CVE-2026-39842 - OpenRemote Expression Injection Detection Script ================================================================= Detects OpenRemote instances vulnerable to CVE-2026-39842 (CVSS 10.0) Expression injection in Rules Engine via unsandboxed Nashorn ScriptEngine.eval() Affected: OpenRemote <= 1.21.0 Fixed: OpenRemote >= 1.22.0 Detection Method: 1. Fingerprints OpenRemote via known API endpoints and response patterns 2. Extracts version information from /api/master/info or HTML meta tags 3. Checks if the rules API endpoint is accessible 4. Compares detected version against the fixed version (1.22.0) Author: @kerattin Date: 2026-04-16 License: MIT """ import argparse import json import re import sys import urllib.request import urllib.error import ssl from typing import Optional, Tuple # ANSI color codes class Colors: RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" BLUE = "\033[94m" MAGENTA = "\033[95m" CYAN = "\033[96m" BOLD = "\033[1m" RESET = "\033[0m" BANNER = f"""{Colors.CYAN}{Colors.BOLD} ___ ____ _____ _ _ ____ _____ __ __ ___ _____ _____ / _ \\| _ \\| ____| \\ | | _ \\| ____| \\/ |/ _ \\_ _| ____| | | | | |_) | _| | \\| | |_) | _| | |\\/| | | | || | | _| | |_| | __/| |___| |\\ | _ <| |___| | | | |_| || | | |___ \\___/|_| |_____|_| \\_|_| \\_\\_____|_| |_|\\___/ |_| |_____| CVE-2026-39842 Detection Script Expression Injection in Rules Engine (CVSS 10.0) {Colors.RESET}""" FIXED_VERSION = (1, 22, 0) TIMEOUT = 10 def parse_version(version_str: str) -> Optional[Tuple[int, ...]]: """Parse a version string like '1.21.0' into a tuple of integers.""" match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_str) if match: return tuple(int(x) for x in match.groups()) match = re.search(r"(\d+)\.(\d+)", version_str) if match: return tuple(int(x) for x in match.groups()) + (0,) return None def make_request(url: str, timeout: int = TIMEOUT, method: str = "GET") -> Tuple[Optional[int], Optional[str], dict]: """Make an HTTP request and return (status_code, body, headers).""" ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: req = urllib.request.Request(url, method=method) req.add_header("User-Agent", "CVE-2026-39842-Scanner/1.0") req.add_header("Accept", "application/json, text/html, */*") resp = urllib.request.urlopen(req, timeout=timeout, context=ctx) body = resp.read().decode("utf-8", errors="replace") headers = dict(resp.headers) return resp.status, body, headers except urllib.error.HTTPError as e: body = "" try: body = e.read().decode("utf-8", errors="replace") except Exception: pass return e.code, body, dict(e.headers) if e.headers else {} except Exception: return None, None, {} def detect_openremote(target: str) -> dict: """ Detect if target is running OpenRemote and check vulnerability status. Returns a dict with: - is_openremote: bool - version: str or None - version_tuple: tuple or None - is_vulnerable: bool or None (None if version unknown) - rules_api_accessible: bool - details: str - evidence: list of strings """ result = { "target": target, "is_openremote": False, "version": None, "version_tuple": None, "is_vulnerable": None, "rules_api_accessible": False, "js_rules_enabled": None, "details": "", "evidence": [], } # Normalize target URL if not target.startswith("http"): target = f"https://{target}" target = target.rstrip("/") # ---- Step 1: Check /api/master/info endpoint ---- info_url = f"{target}/api/master/info" status, body, headers = make_request(info_url) if status == 200 and body: try: info = json.loads(body) result["is_openremote"] = True result["evidence"].append(f"API info endpoint responded: {info_url}") # Version from info response version = info.get("version", info.get("appVersion", "")) if version: result["version"] = version result["version_tuple"] = parse_version(version) result["evidence"].append(f"Version from API: {version}") except json.JSONDecodeError: # Might still be OpenRemote with non-JSON response if "openremote" in body.lower(): result["is_openremote"] = True result["evidence"].append("OpenRemote keyword found in info endpoint response") # ---- Step 2: Check main page for OpenRemote fingerprints ---- if not result["is_openremote"]: status, body, headers = make_request(target) if status and body: openremote_indicators = [ "openremote", "OpenRemote", "or-app", "or-header", "or-realm-picker", "/manager/", "keycloak.js", ] for indicator in openremote_indicators: if indicator in body: result["is_openremote"] = True result["evidence"].append(f"Fingerprint '{indicator}' found in main page") break # Try to extract version from HTML ver_match = re.search(r"openremote[/\-]?v?(\d+\.\d+\.\d+)", body, re.IGNORECASE) if ver_match: result["version"] = ver_match.group(1) result["version_tuple"] = parse_version(ver_match.group(1)) result["evidence"].append(f"Version from HTML: {ver_match.group(1)}") # ---- Step 3: Check /swagger endpoint for API docs ---- if not result["is_openremote"]: swagger_url = f"{target}/swagger" status, body, headers = make_request(swagger_url) if status and body and "openremote" in body.lower(): result["is_openremote"] = True result["evidence"].append("OpenRemote found in Swagger docs") if not result["is_openremote"]: result["details"] = "Target does not appear to be running OpenRemote" return result # ---- Step 4: Check rules API accessibility ---- rules_endpoints = [ f"{target}/api/master/rules/realm", f"{target}/api/master/rules", ] for rules_url in rules_endpoints: status, body, headers = make_request(rules_url) if status in (200, 401, 403): result["rules_api_accessible"] = True result["evidence"].append(f"Rules API endpoint reachable: {rules_url} (HTTP {status})") if status == 200 and body: try: rules_data = json.loads(body) if isinstance(rules_data, list): for rule in rules_data: lang = rule.get("lang", "").upper() if lang == "JAVASCRIPT": result["js_rules_enabled"] = True result["evidence"].append("JavaScript rules found in rules listing") break except (json.JSONDecodeError, TypeError): pass break # ---- Step 5: Determine vulnerability status ---- if result["version_tuple"]: if result["version_tuple"] < FIXED_VERSION: result["is_vulnerable"] = True result["details"] = ( f"VULNERABLE: OpenRemote {result['version']} is below the fixed version 1.22.0. " f"The Nashorn ScriptEngine.eval() executes user-supplied JavaScript rules without " f"sandboxing or ClassFilter restrictions. Any user with write:rules role can achieve " f"RCE as root via Java.type() reflection." ) else: result["is_vulnerable"] = False result["details"] = ( f"NOT VULNERABLE: OpenRemote {result['version']} >= 1.22.0. " f"JavaScript rules have been deprecated and removed in this version." ) else: result["is_vulnerable"] = None result["details"] = ( "OpenRemote detected but version could not be determined. " "Manual verification recommended. Check if version <= 1.21.0." ) return result def print_result(result: dict, verbose: bool = False) -> None: """Pretty-print detection results.""" target = result["target"] print(f"\n{Colors.BOLD}Target:{Colors.RESET} {target}") print(f"{'=' * 60}") if not result["is_openremote"]: print(f"{Colors.BLUE}[INFO]{Colors.RESET} Not an OpenRemote instance") return print(f"{Colors.GREEN}[+]{Colors.RESET} OpenRemote instance detected") if result["version"]: print(f"{Colors.GREEN}[+]{Colors.RESET} Version: {result['version']}") if result["rules_api_accessible"]: print(f"{Colors.YELLOW}[!]{Colors.RESET} Rules API endpoint is reachable") if result["js_rules_enabled"]: print(f"{Colors.RED}[!]{Colors.RESET} JavaScript rules are active on this instance") if result["is_vulnerable"] is True: print(f"\n{Colors.RED}{Colors.BOLD}[VULNERABLE]{Colors.RESET} {result['details']}") print(f"\n{Colors.YELLOW}Vulnerability Details:{Colors.RESET}") print(f" CVE: CVE-2026-39842") print(f" CVSS: 10.0 (Critical)") print(f" CWE: CWE-94 (Code Injection), CWE-917 (EL Injection)") print(f" Attack Vector: Network / Low Privileges (write:rules)") print(f" Impact: RCE as root, file read, credential theft, tenant bypass") print(f" Fix: Upgrade to OpenRemote >= 1.22.0") elif result["is_vulnerable"] is False: print(f"\n{Colors.GREEN}{Colors.BOLD}[NOT VULNERABLE]{Colors.RESET} {result['details']}") else: print(f"\n{Colors.YELLOW}{Colors.BOLD}[UNKNOWN]{Colors.RESET} {result['details']}") if verbose and result["evidence"]: print(f"\n{Colors.CYAN}Evidence:{Colors.RESET}") for ev in result["evidence"]: print(f" - {ev}") def main(): global TIMEOUT parser = argparse.ArgumentParser( description="CVE-2026-39842 - OpenRemote Expression Injection Detection Script", epilog="Example: python3 detect_openremote.py -t https://demo.openremote.io", ) parser.add_argument( "-t", "--target", help="Single target URL or IP (e.g., https://demo.openremote.io)", ) parser.add_argument( "-l", "--list", help="File containing list of targets (one per line)", ) parser.add_argument( "-o", "--output", help="Output results to JSON file", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Show detailed evidence for each finding", ) parser.add_argument( "--timeout", type=int, default=TIMEOUT, help=f"HTTP request timeout in seconds (default: {TIMEOUT})", ) parser.add_argument( "--no-banner", action="store_true", help="Suppress the banner", ) args = parser.parse_args() if not args.target and not args.list: parser.print_help() sys.exit(1) if not args.no_banner: print(BANNER) TIMEOUT = args.timeout targets = [] if args.target: targets.append(args.target) if args.list: try: with open(args.list, "r") as f: for line in f: line = line.strip() if line and not line.startswith("#"): targets.append(line) except FileNotFoundError: print(f"{Colors.RED}[ERROR]{Colors.RESET} File not found: {args.list}") sys.exit(1) all_results = [] vulnerable_count = 0 total_openremote = 0 for target in targets: result = detect_openremote(target) all_results.append(result) print_result(result, verbose=args.verbose) if result["is_openremote"]: total_openremote += 1 if result["is_vulnerable"]: vulnerable_count += 1 # Summary print(f"\n{'=' * 60}") print(f"{Colors.BOLD}Scan Summary{Colors.RESET}") print(f" Targets scanned: {len(targets)}") print(f" OpenRemote found: {total_openremote}") print(f" Vulnerable: {Colors.RED}{vulnerable_count}{Colors.RESET}") print(f" Not vulnerable: {total_openremote - vulnerable_count}") # JSON output if args.output: with open(args.output, "w") as f: json.dump(all_results, f, indent=2, default=str) print(f"\n{Colors.GREEN}[+]{Colors.RESET} Results saved to {args.output}") if __name__ == "__main__": main()