Blog

CVE-2024-5565 — Vanna's exec() call turns prompt injection into code execution

A teardown of how Vanna's text-to-SQL visualization feature hands LLM-generated Python straight to exec(), and how mvm-scout's SCOUT-RCE-002 rule caught the pattern and cleared on the one-line fix.

securitycve-teardownprompt-injectionpythonllmvanna
A diagram showing an untrusted question or data source flowing into an LLM, which generates Python plotting code, which is passed to exec() and runs with the host process's own privileges.

The bug

Vanna is a text-to-SQL library: you ask it a question in natural language, and it asks an LLM to write SQL, run it, and chart the result. The charting step is where CVE-2024-5565 lives. To draw the chart, Vanna asks the LLM to write Plotly code, and then it runs whatever comes back.

That’s the whole vulnerability. There’s no sandbox, no allowlist of safe operations, no parser that checks the generated code is actually a chart. It’s a natural-language interface with exec() sitting behind it. CVSS scores it 8.1, GitHub rates it Critical, and the advisory (GHSA-7735-w2jp-gvg6) tracks the same identifier. It’s fixed in 0.7.9; anything at 0.5.5 or the surrounding range is exposed.

What makes this one worth a teardown isn’t the exec-on-LLM-output pattern itself — that shape shows up across the LLM tooling ecosystem — it’s how directly the fix maps to the flaw. One function call, in one file, on one line, is the entire attack surface. That makes it an unusually clean case for asking whether a detection rule is actually tracking the root cause, or just pattern-matching on noise nearby.

The mechanism

The vulnerable code lives in src/vanna/base/base.py, line 1998:

exec(plotly_code, globals(), ldict)

plotly_code is text the LLM produced. It’s handed to exec() along with the real globals() dict — not a restricted namespace, the actual module globals. Anything the model was steered into emitting runs as native Python inside the host process, with access to whatever that process can already reach: imports, the filesystem, sockets, credentials sitting in memory or environment variables.

The attacker doesn’t need direct access to the codebase or the process to trigger this. They need to influence what the LLM writes. That can be a crafted question a user types in, a poisoned row in the underlying dataset that the LLM summarizes as part of building the chart, or any of the standard indirect-prompt-injection vectors where the model reads untrusted content and treats instructions embedded in it as if they came from the operator. Once the model can be steered into emitting import os; os.system(...) instead of fig = px.bar(...), the charting feature is a general-purpose code execution primitive.

What the scan found

We ran mvm-scout (rev 59aae67) against the vulnerable specimen. Its SCOUT-RCE-002 rule is built to flag exactly this shape — unguarded dynamic execution of externally-influenced input — and it fired on the exec() call at line 1998.

Seven rules fired against this specimen in total, but six of them are not evidence of anything specific to this CVE: SCOUT-AGENT-001, SCOUT-AUTHORITY-001, SCOUT-CVE-001, SCOUT-SECRET-001, SCOUT-SECRET-003, and SCOUT-SSRF-001 all fired identically on both the vulnerable specimen and the patched control. A rule that fires the same way regardless of whether the vulnerability is present isn’t telling you anything about the vulnerability — it’s describing something else about the codebase. We’re not citing those six as evidence for this writeup, and you should be skeptical of any teardown that lumps a pile of same-on-both-sides hits in with the one that actually discriminates.

SCOUT-RCE-002 is the one rule that behaved differently between the two, which is what makes it worth reporting.

The falsification

This is the part that actually matters, more than the initial hit. A rule firing on vulnerable code is easy — the hard part is showing it isn’t firing on something incidental to the file, the library, or the sample.

The entire diff between the specimen and the control is one line:

1998c1998
< exec(plotly_code, globals(), ldict)
---
> ldict["fig"] = safe_eval_plotly_expression(plotly_code, df)

Everything else in the file — imports, surrounding functions, class structure, the rest of the 1998 lines above and below — is untouched. We reran mvm-scout against that single-line mutation. SCOUT-RCE-002 went from firing to silent. Nothing else changed in the rule output for that check.

That’s the differential: SCOUT-RCE-002 fires on the specimen, clears on the control, and the only thing that moved between the two runs is the line the CVE is actually about. If the rule had kept firing after the patch, that would mean it was keying off something else in the file — a library name, an import pattern, incidental structure — and the earlier hit would have been coincidence dressed up as detection. It didn’t. The signal tracks the root cause.

What this evidence does not show

This is scout-detection evidence, not an MVM containment trace, and it’s worth being precise about the boundary.

No exploit was attempted or blocked in this run. We didn’t craft a malicious question, feed Vanna a poisoned data source, or demonstrate that the injection path is reachable by an attacker-controlled prompt in an actual deployment — we compared static source across a specimen and a one-line control. mvm_outcome was not tested here, so nothing in this report supports a claim that MVM prevented the breach, contained anything in a guest, or stopped an exploit from running. Those are separate, not-yet-tested questions, and this run doesn’t speak to them either way.

The defensible statement is narrower than that, and it’s the one this report actually makes: mvm-scout caught the vulnerable exec() pattern in the specimen and correctly cleared once the code was changed to use a constrained evaluator. Nothing here certifies Vanna, mvm-scout, or MVM. It documents one detection result, on one specimen/control pair, run on 2026-09-06. The underlying CVE still fires wherever the patch is absent, regardless of what any scanner reports about it.

The evidence behind this post

Every figure above came out of a run, not a writeup. These are the exact inputs, so you can re-derive them:

AdvisoryCVE-2024-5565
Packagevanna 0.5.5 (PyPI)
Fixed in0.7.9
Severity / CVSSCritical · 8.1
Root causesrc/vanna/base/base.py:1998
Detected bySCOUT-RCE-002
Scanned bytes (sha256)6022fd32186b19ea15bfd75af3935d5af110b67a9f9164b28f9019a3fc10d135
mvm-assurance revision59aae67
Containment demonot run

The scan target was the unmodified upstream file at that tag — the digest above is of the bytes the project published. The control differs from it by exactly one line.