Небезопасная десериализация

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

Небезопасная десериализация — уязвимость, при которой приложение восстанавливает объект из присланных извне байтов и в процессе восстановления выполняет логику, заложенную атакующим.

Сериализация — это превращение объекта в поток байтов (для хранения или передачи), а десериализация — обратная операция. Проблема возникает, когда программа доверяет источнику этих байтов. Если поток пришёл от пользователя — из cookie, тела запроса, очереди сообщений, кеша — то фактически пользователь управляет тем, какие объекты будут созданы в памяти сервера. В худшем случае это приводит к выполнению кода на сервере (класс уязвимостей RCE — Remote Code Execution). В OWASP Top 10 2021 это входит в категорию A08: Software and Data Integrity Failures.

Изучаем мы это исключительно для защиты собственного кода. Подбор «волшебной» строки, которая выполнит команду на чужом сервере, — это несанкционированное воздействие (статьи 272 и 273 УК РФ). Наша цель — научиться писать код, в котором такая атака невозможна в принципе, и распознавать опасные паттерны при ревью.

Зачем это знать защитнику

Десериализация недоверенных данных — тихая уязвимость: внешне функция «просто читает объект», и код проходит ревью, потому что выглядит безобидно. Между тем именно из-за неё происходили громкие компрометации Java- и PHP-приложений. Разработчик, который понимает механику, видит опасную строку сразу и не ставит десериализатор на путь недоверенных данных. Инженер Blue Team понимает, какие форматы и какие точки входа надо контролировать в первую очередь.

Как возникает уязвимость

Корень проблемы — в том, что некоторые форматы сериализации сохраняют не только данные, но и тип объекта, а восстановление объекта запускает методы класса. То есть в потоке байтов закодировано «создай объект класса X», и при десериализации этот класс начинает работать. Если в приложении (или в его зависимостях) есть класс, который при создании/уничтожении выполняет что-то чувствительное — запуск процесса, чтение файла, сетевой вызов, — атакующий выбирает именно такой класс.

Опасный паттерн в Python выглядит так — pickle на данных из запроса:

# УЯЗВИМО: pickle восстанавливает ПРОИЗВОЛЬНЫЙ объект из недоверенных байтов
import pickle

def load_session(raw_bytes):
    # raw_bytes пришли из cookie / тела запроса = недоверенный источник
    return pickle.loads(raw_bytes)   # опасно: формат несёт инструкции, а не только данные

Документация Python прямо предупреждает: модуль pickle небезопасен — никогда не распаковывайте данные из недоверенного источника. Аналогичные риски есть у нативной сериализации в других экосистемах (Java ObjectInputStream, PHP unserialize(), .NET BinaryFormatter, небезопасные YAML-загрузчики). Везде причина одна: формат позволяет закодировать тип и поведение, а не только значения.

Как это работает под капотом

Представим формат, который кодирует «вызови функцию F с аргументом A, чтобы восстановить объект». Десериализатор честно читает эту инструкцию и выполняет её — для него это штатный механизм восстановления состояния. Атакующему не нужно «ломать» парсер: он использует парсер по назначению, просто подавая объект, цепочка восстановления которого приводит к нужному действию. Такую цепочку из существующих в коде классов называют gadget chain. Ключевой вывод: уязвимость не в одном «плохом» классе, а в самом факте десериализации недоверенного ввода форматом, который умеет восстанавливать произвольные типы. Поэтому и защита строится не на «починке формата», а на отказе доверять источнику.

Как защититься

1. Не десериализуйте недоверенные данные опасными форматами

Главное правило: данные, пришедшие от пользователя, нельзя восстанавливать форматами, которые несут тип и поведение (pickle, нативная Java-сериализация, BinaryFormatter). Для обмена с внешним миром используйте форматы только для данных — JSON. JSON описывает значения (строки, числа, списки, словари) и не умеет кодировать «создай такой-то объект и вызови метод».

# БЕЗОПАСНО: JSON переносит только данные, без типов и поведения
import json

def load_session(raw_text):
    data = json.loads(raw_text)          # вернётся обычный dict/list/строки/числа
    # дальше мы САМИ валидируем поля и собираем доверенный объект
    user_id = int(data["user_id"])       # явная проверка типа
    role = data["role"]
    if role not in ("user", "editor", "admin"):
        raise ValueError("недопустимая роль")
    return {"user_id": user_id, "role": role}

Вывод:

load_session('{"user_id":"7","role":"editor"}') -> {'user_id': 7, 'role': 'editor'}
load_session('{"user_id":"7","role":"superuser"}') -> ValueError: недопустимая роль

2. Проверяйте целостность данных

Если объект всё же надо вернуть пользователю и принять обратно (например, токен сессии), подписывайте его. Сервер ставит криптографическую подпись (HMAC) при выдаче и проверяет её при приёме. Так клиент не сможет подменить содержимое — изменённые данные не пройдут проверку подписи, и до десериализации дело не дойдёт.

3. Allow-list классов

Когда без типизированного формата не обойтись, ограничьте, какие классы вообще разрешено восстанавливать, белым списком. Всё, чего нет в списке, — отклоняется. Это превращает «можно восстановить что угодно» в «можно восстановить только заранее одобренные безопасные типы» и обрубает gadget-цепочки.

# Принцип allow-list (псевдокод политики десериализации)
РАЗРЕШЕНО восстанавливать: { OrderDTO, AddressDTO, MoneyDTO }
ВСЁ ОСТАЛЬНОЕ -> отклонить и записать в журнал безопасности

4. Снижайте поверхность атаки

Принимайте на десериализацию минимум данных, держите зависимости обновлёнными (gadget-цепочки часто живут в старых версиях библиотек) и логируйте отклонённые попытки восстановления неизвестных типов — это сигнал для мониторинга.

Итоги

  • Десериализация недоверенных данных опасна, потому что формат может нести не только значения, но и тип и поведение объекта.
  • Никогда не распаковывайте пользовательский ввод через pickle, нативную Java-сериализацию, BinaryFormatter и подобные.
  • Для внешнего обмена используйте форматы только для данных (JSON) и валидируйте каждое поле вручную.
  • Где нужен типизированный формат — подпись целостности (HMAC) и строгий allow-list классов.
  • Несанкционированное воздействие на чужие системы наказуемо (УК РФ ст. 272/273); тренируемся только на своём коде и в разрешённой лаборатории.
Проверьте себя
1. Почему небезопасно вызывать pickle.loads() на данных из cookie пользователя?
Apickle работает медленнее, чем JSON, и тормозит сервер
BФормат pickle может закодировать создание произвольного объекта, и его восстановление выполнит чужую логику на сервере
Cpickle не поддерживает кириллицу и сломается на русских данных
Dcookie слишком короткие, чтобы вместить корректный объект
2. Какая мера лучше всего предотвращает небезопасную десериализацию внешних данных?
AСжимать данные перед отправкой клиенту
BИспользовать формат только для данных (JSON) и явно валидировать каждое поле
CШифровать pickle-поток симметричным ключом
DУвеличить таймаут десериализации
3. Зачем нужен allow-list классов при десериализации типизированным форматом?
AЧтобы ускорить чтение потока байтов
BЧтобы разрешить восстановление только заранее одобренных безопасных типов и обрубить gadget-цепочки
CЧтобы автоматически переводить объекты в JSON
DЧтобы хранить пароли в зашифрованном виде