Pickle сидит в стандартной библиотеке Python с незапамятных времён и за прошедшие годы успел обрасти дурной репутацией, которая полностью заслужена. Модуль умеет превращать практически любой объект языка в байтовую последовательность и обратно, и эта универсальность делает его удивительно удобным для прототипирования. Но та же универсальность является источником многолетних проблем безопасности, потому что десериализация pickle это не просто чтение данных, а исполнение произвольного кода. В официальной документации Python без обиняков написано, что pickle никогда не должен применяться к данным из недоверенных источников, и это предупреждение разработчики игнорируют десятилетиями. Внутри одного приложения, между своими же процессами, использовать pickle технически безопасно, но эта удобная привычка оказывается ловушкой, как только в архитектуре появляется хоть один внешний вход.

Реальные инциденты последнего времени хорошо иллюстрируют масштаб проблемы. В популярном инференс-движке vLLM нашли уязвимость с максимальным баллом CVSS 10.0 ровно из-за того, что pickle-данные передавались через незащищённый ZeroMQ-сокет. Аналогичный класс уязвимостей обнаружился в LightLLM, и история повторяется в десятках популярных библиотек машинного обучения. Параллельно специалисты Sonatype нашли несколько CVE в picklescan - утилите, которой HuggingFace проверяет загружаемые модели на наличие вредоносного содержимого. Даже специализированные сканеры обходятся, потому что природа pickle такова, что отличить безобидную сериализацию от вредоносной до момента её исполнения принципиально невозможно.

Почему pickle нельзя сделать безопасным даже теоретически

Внутри pickle нет никакой магии, и понимание устройства модуля сразу объясняет фундаментальный характер проблемы. Сериализованный поток представляет собой набор инструкций для виртуальной машины pickle, которая при десериализации выполняет эти инструкции одну за другой. В наборе инструкций есть операции вроде REDUCE и BUILD, которые позволяют восстанавливать объекты любых классов, вызывая произвольные функции с произвольными аргументами. Именно через эти операции и работают эксплойты.

Канонический пример атаки помещается в пять строк:

import pickle
import os

class Evil:
    def __reduce__(self):
        return (os.system, ("rm -rf ~",))

payload = pickle.dumps(Evil())

Метод reduce задаёт правила сериализации объекта, и pickle при десериализации честно вызовет os.system с переданными аргументами. Никакой эксплуатации уязвимости компилятора, никакого выхода за границы массива - просто документированное поведение модуля, использованное во вред. Полученные байты можно записать в файл или передать по сети, и любой получатель, выполнивший pickle.loads на этих данных, запустит указанную команду в своём процессе с правами своего пользователя.

Попытки сделать pickle безопасным предпринимались многократно. Существует подкласс pickle.Unpickler с переопределённым методом find_class, который теоретически позволяет ограничить список разрешённых классов:

import pickle
import io

class RestrictedUnpickler(pickle.Unpickler):
    SAFE_CLASSES = {
        ("builtins", "dict"),
        ("builtins", "list"),
        ("builtins", "tuple"),
        ("my_app.models", "UserData"),
    }

    def find_class(self, module, name):
        if (module, name) in self.SAFE_CLASSES:
            return super().find_class(module, name)
        raise pickle.UnpicklingError(f"Класс {module}.{name} запрещён")

def safe_loads(data):
    return RestrictedUnpickler(io.BytesIO(data)).load()

На бумаге выглядит разумно. На практике белый список почти всегда оказывается дырявым. Исследователи безопасности регулярно находят способы обойти подобные фильтры через цепочки вроде subprocess.Popen, копирования через type, доступ к внутренним атрибутам разрешённых классов. Любая ошибка в составлении списка превращает мнимую защиту в формальность. Поэтому индустриальная практика проста: pickle применяется только к данным, которые сам процесс и создал, в режиме записи на диск с последующей проверкой целостности через HMAC или подпись.

Внутреннее IPC через multiprocessing и скрытый pickle под капотом

Самая коварная сторона pickle в Python-приложениях - его незаметность. Когда разработчик пишет код с использованием multiprocessing.Queue, multiprocessing.Pipe, concurrent.futures.ProcessPoolExecutor или Manager, он редко задумывается о том, что каждый объект, переходящий границу процесса, проходит через pickle.dumps и pickle.loads. Если кто-то получает контроль над одним из участвующих процессов или над каналом между ними, он автоматически получает RCE во всех остальных процессах через тот же канал.

В однопроцессном приложении на одной машине, без внешнего доступа к каналам IPC, риск минимален - но он не нулевой. Любая локальная уязвимость, дающая злоумышленнику возможность писать в Unix-сокет или в pipe, конвертируется в выполнение кода через pickle. И даже без активной атаки внутренний pickle создаёт проблемы при работе с недоверенными плагинами, при обработке файлов от пользователя в фоновом воркере, при кешировании ответов внешнего API.

Простейшая демонстрация выглядит так:

from multiprocessing import Process, Queue

def worker(q):
    while True:
        item = q.get()
        if item is None:
            break
        print(f"Получено: {item}")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    q.put({"task": "process", "id": 42})
    q.put(None)
    p.join()

Передача словаря через Queue выглядит безобидно, но фактически это сериализация через pickle с последующей десериализацией в дочернем процессе. Если в Queue по какой-то причине окажутся данные не от родителя, а от внешнего источника, исполнение кода в дочернем процессе становится тривиальной задачей. Архитектурно это не означает, что Queue нельзя применять - просто стоит учитывать, что граница доверия проходит по этому каналу, и компрометация одной стороны автоматически компрометирует другую.

JSON как первая линия защиты для структурированных данных

Самая прямолинейная замена pickle для большинства задач IPC это JSON. Модуль стандартной библиотеки json безопасен по самой своей конструкции - формат описывает только данные, без инструкций исполнения, и десериализатор физически не способен ничего запустить. Худшее, что может сделать вредоносный JSON, это занять всю память глубокой вложенностью или съесть процессор обилием экранированных символов, но это денайл сервиса, а не выполнение кода.

Переход с pickle на JSON в большинстве случаев требует минимальных изменений:

import json
from multiprocessing import Process, Queue

def worker(q):
    while True:
        raw = q.get()
        if raw is None:
            break
        item = json.loads(raw)
        print(f"Получено: {item}")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    q.put(json.dumps({"task": "process", "id": 42}))
    q.put(None)
    p.join()

Технически Queue здесь всё равно использует pickle для байтовой строки, что не страшно - сериализация байтов через pickle не открывает атакам путь, и десериализация даст обратно те же байты без побочных эффектов. JSON-парсинг происходит уже отдельно и безопасно.

У JSON есть ограничения, о которых стоит помнить заранее. Формат не различает кортежи и списки - всё превращается в массивы. Нет встроенной поддержки datetime, Decimal, UUID, set, bytes - всё это приходится сериализовать вручную через кастомный encoder. Большие числа теряют точность, если на принимающей стороне JSON парсится как JavaScript-объект, хотя в чисто питоновском контексте такая проблема не возникает. Скорость парсинга у стандартного json сильно проигрывает бинарным форматам на больших объёмах, особенно когда речь идёт о миллионах мелких сообщений в секунду.

Для типовых случаев с datetime и UUID достаточно простого расширения:

import json
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID

class ExtendedEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, (datetime, date)):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            return str(obj)
        if isinstance(obj, UUID):
            return str(obj)
        if isinstance(obj, set):
            return list(obj)
        return super().default(obj)

data = {"id": 42, "created": datetime.now(), "tags": {"py", "ipc"}}
serialized = json.dumps(data, cls=ExtendedEncoder)

Обратное превращение требует кода на стороне приёмника, который понимает, какое поле является датой, а какое строкой. Этот недостаток компенсируется тем, что граница типов становится явной частью контракта между процессами, что улучшает читаемость и тестируемость. Для скорости стандартный json часто заменяют на orjson, который пишет на Rust и работает в три-десять раз быстрее на типичных нагрузках, поддерживая datetime, UUID и Decimal из коробки без кастомных энкодеров.

MessagePack как бинарный JSON с правильной типизацией

Когда объём передаваемых данных вырастает, и JSON становится узким местом по скорости или размеру, в ход идёт MessagePack. Формат концептуально похож на JSON, но кодирует данные в бинарном виде, что даёт двух-трёхкратное сокращение объёма на типичной структурированной нагрузке и заметное ускорение парсинга. При этом MessagePack сохраняет главное достоинство JSON - он описывает данные, а не код, и десериализация принципиально не способна привести к исполнению чего-либо.

Базовое применение через библиотеку msgpack выглядит почти как замена json по сигнатуре функций:

import msgpack
from multiprocessing import Process, Queue

def worker(q):
    while True:
        raw = q.get()
        if raw is None:
            break
        item = msgpack.unpackb(raw, raw=False)
        print(f"Получено: {item}")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    payload = msgpack.packb({"task": "process", "id": 42, "weight": 3.14})
    q.put(payload)
    q.put(None)
    p.join()

Параметр raw=False в unpackb говорит библиотеке декодировать строковые данные из bytes в str автоматически - без него все строковые поля останутся байтовыми объектами, что иногда удобно, но чаще неожиданно. Поддержка datetime и других нестандартных типов настраивается через extension types, которые MessagePack умеет различать на уровне формата:

import msgpack
from datetime import datetime

def encode(obj):
    if isinstance(obj, datetime):
        return msgpack.ExtType(1, obj.isoformat().encode("utf-8"))
    raise TypeError(f"Не умею сериализовать {type(obj)}")

def decode(code, data):
    if code == 1:
        return datetime.fromisoformat(data.decode("utf-8"))
    return msgpack.ExtType(code, data)

data = {"created": datetime.now(), "value": 100}
packed = msgpack.packb(data, default=encode)
unpacked = msgpack.unpackb(packed, ext_hook=decode, raw=False)

Бинарное представление имеет ещё одно преимущество перед JSON: bytes-поля передаются без всякого base64-кодирования, что важно для случаев, когда между процессами летят пакеты сетевых протоколов, фрагменты изображений или другие непечатные данные. JSON в таких сценариях раздувает объём примерно на треть из-за принудительной кодировки.

Protocol Buffers и схема как контракт между процессами

Когда система разрастается и количество разных типов сообщений между процессами идёт на десятки, имеет смысл задуматься о схематизированной сериализации. Protocol Buffers от Google уже четверть века остаются золотым стандартом в этой нише и неплохо приживаются в Python-проектах, особенно там, где IPC может в будущем превратиться в межсервисное взаимодействие.

Схема описывается в отдельном .proto-файле:

syntax = "proto3";

message TaskMessage {
    string task_type = 1;
    int64 task_id = 2;
    double priority = 3;
    repeated string tags = 4;
}

После генерации Python-кода через protoc объект используется как обычный класс:

import task_pb2
from multiprocessing import Process, Queue

def worker(q):
    while True:
        raw = q.get()
        if raw == b"":
            break
        msg = task_pb2.TaskMessage()
        msg.ParseFromString(raw)
        print(f"Получено: {msg.task_type}, id={msg.task_id}")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    
    msg = task_pb2.TaskMessage()
    msg.task_type = "process"
    msg.task_id = 42
    msg.priority = 0.9
    msg.tags.extend(["urgent", "billing"])
    
    q.put(msg.SerializeToString())
    q.put(b"")
    p.join()

Главное достоинство protobuf не в скорости и не в компактности, хотя по обоим параметрам он сопоставим с MessagePack. Главное это контракт. Схема живёт в одном месте, проверяется на этапе сборки, версионируется и эволюционирует по понятным правилам обратной совместимости. Поле, добавленное в новой версии, спокойно игнорируется старыми процессами. Поле, помеченное как deprecated, выводится из обращения постепенно. Это превращает IPC из массы разрозненных словарей в осмысленный API между компонентами, который сложнее сломать случайным изменением в одном месте.

У protobuf есть и недостатки. Этап генерации кода добавляет шаг в процесс сборки. Схема для совсем простых случаев избыточна. Производительность Python-реализации без расширения на C/C++ заметно отстаёт от того же MessagePack. Тем не менее для крупных проектов с долгим сроком жизни и большим количеством разных сообщений выигрыш в дисциплине разработки часто перевешивает накладные расходы.

Shared memory для больших данных без всякой сериализации

Все описанные выше форматы решают задачу безопасности, но не решают задачу скорости для случаев, когда между процессами летят большие массивы данных. Передача гигабайтного NumPy-массива через Queue или Pipe потребует сначала сериализовать его в байты, потом скопировать через канал, потом десериализовать обратно. Это занимает секунды и удваивает потребление памяти на время передачи.

