The Container Name Was the Command: Komodo’s Periphery Agent, Patched in July, Disclosed in October

On 13 October 2026 the CERT Coordination Center published VU#456950 and CVE-2026-110005 against Komodo, the open-source server and deployment manager from mogh.tech — 12,707 stars, 436 forks, GPL-3.0. The subject is Periphery, the agent Komodo installs on each managed server to drive Docker. The NVD record arrived the same day at 20:17 UTC with status Deferred and, as of this writing, no NVD-assigned CVSS score at all.

The shape of the bug is one this publication has now documented a dozen times in a month: a value nobody thinks of as code — here a Docker container name — is pasted into a string that a shell then parses. What makes this record worth reading in full is everything around the bug: the fix had already been public for 76 days when the advisory landed, the escaping function was already a dependency of the vulnerable binary, and the vendor’s own statement describes a materially larger vulnerability than either the CVE text or the CERT/CC overview.

What the code did

We read the Periphery source at tag v2.2.0 (the last release before the fix) and at v2.3.0. The container log-search handler, GetContainerLogSearch, is in bin/periphery/src/api/container/mod.rs. At v2.2.0:

let grep = format_log_grep(&terms, combinator, invert);
let timestamps = if timestamps { " --timestamps" } else { Default::default() };
let command = format!(
  "docker logs {name} --tail 5000{timestamps} 2>&1 | {grep}"
);

name arrives in the request. It is written into the command string unmodified. The same file’s RemoveContainer handler does the same thing:

let RemoveContainer { name, signal, time } = self;
let stop_command = stop_container_command(&name, signal, time);
let command =
  format!("{stop_command} && docker container rm {name}");

The second interpolated value is the search term itself. format_log_grep lives in bin/periphery/src/helpers.rs, and at v2.2.0 it builds its own quoting by hand:

match combinator {
  SearchCombinator::Or => {
    format!("grep{maybe_invert} -E '{}'", terms.join("|"))
  }
  SearchCombinator::And => {
    format!(
      "grep{maybe_invert} -P '^(?=.*{})'",
      terms.join(")(?=.*")
    )
  }
}

Those are literal single quotes typed into a format string. A search term containing a single quote closes them, and everything after it is read by the shell as a new word — which is exactly how the vendor describes it: “The terms sat inside hand-written single quotes, so a term containing ' escaped its quoting and ran as a separate command.”

The third path is the strangest. create_swarm_secret in bin/periphery/src/docker/secret.rs at v2.2.0 passed the secret payload through a quoted heredoc:

write!(
  &mut command,
  r#" {name} - <<'EOF'
{}
EOF"#,
  data.trim()
)?;

A <<'EOF' heredoc does suppress expansion of its contents. What it does not do is survive its own terminator appearing in the data. Per the vendor statement, “a payload containing a line of exactly EOF closed it early and executed the following lines.” The --driver, --label and name values on the same command were interpolated raw.

Which shell, and as whom

All three paths end at run_komodo_shell_command in lib/command/src/lib.rs. Its doc comment says the command is wrapped in sh -c, and CERT/CC’s overview repeats that; the vendor statement says bash -c. Reading the helper resolves the discrepancy — both are right, in order:

