# Variant RCA Report — CVE-2026-58138

## Summary

No bypass of the fix was found. Three materially-different alternate triggers
for the same root cause were confirmed on the vulnerable version (3.22.3) and
all were blocked on the fixed version (3.30.2). The tested variants are: (A)
Python evaluator via INLINE task — a separate GraalVM context builder
(`PythonEvaluator`) with its own fix; (B) LAMBDA task — a deprecated task type
that evaluates JavaScript via the same `ScriptEvaluator` sink but through a
different task dispatch path; (C) graaljs evaluator via INLINE — an alias
evaluator type that confirms the `javascript`/`graaljs` alias path reaches the
same sink. A fourth variant (D) tested a `Java.type()` bypass attempt on the
fixed version to verify that `allowHostClassLoading(false)` blocks host-class-
lookup RCE — it was blocked. The fix in 3.30.2 (commits `87a7d96` +
`c691e35`) is **complete**: all GraalVM context creation sites are patched,
and the binary restrictions (`allowHostClassLoading(false)`,
`allowCreateProcess(false)`, `allowIO(IOAccess.NONE)`) prevent RCE even if the
host-access deny-list has gaps.

## Fix Coverage / Assumptions

**Invariant the fix relies on:** All script evaluation in Conductor goes
through exactly two GraalVM `Context` creation sites:
1. `ScriptEvaluator.createNewContext()` — used by ALL JavaScript paths
   (`javascript` and `graaljs` evaluator types, `LAMBDA`, `DO_WHILE`,
   `DECISION`, event handler conditions, workflow validation).
2. `PythonEvaluator.evaluate()` — used by the `python` evaluator type.

**Code paths explicitly covered:**
- `ScriptEvaluator.createNewContext()`: patched with `denyAccess(...)` for
  Class, ClassLoader, reflection.*, Runtime, ProcessBuilder, Process, System,
  Thread, ThreadGroup + `allowHostClassLoading(false)`,
  `allowCreateProcess(false)`, `allowIO(IOAccess.NONE)`, etc.
- `PythonEvaluator`: `allowAllAccess(true)` removed, defaulting to GraalVM
  safe mode.

**What the fix does NOT cover:** No gaps found. Source analysis confirms only
two `Context.newBuilder(...)` call sites exist in the codebase. All entry
points funnel through these two sinks. The deny-list approach is backed by
binary restrictions that prevent RCE even if non-denied classes are reachable.

## Variant / Alternate Trigger

Three alternate triggers were tested, each reaching the same root cause from a
materially different code path:

### Variant A: Python evaluator via INLINE task
- **Entry point:** `POST /api/metadata/workflow` (register) + `POST /api/workflow/{name}` (start), both unauthenticated.
- **Task type:** `INLINE` with `evaluatorType: "python"`.
- **Sink:** `PythonEvaluator.evaluate()` → `Context.newBuilder("python").allowAllAccess(true).build()`.
- **Payload:** GraalPython `import java.lang.Runtime as Runtime; rt = Runtime.getRuntime(); proc = rt.exec(['sh', '-c', CMD])`.
- **Why distinct:** Separate GraalVM context builder from the JS path. The fix for this sink is different (removing `allowAllAccess(true)` vs the JS deny-list + binary restrictions).

### Variant B: LAMBDA task (deprecated)
- **Entry point:** Same unauthenticated API.
- **Task type:** `LAMBDA` with `scriptExpression` input parameter.
- **Sink:** `Lambda.execute()` → `ScriptEvaluator.eval("function scriptFun(){<expr>} scriptFun();", taskInput)` → `createNewContext()`.
- **Payload:** Reflective JS (`$.getClass().getClass()` → `Class.forName` → `Runtime.exec`), with `return o;` for the function wrapper.
- **Why distinct:** Different task type (`LAMBDA` vs `INLINE`), different expression wrapping (function wrapper vs bare expression), deprecated code path.

### Variant C: graaljs evaluator via INLINE task
- **Entry point:** Same unauthenticated API.
- **Task type:** `INLINE` with `evaluatorType: "graaljs"`.
- **Sink:** `GraalJSEvaluator.evaluate()` → `ScriptEvaluator.eval()` → `createNewContext()`.
- **Payload:** Same reflective JS as the original repro.
- **Why distinct:** Different evaluator type (`graaljs` vs `javascript`), confirms the alias path (`GraalJSEvaluator` delegates to `ScriptEvaluator`).

