From Workflow Definition to Shell: Finding CVE-2026-58138 in Conductor
A case study of CVE-2026-58138, an unauthenticated RCE in Conductor: the vulnerable path, why four stock scans missed it, and how we built and tested the OpenTaint rule and GraalVM approximation that found it.
Conductor lets an API client submit a complete workflow definition and start it in one request. That definition can contain JavaScript or Python expressions. This is useful as long as those expressions remain inside a sandbox. In Conductor, they did not.
An unauthenticated request could supply a script that reached a GraalVM context with access to the host JVM. From there, the script could run operating-system commands as the Conductor process. The issue is now tracked as CVE-2026-58138, with a CVSS 3.1 score of 9.8 and a CWE-94 classification. The public CVE record lists versions from 3.21.21 up to, but not including, 3.30.2 as affected.
We found the bug while using the OpenTaint AppSec Agent to review Conductor 3.23.0. This case study follows the investigation from the HTTP endpoint to a working exploit. It also shows how the agent turned what it learned into a tested rule and a reusable model of the GraalVM APIs along the path.
Following the request to the evaluator
The path starts at POST /api/workflow. The endpoint in Conductor 3.23.0 accepts a StartWorkflowRequest and passes it into the workflow service:
@PostMapping(produces = TEXT_PLAIN_VALUE)public String startWorkflow(@RequestBody StartWorkflowRequest request) { return workflowService.startWorkflow(request);}The caller can include the workflow definition directly in that request. Four task types, INLINE, LAMBDA, DO_WHILE, and SWITCH, can take an expression from the definition and evaluate it. The INLINE task makes the trust boundary especially clear. These are the relevant lines from Conductor 3.23.0:
@Overridepublic boolean execute( WorkflowModel workflow, TaskModel task, WorkflowExecutor workflowExecutor) { Map<String, Object> taskInput = task.getInputData(); String evaluatorType = (String) taskInput.get(QUERY_EVALUATOR_TYPE); if (evaluatorType == null) { evaluatorType = "javascript"; } String expression = (String) taskInput.get(QUERY_EXPRESSION_PARAMETER);
try { checkEvaluatorType(evaluatorType); checkExpression(expression); Evaluator evaluator = evaluators.get(evaluatorType); Object evalResult = evaluator.evaluate(expression, taskInput); task.addOutput("result", evalResult); task.setStatus(TaskModel.Status.COMPLETED); } catch (Exception e) { // ... }
return true;}The request controls both values passed to evaluator.evaluate(expression, taskInput). The expression parameter contains the JavaScript program to run. The taskInput parameter contains the workflow task data that the evaluator exposes to that program. The JavaScript evaluator in 3.23.0 builds a full-host-access context:
private static Context createNewContext() { return Context.newBuilder("js") .allowHostAccess(HostAccess.ALL) .option("engine.WarnInterpreterOnly", "false") .build();}In the non-pooled evaluation branch, it binds the input map to $ and submits the expression to Context.eval:
try (Context context = createNewContext()) { final Value jsBindings = context.getBindings("js"); jsBindings.putMember("$", input); if (console != null) { jsBindings.putMember("console", console); } final Future<Value> futureResult = executorService.submit(() -> context.eval("js", script)); Value value = futureResult.get(maxExecutionTimeSeconds.getSeconds(), TimeUnit.SECONDS); return getObject(value);}That is the entire vulnerability in outline: attacker-controlled code reaches Context.eval, and the context was built with HostAccess.ALL.
Why the JavaScript sandbox could be escaped
There is a detail that makes the JavaScript path easy to underestimate. HostAccess.ALL does not expose GraalJS’s Java.type(...) helper by itself. That usually requires allowAllAccess(true), so the obvious payload fails. A quick manual test can therefore leave the impression that the context is restricted.
The $ binding changes the picture. It is not a copy of the task input serialized into JavaScript. It is a live Java LinkedHashMap. With full host access, the script can call its public methods, including getClass(), inherited from java.lang.Object.
That one object is enough to reach the rest of the JVM through reflection:
var classClass = $.getClass().getClass();var stringClass = classClass.getMethod("getName").getReturnType();var forName = classClass.getMethod("forName", stringClass);var Runtime = forName.invoke(null, ["java.lang.Runtime"]);// ...Once the script obtains Class.forName, it can resolve Runtime, build the required argument array, and call Runtime.exec. The command runs with the privileges of the Conductor JVM. In the official conductor:server image we tested, that user was root.
The Python evaluator was more direct:
try (Context context = Context.newBuilder("python").allowAllAccess(true).build()) { if (input instanceof Map) { // ... context.eval("python", wrappedExpression.toString()); // ... } else { return null; }}Here allowAllAccess(true) enables host access, process creation, native access, and I/O together. A submitted expression could import subprocess and execute a command without the Java reflection chain.
Why the stock scans missed it
Before adding any project-specific knowledge, we scanned Conductor 3.23.0 with CodeQL, Semgrep OSS, Semgrep Pro, and OpenTaint’s stock rules. Together they returned 44 findings. None of the four scans reported either GraalVM evaluator.
This was not primarily a path-depth problem. A taint engine needs two facts before it knows which path to follow:
- Where untrusted data enters the program.
- Which operations become dangerous when that data reaches them.
The first fact was already covered. Spring request bodies are standard taint sources. The second was missing: none of the tested stock rules treated org.graalvm.polyglot.Context.eval as a code-injection sink. Without a sink, there is no query asking the engine to connect the request to the evaluator.
Adding a sink was necessary, but it was not sufficient. The vulnerable JavaScript context is built through GraalVM’s fluent Context.Builder API: allowHostAccess(...), then option(...), then build(). Because GraalVM is an external dependency, the analyzer also needs to know that option(...) returns the same builder. The solution therefore has two parts: a rule that describes the security property, and an approximation that describes the external API’s dataflow.
Step 1: write a sink for the dangerous eval
The project rule is a join. It connects OpenTaint’s existing Spring and servlet sources to the new GraalVM sink:
mode: joinjoin: refs: - rule: java/lib/spring/untrusted-data-source.yaml#spring-untrusted-data-source as: spring-source - rule: java/lib/generic/servlet-untrusted-data-source.yaml#java-servlet-untrusted-data-source as: servlet-source - rule: java/lib/generic/graalvm-polyglot-sinks.yaml#graalvm-polyglot-eval as: sink on: - 'spring-source.$UNTRUSTED -> sink.$UNTRUSTED' - 'servlet-source.$UNTRUSTED -> sink.$UNTRUSTED'Marking every Context.eval as remote code execution would be a poor sink. Applications can evaluate untrusted expressions in deliberately restricted contexts. For this case, the sink had to require both of these conditions:
- the script passed to
evalis tainted. - the same
Contextwas built with full host access.
The two direct branches of the sink rule cover the full-access forms used by the vulnerable JavaScript and Python evaluators:
- id: graalvm-polyglot-eval pattern-sinks: - patterns: - pattern: (org.graalvm.polyglot.Context $CTX).eval($LANG, $UNTRUSTED) - focus-metavariable: $UNTRUSTED - pattern-either: - pattern-inside: | org.graalvm.polyglot.Context $CTX = (org.graalvm.polyglot.Context.Builder $B).allowAllAccess(true).build(); ... - pattern-inside: | org.graalvm.polyglot.Context $CTX = (org.graalvm.polyglot.Context.Builder $B).allowHostAccess(org.graalvm.polyglot.HostAccess.ALL).build(); ...There are three ideas in this rule.
First, $UNTRUSTED names the executable argument at the sink. focus-metavariable: $UNTRUSTED makes that argument, rather than $LANG or $CTX, the value that must be tainted. The join connects the $UNTRUSTED mark exposed by either HTTP source rule to this sink argument.
Second, names prefixed with $ are metavariables. $CTX binds the context used at the eval call. Reusing $CTX in pattern-inside correlates that call with the context built using full access. A different, safely configured context elsewhere in the same method does not satisfy the condition.
Third, ... is a dataflow transition in an OpenTaint pattern. It does not require the builder and the eval call to appear next to each other in one syntax block. It asks whether the context built at the first point can flow to the second point, including through method calls. That matters in Conductor, where a factory creates the context and a later lambda captures it for execution on a thread pool.
The sink is intentionally narrow. Tainted code evaluated in a context with no host-access grant is outside this rule’s target, while constant code evaluated in a full-access context also stays quiet.
Step 2: model the GraalVM boundary
With the sink in place, context correlation still stopped at the fluent GraalVM builder. This is where an OpenTaint approximation is useful. An approximation is a small declaration of how an external method moves data. It does not attempt to reimplement the method. It records only the behavior the analysis needs. The excerpts below come from the checked-in org.graalvm.polyglot.yaml.
In the GraalVM 25.0.2 implementation, Context.Builder.option mutates the builder and returns this. The approximation therefore copies the receiver to the result:
passThrough: - function: org.graalvm.polyglot.Context$Builder#option copy: - from: this to: resultThis preserves builder identity across the .option(...) call between .allowHostAccess(HostAccess.ALL) and .build(). It lets the rule correlate the context returned by createNewContext() with the later eval call. Although this -> result copies the whole object, it is exact here because both positions refer to the same Java object. No option argument or builder field is marked as tainted.
At this point the rule covers both vulnerable Conductor evaluators. The JavaScript path needs the approximation. The Python path uses a direct allowAllAccess(true).build() chain.
Extending the rule to eval(Source)
Conductor calls eval(language, source), but GraalVM also accepts a Source object. Supporting that overload makes the sink reusable, and it introduces a useful distinction between propagators and approximations.
The sink adds the overload while keeping the evaluated argument focused as $UNTRUSTED:
- pattern-either: - pattern: (org.graalvm.polyglot.Context $CTX).eval($UNTRUSTED) - pattern: (org.graalvm.polyglot.Context $CTX).eval($LANG, $UNTRUSTED)- focus-metavariable: $UNTRUSTEDA pattern propagator expresses rule semantics. After a matched operation, it says which object should itself be considered tainted for this rule. An approximation expresses library semantics by describing how an external dependency moves an existing dataflow mark. Approximations are reusable across rules. Propagators belong to one rule’s definition of taint.
For eval(Source), taint begins on the script text but the sink receives the completed object. The rule makes those semantic transitions explicit:
pattern-propagators: - pattern: $B = org.graalvm.polyglot.Source.newBuilder($L, $SRC, $N) from: $SRC to: $B - pattern: $S = (org.graalvm.polyglot.Source.Builder $B).buildLiteral() from: $B to: $SThe first edge says that a builder created from tainted script text represents untrusted executable code. The second makes the same semantic decision for the completed Source. That whole-object mark does not mean every field is tainted. It means the object passed to eval(Source) represents untrusted code. The complete rule has equivalent edges for build() and Source.create().
An intervening cached(boolean) call is different. It does not change what the builder represents. It simply returns the same builder. GraalVM’s implementation confirms that behavior, so it belongs in the approximation:
- function: org.graalvm.polyglot.Source$Builder#cached copy: - from: this to: resultThe mechanisms now compose in a clear order: a propagator creates the rule-level mark, the approximation preserves it through the external call, and a terminal propagator transfers the meaning to the completed Source. No virtual Source field is involved.
This extension intentionally covers the factory and terminal chains encoded in the rule. An application that later replaces builder content through content(...) would need overwrite-aware handling.
Test the artifact before scanning the application
A custom security rule can multiply either signal or noise across a codebase, so we tested its behavioral boundaries before scanning Conductor. Positive cases cover both full-access configurations, a context factory, intervening fluent calls, custom HostAccess, and all supported Source construction paths. Negative cases cover sandboxed contexts, constant executable text, taint in non-code arguments such as the language or source name, and the restricted policy used by the patch.
All fifteen cases pass with OpenTaint 0.4.5. The test project is included in the reproduction repository with the rule and approximation.
The completed trace
With the sink and approximation loaded, OpenTaint produced an interactive HTML report with two findings: PythonEvaluator.java:63 and ScriptEvaluator.java:203. Each finding contains four alternative traces from HTTP entry points in WorkflowResource: start, synchronous execute, rerun, and test. Those four traces are entry routes, not the four script-capable task types.
The report contains one finding per vulnerable evaluator. Select either sink to inspect its source code and alternative flows.
A representative JavaScript trace starts with the request body passed to WorkflowResource.startWorkflow. It follows the inline workflow definition through task creation and scheduling, then tracks the extracted expression into the JavaScript evaluator. In ScriptEvaluator, the executor lambda captures both the tainted script and the full-access context before passing the script to Context.eval at line 203.
The final report steps show the context and script captured by the lambda before tainted code reaches Context.eval.
Two parts of that result were especially useful. The trace survived the lambda and executor boundary at line 202, and it retained the full-access builder state all the way to the sink. The former came from the engine’s interprocedural analysis. The latter depended on the Context.Builder approximation.
We then used the trace to write a proof of concept. Running python3 poc/poc_inline_lambda_rce.py --base-url http://localhost:8000 submits a malicious workflow and polls its result. The command output, including uid=0(root), is returned in task.outputData.result. This closed the gap between a plausible static path and a demonstrated RCE.
The patch forced a better rule
Conductor fixed the issue in 3.30.2. The Python evaluator dropped allowAllAccess(true). The JavaScript evaluator kept limited host interoperability but denied access to the reflection and process classes used by the escape. It was then hardened further by disabling class loading, native access, thread and process creation, I/O, and environment access.
The fixed 3.30.2 source builds the JavaScript context as follows:
private static Context createNewContext() { HostAccess hostAccess = HostAccess.newBuilder(HostAccess.ALL) .denyAccess(Class.class) .denyAccess(ClassLoader.class) .denyAccess(java.lang.reflect.Method.class) .denyAccess(java.lang.reflect.Field.class) .denyAccess(java.lang.reflect.Constructor.class) .denyAccess(java.lang.reflect.Array.class) .denyAccess(Runtime.class) .denyAccess(ProcessBuilder.class) .denyAccess(Process.class) .denyAccess(System.class) .denyAccess(Thread.class) .denyAccess(ThreadGroup.class) .build(); return Context.newBuilder("js") .engine(ENGINE) .allowHostAccess(hostAccess) .allowHostClassLoading(false) .allowNativeAccess(false) .allowCreateThread(false) .allowCreateProcess(false) .allowIO(IOAccess.NONE) .allowEnvironmentAccess(EnvironmentAccess.NONE) .build();}This exposed a blind spot in the first version of our rule. HostAccess.newBuilder(HostAccess.ALL).build() is also dangerous if no restrictions are added, but it does not pass the HostAccess.ALL literal directly to allowHostAccess. A rule that understands only the direct form would miss an unsafe custom HostAccess object. On the other hand, flagging every builder seeded from HostAccess.ALL would report the patched code even though it explicitly removes the dangerous capabilities.
The second version handles both sides of that distinction:
- patterns: - pattern-inside: | $BUILDER = org.graalvm.polyglot.HostAccess.newBuilder(org.graalvm.polyglot.HostAccess.ALL); ... - pattern-inside: | org.graalvm.polyglot.HostAccess $HA = $BUILDER.build(); ... - pattern-inside: | org.graalvm.polyglot.Context $CTX = (org.graalvm.polyglot.Context.Builder $B).allowHostAccess($HA).build(); ... - pattern-not-inside: | $BUILDER = $BUILDER.denyAccess(...); ...The first three clauses follow one host-access builder into the context that eventually performs the evaluation. The negative clause excludes that chain after the same builder calls denyAccess, which covers the restricted policy introduced by Conductor’s patch.
A broader rule can capture the argument to denyAccess and constrain the class literal. This case study only needs to distinguish Conductor’s unrestricted vulnerable builder from its restricted fixed builder. The test suite covers both cases. In 3.30.2, the Python context also drops allowAllAccess(true), so neither patched evaluator satisfies the sink rule.
A regression across scripting engines
Conductor had fixed a closely related inline-script RCE before, in CVE-2025-26074. That issue involved the older Nashorn evaluator. When the implementation moved to GraalJS, the sink API changed and full host access reopened the same trust-boundary failure on a new engine. The addition of GraalPy created a second vulnerable path.
This is why the reusable artifact matters. A patch fixes one implementation. A rule records the security property: untrusted code must not reach an evaluator that can access the host. The rule still needs maintenance when an API changes, but it gives the next review a concrete test suite and a place to encode what was learned this time.
Reproduce the case study
The standalone reproduction repository contains the complete sink rule, the GraalVM approximation, all fifteen rule tests, the SARIF result, and the working PoC. You can also open the report in the viewer and follow the JavaScript and Python traces from the request to Context.eval.
OpenTaint is an open source taint analysis engine designed to work with AI agents. The AppSec Agent skills used in this review let a coding agent map an attack surface, write and test project rules, model external libraries, run the scan, and confirm a finding with a PoC. The result of that one-time investigation is not another prose report alone: it is a set of deterministic artifacts that can run again on every change.

