GHSA-5324-3W58-QPP2
Vulnerability from github – Published: 2026-10-28 21:17 – Updated: 2026-10-28 21:17Summary
_build_session_scope() in mem0/memory/main.py builds the conversation-history scope key used to fetch/store the last N raw messages by naively joining caller-supplied user_id/agent_id/run_id values with unescaped & and = delimiters:
def _build_session_scope(filters): parts = [] for key in sorted(["user_id", "agent_id", "run_id"]): val = filters.get(key) if val: parts.append(f"{key}={val}") return "&".join(parts)
_validate_and_trim_entity_id() (the only validation applied to these values) rejects only empty/whitespace-only strings — it never forbids & or =. This scope string is then used as an exact-match key against a SQLite column (storage.py: WHERE session_scope = ?) to fetch the last 10 raw messages for a given scope (main.py, Phase 0 of _add_to_vector_store, both sync and async).
Impact
Any caller who controls only their own run_id (or agent_id) — a very common integration shape, since apps frequently pass a client-/session-supplied identifier straight through as run_id without namespacing — can craft a value containing &user_id= (or &agent_id=) that produces a scope string byte-for-byte identical to a different tenant's real scope string.
Concretely: Tenant A calls add(msgs, user_id="alice", run_id="proj-x") → scope "run_id=proj-x&user_id=alice". An attacker who can only set run_id calls add(msgs, run_id="proj-x&user_id=alice") → identical scope string. The attacker's next add() call:
- Reads Tenant A's raw prior conversation via get_last_messages(session_scope) and splices it verbatim into the attacker's own LLM fact-extraction prompt — a direct cross-tenant confidentiality leak (conversation content, potentially including PII, credentials, etc.).
- Writes into the same colliding row-set via save_messages(), whose eviction logic (DELETE FROM messages WHERE session_scope = ? AND id NOT IN (...)) can evict/interleave Tenant A's history — permanent cross-tenant data corruption, not just a read.
Reachable via the public Memory.add() / AsyncMemory.add() API, and by extension the REST server's POST /memories endpoint (which takes run_id/agent_id straight from the request body).
Reproduction
from mem0.memory.main import _build_session_scope
Tenant A: real user_id + run_id
scope_a = _build_session_scope({"user_id": "alice", "run_id": "proj-x"}) print(scope_a) # "run_id=proj-x&user_id=alice"
Attacker: controls only run_id, embeds a fake user_id inside it
scope_c = _build_session_scope({"run_id": "proj-x&user_id=alice"}) print(scope_c) # "run_id=proj-x&user_id=alice"
assert scope_a == scope_c # byte-for-byte collision
A full end-to-end proof (actual add() calls, no mocking of the vulnerable functions) confirms the attacker's subsequent add() call receives the victim's raw prior message content via get_last_messages.
Suggested Fix
Reject & and = in _validate_and_trim_entity_id(), alongside the existing whitespace check — this is the single validation chokepoint already used by every entry point (add, search, get_all, delete_all), so the fix is centralized. Alternatively/additionally, encode each component unambiguously (percent-encode each id, or use a length-prefixed/JSON-encoded scope key) so no valid-character combination in one field can ever collide with a different partition.
Also affected: TypeScript SDK (npm mem0ai)
The same flaw exists in mem0-ts/src/oss/src/memory/index.ts,
Memory.buildSessionScope(), which joins agent_id/run_id/user_id into the same
key=value&key=value string with the delimiters unescaped:
private buildSessionScope(filters: SearchFilters): string {
const parts: string[] = [];
for (const key of ["agent_id", "run_id", "user_id"].sort()) {
const val = (filters as any)[key];
if (val) parts.push(${key}=${val});
}
return parts.join("&");
}
The scope string is used the same way, as an exact-match key into the messages table via SQLiteManager.getLastMessages() and saveMessages(), so the identical forge, cross-tenant read and eviction all apply.
Verified against the current releases as of 2026-07-26: pip mem0ai v2.0.14 and npm mem0ai ts-v3.1.2 both still contain the unescaped join.
Reporter: Abhinav Singh (GitHub: OfficialAbhinavSingh)
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "mem0ai"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.0.18"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "mem0ai"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.1.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-116575"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-28T21:17:08Z",
"nvd_published_at": "2026-10-26T21:18:13Z",
"severity": "MODERATE"
},
"details": "Summary\n\n_build_session_scope() in mem0/memory/main.py builds the conversation-history scope key used to fetch/store the last N raw messages by naively joining caller-supplied user_id/agent_id/run_id values with unescaped \u0026 and = delimiters:\n\ndef _build_session_scope(filters):\n parts = []\n for key in sorted([\"user_id\", \"agent_id\", \"run_id\"]):\n val = filters.get(key)\n if val:\n parts.append(f\"{key}={val}\")\n return \"\u0026\".join(parts)\n\n_validate_and_trim_entity_id() (the only validation applied to these values) rejects only empty/whitespace-only strings \u2014 it never forbids \u0026 or =. This scope string is then used as an exact-match key against a SQLite column (storage.py: WHERE session_scope = ?) to fetch the last 10 raw messages for a given scope (main.py, Phase 0 of _add_to_vector_store, both sync and async).\n\nImpact\n\nAny caller who controls only their own run_id (or agent_id) \u2014 a very common integration shape, since apps frequently pass a client-/session-supplied identifier straight through as run_id without namespacing \u2014 can craft a value containing \u0026user_id=\u003cvictim\u003e (or \u0026agent_id=\u003cvictim\u003e) that produces a scope string byte-for-byte identical to a different tenant\u0027s real scope string.\n\nConcretely: Tenant A calls add(msgs, user_id=\"alice\", run_id=\"proj-x\") \u2192 scope \"run_id=proj-x\u0026user_id=alice\". An attacker who can only set run_id calls add(msgs, run_id=\"proj-x\u0026user_id=alice\") \u2192 identical scope string. The attacker\u0027s next add() call:\n\n1. Reads Tenant A\u0027s raw prior conversation via get_last_messages(session_scope) and splices it verbatim into the attacker\u0027s own LLM fact-extraction prompt \u2014 a direct cross-tenant confidentiality leak (conversation content, potentially including PII, credentials, etc.).\n2. Writes into the same colliding row-set via save_messages(), whose eviction logic (DELETE FROM messages WHERE session_scope = ? AND id NOT IN (...)) can evict/interleave Tenant A\u0027s history \u2014 permanent cross-tenant data corruption, not just a read.\n\nReachable via the public Memory.add() / AsyncMemory.add() API, and by extension the REST server\u0027s POST /memories endpoint (which takes run_id/agent_id straight from the request body).\n\nReproduction\n\nfrom mem0.memory.main import _build_session_scope\n\n# Tenant A: real user_id + run_id\nscope_a = _build_session_scope({\"user_id\": \"alice\", \"run_id\": \"proj-x\"})\nprint(scope_a) # \"run_id=proj-x\u0026user_id=alice\"\n\n# Attacker: controls only run_id, embeds a fake user_id inside it\nscope_c = _build_session_scope({\"run_id\": \"proj-x\u0026user_id=alice\"})\nprint(scope_c) # \"run_id=proj-x\u0026user_id=alice\"\n\nassert scope_a == scope_c # byte-for-byte collision\n\nA full end-to-end proof (actual add() calls, no mocking of the vulnerable functions) confirms the attacker\u0027s subsequent add() call receives the victim\u0027s raw prior message content via get_last_messages.\n\nSuggested Fix\n\nReject \u0026 and = in _validate_and_trim_entity_id(), alongside the existing whitespace check \u2014 this is the single validation chokepoint already used by every entry point (add, search, get_all, delete_all), so the fix is centralized. Alternatively/additionally, encode each component unambiguously (percent-encode each id, or use a length-prefixed/JSON-encoded scope key) so no valid-character combination in one field can ever collide with a different partition.\n\nAlso affected: TypeScript SDK (npm mem0ai)\n\nThe same flaw exists in mem0-ts/src/oss/src/memory/index.ts,\nMemory.buildSessionScope(), which joins agent_id/run_id/user_id into the same\n`key=value\u0026key=value` string with the delimiters unescaped:\n\n private buildSessionScope(filters: SearchFilters): string {\n const parts: string[] = [];\n for (const key of [\"agent_id\", \"run_id\", \"user_id\"].sort()) {\n const val = (filters as any)[key];\n if (val) parts.push(`${key}=${val}`);\n }\n return parts.join(\"\u0026\");\n }\n\nThe scope string is used the same way, as an exact-match key into the messages\ntable via SQLiteManager.getLastMessages() and saveMessages(), so the identical\nforge, cross-tenant read and eviction all apply.\n\nVerified against the current releases as of 2026-07-26: pip mem0ai v2.0.14 and\nnpm mem0ai ts-v3.1.2 both still contain the unescaped join.\n\nReporter: Abhinav Singh (GitHub: OfficialAbhinavSingh)",
"id": "GHSA-5324-3w58-qpp2",
"modified": "2026-10-28T21:17:08Z",
"published": "2026-10-28T21:17:08Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/mem0ai/mem0/security/advisories/GHSA-5324-3w58-qpp2"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-116575"
},
{
"type": "WEB",
"url": "https://github.com/mem0ai/mem0/pull/6892"
},
{
"type": "WEB",
"url": "https://github.com/mem0ai/mem0/commit/71f2ebefa3494da21550fb525216818776cde67f"
},
{
"type": "PACKAGE",
"url": "https://github.com/mem0ai/mem0"
},
{
"type": "WEB",
"url": "https://github.com/mem0ai/mem0/releases/tag/ts-v3.1.6"
},
{
"type": "WEB",
"url": "https://github.com/mem0ai/mem0/releases/tag/v2.0.18"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Mem0: Cross-tenant conversation leak via unescaped delimiter collision in session-scope key (_build_session_scope)"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.