### Variant D: Java.type() bypass attempt (fixed version only)
- **Entry point:** Same unauthenticated API.
- **Task type:** `INLINE` with `evaluatorType: "javascript"`.
- **Payload:** `Java.type('java.lang.Runtime')` instead of reflection from `$`.
- **Purpose:** Test whether `allowHostClassLoading(false)` in the complete fix blocks host-class-lookup RCE.
- **Result:** Blocked (`FAILED_WITH_TERMINAL_ERROR`).

## Impact

- **Package/component affected:** `conductor-oss/conductor` —
  `core/.../events/ScriptEvaluator.java` (JS evaluator) and
  `core/.../execution/evaluators/PythonEvaluator.java` (Python evaluator).
  Reachable through `INLINE`, `LAMBDA`, `DO_WHILE`, `DECISION`, `SWITCH` task
  types and event handler conditions via the unauthenticated REST API.
- **Affected versions:** 3.21.21 … < 3.30.2 (confirmed on 3.22.3 for all
  variants). Fixed in 3.30.2.
- **Risk level:** Critical — unauthenticated remote code execution as root.

## Impact Parity

- **Disclosed/claimed maximum impact:** Unauthenticated remote code execution
  (arbitrary OS command execution) — `code_execution`.
- **Reproduced impact from this variant run:** All three variants (A/B/C)
  achieved full unauthenticated RCE as root on the vulnerable 3.22.3 image.
  The command output (`uid=0(root)…`, marker, hostname) was returned through
  the API, and a marker file was written inside the container filesystem.
- **Parity:** `full` — each variant exercises the same unauthenticated remote
  API → script evaluation → OS command execution chain and demonstrates the
  full claimed impact.
- **Not demonstrated:** Nothing — the variants reached the same full impact as
  the original repro.

## Root Cause

The root cause is identical across all variants: GraalVM polyglot `Context`
objects are created with full host access (`HostAccess.ALL` for JS,
`allowAllAccess(true)` for Python), allowing user-supplied script expressions
to bootstrap Java reflection (or use Java interop) to reach
`java.lang.Runtime.exec()`. The unauthenticated Conductor REST API lets any
remote attacker register and execute workflows containing these expressions.

**Fix commits:**
- `87a7d96aabbb706d6e84f812b93da5165028d18f` — partial fix (deny-list for JS,
  remove `allowAllAccess` for Python).
- `c691e35e768caeb802c9f06ecdd9674c80081af1` — complete fix (add
  `allowHostClassLoading(false)`, `allowCreateProcess(false)`,
  `allowIO(IOAccess.NONE)`, `js.load=false`, shared Engine).

All entry points funnel through the two patched context builders, so the fix
is complete and no bypass was found.

## Reproduction Steps

1. **Reference script:** `bundle/vuln_variant/reproduction_steps.sh` (with
   helper `bundle/vuln_variant/variant_exploit.py`).
2. **What the script does:**
   - Pulls `conductoross/conductor:3.22.3` (vulnerable) and
     `conductoross/conductor:3.30.2` (fixed) Docker images.
   - **Phase 1 (vulnerable):** Starts 3.22.3 with `--network host`, waits for
     `/health`, then runs variants A (python), B (lambda), C (graaljs) — each
     an unauthenticated workflow registration + start with an RCE payload.
     Asserts `task_status=COMPLETED`, `rce_confirmed=true`, and marker file
     present inside the container.
   - **Phase 2 (fixed):** Starts 3.30.2 the same way, runs variants A, B, C,
     and D (Java.type() bypass). Asserts all are blocked
     (`FAILED_WITH_TERMINAL_ERROR`/`FAILED`, `rce_confirmed=false`, no marker
     file).
   - Writes `artifacts/variant_summary.json` and individual JSON results.
   - **Exit 0** = bypass found (some variant reproduces on fixed); **Exit 1**
     = no bypass.
3. **Expected evidence of reproduction:**
   - Vulnerable: `artifacts/vuln_variant_{A,B,C}.json` show
     `rce_confirmed=true`, `task_status=COMPLETED`, and
     `task_output_result` containing `uid=0(root)…` + marker.
     `artifacts/vuln_variant_marker_check.txt` shows the marker file inside
     the container.
   - Fixed: `artifacts/fixed_variant_{A,B,C,D}.json` show
     `rce_confirmed=false`, `task_status=FAILED_WITH_TERMINAL_ERROR` or
     `FAILED`. `artifacts/fixed_variant_marker_check.txt` shows no marker file.

## Evidence

- `bundle/logs/vuln_variant_steps.log` — main run log showing all phases.
- `bundle/artifacts/vuln_variant_A.json` — Python variant on vulnerable:
  `rce_confirmed=true`, output `uid=0(root) gid=0(root) groups=0(root)\nPRUVA_VAR_RCE_<ts>\nmuramasa\n`.
- `bundle/artifacts/vuln_variant_B.json` — LAMBDA variant on vulnerable:
  `rce_confirmed=true`, same output.
- `bundle/artifacts/vuln_variant_C.json` — graaljs variant on vulnerable:
  `rce_confirmed=true`, same output.
- `bundle/artifacts/vuln_variant_marker_check.txt` — marker file confirmed
  inside vulnerable container.
- `bundle/artifacts/fixed_variant_A.json` — Python variant on fixed:
  `rce_confirmed=false`, `task_status=FAILED_WITH_TERMINAL_ERROR`.
- `bundle/artifacts/fixed_variant_B.json` — LAMBDA variant on fixed:
  `rce_confirmed=false`, `task_status=FAILED`.
- `bundle/artifacts/fixed_variant_C.json` — graaljs variant on fixed:
  `rce_confirmed=false`, `task_status=FAILED_WITH_TERMINAL_ERROR`.
- `bundle/artifacts/fixed_variant_D.json` — Java.type() bypass on fixed:
  `rce_confirmed=false`, `task_status=FAILED_WITH_TERMINAL_ERROR`.
- `bundle/artifacts/fixed_variant_marker_check.txt` — no marker file in fixed
  container.
- `bundle/artifacts/variant_summary.json` — structured summary.
- `bundle/logs/vuln_variant/fixed_version.txt` — fixed version commit SHA.
- Environment: Docker `--network host`; sandbox reached conductor via gateway
  `172.20.0.1:8080`; vulnerable image `conductoross/conductor:3.22.3`
  (commit `c969d9b82d4f37e92dffe2fa85da026af32956b3`), fixed image
  `conductoross/conductor:3.30.2` (commit
  `2bea5078f916c0d529cd0f32a55f79425b65a5b8`).

## Recommendations / Next Steps

1. **No bypass found — the fix is complete.** The 3.30.2 fix covers all
   GraalVM context creation sites and blocks RCE through all tested entry
   points (INLINE/javascript, INLINE/graaljs, INLINE/python, LAMBDA,
   Java.type() bypass).

2. **Defense-in-depth for the Python evaluator:** The Python evaluator's fix
   relies on GraalVM's safe defaults (removing `allowAllAccess(true)`). For
   consistency with the JS evaluator, explicitly set
   `allowHostClassLoading(false)`, `allowCreateProcess(false)`,
   `allowIO(IOAccess.NONE)` on the Python context as well.

3. **Consider `HostAccess.NONE` for the JS evaluator:** The current JS fix
   starts with `HostAccess.ALL` and applies a deny-list. Using
   `HostAccess.NONE` and explicitly allowing only data access on the bound `$`
   input would be safer (allow-list vs deny-list).

4. **Authentication:** The community Conductor API is unauthenticated by
   default. Deploy authentication/authorization in front of the API as
   defense-in-depth, regardless of the script sandbox.

5. **Regression tests:** The fix added `InlineTest.java` regression tests.
   Extend these to cover the `python` evaluator type, the `LAMBDA` task type,
   and the `graaljs` evaluator type — all should assert
   `FAILED_WITH_TERMINAL_ERROR` for reflection-based and `Java.type()`-based
   RCE expressions on the fixed version.

## Additional Notes

- **Idempotency:** The script was run twice with identical results. Each run
  uses timestamped workflow names and markers, and cleans up containers at
  startup and after each phase.
- **Bounded search:** Three meaningfully distinct variant candidates were
  tested (different evaluator sink, different task type, different evaluator
  alias) plus one bypass technique (`Java.type()`). Fewer than 3 additional
  candidates exist because all JS paths funnel through a single
  `ScriptEvaluator.createNewContext()`, and only two GraalVM context creation
  sites exist in the codebase. Other task types (`DO_WHILE`, `DECISION`,
  event handlers) reach the same sink and are covered by the same fix.
- **Networking:** Docker `--network host` + gateway detection, same topology
  as the original repro.
