← Back to Research

PoC deep-dive: how a text-to-SQL agent turns prompt injection into RCE on the database host (CVE-2026-25879)

On 2026-06-01, the Langroid project published advisory GHSA-mxfr-6hcw-j9rq for CVE-2026-25879, a 9.8-critical flaw in the SQLChatAgent component of its LLM application framework. Versions prior to 0.63.0 let a large language model write SQL and then run it against a live database. That is the entire feature. It is also the entire vulnerability. When the database role behind the agent carries privileges that reach the filesystem or the shell, an attacker who can shape the agent's input can ride prompt injection straight through the query layer and into remote code execution on the database host.

This is a defender's deep-dive in the same shape we use for every advisory we publish: how the defect actually works, how you determine whether your own deployment is exposed without firing a weaponized payload, and how you fix it. The bug is interesting beyond one framework because it is a clean example of a category that is about to become common. We are wiring LLMs to tools that have real-world side effects, and a text-to-SQL agent is one of the most side-effect-heavy tools there is. The lesson generalizes well past Langroid.

Why a text-to-SQL agent is a privileged-execution surface

A retrieval chatbot that answers from a vector store has a bounded blast radius: the worst it can leak is whatever you embedded. A text-to-SQL agent is a different animal. Its whole purpose is to take a natural-language question, have the model compose a SQL statement, and execute that statement against a real database connection. The model is, in effect, an unauthenticated author of arbitrary queries that run with whatever privileges the connection role holds.

That would be merely risky if SQL were a pure data language. It is not. Every major engine ships dialect-specific primitives that cross the boundary from the database into the host operating system. PostgreSQL has COPY ... FROM PROGRAM, which runs a shell command and pipes its output into a table, and it exposes server-program execution to roles holding pg_execute_server_program. MySQL and MariaDB have the FILE privilege, which lets a query read and write arbitrary files on the server with LOAD_FILE and INTO OUTFILE. Microsoft SQL Server has xp_cmdshell, a stored procedure that runs an operating-system command directly. Each of these is a documented feature for trusted administrators. Each becomes a remote-code-execution primitive the moment an untrusted party can choose the SQL.

CVE-2026-25879 is the collision of those two facts. The untrusted party is the LLM, and the LLM is steerable by anyone whose text reaches it.

The injection path: direct and indirect

There are two ways the attacker's text reaches the model, and the second is the dangerous one.

The direct path is obvious: a user types the malicious instruction into the chat box. If your text-to-SQL agent is internet-facing or reachable by any partly-trusted user, you already have to assume hostile input.

The indirect path is the one that turns a curiosity into a breach. Many of these agents are agentic: the model runs a query, sees the rows that come back, and uses those rows to decide what to do next. That means the database content itself is part of the model's context. An attacker who can write a single row into a table the agent reads, perhaps through an ordinary application form that lands in the same database, can plant an instruction that the model will later read back and obey. The advisory calls this out explicitly: the agent's input can be shaped indirectly via data returned to the LLM. This is the same indirect-prompt-injection pattern that makes RAG systems dangerous, except the tool on the other end is not a summarizer, it is a SQL executor with a host-reachable role.

So the threat model is not only "a malicious user asks a nasty question." It is "any data the agent might ever read could contain an instruction." That is a much larger surface, and it is invisible to a perimeter that only watches the chat endpoint.

The defect class: the agent trusts that model output is data, not code

Underneath the LLM framing this is an old, well-understood failure: concatenating untrusted input into a privileged interpreter and executing it without an allowlist. Classic SQL injection puts attacker text inside a query the developer wrote. Here the model writes the entire query, so there is no developer-authored template to parameterize. The mitigation cannot be "use bound parameters," because there is no fixed statement to bind into. The only defensible control is to constrain what statements are allowed to execute at all.

Conceptually, the pre-0.63.0 execution path looked like this:

# DEFECTIVE: whatever the model emits gets executed with the role's full rights.
def run_agent_sql(model_output: str, conn):
    sql = extract_sql(model_output)     # trust the LLM to have written safe SQL
    return conn.execute(sql)            # <-- the bug: no statement allowlist,
                                        #     no dialect-aware dangerous-op block

# DEFENSIBLE: parse the statement, allow only SELECT, block dialect escapes.
def run_agent_sql(model_output: str, conn, dialect: str):
    sql = extract_sql(model_output)
    stmt = sqlglot.parse_one(sql, read=dialect)   # real parse, not a regex
    if stmt.key != "select":                       # SELECT-only allowlist
        raise Unsafe("non-SELECT statement refused")
    if has_dangerous_primitive(stmt, dialect):     # COPY...PROGRAM, INTO OUTFILE,
        raise Unsafe("dangerous primitive refused") #   xp_cmdshell, LOAD_FILE, ...
    return conn.execute(sql)

The fix shipped in v0.63.0 is exactly the right shape. SQLChatAgent now defaults to a SELECT-only allowlist enforced by parsing the statement with sqlglot, layered with a dialect-aware blocklist for the dangerous primitives above. Parsing matters: a regex that greps for COPY is trivially bypassed by comments, casing, or whitespace tricks, whereas an actual parse understands the statement's structure. The previous unrestricted behavior is still available behind an explicit allow_dangerous_operations=True flag for operators who genuinely want it on a fully trusted, isolated deployment. That flag is the line between a deliberate choice and an accident.