if PathBuf::from("/bin/bash").exists() {
  String::from("/bin/bash")
} else if PathBuf::from("/usr/bin/bash").exists() {
  String::from("/usr/bin/bash")
} else if PathBuf::from("/bin/sh").exists() {
...

The resolver prefers bash and falls back to sh, then runs cmd.args(["-c", command]). On a typical Linux host the interpreter is bash, so the attacker gets bash’s grammar, not POSIX sh’s. Periphery runs as root with access to the Docker socket, so the resulting process does too.

The control was already in the build

The interesting detail is not that escaping was missing. It is that it was not missing from the project. bin/periphery/Cargo.toml at v2.2.0 already carries shell-escape.workspace = true, and the vulnerable tree already called escape() elsewhere — in api/compose.rs, on a different set of arguments. The crate was linked in, the helper was imported, and the log, destroy and secret paths simply did not call it.

The v2.3.0 diff is therefore not an architectural change. It is the same function applied to the arguments that had been skipped:

// v2.3.0, container/mod.rs
let name = escape(name.into());
let command = format!(
  "docker logs {name} --tail 5000{timestamps} 2>&1 | {grep}"
);
// v2.3.0, helpers.rs
SearchCombinator::Or => {
  format!(
    "grep{maybe_invert} -E {}",
    escape(terms.join("|").into())
  )
}

The hand-typed '...' are gone; escape() now supplies whatever quoting is correct. The heredoc in secret.rs was replaced outright with a pipe, which removes the terminator problem instead of trying to filter it:

let name = escape(name.into());
let command = format!(
  "printf '%s\\n' {} | docker secret create{flags} {name} -",
  escape(data.trim().into())
);

And a third change addresses a class the CVE text never mentions. v2.3.0 inserts -- before positional arguments, with the reason written into the source:

// `--` so a name beginning with `-` is not parsed as a flag.
let command =
  format!("docker logs --tail {tail}{timestamps} -- {name}");

That one matters because it fixes the paths where no shell is involved at all. A name beginning with - cannot execute a command, but it can be read by docker itself as an option. Escaping stops the shell from acting on a value; it does nothing to stop the program the value is handed to. The two defects need two different fixes, and v2.3.0 ships both. Readers who followed our account of eighteen filename command-injection CVEs will recognise the argument-injection variant that survives quoting.

Three descriptions of one vulnerability

The public record does not agree with itself about scope, and the differences are worth laying out rather than averaging.

The NVD text names version 2.2.0 and two operations, DestroyContainer and SearchContainerLog, reachable with Execute, or Read plus Logs, permission.

The CERT/CC overview says versions <= 2.2.0 and the same two vectors, container destruction and container log searches.

The vendor statement, dated 19 August 2026, is broader on versions and narrower on one vector. It covers “all versions prior to 2.3.0,” verified vulnerable at v1.19.1, v2.0.0, v2.1.0, v2.1.1, v2.1.2 and v2.2.0, and lists SearchContainerLog, SearchStackLog and the Swarm service equivalent, plus RunStackService (where the ad-hoc service, workdir, user, entrypoint and env values were interpolated into docker compose run, with only command escaped), plus Swarm secret and config creation. It does not name container destruction.

On that last point the code is the tiebreaker, and it sides with CERT/CC: the RemoveContainer handler quoted above does interpolate name raw at v2.2.0, and v2.3.0 escapes it and adds --. The destroy vector is real. The honest summary is that the vendor’s list and the CVE’s list are each incomplete in a different direction, and only reading the v2.2.0→v2.3.0 diff gives the union.

The timeline is the story

Line up the dates from the release metadata and the CERT/CC note:

  • 29 July 2026, 22:36 UTC — Komodo v2.3.0 is published. The fix is in it.
  • 19 August 2026 — CERT/CC records the vendor as notified; the vendor statement carries the same date.
  • 1 September 2026 — v2.3.3, the current release, ships.
  • 13 October 2026 — VU#456950 and CVE-2026-110005 go public, 76 days after the patched release.

The fix landed before the coordination process formally began. That ordering is not a failure — it is what a maintainer doing the right thing quickly looks like — but it has a consequence for everyone downstream, and the consequence is visible in the v2.3.0 changelog. The fix appears there, once, in the Misc. section, between a UI permissions-table performance fix and a command-timeout change:

Commands: Add shell escaping on ad-hoc commands / requests available to authenticated users with permission access below Write.

That is an accurate sentence. It is also the only signal an operator had for eleven weeks, and it is filed under Misc. with no CVE, no severity, and no indication that the thing being escaped was reachable by a user holding only a Logs grant. Anyone running a vulnerability scanner against their Komodo deployment in September got nothing, because there was nothing to match on. We have made this observation about fixes that shipped in June and were published in October and about three months of fixed-but-undisclosed advisories; the pattern here is the same with a shorter clock.

What the severity argument actually is

There is no NVD score. The vendor self-assesses CVSS 3.1 9.9 Critical (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H), CWE-78 with CWE-88 secondary. The scope change is the load-bearing metric and it is defensible: the attacker’s authority inside Komodo’s permission model is converted into root on a host that Komodo is merely supposed to be managing.

The vendor is also unusually candid about who is not affected, and that qualification deserves to travel with the number:

Only Komodo installations with more than one user, where non-admin users hold restricted permissions. This is a privilege escalation within Komodo’s permission model — it is not remotely exploitable without an account, and it grants an admin nothing they do not already have. If your installation has only admin users, you are not affected.

For the single-operator homelab that describes a large share of Komodo’s install base, the practical risk is close to zero. For a team that deliberately handed a junior engineer or a CI identity a read-only role with log access, the bug erases the distinction that role was created to express. PR:L is doing real work in that vector; the interesting population is the one that bothered to configure least privilege.

Why this belongs in an agent-security briefing

Komodo is not an AI product. It is, increasingly, the thing underneath one: a multi-server Docker control plane of exactly the kind that now gets wired to automation, scheduled jobs, and — the reason we are writing about it — model-driven operators that are deliberately given a narrow Komodo role so they can read logs but not run commands.

That is the same architecture we described in Roo Code’s five approval bypasses: a policy layer that decides what is allowed, sitting above a layer that turns the decision into a string for a shell. In Roo Code the gate and bash disagreed about tokenisation. Here there is no disagreement to have, because the string was never checked at all — the permission check ran, passed correctly, and then the handler concatenated. A correct authorisation decision about which operation you may perform says nothing about what the arguments to that operation can do, and the Logs grant is the clean illustration: it was designed to mean “may read output,” and it meant “may run bash as root.”

It also lands on the vendor’s own boundary note, which is the part most likely to bite an automated deployment: the grants above are required “unless a Periphery instance is exposed directly without its passkey.” Periphery’s authentication is that passkey. An agent-provisioned host that brings up Periphery on a reachable interface without it does not need any of this analysis; the endpoints are simply open.

Remediation

Upgrade Periphery and Core to 2.3.0 or later; current is v2.3.3. The vendor is explicit that there is no workaround — “auditing who holds Logs or Execute grants limits exposure but does not remove it” — so the audit is a scoping exercise, not a mitigation.

Two things not to misread after upgrading. First, Komodo still passes several configuration fields to a shell on purpose: deployment command and extra_args, pre_deploy/post_deploy, on_clone/on_pull, and compose_cmd_wrapper. Setting those requires write permission, which already implies command execution; they are features, and 2.3.0 does not change them. Second, if you grant Komodo roles to automation, treat Write on any resource as equivalent to shell on the servers that resource touches, because by the project’s own documentation it is.

For your own code, the generalisable rule is the one the v2.3.0 diff demonstrates twice: a value that crosses into a command needs two defences, not one. Escape it so the shell will not act on it, and pass -- so the program will not read it as a flag. Komodo had the escaping crate compiled into the binary the whole time; what was missing was the discipline of calling it on every path, which is a code-review problem long before it is a cryptography or sandboxing problem. Grep your own tree for format strings and concatenation that reach sh -c, not for a package name.

Reporting note: all CVE fields, timestamps, references and the Deferred status were read first-hand from the NVD 2.0 API. The vulnerability note, vendor statement, affected-method list, CVSS self-assessment, acknowledgement and notification date were read first-hand from CERT/CC VU#456950. Release tags, publication timestamps, changelog text and repository metadata were read first-hand from the GitHub API. All source excerpts were fetched directly from the repository at the named tags and are quoted verbatim. Nothing was installed, no Komodo instance was deployed, no container was created or named, no Periphery endpoint was contacted and no payload was executed. A Brave Search query returned only CVE-mirror aggregators and one news summary; no independent technical analysis was available at the time of writing.

Sources: