Skip to content

Back to the catalog

Machine speed, hold the AI: hand-rolled marimo CVE-2026-39987 exploit
sysdig.comwriteup

Machine speed, hold the AI: hand-rolled marimo CVE-2026-39987 exploit

Sysdig's Threat Research Team documents a hand-coded (non-LLM) exploitation of CVE-2026-39987, a pre-authentication RCE in marimo notebooks caused by a missing validate_auth() check on the /terminal/ws WebSocket endpoint. The operator chained credential theft from environment variables and Redis, AWS Secrets Manager retrieval, and SSH pivoting to a bastion host in eight seconds, ignored a planted prompt-injection honeypot meant to detect LLM agents, and left behind reusable base64-encoded boto3/paramiko toolkits in /tmp. The post includes full script reproductions, IOCs (IPs, file paths, AS63949/Linode infrastructure), CloudTrail/GuardDuty detection guidance, and remediation steps (patch to marimo 0.23.0, restrict the terminal endpoint, scope Secrets Manager access).

TL;DR Sysdig TRT dissects a human-operated (non-AI) exploit chain against marimo's CVE-2026-39987 WebSocket auth-bypass RCE, reaching AWS credential theft and bastion SSH access in 8 seconds, complete with IOCs and detection guidance.
Document

What to do

The summary already has the mechanism, the toolkit, and the high-level IOCs. What it omits is the version boundary, the urgency, and a set of detection signatures precise enough to hunt on — plus one distinct finding (a failed EC2 Instance Connect path) that isn't a footnote to the credential chain.

Version boundary and urgency. All marimo versions up to and including 0.20.4 are affected; the fix ships in 0.23.0 (PR #9098 wires the missing validate_auth() into /terminal/ws). This CVE has been on CISA KEV for months, with a federal remediation deadline of May 7, 2026 — already past as of this writing. The page's remediation list also adds: assume full exposure of .env, the data layer, and environment variables if /terminal/ws was reachable at any point since April 8, and rotate accordingly.

Detection signatures (the page's actual value — hunt on chain shape, not typing style):

  • CloudTrail: secretsmanager:GetSecretValue from a principal that has never historically called Secrets Manager, returning AccessDeniedException, followed by retries across multiple regions in under a minute.
  • The region-fallback order is deterministic: ["us-west-2", "eu-west-1", "ap-southeast-1", "us-east-2"] in exactly that sequence after a us-east-1 failure.
  • GuardDuty: UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS covers the replayed key from an IP outside normal egress.
  • On-host behavioral: echo '<base64>' | base64 -d > /tmp/<name>.py followed within seconds by nohup python3 /tmp/<name>.py; paramiko.SSHClient.connect(..., username="root") from a non-SSH-client process; pip3 install boto3 early in the session (the marimo container ships without boto3).
  • Egress: block outbound TCP from notebook containers except what's required; reverse-shell ports 4444, 1337, 1234, 8080, 9001, 31337 should never appear.

The EC2 Instance Connect fingerprint

A finding the summary doesn't carry: alongside the Secrets Manager chain, a second independent script attempted a parallel route — enumerate instances via DescribeInstances, then push an attacker key via SendSSHPublicKey (EC2 Instance Connect) for a Secrets-Manager-free SSH path. It failed with an instructive bug: when enumeration returned UnauthorizedOperation, the instance-ID variable was never populated, so SendSSHPublicKey fired against the placeholder i-0000000000000000 and AWS rejected it as malformed before IAM evaluation.

This yields a high-fidelity fingerprint: three denied EC2 enumeration calls followed by SendSSHPublicKey against the placeholder ID — automation running without an enumeration guard. It's also the page's evidence of human-built redundancy (two independent routes to the bastion), a contingency-planning behavior the profiled LLM agents didn't exhibit.

Preconditions worth acting on

The operator harvested two distinct AWS keys from two surfaces — the process environment and the application's Redis backend — mapping to different IAM users. Removing one surface would not have closed the path. Audit /proc/<pid>/environ, systemd EnvironmentFile, ~/.aws/credentials, ~/.aws/config, the app .env, and any runtime secret store. Then scope secretsmanager:GetSecretValue: the IAM user on a notebook host should never have read access to a deploy-bastion SSH key.

Caveats

The eight-second chain (WebSocket open 18:57:22 → bastion SSH auth 18:57:30) was only possible after ~4 hours of in-session toolkit building; the speed is the replay, not the development. Script 5 (the consolidated full chain) was only partially captured — the command stream cut off after the imports. The non-LLM attribution is behavioral (ignored a planted prompt-injection marker seen twice, iterative debugging, hand-typed quoting variation), not proof.

Organisations named here

  • 172.236.12.17
  • 45.79.187.72
  • AS63949 Akamai Connected Cloud
  • NKAbuse
  • PR #9098

CVEs named here

  • CVE-2026-39987

Technologies named here

  • Falco
  • marimo
  • Falco Feeds
  • AWS Secrets Manager
  • Boto3
  • AWS EC2 Instance Connect
  • Paramiko
  • nc
  • Sysdig Secure
  • AWS GuardDuty
  • AWS CloudTrail
  • AWS
  • Python
  • Redis

Techniques named here

  • Pre-authentication WebSocket RCE exploitation
  • Credential harvesting from compromised host
  • AWS credential pivot / cloud credential replay
  • Region-fallback discovery
  • Base64-encoded script staging
  • Standard-library reverse shell
  • SSH key-type brute detection
  • EC2 Instance Connect key push
  • Prompt-injection resistance / probe evasion
  • Same-provider infrastructure reuse

Threat actors named here

  • Unnamed hand-rolled/skilled human operator
  • NKAbuse RAT campaign
  • SCARLETEEL

Also named here

  • UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS