Back to Home

An LLM Agent Just Compromised a Notebook. Read That Sentence Again.

9 min read
Sumeet Zankar

Sumeet Zankar

AI Solutions Specialist & Full-Stack Developer

Sysdig watched an LLM agent chain a Marimo RCE into AWS Secrets Manager into a Postgres dump in about an hour — with the agent's Chinese-language chain-of-thought leaking into its own commands. Here's what the attack actually shows about notebook security in the agent era.

First, let's kill the framing.

A lot of the early coverage of CVE-2026-39987 called it a "zero-day." It wasn't. Marimo published GHSA-2679-6mx9-h9xc and shipped the fix (0.23.0) at the same moment — April 8, 2026, 21:50 UTC. Sysdig's honeypots caught the first in-the-wild exploitation at 07:31 UTC the next morning. Nine hours and forty-one minutes from coordinated disclosure to first shell. That's not a zero-day. That's an N-day weaponized at zero-day speed, by attackers working off the advisory text alone (a CVE number hadn't even been minted yet). The distinction matters, because "patch faster" stops being a coherent strategy when "faster" means under ten hours.

But that's the boring story. The actually interesting one came a month later, when Sysdig's TRT published a second writeup with a much sharper headline: an attacker had used an LLM agent to drive post-exploitation against a Marimo instance. End-to-end: notebook RCE → AWS keys → Secrets Manager → SSH bastion → internal Postgres dump. Roughly one hour. Driven by a model, not a human.

That sentence is the post.

The bug, briefly

Marimo's edit-mode server exposes WebSocket endpoints. The main session socket (/ws) authenticates the upgrade. The terminal socket (/terminal/ws) — which spawns a real PTY shell via pty.fork() — does not. One sibling route checks credentials. The parallel sibling route hands out root.

Here's the vulnerable path, paraphrased from the advisory:

# marimo/_server/api/endpoints/terminal.py — pre-patch
@router.websocket("/ws")  # mounted at /terminal/ws
async def websocket_endpoint(websocket: WebSocket) -> None:
    app_state = AppState(websocket)
    if app_state.mode != SessionMode.EDIT:
        await websocket.close(...)
        return
    if not supports_terminal():
        await websocket.close(...)
        return
    # NO AUTH CHECK ← this is the whole bug
    await websocket.accept()
    ...
    child_pid, fd = pty.fork()  # full PTY shell, attacker-controlled

Compare with /ws:

validator = WebSocketConnectionValidator(websocket, app_state)
if not await validator.validate_auth():
    return

The fix, in PR #9098, is exactly what you'd expect: call validate_auth() before accept(), plus negative-path tests for unauthorized and wrong-token connections. Solid, targeted patch.

Why didn't middleware catch this? Marimo uses Starlette's AuthenticationMiddleware, which tags failed-auth WebSocket connections as UnauthenticatedUser but does not actively reject the upgrade. Enforcement has to happen at the endpoint. The terminal route had no enforcement. This is a classic WebSocket failure mode: REST middleware semantics don't transfer to long-lived bidirectional connections, and nobody writes a test that says "WebSocket route X without credentials must return 1008."

The PoC published in the advisory itself is roughly seven lines of Python:

import websocket, time
ws = websocket.WebSocket()
ws.connect('ws://TARGET:2718/terminal/ws')   # no creds
time.sleep(2)
ws.send('id\n')
print(ws.recv())   # uid=0(root) gid=0(root) groups=0(root)

That's the entire exploit. No payload encoding, no token replay, no second stage. Default pip install marimo + docker run deployments hand out uid=0 because the upstream Docker base runs as root.

The timeline

When (UTC)What
2026-04-08 21:50GHSA-2679-6mx9-h9xc published; Marimo 0.23.0 released
2026-04-09 07:31First in-the-wild exploit observed (Sysdig honeypot) — +9h 41m
2026-04-09 07:44.env credentials exfiltrated — +13 minutes into session
2026-04-23Added to CISA KEV catalog; remediation due May 7
2026-05-10Sysdig observes LLM-agent-driven post-exploitation chain

The May 10 attack: what an agent-driven intrusion looks like

This is the part worth reading slowly.

Sysdig's TRT documented an attacker who hit a vulnerable Marimo instance, got the shell, and then handed the rest off to a model. Not a script. An interactive agent loop. The signals were specific enough that you can almost read the system prompt off them.

Signal 1 — chain-of-thought leakage. A Chinese-language planning comment — # 看还能做什么 ("see what else we can do") — appeared in the command stream. That's not a typo. That's an agent's internal reasoning bleeding into its tool calls because someone forgot to strip non-shell tokens before emitting commands. Once you've seen a model do this on your own laptop, you recognize it instantly.

Signal 2 — machine-shaped output discipline. Every command was structured for downstream parsing: --- delimiters between sections, 2>/dev/null on everything, | cat to disable pagers, fixed-width formatting flags. No human types like that under time pressure. An agent typing into a buffer it has to parse back does exactly that.

Signal 3 — value handoffs. The model ran ls -la ~/.ssh/id_ed25519* before cat ~/.ssh/id_ed25519. That's the existence-check-before-read pattern you get when an agent has a guardrail against reading non-existent files (because errors waste tokens and confuse the next turn). A human attacker just runs cat.

Signal 4 — improvisation. The attacker dumped a Postgres schema they had no prior knowledge of, and adapted on the fly. The query patterns showed the model reading \dt output, picking interesting tables by name, and writing per-table dumps. No scripted attack tool does that — by definition, scripts ship with their target schema.

End-to-end chain:

  1. Marimo RCE → shell as root in the container.
  2. env | grep -iE 'aws|key|secret|token|api|openai|anthropic' — credential sweep.
  3. Found AWS keys → aws secretsmanager list-secrets → pulled an SSH private key out of Secrets Manager.
  4. Eight parallel SSH sessions to an internal bastion (the agent parallelized; a human wouldn't have bothered).
  5. pg_dump against the internal Postgres, output streamed to attacker infrastructure.
  6. Total elapsed: ~60 minutes.

The notebook host wasn't the target. It was a credential cache for an AI dev workstation, and the agent knew it.

Why this changes the defensive picture

There are two things we (as people who build with agents) need to absorb.

First: notebook surface area was designed for one human. Jupyter, Marimo, Streamlit, Langflow — these tools assume a single trusted user at a localhost REPL. Then someone bolts on a "share with collaborators" feature, and now localhost:2718 is notebook.staging.acme.com, and the threat model never got rewritten. Marimo's bug is a clean instance of the pattern: the terminal route was perfectly safe in 2023 when nobody was reaching it. In 2026, it's a pre-auth RCE on the public internet.

I'd bet money there are equivalent bugs in at least three other notebook/agent platforms shipping right now. The class is CWE-306 manifesting as inconsistent auth across sibling WebSocket routes, and the Jupyter Server Proxy CVE and Langflow CVE-2026-33017 (also exploited in <24h after disclosure) say it's the dominant failure mode in this entire category.

Second: agents are now in the attacker toolchain, and they don't need to be good at it. The Marimo-to-Postgres chain isn't novel attack craft. Any competent red-teamer could have done it. What's new is that the attacker didn't have to. They pointed a model at a shell and the model wrote the playbook in real time, against a target it had never seen, with credentials it discovered as it went. That's a labor-cost collapse on the offense side, and it's already happening on a niche tool with ~20k GitHub stars. Imagine what it looks like next year against your CI runners.

What to actually do if you ship notebook or REPL UIs

I'm going to skip the generic "patch your stuff" advice. Here's the specific list:

  1. Enforce auth at the router level, not the endpoint level. Make the unsafe pattern impossible to write. In FastAPI/Starlette, that's a router-scoped dependency or a mounted sub-application with requires(...) baked in. If a junior dev can add a new WebSocket route without thinking about auth, your framework choice is wrong.
  2. Write negative tests for every WebSocket route. "Connect without credentials → assert 1008/close" is a four-line test. Marimo's PR #9098 added exactly this. Every notebook-style project should have this test for every WS route, today, regardless of whether they've shipped a CVE yet.
  3. Reconsider "interactive terminal" as a built-in. If your notebook tool ships a PTY shell endpoint by default, you've made a product decision that this tool ships RCE-as-a-service whenever auth fails. That can be the right call. It should be a conscious one.
  4. Assume credential exfil within minutes of any container compromise. The interesting question isn't whether attackers can read .env. It's whether your blast radius is bounded when they do. Short-lived credentials. IMDSv2. Per-environment IAM roles. The boring stuff is what saves you.
  5. Behavior-based detection beats signature detection in the agent era. You can't pattern-match on "looks like a human typing." Syscalls still look like syscalls though — Falco-style runtime rules caught the Sysdig honeypot session in real time, agent or no agent.

The Marimo team's response to the original CVE was actually solid — coordinated disclosure, full PoC in the advisory, targeted fix with tests, listed in CISA KEV inside two weeks. That's what a competent OSS incident response looks like. The bug shouldn't have shipped, but once it did, they handled it about as well as you can.

The harder problem isn't this CVE. It's that the same agent paradigm we're all building toward is now driving the post-exploitation playbook against the tools we use to build it. If you're shipping anything with a code-execution surface, you're now defending against an attacker who can write a custom intrusion script for your specific environment, in real time, for free.

That's the part to read twice.


Sources: Sysdig TRT (disclosure-to-exploit timing, AI agent at the wheel) · GHSA-2679-6mx9-h9xc · NVD CVE-2026-39987 · marimo-team/marimo#9098 · The Hacker News coverage.

SecurityAI AgentsMarimoCVE-2026-39987LLMIncident Analysis

Enjoyed this article?

Connect with me on LinkedIn for more insights on AI, automation, and full-stack development.