Модуль multiprocessing.shared_memory из стандартной библиотеки Python позволяет завести область памяти, видимую сразу нескольким процессам, и работать с ней без сериализации:

from multiprocessing import Process
from multiprocessing.shared_memory import SharedMemory
import numpy as np

def consumer(shm_name, shape, dtype):
    existing_shm = SharedMemory(name=shm_name)
    arr = np.ndarray(shape, dtype=dtype, buffer=existing_shm.buf)
    print(f"Сумма в дочернем процессе: {arr.sum()}")
    existing_shm.close()

if __name__ == "__main__":
    data = np.arange(10_000_000, dtype=np.float64)
    shm = SharedMemory(create=True, size=data.nbytes)
    shared_arr = np.ndarray(data.shape, dtype=data.dtype, buffer=shm.buf)
    shared_arr[:] = data[:]
    
    p = Process(target=consumer, args=(shm.name, data.shape, data.dtype))
    p.start()
    p.join()
    
    shm.close()
    shm.unlink()

Имя сегмента памяти и метаинформация о форме массива передаются через обычный механизм запуска процесса, а сами данные при этом не копируются. Дочерний процесс открывает существующий сегмент по имени и работает с памятью напрямую. Для массива в десять миллионов значений с двойной точностью это разница между десятками миллисекунд на сериализацию плюс копирование и буквально микросекундами на открытие сегмента.

В подходе есть нюансы, о которых обычно умалчивают вводные руководства. Очистка ресурсов лежит на разработчике - сегмент памяти, не освобождённый через unlink, останется висеть в системе до перезагрузки. На Linux это особенно заметно, так как видимые сегменты появляются в /dev/shm, и при многократном тестировании файлы там накапливаются. Безопасность тут другого рода: содержимое сегмента доступно любому процессу того же пользователя, знающему имя сегмента, поэтому передавать через shared memory секретные данные без дополнительной защиты неразумно. Синхронизация доступа на совести разработчика - двум процессам, пишущим в одну область одновременно, нужен механизм блокировок через Lock или семафоры.

Для типизированных данных вроде NumPy-массивов есть и более высокоуровневый ShareableList, который умеет хранить смесь чисел, строк и булевых значений, но он подходит только для небольших структур, а не для массивов мегабайтного размера.

Сводная стратегия выбора и где какой инструмент уместен

Выбор подходящего механизма IPC зависит от трёх параметров: объёма данных, частоты обмена и уровня доверия к источнику. Если данные приходят извне или хотя бы потенциально могут прийти извне - pickle отпадает сразу, без разговоров. Если данные строго внутренние, и архитектура гарантирует, что во всех точках входа применяется отдельная схема валидации, тогда pickle с подписью HMAC иногда оправдан, но даже в этом случае альтернативы обычно дают сопоставимую производительность без рисков.

Несколько практических рекомендаций для типовых ситуаций:

  1. для сообщений между процессами с произвольной структурой использовать JSON в стандартном виде или через orjson для скорости;
  2. для бинарных данных и высоконагруженных пайплайнов сообщений переходить на MessagePack с явной обработкой расширенных типов;
  3. для больших проектов с долгим жизненным циклом и множеством разных сообщений завести protobuf-схемы и проверять совместимость на CI;
  4. для передачи больших массивов численных данных применять shared_memory с явной синхронизацией доступа;
  5. полностью убрать pickle из любых каналов, где данные хоть теоретически могут прийти из недоверенного источника, включая кеши Redis, файлы из файловой системы и сетевые соединения.

Pickle стоит оставить там, где он действительно незаменим - в сохранении состояния сложных Python-объектов на диск для последующего восстановления тем же процессом, в кешировании результатов внутри одного приложения, в передаче кастомных классов между процессами одного контролируемого пула, защищённого от внешнего влияния. Везде, где есть граница доверия, нужен формат, не способный исполнять код по своей природе. Сэкономленные на этом решении нервы и время на разбор инцидентов окупают тот небольшой объём работы, который требуется для замены десятка вызовов dumps и loads на эквиваленты из msgpack или json. А привычка думать о сериализации не как о технической детали, а как об архитектурной границе, делает код устойчивее не только к атакам, но и к собственным ошибкам, что в долгосрочной перспективе ценится не меньше формальной защищённости.