
End-to-end reproduction and cross-layer detection of CVE-2026-53576, the unauthenticated RCE in Kestra — taken past the base PoC to show how a common Docker-socket misconfiguration turns container-root into full host compromise.
Kestra, an open-source workflow orchestration platform, shipped an authentication filter that decided whether a request needed credentials by checking if the URL ended with /configs - a check meant to expose one harmless public endpoint.
Because the check only looked at the tail of the string, any request whose URL happened to end that way skipped authentication entirely, including endpoints that create and run arbitrary code. The result: anyone who can reach a vulnerable Kestra instance over the network can run commands as root with zero credentials, no phishing, no password guessing, no privilege escalation step required.
In this exercise, that single gap was carried end-to-end against a self-hosted lab instance:
Every stage was captured against defensive telemetry (network IDS, host runtime security, Linux audit, and system logs).
Business impact if unpatched: complete compromise of the host running Kestra, not just the application - and by extension anything else reachable from that host.
Fix: upgrade to Kestra 1.0.45 / 1.3.21 or later (patch alone closes the primary vulnerability; the escalation and persistence stages require a separate, independent fix - don't mount the Docker socket into application containers, see Section 8).
This report documents CVE-2026-53576, a CVSS 10.0 unauthenticated remote code execution vulnerability in Kestra ≤1.3.20, caused by a path-suffix authentication bypass (AuthenticationFilter.java, endsWith("/configs")).
An attacker who names a workflow's namespace and ID configs can create and execute arbitrary shell commands with no credentials, as root, inside the Kestra container. Fixed in 1.0.45 and 1.3.21. The identical bug was also reported independently under CVE-2026-49869, which is the CISA KEV-listed number tied to real in-the-wild exploitation. Both should be cited together, since public sources and scanners may reference either one.
| Vulnerable | Kestra ≤ 1.3.20 (and pre-1.0.45 on the 1.0.x line) |
| Fixed | 1.0.45 / 1.3.21+ |
| CVE | CVE-2026-53576 |
| Twin advisory | CVE-2026-49869 - same bug, CISA KEV-listed (in-the-wild) |
| Date | Event |
|---|---|
| 2026-06-02 | Kestra 1.3.21 released; changelog references a "potential authentication bypass in the authentication filter" |
| 2026-06-03 | Kestra 1.0.45 released on the 1.0.x line |
| 2026-09-02 | CVE-2026-49869 (the twin advisory) added to the CISA KEV catalog |
| 2026-09-22 to 09-24 |
Self-hosted homelab:
- Debian host (hostname docker, kernel 6.12.107+deb13-amd64),
- Docker Engine 29.8.1.
- Kestra deployed as the official kestra/kestra:v1.3.20 image, reachable through a Caddy reverse proxy at kestra.int.atlasvec.com.
- Telemetry stack under test: Falco (runtime/eBPF, host-level),
- Suricata 8.0.3 IDS (passive SPAN mirror), forwarding toward Splunk.
Before touching anything, I confirmed the target was actually vulnerable and the telemetry stack was live. I also took a snapshot of the pre-attack state so I could revert once I was done.
Kestra running the vulnerable version:

The container itself, up and reachable:

Falco's units loaded on the host:

Falco actively emitting events (modern eBPF mode):

Suricata's service active:

And its eve.json writing live events:

This is two mistakes lining up, not one.
First, the authentication filter decides whether a request can skip auth by matching the end of the request path:
// AuthenticationFilter.java — the vulnerable check, confirmed against the self-hosted v1.3.20 source
if (path.endsWith("/configs")) {
// treated as a public, unauthenticated path
}
That check exists to expose a single harmless endpoint, the public instance config. Because endsWith only reads the tail of the string, it cannot tell that one safe path apart from any other path that happens to end the same way.
Second, Kestra's router still dispatches those look-alike paths to their real, sensitive handlers. /api/v1/main/flows/configs reaches the flow-create handler. /api/v1/main/executions/configs/configs reaches the execution handler. The filter waves both through as public, and the router runs them anyway. Name a flow's namespace and ID configs, and a code-executing endpoint now ends in /configs, so it inherits the public path's free pass.
The captured request pair shows it plainly. GET /api/v1/main/flows/search with no credentials returns 401. POST /api/v1/main/flows/configs with the same empty auth header returns 200 and stores the flow (see §5.1).
Every step below is captured with its own screenshot. All of it runs inside my own isolated homelab against a self-hosted Kestra instance behind the reverse proxy.
Control : GET /api/v1/main/flows/search with no credentials → 401 Unauthorized.
Confirms auth is enforced on a normal route.

Plant : Sending POST /api/v1/main/flows/configs with no auth header. The body is a flow YAML whose namespace and flow id are both configs, containing a Commands task that uses the Process task runner. The server returns 200 OK and stores the flow. The /configs suffix on an otherwise protected route is what triggers the bypass (see Section 4).
Listen & Wait : Starting simple listener using nc for testing purposes, so we catch the reverse shell.

Trigger / Execution POST /api/v1/main/executions/configs/configs, no auth header → 200 OK, new execution created.

BaaaaM : We've popped the shell, but remember - we're "isolated", root BUT inside the Kestra docker container, so we "shouldn't" be able to escape from here. "well, that's what I thought"

The task's command runs as a direct child of the Kestra JVM process, confirmed via the host-side process tree, and completes successfully per Kestra's own execution log.
Kestra's Logs Dashboard

Kestra's Process Tree

Result: unauthenticated remote code execution as root, inside the Kestra container. This is the complete, self-contained proof of CVE-2026-53576.
From the Stage 1 shell, /var/run/docker.sock was found mounted into the container - a Docker-outside-of-Docker (DooD) pattern, common when Kestra's own Docker task runner is configured, since it needs a daemon to talk to.
Reaching that socket at all is equivalent to host root: the Docker API lets a client configure arbitrary host bind mounts and host PID/network namespaces for containers it spawns, and the daemon behind the socket already runs as root on the host.
This is not a kernel or container-escape exploit - it's the Docker API doing what it's designed to do, reached from somewhere it shouldn't have been reachable from.
Using the socket, I created a sibling container with the host filesystem bound in and host PID and network namespaces, then started it.
Note: a second, quieter variant also came up during testing, though I didn't screenshot it separately. Instead of always creating a new sibling container, the same socket access lets you run POST /containers/{id}/exec against a container that is already running, so there is no container-create event at all. On this Docker host I have both Kestra and Portainer, either of which I could use for the same escape. That matters for detection: any rule focused on a new privileged container being created misses this variant.
Confirming root and finding the socket sitting there, unmounted from any restriction:

Checking the Docker daemon is actually reachable through the socket:

Listing what images are already local, so I don't need to pull anything:

First attempt: a privileged container with the host filesystem bound in:

Second attempt, this time adding host PID and network namespaces:

Third attempt, chrooting into the mounted host filesystem directly:

Listener up and waiting on the port the escape shell will call back to:

Starting the container:

Root, but this time it's the host, not the container:

Kernel version matching the real host, not a container's isolated view:

Upgrading to a proper interactive shell for the rest of the session:

From the host-root shell I set up two independent persistence mechanisms,
First, an SSH key. I checked who already had access :

Then checked sshd's own config for anything that would block a new key:

Generated a keypair on the attacker box:

Confirmed sshd was actually listening:

Dropped the public key into root's authorized_keys:

And logged in with the matching private key:

PS: That's root access independent of the original RCE chain. Even if Kestra gets patched, this key still works.
Second, a cron beacon. I dropped a root-level callback set to run on an interval, then tested with a fresh listener to see if it actually fired on schedule:

It fired. A second foothold on the host, independent of both the RCE and the SSH key.
The timeline below is different: it's reconstructed entirely from independent defender-side telemetry (network IDS + host runtime security), pulled and validated live against real Splunk data.
Primary RCE - network detects delivery, host confirms execution, 103 seconds apart:
| Time (EDT) | Layer | Event |
|---|---|---|
| 02:46:09.778 | Network (Suricata) | Public ET signature for this exact CVE fires |
| 02:47:52.277 | Host (Falco) | Reverse shell confirmed inside the Kestra container, exact command and container ID captured |
Escalation - network and host each independently corroborate a different part of the same event:
| Time (EDT) | Layer | Event |
|---|---|---|
| 03:48:55.503 | Host (Falco) | Reverse shell from the escalation container |
| 03:51:57.360 | Network (Suricata) |
I built the detections against real captured telemetry, then tested each one against a replay. The matrix shows where coverage existed before I started and where it did not.
The gap is the docker-socket escalation. Falco's default rules catch the shell a compromised container spawns, but nothing in the stable default set flags a process reaching /var/run/docker.sock to drive the Docker API directly. The rules that would touch privileged-container launches, Launch Privileged Container and Launch Sensitive Mount Container, ship at incubating and sandbox maturity, so the default ruleset does not load them either. I closed the gap with an auditd correlation search (Detection 03) and a custom Falco rule (§6.3).
All five run live in Splunk (*/5 * * * *, alert tracking enabled, per-field throttling), not just written and left as text. Full SPL, exact FP notes, severity, MITRE tags, and response runbooks for each are in detections/splunk/*.spl. The status table below is the live-tested result for each, including a real bug I found and fixed.
Detection 03's division of labor is deliberate: rather than re-implementing Suricata's own signature logic in SPL, or trying to have Falco catch a socket-level technique it wasn't built for, the SIEM layer here consumes auditd's EXECVE record of the exact curl --unix-socket calls - the telemetry source proven most reliable for this specific technique.
suricata:eve alert):index=main sourcetype=suricata:eve event_type=alert
alert.signature="ET WEB_SPECIFIC_APPS Kestra Unauthenticated Remote Code Execution (CVE-2026-53576)"
| stats count min(_time) as first_seen max(_time) as last_seen
values(src_ip) as source_ips
by dest_ip dest_port
| eval first_seen=strftime(first_seen,"%Y-%m-%d %H:%M:%S")
| eval last_seen=strftime(last_seen,"%Y-%m-%d %H:%M:%S")

Detection 02 - Falco's redirect-to-network rule, broken out per container. The Source IP shifts from the Kestra container's bridge address (172.17.0.3, primary RCE on port 4489) to the host itself (172.66.66.67, escalation on port 4491) - the container-to-host escalation made visible - while the destination stays constant: the attacker's listener at 172.66.66.125:

Detection 03 - auditd EXECVE records of curl --unix-socket docker.sock:

Detection 04 - journald root SSH login, key fingerprint captured:

Detection 05 - the hidden-dotfile cron beacon (/usr/local/bin/.sysmon) running as root on an exact 5-minute cadence: 79 distinct intervals across ~6.5 hours (04:30-11:00 EDT). The distinct_intervals >= 3 threshold is what separates a periodic beacon from a one-off cron job:

All five deployed as scheduled saved searches (*/5 * * * *):

Proof the cron scheduler actually runs these, not just that they exist: all five executed 54 times. Detection 03 fired once (the docker-socket abuse, 06:35 EDT) and Detection 05 fired 53 times (the still-active cron beacon). Detections 01/02/04 show 0 fires as those are one-time historical events (exploit request, RCE, SSH login) that have aged out of the -10m lookback, whereas a live or recurring instance would still alert:

The /dev/tcp/ reverse-shell pattern - used in both the primary RCE and the escalation callback. SigmaHQ ships a rule for it: "Suspicious Reverse Shell Command Line" (id 738d9bcf-6999-4fdb-b4ac-3033037db8ab, by Florian Roth at Nextron Systems), and one of its keywords is bash -i >& /dev/tcp/ - exactly what showed up in our capture.
So I pulled that rule in unchanged (detections/sigma/lnx_shell_susp_rev_shells.yml) and checked its keyword against the same Falco event Detection 02 already captured. Sigma doesn't "fire" on its own - it's a portable signature, not a running engine - but I can show its keyword landing on real telemetry from this run instead of a textbook example.
Public SigmaHQ rule "Suspicious Reverse Shell Command Line" - its bash -i >& /dev/tcp/ keyword against the same real event as Detection 02:

detections/falco/docker_socket_abuse.yaml - deployed to the live Falco instance and confirmed firing on a real replay, 2026-09-24T10:27:29Z.
Falco's default ruleset doesn't cover this. Detecting a process reaching /var/run/docker.sock isn't a new idea - Sysdig's own Falco examples do it by watching for open_write on the socket path - but there's no such rule in the shipped/default set (the project has an open request for one, falcosecurity/falco #2940), and the one adjacent default rule only catches the docker/kubectl CLIs, not a raw curl --unix-socket.
The catch: that standard open_write-on-the-socket approach didn't fire in this setup - with modern_bpf and a passive mirror, the socket is reached via connect(), not open(). So I match on the process command line at spawn instead (spawned_process + proc.cmdline contains "docker.sock"), the same event class Falco already handles reliably here. It fires twice per call - the shell wrapper and the curl leaf - with full context:
priority=Critical, container_id=d417e027d444, image=kestra/kestra:v1.3.20, user=root, cmdline="curl -s --unix-socket /var/run/docker.sock http://localhost/version", tags=[T1610, T1611]
Custom Falco rule firing - "Unexpected Process Accessing Docker Socket" (T1610/T1611):

Stock Falco rules that fired - the default set has no docker-socket rule, which is the gap:

The network layer for the primary exploit is already covered by a public Emerging Threats signature, ET WEB_SPECIFIC_APPS Kestra Unauthenticated Remote Code Execution (CVE-2026-53576), which fired on real traffic during Phase 2.
Below is the same bypass seen at the network layer, and I built the query from the vulnerability pattern. Every request whose path ends in /configs on a flows or executions endpoint came back 200, while a normal route (/flows/search) returned the expected 401.
The Attacker IP (XFF) column is the real HTTP origin, read from the X-Forwarded-For header: 10.10.10.106, my Mac running Burp. Suricata's own src_ip only sees the reverse-proxy hop (172.66.66.1). That's a different machine from the 172.66.66.125 Kali box that later caught the reverse shells and made the SSH login, so the HTTP exploit and the callbacks came from separate hosts.

cve_2026_53576_kill_chain is deployed to Splunk and contains: headline KPIs (detection layers, ATT&CK techniques, detections deployed, persistence mechanisms), an attack-timeline column chart coloured by telemetry layer, a MITRE ATT&CK kill-chain map with a live evidence count per stage, the network-and-host correlated timeline from §5.4, detection coverage by scheduled saved search, the docker-socket technique breakdown, and an indicators-of-compromise table pulled live from the logs.
KPIs, the attack-timeline chart, and the start of the ATT&CK map:

The rest of the ATT&CK map, the correlated kill-chain, detection coverage next to the docker-socket breakdown, and the IOC table:

Primary fix. Upgrade to Kestra 1.0.45 (1.0.x line) or 1.3.21+ (1.3.x line). This alone closes the auth-bypass - Stage 1 of this exercise - and is the only fix that requires no further compensating controls once applied. See GHSA-2q47-568g-9h4f.
If immediate patching isn't possible, compensating controls, in order of impact:
Reverse-proxy enforcement - since the vulnerable filter only fails on the raw path suffix, a reverse proxy in front of Kestra (Caddy, in this lab) can independently enforce auth on any path matching /api/v1/*/(flows|executions)/.* regardless of what it ends in, closing the bypass at a layer the application bug can't reach.
Restrict the Docker socket mount - this closes Stages 2–3 entirely, independent of the Kestra patch. Don't bind-mount /var/run/docker.sock into the Kestra container. If the Docker task runner is genuinely required, use a scoped socket proxy (e.g. docker-socket-proxy) that allowlists specific API calls instead of granting full daemon access - full access to the socket is equivalent to host root, as demonstrated in §5.2.
SSH hardening - the persistence chain's first mechanism depended on root being reachable via key-based SSH at all. PermitRootLogin no (or requiring a bastion/MFA for root) would have blocked that specific persistence path regardless of the initial RCE - worth doing independent of this CVE.
Interim detection coverage - the Splunk correlation searches and the custom Falco rule, all deployed and verified during this exercise, provide the interim coverage for the stages of this specific chain while patching is scheduled.
Post-incident hygiene, if this pattern is found already exploited: treat as full host compromise, not just application compromise - rotate every credential and secret the Kestra instance had access to (KV store entries, connection credentials for downstream systems), not just Kestra's own access.
In-the-wild status. CVE-2026-49869, the twin advisory for this same bug, is listed in CISA's KEV catalog and tied to observed crypto-mining and cloud-credential-theft campaigns. CVE-2026-53576 (this report) has no KEV listing of its own, but it is the same vulnerability. Vulnerability management tooling that tracks only one of the two CVE numbers may under-report exposure.
738d9bcf-6999-4fdb-b4ac-3033037db8ab, Florian Roth / Nextron Systems), used under the Detection Rule License 1.1| This lab reproduction, detection build, and cross-layer validation |
TCP flow and a content-based alert - the literal string uid=0(root) was seen leaving the host in plaintext when id was run |
| Stage | Network (Suricata) | Host (Falco) | Host (auditd/journald) | Status |
|---|
| Initial exploit request | Public ET signature fires | No app-layer visibility | - | Covered (pre-existing) |
| RCE execution | - | Native rule, T1059-tagged | - | Covered (pre-existing) |
| Docker-socket escalation (the technique) | - | Gap: catches only the resulting shell, not the socket-abuse API calls | EXECVE records capture the exact curl --unix-socket calls | Gap found, closed with auditd + a custom Falco rule |
| Escalation impact confirmation | Content alert on the leaked id output | Same generic shell rule | - | Covered (pre-existing, cross-layer) |
| SSH persistence | - | - | journald: full PAM lifecycle plus key fingerprint | Covered (host-only) |
| Cron persistence | - | - | journald and linux_audit, two sources, exact 5-min cadence | Covered (host-only) |
| # | Detection | MITRE | Status |
|---|
| 01 | Network exploit signature (consumes the Suricata ET alert) | T1190 | Fired - historical replay confirmed, no new events since (outside current lookback) |
| 02 | Host RCE execution (Falco redirect-to-network rule) | T1059 | Fired - historical replay confirmed |
| 03 | Docker-socket abuse (auditd, the gap-closer) | T1610, T1611 | Fired on the live scheduler itself - triggered_alert_count: 1 confirmed on the real */5 * * * * run, not just a manual test |
| 04 | SSH root-login persistence | T1098.004, T1021.004 | Fired - historical replay confirmed |
| 05 | Cron-beacon persistence | T1053.003 | Deployed with a bug I found and fixed live. The -10m dispatch window held only two of the three 5-minute buckets the distinct_intervals >= 3 threshold needs, so it could never fire despite the beacon still running in real data. Widened it to -30m and redeployed - and the scheduler has fired it 53 times since, so the fix holds. |
| Tactic | Technique | Evidence |
|---|
| Initial Access | T1190 - Exploit Public-Facing Application | Control/plant/execute requests (§5.1); Suricata ET signature fire, §5.4 |
| Execution | T1059.004 - Command and Scripting Interpreter: Unix Shell | Kestra Commands task, host process tree (07-kestra-process-tree.png); Falco rule, §6.1 Detection 02 |
| Command and Control | T1095 - Non-Application Layer Protocol | Raw /dev/tcp/ TCP reverse shell - no application-layer C2 framing used |
| Discovery | T1613 - Container and Resource Discovery | Docker image enumeration via the socket (10-docker-images-enum.png) |
| Privilege Escalation | T1610 - Deploy Container | Sibling container create/start via the Docker API (11–15); auditd EXECVE records, §6.1 Detection 03 |
| Privilege Escalation | T1611 - Escape to Host | Host-root confirmation, hostname/kernel match (16, 17) |
| Persistence | T1098.004 - Account Manipulation: SSH Authorized Keys | Key implant to /root/.ssh/authorized_keys (23) |
| Lateral Movement | T1021.004 - Remote Services: SSH | Successful key-based root login (24); journald, §6.1 Detection 04 |
| Persistence | T1053.003 - Scheduled Task/Job: Cron | Cron beacon, confirmed firing on schedule (25); §6.1 Detection 05 |