How to tell if you are exposed, evidence-first

The version check is necessary but it is not a finding on its own. A vulnerable version with a read-only database role and no host-reaching privileges is far less exploitable than a current version pointed at a superuser. Exposure here is the product of three independent facts, and you confirm each one separately without ever running a host command.

First, the version and configuration. This is passive: read what you have deployed.

# Passive: is Langroid below the fixed release, and is the dangerous flag on?
pip show langroid 2>/dev/null | grep -i '^Version:'   # fixed in 0.63.0
# Vulnerable if Version < 0.63.0.
grep -rEn "allow_dangerous_operations\s*=\s*True" . 2>/dev/null
# Any hit re-opens the unrestricted path even on a patched build.

Second, and this is the one most teams skip, the database role's actual grants. The blast radius is set here. A SELECT-only login role cannot reach COPY ... FROM PROGRAM no matter what the model writes. The check is a benign metadata query against your own database with your own credentials. It reads catalog state and runs nothing.

-- PostgreSQL: does the agent's role hold a host-reaching grant?
-- Read-only catalog query. It executes no program and writes no file.
SELECT r.rolname, r.rolsuper,
       pg_has_role(r.rolname, 'pg_execute_server_program', 'USAGE') AS can_run_program
FROM pg_roles r
WHERE r.rolname = current_user;
-- FINDING posture: can_run_program = true OR rolsuper = true means a coerced
-- COPY ... FROM PROGRAM would execute. false/false is a contained PASS.

For MySQL the equivalent is SHOW GRANTS and looking for FILE; for SQL Server it is checking whether xp_cmdshell is enabled and the login can reach it. In every case the rule is the same: the finding is the intersection of a vulnerable version, a reachable agent, and a role that actually holds a host-reaching privilege. Any one of those missing is a PASS, and a scanner that raises an alert on the version banner alone is manufacturing noise.

Third, reachability. The injection only matters if attacker-influenced text can reach the model, directly through the chat surface or indirectly through a table the agent reads. Map which tables feed the agent's context and which of those accept writes from a lower-trust path. That mapping is the difference between a theoretical and a live finding.

How we validate it, and why the validation is the product

We track this as an LLM-surface scenario in the RED-LLMSQL family. The catalog entry does not stop at "Langroid < 0.63.0 is installed." It chains the three facts above into a single disposition. We confirm the version, we read the database role's grants through a benign catalog query against the customer's own instance, and we test whether a benign, clearly-marked probe instruction routed through the agent results in a non-SELECT statement reaching the executor. The probe never carries a COPY ... FROM PROGRAM payload or any other host-reaching primitive. It asks only the honest question: does the agent refuse to execute a statement it should refuse, or does the refusal layer let it through.

When all three line up, we mint a Proof Capsule that carries the version, the specific grant that makes the role host-reaching, the request and response showing the missing statement allowlist, and the remediation that names v0.63.0 and the role-privilege reduction. When the role is read-only, or the agent already refuses non-SELECT statements, we record a PASS and we do not raise a finding, because a disposition that proves nothing got through is not a vulnerability. That distinction is the whole point. Anyone can grep for a version string. The value is in proving, with evidence, whether the boundary actually holds in your deployment.

How to fix it, in priority order

  1. Upgrade Langroid to 0.63.0 or later. This restores the SELECT-only allowlist and the dialect-aware dangerous-operation blocklist by default. It is the only durable fix.
  2. Strip the database role down to least privilege. The agent's connection role should hold SELECT on exactly the tables it needs and nothing else. Revoke pg_execute_server_program, the MySQL FILE privilege, and xp_cmdshell access. Never point a text-to-SQL agent at a superuser. This single change collapses the blast radius even on an unpatched build.
  3. Leave allow_dangerous_operations at its default. If you must enable it, do so only on a fully isolated, trusted deployment, and document why.
  4. Treat database content as untrusted input to the model. Any table the agent reads can carry an injected instruction. Constrain which tables feed the agent's context and which lower-trust paths can write to them.
  5. Run the agent's database on an isolated host with no outbound network and no secrets on disk, so that even a successful primitive lands somewhere with nothing worth taking.

What this CVE says about the next year of LLM bugs

The framework will get patched and the next text-to-SQL agent will ship the same mistake, because the mistake is structural. We are connecting non-deterministic models to deterministic tools that have real authority, and the model is steerable by anyone whose text reaches it. The defensible pattern is the one the fix demonstrates: constrain the tool, not the prompt. You cannot reliably stop a model from being talked into writing a dangerous statement. You can absolutely stop a dangerous statement from executing, by parsing what the model emits and allowing only what is safe, and by ensuring the role behind the connection cannot reach the host even if the allowlist someday fails.

Pen-testers will inventory your LLM stack once and call it reviewed. The exposure is not the model, it is the boundary between the model's output and the tool that runs it, and that boundary has to be tested with evidence on every dependency bump and every privilege change. We test the boundary by asking the agent to run a statement it should refuse, every week, and we prove which agents actually refuse, with the patch and the privilege reduction attached.

Sources

Get your exposure check: full report in 4-24 hours

Real assessment on production-grade infrastructure. We prove what is exploitable and attach the fix. Paying customers get priority capacity.

Queue My Assessment